Skip to content

[Bug]: SSH reconnect kills a busy remote server, cancelling all running agents (2s reuse probe) #16477

Description

@bloom-street

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 (SSH environment launch script), with apps/server startup recovery

Steps to reproduce

  1. On laptop A, run T3 Code desktop with an SSH environment pointing at machine B. The launch script starts a managed server on B (~/.t3/ssh-launch/<key>/, managed).
  2. Start several long agent runs on B, with background shells and subagents, and leave B under heavy CPU load (load average about 2-3x core count from test suites).
  3. Close the lid on laptop A, on battery. macOS DarkWakes it about every 15 minutes, and the desktop app reconnects the SSH environment on each wake.
  4. Each reconnect reruns REMOTE_LAUNCH_SCRIPT on B.

Expected behavior

A live, healthy server that is only slow to answer is reused. Running agents and their background work survive a client reconnect.

Actual behavior

On each reconnect the reuse probe (REMOTE_REUSE_READY_TIMEOUT_MS = 2e3, SSH_READY_PROBE_TIMEOUT_MS = 1e3) does not get an answer in time on the loaded host. The script hits elif ! wait_ready ...; then kill "$REMOTE_PID" and starts a new server. The new server's V2 orchestration recovery then stops every provider session (stoppedSessions: 8) and emits run.background-work-cancelled for every running background shell and subagent.

In one night this happened 29 times, about every 15 minutes from 00:34 to 08:29. Each server restart was about 10-15 s after a laptop DarkWake, for example:

Laptop DarkWake Server log Relay client stopped then Listening on
00:33:44 00:33:57 → 00:34:06
00:46:23 00:46:33 → 00:46:40
01:02:08 01:02:20 → 01:02:24
01:21:42 01:21:59 → 01:22:03
01:37:27 01:37:37 → 01:37:42

No agent work survived the night. The server never crashed; every shutdown was a clean SIGTERM.

