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
):
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 respectsprocess-environment
.tramp-maybe-open-connection
: If there is no available “connection”, Tramp tries to create one. This function will calltramp-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 fortramp-send-command
in the future.tramp-open-connection-setup-interactive-shell
: It’s a subroutine oftramp-maybe-open-connection
that is responsible for spawning the remote interactive shell and set its environment. It opens a shell bytramp-open-shell
, and then setup the environment with several invocationtramp-send-command
.tramp-open-shell
: It willexec
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:
From the diagram above, you can notice that two exec
s 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 (respectprocess-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 oftramp-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 exec
s 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")