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
@@ -23,12 +23,26 @@ pub type ImageRef = String;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Event {
|
||||
/// What the user sent, echoed into the transcript by the manager (not
|
||||
/// What the user sent, written into the transcript by the manager (not
|
||||
/// by drivers) so every device renders the full conversation from the
|
||||
/// one stream.
|
||||
/// one stream. Recorded when the session reads the message, which is
|
||||
/// what `MessageTaken` reports.
|
||||
UserMessage {
|
||||
text: String,
|
||||
},
|
||||
/// A driver has taken one of the user's messages and started reading
|
||||
/// it. The manager turns this into the `UserMessage` above, so it
|
||||
/// never reaches a phone itself.
|
||||
///
|
||||
/// It exists because sending and being read are not the same moment. A
|
||||
/// message sent into a running turn waits for that turn to finish, and
|
||||
/// until then the session has not seen it -- so recording it among
|
||||
/// things already read puts it in the transcript above output that
|
||||
/// predates it, and leaves a phone drawing it as still waiting with
|
||||
/// nothing coming to say otherwise.
|
||||
MessageTaken {
|
||||
text: String,
|
||||
},
|
||||
/// Streaming assistant text; the phone renders the concatenation as
|
||||
/// markdown.
|
||||
AssistantText {
|
||||
@@ -87,6 +101,16 @@ pub enum SessionStatus {
|
||||
AwaitingInput,
|
||||
Compacting,
|
||||
Exited,
|
||||
/// There is a process recorded for this session and the machine will
|
||||
/// not say whether it is still running.
|
||||
///
|
||||
/// Its own state rather than the nearest of the others, because both
|
||||
/// neighbours are lies with consequences: `Exited` invites starting a
|
||||
/// second process against a conversation that may already have one,
|
||||
/// and `Idle` claims a session is waiting for you when nobody has
|
||||
/// checked. It resolves itself -- the driver keeps asking -- so what
|
||||
/// it means to a reader is "wait", not "act".
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Where a driver reports events. Unbounded because producers are child
|
||||
@@ -101,6 +125,12 @@ pub type EventSink = mpsc::UnboundedSender<Event>;
|
||||
/// real dialects queue it for injection at the next tool boundary rather
|
||||
/// than the end of the turn.
|
||||
pub trait Driver: Send + Sync {
|
||||
/// Takes a message, now or once the session is free for it.
|
||||
///
|
||||
/// Every driver owes exactly one `MessageTaken` per message, at the
|
||||
/// moment it actually starts reading it: that event is what puts the
|
||||
/// message in the transcript, so a driver that never sends it drops
|
||||
/// the message from the conversation entirely.
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>);
|
||||
fn answer_question(&self, id: &str, answer: &str);
|
||||
/// Stop mid-run; the session survives.
|
||||
@@ -112,6 +142,23 @@ pub trait Driver: Send + Sync {
|
||||
fn set_permission_mode(&self, mode: &str);
|
||||
/// pi: native compaction; claude: `/compact`.
|
||||
fn compact(&self);
|
||||
/// Graceful process exit.
|
||||
fn shutdown(&self);
|
||||
/// Stop attending to the process but leave it running, because this
|
||||
/// server is going away and means to adopt it again when it comes
|
||||
/// back.
|
||||
///
|
||||
/// This is deliberately not a shutdown. A backend restart -- a
|
||||
/// rebuild, a service restart, a crash -- must not end a turn that is
|
||||
/// in flight, so a session's process outlives the server that started
|
||||
/// it and is found again through `session::process`. A driver with no
|
||||
/// process of its own has nothing to do here.
|
||||
///
|
||||
/// Its counterpart is [`Driver::stop`]. Every driver owes exactly one
|
||||
/// of the two on the way out, and which one is the difference between
|
||||
/// "back shortly" and "this conversation is over".
|
||||
fn detach(&self);
|
||||
/// End the process for good, because the session it belongs to is
|
||||
/// being deleted. The path out for everything [`detach`] preserves.
|
||||
///
|
||||
/// [`detach`]: Driver::detach
|
||||
fn stop(&self);
|
||||
}
|
||||
Reference in new issue
Block a user