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
@@ -34,6 +34,24 @@ use super::transport::{Launch, Transport};
|
||||
/// line of it would otherwise cross a WireGuard link to a phone.
|
||||
const REPLAY_LINES: usize = 2000;
|
||||
|
||||
/// Whether a session is open in a CLI somewhere.
|
||||
///
|
||||
/// Three answers, because "nobody could check" is not "nobody is using
|
||||
/// it". Collapsing them would put the dangerous case behind the safe
|
||||
/// word, which is how the expensive version of this happens: an import
|
||||
/// that looks permitted, of a session that is being written to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InUse {
|
||||
/// Checked, and nothing is running it.
|
||||
No,
|
||||
/// Checked, and a live CLI has it open.
|
||||
Yes,
|
||||
/// The machine does not keep the record this is read from, so there is
|
||||
/// no answer to be had -- not an answer of "no".
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One Claude Code session found on a machine.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -54,6 +72,16 @@ pub struct Importable {
|
||||
/// reader knowing: a name is a claim about what a session *is*, and a
|
||||
/// last message is only the last thing that happened in it.
|
||||
pub named: bool,
|
||||
/// Whether a CLI is running this session right now.
|
||||
///
|
||||
/// The load-bearing field on this struct. Importing a session that is
|
||||
/// already open puts a second `--resume` on one file: the whole
|
||||
/// conversation gets duplicated into it, both copies then read each
|
||||
/// other's writes as work done elsewhere, and the adopted one is
|
||||
/// billed for re-reading everything -- measured on 2026-08-29 at 65 MB
|
||||
/// and 154 screenshots, from importing the session the importing agent
|
||||
/// was itself running in.
|
||||
pub in_use: InUse,
|
||||
/// Where it lives. Not serialized: the phone chooses by id and the
|
||||
/// server resolves the path, so a path never crosses the wire in
|
||||
/// either direction.
|
||||
@@ -69,7 +97,22 @@ pub struct Importable {
|
||||
/// `stat -c` is GNU-specific, which is fine for the machines here and is
|
||||
/// the thing to change first if this ever meets a BSD.
|
||||
pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||
// Two questions per file, both answered from the end of it.
|
||||
// Which sessions are open right now, before the files themselves.
|
||||
//
|
||||
// Claude Code writes a descriptor per live session at
|
||||
// `~/.claude/sessions/<pid>.json`, and the pid is the file name. It
|
||||
// also records `procStart` -- the kernel's start time for that pid --
|
||||
// for the same reason `session::process` does: a pid on its own is
|
||||
// reused, so a descriptor left behind by a CLI that crashed would
|
||||
// otherwise mark a session as open for as long as something else held
|
||||
// its number. Checking both is what makes this a measurement.
|
||||
//
|
||||
// The `LIVEKNOWN` line says the directory was there to be read at
|
||||
// all. Without it an old CLI that keeps no descriptors would look
|
||||
// exactly like a machine with nothing running, which is the one
|
||||
// mistake this check exists to prevent.
|
||||
//
|
||||
// Then two questions per file, both answered from the end of it.
|
||||
//
|
||||
// A rename, if there was one: `/rename` appends a `custom-title`
|
||||
// record, and a name somebody chose beats anything inferred from the
|
||||
@@ -93,6 +136,19 @@ pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||
// two. Excluding `tool_use_id` keeps both shapes of a real message
|
||||
// and drops the one that is not.
|
||||
let script = r#"
|
||||
if [ -d "$HOME/.claude/sessions" ]; then
|
||||
printf 'LIVEKNOWN\n'
|
||||
for s in "$HOME"/.claude/sessions/*.json; do
|
||||
[ -f "$s" ] || continue
|
||||
pid=${s##*/}; pid=${pid%.json}
|
||||
[ -d "/proc/$pid" ] || continue
|
||||
start=$(awk '{ n=index($0,") "); $0=substr($0,n+2); print $20 }' "/proc/$pid/stat" 2>/dev/null)
|
||||
[ -n "$start" ] || continue
|
||||
grep -q "\"procStart\":\"$start\"" "$s" || continue
|
||||
sid=$(grep -o '"sessionId":"[^"]*"' "$s" | head -1 | cut -d'"' -f4)
|
||||
[ -n "$sid" ] && printf 'LIVE\t%s\n' "$sid"
|
||||
done
|
||||
fi
|
||||
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" "$(wc -l < "$f")" "$f"
|
||||
@@ -104,7 +160,24 @@ done
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script.to_string()], None);
|
||||
let found = transport.capture(&launch).await?;
|
||||
|
||||
let mut live = std::collections::HashSet::new();
|
||||
let mut checkable = false;
|
||||
for line in found.lines() {
|
||||
if line.trim() == "LIVEKNOWN" {
|
||||
checkable = true;
|
||||
} else if let Some(id) = line.strip_prefix("LIVE\t") {
|
||||
live.insert(id.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut sessions: Vec<Importable> = found.lines().filter_map(parse_row).collect();
|
||||
for session in &mut sessions {
|
||||
session.in_use = match (checkable, live.contains(&session.id)) {
|
||||
(_, true) => InUse::Yes,
|
||||
(true, false) => InUse::No,
|
||||
(false, false) => InUse::Unknown,
|
||||
};
|
||||
}
|
||||
// Most recent first, and only that. Naming was tried as the first key
|
||||
// and is a worse list: it buries what somebody was just doing under
|
||||
// everything they ever named, and the reason to open this screen is
|
||||
@@ -151,6 +224,10 @@ fn parse_row(line: &str) -> Option<Importable> {
|
||||
|
||||
Some(Importable {
|
||||
id,
|
||||
// Filled in by `list`, which is the only thing that knows: it
|
||||
// takes one command to ask a machine, and asking per row would be
|
||||
// one ssh connection each.
|
||||
in_use: InUse::Unknown,
|
||||
cwd: cwd.unwrap_or_default(),
|
||||
// A name somebody typed outranks anything read out of the
|
||||
// conversation, because they chose it to answer this exact
|
||||
|
||||
Reference in new issue
Block a user