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:
irisandClaude Opus 5 committed 2026-08-29 04:47:43 -04:00
1 parent 9791afcfd6
commit 362d436d4f
23 files changed
+1934 -345

No files matched your search

+102 -4
View File
@@ -14,7 +14,7 @@ use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::driver::Event;
use super::driver::{Event, SessionStatus};
/// One transcript line: an [`Event`] plus its position and time. The event
/// is flattened so the wire shape stays one flat object.
@@ -70,9 +70,6 @@ impl Transcript {
}
}
/// Replays every event with `seq > after`, oldest first. A missing file is
/// an empty transcript, not an error -- the session just hasn't produced an
/// event yet.
/// A window of the transcript ending just before `before`, newest-biased.
///
/// The screen opens on the end of a conversation, not the start of it, and
@@ -96,6 +93,50 @@ pub fn read_window(path: &Path, before: Option<u64>, limit: usize) -> Result<Vec
Ok(all)
}
/// How far behind a reconnecting subscriber can be and still be handed the
/// backlog one event at a time.
///
/// Past this it is served better by rebuilding its view from the newest
/// window than by receiving everything it missed. The events are the same
/// either way; what differs is that one arrives as a single window and the
/// other as thousands of frames a screen renders one by one. Set well
/// above a screenful (`transcript`'s page is 80) so an ordinary blip -- a
/// phone asleep, a tunnel reconnecting, a backend restart -- still streams
/// continuously, and only a genuine backlog changes mode.
pub const CATCH_UP_LIMIT: usize = 200;
/// What a subscriber asking for "everything after my cursor" gets back.
///
/// Two answers rather than one list, because they mean different things to
/// the screen holding the cursor: one continues what it already has, the
/// other replaces it. Collapsing them into a list would leave the client
/// splicing a window onto rows it has no way to know are no longer
/// adjacent to it -- a seam that looks exactly like ordinary output.
#[derive(Debug, Clone, PartialEq)]
pub enum CatchUp {
/// The events after the cursor, continuing what the subscriber holds.
Continue(Vec<SeqEvent>),
/// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the
/// newest window, replacing whatever it holds. Earlier history is
/// still there to be paged backwards through, exactly as it is when a
/// session is first opened.
Restart(Vec<SeqEvent>),
}
/// Everything after `after`, or the newest `limit` when that is more than
/// `limit` events.
pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result<CatchUp> {
let mut events = read_after(path, after)?;
if events.len() > limit {
events.drain(..events.len() - limit);
return Ok(CatchUp::Restart(events));
}
Ok(CatchUp::Continue(events))
}
/// Replays every event with `seq > after`, oldest first. A missing file is
/// an empty transcript, not an error -- the session just hasn't produced an
/// event yet.
pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
let file = match File::open(path) {
Ok(file) => file,
@@ -117,6 +158,27 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
Ok(events)
}
/// The state the session was last reported to be in.
///
/// Read from the transcript rather than assumed at launch, because a
/// server that has just restarted has been told nothing yet and the last
/// thing written down is the only thing it knows. Assuming idle claimed a
/// session was waiting for you when it had exited hours earlier, and would
/// now also claim it of one whose process is still mid-turn.
///
/// `None` for a transcript that has never carried a status, which is a new
/// session and genuinely has no prior state.
pub fn last_status(path: &Path) -> Option<SessionStatus> {
read_after(path, 0)
.ok()?
.into_iter()
.rev()
.find_map(|entry| match entry.event {
Event::Status { state } => Some(state),
_ => None,
})
}
fn last_seq(path: &Path) -> Result<u64> {
Ok(read_after(path, 0)?
.last()
@@ -169,6 +231,42 @@ mod tests {
assert_eq!(reopened.append(text("c"), 3.0).expect("append").seq, 3);
}
#[test]
fn a_short_backlog_continues_and_a_long_one_restarts() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("transcript.jsonl");
let mut transcript = Transcript::open(&path).expect("open");
for n in 0..10 {
transcript
.append(text(&n.to_string()), 0.0)
.expect("append");
}
// Within the limit the subscriber keeps what it has.
let CatchUp::Continue(events) = catch_up(&path, 7, 5).expect("catch up") else {
panic!("a backlog of 3 should continue");
};
assert_eq!(events.len(), 3);
assert_eq!(events[0].seq, 8);
// Past it, the newest window replaces what it has -- and it is the
// newest, not the oldest, that survives the trim.
let CatchUp::Restart(events) = catch_up(&path, 0, 5).expect("catch up") else {
panic!("a backlog of 10 should restart");
};
assert_eq!(events.len(), 5);
assert_eq!(events[0].seq, 6);
assert_eq!(events[4].seq, 10);
// Exactly at the limit is still a continuation: the boundary
// belongs to the cheaper answer, so a client is not reset for
// being one event behind the threshold.
assert!(matches!(
catch_up(&path, 5, 5).expect("catch up"),
CatchUp::Continue(_)
));
}
#[test]
fn a_missing_file_reads_as_empty() {
let dir = tempfile::tempdir().expect("tempdir");