Post

Tramp Pollutes my $HISTFILE!

One day, I found my shell history file is polluted by some program. This blog describes how I found the culprit and tried to tweak Tramp.

Background

Today I logged in my machine and started working as usual. Then I accidentally found something nasty in my history file (~/.bash_history):

Tramp Nasty HISTFILE

Of course, my roommates and fellows are innocent. These commands are generated by some programs, and from INSIDE_EMACS='29.3,tramp:2.6.3-pre', I immediately knew that Emacs was the culprit.

Here is my experience about figuring out the problem, and finally coming up with some resolutions that may be helpful for you.

What is Tramp?

Emacs users shall never neglect Tramp, a builtin Emacs package that implements “Transparent Remote (file) Access, Multiple Protocol”. It helps you seamlessly connect to a remote machine, or handling remote files and processes.

Tramp provides a lot of subroutines that may hooked into many Elisp functions. For example, you can directly copy a remote file to your local machine:

1
(copy-file "/ssh:remote-machine:~/foo.txt" "~/Downloads/")

You can create a remote process:

1
2
3
4
5
6
7
(let ((default-directory "/sshx:remote-machine:~")
      (buf (generate-new-buffer "temp")))
  (make-process
   :name "hostname-remote"
   :buffer buf
   :command '("hostname")
   :file-handler t))

You can directly open a remote file in the buffer:

1
(find-file "/-:remote-machine:~")

Tramp does these by detecting whether you are handling a remote file (file-remote-p), so you find that it “just works”. Moreover, it supports numerous methods to connect to remote machine, including ssh, docker, kubernetes, adb, …

What’s wrong with Tramp?

It’s almost a rife consensus: Tramp is slow. You may compare it with VS Code’s remote ssh extension, which is also integrated with the editor seamlessly but much faster than Tramp. Why is Tramp so slow?

The problem of Tramp is also its advantage: It tries to do a lot of stuff, aggressively, to allow developers/users to handle remote files just like how they handle local files. It makes Tramp useful at most of the time, but tremendously hurts the performance.

Before proposing the solution, let’s investigate how Tramp works.

Tramp Internal

Let’s begin with the make-process example mentioned above.

1
2
3
4
5
6
7
(let ((default-directory "/sshx:remote-machine:~")
      (buf (generate-new-buffer "temp")))
  (make-process
   :name "hostname-remote"
   :buffer buf
   :command '("hostname")
   :file-handler t))

Since we’ve specified :file-handler t, Emacs tries to find a correct handler of default-directory. Generally, Tramp has registered its special handlers once Emacs is startup, so it can detect when you are making a remote process and begin to intervene.

