某次 VSCode 升级之后,我从 macOS 连远端开发机的 Remote-SSH 突然进入了死循环:客户端每隔几秒发起一次新的 SSH 连接,每次都能通过 pubkey 认证、能在远端拉起 vscode-server,但十几秒后就 timed out,紧接着 attempt N+1。前后连了几十次都接不上。
机器上没有任何代码改动,远端服务可达,公钥认证也没坏。问题出在客户端的某个不起眼的角落,被一堆看起来很正常的日志掩盖着。这篇文章记录从现象到根因的整个排查过程,最后定位到一条113 字节长的路径上。
现象
VSCode 输出面板里,Remote-SSH 频道刷出来的内容大致这样:
1 | [15:34:43.854] SSH Resolver called for "ssh-remote+dev", attempt 1 |
每一轮 attempt 都走完同样的剧本:建连、认证、远端 server 跑起来、监听端口报告回来——然后五六秒后 local-server 自己宣告 timed out,整个会话被回收,立刻发起下一轮。
attempt 计数会一直涨到 9、10、11,每轮花掉 5 秒左右,远端机器被反复刷出 vscode-server 进程,客户端这边一直停在「正在连接到远程主机」的转圈状态。
第一次误判:以为远端 vscode-server 半死
最自然的怀疑方向是远端。我登上去看 vscode-server 的日志:
1 | [14:57:44] No ptyHost heartbeat after 6 seconds |
SIGPIPE 加 No ptyHost heartbeat——一眼看上去就是 server 进程内部某条 pipe 被对端关掉了,但主进程没有崩,pid 文件还在,所以新的连接进来时复用了这个半死的 server。
而且 14:57 这个时间点正好对得上前一阵那次内存抖动,PSI 数据里 memsome=62% 把所有进程都按进了 D 状态,刚好可以解释为什么 ptyHost 心跳超时。
我把整棵 vscode-server 进程树 kill 掉,client 立刻重连成功。看上去一切都解决了。
但这只是巧合:内存压力消退之后,client 自己也愿意接受重连了一段时间。十几秒后,问题原样复发,和远端进程没有任何关系——我能在远端日志里看到 client 一次次成功连进来,client 这边却还在喊 timed out。
诊断方向错了。
真正的线索藏在 stderr 一行
回到 client 输出,逐行慢慢看,注意到一条非常容易被忽视的 stderr 行,每一轮 attempt 都出现一次:
1 | [15:34:43.978] > local-server-1> Spawned ssh, pid=21495 |
listen EINVAL 是 Node.js net.Server#listen() 在底层 bind() 失败之后会抛出的错误。EINVAL 在 unix socket 上意味着 sockaddr 不合法。
接着每一轮都跟着两次:
1 | [15:34:46.420] Server delay-shutdown request failed: connect EINVAL <同一条路径> - Local |
connect EINVAL 是 client 端连同一个 socket 时也失败——因为根本没人在听。Server delay-shutdown 是 VSCode 的 local-server 用来给自己续命的心跳:一段时间收不到这个信号,local-server 就会主动退出。这正好对应了前面那个雷打不动的 5 秒后 timed out。
把这几条串起来:local-server 启动时尝试 bind() 一个 unix socket 来收 delay-shutdown 心跳,bind 失败,没有人监听,client 永远连不上自己的 local-server,于是按超时逻辑自杀,触发上层 reconnect——这就是死循环的根源。
sun_path 的 104 字节上限
那条让 bind 失败的路径长这样:
1 | /var/folders/bq/<launchd-uuid>/T/vscode-ssh-askpass-<40 字符 hex>.sock |
数一下实际样本(uuid 和 hash 都已脱敏):
1 | /var/folders/bq/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/T/vscode-ssh-askpass-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.sock |
总长 113 字节。
而 macOS 的 unix socket 地址结构定义在 <sys/un.h>:
1 | struct sockaddr_un { |
sun_path 字段固定 104 字节,包括末尾的 NUL,所以实际能放下的路径最长 103 字节。Linux 同一个字段是 108 字节,BSD 系普遍 104,标准并没有要求统一。
113 > 103,bind 必然返回 EINVAL。这是一条无论你重连多少次都不会自愈的硬约束。
为什么之前能用,现在不能
/var/folders/bq/<uuid>/T/ 是 macOS 给每个 user agent 分配的私有 TMPDIR,长度对一个固定用户固定登录会话来说是个常量,大约 49 字节。vscode-ssh-askpass- 前缀是 19 字节。剩下的预算是 103 - 49 - 19 = 35 字节,给后面的随机 hash 和扩展名用。
VSCode 之前用的格式大概用了 32 个字符的 hash + .sock(37 字节),刚刚好踩着上限边缘。某次升级之后改成了 40 字符 hash(45 字节),瞬间从「贴线通过」变成「越界」。
不能确证是哪个 commit 引入的,但表现上完全是一次「靠余量很小的不变量」被打破后的回归——典型的把 in-memory 标识符改长了几个字节,没人意识到这条路径要绕过 macOS 的 socket 长度限制。
整件事情甚至安静得听不见。bind() 失败的 EINVAL 只是普通 stderr 一行,混在 > stderr> debug1: ...、> Welcome to Ubuntu ... 这种大段欢迎横幅里非常不起眼。如果不主动盯着每行 stderr 看,很容易跟着「Authenticated」「Remote server is listening on port ...」这些「成功」字样一路读到 timeout。
这是排查里最磨人的一类问题:核心错误信息有,但被设计成了 best-effort,错过了不会再提醒第二次。
一个走错的修复:lockfilesInTmp
VSCode 的 Remote-SSH 设置里有这么一条:
1 | "remote.SSH.lockfilesInTmp": true |
字面看起来非常对症——「把 lockfile 放到 /tmp」,正是缩短路径的思路。我打开它、reload window、再连,问题原样复发。
掉进了陷阱:这个 setting 控制的只是 install lock 这一类文件的位置,路径在客户端日志里是这样的:
1 | Acquiring local install lock: /var/folders/bq/<uuid>/T/vscode-remote-ssh-<id>-install.lock |
这是个文件锁(flock),不是 unix socket,bind 失败的另一个独立路径。askpass / ipc handle 这些 socket 不归这个开关管,仍然继续走 TMPDIR。
教训:单看名字像,不一定就是对应的开关。改完之后要看日志里那条具体出错的路径有没有变。这次它没变,意味着没修对地方。
正确的修复:给 VSCode 一个短的 TMPDIR
既然 socket 路径是 ${TMPDIR}/vscode-ssh-askpass-<hash>.sock,最直接的解法是把 TMPDIR 指到一个更短的位置。/tmp 是 4 字节,但 /tmp 在 macOS 上不按用户隔离权限,同机其他用户原则上能看到这些 socket。一个更稳妥的选择是 ~/tmp:
1 | mkdir -p ~/tmp |
700 让别的用户进不来,行为接近原本 /var/folders/... 的私有 TMPDIR。
接下来要决定让谁继承这个 TMPDIR。
最粗暴的是全局——写一个 LaunchAgent 调 launchctl setenv TMPDIR ~/tmp,所有 GUI 应用都生效。问题是副作用太大:每个原本写 /var/folders/.../T/ 的 GUI 程序现在都改写 ~/tmp,行为差异可能踩到一些奇怪的角落。
更克制的做法是只给 VSCode 一个短 TMPDIR:
1 |
|
把这个 wrapper 放进 PATH 里替代原来的 code,从命令行启动 VSCode 就走我们设定的 TMPDIR;想从 Dock 启动也生效的话,可以用 Automator 包成一个调相同 wrapper 的 .app。
来算一下新路径长度:
1 | /Users/<user>/tmp/vscode-ssh-askpass-<40 hex>.sock |
合计大约 81 字节,离 103 上限还有 20+ 字节的余量。即使 VSCode 哪天再把 hash 加长几个字符,也还撑得住。
小结
回头看这次排查,真正花时间的是被远端那个 SIGPIPE 误导。一个表面看起来非常合理的怀疑方向,加上「kill 之后好了一会儿」的伪正反馈,几乎让我跳过了真正的根因。
这背后有个一般的教训:不要被巧合的修复说服。如果你的 fix 和现象之间没有清晰的因果链,那「好了」很可能只是另一个无关变量恰好回到了正常值。这次是远端的内存压力消退掉了,下次可能是别的什么。
另外一点是关于沉默失败的。bind() EINVAL 是个严重错误,但 VSCode 把它当作 best-effort 写进了 stderr,没有放大,没有面向用户的提示。客户端的上层只看到「local-server timed out」,没人告诉它真正的原因是 sockaddr 长得超出 BSD 的祖传约束。事实就这么躺在日志里,但你得知道要找它。
最后是 sockaddr_un.sun_path 这种祖传不变量。104 字节这个数字至少能追溯到 4.4BSD,它早于 VSCode 的诞生几十年,在 2026 年还能让人在 macOS 上踩到,并不奇怪。任何用 unix socket 做跨进程 IPC 的程序,在选 socket 路径的时候都欠所有用户一句「我够短吗」的自检。