I often use emacsclient(1), from a shell, to quickly visit a nearby file instead of switching back to Emacs and drilling down through directories with M-x find-file. As I'm frequently working over SSH, I wanted something analogous for remote shells. Here is one way to accomplish that with SSH's Remote Forwarding of UNIX Domain Sockets, Emacs' TRAMP, and a little Ruby.

The Emacs Server provides emacsclient(1), a small companion utility that signals a running Emacs to load a file for editing. This is an alternative to starting a new instance of Emacs each time some process needs to invoke an editor. The classic example is a local mail client that uses an external program for message composition. I alias emacs to emacsclient and export the environment variable EDITOR=emacs so my mail client, mutt(1), winds up using my running Emacs to compose new mail messages.

I work across many hosts and make heavy use of Emacs' venerable TRAMP package. I found myself looking for a version of this pattern, of specifying files to load from the shell, for my remote shells as well. Visiting a remote file with TRAMP is easy enough, and the excellent Ivy framework, from the Swiper package, further improves (pretty much any) refinement task, like matching filenames as you step through directories. But for the frequent scenario where I'm SSH'd into a machine and actively moving around the filesystem, it's much faster to type emacs and let the shell autocomplete a relative filename, than to switch back to Emacs, specify the remote host and full path to some file I was just interacting with.

Basically, I wanted to be sitting at my workstation, buttercup, with my nice running Emacs and from some shell on a remote host, type "emacs dir/foo_file" and have it open back on buttercup.

Unpacking emacsclient A Bit

Let's see how emacsclient works, and how we might be able to adapt it.

You first launch the Emacs Server from the Emacs instance you want to use as your editor. This is typically a long-running, nicely configured, graphical instance on the workstation you're sitting at.

(server-start)

;; You might also want this for temporary file cleanup
(setq server-temp-file-regexp ".*")

Once the server is up, communication takes place over a UNIX Domain Socket with a default name of /tmp/emacs${UID}/server. The Emacs Server protocol is very simple and you can see it in action with the beloved socat(1).

jereme@buttercup $ socat unix-listen:$HOME/sock -

From another shell, run emacsclient, explicitly passing a socket to use. In this case, the debugging socket we just started with socat.

jereme@buttercup $ emacsclient --socket=$HOME/sock foo
Waiting for Emacs...

Back in the first shell, you will see the message sent by emacsclient when you asked to visit the file foo.

jereme@buttercup $ socat unix-listen:$HOME/sock -
-dir /home/jereme/ -current-frame -tty /dev/pts/39 screen-256color -file foo

It's a very simple protocol, specifying the current directory and the name of the file to visit.

Forwarding a Remote Listening UNIX Domain Socket

OpenSSH 6.7 added UNIX Domain Socket forwarding following the same semantics long-offered for TCP sockets, and still via the -R option. You can forward a remote listening socket, connecting it to a local one, like the one used by the Emacs Server and emacsclient. The only wrinkle is that the Emacs Server protocol, as observed, doesn't have any notion of local or remote machines; it is built on UNIX Domain Sockets, a host-based Inter-Process Communication mechanism.

Fortunately, emacsclient will happily pass TRAMP-style URIs along to the Emacs Server, whereby triggering a remote file to be loaded. Combining a forwarded listening socket with TRAMP URIs pointing to the remote host allows me to SSH from buttercup to elder-whale, and from that remote shell, trigger buttercup's Emacs to load remote files from elder-whale. A verbose example should clarify.

jereme@buttercup $ ssh -R ~/.ssh/emacs-server:/tmp/emacs1000/server elder-whale
Last login: Sun Mar 24 11:41:48 2019 from buttercup

jereme@elder-whale $ emacsclient -s ~/.ssh/emacs-server /scp:elder-whale:/home/jereme/foo_file
Waiting for Emacs...

Here we're logging in to elder-whale via SSH and have forwarded buttercup's Emacs Server's listening socket, /tmp/emacs1000/server, and connected it to elder-whale's ~/.ssh/emacs-server. We then run emacsclient, specifying the forwarded socket, and give it a TRAMP-style URI, /scp:elder-whale:/home/jereme/foo_file. The result is that buttercup's running Emacs pops open a buffer visiting  foo_file, living on elder-whale.

Tying It All Up Nicely

We don't want to have to manually munge paths to TRAMP URIs, worry about which socket to use with emacsclient, or think much about whether we're local or remote. We want to be able to run emacs foo and have it Do What I Mean.

I already alias emacsclient as emacs. Providing a replacement emacsclient script, earlier in the $PATH, wrapping the real emacsclient with our supplemental logic, follows a standard strategy. A little Ruby lets us wire it all up.

  • First we check if we're running over SSH. If not, we just pass everything through to the real emacsclient. Note: adjust the path to your particular emacsclient binary as needed, likely /usr/bin/emacsclient.
  • Then we punt on some tricky options used for more esoteric functionality: running elisp directly and custom frame handling (Emacs parlance for windows).
  • Finally, we go through some minor contortions to generate a clean path, even for non-existent files, so Emacs can create them if that's our intention. That's why we don't just use Pathname#realpath.
#!/usr/bin/env ruby

require 'pathname'
require 'socket'

emacsclient = "/usr/local/bin/emacsclient"
emacs_socket = "/home/jereme/.ssh/emacs-server"

def ssh_running?
  ENV.has_key?("SSH_CLIENT")
end

def tricky_args?
  ARGV.any? { |i| i =~ /^-[ecF]$/ }
end

if (ssh_running? and not tricky_args?)
  file = Pathname.new(Dir.pwd) + ARGV.pop()
  args = ["-s", emacs_socket, *ARGV, "/scp:#{Socket.gethostname}:#{file.cleanpath}"]
else
  args = ARGV
end

system(emacsclient, *args) or
  raise "failed to run emacsclient"

Finally, I export an environment variable, EMACS_SERVER_SOCK, and alias ssh to cut down on the typing.

export EMACS_SERVER_SOCK="/tmp/emacs$(id -u)/server"
alias ssh="ssh -R ~/.ssh/emacs-server:$EMACS_SERVER_SOCK"

Note: ssh(1) will not use an existing socket file so you have to configure both ends of the connection to allow the socket to be replaced. Setting the `StreamLocalBindUnlink options to yes solves this, but also requires you to have some control of the remote SSH daemon you're connecting to. Have at a look at ssh_config(5). Outside of that, you could make your own arrangements to cleanup the socket.

Conclusion

So far everything has been working well and the strategy seems robust. I have not encountered issues with multiple SSH connections, terminal multiplexors like tmux(1) or screen(1) (client- or server-side), or multi-host SSH configurations using ProxyCommand or ProxyJump. I do make use of emacsclient's -e, -c, and -F options but I think I'm OK with the current behavior of excluding them from handling. I find I'm either using them locally or intentionally executing on a remote host.

Cover photo by Steven Lewis