Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add query server command to expose internal state #1310

Closed
wants to merge 17 commits into from

Conversation

joelim-work
Copy link
Collaborator

@joelim-work joelim-work commented Jun 18, 2023

Description

This PR supersedes #1306 and is an alternate approach of solving the problem of exposing internal state for scripting purposes.

This also fixes a bug in Windows where cmaps/cmds/jumps exits immediately without showing anything because that is the behaviour of the more pager if there isn't enough content to fill an entire page. The reason is that runPagerOn is limited to using $ commands, but lf -remote "query $id ..." can be used in any type of shell command.

Examples

Display mappings:

$lf -remote "query $id maps" | less

Change location based on the jump list:

map f ${{
	extract_dirs() {
		# remove the header line and print the last field
		awk -F'\t' 'NR > 1 { print $NF }'
	}
	dir=$(lf -remote "query $id jumps" | extract_dirs | sort -u | fzf)
	if [ -d "$dir" ]; then
		lf -remote "send $id cd \"$dir\""
	fi
}}

Use awk to add color when displaying mappings and other info:

cmd maps color-pager maps
cmd cmaps color-pager cmaps
cmd cmds color-pager cmds
cmd jumps color-pager jumps

cmd color-pager ${{
AWK_SCRIPT=$(cat << "EOF"
BEGIN {
    FS = "\t"
    OFS = "\t"
}
NR == 1 {
    print "\033[35m" $0
}
NR > 1 {
    $1 = "\033[34m" $1
    $NF = "\033[32m" $NF
    print $0
}
EOF
)

lf -remote "query $id $1" | awk "$AWK_SCRIPT" | "$PAGER"
}}

Notes

  • See Access to jump history from commands #1277 (comment) for more about the motivation behind this approach.
  • Default commands are provided, which replaces the runPagerOn functionality. This works on Windows as well, provided Improve windows shell handling #1309 is merged.
  • Feel free to suggest other names. It seems that historically server commands have always had exactly four letters, I don't know whether it's supposed to be a tradition or not.

Copy link
Collaborator

@ilyagr ilyagr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks nice, this solves many of the problems with the other approaches.

My one concern about this approach is that it creates a performance vs reliability tradeoff I discuss in another comment. Though, perhaps, something about the API we are using means that talking to the server is guaranteed to be quick?

Some really minor thoughts:

  • This approach does mean that :maps do not work if you start lf as lf -single. That's probably fine; it seems like less of a problem to me than the issues with the other approaches.

  • If anybody is testing this, don't forget to killall lf regularly. Otherwise, you're likely to have an lf client with this PR communicate with a server that doesn't have this PR, which results in confusing errors.

client.go Outdated
c.Close()

// wait for server to finish storing data and send response, if any
io.ReadAll(c)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't seen any problems yet, but there is a potential performance problem here. If I understand the code correctly (following your comment), if sending the data is slow over the connection, lf would hang, and it looks like this would happen after every command. Might this happen if the connection is interrupted somehow, as well? Would it then hang forever or until some timeout? I'm unsure.

Perhaps there is some reason this is not a concern?

If it is a concern, one way to fix it would be to make this happen in a goroutine and not wait for success before executing the following commands. I think that's be OK with the data we're currently sharing this way -- maps and cmds rarely change, and when they do, presenting an old version is not the end of the world.

However, going beyond the scope of this PR, not waiting for confirmation would make it extremely dangerous to store things like $fs in this way. At first, I was excited about the prospect of addressing something like #47 using query. However, it would be very problematic if somebody ran lf -remote "query $id fs"|xargs -0 rm and got an old version of a file selection. So, if we go that way, I think we should put a prominent warning in the code (in exportData, say) to not share such information via 'query'.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the connection is slow then yes lf would hang until it's finished or there's some kind of error. The server doesn't really do anything much apart from handling connections and forwarding send commands, so from a performance point of view it is sufficiently fast and I don't notice any kind of delay unless I deliberately add in time.Sleep for testing purposes.

Storing the data in the same thread and waiting for the server to finish was my intention, otherwise there could be a race condition as you have described. Currently app.runShell continues anyway even storing the data fails (e.g. if I deliberately kill the server), do you think it would be better to return instead?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure what's best. For the data we are storing now, I'm OK with race conditions as I said. If we, theoretically, stored something like the selection this way, I'd want app.runShell to abort if anything is off.

I've never noticed performance problems with lf -remote commands, so maybe that's OK. I do run into #495; I don't understand that issue thoroughly, but I think you'd know whether it would be a problem here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm generally allowing race conditions - although maps and cmds rarely change, the jump list will naturally accumulate entries. I have added another commit c55607a which checks if the store command succeeds, otherwise app.runShell is aborted, although in my testing I haven't encountered this scenario without deliberately killing the server.

