Let sessions outlive the backend, and never resume one twice
Three `claude` processes ended up running against this checkout on 2026-08-29, and the account hit its session limit. One cause, several ways in. An agent imported the Claude Code session it was *itself* running in. That is an ordinary import, and importing runs `--resume` -- so a second CLI attached to a file the first was still writing. The whole 65 MB conversation, 154 embedded screenshots included, was re-appended to the transcript under a new prompt id; both copies then read each other's writes as work done elsewhere, and the adopted one was billed for re-reading all of it. Meanwhile `shutdown_all` asked each session to stop and the process exited immediately, so the SIGKILL timer died with the runtime, the stop was unreliable, and whatever survived was orphaned with nothing written down to find it by. The processes leaked either way. So leak them on purpose, and be able to pick them back up. A session's process now outlives the backend and is adopted again on the way up, which is worth having for its own sake: restarting the server no longer ends a turn somebody is waiting on. Its stdio lives in the session directory -- a fifo opened read-write so the process is its own last writer and never reads EOF, plus stdout/stderr logs read from a byte offset. `session::process` records the pid *and* the kernel's start time for it, because a pid alone is reused and adopting a stranger's would mean never resuming the real conversation. That makes the fix structural rather than a check: everything goes through `ClaudeDriver::launch`, which adopts if it can and starts if it cannot, and `--resume` is reachable only on the second path. `Driver` gains two ways out where it had one -- `detach` (coming back) and `stop` (the session is being deleted, so the process must not survive). Importing a session that is open is now refused outright. Claude Code keeps `~/.claude/sessions/<pid>.json` for every live session, so this is a measurement rather than a guess; it reports no/yes/unknown, because a machine that keeps no such record cannot answer and "could not check" is not "nobody is using it". `SessionStatus` gains `Unknown` for the same reason. Also here, found on the way: - A reconnecting phone was sent the entire backlog. Opening a session was bounded to a page but reconnecting was not, so a long disconnect delivered thousands of events one frame at a time. Past `CATCH_UP_LIMIT` the stream sends a `reset` frame and the newest window, and the client rebuilds from it as it does on open -- without the reset the window is spliced onto rows no longer adjacent to it. - A session's status was assumed idle at launch. Read from the transcript instead, so a restart stops claiming an exited session is waiting for you. - `llama-server`'s stdout was piped and never drained, so a chatty one blocked on a full pipe buffer mid-load. It goes to a log now. - A turn that exited or errored never emitted `Idle`, so the queue stayed "running" for good: every later message was held forever and, since a message is only recorded when taken, vanished with nothing on screen. - Two doc comments had drifted onto the wrong functions. Verified by killing the server mid-turn: the process survived, finished its turn unattended (12.8 KB of output nothing was reading), and the restarted server adopted it -- one process, all 700 lines in the transcript, no hole, and it still took a new message afterwards. Deleting a session stops its process; a 266-event backlog resets while a 16-event one streams. 46 tests, clippy and rustfmt clean, app compiles and lints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
This commit is contained in:
1 parent
9791afcfd6
commit
362d436d4f
23 files changed
+1934
-345
No files matched your search
@@ -223,11 +223,74 @@ turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`.
|
||||
- Model change mid-session: try the control protocol's set-model; if the
|
||||
installed CLI doesn't support it, fall back to `shutdown` + respawn with
|
||||
`--resume <session_id> --model <new>` — cheap, since Claude persists
|
||||
sessions in `~/.claude/projects` anyway. That same resume path is the crash
|
||||
recovery story: a dead backend or a killed process loses nothing.
|
||||
sessions in `~/.claude/projects` anyway. That resume path is the recovery
|
||||
story for a process that has genuinely died; a backend restart no longer
|
||||
uses it, because the process is still there to be adopted (see below).
|
||||
**Resuming is only ever safe when nothing else has that session open.**
|
||||
- Images in: base64 image content blocks in the stream-json user message.
|
||||
- Working directory, host, and model are spawn-screen fields.
|
||||
|
||||
### Session processes outlive the backend (decided 2026-08-29)
|
||||
|
||||
A session's process is **left running when the backend stops, and adopted
|
||||
again when it starts.** Restarting the server — a rebuild, a service
|
||||
restart, a crash — must not end a turn somebody is waiting on, and a turn
|
||||
can easily be minutes long.
|
||||
|
||||
What this replaces: `shutdown_all` asked every driver to stop, then the
|
||||
process exited immediately. The SIGKILL escape hatch was a timer inside the
|
||||
runtime that died with it, so the stop was unreliable; whatever survived was
|
||||
orphaned with nothing written down to find it by. Processes leaked either
|
||||
way. The change is that they are now left on purpose and can be picked back
|
||||
up.
|
||||
|
||||
How it works, all inside the session directory beside the transcript:
|
||||
|
||||
- `process.json` — the pid, the kernel's **start time** for that pid, and
|
||||
how much of the output log has been read. The start time is what makes
|
||||
the pid an identity: pids are reused, and adopting a stranger's would mean
|
||||
never resuming the real conversation and signalling something unrelated.
|
||||
- `stdin.fifo` — opened **read-write** and inherited by the process, so it
|
||||
is its own last writer and never reads EOF when the server goes away.
|
||||
Closing stdin therefore stops being the graceful-exit signal; ending a
|
||||
process is a signal now, and only `Driver::stop` does it.
|
||||
- `stdout.log` / `stderr.log` — plain appended files, read from a byte
|
||||
offset. A fifo would fill its 64 KB buffer and block the process while
|
||||
nothing was draining it, which would stall the very turn the leak exists
|
||||
to protect. Measured: the CLI writes to a file unbuffered, so streaming is
|
||||
unaffected.
|
||||
|
||||
Two consequences worth stating:
|
||||
|
||||
- **`--resume` is reachable only when nothing is running.** This is the same
|
||||
rule as the import refusal below, and for the same reason: two CLIs on one
|
||||
session file duplicate the conversation into it and bill the second for
|
||||
re-reading all of it.
|
||||
- **Local only.** An ssh session's child sits behind a connection that dies
|
||||
with the server, so there is nothing to adopt; nothing is recorded for one
|
||||
and it takes the ordinary `--resume` path.
|
||||
|
||||
`Driver` therefore has two ways out rather than one: `detach` (the server is
|
||||
going away and means to come back) and `stop` (the session is being deleted,
|
||||
so the process must not survive). Every driver owes exactly one of them.
|
||||
|
||||
### Importing refuses a session that is already open (decided 2026-08-29)
|
||||
|
||||
Claude Code keeps a descriptor per live session at
|
||||
`~/.claude/sessions/<pid>.json` carrying the `sessionId` and a `procStart`
|
||||
— the same pid-plus-start-time identity used above. So "is this session
|
||||
open right now" is a **measurement**, not a heuristic, and the import list
|
||||
reports it as `no` / `yes` / `unknown`. Three answers because a machine that
|
||||
keeps no such record cannot answer, and "could not check" is not "nobody is
|
||||
using it".
|
||||
|
||||
`yes` is refused. This is not hypothetical: on 2026-08-29 an agent imported
|
||||
the session it was itself running in. Two `claude --resume` processes then
|
||||
edited one checkout and appended to one transcript, the whole 65 MB
|
||||
conversation — 154 embedded screenshots — was duplicated into the file under
|
||||
a new prompt id, and the adopted copy re-read all of it. It ended at the
|
||||
account's session limit.
|
||||
|
||||
### pi driver specifics
|
||||
|
||||
- Spawn: `pi --mode rpc --provider openai-generic --model <name>` (endpoint =
|
||||
|
||||
Reference in new issue
Block a user