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
@@ -43,7 +43,10 @@ repo is in PLAN.md's "Backend layout" section.
|
||||
crosses the tunnel is what a person reads, not what the model is given.
|
||||
An imported session then **keeps itself level with that file**, so work
|
||||
done at a terminal appears without anyone pressing anything. `--resume`
|
||||
appends to the same transcript rather than forking (measured), so the
|
||||
appends to the same transcript rather than forking — measured, but
|
||||
**against a session nothing else had open**; against a live one it
|
||||
duplicates the whole conversation into the file, which is why an open
|
||||
session cannot be imported at all (see below). So the
|
||||
only hard question is which new lines came from *here* — answered by
|
||||
counting the events this session has recorded, not by looking at its
|
||||
status. Status is the obvious signal and is wrong: a turn that starts and
|
||||
@@ -247,6 +250,40 @@ follows is only what that means for **this** project.
|
||||
`--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`, or
|
||||
`XDG_CONFIG_HOME=… XDG_DATA_HOME=…`.
|
||||
|
||||
## Sessions outlive the backend
|
||||
|
||||
Since 2026-08-29 a session's process is **deliberately left running when
|
||||
`ai-server` stops**, and adopted again when it starts — so restarting the
|
||||
backend does not end a turn. PLAN.md has the design; what matters day to
|
||||
day:
|
||||
|
||||
- **Stopping the server no longer stops the sessions.** After `pkill
|
||||
ai-server` the `claude` processes are still there, on purpose, and the
|
||||
next start picks them up (`reattaching to the claude-cli it left
|
||||
running` in the log). To actually end one, delete the session — that is
|
||||
the only path that stops a process.
|
||||
- **Each session directory now holds `process.json`, `stdin.fifo`,
|
||||
`stdout.log` and `stderr.log`.** `stdout.log` is the driver's input, read
|
||||
from the byte offset in `process.json`; removing either by hand while the
|
||||
session is live loses output or replays it.
|
||||
- **`--resume` only ever runs when nothing is running.** That check is the
|
||||
fix for the incident below, and the reason there is one entry point
|
||||
(`ClaudeDriver::launch`) rather than a spawn and an attach.
|
||||
- Local only: an ssh session's child dies with its connection, so it takes
|
||||
the ordinary `--resume` path.
|
||||
|
||||
**Never import a Claude Code session that is open in a terminal.** The app
|
||||
refuses it now — it reads `~/.claude/sessions/<pid>.json`, which Claude
|
||||
Code keeps for every live session, and checks the pid's start time so a
|
||||
descriptor left by a crashed CLI doesn't count. Refused rather than warned
|
||||
about, because on 2026-08-29 an agent imported the session it was *itself*
|
||||
running in. That put two `claude --resume` processes on one file: the whole
|
||||
65 MB conversation, 154 embedded screenshots included, was re-appended to
|
||||
the transcript under a new prompt id, both copies replayed each other's
|
||||
writes as work done elsewhere, and the adopted one was billed for re-reading
|
||||
all of it. It ended at the account's session limit, with three `claude`
|
||||
processes running against one checkout.
|
||||
|
||||
## Things that have bitten
|
||||
|
||||
Project-specific only — a lesson that would bite any project on this
|
||||
@@ -267,6 +304,15 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or
|
||||
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory`
|
||||
its preamble sniff, so it tries DER instead and fails at runtime with
|
||||
`ASN.1 ... DECODE_ERROR` — nowhere near the code that produced it.
|
||||
- **A reconnecting phone used to be sent the entire backlog.** The SSE
|
||||
stream replayed everything after the client's cursor, unbounded, while
|
||||
*opening* a session was bounded to a page — so a long disconnect
|
||||
delivered thousands of events one frame at a time. Past
|
||||
`CATCH_UP_LIMIT` the stream now sends a `reset` frame and the newest
|
||||
window instead, and the client rebuilds from it exactly as it does when
|
||||
the screen opens. The reset is not optional: without it the window is
|
||||
spliced onto rows that are no longer adjacent to it, which reads as
|
||||
ordinary output.
|
||||
- **ZXing only looks for a dark code on a light ground.** The enrollment
|
||||
QR is block characters in the terminal's foreground colour, so a
|
||||
dark-themed terminal renders it as a negative and the in-app scanner
|
||||
|
||||
Reference in new issue
Block a user