#495 is a separate issue which only involves running lf -remote during startup - I posted a more detailed explanation here: #1293 (comment). That being said you can trigger a similar error if you add maps as a startup command, but I can't see why anyone would want to do this.

@joelim-work
Copy link
Collaborator Author

  • This approach does mean that :maps do not work if you start lf as lf -single. That's probably fine; it seems like less of a problem to me than the issues with the other approaches.

@ilyagr Good point about lf -single, I did not consider it earlier, and I have now added an if statement to not export the data when in single mode. This does make me wonder if we should provide all these maps/cmaps/... commands by default, since they won't work in single mode, and it should be fairly trivial for users to implement if they do want such a command.

@ilyagr
Copy link
Collaborator

ilyagr commented Jun 20, 2023

This does make me wonder if we should provide all these maps/cmaps/... commands by default, since they won't work in single mode, and it should be fairly trivial for users to implement if they do want such a command.

I would, personally, rather switch to the approach of #1306 or get rid of the -single mode or ignore the problem since, hopefully, few people use -single and the ones that do can figure it out.

We could also try to make it so that :maps gives a clear error in -single mode. I'd guess this would mainly mean that lf -remote "query ..." should give a clear error and somehow cause less to exit with an error (not sure if that's doable in plain sh without something like mispipe from moreutils).

@joelim-work
Copy link
Collaborator Author

I would, personally, rather switch to the approach of #1306 or get rid of the -single mode or ignore the problem since, hopefully, few people use -single and the ones that do can figure it out.

OK at this stage, you have somewhat convinced me that this approach also has some drawbacks too, when compared to the pipe approach, namely the reliance on the server. I think it's a tradeoff between nicer scripting syntax (i.e. being able to query the server for information at any point in the script), and reliability (i.e. not having to depend on the server). Although I should point out that users generally take it for granted that the server is running properly at all times, for instance many scripts in the wiki Tips use lf -remote "send $id ...".

At the end of the day, I'm fine with either approach, I just wanted to make sure different strategies are investigated, because I don't want to introduce a feature like this, only to find that there is something better but also can't change because people are already using it.

@joelim-work
Copy link
Collaborator Author

For now I will move this PR back to a draft as well. Maybe some further discussion is needed, perhaps with input from @gokcehan.

While I am fine with this approach being reliant on the server, I don't like the fact that default commands like maps, etc. are provided but don't work with -single mode.

@joelim-work joelim-work marked this pull request as draft June 20, 2023 08:03
@ilyagr
Copy link
Collaborator

ilyagr commented Jun 20, 2023

I'm also not sure what's best, but I think we have two pretty good options now. Thank you again for working on them!

@gokcehan
Copy link
Owner

gokcehan commented Jul 4, 2023

@joelim-work Thank you very much for coming up with yet another simpler approach for this purpose. I have been playing with it for a while and it seems to work well. I think my initial concerns are similar to @ilyagr 's in that I'm also not sure if the server involvement in every command is a good idea or not, but I can't think of a good solution at this moment. Maybe a command type modifier can be added similar to what is proposed in #1277 (e.g. $> as an alternative to $ to store data before the command), though I'm not sure if I like this idea either. I also still suspect there could be a way to make this work maybe by adding a blocking version of -remote option, but I can't wrap my head around it yet. I will try to give it some more thinking and let you know if I can come up with something.

@joelim-work
Copy link
Collaborator Author

@gokcehan The main lf instance isn't really capable of acting as its own server (while executing $ or ! commands the main thread is blocked from executing anything else) so I decided to use the lf server for this purpose. However, I can see the concerns for storing the state for every shell command by default, maybe it would be possible to add a store command and require the user to explicitly call this before using such a shell command, something like this:

cmd maps :store; ${{
    lf -remote "query $id maps" | less
}}

And a user option autostore can be added to enable storing by default, as a convenience. I think this is better than making changes to the existing prefixes like $>, since it will mean having to make changes to the parser and other parts of the code that check for specific prefixes.

This does make the default commands (i.e. maps, cmaps, cmds and jumps) a bit more complex though. I'm not sure whether it's worth providing them by default anymore if this change gets merged. I have always preferred to provide a generic low-level infrastructure for querying data, allowing users to build and customize high-level functionality on top of it, compared to providing such commands whose behavior is fixed. Although at the time #916 was implemented, I had not come up with this idea yet.

On a side note, I have found a way to add color when mappings/other info is displayed, by piping the result of lf -remote "query $id ..." to awk, which I will add to the PR description.

@gokcehan
Copy link
Owner

gokcehan commented Jul 5, 2023

