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
@@ -198,6 +198,14 @@ data class Importable(
|
||||
val lines: Int,
|
||||
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
|
||||
val named: Boolean,
|
||||
/**
|
||||
* Whether a Claude Code is running this session right now.
|
||||
*
|
||||
* "unknown" is a third answer and not a synonym for "no": the machine may keep no record of
|
||||
* what is running, and a session that cannot be checked is not a session that is free. The
|
||||
* server refuses an import of a "yes"; the row says so before you press it.
|
||||
*/
|
||||
val inUse: String,
|
||||
)
|
||||
|
||||
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
@@ -209,6 +217,9 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
title = session.optString("title"),
|
||||
modified = session.optDouble("modified", 0.0),
|
||||
lines = session.optInt("lines", 0),
|
||||
// Absent means an older backend that cannot answer, which is exactly what
|
||||
// "unknown" says -- so the default is the honest one rather than "no".
|
||||
inUse = session.optString("inUse", "unknown"),
|
||||
named = session.optBoolean("named", false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ import java.net.URL
|
||||
* of throwing, so a deliberate close doesn't surface as a connection error. The caller owns
|
||||
* reconnecting (with the last seq it saw as the new cursor) -- see SessionScreen.
|
||||
*/
|
||||
/**
|
||||
* The frame name the server uses to say a cursor was too far behind to continue from. Must match
|
||||
* `send_backlog` in the backend's routes.rs.
|
||||
*/
|
||||
private const val RESET_EVENT = "reset"
|
||||
|
||||
class EventStream(private val settings: ServerSettings, private val sessionId: String) {
|
||||
@Volatile private var connection: HttpURLConnection? = null
|
||||
@Volatile private var closed = false
|
||||
@@ -22,8 +28,15 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S
|
||||
connection?.disconnect()
|
||||
}
|
||||
|
||||
/** Streams events after [after] into [onEvent] until the stream drops. */
|
||||
fun run(after: Long, onEvent: (SeqEvent) -> Unit) {
|
||||
/**
|
||||
* Streams events after [after] into [onEvent] until the stream drops.
|
||||
*
|
||||
* [onReset] fires when the server answers that the cursor is too far behind to continue from:
|
||||
* everything already displayed is stale and the events that follow are a fresh window, so the
|
||||
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
|
||||
* arrives before those events, so a caller that clears on it stays in order.
|
||||
*/
|
||||
fun run(after: Long, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
|
||||
val connection =
|
||||
URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection()
|
||||
as HttpURLConnection
|
||||
@@ -43,22 +56,26 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S
|
||||
}
|
||||
|
||||
val reader = connection.inputStream.bufferedReader()
|
||||
// SSE framing: `data:` lines accumulate until a blank line ends
|
||||
// the event. `id:` (the seq) is also inside the JSON payload,
|
||||
// so only data lines matter; comment lines (keep-alives) start
|
||||
// with ':' and are skipped.
|
||||
// SSE framing: `data:` and `event:` lines accumulate until a
|
||||
// blank line ends the frame. `id:` (the seq) is also inside the
|
||||
// JSON payload, so it needs no separate handling; comment lines
|
||||
// (keep-alives) start with ':' and are skipped.
|
||||
val data = StringBuilder()
|
||||
var name: String? = null
|
||||
while (true) {
|
||||
val line = reader.readLine() ?: break
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (data.isNotEmpty()) {
|
||||
onEvent(parseSeqEvent(data.toString()))
|
||||
data.clear()
|
||||
}
|
||||
// A named frame carries no payload and a data frame
|
||||
// has no name, so this is one or the other.
|
||||
if (name == RESET_EVENT) onReset()
|
||||
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data.toString()))
|
||||
data.clear()
|
||||
name = null
|
||||
}
|
||||
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
||||
else -> {} // id:, event:, comments -- nothing to do
|
||||
line.startsWith("event:") -> name = line.removePrefix("event:").trim()
|
||||
else -> {} // id:, comments -- nothing to do
|
||||
}
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
|
||||
@@ -247,7 +247,11 @@ private fun ImportableList(
|
||||
items(state.value) { session ->
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable(
|
||||
enabled = importing == null
|
||||
// Disabled rather than hidden: a row that vanished would make
|
||||
// its own absence the signal, and the reader could not tell a
|
||||
// session in use from one that is not there. See `detailOf` for
|
||||
// what it says instead.
|
||||
enabled = importing == null && session.inUse != "yes"
|
||||
) {
|
||||
onPick(session)
|
||||
}
|
||||
@@ -304,6 +308,14 @@ private fun ImportableList(
|
||||
private fun detailOf(session: Importable, importing: String?): String =
|
||||
listOfNotNull(
|
||||
if (importing == session.id) "importing…" else null,
|
||||
// First, because it decides whether the rest of the row is worth reading. Words
|
||||
// rather than a colour: "open somewhere else" and "we could not check" are
|
||||
// different in kind, and nothing about a shade says which one this is.
|
||||
when (session.inUse) {
|
||||
"yes" -> "open in a terminal — close it there first"
|
||||
"unknown" -> "can't tell if it's open"
|
||||
else -> null
|
||||
},
|
||||
// Said, because a name and a last message are different claims: one describes the
|
||||
// session, the other is only what happened last in it.
|
||||
if (session.named) "named" else null,
|
||||
|
||||
@@ -263,6 +263,10 @@ fun StatusText(status: String) {
|
||||
"running" -> "running" to runningColor
|
||||
"compacting" -> "compacting" to runningColor
|
||||
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
// Said in words, because it differs in kind from the others rather than in degree:
|
||||
// the session is not idle and has not exited, nobody has been able to find out
|
||||
// which. A muted colour alone would read as one of the quiet states.
|
||||
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -130,11 +130,11 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
|
||||
it.copy(answer = event.answer)
|
||||
else it
|
||||
}
|
||||
is SessionEvent.Status -> items
|
||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message)
|
||||
is SessionEvent.Image -> items + TranscriptItem.ImageItem(event.ref)
|
||||
is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]")
|
||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||
is SessionEvent.Status,
|
||||
is SessionEvent.UsageDelta -> items
|
||||
}
|
||||
|
||||
@@ -182,12 +182,11 @@ fun SessionScreen(
|
||||
// Every transcript event loaded, in order, beside the rows they folded
|
||||
// into. See `apply`.
|
||||
var loaded by remember { mutableStateOf(listOf<SessionEvent>()) }
|
||||
// Messages sent into a turn that was already running. The backend
|
||||
// records them the moment they are sent, so they land in the
|
||||
// transcript at the time *we* spoke -- but the session has not read
|
||||
// them yet, and showing them in that position claims it has. They are
|
||||
// held out until the turn ends, which is the first moment anything
|
||||
// here can honestly say they were taken.
|
||||
// Sent, but not yet read by the session -- which is when the backend
|
||||
// records it and it comes back as a row. Until then it is drawn below
|
||||
// the working indicator, because that is where it is in the session's
|
||||
// reading of events: after everything taken in, not yet taken in
|
||||
// itself.
|
||||
var queued by remember { mutableStateOf(listOf<String>()) }
|
||||
val running = status == "running" || status == "compacting"
|
||||
var moreHistory by remember { mutableStateOf(true) }
|
||||
@@ -197,10 +196,25 @@ fun SessionScreen(
|
||||
|
||||
fun apply(entry: SeqEvent) {
|
||||
lastSeq.set(entry.seq)
|
||||
// The oldest event this view holds, which is what paging backwards
|
||||
// starts from. Maintained here rather than by each loader: the
|
||||
// first page and a stream reset both begin an empty view, and one
|
||||
// of them getting it wrong is a transcript that will not scroll up.
|
||||
if (oldestSeq == 0L) {
|
||||
oldestSeq = entry.seq
|
||||
moreHistory = entry.seq > 1L
|
||||
}
|
||||
when (val event = entry.event) {
|
||||
is SessionEvent.Status -> status = event.state
|
||||
is SessionEvent.UsageDelta -> totalTokens += event.tokens
|
||||
else -> {
|
||||
if (event is SessionEvent.Status) status = event.state
|
||||
// The message coming back is the session saying it has
|
||||
// read it, so the bubble held below the indicator becomes
|
||||
// the row `foldEvent` is about to add. Matched by text --
|
||||
// all that distinguishes one message from an identical
|
||||
// earlier one -- and only the first match, so two
|
||||
// identical messages wait twice.
|
||||
if (event is SessionEvent.UserMessage) queued = queued - event.text
|
||||
// Kept as well as folded. Folding is one-way -- a tool's
|
||||
// start and end become one row -- so a page arriving in
|
||||
// front of what is already here cannot be stitched on
|
||||
@@ -221,8 +235,6 @@ fun SessionScreen(
|
||||
try {
|
||||
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
|
||||
page.forEach { apply(it) }
|
||||
oldestSeq = page.firstOrNull()?.seq ?: 0L
|
||||
moreHistory = oldestSeq > 1L
|
||||
} catch (e: ApiException) {
|
||||
// Not fatal: the stream below still replays from zero, which is
|
||||
// slow but complete. Saying so beats silently showing nothing.
|
||||
@@ -238,7 +250,22 @@ fun SessionScreen(
|
||||
activeStream.set(stream)
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
stream.run(lastSeq.get()) { entry ->
|
||||
stream.run(
|
||||
after = lastSeq.get(),
|
||||
onReset = {
|
||||
// Too far behind to continue from: what is on
|
||||
// screen is a stale prefix of a conversation
|
||||
// that has moved on, and the window arriving
|
||||
// next is not adjacent to it. Dropping the rows
|
||||
// is what makes this the same as opening the
|
||||
// screen -- `apply` refills them, and scrolling
|
||||
// up pages the rest back in as it always does.
|
||||
items = listOf()
|
||||
loaded = listOf()
|
||||
oldestSeq = 0L
|
||||
moreHistory = true
|
||||
},
|
||||
) { entry ->
|
||||
apply(entry)
|
||||
streamError = null
|
||||
}
|
||||
@@ -305,9 +332,7 @@ fun SessionScreen(
|
||||
var earlier = listOf<TranscriptItem>()
|
||||
older.forEach { entry ->
|
||||
val event = entry.event
|
||||
if (
|
||||
event !is SessionEvent.Status && event !is SessionEvent.UsageDelta
|
||||
) {
|
||||
if (event !is SessionEvent.UsageDelta) {
|
||||
earlier = foldEvent(earlier, event)
|
||||
}
|
||||
}
|
||||
@@ -321,10 +346,8 @@ fun SessionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The turn ending is the moment a queued message stops being pending:
|
||||
// whatever it was going to be injected into is over, so it has been
|
||||
// read or it never will be, and either way it belongs in the
|
||||
// conversation where it happened.
|
||||
// A send that never came back cannot stay outstanding forever; the
|
||||
// turn ending is the latest moment it could still have been in flight.
|
||||
LaunchedEffect(running) { if (!running) queued = emptyList() }
|
||||
|
||||
LaunchedEffect(summary.setupName, summary.provider) {
|
||||
@@ -431,16 +454,6 @@ fun SessionScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Anything still queued is shown separately, below, so it must not
|
||||
// also appear in place. Matched by text from the end, since that is
|
||||
// all that distinguishes one message from an identical earlier one.
|
||||
val shown =
|
||||
if (queued.isEmpty()) items
|
||||
else {
|
||||
val outstanding = queued.toMutableList()
|
||||
items.filterNot { it is TranscriptItem.UserMsg && outstanding.remove(it.text) }
|
||||
}
|
||||
|
||||
// Laid out from the bottom, with the newest message at index 0.
|
||||
//
|
||||
// The obvious arrangement -- oldest first, then scroll to the end
|
||||
@@ -505,7 +518,7 @@ fun SessionScreen(
|
||||
}
|
||||
// Reversed to match the layout, so index 0 is the newest and
|
||||
// the reader still sees them in the order they happened.
|
||||
items(shown.asReversed()) { item ->
|
||||
items(items.asReversed()) { item ->
|
||||
when (item) {
|
||||
is TranscriptItem.UserMsg -> UserBubble(item.text)
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
|
||||
Reference in new issue
Block a user