Remote SSH daemon destroys all running sessions on transient reconnect instead of reattaching

Status Open
Reported on v2.1.260
Maintainer reply None cached
Activity 0 comments · opened Sep 9, 2026

Summary

On a transient SSH interruption, the desktop app's remote-session warm-up destroys a healthy remote daemon instead of reattaching to it, and the resulting server.shutdown SIGKILLs every tracked child session.

In our case one brief network blip cost 10 live ccd-cli agent sessions (42 process groups including their children). The user did not close the app or the sessions.

What makes this look like a branch-selection bug rather than a missing capability: the same shipped binary already implements non-destructive daemon replacement on its takeover path (SIGTERM-first, children explicitly orphaned and surviving). Only the RPC shutdown path is unconditionally destructive.

Versions

  • Remote server: claude-ssh 4534d864… (built 2026-09-02T00:57:03Z)
  • Remote CLI spawned per session: ccd-cli 2.1.260
  • Remote host: Linux x86-64; client: Claude desktop app on macOS

What happens

  1. The SSH channel drops briefly. Client side logs channel_closed_no_socket, then warm-up fails with bridge_startup_timeout.
  2. On that failure branch, RemoteServerController issues server --stop — i.e. the server.shutdown RPC — without first checking whether the existing daemon is still alive and serving.
  3. The daemon's server.shutdown handler SIGKILLs all tracked child process groups. There is no SIGTERM stage, no grace period, and no active-session guard on this path.

Evidence (remote server log, redacted)

The daemon was still healthy and doing useful work in the same second the shutdown arrived — note the exit code 0 reap immediately before it, 104 seconds after the connection had dropped:

16:46:12 [Server]    writeResponse: wrote 0/54 bytes, error=... use of closed network connection
16:46:12 [frameSink] write failed, detaching: ... use of closed network connection
             ---- 104 seconds of silence; daemon alive throughout ----
16:47:56 [process.Manager] Process <uuid> exited with code 0          <-- healthy, still reaping
16:47:56 [Server]         New connection from: @
16:47:56 [ServerHandler]  server.shutdown received over RPC
16:47:56 [Server]         shutdown requested
16:47:56 [process.Manager] Process <uuid> exited with code -1, terminated by SIGKILL, signalled at shutdown request
             ... (repeated for every tracked child) ...
16:47:56 [Server]         cleanup: closed 1 connection(s), killed 42 child process group(s)

Blast radius for that single event: 10 ccd-cli session processes SIGKILLed outright, 42 process groups total.

Control observation on the same machine: a second daemon belonging to a different account has run continuously for 2 days under the same desktop app with zero shutdowns. So the destructive stop is specifically the warm-up failure branch, not ordinary reconnect — ordinary reconnect already reuses the running daemon correctly.

The non-destructive path already exists

The daemon-vs-daemon takeover path in the shipped binary escalates gracefully and explicitly preserves children — this log template is present in the binary:

[daemon] %s: run dir is held by a live daemon, %s; sending SIGTERM
[daemon] %s: previous daemon %s exited after SIGTERM
[daemon] %s: WARNING previous daemon %s ignored SIGTERM for %s; sending SIGKILL
          (its Claude Code children, if any, are orphaned)

The RPC path taken by server --stop has no equivalent — it goes straight to SIGKILL of every tracked child.

No user-side mitigation exists

We looked for one before filing. As far as we can tell there is no supported knob anywhere:

  • Server flag surface is -serve -bridge -stop -socket -token-file -install -cli-* -version — no reuse/reattach/grace/probe-budget flag.
  • No environment variable appears to gate the shutdown or warm-up path.
  • The daemon reads no configuration file beyond the documented token file and socket path.
  • The RPC surface exposes server.shutdown and server.ping plus process.* / files.* / git.status — there is no drain, detach, reload, or keepalive method, and no way for the server to refuse or defer a shutdown while sessions are active.
  • The SSH-sessions documentation covers host/port/identity and enterprise host allowlists only; nothing on reconnect lifecycle, retry budgets, or session durability.

We deliberately did not work around it (no binary wrapper, no forced SSH command, no detachment shim), because any of those would fight the product rather than fix it.

Requested behaviour

On warm-up failure, probe the existing daemon before destroying it — the pieces are already there:

  • server.ping RPC for liveness, and
  • the daemon.lock record, which already carries pid, instanceId and startedAt.

If the daemon answers, reattach instead of issuing server.shutdown. If a stop is genuinely required, apply the same SIGTERM-first / orphan-children semantics the takeover path already uses, or add an active-session guard so a stop cannot silently destroy running work.

Failing that, exposing the choice as a setting would be enough for us.

Impact

We run long-lived agent sessions on a remote host. A single transient network blip currently discards all of them with no warning and no recovery path, and because it is triggered by network conditions rather than user action it is not something the user can avoid.

View original on GitHub ↗