@joelim-work I played with the code a little bit last night but eventually I gave up. Since the main thread is blocked, I tried to utilize the goroutine created for the remote commands (i.e. readExpr). Instead of stripping send $id and then sending the rest as the command to be evaluated, I tried to send everything as send $id <cmd> and then do the stripping in the client. This way it is possible to distinguish commands other than send in the client (e.g. recv $id maps). To communicate the information back to the server and then back to the client running the -remote command, I tried to utilize the existing connections but I failed because somehow they were evaluating themselves at the same time. Maybe it was possible and I was too tired to make it work, but I also felt overwhelmed by the additional complexity.

Adding an explicit :store command and the autostore option sounds more interesting to me at this point.

@joelim-work
Copy link
Collaborator Author

@gokcehan I don't think I mentioned it anywhere, but I also previously tried having the server forward the query to the main instance and then back to the -remote client, although I implemented the actual lookup in the main thread, and that's how I found out about the main thread blocking issue.

I guess implementing the lookup in readExpr thread will work, the only caveat is that you now have a second thread accessing this data - in general I have concerns with thread safety issues when sharing data between threads like this. But maybe it's fine since the main thread is blocked anyway(?), I'm not sure. If you push your work up to a branch, I'm happy to have a look at it.

Anyway I implemented the store command along with the autostore option. I pushed this to a separate branch so that it doesn't interfere with this PR:

It does add a bit more complexity compared to just storing by default (effectively set autostore true) though, I was wondering whether it makes sense to consider adding it separately afterwards, if storing by default turns out to be problematic.

@gokcehan
Copy link
Owner

gokcehan commented Jul 5, 2023

@joelim-work It may not worth trying since it does not work but I had tried something along the following:

