emacsclient in TRAMP remote shells

Update: Emacs 26.1 added official support for this with a new emacsclient --tramp option! The 26.1 manual isn’t online yet, but you can learn more with M-x info inside Emacs 26.1, Emacs => emacsclient options.

I recently started running shells inside Emacs via shell mode, including ssh sessions to remote hosts using TRAMP. I’m pretty happy with it so far, but one thing that didn’t work out of the box was emacsclient. Ideally it would open files in the local Emacs with TRAMP, but there’s no obvious way for an emacsclient running remotely to connect to the local Emacs’s server.

However, there are lots of variations on a not so obvious way. If you run the Emacs server over TCP instead of a local socket, and use the ssh -R option to set up a reverse port forward, and copy the server file to the remote host so that emacsclient can use it, you actually can connect back to the local Emacs server!

Here’s Emacs Lisp that sets this up:

(defadvice make-network-process (before force-tcp-server-ipv4 activate)
  "Monkey patch the server to force it to use ipv4. This is a bug fix that will
hopefully be in emacs 24: http://debbugs.gnu.org/cgi/bugreport.cgi?bug=6781"
  (if (eq nil (plist-get (ad-get-args 0) :family))
      (ad-set-args 0 (plist-put (ad-get-args 0) :family 'ipv4))))

;; now that the ipv4 advice is in place, restart the server.
(custom-set-variables '(server-use-tcp t))
(if (server-running-p) (server-start))

(require 'alist)
(defun update-tramp-emacs-server-ssh-port-forward ()
  "Update TRAMP's ssh method to forward the Emacs server port to the local host.
This lets emacsclient on the remote host open files in the local Emacs server.

put-alist, used below, is defined in alist, which is part of the APEL library:
http://kanji.zinbun.kyoto-u.ac.jp/~tomo/elisp/APEL/index.en.html"
  (let* ((ssh-method (assoc "ssh" tramp-methods))
         (ssh-args (cadr (assoc 'tramp-login-args ssh-method))))
    (put-alist 'tramp-login-args
      (list (put-alist "-R" (let ((port (process-contact server-process :service)))
        ;; put-alist makes a dotted pair for the key/value, but tramp-methods
        ;; needs a normal list, so put the value inside a list so that the
        ;; second part of the dotted pair (ie the cdr) is a list, which converts
        ;; it from a dotted pair into a normal list.
                              (list (format "%d:127.0.0.1:%d" port port)))
                       ssh-args))
      ssh-method)))

(defadvice server-process-filter (before handle-remote-emacsclient-file activate)
  "Detect remote emacsclient and inject the tramp '/host:' prefix.

  Note the hack here that assumes remote emacsclient invocations
  have the regex '-tty /dev/(pts/[0-9]|ttype)[0-9]+)' in their
  command sequence string, and all others have either no -tty or
  a one-digit /dev/pts/.... I haven't yet found a better way to
  distinguish local and remote clients."
  (if (string-match "-tty /dev/\\(pts/[0-9]\\|ttyp\\)[0-9]+" (ad-get-arg 1))
      (with-parsed-tramp-file-name default-directory parsed
        (let* ((message (ad-get-arg 1))
               (absolute (and (string-match "-file \\([^ ]+\\)" message)
                              (file-name-absolute-p (match-string 1 message))))
               (tramp-prefix (tramp-make-tramp-file-name parsed-method
                                                         parsed-user
                                                         parsed-host
                                                         nil))
               (dir (if absolute nil parsed-localname)))
          (ad-set-arg 1 (replace-regexp-in-string "-file "
                          (concat "-file " tramp-prefix dir) message))))))

(defun ssh-shell (host bufname)
  "SSH to a remote host in a shell-mode buffer using TRAMP."
  (update-tramp-emacs-server-ssh-port-forward)
  (let ((default-directory (format "/%s:" host))
        (tramp-remote-process-environment
         (cons (format "EDITOR='emacsclient -f ~/.emacs.d/%s_server'" (getenv "HOST"))
                 tramp-remote-process-environment)))
    (shell bufname))
  ;; copy emacs server file so remote emacsclient can connect to this emacs
  (let ((default-directory "/tmp")
        (local-server-file (process-get server-process :server-file))
        (remote-server-file (format "~/.emacs.d/%s_server" (getenv "HOST"))))
    (async-shell-command
     (format "scp -v %s %s:%s" local-server-file host remote-server-file))))

Note that this uses the put-alist function from the APEL library. If you’d rather not install that whole library, here’s that function’s definition:

(defun put-alist (key value alist)
  "Set cdr of an element (KEY . ...) in ALIST to VALUE and return ALIST.
If there is no such element, create a new pair (KEY . VALUE) and
return a new alist whose car is the new pair and cdr is ALIST."
  (let ((elm (assoc key alist)))
    (if elm
        (progn
          (setcdr elm value)
          alist)
      (cons (cons key value) alist))))

5 thoughts on “emacsclient in TRAMP remote shells

  1. Wow. This is a mind bender. I’m not understanding why you do this. Why not open the file regularly from the shell buffer (which would use tramp)?

    Also, does this enable collaborative editing- two people updating the same buffer?

  2. you’re right, opening files manually from the shell buffer does automatically use TRAMP. this is for using emacsclient, which you usually set to $EDITOR so that programs will use it to open files automatically – for example, commit log messages.

    Bookmarks

  • 🔖 fakecreditcard

Leave a Reply

Your email address will not be published. Required fields are marked *