As part of my work, I usually SSH into a Linux machine from my Mac, open Emacs, and do coding and other stuff in Emacs. Quite frequently, I execute commands to compile/execute something. These commands usually take some time to run. In the meantime, I switch to some other task and check the command's progress every once in a while.
Instead of having to check for the command's completion manually, I wanted to be notified whenever the command is done.
What I wanted is - when I run a long running command like in the following image -
I need a notification when the command finishes like so -
Suppose we want to display a notification whenever a command finishes after taking more than 20 seconds. To achieve this, we need someone to monitor the time taken by all the shell commands issued. If any command takes more than 20 seconds, we need a way to communicate it to the Mac. So, we have to answer the following questions to get this working -
How will we monitor all the commands issued?
How will we communicate a command's completion to Mac?
How will we display the notification?
In Emacs, I use a shell buffer as my shell. Since Emacs is the one proxying all the commands issued in this buffer to the actual shell process, it would be a perfect fit to monitor the commands.
Emacs's shell buffer emulates a terminal. Every command we issue in this buffer is passed on to the shell process with stdout and stderr bound to the buffer. Where this buffer differentiates itself from a terminal is that it is like any other Emacs's text buffer. This means we can search through the command history and output without requiring to grep, jump to previous commands just like jumping to the end of a function in Python code, etc. Basically, shell mode is a terminal with all of Emacs's text editing capabilities.
In Emacs, every keystroke invokes a function. In Emacs's shell mode, when we press the Enter key, the function
comint-send-input runs sending the input to the shell process. Emacs allows customizing the behaviour of functions using hooks. Our function
comint-send-input runs the functions in
comint-input-filter-functions before sending the command to the shell process and the ones in
comint-output-filter-functions after receiving the output.
So, before sending every command, we will note down the command and the timestamp. Once we receive the output from the command, we can do whatever we want based on the time it took for the command to complete. The following code is run on every command submission -
Since we can issue multiple commands at once to the shell, we need a collection data structure to store the timestamps.
fifo is a ring object that implements a FIFO queue. On every command submission, this code inserts into fifo a cons of the actual command the timestamp.
The following code runs on every command completion -
check-message is called every time some output is inserted into the buffer. In the
let* block, we pop the message and timestamp from the fifo and in case the command was issued more than 20 seconds back, we notify the user using
notify-command-to-user. How exactly we are going to do this is the topic of the next section.
One problem with this is - the function
check-message is run every time some output is inserted into the buffer; not just at the end of a command execution. So, to figure out when a command has finished execution, I'm using the following heuristic - check if the output matches the command prompt regexp. If it does, the command has finished execution. This is just a heuristic and there are cases where this can fail.
One more detail involved here is - I frequently have multiple shells open. If all these shells use the same fifo, then a timestamp queued by one shell might be dequeued by another shell. To avoid this, we make fifo a buffer local variable.
We now have a way to find out when a command took more than 20 seconds to complete. Next task is to figure out how to communicate this to the SSH client (Mac). The only link between the Mac and the server is the SSH tunnel created. We are going to use this tunnel for communication. OpenSSH client has an amazing feature called remote port forwarding 1.
The command to do this looks something like
This instructs the SSH client to start listening a port, 8888 in this case, on the server. This listener then forwards all the packets it receives to the client at port 7777. It uses the established SSH tunnel for the network path.
We now have a network path we can use to communicate to the client. Now we need to decide on a protocol - what data will we send to Mac? I went with HTTP. We'll make Emacs send a HTTP POST call to
localhost:8888. SSH forwards this to Mac at port 7777. I will run a simple HTTP service in Mac that listens on port 7777 and displays the notification.
notify-command-to-user function does the following -
Basically we are calling
curl -X POST -d <command and time-taken> localhost:7777 from Elisp.
Following is the Python code that starts a HTTP server on port 7777 -
We first create a
NotificationHandler class that can handle the POST call 2. This class's
do_POST method is invoked for every POST request. In this, we read the body and sanitize the command by escaping double quotes and backslashes to prevent any accidental command injection.
We then use
osascript command to display a notification.
So finally, Emacs figures out what commands to notify the users about, SSH provides the network path, and a HTTP server in Mac displays the notification.
To get this working without any configuration in future,
I added HTTP server to startup applications in my Mac.
Added the above Emacs scripts to my Emacs startup file.
Instead of SSHing to the machine like
ssh <server>, I have to use
ssh -R localhost:8888:localhost:7777 <server>.
We are now running a HTTP server in our Mac capable of displaying notifications. Anyone with a network path to our system can spam us with notifications. I'm not sure how best to avoid this. One way is to use
iptablesequivalent to restrict access to port 7777 only to localhost.
Adding to the above point, Python's http.server module explicitly mentions that this is not a secure HTTP server.
The Black Magic Of SSH / SSH Can Do That? https://vimeo.com/54505525
Running Shells in Emacs: An Overview https://www.masteringemacs.org/article/running-shells-in-emacs-overview