Click to show diff
diff --git a/client.go b/client.go
index 0fab7d5..55f23e2 100644
--- a/client.go
+++ b/client.go
@@ -7,6 +7,7 @@ import (
 	"log"
 	"net"
 	"os"
+	"strconv"
 	"strings"
 	"time"
 
@@ -84,9 +85,45 @@ func readExpr() <-chan expr {
 		s := bufio.NewScanner(c)
 		for s.Scan() {
 			log.Printf("recv: %s", s.Text())
-			p := newParser(strings.NewReader(s.Text()))
-			if p.parse() {
-				ch <- p.expr
+			word, rest := splitWord(s.Text())
+			switch word {
+			case "send":
+				if rest != "" {
+					word2, rest2 := splitWord(rest)
+					id, err := strconv.Atoi(word2)
+					if err != nil {
+						p := newParser(strings.NewReader(rest))
+						if p.parse() {
+							ch <- p.expr
+						}
+					} else {
+						if id != gClientID {
+							log.Println("send: wrong id")
+						} else {
+							p := newParser(strings.NewReader(rest2))
+							if p.parse() {
+								ch <- p.expr
+							}
+						}
+					}
+				}
+			case "recv":
+				if rest != "" {
+					word2, rest2 := splitWord(rest)
+					id, err := strconv.Atoi(word2)
+					if err != nil {
+						log.Println("recv: only single id is supported")
+					} else {
+						if id != gClientID {
+							log.Println("recv: wrong id")
+						} else {
+							if rest2 == "maps" {
+								fmt.Fprintln(c, listBinds(gOpts.keys).String())
+							}
+							// TODO: add the rest
+						}
+					}
+				}
 			}
 		}
 
diff --git a/server.go b/server.go
index 7d63ca6..5060bb3 100644
--- a/server.go
+++ b/server.go
@@ -3,6 +3,7 @@ package main
 import (
 	"bufio"
 	"fmt"
+	"io"
 	"log"
 	"net"
 	"os"
@@ -102,20 +103,35 @@ Loop:
 			}
 		case "send":
 			if rest != "" {
-				word2, rest2 := splitWord(rest)
+				word2, _ := splitWord(rest)
 				id, err := strconv.Atoi(word2)
 				if err != nil {
 					for _, c := range gConnList {
-						fmt.Fprintln(c, rest)
+						fmt.Fprintln(c, s.Text())
 					}
 				} else {
 					if c2, ok := gConnList[id]; ok {
-						fmt.Fprintln(c2, rest2)
+						fmt.Fprintln(c2, s.Text())
 					} else {
 						echoerr(c, "listen: send: no such client id is connected")
 					}
 				}
 			}
+		case "recv":
+			if rest != "" {
+				word2, _ := splitWord(rest)
+				id, err := strconv.Atoi(word2)
+				if err != nil {
+					echoerr(c, "listen: recv: only single id is supported")
+				} else {
+					if c2, ok := gConnList[id]; ok {
+						fmt.Fprintln(c2, s.Text())
+						io.Copy(c, c2)
+					} else {
+						echoerr(c, "listen: recv: no such client id is connected")
+					}
+				}
+			}
 		case "quit":
 			if len(gConnList) == 0 {
 				gQuitChan <- struct{}{}

I agree this may not be a good idea due to potential race conditions.

I will take a look at your new branch.

@joelim-work
Copy link
Collaborator Author

Hi @gokcehan I think the server doesn't distinguish between connections from -remote and regular instances, so any data the main instance sends back will be interpreted as a server command (e.g. send, conn, etc.). Anyway I'm not sure how much I like the idea - the readExpr thread now has additional complexity, but also direct access to gOpts and other data which is supposed to be managed by the main thread. In particular, I think it will be a potential problem for async shell commands since they run in a separate thread, allowing the main thread to continue running and possibly conflict with the readExpr thread.

This also reminds me of the discussion in #1314, where global state is shared among different threads.

@joelim-work
Copy link
Collaborator Author

joelim-work commented Jul 7, 2023

Actually maybe ignore the above comment from me, I managed to get the readExpr thread approach working:

Click to show diff
diff --git a/client.go b/client.go
index 0fab7d5..cbc9ef2 100644
--- a/client.go
+++ b/client.go
@@ -84,9 +84,23 @@ func readExpr() <-chan expr {
 		s := bufio.NewScanner(c)
 		for s.Scan() {
 			log.Printf("recv: %s", s.Text())
-			p := newParser(strings.NewReader(s.Text()))
-			if p.parse() {
-				ch <- p.expr
+			if word, rest := splitWord(s.Text()); word == "recv" {
+				switch rest {
+				case "maps":
+					io.Copy(c, listBinds(gOpts.keys))
+				case "cmaps":
+					io.Copy(c, listBinds(gOpts.cmdkeys))
+				case "cmds":
+					io.Copy(c, listCmds())
+				case "jumps":
+					// TODO: not available globally
+				}
+				fmt.Fprintln(c, "")
+			} else {
+				p := newParser(strings.NewReader(s.Text()))
+				if p.parse() {
+					ch <- p.expr
+				}
 			}
 		}
 
diff --git a/server.go b/server.go
index 7d63ca6..2e2a1a1 100644
--- a/server.go
+++ b/server.go
@@ -84,6 +84,7 @@ Loop:
 					echoerr(c, "listen: conn: client id should be a number")
 				} else {
 					gConnList[id] = c
+					return
 				}
 			} else {
 				echoerr(c, "listen: conn: requires a client id")
@@ -95,6 +96,9 @@ Loop:
 				if err != nil {
 					echoerr(c, "listen: drop: client id should be a number")
 				} else {
+					if c2, ok := gConnList[id]; ok {
+						c2.Close()
+					}
 					delete(gConnList, id)
 				}
 			} else {
@@ -116,6 +120,29 @@ Loop:
 					}
 				}
 			}
+		case "recv":
+			if rest != "" {
+				word2, rest2 := splitWord(rest)
+				id, err := strconv.Atoi(word2)
+				if err == nil {
+					if c2, ok := gConnList[id]; ok {
+						fmt.Fprintln(c2, "recv "+rest2)
+						s2 := bufio.NewScanner(c2)
+						for s2.Scan() {
+							if s2.Text() == "" {
+								break
+							}
+							fmt.Fprintln(c, s2.Text())
+						}
+					} else {
+						echoerr(c, "listen: recv: no such client id is connected")
+					}
+				} else {
+					echoerr(c, "listen: recv: client id should be a number")
+				}
+			} else {
+				echoerr(c, "listen: recv: requires a client id")
+			}
 		case "quit":
 			if len(gConnList) == 0 {
 				gQuitChan <- struct{}{}

The trick was to just simply prevent the server from trying read from the main instance after registering it by adding a return. Also, using io.Copy during a recv request doesn't work because it blocks until EOF, so I just used a blank line as an ending marker.

I'm still concerned about thread safety, and also the jump list isn't available globally. I'm not sure what's the best way to tackle this, but maybe it's enough to just have app.runShell export this information to a global data structure where it will be read by the readExpr thread.

I think this approach might be more attractive than having to worry about making repeated calls to the server to store data, so I'm willing to keep this idea in consideration. I have pushed my work up to this branch: https://github.com/joelim-work/lf/tree/remote-recv

@gokcehan
Copy link
Owner

@joelim-work Maybe I forgot to drop a reply here, sorry about that. I think both approaches are fine. I had tried them both briefly before and I had not noticed any issues. If you have a preference already and you think the patch is ready, feel free to merge it. You don't have to wait for my approval.

@joelim-work
Copy link
Collaborator Author

Closing in favor of #1384. Might leave it open for a few days or so if anyone wants to have a look.

@joelim-work joelim-work mentioned this pull request Sep 5, 2023
@joelim-work joelim-work deleted the remote-query branch September 8, 2023 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Access to jump history from commands
3 participants