1
2
(find-file-name-handler "/sshx:remote-machine:~" 'make-process)
;; ⇒ tramp-file-name-handler

To create a remote process, Tramp will try to connect to the remote machine, and then execute the command via the remote shell. It at least does the following stuff:

  • tramp-sh-handle-make-process: The root handler that implements a “remote” make-process. It respects process-environment.
  • tramp-maybe-open-connection: If there is no available “connection”, Tramp tries to create one. This function will call tramp-send-command for several times. The first time is used for establishing a remote connection. Tramp invokes a remote shell according to different protocols and waits for an expected shell prompt to show up. The new remote interactive shell will be used for tramp-send-command in the future.
  • tramp-open-connection-setup-interactive-shell: It’s a subroutine of tramp-maybe-open-connection that is responsible for spawning the remote interactive shell and set its environment. It opens a shell by tramp-open-shell, and then setup the environment with several invocation tramp-send-command.
  • tramp-open-shell: It will exec into a new shell, typically the standard /bin/sh. An important variable that I will describe today, tramp-histfile-override, can affect its behavior.

Here is a sequence diagram that illustrates the main procedure to call a remote process:

How does Tramp handle make-process?

From the diagram above, you can notice that two execs are used. It’s roughly equivalent to executing two commands in your shell:

1
2
exec ssh user@host
exec env HISTFILE='~/.tramp_history' /bin/sh -i

Then Tramp sends the command to the current remote shell, catches the output and logs to the buffer.

How tramp-sh-handle-make-process handles our original make-process arguments is also interesting. Before it calls tramp-maybe-open-connection somewhere (maybe through its subroutine), it performs:

  • argument parsing,
  • name uniquification,
  • command rewrite (respect process-environment),

There are several variables that are essential for Tramp’s behavior in the example above:

  • tramp-histfile-override: whether HISTFILE is overridden when invoking a shell.
  • process-environment: you can let-binding the variable, and Tramp will recognize the difference and set the environment variables.
  • tramp-methods: how Tramp create a connection.

To better understand process-environment, you may want to read the source code of tramp-sh-handle-make-process.

So, why is my HISTFILE polluted?

The default value of tramp-histfile-override is ~/.tramp_history, which means Tramp will set HISTFILE to it when opening a new shell, more specifically, during tramp-open-shell.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;; From `tramp-open-shell':
(tramp-send-command
 vec (format
      (concat
       "exec env TERM='%s' INSIDE_EMACS='%s' "
       "ENV=%s %s PROMPT_COMMAND='' PS1=%s PS2='' PS3='' %s %s -i")
      tramp-terminal-type (tramp-inside-emacs)
      (or (getenv-internal "ENV" tramp-remote-process-environment) "")
      (if (stringp tramp-histfile-override)
          (format "HISTFILE=%s"
                  (tramp-shell-quote-argument tramp-histfile-override))
        (if tramp-histfile-override
            "HISTFILE='' HISTFILESIZE=0 HISTSIZE=0"
          ""))
      (tramp-shell-quote-argument tramp-end-of-output)
      shell (or (tramp-get-sh-extra-args shell) ""))
 t t)

The problem is that, the command to open the new shell is executed in the original shell that is used for “initializing a connection”. After ssh into a remote shell, Tramp will always tries to exec into another shell! So each time you create a connection, an exec ... /bin/sh -i command is written into the login shell’s history file, typically ~/.bash_history. On most GNU/Linux distros, sh is just alias of bash, and they share the same history file.

Solutions

Luckily I’ve found the reason, so the remaining problem is to apply a reasonable (and possibly elegant) workaround. Here are some choices.

Adapt the shell

The most easily conceivable solution might be configuring the shell. I asked ChatGPT: “How to make bash stop writing some commands (in certain pattern) into the history file?” And he gave me a few methods, including setting HISTIGNORE, customizing history function and so on.

You can also write a simple shell script to strip these nasty history from ~/.bash_history:

1
sed -i "/^exec env TERM.*INSIDE_EMACS.*ENV.*PROMPT_COMMAND.*PS1.*PS2.*PS3.*-i$/d" ~/.bash_history

This kind of solutions is simple and easy to achieve. However it’s not adopted by myself, since I do not want to touch my shell configuration file to adapt for a narrow use case… The problem is caused by Tramp’s over-design and lack of meticulous consideration, and the shell has no responsibility to pay the bill.

Advise tramp-open-shell

tramp-open-shell sends an exec command to the remote login shell. If the command is prefixed with a space and your shell has enabled ignorespace option (this is a quite normal and useful option), then the command will finally ignored by the shell, without writing to the history file.

Unfortunately, the string template for the command is hard-coded in tramp-open-shell. The only way to prefix a space is to override the whole function! The solution works, but it’s really ugly so I abandon it too.

Set HISTFILE in advance

Yeah, we can set HISTFILE (to an empty string) in advance, before tramp-open-shell attempts to run that ugly command.

There are also several methods to do that. I finally decide to set HISTFILE once the connection is created, i.e., the exec ssh command is executed. We can specify ssh options in the command line to do that. See ssh_config(5) for details.

1
ssh -o RemoteCommand='env HISTFILE= /bin/sh -i' -t user@host

The env(1) command sets environment and executes /bin/sh -i command. The remote command is executed in the remote user’s login shell, typically bash. Since bash set HISTFILE to ~/.bash_history, we use env 'HISTFILE=' to reset it. As a result, we get into a standard /bin/sh shell with HISTFILE set to an empty string. tramp-open-shell will send commands that aren’t written to the history file.

So how to tell Tramp that it should use this command to initialize a connection? In fact, all protocols/methods supported by Tramp is specified in the variable tramp-methods. Analogous to our familiar ssh method, Tramp provides the sshx method which wraps typical ssh command:

‘sshx’

Works like ‘ssh’ but without the extra authentication prompts. ‘sshx’ uses ‘ssh -t -t -l USER -o RemoteCommand=’/bin/sh -i’ HOST’ to open a connection with a “standard” login shell. It supports changing the remote login shell ‘/bin/sh’.

sshx specifies the RemoteCommand option so a standard /bin/sh -i is always created. We can change the argument of RemoteCommand:

1
2
3
4
(let ((args
       (assoc 'tramp-login-args (assoc "sshx" tramp-methods))))
  (setf (cadr (assoc "-o" (cadr args)))
        "RemoteCommand=\"env 'HISTFILE=' %l\""))

Finally, we’ve set HISTFILE of initial shell, before it execs into another shell! And I apply this ultimate solution at last.

Other Tips

I’d like to recommend the configuration:

1
(setq tramp-default-method "sshx")

It has several benefits, such as avoiding abnormal stuck of Tramp because of your fancy shell prompt (see Tramp hangs #3).

To make Tramp a little faster, you can specify the ControlMaster option for Tramp:

1
2
(setq tramp-ssh-controlmaster-options
      "-o ControlMaster=auto -o ControlPath=/tmp/controlpath-%r@%h:%p -o ControlPersist=600")
This post is licensed under CC BY 4.0 by the author.