Related cases in the same script:

  • RUNNER_CHANGED=1, when a client on a different nightly reconnects after an auto-update, also kills a busy managed server and cancels all of its work without warning.
  • If server-runtime.json points at another live server (for example the desktop app's own server on B), the next reconnect kills the managed server even though it is healthy and running agents.

Impact

Major degradation or frequent failure

Version or commit

0.0.46-nightly.20261004.2657 (overnight), then 0.0.46-nightly.20261005.2702. macOS on both ends.

Suggested fixes

  • Do not kill a managed server whose pid is alive just because a 2 s readiness probe failed. Retry with a longer budget (for example 30-60 s) or check liveness via the pid and server-runtime file before deciding.
  • On RUNNER_CHANGED with active runs, defer the restart or ask the user, rather than SIGTERMing immediately.
  • Consider making a client reconnect never restart the server at all. Restarts only from explicit user action or update.

Activity

  1. juliusmarminge commented on Oct 6, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Triage

    Thanks @bloom-street for the overnight log analysis, the wake pattern and the clean SIGTERMs made this much easier to trace. Reading current main, a desktop SSH reconnect can stop a managed remote server, and the new process's startup recovery is what ends the running agents. It doesn't look like a crash or a provider bug.

    What I found

    • Stale local forward after sleep. ensureTunnelEntry probes the existing local forward for 2 seconds. After sleep that forward is dead, so the probe fails even if the remote server would have answered immediately. The tunnel finalizer then calls stopRemoteServer(), and the stop script SIGTERMs the recorded pid unless the host is marked external. The next launch starts a replacement. Host load isn't required for this path.
    • The elif ! wait_ready branch is a second kill. In packages/ssh/src/tunnel.ts, a managed pid that's still alive is SIGTERM'd when RUNNER_CHANGED=1, or when wait_ready misses REMOTE_REUSE_READY_TIMEOUT_MS (2s, each GET / capped at 1s). That runs when the finalizer didn't already remove the pid file. A loaded host can miss that budget. The logs don't show ssh.environment.tunnel.existing.stale vs. the launch script, so it's not clear which of the two fired each time; both produce the same SIGTERM and replacement.
    • Recovery on the new server. V2 orchestration recovery completed is startup reconciliation: stoppedSessions: 8 is every provider session not already stopped or errored, and run.background-work-cancelled clears background shells and subagents. continueThreadsAfterServerUpdate (off by default) would start a new continuation turn, but wouldn't preserve the killed provider process.
    • server-runtime.json case. The managed process is started with --base-dir ~/.t3, so the file it reads on the next launch is usually its own record. If that probe succeeds and state says managed, the script kills that pid and flips the host to external. fix(ssh): stop killing the managed server on reconnect #13341 and fix(ssh): reconnecting no longer restarts the remote server #13521 skip the kill only when the runtime pid is the managed pid; they don't change the timeout kill or the tunnel finalizer.
    • RUNNER_CHANGED case. This is the update path. Whether to defer an update while runs are active is a separate question for maintainers.
    • Related PRs and issues. fix(desktop): give the SSH readiness probe room for high-RTT links #7815 lengthens the desktop tunnel probe for high-RTT links and leaves the remote 2s reuse budget alone. fix(ssh): keep remote work running when the desktop closes #10951 would have stopped the finalizer from killing the remote on teardown but was closed unmerged. [Bug]: Restarting the desktop client breaks running remote Codex threads over SSH #10934 (quit/restart, same finalizer), SSH reconnect can replace an external T3 service with a competing managed runtime #5749 (a competing server beside an external service), and [Bug]: SSH remote environment never connects when link RTT exceeds ~1s, eg on in-flight wifi (readiness probe deadline is 1000ms) #7733 (desktop never connects because the local probe is 1s) are different failures.
    • Docs. The user docs say removing the connection stops a server T3 Code launched; a lid close isn't removing the connection, but the finalizer treats any stale tunnel as a managed-server stop.

    Likely fix area

    packages/ssh/src/tunnel.ts and the tunnel finalizer. Some options:

    Workaround: for a host that needs to outlive the laptop, npx t3 service install on the remote and then a normal paired connection avoids the managed-server lifecycle.

    A maintainer will decide on the fix direction.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 6, 2026
  3. topitopongsala commented on Oct 9, 2026

    @topitopongsala

    Same bug here, with both sides of the trace. This answers the open question in the triage: the kill came from the tunnel finalizer (stopRemoteServer), not from the launch script's wait_ready branch.

    Setup. Client: macOS desktop 0.0.46-nightly.20261007.2787. Remote: macOS, SSH environment, managed server in ~/.t3/ssh-launch/<key>/ (managed). The remote host was not overloaded. The client was on an outside network and not asleep.

    Client desktop.trace.ndjson (local time):

    08:55:17.384 EnvironmentSupervisor.connectionLost  target.kind=SshConnectionTarget connection.connected_ms=90152 connection.failure.reason=transport
    08:55:20.445 desktop.ipc.sshEnvironment.ensureEnvironment -> ssh/tunnel.ensureEnvironment -> ensureTunnelEntry
    08:55:20.459 shared.httpReadiness.waitForHttpReady  GET http://127.0.0.1:<local forward>/  x20, every ~100 ms, until 08:55:22.434
    08:55:22.461 ssh/tunnel.stopRemoteServer
    08:55:22.461 ssh/tunnel.closeTunnelEntry
    08:55:24.980 ssh/tunnel.launchOrReuseRemoteServer -> ensureTunnelEntry.create
    

    Remote side, same seconds:

    08:55:22.49  sshd: Accepted publickey from the client
    08:55:22.82  server.log: Relay client stopped
    08:55:28.0   every ACP agent (Devin) session/prompt fails with Interrupt; shutdown reconciliation marks the runs cancelled
    08:55:30.06  server.log: Listening on http://127.0.0.1:3774 (replacement)
    

    So a brief SSH transport drop is enough. The 2 s probe goes to the dead local forward, fails, and the finalizer stops a healthy remote server. That server was serving T3 Connect relay traffic the whole time. We saw 17 restarts like this in about 17 hours. Each one cancelled every running agent on the host.

    Two notes that may help pick the fix:

    The first option in the triage fits what we saw: on a stale tunnel, close only the local forward, and keep stopRemoteServer() for an explicit Disconnect or Remove.

  4. stockgeist commented on Oct 9, 2026

    @stockgeist

    Another way the launch script kills a healthy server on reconnect. The host here is not loaded and wait_ready succeeds; the kill comes from the managed branch of the server-runtime.json reuse block.

    What happened

    • Linux remote (desktop app also runs on it, serving 127.0.0.1:3773); a Mac connects over SSH.
    • Once, the Mac connected while the desktop app was closed, so the launch script started its own server (t3 serve --port 3774, state dir managed).
    • That server wrote itself into ~/.t3/userdata/server-runtime.json.
    • From then on, every reconnect killed it and started a new one: 99 Listening on http://127.0.0.1:3774 lines in ~/.t3/ssh-launch/<key>/server.log, each right after a tailscaled sh -l -s -- <key> session start. No OOM kills or core dumps. Every kill cancelled running agent turns and subagents.

    Why

    In REMOTE_LAUNCH_SCRIPT (still the same in 0.0.46-nightly.20261009.2861):

    if [ -n "$DEFAULT_REMOTE_PORT" ]; then
      REMOTE_PORT="$DEFAULT_REMOTE_PORT"
      if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then
        if [ "$REMOTE_MANAGED" = "managed" ]; then
          PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"
          ...
            kill "$PID_TO_STOP" 2>/dev/null || true

    This branch is meant to retire the script's own server when some other default server is running. But when server-runtime.json points at the managed server itself, PID_TO_STOP == DEFAULT_RUNTIME_PID: the script kills the server it just found healthy, the following external wait_ready on that port fails, and it starts a new managed server, which writes itself into server-runtime.json again. So it loops on every reconnect.

    Suggested fix: when PID_TO_STOP equals DEFAULT_RUNTIME_PID (or the state dir's port equals DEFAULT_REMOTE_PORT), reuse the server and keep the state as managed.

    Workaround: write external to ~/.t3/ssh-launch/<key>/managed so the script reuses the server.

    Related: restarts on version change

    The RUNNER_CHANGED branch also kills a live managed server whenever the generated run-t3.sh differs, i.e. on every nightly update, regardless of running turns. Could these restarts wait (or be deferred) until no agent turns or subagents are running?

    Possibly related (not confirmed)

    While cleaning up, I SIGTERMed the stray 3774 server after the desktop app had already restarted on 3773. Afterwards server-runtime.json was gone (the userdata dir's mtime matches the SIGTERM), so the next SSH launch would have found no server and started a managed one again. It looks like a server removes server-runtime.json on shutdown without checking that the file still names its own PID. Restarting the desktop app rewrote the file.

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

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions