Skip to content

SSH remote: client kills its own managed server on every reconnect (default-runtime adoption check) #15986

Open

Description

@understory-finch

Summary

When the desktop app connects to a remote host over SSH, every reconnect after the first SIGTERMs the healthy server that the client itself launched, then starts a new one. Every provider session (Claude, etc.) on the remote dies on each reconnect.

On my Mac host this caused 22 server restarts in ~18h on v0.0.45. Each one followed a tunnel reconnect (laptop sleep, Wi-Fi blip, app restart).

Root cause

This is in REMOTE_LAUNCH_SCRIPT in packages/ssh/src/tunnel.ts (lines from v0.0.45; the logic is unchanged on main @ cf3e714):

  1. The client starts the managed server with --base-dir "$HOME/.t3" (L705) and writes managed to $MANAGED_FILE.
  2. Because of that base dir, the managed server writes its own pid and port to ~/.t3/userdata/server-runtime.json. That is the same file the script treats as the "default runtime" (DEFAULT_RUNTIME_FILE, L550).
  3. On the next launch, resolve_default_runtime_port finds that file. The pid is alive and the origin is localhost, so it passes.
  4. The port answers wait_ready, and REMOTE_MANAGED = managed (L646). The script assumes an external service has appeared and kills PID_TO_STOP, then marks the state external. But that pid is the client's own managed server, the same pid as in $PID_FILE.
  5. The external branch re-probes the port. It just killed that server, so the probe fails, the state is cleared, and a fresh server is spawned (L695-709).

tunnel.test.ts (around L300-312) asserts the kill-then-adopt ordering. It does not cover the case where the runtime file belongs to the managed server itself.

Evidence (remote host)

  • ~/.t3/ssh-launch/<hash>/pid == server-runtime.json.pid, and managed = managed.
  • server.log shows a new Listening on http://127.0.0.1:3773 22 times with no error before any of them.
  • server.trace.ndjson: ProviderService finalizer (runStopAll → stopSessions) at 10:16:58, last span 10:17:00. ssh-launch files rewritten and new server up at 10:17:07. This is a clean SIGTERM followed by an immediate relaunch.

Suggested fix

Only adopt or kill when the default runtime is not the managed server. For example, skip the kill branch when DEFAULT_RUNTIME_PID = REMOTE_PID and treat it as reuse. Alternatively, have the server record whether it is service-managed in server-runtime.json and check that.

Workaround

Run t3 service install on the remote host and remove ~/.t3/ssh-launch/<hash>/{pid,managed}. The client then adopts the service as external and never kills it.

Environment: desktop client → remote macOS (Darwin 24.5, arm64) over Tailscale SSH, t3 0.0.45 release archive.

Activity

  1. caitlon commented on Oct 6, 2026

    @caitlon

    Same here on v0.0.45, macOS host reached from a MacBook over SSH: 25 server restarts between 2026-10-05 16:08 and 2026-10-06 08:30 UTC. Each one starts within 10 s of an incoming SSH login from the laptop, mostly sleep/wake overnight. On the host, ~/.t3/ssh-launch/<key>/managed reads managed and pid matches the pid in ~/.t3/userdata/server-runtime.json, so the default-runtime check adopts and kills the client's own server, exactly as described above.

    Two consequences not mentioned yet:

    • Background subagents die silently. Every restart stops all Claude sessions (Session stopped, graceful), and the next turn resumes with "Background agent … didn't finish before the previous session ended". In one evening that killed five running subagents (a builder, a code reviewer, and the same review agent twice), each mid-task, with nothing in the UI at the time.
    • Other clients lose the server too. A phone connected to the same host is cut off on every laptop reconnect. Once, at 04:12 UTC, the old server was killed and no new one started until 04:23, so the phone had no server for about ten minutes.

    The phone itself never caused a restart: none of its 37 turns changed the model or effort, and switching devices mid-session without a laptop reconnect left the session alive. So the trigger is purely the laptop's SSH launch.

  2. Trillx commented on Oct 11, 2026

    @Trillx

    Confirming on Linux with a systemd user service already on the host. Fresh evidence from t3 triage.

    Setup. Host: Linux x64 (kernel 7.0.0), t3code.service (systemd user unit) on 127.0.0.1:3773 with base dir ~/.t3. Client: desktop app on another machine with an SSH environment to this host over Tailscale. Version 0.0.46-nightly.20261010.2948 on both ends. packages/ssh/src/tunnel.ts hasn't changed on main since 2026-10-06, so this is still live.

    What happened. The SSH launcher's managed server (t3 serve --port 3774 --base-dir ~/.t3, managed) restarted 16 times between 2026-10-09 21:04 and 2026-10-11 00:06 UTC. Every restart was a clean Relay client stopped → shutdown reconciliation → Listening on http://127.0.0.1:3774, with no errors. The user's Claude thread runs on that server, so each restart closed its agent session. In the provider event log, every query.close lands 1–3 s after a server stop:

    managed server stop thread query.close
    15:38:54 15:38:55
    16:29:02 16:29:03
    16:34:55 16:34:58
    17:19:28 17:19:30
    17:42:02 17:42:05
    23:49:59 23:50:02
    00:05:57 00:05:58

    What this adds to the report.

    1. An installed service doesn't prevent the loop. The service was running on 3773 the whole time. The managed server's startup overwrites server-runtime.json with its own pid/port, which hides the service from the launcher. The launcher then adopts and kills its own server as described above, instead of adopting the service.
    2. Shutdown erases the service's runtime record. When the managed server stops, it clears ~/.t3/userdata/server-runtime.json without checking whose record it is (apps/server/src/server.ts, the layerRuntimeState release). After the final stop the file was gone even though the service was still running. That breaks discovery for the launcher and for CLI commands that read the file, until the service restarts. (fix(server): preserve newer runtime state on shutdown #9045 proposes the ownership check.)
    3. Two servers on one base dir both ran a relay tunnel. Both servers started a cloudflared for the same managed tunnel ID while sharing one database. The service's relay log at one point showed originService=http://127.0.0.1:3774. Related to Multiple servers share one --base-dir and each resumes the others' live threads #14115.

    Workaround that worked here. Remove the SSH environment in the desktop app (its disconnect runs the stop script and clears pid/port/managed), run systemctl --user restart t3code.service to rewrite server-runtime.json, and connect through T3 Connect instead. If you keep SSH, the alternative is writing external to ~/.t3/ssh-launch/<key>/managed and the service port to port.

    Open fixes: #17684 (newest, mergeable), #13341 and #13521 for the launcher, and #9045 for the runtime-file cleanup on shutdown. This case is the pid-match path #17684 covers.

    Related: #16477, #14115, #17039.

    Investigated and drafted by Claude Code (Claude Opus 5.5) via t3 triage.

  3. Tarekkharsa commented on Oct 11, 2026

    @Tarekkharsa

    Confirming on macOS 27.0.1 (Mac mini M4 host, reached from a MacBook Pro over SSH via Tailscale), T3 Code Nightly 0.0.46-nightly.20261010.2935.

    • ~/.t3/ssh-launch/<key>/pid == server-runtime.json pid (both 9404), managed = managed.
    • server.log has 73 clean restarts (V2 orchestration shutdown reconciliation completed → Listening on), with no crash reports. Overnight and idle restarts come about every 15–16 min, matching laptop DarkWakes.
    • Each restart is followed within about 2 s by a new SSH session from the laptop. For example, the server started at 10:30:16 and the SSH login arrived at 10:30:18. The host never slept, its Wi-Fi was stable, and no update was installed at those times.
    • Running agents are lost. Some shutdowns log terminalizedRuns: 2, stoppedSessions: 2, and the subagents I had resumed were cancelled at 09:57 and 10:30 today.
    • The desktop app's own backend on the host uses the same --base-dir ~/.t3 (see Multiple servers share one --base-dir and each resumes the others' live threads #14115). macOS also flagged T3 for about 8.6 GB of disk writes in about 6 h, and statev2.sqlite is about 2 GB.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions