Skip to content

fix(sessions): assign session output seq atomically - #88

Merged
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/session-output-seq-race
Jul 30, 2026
Merged

fix(sessions): assign session output seq atomically#88
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/session-output-seq-race

Conversation

@clawedassistant26

Copy link
Copy Markdown
Contributor

The bug

POST /api/sessions/:id/output picks the next seq in two statements:

const top = await get(`SELECT MAX(seq) AS seq FROM session_output WHERE session_id = ?`, [session.id]);
const seq = Number(top?.seq || 0) + 1;
await run(`INSERT INTO session_output (session_id,seq,chunk,created_at) VALUES (?,?,?,?)`, ...);

A CLI streams output as it arrives, so two output posts for one session are genuinely in flight at once. Against a network database (Turso) every statement is a round trip, so both reads land before either insert does and both chunks are written with the same seq. There is no unique constraint on (session_id, seq) to catch it, so it fails silently.

Why it matters

The mirror's resume contract is that the page tracks the last seq it rendered and reconnects with ?since=<seq>, and /sessions/:id/stream replays seq > since. So a chunk that shares the seq the browser already reported is skipped permanently. The operator is looking at a terminal that quietly dropped a line, with nothing to indicate output is missing.

sessions.test.mjs already pins this invariant for sequential posts — "seq must be monotonic per session". This makes it hold under the concurrency the endpoint actually sees.

Repro on unmodified main (cb55ab3)

Real sessionsRouter against a throwaway libsql file DB, no stubs, no network. Statements deferred to a macrotask so two handlers interleave the way they do against a remote DB — the same trick approvals-credits.test.mjs already uses, and for the same reason. Two concurrent posts, fresh socket each:

session_output rows:
  seq=1  chunk="AAA\n"
  seq=1  chunk="BBB\n"

seqs: [1,1]   distinct: 1 of 2   *** BUG ***

browser reconnects with ?since=1 -> replays 0 chunk(s): []
chunks written: 2, chunks a resuming browser can ever see: 0 -> 2 LOST

Patched: seqs: [1,2], and the resuming browser gets the chunk it had not seen.

The fix

Assign the seq inside the INSERT, so the read and the write are one statement and concurrent appends serialize on the write lock:

INSERT INTO session_output (session_id,seq,chunk,created_at)
SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ? FROM session_output WHERE session_id = ?
RETURNING seq

This is the discipline already used elsewhere in the app rather than a new pattern: reserve() in lib/credits.mjs puts its WHERE inside the insert, and the command claim in this same file uses the UPDATE as the lock. RETURNING gives the assigned seq back for publish() and the scrollback prune. An aggregate SELECT with no GROUP BY still yields one row on an empty table, so COALESCE makes the first chunk seq 1.

15 insertions / 4 deletions in sessions.mjs, 8 of them comment.

Deliberately not included

A UNIQUE (session_id, seq) index would make this a hard error rather than silent corruption, and I would normally add one. I left it out because v0.12.0 is already shipped: any deployment that has hit this race has duplicate rows, and the migration would fail on them. Doing it properly needs a dedupe-then-constrain migration that decides what to do with the colliding chunks, which is your call, not a drive-by. Happy to send that separately if you want it.

I also left SCROLLBACK pruning, the SSE fan-out and the command paths alone — they were correct and are covered as controls below.

Tests

New apps/pwa/test/sessions-output-seq.test.mjs, 8 tests, built on the approvals-credits.test.mjs harness.

Three are the bug:

  • two concurrent chunks get distinct seqs [1,2]
  • a browser resuming mid-race still reaches the other chunk (asserts the exact seq > since predicate /stream replays with)
  • a 5-chunk burst is numbered 1..5 with no gaps, repeats or dropped chunks

Five are controls that pass both with and without the fix, so the change is provably scoped: sequential posts still number in order; seq is still per-session not global; an empty chunk still writes no row and still returns 200 as a keep-alive; a 20050-char chunk is still capped at 20000; a foreign API key still gets 404 and writes nothing.

Fail-before verified by restoring pristine sessions.mjs via git checkout --: 3 fail / 5 pass unpatched, 8/8 patched.

Full suites green: apps/pwa 89 -> 97, root 283 -> 291, 0 failures.

A CLI streams output as it arrives, so two POST /api/sessions/:id/output
calls are in flight at once. The handler read MAX(seq) and then inserted
in a separate statement, so against a network database (Turso) both reads
land before either insert does and both chunks are written with the same
seq.

The mirror's resume contract is that the page remembers the last seq it
rendered and reconnects with ?since=<seq>, which replays `seq > since`.
A chunk sharing the seq the browser already reported is therefore skipped
permanently: the operator is looking at a terminal that silently dropped
a line, with no indication anything is missing.

Pick the seq inside the INSERT via SELECT ... RETURNING, the same
single-statement discipline credit reservations and the command claim
already use, so concurrent appends serialize on the write lock.

Co-Authored-By: Claude <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 7d6997c into moshcoder:main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants