Emacs TRAMP遭遇zsh触发卡死之解

Emacs

Emacs

1. 起因

Emacs在过去几年一直是我的日常编辑器,–尽管更多时间,我是在使用IDE(如Android Studio、Visual Studio Code等)与终端(如iTerm2等)。虽然也知道Emacs能够打开并编辑远程计算机上的文件,但还是更多地在ssh登录到远程计算机后运行Emacs,特别是在我尝试了几次利用TRAMP1打开远程文件之后。

使得我对TRAMP敬而远之的主要障碍就是,每次打开局域网下服务器的文件时,Emacs就会卡死!此时,我只好使用 kill 命令来杀死emacs进程。但由于我是采用了 emacs -daemon 来启动后台进程, emacsclient -t 来打开前端进程,一旦杀死emacs进程,就会使得全部的emacsclient退出。而此时,我可能已经打开了大量文件,如果要重新一个一个恢复,将会是一件麻烦的事情。

2. 准备

在我的问题上下文里,ssh并没有采用自建ssh-agent,而是需要和gpg-agent协同工作。梳理整个操作流程,共涉及到三个参与者:

  1. ssh,负责远程连接、传输等;
  2. gpg-agent,负责在用户认证环节提供公私钥证书等;
  3. TRAMP,负责与Emacs适配等。

首先,通过在终端直接执行ssh登录,结果正常,说明ssh与gpg-agent可以正确配合工作。因此,可以大致排除二者的嫌疑,余下的”疑凶”也就是TRAMP了。只有获得卡死的具体原因,才能对症下药,因此就需要补充线索,也就是TRAMP的日志了。从其用户手册上可以找到1如何打开和提高日志的配置项的说明:
TRAMP messages are raised with verbosity levels ranging from 0 to 10.
TRAMP does not display all messages; only those with a verbosity level less than or equal to tramp-verbose.

足足有11个级别,–这远远大于通常的4-5个:

0 Silent (no TRAMP messages at all)
1 Errors
2 Warnings
3 Connection to remote hosts (default verbosity)
4 Activities
5 Internal
6 Sent and received strings
7 Connection properties
8 File caching
9 Test commands
10 Traces (huge)
11 Call traces (maintainer only)

于是,就在Emacs的启动脚本(以我的情况为例,位于 ~/.emacs.d/init.el )文件,设置日志:(setq tramp-verbose 10)

3. 分析

再次尝试打开远程文件,顺利捕获到报错信息!其中,buffer *Backtrace* 的部分信息如下:

Debugger entered–Lisp error: (file-error "Timeout reached, see buffer ‘*tramp/ssh harvey@192…")
  signal(file-error ("Timeout reached, see buffer ‘*tramp/ssh harvey@192…"))
  tramp-error(nil file-error "Timeout reached, see buffer ‘*tramp/ssh harvey@192…")
  tramp-signal-hook-function(file-error ("Timeout reached, see buffer ‘*tramp/ssh harvey@192…"))
  signal(file-error ("Timeout reached, see buffer ‘*tramp/ssh harvey@192…"))
  tramp-maybe-open-connection((tramp-file-name "ssh" "harvey" nil "192.168.31.175" nil "~/Downloads/zshrc.backup.20230905" nil))
  tramp-send-command((tramp-file-name "ssh" "harvey" nil "192.168.31.175" nil "~/Downloads/zshrc.backup.20230905" nil) "echo ~ 2>/dev/null; echo tramp_exit_status $?")
  tramp-send-command-and-check((tramp-file-name "ssh" "harvey" nil "192.168.31.175" nil "~/Downloads/zshrc.backup.20230905" nil) "echo ~")
  tramp-sh-handle-get-home-directory((tramp-file-name "ssh" "harvey" nil "192.168.31.175" nil "~/Downloads/zshrc.backup.20230905" nil) "")

再查看buffer *debug_tramp* 的内容如下,发现其与buffer *Backtrace* 并未有太多不同:

backtrace()
tramp-error(nil file-error "Timeout reached, see buffer ‘*tramp/ssh harvey@192…")
tramp-signal-hook-function(file-error ("Timeout reached, see buffer ‘*tramp/ssh harvey@192…"))
signal(file-error ("Timeout reached, see buffer ‘*tramp/ssh harvey@192…"))
tramp-maybe-open-connection((tramp-file-name "ssh" "harvey" nil "192.168.31.175" nil "~/Downloads/zshrc.backup.20230905" nil))
tramp-send-command((tramp-file-name "ssh" "harvey" nil "192.168.31.175" nil "~/Downloads/zshrc.backup.20230905" nil) "echo ~ 2>/dev/null; echo tramp_exit_status $?")
tramp-send-command-and-check((tramp-file-name "ssh" "harvey" nil "192.168.31.175" nil "~/Downloads/zshrc.backup.20230905" nil) "echo ~")
tramp-sh-handle-get-home-directory((tramp-file-name "ssh" "harvey" nil "192.168.31.175" nil "~/Downloads/zshrc.backup.20230905" nil) "")

最后,查看buffer *debug tramp/ssh harvey@192.168.31.175* ,部分信息如下:

11:49:28.486065 tramp-process-actions (1) # File error: Timeout reached, see buffer ‘*tramp/ssh harvey@192.168.31.175*’ for details
Linux Gen8-1 6.1.0-15-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.66-1 (2023-12-09) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sun Apr  7 11:47:28 2024 from 192.168.31.100
%                                                                              
 

 harvey@Gen8-1  ~ 
11:49:47.044520 tramp-process-actions (3) # Waiting for prompts from remote shell…failed
11:49:47.044772 tramp-maybe-open-connection (3) # Opening connection nil for harvey@192.168.31.175 using ssh…failed
11:49:47.044883 tramp-get-connection-property (7) # process-buffer nil; cache used: t

至此,引发卡死问题的直接元凶露出了马脚:Waiting for prompts from remote shell…failed。大致可以推定是TRAMP执行失败了!余下的工作,就借助搜索引擎和ChatGPT来推进了。果不其然,由于shell promot不被TRAMP识别,导致了Emacs被卡死。而之所以识别失败,是因为目标计算机采用了oh-my-zsh,将 PS1 设置成了“非主流”格式,同时,TRAMP又严重依赖通过文本解析来处理交互。于是,综合作用下,Emacs就表现出卡死的症状,–其实,如果等得时间足够长(大概3-5分钟),并执行Ctrl+g,Emacs会中止TRAMP操作,从而恢复正常。

上述的推理也在 解决Emacs远程连接时卡住的bugFixing Emacs tramp mode when using zsh 两处网络文章中得到了印证。

4. 解决

根据 Emacs Wiki: TrampMode 的解释,造成 TRAMP Hang 的原因还有其他。对本次问题提供的解决办法就是,在远程计算机的zsh配置文件(即 ~/.zshrc )的末尾,增加如下设置:

# Fix Emacs Tramp mode's imcompatible with zsh (oh-my-zsh) prompt
if [[ "$TERM" == "dumb" ]]
then
  unsetopt zle
  unsetopt prompt_cr
  unsetopt prompt_subst
  if whence -w precmd >/dev/null; then
      unfunction precmd
  fi
  if whence -w preexec >/dev/null; then
      unfunction preexec
  fi
  PS1='$ '
fi

至此,问题得到了解决,也给我一些启发。首先,TRAMP采用文本解析的方式,在我看来是不够健壮的。如果不了解TRAMP对Shell的prompt敏感这个情况,用户很难第一时间会想到zsh/oh-my-zsh。–当然它们没有错。我注意到前文提到两篇网络文章分别发布于2012年和2016年,那么这十多年来一定有不少用户发生了和我一样的遭遇。今天的解决办法只能说是规避,至于如何优雅地处理这个问题,需要更多思考。

其次,开源软件、搜索引擎、大语言模型真是好东西。从前遇到开源软件的问题,首先是用搜索引擎,但是漫无目的。现在把问题提给大语言模型,它能想到可能的原因是哪几个,告诉你从什么地方开始排查。这次的问题排查就是如此。原本开源软件往往因为缺少专业的技术支持,而使得非专业用户望而却步,今天会因为类似ChatGPT一类的工具,使得技术问题也能被非专业用户解决,从而赢得它们青睐。

Footnotes:

1 TRAMP 2.6.3 User Manual

Leave a comment

Your comment