[Bug]: Two desktops launching one SSH host at once both start a server, and the record keeps the dead one's PID#32549

opened3d agobyVvpsman

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/desktop (the SSH launch script in packages/ssh/src/tunnel.ts)

Steps to reproduce

In the app:

  1. Two desktop clients each have an SSH environment for the same host with the same alias, hostname, user and port. They share one launch record, ~/.t3/ssh-launch/<state key>/.
  2. Nothing is serving the host's default home, for example because its server was just stopped.
  3. Both desktops connect in the same second. In practice that happens when both reconnect after the server stops.

Deterministic version: render REMOTE_LAUNCH_SCRIPT from main at e455200077 with the archive runner (0.1.8-nightly.20261107.4314), then on a Linux host:

sh
1export HOME="$(mktemp -d)"
2sh -l -s -- k < launch.sh & sh -l -s -- k < launch.sh & wait
3cat ~/.t3/ssh-launch/k/pid # compare with "pid" in ~/.t3/userdata/server-runtime.json
4sh -l -s -- k < launch.sh # the next connect

Expected behavior

One server starts and both launches attach to it. The record's pid names that server, so the next connect reuses it as managed and a desktop update still replaces it.

Actual behavior

Both launches pick the same free port and both start t3 serve. The second server can't take the home's owner lock and exits with ServerAlreadyRunningError. The first server answers on that port, so both launches print {"remotePort":3774,"serverKind":"managed"}. Each launch writes its own child's PID to pid right after nohup, and the second write wins. The record now names the server that died.

The next connect sees that server-runtime.json names a different PID from the record and adopts the live server as external (the elif [ -n "$DEFAULT_REMOTE_PORT" ] branch of the launch script). After that, a changed runner no longer replaces it and Disconnect no longer stops it. The host stays on whatever version started it. #24095 reaches the same end state by renaming the SSH alias.

This happened in 6 of 6 trials in a throwaway home. It isn't a coin flip: the server started second always loses the lock, and its launch always writes pid last.

From the code, not observed: if the two launches pick different ports, the losing launch waits 60 s for its own port, then runs rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" and deletes the record the other launch wrote.

Impact

Minor bug or occasional failure

Version or commit

main @ e455200077 (tunnel.ts is unchanged since v0.1.8-nightly.20261107.4314)

Environment

Linux x64 hosts reached over SSH from two T3 Code desktop clients. Throwaway-home runs on Debian 13 with dash as sh.

Logs or stack traces

shell
1# Throwaway home, trial 1 (host-local time)
217:16:37.380 both launches start
317:16:39.859 server.log: ERROR ServerAlreadyRunningError: A T3 Code server already owns ~/.t3/userdata. ...
417:16:40.372 server.log: Listening on http://127.0.0.1:3774
517:16:40.503 both launches print {"remotePort":3774,"serverKind":"managed"}
6record: managed=managed port=3774 pid=1163904 (not running)
7server-runtime.json: pid=1163900 port=3774 (running)
8next launch: {"remotePort":3774,"serverKind":"external"}, record now external with no pid
9launch with a changed runner (what a desktop update sends): "external", still pid 1163900
10
11# Real hosts, 2026-11-07
12Host 1, 17:48:39 both desktops connected in the same second to a host with nothing listening.
13 One server died at 17:48:43.835 with ServerAlreadyRunningError. The record read
14 managed with that dead PID; server-runtime.json named the live server.
15Host 2, 01:14:42 both desktops reconnected together after their server stopped, same error.
16 At 01:15 the next launch adopted the live server as external. It stayed on
17 0.1.8-nightly.20261105.4218 while both desktops moved to 0.1.8-nightly.20261107.4314.

Workaround

Stop the server, delete ~/.t3/ssh-launch/<state key>/, then connect one desktop at a time.

Proposed fix

Hold one lock per T3 home across the check, the start and the wait for readiness in REMOTE_LAUNCH_SCRIPT. The runner already does this for its install lock (T3_LOCK with set -C, a PID inside, and a stale lock taken over). The lock is per home rather than per record because two records, from two aliases for one host, also share one home. The wait for the lock has to outlast REMOTE_READY_TIMEOUT_MS. The desktop's in-process withTargetLock can't help here: it only serialises one desktop's own launches.

#25633 and #26191 edit the same part of the script, so whichever lands second needs a rebase. I can open a PR with a test in tunnel.test.ts (two launches in a throwaway home give one server and a record whose pid matches server-runtime.json) if this direction works for you.

Investigated with Claude Code (Claude Opus 5.5).