You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(claude): read old command history without blocking on slow storage - #2616
This is the history-first step requested in #2610, cut down to the smallest move after the review. Only the bulk reads of server/claude-command-history.ts change. Everything else is main's code.
Reverted to main exactly:claude-subagents.ts, claude-task-scan.ts, file-tasks.ts and their tests. claude-task-state.ts, claude-task-history.ts and their tests are gone, along with the proof/revision/pending overlay and the task-scan retry. Also reverted: ci.yml, remote-bundles.yml and scripts/remote-bundle-smoke.ts. The remote bundle copies server/ whole, so it carries the new file without them.
Changed: the drain's scan and check read through transcriptReader (server/transcript-reader.ts) instead of calling readSync themselves. On network filesystems and on macOS and Windows, the reader reads through the thread pool. On local Linux filesystems it still reads synchronously inside the slice (below). The drain still runs one slice at a time, with a setImmediate between slices, except when a slice is stuck (below).
Local filesystems (the round after the review of c5103d0): a call through the thread pool costs more CPU than a read the page cache answers: a wake of the pool thread, a copy on another core and a wake of the event loop. So on Linux, a transcript whose directory is on ext2/3/4, btrfs, xfs, tmpfs, zfs, f2fs or bcachefs (statfs type, LOCAL) is opened, statted, read and closed on the event loop, inside the same lease, slices and turns.
FUSE (SSHFS, virtiofs), NFS, SMB, 9p (WSL's Windows drives), overlayfs and any type not listed keep the thread pool. So do macOS and Windows.
The statfs itself is async, since it can block on a hung NFS mount. It is cached for 60 s per directory, and a lease that has to look it up opens the transcript at the same time, so a network filesystem pays no extra round trips before its open.
Any call on the event loop that takes over 2 ms is taken as answered by the disk, not the page cache. The rest of that lease and that transcript's leases for the next second then go through the thread pool, with the read ahead. A 1 MiB read from the page cache takes about 0.2 ms here. The open and the close on the event loop are timed the same way, and a call that throws counts as slow too.
The lookup also takes the directory's device (stat beside the statfs), and the lease's first stat compares the transcript's own device with it before any read. A transcript on another device, such as a file bind-mounted from NFS or FUSE into a local directory, goes through the thread pool, and its later leases do too for 60 s.
The reader tells the drain whether the event loop ran while the lease's last call waited (waited()). A slice reading on the event loop counts from the start of its step and stops at its 4 ms, as main's does; reads through the thread pool keep the rules above.
No read ahead starts while reads are on the event loop. Every byte taken in is still read between two equal stats, from fstatSync on the same descriptor. Cancellation and the deadline are checked before and after every call.
Slices:
A slice ends after 4 ms of wall time from its start, as on main, so a large session on slow storage hands over after each read.
Parsing a chunk that arrives after the slice is up through the thread pool stops 4 ms after its bytes arrive, not 4 ms after the slice started. On main the read is inside the slice. On storage where one 1 MiB read takes longer than that (SSHFS), the parse stops after a single record and the next slice reads the same chunk again from there. A chunk read on the event loop gets no such extension, since the loop had no turn while it was read. A slice on the event loop counts from the start of its step, as main's does, so its open and first stats come out of its 4 ms.
When nobody else is waiting, the session keeps its transcript open into the next slice. Opening a transcript and checking it is the path it was let in for costs several round trips on remote storage. It lets go at the end of the slice in which nobody watches or waits for the history any more, another session that can have a slice is queued, another reader waits for the reader, the read starts over, or a watcher has a new end to hear. It also lets go once half the read's 10 s deadline has passed, counted from when the read was asked for. A request for the same session does not end it. In production both readers keep it: the subagent tracker watches the history while it needs it (watchCommandHistory), and the task scan waits for it (waitCommandHistory).
A turn inside a held lease waits for two setImmediates, not one. An immediate set while an I/O callback runs comes before the timers already due. With one, a slice that needs no read (the rest of a window, below) could follow the last slice before them: 44–51 event-loop stalls over 8 ms per dense read in profiling, against 0–1 with two.
Read-ahead and reuse (kept, re omo M1): they carry the thread-pool path that network filesystems take, which the fast path doesn't touch. Over SSHFS, without them the 276 MB sparse transcript took 9.55 s and the 95 MB dense one 9.2 s; with them, 5.1–5.6 s and 2.7–2.9 s.
A lease can start one read ahead into a second buffer, beside the operation it awaits. After a window is read, the next window, from that window's last whole line, is read while the stat after it is on its way and the window is scanned. A window() that asks for those bytes gets them with no second read. One that asks for other bytes lets the read ahead settle first. A lease has at most two native operations in flight, so the reader has at most four.
The check reads the next span ahead the same way, while it hashes the current one.
No read ahead starts when nobody listens, since the read then ends with that slice and a read ahead left over would hold up the lease's close. None starts when the window already reached the end, since there is nothing past it to read yet.
A slice that ends inside a window leaves that window's whole lines for the next slice. The next slice takes them in with no read and no stat, as long as no stat came since the one after they were read. On the dense transcript this cuts the reads from about three times the file to once.
Every byte taken in is still bracketed by two equal stats: a read ahead starts after a stat equal to checked, and reused bytes come from a read that did. A rewrite is still caught by the next stat or check.
A stuck transcript holds up only its own history (the review's HIGH):
A slice is taken as stuck when it has neither ended nor taken a turn for 50 ms (STALL_MS) while another session that can have a slice waits. The drain leaves it to end by itself and starts the next session's slice beside it.
Turns are counted per session. A held lease that takes its turns is reading, not stuck, and lets go at the end of its next turn, so healthy storage still runs one slice at a time.
At most the reader's capacity (2) of slices run at once, one per transcript. A held lease lets go only for a session that can have a slice, not for one whose transcript a stuck slice still reads.
A healthy lease's open and its checks on remote storage, several round trips, can outlast 50 ms too. The next slice then starts beside it.
Cancel on the last release (the review's first MEDIUM): resetCommandHistory() aborts the reads of every slice begun before it. The slice gives up at once, and the drain ends. What it cannot do: the native operation already started keeps its reader lease until it settles, since a syscall cannot be interrupted, and a new read of that transcript waits for that lease. commandHistorySettled() resolves once no slice runs and none waits to start. commandHistorySize().draining counts the slices left running too.
Guards the async reads need:
A stat is compared with the transcript as it was known when the stat was asked for. A stat older than one a status poll took in between is no news. A transcript cut short in place, even back to the size a poll saw, is read anew.
A chunk's offset, ends and checked range are published together, after the stat that follows the read. A request in between sees the read as it was.
A read that gives up on that stat (deadline or busy) keeps the chunks it already read and checked.
A session asked for again while its slice waits is queued once.
If the path names another file by the time the reader admits it, the read starts anew, as main's stat would.
Since fix(claude): recheck a same-size history rewrite within one timestamp tick #2620, a request that finds an end's line rewritten starts the history over (evidence) while a slice may be waiting for the disk. The slice drops the window or check it was reading then and reads again from the start. Without this, it took in a window read from the old position as the transcript's first bytes, until the next request or check corrected it.
Busy reads: a TranscriptBusyError (reader queue full, or a read past its 10 s deadline) backs off without using the failure budget (CodeRabbit thread on claude-command-history.ts:98). Other errors count as on main.
Reader: one shared in-process reader, for this history now and the image path later. It uses plain stats, the same form lstatSync gives, so its versions compare with historyVersion. window() reads into one of two 1 MiB buffers per lease, taken from a pool of the buffers of leases that ended. A buffer goes back to the pool only once every native read into it has settled, a read ahead included. The deadline timer does not keep the process alive.
Known limits
Not fixed here; each is written in the code's comments:
A local disk that stops answering inside a call on the event loop blocks the loop, as main's readSync does. The fallback notices a slow call only once it returns. Network filesystems can't hit this, since they never take the fast path.
A network file bind-mounted into a local directory is opened and statted once on the event loop before its device gives it away, once per 60 s for that path. The open is timed, so a slow one also sends it to the thread pool.
macOS and Windows have no fast path, so their local reads keep the thread pool's CPU cost. On macOS, statfs gives no fixed type for APFS; comparing the transcript directory's type with that of / should work, but I haven't tried it on a Mac, so it's left for a follow-up.
A native operation that never settles keeps its lease. Two of them, on two paths, keep both of the reader's leases, and every later read waits out its 10 s deadline. A read ahead counts as one.
A cancelled lease keeps its path until its operations settle. A new read of that transcript waits behind it.
Transcript identities use number stats (dev:ino, as lstatSync gives them). On a filesystem whose numbers pass 2^53, two files may share an identity.
A transcript whose reads keep running into the 10 s limit is tried again every second while it is watched, with no longer backoff. A busy reader is no failure of the transcript.
Tests
Every assertion from main's claude-command-history.test.ts is unchanged: against main, that file changes only its imports. At the review's request (the second MEDIUM), the assertions on lease and turn counts in tests this PR added earlier now assert outcomes:
Test
Asserted
Asserts now
keeps a transcript open across turns while no other session waits
1 lease
each open costs 100 ms of a controlled clock, and the read took less than its reads plus two opens
lets go of a transcript it keeps open at the end of the turn another session is asked for in
small answered at read 3; 2 leases
small answered with its end while the large one still had reads to go
lets go of a transcript it keeps open well before the read's deadline
2 leases
the clock moves 5 s at read 2 and 5 s more at read 4; the read still comes back complete in one wait (a lease held past half its deadline would run out of time and give up)
gives each turn of a transcript it keeps open a slice of its own
fewer setImmediate calls than reads / 2
each turn's held time on a controlled clock: at most 5 ms, and every turn but the last at least 2 ms
reads on in the same lease when the session it reads is asked for again
1 lease at each read
at its last read, the clock shows its reads plus exactly one open
reads one slice for a request nobody watches or waits for
polled up to 1000 event-loop turns; 1 read, 1 lease
waits on commandHistorySettled(); read on some way but not through, nothing left queued, draining or timed
reads on in one lease for a tracker's watch after it hears the first end
2 leases
the watcher hears the first end before the read is through
lets go of a transcript it keeps open at the end of the turn it started over in
heard before read 8
heard while the restarted read is not through
counts the time it keeps a transcript open from when its read was asked for
2 leases
the clock moves 6 s before the lease opens and 5 s more at read 3; the read still comes back complete
Merging main with #2620 changed two of this PR's own tests (none of #2620's, none of main's), because of #2620's rules:
"lets go of a transcript it keeps open at the end of the turn it started over in" rewrote the first record. Under fix(claude): recheck a same-size history rewrite within one timestamp tick #2620 the next request's evidence finds that rewrite first and starts over before any slice, so the turn-break went untested. It now rewrites the record after the end's line, so only a check finds it, and its ends assertion is ["a"], not ["b"]. Dropping || restarted from the turn-break still fails it.
Every other assertion of those tests, and of every other test, is unchanged.
This round also moves the tests off real time (Terra LOW):
The reader's event-loop tests run on a clock only they move (stillClock()), so a preempted read can't cross the 2 ms bar. 48 parallel runs of transcript-reader.test.ts beside 64 CPU spinners: 42 of 48 passed on 93f74fb, 48 of 48 now (also 48 of 48 with 16 spinners).
The test helpers that stand in for slow or held storage (slowReads, holdWindows) now say their reads waited off the event loop, and the one that answers at once says they didn't, so the tests behave the same whatever filesystem the host's /tmp is on.
"reads another transcript while one transcript's read is stuck on its disk" and "starts no more slices beside stuck ones than the reader has room for" used a real 150 ms wait. They now move a fake clock past the 50 ms stall instead.
The two reader tests that spun on Date.now() past a deadline now set the system time inside the callback, or while the close is pending.
"reads on by whole chunks when every read outlasts a slice" did not catch its fix: with the parse stop counted from the slice start it still passed, at c5103d0 too, because reusing a window's rest kept its read count in bounds. It now runs on a clock that moves only with the reads, and also asserts that the ends known each time the event loop runs grow by more than 100 at a time (a chunk holds about 450), not a record at a time. With the fix undone it fails.
"reads no other session beside a transcript it keeps open that takes its turns, however long it reads" no longer intercepts every 50 ms timer. The small session now asks between two turns of the large one, on a fake clock. With the old timing it passed with its fix undone, at c5103d0 too; it fails now.
Each test below fails with its fix undone; I undid each fix and ran the files:
Test
Fails when
backs off a read the reader is too busy for…
a busy read counts as a failure
keeps the chunks it read and checked when a read gives up on its stat…
offset and ends are published before the stat
queues a session asked for while its slice reads only once
the requeue guard is removed
keeps what it read when a slice's stat is older than one a poll took…
an older stat discards the read
starts over on a transcript cut short in place while a slice reads it
a stat matching the poll's version is accepted though the transcript shrank
reads on by whole chunks when every read outlasts a slice
the parse stop counts from the slice start
lets a small session have its turn after each slow read of a large one
the slice counts only CPU time, or the queue is not checked
never keeps the process alive for a read's deadline alone
the deadline timer is not unref'd
keeps a transcript open across turns while no other session waits
each slice ends the lease
lets go of a transcript it keeps open at the end of the turn another session is asked for in
the queue is not checked
lets go of a transcript it keeps open at the end of the turn another reader waits for it in
the reader's waiting count is not checked
lets go of a transcript it keeps open at the end of a turn that told a watcher of an end
a new end for a watcher is not checked, or a waiter counts as a watcher
lets go of a transcript it keeps open well before the read's deadline
the half-deadline is not checked
reads on in the same lease when the session it reads is asked for again
any queued session ends the lease, or each slice ends it
gives each turn of a transcript it keeps open a slice of its own
the slice is not renewed after a turn, or there is no turn
reads one slice for a request nobody watches or waits for
a read with no listener is kept open
reads on in one lease for a tracker's watch after it hears the first end
a new end for a watcher does not end the lease
lets go of a transcript it keeps open at the end of the turn it started over in
a start over does not end the lease
counts the time it keeps a transcript open from when its read was asked for
the half-deadline counts from when the lease opened, or is not checked
takes in the rest of a window a slice ended in without reading it again
the rest of a window is read again
reads another transcript while one transcript's read is stuck on its disk
the drain waits for a stuck slice
reads no other session beside a transcript it keeps open that takes its turns, however long it reads
a turn does not count as progress
starts no more slices beside stuck ones than the reader has room for
both capacity checks are removed, the drain's and next()'s (either alone holds the bound)
gives up a read stuck in flight once the last server lets go
a reset does not abort the slice's read
reads ahead while the lease awaits a stat, and hands the bytes to the window that asks for them
the buffers are not swapped, so the next read ahead overwrites the window just returned
lets a read ahead settle before a window that asks for other bytes
that window does not wait for the read ahead
closes a lease and gives its buffers to another only once a read ahead nobody asked for settled
the lease does not track its read ahead
ends a cancelled lease's read ahead without an unhandled rejection
the lease does not track its read ahead
drops a window read across a start over that a request made while the window waited for the disk
the slice takes in a window read across a start over
reader: reads a transcript on a local filesystem on the event loop, and one on any other through the thread pool (btrfs, ext4, tmpfs; NFS, FUSE, 9p)
every filesystem counts as local, or there is no fast path
reader: reads through the thread pool for a second once a read on the event loop was slow, then tries the event loop again
a slow call doesn't switch the lease, or the transcript's second isn't checked
reader: gives up a read on the event loop between windows once cancelled
the cancel checks around a sync call are removed
reader: opens a transcript while its filesystem is looked up, and reads it on the event loop once that says local
the open waits for the lookup, or the lease stays on the thread pool after it
reads another transcript while one on a network filesystem is stuck in its native read
every filesystem counts as local, so the stuck read blocks the event loop; or the drain waits for a stuck slice
holds the event loop no longer than a slice and the call that ended it when it reads on the event loop
a window read on the event loop after the slice's end gets a slice of its own, or the first slice counts from when the work starts instead of from the step's start, or the reader says every call waited
reader: reads through the thread pool for a second once an open on the event loop was slow or threw
a slow open or one that throws isn't counted, or the lease whose open was slow reads on the event loop
reader: reads through the thread pool for a second once a close on the event loop was slow or threw
a slow close or one that throws isn't counted
reader: reads a transcript on another device than its local directory through the thread pool
there is no device comparison, or a mismatch doesn't mark the path
reader: reads through the thread pool for a second once a read on the event loop threw
a call that throws isn't counted
reader: tells the work whether the event loop ran while the lease waited
a queued lease counts as not having waited, a call on the event loop doesn't clear it, or a call through the thread pool doesn't set it
Three tests have no fix to undo:
"lets the event loop run while a slice waits for the disk" checks that the drain yields while a read is held. Main's sync read cannot be held.
"starts over on a transcript rewritten and grown while it reads a window ahead" guards the read-ahead against a rewrite. Main reads no window ahead, and passes it too.
Removing the read ahead's own .catch does not fail "ends a cancelled lease's read ahead…": the lease's operation bookkeeping also handles that rejection.
Four lines have no unit test:
The 2 ms bar's value. The tests inject slow calls far past it; where it sits between a page-cache read (about 0.2 ms per MiB) and a cold NVMe read (1.9 ms typical) is a measurement, below.
A chunk that arrives within a slice is parsed until that slice ends, not for 4 ms from its arrival. It bounds event-loop delay, and none of the tests' clocks shows a difference. Without it, event-loop p95 on SSHFS rose to 14 ms in the profiling run.
The second immediate of a turn. It orders the turn after the timers already due, which no test clock shows. Profiling measured it (see Slices).
A check pass whose check a start over cleared returns at once. Without it, the pass ends on the emptied spans and marks offset 0 checked, which the next scan's start overwrites before any end can be told, so no test can see it. It is there so check and scan treat a start over alike.
One of #2620's tests sees less here: "shares one recheck within a version's tick among the requests that wait…" counts fs.readSync, which on this branch is only vouchLines; the drain reads through the reader. Its assertion holds. A probe wrapping the reader's window() around the same five waits found no drain reads, as on main.
The inherited same-millisecond matching issue is out of scope.
Possible follow-up, not in this PR: waitCommandHistory and commandHistory still call lstatSync through historyVersion, as on main. On SSHFS that holds the event loop about 100 ms per call. #2620's vouchLines is the same kind of read: synchronous, up to 4 MiB per call, run whenever the history is not vouched, which since #2620 includes any transcript checked within 3 s of its mtime. Moving either off the request path is a change to main's synchronous API.
Validation
Toolchain: Bun 1.4.2, Node 22.23.4, herdr 0.9.3. The head is 7edf4df: c5103d0 (which merged main 05c9133) plus the test rework, the local-filesystem fast path, the changelog, opening during the filesystem lookup, the slice clock on the event loop, two commits strengthening one test, the reader tests' clock, and timing the open and close with the device check. The full benchmark below ran on b9b2acd; a local run on 7edf4df is after it.
bun run check fast on 7edf4df: 10755 passed, 0 failed, 41 skipped in 663 files; workflow syntax, generated types and themes, typecheck and build all ok. It includes fix(claude): reconcile background commands after cold history reads #2608's tests (claude-subagents, claude-task-scan) and file-view and file-pool, which use file-tasks.ts.
server/transcript-reader.test.ts and server/claude-command-history.test.ts: 82 passed, 0 failed. The history tests, 8 copies at once, 3 rounds: 24 of 24 passed.
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.excludesFile GIT_CONFIG_VALUE_0=/dev/null bun run check run bun test server/claude-session.contract.test.ts server/api.contract.test.ts: 207 passed, 0 failed, on an isolated herdr 0.9.3.
git diff upstream/main -- server/claude-subagents.ts server/claude-task-scan.ts server/file-tasks.ts server/claude-subagents.test.ts server/claude-task-scan.test.ts .github scripts is empty against 05c9133.
The review's cases ran as synthetic scripts against main and c5103d0 with the same output on both (listed in the earlier round). The review's repro, a held read of A while B is asked for, is the test "reads another transcript while one transcript's read is stuck on its disk", and its network-filesystem twin covers the stuck native read.
Measured with the same synthetic benchmark as before (gist below): b9b2acd against main 05c9133, interleaved, median [min–max]. The host also runs a VM using about 8.5 cores, so load was 9–13 and every sample is flagged; treat local differences under ~20% as noise, and CPU as the steadier figure.
Local disk, 5 runs each. Ratios are this branch over main per interleaved pair, so <1 is faster:
main
this branch
ratio
276 MB sparse, page cache warm
65 ms [57–70]
74 ms [66–79]
1.15 [0.96–1.34]
276 MB sparse, cold
158 ms [154–189]
164 ms [146–203]
1.02 [0.85–1.28]
95 MB dense, warm
1027 ms [961–1166]
949 ms [915–1124]
0.94 [0.89–0.96]
95 MB dense, cold
854 ms [759–994]
819 ms [735–854]
0.93 [0.86–1.03]
CPU (user + sys), sparse warm
64 ms
90 ms
1.25 [1.06–1.40]
CPU, sparse cold / dense warm
157 / 1172 ms
190 / 1104 ms
1.22 / 0.96
longest event-loop gap, sparse warm / dense warm
4.7 / 7.3 ms
4.7 / 7.3 ms
other requests during the sparse read, p95
4.1 ms
4.1 ms
1 MB session, warm / cold
5 / 5 ms
8 / 7 ms
1.60 / 1.63
Sparse warm CPU is the figure the last review blocked on: 78 ms on main against 165 on c5103d0 in your run. Three full runs across this round's commits on this host put it at 1.16, 1.10 and 1.25 times main's (fd22a07, b994f29, b9b2acd), against 1.48 for c5103d0 in the same harness here. The branch's own sparse warm CPU held at 77–93 ms across those runs; main's moved between 64 and 88, which is most of the spread in the ratio. What remains is mostly JIT time for the extra code paths: in a profile of the second read in one process, main-thread work matched main's within 2 ms, and the pool threads did none. Sparse warm read time overlaps main's in every run (1.02–1.15).
Cold sparse costs ~20% more CPU than main: on a cold read the first slow call (over 2 ms) moves the rest of the lease to the thread pool, which is what keeps a local disk that stalls from blocking the event loop for more than one call.
The longest event-loop gap now matches main's. On the fast path, slices take only what fits in their 4 ms, as main's do. Loop-gap max for the 1 MB session reads 4.6–4.8 ms against main's ~1 ms, but that is how the harness samples, not a longer block. The sampler records a gap only when its 2 ms tick fires before the read resolves, so a read that ends on sync work never has its last gap counted. Main's small read usually ends inside its first gap. One main run that got a second tick recorded 5.4 ms. A probe that also counts the last gap found the longest block at 4.3–5.1 ms on main and 3.3–5.4 ms here: parsing the 1 MiB window inside one slice.
Local disk on 7edf4df against main 05c9133, 5 runs each: sparse warm 61 → 67 ms (ratio 1.15 [1.04–1.62]), CPU ratio 1.22 [1.13–1.68]; dense warm 0.95, dense cold 0.86; longest event-loop gap, sparse warm 4.6 / 4.7 ms and dense warm 6.6 / 6.4 ms. Sparse cold CPU came out 1.39 [1.07–1.76] in this run (1.22 on b9b2acd); a cache miss now also stats the directory beside its statfs, and every bench read is a fresh process, so each pays it once. I haven't separated that from load; your spot-check on a quiet host will show it better.
Over SSHFS: sshfs 3.7.6 with cache=no,direct_io,no_readahead, 14 ms round trip, 3 runs each. These are unchanged from c5103d0; network filesystems never take the fast path. An interleaved SSHFS run of c5103d0 against b9b2acd's parent gave ratios of 1.00 and 1.02 for the small-session rows.
main
this branch
276 MB sparse, total
61.6 s [61.5–61.7]
5.18 s [5.14–5.20]
other requests during it, p95 / max
155 / 275–345 ms
1.0–1.8 / 101–103 ms
longest event-loop stall
177–247 ms
101–102 ms
95 MB dense, total
not done after 300 s, 3 of 3 (1.0 MB read)
2.58 s [2.54–2.71]
other requests during it, p95 / max
153 / 274–276 ms
33–42 / 100–102 ms
1 MB session alone
7.75 s
0.49 s
1 MB session while the 276 MB one reads
15.3 s
0.47 s
1 MB session while the 95 MB one reads
15.4 s
0.48 s
CPU, sparse (user + sys)
0.36 s
0.31 s
Main sets the slice's 4 ms clock before its blocking read, and one 1 MiB read over SSHFS takes ~110 ms, so each slice parses about one record and the next slice reads the same chunk again. This branch's single ~100 ms stall per read fits one round trip of the synchronous lstatSync in historyVersion (see the follow-up).
System calls on the transcript, page cache warm, under strace:
A small session's extra ~3 ms is once per process: starting Bun's thread pool for the first statfs and open of a directory, and the first run of the reader's code paths. After that, on a local filesystem, a read makes no thread-pool round trip, and compiled it is within noise of main's (2.3 against 2.1 ms in the profile).
Not run locally: the integration and browser suites, SSH, native Windows and macOS. Upstream CI covers them.
Optional follow-up for the owner: adding server/transcript-reader.test.ts and server/claude-command-history.test.ts to the Native Windows and remote Windows test lists. That is a workflow change, so it is left out until approved.
All fixtures are synthetic files in temporary directories. No live herdr session or real transcript was used.
Independent agent review covered the frozen implementation separately from its author; it was not a CodeRabbit review. All concrete findings were fixed before this push:
Resource ownership: cancellation/deadlines retain physical capacity and descriptors until native settlement/close; a queued hot path cannot consume the spare lane needed by a healthy path. Caller-local deadlines do not poison shared storage backoff.
Task authority: reparse local state after the history wait, check the actual current version, and vouch partial/changed-version terminal lines before applying them. An unverified timeout candidate cannot close a shell, including after structured cloning. The former single-snapshot implementation fails the new freshness regression.
Tracker lifecycle: stopped/replaced trackers abort owned reads; process-start discovery reclassifies old launches; historical completion reuse requires provenance and the exact verified parent version. Changed versions use local pending/running state until proof, rather than retaining unchecked terminals after a rewrite.
Continuation and precision: a superseded current proof triggers one fresh poll without a stable-error retry loop; releasing/reacquiring a requeued history watch cannot wedge the scan; exact-millisecond clocks avoid bigint conversion double-rounding. The queue regression fails when the old queue flag is restored.
The final semantic review independently passed all 225 focused tests and the external process-start, parent replacement/truncation and rewrite-plus-growth reproductions. The final resource/platform delta review passed 58 focused tests. Root validation results are in the PR description. Windows execution remains an upstream CI gate; native shared-pool and stuck-I/O limitations are explicit there.
Auto incremental reviews are disabled on this repository.
Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.
You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.
Use the checkbox below for a quick retry:
Trigger review
Summary
Summary by CodeRabbit
Bug Fixes
Improved background command and task history accuracy when transcripts change, reducing the chance of applying stale completion results.
History checks now handle delayed reads more reliably and avoid letting one cancelled request disrupt other readers.
Task status is better maintained when sessions or transcript files are replaced, truncated, or updated during a scan.
The change adds a bounded asynchronous transcript reader and uses it for command-history scanning and verification. Claude task snapshots are folded with version-checked history evidence, and task tracking and scan paths are updated to use the asynchronous flow.
Adds a shared reader for bounded positioned reads and metadata lookup. It supports cancellation, deadlines, queue limits, file-version checks, and reset and lifecycle queries.
Moves history scanning and line verification to asynchronous reader operations. Adds bounded retries, abort and deadline handling, and unverified candidates when a wait expires. The bundle smoke check exercises positional reads and command-history results.
Task folding and history proofs server/claude-task-state.ts, server/claude-task-state.test.ts, server/claude-task-history.ts, server/claude-task-history.test.ts
Adds task snapshot and folded-state types. Matching command records and verified history determine task outcomes; task-history checks reject evidence that does not match the current parent transcript.
Updates subagent tracking, task scans, and file tasks to use snapshots and asynchronous history reads. Tests cover pending reads and transcript or session changes. Windows test jobs add the reader and history tests.
devswha/ovis#2608: Introduced Claude command-history evidence used by subagent tracking and task scans, which this change updates to asynchronous reads.
devswha/ovis#2511: Moved Claude task-list scans to the file-task worker; this change updates the same scan and file-task data flow.
Suggested reviewers:devswha
Merge Risk: Moderate · previous review 4dd31
Under sustained reader saturation or slow storage, background-command history can give up permanently, so finished commands may keep showing as running. Stop counting busy or timeout rejections toward the failure limit before merging.
Pre-merge checks: 4 · 1
Failed checks: 1 warning
Check name
Status
Explanation
Resolution
Docstring Coverage
Docstring coverage is 46.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta…
Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed checks: 4 passed
Check name
Status
Explanation
Linked Issues check
Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check
Check skipped because no linked issues were found for this pull request.
Title check
The title clearly identifies the primary change: preventing slow storage from blocking Claude command-history reads.
Description check
The pull request description includes the required Change and Validation sections. It explains the problem and resulting behavior, links the related issue, lists validation commands with results, iden…
Full details: Description check
Explanation
The pull request description includes the required Change and Validation sections. It explains the problem and resulting behavior, links the related issue, lists validation commands with results, identifies skipped suites, and provides relevant performance and platform coverage details.
Full details: Docstring Coverage
Explanation
Docstring coverage is 46.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 28 functions across 15 files. (3 skipped: 3 unsupported.)
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
Finishing Touches
Generate unit tests (beta)
Create a new PR
Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.
Thanks @duytbp for splitting this out as planned. Review (omo + Codex Terra xhigh) found that this step does change behaviour, so it can't merge yet. Since you're actively iterating on this, we'll leave the fixes to you on this branch.
HIGH (reproduced against main): an ended command flips back to running after an ordinary write.
Setup: an old subagent command whose completion notice sits before the 16 MiB cold window. Wait until the tracker shows the pane at 0 running, then append one ordinary line to the parent transcript and poll.
origin/main: the count stays 0 on every poll.
This branch: the count goes back to 1 and the pane's change callback fires 1, 0, 1, 0, ... as writes continue.
Cause: the cached proof and pending outcomes are dropped on any size/mtime change (server/claude-subagents.ts:1589-1607, server/claude-task-state.ts:79-86). Main re-checks the known ends against the grown file instead.
The branch's own test "checks changed-generation shell status..." in claude-subagents.test.ts asserts countOf("p1") is 1 ("changed bytes have no historical authority until vouched"); on main it is 0. That test encodes the regression.
Inferred, not reproduced: on the slow storage this targets, a session that appends faster than one proof completes would keep finished commands "running" for as long as it is active.
MEDIUM
The task list can call a finished command running: with two appends during a scan, claude-task-scan.ts:39-49 answers without history, and claude-task-scan.test.ts:295-309 (mutations=2) expects command B to be "running". On main it is completed.
Existing assertions were weakened or moved: "drops every read and timer once the last server lets go" lost waiting: 1, and "counts an ended command once its history is read despite a skipped record" now asserts a separate historicalState() path instead of the tracker that serves panes.
eventual() in the tests polls on setImmediate for up to 2 s. Please wait on the actual change event (with a bounded timeout) instead.
Scope: for a no-behaviour-change step this adds a lot of new tracker state (revision/proof/read, a pending overlay, per-path backoff, a snapshot/fold split, a task-scan retry protocol); the two regressions above come from it. The smaller the move, the easier it is to prove nothing changed.
Inherited from main, noted for completeness (Terra): an end at the launch's millisecond with a matching Bash call id still closes the launch even when its row comes before the launch.
Cost (file already in the page cache, so no slow-disk benefit shows): total read time on a 276 MB sparse history was 61-64 ms on main vs 132-178 ms here, and about 1.2 s vs 1.3-1.4 s on a 95 MB dense one; the longest event-loop stall was about equal.
Thanks for the concrete reproductions. Agreed: the pending/running transition is a behavior regression, including its effect on #2069. I will fix publication/reconciliation so ordinary appends preserve the proven result, retain valid completion evidence through task-list appends, restore the original tracker/serving-path assertions, and replace the new polling helpers with bounded waits on actual events. The inherited same-millisecond issue stays outside this step.
Both Windows failures are the same new fixture's attempt to replace an open destination. The actual bundled async-reader/history smoke passed. A reviewed test-only correction preserves native positional/buffer and replacement checks, with deterministic mid-lease identity-change and rejected-admission coverage; it adds no skip or retry. I will include it with the semantic fixes in one consolidated push.
I will also run the matching synthetic SSHFS comparison and report total time and other-request latency alongside local measurements. The existing harness now has a separately frozen, minimal adapter that awaits history evidence on both sources; prior measurements remain unchanged. No image implementation changes have started, and this PR remains pending the behavior and measured-benefit gates you listed.
Revert the tracker, task-scan and task-state changes to main and keep main's
synchronous command-history API (historyVersion, commandHistory, evidence,
vouchedEnds). Only the drain's scan and check reads go through the shared
TranscriptReader, still working a 4 ms slice per lease and one session at a
time. A TranscriptBusyError backs off without using the failure budget, a
slice whose stat is older than one a poll took meanwhile no longer starts the
read over, and a session asked for while its slice waits is queued once.
The reader takes plain stats (the form lstatSync gives, so its versions compare
with historyVersion), drops the unused metadata-only lease, and adds pooled
lease windows so a scan allocates no 1 MiB buffer per read. The workflow and
remote-bundle smoke additions are reverted: the bundle copies server/ whole.
A slice's 4 ms budget started before its read, so on storage where one
1 MiB read outlasts it (SSHFS) the parse stopped after one record and each
read moved the scan on by one record. Each chunk now gets a whole slice
from when its bytes come, and a lease ends once the work on what it read
(parse and digests, not the waits for the disk) reaches the slice. scan is
synchronous again, taking the bytes read.
Review of the CPU-only slice:
- A slice ends 4 ms of wall time after it starts, as on main, so a
small session gets its turn after each slow read of a large one.
Parsing a chunk still stops 4 ms after its bytes arrive.
- A stat is compared with the transcript as known when it was asked
for: an older stat than a poll's is no news, but a transcript cut
short in place is read anew.
- A chunk's offset, ends and checked range are published together after
the stat that follows its read, and a read that gives up on that stat
keeps what it already checked.
- The queue-once test gates the read instead of timing it.
- The reader's deadline timer is unref'd and reset() empties the buffer
pool.
- The bun:test imports are merged and the changelog is reworded.
On remote storage such as an SSHFS home, every slice of a long
transcript's history opened the file again and checked it was the one
asked for, several round trips each time. When no other session or
reader waits, the read now keeps the transcript open into its next
slice, giving the event loop a turn in between. It lets go at the end
of the slice in which another session is queued, another reader waits,
or a watcher has a new end to hear, and once half the read's deadline
has passed.
…y read on
Ends a held lease at the end of a turn when no caller watches or waits for
the history, or the read started over, and counts the half-deadline from when
the read was asked for. Drops a clause in seen() that never changed the
result, and narrows the changelog to what this step does.
duytbp
changed the title
fix(claude): move command history to bounded async reads
fix(claude): read old command history without blocking on slow storage
Nov 2, 2026
@devswha Sorry about the previous push. It wasn't the small step we agreed on. It added tracker state that changed behaviour, the regressions you found came from that, and it cost you a review to find them. I've cut it back to what the step was meant to be: only the bulk reads of server/claude-command-history.ts move to async reads through a shared reader. Everything else is main's code.
Your points
Ended command flips back to running (HIGH), and Claude pane reads working while only its background subagents run (title spinner after end_turn) #2069:claude-subagents.ts, claude-task-scan.ts, file-tasks.ts and their tests are byte-identical to main again, and claude-task-state.ts / claude-task-history.ts are gone. vouchedEnds is main's, so a grown transcript rechecks known ends as before. I ran your setup against main and this branch, and the output matched: the count stays 0 after an ordinary append and over 20 more appends, and when the rest is written, restsOn=false, count=0.
Task list with two appends (MEDIUM): main's scan and test, unchanged. B is completed.
Weakened or moved assertions: restored. Every existing assertion in claude-command-history.test.ts is unchanged, waiting: 1 included. The test diff adds tests and two names to the bun:test import, nothing else.
eventual() polling: gone. The new tests wait on the change event, or step a fake clock.
Scope: no new tracker state. The new state is the reader (server/transcript-reader.ts), which you asked to be shared, plus what the async reads need to stay correct. That's listed under "Guards" in the description.
Same-millisecond issue: left as on main.
Workflows: no workflow or script changes. The Windows test-list addition is an optional follow-up, only if you want it.
Cost. A cold history read on a synthetic transcript, median [min–max]:
The two dense rows and the SSHFS p95 come from the full run on 83a5101, one commit before the last review fixes. The other rows are a recheck of the final head.
On a local disk this branch is slower, 1.15–1.66x main per interleaved pair (1.51x on sparse at the final commit). The cost is Bun's async read and the fstat after each chunk. The description names the follow-up that closes most of it: reading the next chunk while that stat is in flight. I left it out to keep this step small. If you'd rather this step be no slower locally first, I'll add it here.
Run it yourself. The benchmark is at https://gist.github.com/duytbp/470e90421331b31ccd953de200d6aae0. It uses synthetic transcripts only and needs no herdr or Claude account. bench.sh <rev> in any checkout gives the local numbers in about 5 minutes against 5456638. MODES=sshfs adds the SSHFS lab, which needs Linux and sudo, and takes about 30 minutes.
The description has the full tables and the checks that were run. Thanks for the careful review. I'll keep the image step (#2610) a draft on top of this until this one is settled.
Thanks @duytbp, this is the step we agreed on, and thanks for the honest note and the benchmark gist. Both reviewers (omo and Codex Terra xhigh) re-ran the earlier repros against main and this head with identical results: ended stays ended over appends, #2069's rest holds, the task list matches main, and every existing assertion is unchanged. omo approves the move as behaviour-preserving. Terra found one remaining gap in the isolation this step is meant to give, plus a few smaller items:
HIGH (reproduced): one stalled transcript read still holds up every other transcript's history.claude-command-history.ts:265 has a single drain that awaits each step before starting the next, so the reader's second slot can't serve transcript B while A's window is stalled. With A held, B's waitCommandHistory returned complete: false, through: 0 after 150 ms, and that holds until A's 10 s deadline. Main is worse (its sync read blocks the whole server), so it's not a regression, but "a slow read can't wedge other transcripts" is what this step is for. Please let different paths drain independently (up to the reader's limit).
Repro (bun test, R2616_ROOT=<checkout>)
import{it,expect,spyOn}from'bun:test';import{mkdtempSync,writeFileSync,rmSync,statSync}from'node:fs';import{tmpdir}from'node:os';import{join}from'node:path';constroot=process.env.R2616_ROOT!;const{ holdCommandHistory, waitCommandHistory, commandHistorySize }=awaitimport(`${root}/server/claude-command-history.ts`);const{ transcriptReader }=awaitimport(`${root}/server/transcript-reader.ts`);constgate=()=>{letopen!: ()=>void;constpromise=newPromise<void>(r=>open=r);return{promise, open};};it('held window delays unrelated history and survives last-server release',async()=>{constdir=mkdtempSync(join(tmpdir(),'sched-'));consta=join(dir,'a'),b=join(dir,'b');constrow=JSON.stringify({type:'queue-operation',operation:'enqueue',timestamp:'2026-10-01T12:00:00Z',content:'<task-notification><task-id>done</task-id><status>completed</status><summary>Background command finished</summary></task-notification>'})+'\n';writeFileSync(a,row);writeFileSync(b,row);constentered=gate(),held=gate();constreal=transcriptReader.read.bind(transcriptReader);constspy=spyOn(transcriptReader,'read').mockImplementation((path:any,work:any,opts:any)=>real(path,path===a ? (file:any)=>work({...file,window:async(...args:any[])=>{entered.open();awaitheld.promise;returnfile.window(...args);}}) : work,opts));constrelease=holdCommandHistory();try{constfirst=waitCommandHistory(a,statSync(a).size,60000);awaitentered.promise;constother=awaitwaitCommandHistory(b,statSync(b).size,150);console.log('healthy while a held:',{complete:other?.complete,through:other?.through,history:commandHistorySize(),reader:transcriptReader.size()});expect(other?.complete).toBe(false);expect(other?.through).toBe(0);release();awaitnewPromise<void>(r=>setImmediate(r));console.log('after last release:',{history:commandHistorySize(),reader:transcriptReader.size()});expect(commandHistorySize().draining).toBe(true);expect(transcriptReader.size().active).toBe(1);constnext=awaitwaitCommandHistory(b,statSync(b).size,150);console.log('new history after release:',{complete:next?.complete,through:next?.through});expect(next?.complete).toBe(false);held.open();awaitfirst;awaittranscriptReader.settled();}finally{held.open();release();spy.mockRestore();rmSync(dir,{recursive:true,force:true});}},3000);
MEDIUM
Releasing the last holder doesn't cancel a read already in flight: in the repro above, after release() the history still reports draining: true and the reader active: 1, and a new history request stays incomplete until the held window returns. The existing shutdown test only releases before the read starts; please cancel the active request on release, with a test that holds it in flight.
Tests: claude-command-history.test.ts:509 polls up to 1000 event-loop turns; please wait on the actual change instead. Several new tests also assert exact lease and turn counts (:419, 434, 446, 501, 512, 526, 552, 573), which ties them to the current scheduling; asserting the outcome instead would keep them valid after the read-ahead.
Known limits, fine to document rather than fix here: two native reads that never settle keep both reader slots (the deadline only ends the callers' waits); dev:ino uses number stats like main, so it inherits main's precision limit; a transcript that keeps hitting the 10 s limit is retried every second without backoff while watched.
Cost, and the read-ahead. Our own local page-cache pairs agree with yours: 276 MB sparse 82-92 ms on main vs 91-151 ms here, 95 MB dense 1.12-1.29 s vs 1.32-1.43 s, with a shorter or equal longest stall. Yes please: add the read-ahead (reading the next chunk while the fstat is in flight) in this PR, then re-run the same table (local sparse and dense total, longest stall, SSHFS, other requests' p95). The bar is: local no slower than main within noise, the network-mount win holds, and #2608's cases don't regress. If it gets there, we'll merge this step.
FYI, not from this PR: on native Windows, invalidates completion authority on replacement, truncation and same-size rewrites fails intermittently on main too (a same-size rewrite within one timestamp tick looks unchanged); no CI job runs that file on Windows. We'll track it separately.
Thanks. Both reviews are clear, and I'm working on all of it in this PR:
different paths drain independently, up to the reader's limit, with your repro as a test;
the last release cancels a read in flight, tested with the read held;
the tests wait on the change instead of polling turns, and assert the outcome instead of lease and turn counts;
the read-ahead.
Then I'll re-run the same table: local sparse and dense total, longest stall, SSHFS, other requests' p95, and #2608's cases. The known limits will go in the description.
Heads-up @duytbp: #2620 just merged into main (05c9133). It changes server/claude-command-history.ts and its test: on Windows a same-size rewrite inside one timestamp tick often keeps id, size and mtime, so a check now only vouches for a version without re-reading when it began at least 3 s after that version's mtime, waiters share one recheck per tick, and end lines are re-read in coalesced runs. Your branch now conflicts in both files, so please merge main in (no rebase needed) before the next round and the re-measure, so the table is taken against the new main.
A lease can start one read ahead into a second buffer beside the operation
it awaits, so the read of the next window overlaps the stat after this one
and the scan of its bytes. A window that asks for those bytes gets them
without another read; one that asks for others lets the read ahead settle
first, and the lease closes only once it has. The check reads the next span
ahead the same way. No read ahead starts when nobody listens, since the read
ends with that slice, or when the window already reached the end.
A slice that ends inside a window leaves its whole lines for the next one,
which takes them in with no read and no stat while no stat came since. A
turn kept inside a held lease now waits for two immediates: one set from an
I/O callback runs before the timers already due, so a slice that needs no
read could otherwise follow the last one before them.
The turn test runs on reads that answer at once and probes the event loop
instead of counting setImmediate calls, which the second immediate doubles.
…urns
The tests of a held transcript asserted exact lease and read counts, which
a read ahead, a slow host or a turn that comes a read later all move. Each
now asserts the outcome the count stood for: the transcript opened far
fewer times than it was read, the small session answered while the large
one still had reads to go, the watcher heard the first end, or the start
over, before the read was through. The deadline tests move the clock again
later in the read, so a lease held too long runs out of time and the read
comes back incomplete.
commandHistorySettled() resolves once no slice runs and none waits to
start, so the test of a request nobody watches waits on it instead of
polling the event loop a thousand turns.
…on its disk
The drain ran one slice at a time, so a read held up on one transcript's
storage held up every other session's history behind it. A slice that has
neither ended nor taken a turn for 50 ms while another session waits is now
left to end by itself, and the drain starts the next session's slice beside
it, up to the reader's capacity, one slice a transcript. A held lease that
takes its turns is reading, and still lets go at the end of the next one,
so on healthy storage slices still run one at a time. A held lease lets go
only for a session that can have a slice, not for one whose transcript a
stuck slice still reads.
commandHistorySettled() and commandHistorySize().draining count the slices
left running too.
…lets go
Letting go of the last holder dropped every session but left a slice's read
running, so a read stuck on its disk kept the drain waiting for it. A reset
now aborts the reads of every slice begun before it: the slice gives up at
once and the drain ends. The native operation it started still keeps its
reader lease until it settles, since a syscall cannot be interrupted, and a
new read of that transcript waits for that lease.
The reader's comment says what it does not cover: a native operation that
never settles keeps its lease, so two keep every lease; a cancelled lease
keeps its path until its operations settle; and identities use number
stats, which can collide past 2^53. The history's comments say that a
transcript whose reads keep running out of time is tried every second with
no longer backoff, and that a healthy lease's open on remote storage can
outlast the stall time. The changelog says one stuck transcript no longer
holds up the others.
Pushed ba3d01e with every item from the review. The description has the details and the new tables.
HIGH: a slice that has neither ended nor taken a turn for 50 ms, while another session waits, is left to finish on its own, and the next session's slice starts beside it. At most the reader's capacity (2) run at once, one per transcript. Your repro is now the test "reads another transcript while one transcript's read is stuck on its disk": B comes back complete with its end while A's window is held.
MEDIUM, cancel: the last release aborts the slice's read in flight. The test "gives up a read stuck in flight once the last server lets go" holds A's window, then releases. The request gives up, the drain ends, and a new request for B comes back complete while A's operation is still held. What it can't do: the native operation already started keeps its reader lease until it returns, so a new read of A itself waits for it. That limit is listed under known limits.
MEDIUM, tests: the 1000-turn poll now waits on commandHistorySettled(). The lease- and turn-count assertions now check outcomes; the description has a before/after table for each.
Known limits: listed in the description and in the code's comments.
Read-ahead: the next window is read while the stat after the current one is on its way. The next slice also takes in the rest of a window a slice ended in, with no new read. Main reads the dense transcript nearly four times over; this branch reads it once.
Against the bar, on ba3d01e (5 interleaved local pairs; ratios are this branch over main):
Local: dense 1.06 warm and 1.03 cold, sparse 1.04 cold. Sparse warm is the noisy row: 1.27 [1.00–1.73] in this run, and 0.80 [0.72–1.12] in a run just before the last fix, which doesn't change the path a lone read takes. Our host also runs a VM that keeps load around 13, so I read those as within noise. Its CPU is a real cost, though: about 1.3–1.75× main on the sparse transcript, from the thread pool's round trips.
Network mount: sparse 62.2 s → 5.15 s, dense more than 300 s (did not finish) → 2.9 s, a 1 MB session behind either 15.3 s → 0.47 s, and other requests' p95 during sparse 156 → 1.5 ms.
One row stays slower, and I'd like to ask you to accept it rather than tune it here: a small session read on its own. It costs about 1.9 ms once per process, to start Bun's async file thread pool and run the reader code for the first time. After that it costs 0.3–0.5 ms per read, for six thread-pool round trips. In the table that's 4 → 5 ms warm and 5 → 9 ms cold. On a network mount the same read goes from 7.75 s to 0.48 s.
Thanks @duytbp, that covers every item; we'll review ba3d01e's approach as soon as it sits on current main. The branch still conflicts with main in server/claude-command-history.ts and its test (#2620, merged at 05c9133, see my earlier note): please merge main in (no rebase) and re-run the table on the merged head, since #2620 changes when a check vouches for a version and how end lines are re-read, both on the paths you timed. The small-session row is noted; the decision on it goes with the re-measured table.
Thanks. Sorry I missed your heads-up about #2620. I'm merging main in now, without a rebase. I'll keep #2620's behaviour and its test assertions unchanged, then re-run the same table on the merged head and post it here.
A request that finds an end's line rewritten starts the history over (evidence) while a slice may wait for the disk. The slice now drops the window or check it was reading then, rather than taking that window in at the new start.
Merged main in (05c9133, a merge commit, no rebase) and pushed c5103d0, so you can review now. The table is re-running on the merged head against 05c9133. I'll post it as soon as it's done.
One interaction, fixed in c5103d0:fix(claude): recheck a same-size history rewrite within one timestamp tick #2620's evidence can now start the history over from a request while a slice waits on its disk. The slice then took in a window read from the old position as the transcript's first bytes. It corrected itself on the next request or check, but in between a request could get [] instead of an end. The slice now drops a window or check read across a start over and reads again from 0. New test: "drops a window read across a start over that a request made while the window waited for the disk". It fails without the guard.
Checks on c5103d0:check fast passes (10744 tests). The history tests pass 24 of 24 with 8 copies at once. The contract tests pass 207 of 207. The review's synthetic cases give the same output as main 05c9133.
The re-measured table is in the description: c5103d0 against main 05c9133, same harness, interleaved runs. Against the bar:
Local: dense 0.87 warm and 0.98 cold, sparse 0.91 cold, and sparse warm 1.21 [0.65–1.46]. That last one is the noisy row: its medians are 85 vs 89 ms. The longest stall is shorter or equal. CPU on the sparse transcript is 1.3–1.5× main's. That's the cost of the async reads, and it holds across runs.
On the small-session row, measured on its own: 4 → 8 ms warm and 5 → 8 ms cold. That's 1–2 ms more than the 1.9 ms once-per-process plus 0.3–0.5 ms per read that I profiled before the merge, and I haven't accounted for the rest yet. With a large transcript reading at the same time, it's even with main (5 vs 5 ms). I'd still ask you to accept it for this step. If you'd rather I profile the gap first, I will.
Thanks @duytbp, the merge with #2620 looks right and the start-over fix is a good catch. Both reviews of c5103d0 are in (omo approves with a watch, Codex Terra finds no HIGH): the stuck-read and cancel fixes work, the old repros match main, and #2620's and #2608's tests are unchanged and pass (omo also ran the history tests 6x on native Windows, all green). The small-session row (+3 to +4 ms, the thread pool's first start) is accepted for this step.
One thing blocks the merge, and it's the round we'd like now: the page-cached sparse read is slower and costs about twice the CPU. Our own ABBA run on a quiet host (load 1.4, 0 flagged samples, both orders agree), same harness:
case
main read ms
PR read ms
main CPU ms (user+sys)
PR CPU ms
main loop gap max
PR loop gap max
sparse 276 MB warm (A)
71 [67-71]
84 [82-85]
77
164
4.7
2.8
sparse warm (B)
70 [68-72]
84 [81-87]
78
167
4.7
2.8
sparse cold (A)
345 [344-349]
312 [306-318]
124
211
5.3
2.8
sparse cold (B)
341 [338-343]
304 [302-310]
123
207
5.4
2.8
dense 95 MB warm (A)
971 [950-995]
950 [935-971]
1226
1236
5.4
6.7
dense warm (B)
966 [957-971]
936 [909-954]
1226
1211
5.5
6.1
dense cold (A)
1106 [1088-1126]
954 [949-977]
1265
1258
5.3
6.4
dense cold (B)
1109 [1096-1127]
947 [929-976]
1267
1253
5.5
6.1
small warm (A/B)
4 [4-4]
7 [7-8]
6
9
0.9
3.6
small cold (A/B)
6 [5-7]
9 [8-9]
6-7
10
4.6
2.6-2.8
Other requests' ping p95 during the read: sparse 4.0-4.6 -> 0.3-0.7 ms, dense ~4.0 -> ~3.9 ms, small 2.3-3.9 -> 0.5-0.7 ms.
Sparse warm doesn't overlap main (70 [67-72] vs 84 [81-87] ms), and CPU on the sparse transcript is ~85 ms more per read (warm 78 -> 165 ms). Terra isolated the cost: a scratch copy of the scanner with its file calls made synchronous used 87-106 ms CPU, close to main, so it's the per-window async dispatch, worker wakeups and completion handoffs, not the scan itself. A cached file shouldn't pay thread-pool round trips for every window: e.g. take a fast path when the bytes are already read (a window that is already in hand, or several positional reads and checks batched per async handoff), keeping the event loop responsive, cancellation and fairness as they are now. Bar: the same ABBA table after, with sparse warm overlapping main and the extra CPU on big cached reads mostly gone; dense, sparse cold, the network-mount numbers and #2608's cases staying as they are.
While you're there (smaller, from the reviews):
Complexity (omo M1):claude-command-history.ts:340-520 stacks a 50 ms stall race, leases held across turns, read-ahead with a buffer pool, reuse of a window's remaining bytes, and turn/restart counters; the last commit fixed an interaction among them. If the fast path makes some of these unnecessary (read-ahead and the byte reuse are the first candidates), please drop them.
Tests pinned to internals (omo M2)::754 intercepts every 50 ms timer, :646-647 caps reads per turn, :554 and :616 count leases; please assert outcomes. Two tests still rely on a real 150 ms window and the reader tests spin on Date.now() (Terra LOW): a controlled clock would make them deterministic.
Known limits, fine to keep documented: two native reads that never return still hold both reader slots (the changelog line slightly overstates the isolation: please say "one stuck transcript"); up to 4 MiB of spare buffers stay alive; a busy transcript is retried every second while watched.
Thanks for sticking with this; the network-mount and dense wins are real.
…nd reader tests
The lease tests now assert the time a read took rather than how many leases it opened. The test of a transcript that takes its turns asks for the small session between two turns, where only the drain's turn count keeps it from starting a slice beside the large one. The stuck-read tests and the reader's deadline tests move a fake clock instead of waiting 150 ms or spinning on Date.now().
…e event loop
On Linux, a transcript whose directory is on a local filesystem (statfs type ext4, btrfs, xfs, tmpfs, zfs, f2fs or bcachefs) is opened, statted, read and closed with synchronous calls inside the slice, with no read ahead. A call through the thread pool costs more CPU than a read the page cache answers. Every byte is still bracketed by two equal stats, from fstatSync.
A call slower than 2 ms sends the rest of the lease, and that transcript's leases for a second, through the thread pool. The filesystem type is cached for a minute per directory. FUSE, NFS, SMB, 9p, any other type, macOS and Windows keep the async path, with its read ahead and the drain's 50 ms leave-behind, so a stuck network mount still holds up only its own history.
A lease that has to look up its directory's filesystem (once a minute per directory) now opens the transcript through the thread pool at the same time, instead of after the lookup. On SSHFS the statfs costs about 70 ms of round trips of its own, which every first read in a directory waited for before its open. If the lookup says local, the lease reads on the event loop through the opened handle's descriptor (readSync and fstatSync), as a lease opened with openSync does.
A slice's window read on the event loop no longer gets a slice of its own when it comes in after the slice's end: the loop had no turn while it was read, so it is taken in only until that end, and the rest of the window waits for the next turn. Such a slice also counts from the start of its step, as main's does, so the lookup, open and first stats of its lease come out of its time.
The reader tells the work whether the event loop ran while the lease's last call waited (`waited()`), and, before the first call, whether the lease waited for its turn in the reader or reads through the thread pool. Reads through the thread pool keep the rule as it was. Tests that stand in for slow or held storage say their reads waited.
… in by a slice of its own
"reads on by whole chunks when every read outlasts a slice" counted only reads, which the reuse of a window's rest keeps within bounds whether or not a chunk that came after its slice's end gets a slice of its own. It now also asserts that every turn takes in several hundred ends, never one record, on a clock that moves by the reads alone (and 1 µs a look), so the outcome does not depend on the host's load.
Pushed 93f74fb (fast-forward from c5103d0, no rebase) for the CPU round, and the description is updated. The change is a fast path: on a local Linux filesystem (ext4, btrfs, xfs, tmpfs, zfs, f2fs, bcachefs, by statfs type), the lease opens, stats and reads on the event loop, inside the same slices and turns. Network filesystems, macOS and Windows keep the thread pool. A call on the event loop that takes over 2 ms, i.e. one the disk answered rather than the page cache, sends the rest of that lease and that transcript's next second through the pool.
Against your bar, b9b2acd against main 05c9133, same harness, interleaved (my host is loaded, so all samples are flagged):
main
this branch
sparse warm, read
65 ms [57–70]
74 ms [66–79], ratio 1.15 [0.96–1.34]
sparse warm, CPU
64 ms
90 ms, ratio 1.25 [1.06–1.40]
sparse warm, longest loop gap
4.7 ms
4.7 ms
sparse cold, read / CPU
158 / 157 ms
164 / 190 ms
dense warm, read / CPU
1027 / 1172 ms
949 / 1104 ms
dense warm, longest loop gap
7.3 ms
7.3 ms
SSHFS sparse / dense
61.6 s / not done in 300 s
5.18 s / 2.58 s
Sparse warm CPU, the row that blocked: in your run c5103d0 took 165 ms against main's 78 (2.1×). Here, three full runs across this round's commits give 1.10, 1.16 and 1.25× main's, against 1.48× for c5103d0 in the same harness. The branch held at 77–93 ms in those runs, while main moved between 64 and 88. The rest is mostly first-run compilation: compiled, the main-thread work matches main's within 2 ms, and the pool threads do none. Read time overlaps main's in every run. Your quiet-host ABBA would settle the last few percent better than mine.
Sparse cold is now even with main in read time (it was about 10% faster), and costs ~20% more CPU than main. Once a read takes over 2 ms, the rest of the lease goes through the thread pool, so a stalling local disk blocks the loop for one call, after which the thread pool takes over.
Small session: +3 ms, as accepted. Its loop-gap max reads 4.7 ms against main's ~1, but that is the harness's sampler: it records a gap only when a tick fires before the read resolves, so main's small read rarely gets its parse counted. A probe that counts the last gap too found 3.3–5.4 ms here and 4.3–5.1 ms on main.
The smaller items:
Complexity (M1): the read-ahead and the reuse of a window's rest stay. They carry the thread-pool path that SSHFS takes, which the fast path doesn't touch. Without them, SSHFS sparse took 9.55 s and dense 9.2 s.
Tests pinned to internals (M2): the lease and turn counts now assert outcomes on a controlled clock. The 150 ms waits and the Date.now() spins are gone, and the 50 ms timer interception is replaced by a fake clock. Two tests turned out not to catch their own fixes, at c5103d0 too: "however long it reads" and "reads on by whole chunks". Both are fixed, and each fails with its fix undone. The description lists every changed assertion.
Changelog: it now says "one stuck transcript".
Checks on 93f74fb: check fast 10751 passed, 0 failed; the history tests 24 of 24 with 8 copies at once; contract 207 passed.
Known limits added to the description: a local disk that stops answering inside a call on the event loop blocks the loop, as main's readSync does. And macOS and Windows keep the thread pool's cost. For macOS, comparing the directory's statfs type with /'s should work, but I haven't tried it on a Mac, so it's a possible follow-up.
Thanks @duytbp, this round closes the gap: our own interleaved run (main 3e51902 vs 93f74fb, both orders, 7 reps each) has sparse warm at 68 [67-70] / 73 [70-77] ms on main vs 71 [70-75] / 76 [72-80] on the PR (overlapping), +8 ms CPU per warm read (was +85), dense cold about a third faster, and other requests' p95 during a sparse cold read down from ~9 ms to 0.3 ms. Both checks we set for the event-loop path hold: the worst single event-loop block reading 64-200 MB transcripts was 8.17 ms (main 8.40 ms), and a real sshfs mount, overlayfs, squashfs, mocked NFS/CIFS/9p/ceph/ecryptfs and a failing statfs all stayed on the pool.
One last small round, then we merge:
Required
Controlled clock in the four Linux fast-path reader tests (transcript-reader.test.ts around :373, :407, :426, :444). They time real reads against the 2 ms threshold, so a preempted read trips the fallback: 8 of 48 runs failed under heavy load (48 parallel runs beside 64 CPU spinners); the previous round's tests passed 48/48 there. :385 already shows the pattern.
Time the synchronous open and close too (:196, :71), and count a call that throws. An injected 80 ms openSync delayed a timer by 80 ms and the next lease opened synchronously again instead of cooling down.
Take the filesystem type of the opened file, not its parent directory (near() at :371, cached 60 s). A file bind-mounted from NFS/FUSE into an ext4 directory would be read synchronously (simulated: the parent reported ext4, the file NFS, only the parent was asked). fstatfs on the fd isn't in Bun/Node, so e.g. compare fstat(fd).dev with the dev of the directory you statfs'd and use the pool when they differ.
Optional, only if the timing table stays met: send the first read of a range through the pool and use the event loop only for bytes this process has already read once. Today a synchronous read can't be interrupted, so one stuck local read blocks the bridge for its full length (an injected 100 ms read delayed a timer by 101 ms). Main makes the same synchronous calls, so this is pre-existing and doesn't block on its own.
Not needed: Linux remote bundles taking the fast path is fine (a remote bundle is just the server on that PC).
After your push we'll run a delta review and a spot-check of the same timing table.
Thanks for the quick and thorough check. Working on the three required items now: a controlled clock in the four fast-path reader tests, with the same 48-parallel stress run beside spinners; timing the synchronous open and close, with a throw counting as slow; and comparing the opened file's dev with its directory's, using the pool when they differ.
I'll leave out the optional pool-first read. Every first read of a range in a process, page-cached or not, would then pay the thread-pool cost again. That is most of what a history read does after a restart, and what the CPU row measures. As you say, the uninterruptible synchronous read is main's too.
… move
The tests of reads on the event loop timed real calls against the 2 ms bar for a slow one, so a read preempted on a busy host sent the lease to the thread pool and failed them. They now run on a clock (performance.now) that moves only as the test moves it.
… transcript's device
An open or close on the event loop is now timed like a read there: one slower than 2 ms, or one that throws, sends the transcript through the thread pool for a second, and after a slow open the rest of that lease too. A read or stat there that throws counts the same way.
The filesystem lookup now also takes the directory's device, and the lease's first stat on the event loop, before any read, compares the transcript's device with it. A transcript on another device (a file mounted into the directory from another filesystem) goes through the thread pool for the rest of the lease, and for as long as a lookup is kept. A known directory still costs no round trip, and the lookup still runs while the transcript opens.
Pushed 7edf4df (fast-forward from 93f74fb) with the three required items, and the description is updated:
Controlled clock: the reader's event-loop tests now run on a clock only they move (stillClock()), :385 and the third part of :444 included, since those mixed real time with an offset. Under 48 parallel runs of transcript-reader.test.ts beside 64 CPU spinners: 93f74fb passed 42 of 48 (the same tests you saw fail), and 7edf4df passes 48 of 48, with 16 spinners too.
Open and close timed: an open or close on the event loop over 2 ms, or one that throws, marks the transcript for the thread pool for a second. A slow open also sends the rest of its own lease there. A read or stat that throws now counts too. New tests: one each for a slow or throwing open, a slow or throwing close, and a throwing read.
The file's own device: the lookup stats the directory beside its statfs and caches its dev. The lease's first stat, which comes before any read, compares the transcript's fstat().dev with it. On a mismatch the rest of the lease goes through the pool, and the path stays there for 60 s. A known directory still costs no extra call, and a cache miss still opens while the lookup runs. New test: a directory that reports another device than its file. The limit is in the description: a bind-mounted network file is opened and statted once on the loop before its device gives it away, once per 60 s.
Each new test fails with its fix undone. Checks on 7edf4df: check fast 10755 passed, 0 failed; reader and history tests 82 passed; history tests 24 of 24 with 8 copies at once; contract 207 passed.
A local run on 7edf4df against 05c9133, 5 runs each, on my loaded host:
sparse warm: 67 against 61 ms, CPU ratio 1.22;
dense warm: 0.95, and cold 0.86;
the longest event-loop gap matches main's on both: 4.7 against 4.6 ms, and 6.4 against 6.6 ms.
Sparse cold CPU came out 1.39× main's here, against 1.22× on b9b2acd, with a wide range (1.07–1.76). A cache miss now stats the directory too, and every bench read is a fresh process, so I can't separate that from load. Your spot-check will show it more clearly.
The optional pool-first read is left out, as I said above.
…2647)
## Change
Reverts #2624 (9ef34f8). An improved version will land again on top of
this.
#2624 follows a growing chat row with LegendList's `scrollToEnd`, which
lands one frame after each measurement. Each streamed change is
therefore two frames behind: the frame it is appended in, and the next
one. On a fast machine that passes. On GitHub-hosted runners the frames
are slower, so the next change often lands before the reader has caught
up. `scripts/chat-virtual-browser-qa.ts:717` ("continuous content left
the reader behind for N consecutive animation frames") then failed on
every attempt of fork PRs: #2616 twice (4 frames), and #2636 (6 frames).
In each of those runs every change lagged exactly 2 frames (140 lag
frames for 70 changes).
The same follow also stops following in the script's fold-together step.
A row above the view folds as the last row grows. LegendList's own
adjustment, followed by a `scrollToEnd` aimed at an end it computed
before the rows moved, nets out as a move up with no shrink seen.
ChatView then reads that as the reader leaving. The glide it replaced
did not do this.
Reproduced locally with the unmodified script, alternating commits under
`dori heavy`:
| Profile | 05c9133 (before #2624) | 9ef34f8 (#2624) | main |
|---|---|---|---|
| 2 cores, 2 CPU hogs, 8 rounds | 0/8 (p95 bound, the old glide's known
lag) | 3/6 valid (fold-together 2, line 717 once) | 3/8 (fold-together
3, line 717 twice) |
| 2 cores, 2x CPU throttle (CDP) | not run | not run | 0/4 (line 717,
4-10 frames) |
The 05c9133 column shows why #2624 was written: the old glide fails the
p95 bound that #2624 added. That bound goes away with this revert,
together with the rest of #2624's script changes.
## Validation
- `scripts/chat-virtual-browser-qa.ts` (as reverted): 1 consecutive
passes so far (the run continues to 22; I'll update this line) on 2
cores with 2 CPU hogs and 2x CDP CPU throttle, `UI_EVIDENCE_DIR` set as
CI does, under `dori heavy`. The fold steps passed in every run.
- `bun run check fast`: success on a5c4e5b8 (same tree as this head;
only the commit message changed).
- omo code reviewer: APPROVE. The revert is exact: `git diff` against
9ef34f8~1 is empty for the reverted files. Nothing merged after #2624
depends on it, and the fragment was never released. Its LOW is applied:
the commit message no longer claims grow-above.
- Skipped: full check, Windows VM.
No changelog fragment: #2624's own fragment was not released yet, and
this revert removes it.
Thanks @duytbp for this, and for the measurements, the SSHFS lab and the fast turnarounds across five rounds. Reading old command history no longer blocks the bridge on slow storage: transcripts are read in bounded slices through a shared reader, on the thread pool for network filesystems, macOS and Windows, and on the event loop for page-cached files on local Linux filesystems, falling back to the pool for a file whose own device differs from its directory's or after any slow or failing call.
Merge-pass timing (interleaved, main 3e51902 vs 7edf4df, 7 runs per side in both orders, quiet host):
row
main (A / B order)
PR (A / B order)
verdict
sparse warm, read ms
68 [66-70] / 68 [64-82]
71 [70-72] / 75 [71-75]
same (ranges overlap; +3-7 ms)
sparse warm, CPU ms per read (user+sys)
75 / 82
86 / 88
+6 to +11 ms per read
sparse cold, read ms
336 [334-339] / 338 [336-342]
305 [299-323] / 303 [300-308]
better (-10%)
sparse cold, CPU ms
119 / 125
204 / 213
+85 ms, once per cold transcript
dense warm, read ms
967 / 976
955 / 956
same
dense cold, read ms
1102 / 1109
952 / 946
better (-14%)
small warm / cold, read ms
4 / 5-7
7 / 9
+2-4 ms (accepted row)
other requests' ping p95 during sparse cold
4.4 / 4.5 ms
0.3 / 0.3 ms
better
worst loop gap, any row
5.6 ms
6.7 ms
same size as main
The author's SSHFS lab showed sparse/dense history reads at 61.6 s / not done in 300 s on main vs 5.2 s / 2.6 s here.
Checks set for the event-loop path, both measured: the worst single event-loop block reading 64-200 MB transcripts matched main's (8.17 vs 8.40 ms in round 4); a real sshfs FUSE mount, overlayfs, squashfs, a single file bind-mounted from another device, mocked NFS/CIFS/9p/ceph/ecryptfs and a failing statfs all use the thread pool. The reader tests run on a controlled clock and passed 264 of 264 heavy-load runs (48 parallel beside up to 160 CPU spinners).
Reviews: omo approves; Codex Terra xhigh found no HIGH. Left as known limits, not blocking:
pre-existing: a synchronous call can't be interrupted, so one stuck local read blocks the loop for its length (main makes the same synchronous calls);
MEDIUM (unusual setup only, a network file bind-mounted into a local directory): a slow first fstat or a slow close can shorten that file's 60 s pool fallback to 1 s, and the fallback table is cleared whole at 256 entries; after the 60 s, such a file gets one open and stat on the loop again (at most once a minute);
LOW: no test pins the full 60 s fallback; a non-I/O error also sends a file to the pool for 1 s; one test helper uses as any.
CodeRabbit's check shows it skipped this head; the merge pass and both reviewers reviewed it.
)
## Change
`scripts/device-security-demo-regression.ts:283` failed on GitHub-hosted
runners: on #2616 twice in one run, and once more on another of its
runs, and on #2636 once. In each case the page was at 390 px light, and
Escape right after a fictional phone paired left Settings open
(`.settings-dialog` never detached within 30 s). This is a product race,
not a test flake. A person pressing Escape right after a phone pairs
sees Settings stay open.
At phone width the pairing closes the Connect a phone sheet by itself.
An Escape in the next moments was lost in one of two ways:
1. **The sheet's Escape listener outlived the sheet.**
`ConnectPhoneDialog` listens for Escape on the window, in the capture
phase, and calls `stopPropagation()` and its own close. The listener is
removed in a passive effect's cleanup, which runs after the sheet has
left the page. An Escape in that gap was taken by a sheet that no longer
existed, so Settings never heard it. Now the listener lets the key
through once its surface is gone, because React detaches the ref before
the passive cleanup.
2. **Settings stepped back from the wrong entry.** A closed sheet steps
back off its history entry in the next task (`leaveSheet`,
`setTimeout(0)`). If Settings closed before that step ran, its rewind of
`go(-depth)` started from the sheet's entry. That entry copies Settings'
own fields, so the rewind landed on Settings' list of pages and Settings
stayed open. Now Settings steps over that entry too and takes over the
sheet's step, through `sheetStep.takeLeaving` in `historyKeys.ts`, so
the step never runs twice.
How it was found: a probe sent Escape at fixed moments after the sheet
left the DOM, on 2 cores with 2 CPU hogs. It sent the key in the
mutation's own microtask, then after 1, 2 and 4 tasks, and also as
Playwright's key press. The probe covered the first pairing and the
second one, after Settings was reopened from the notice. On main, Escape
within 2 tasks left Settings open 30 times out of 30. The first race
showed the key seen in the window capture phase but never reaching
Settings' listener. The second showed `go(-2)` issued from the sheet's
entry. With this change, Settings closed 50 times out of 50.
## Tests
`src/lib/sheetHistory.test.ts` adds two tests with both history modules
on one browser. Each closes Settings while a sheet opened inside it is
still stepping off its entry, once with the address unchanged and once
with it changed. Both fail on main, where Settings stays on its list at
depth 1. The changed-address test also fails if `takeLeaving` skips a
sheet whose address changed. I checked that by mutating a scratch copy.
## Validation
- `scripts/device-security-demo-regression.ts`: 22 consecutive passes
out of 22 on 2 cores with 2 CPU hogs, with `UI_EVIDENCE_DIR` set as CI
does, under `dori heavy`. An earlier head, 009f8920, which differed only
in the changed-address case, passed 10 out of 10.
- `bun test src/lib/sheetHistory.test.ts src/lib/settingsHistory.test.ts
src/components/ConnectPhoneDialog.test.tsx`: 66 pass.
- `bun run check fast`: success on 6db6150.
- Reviews:
- omo code reviewer: APPROVE after its HIGH was fixed. The `?query` test
import broke the typecheck, and now uses a path variable. Its LOW-A
(take a leaving sheet's step whatever its address) is applied.
- Codex Terra: two rounds of REQUEST_CHANGES, both on the
changed-address case. The first asked to respect `leaveSheet`'s
changed-address rule; the second asked that Settings still close in that
case. The final code closes Settings and steps back past that entry, as
the omo reviewer recommended. The new test covers it.
- Left as follow-ups: the guard in `ConnectPhoneDialog` has no unit
test, because the test renderer gives no DOM refs; the browser script
and the probe cover it. Also, once the sheet's own step back has
started, Settings can still hear a step that isn't its own. That was
already true on main and isn't seen in a browser.
- Skipped: full check, Windows VM.
… has ended (#2652)
## Change
`scripts/chat-select-quote-demo-regression.ts:80` (`.selection-offer`
never visible) failed on GitHub-hosted runners, twice in a run each
time, on #2616 (run 41504169612) and #2636 (run 41588361689). Both runs
left only `desktop-1280-dark-1-select` and `-2-quoted` in their
evidence, so a selection after the second quote failed. Test-only: no
product code changes.
The demo moves the transcript during the script's mouse drags, at two
moments:
- **On load:** the demo's Claude pane is mid-turn when the demo opens,
and `site/demo/transport.ts` ends that turn on a 4.5 s timer. The
earlier reply gets its final words (+61 px) and the Working line goes
(-34 px), and the chat, following its end, moves the reply being quoted
by 34 px.
- **After Send:** the demo answers the sent message, and the answer
landing moves the transcript the same way.
If a drag spans either moment, it ends a line away from where it
started. On a slow runner, the script's first drag lands around 4.5 s
after load.
Seen with a probe that recorded each row's size and the footer at the
press and at the release:
- At the first drag's press, row 3 was 140 px and the footer 54 px
("Working…"). At its release they were 201 px and 20 px, `scrollTop`
+27, and the selection was `"\nAdd a revision column to notes and "`
instead of `"backfill it from updated_at"`.
- At the fourth drag, the demo's answer row (114 px) landed between the
press and the release, and `scrollTop` went +80.
`select()` also read the last agent reply, so it found the plan only
while the demo's answer had not landed yet.
## Fix
- The first selection starts once the demo's turn has ended: its last
words ("6 tests pass.") are drawn and the Working line is gone.
- After the send, the next drag waits for the demo's answer: one more
agent reply, and the Working line gone.
- `select()` uses the last agent reply that holds the words.
- Right after a drag, the script asserts the selection holds the words,
and says what it took if not, instead of waiting 10 s for an offer.
The assertions are unchanged.
## Validation
- Failing first: main's unmodified script with only a human-paced drag
(60 mouse steps instead of 4), no throttling, failed 3/3 (`QSLOW` runs:
"quotes stack in the order taken", and selections a line off).
- Fixed: 22 consecutive passes out of 22 on 2 cores with 2 CPU hogs, 2x
CDP CPU throttle and 60-step drags, with `UI_EVIDENCE_DIR` set as CI
does, under `dori heavy`.
- `bun run check fast`: success on 542e942.
- omo code reviewer: APPROVE, over three rounds. Its findings are
applied: reset the settle state (that wait was later dropped as not the
cause); wait on the demo answer rather than a row count, which list
virtualization keeps flat; and a clear error when no reply holds the
words. Declined: its optional extra frame after the footer wait, because
the answer lands below a view that no longer follows. Its product note
is filed as #2650.
- Skipped: full check, Windows VM.
No changelog fragment: test-only.
… it exits with (#2653)
## Change
`scripts/install-node-fallback.test.ts` "kills what a hung host started,
not only the host" failed twice on #2616's GitHub-hosted run: Fast
checks, run 41603870470, attempts 2 and 3. It answered `RUNS` instead of
`REFUSED` after about 3 s, and it passes locally. The fault is in
`install.sh` itself, not the test: `bounded()` could report a command it
had timed out as successful.
`bounded()`'s watcher kills the command's tree once the time limit
passes, children first and the command last. The test's hung host runs
`child & wait`. A `wait` with no operand returns 0 once the child is
gone. So on a slow runner, the host exits 0 between its child's kill and
its own. `bounded()` then returned 0, and `rust_host_runs` took a hung
Rust PTY host for a working one. On a slow PC the installer could have
chosen a hung host over the Node fallback.
Once past the time limit, the watcher now ignores TERM, kills the tree
and exits 124. `bounded()` always stops the watcher and waits for it,
and returns 124 when the watcher says it timed out. A kill that has
started always finishes, and a command that ends within its time limit
keeps its own status.
## Tests
A new `bounded` test in `scripts/install-node-fallback.test.ts` makes
the race deterministic. It stands in for the tree's kill with one that
makes the command exit 0 itself, as such a host would. The test fails on
main (`PASSED`) and passes here (`TIMED OUT`). A second test checks that
a command which ends within the time limit keeps its status: 0 stays 0,
and 3 stays 3.
## Validation
- `bun test scripts/install-node-fallback.test.ts`: 31 pass.
- Shell probe of `bounded()` with `set -eu` under dash, `bash --posix`
and sh:
- killed while it exits 0: 124;
- `exit 3`: 3;
- `true`: 0;
- a real hang: 124.
- `sh -n install.sh`: ok.
- The old test under contention (1 core, 3 CPU hogs, 30 runs each): main
30/30 and the fix 30/30. The race did not show locally, so the
deterministic test is the failing-first evidence.
- `bun run check fast`: success on 12f49a8.
- Reviews:
- Codex Terra: REQUEST_CHANGES on the first version, which used a marker
file. Its two P2s: the watcher's cleanup could be cut short at the
deadline, and an `rm` that fails under `set -e` could leave the command
unbounded. This version removes the marker. Terra re-reviewed it:
APPROVE, no findings.
- omo code reviewer: APPROVE on the first version; re-review of this
delta: APPROVE (CLEAR). Its one remaining note is optional: the test's
FIFO could live in the test's own temp dir.
- Skipped: full check, Windows VM (`install.sh` is not used on Windows),
a real macOS `/bin/sh` (`bash --posix` stood in for it).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Change
This is the history-first step requested in #2610, cut down to the smallest move after the review. Only the bulk reads of
server/claude-command-history.tschange. Everything else is main's code.claude-subagents.ts,claude-task-scan.ts,file-tasks.tsand their tests.claude-task-state.ts,claude-task-history.tsand their tests are gone, along with the proof/revision/pending overlay and the task-scan retry. Also reverted:ci.yml,remote-bundles.ymlandscripts/remote-bundle-smoke.ts. The remote bundle copiesserver/whole, so it carries the new file without them.historyVersion,sameHistory,commandHistory,evidence,vouchedEnds,waitCommandHistoryand the rest).vouchedEndsstill rechecks known ends against a grown transcript synchronously, so an ordinary append keeps an ended command ended and Claude pane reads working while only its background subagents run (title spinner after end_turn) #2069 holds. fix(claude): recheck a same-size history rewrite within one timestamp tick #2620's parts are main's code too, merged in unchanged:evidence's 3 s rule and its start over on a rewritten line,since, the shared recheck per tick, andvouchLines' coalesced, budgeted re-reads. They stay synchronous onreadSync, on the request path.scanandcheckread throughtranscriptReader(server/transcript-reader.ts) instead of callingreadSyncthemselves. On network filesystems and on macOS and Windows, the reader reads through the thread pool. On local Linux filesystems it still reads synchronously inside the slice (below). The drain still runs one slice at a time, with asetImmediatebetween slices, except when a slice is stuck (below).statfstype,LOCAL) is opened, statted, read and closed on the event loop, inside the same lease, slices and turns.statfsitself is async, since it can block on a hung NFS mount. It is cached for 60 s per directory, and a lease that has to look it up opens the transcript at the same time, so a network filesystem pays no extra round trips before its open.statbeside thestatfs), and the lease's first stat compares the transcript's own device with it before any read. A transcript on another device, such as a file bind-mounted from NFS or FUSE into a local directory, goes through the thread pool, and its later leases do too for 60 s.waited()). A slice reading on the event loop counts from the start of its step and stops at its 4 ms, as main's does; reads through the thread pool keep the rules above.fstatSyncon the same descriptor. Cancellation and the deadline are checked before and after every call.watchCommandHistory), and the task scan waits for it (waitCommandHistory).setImmediates, not one. An immediate set while an I/O callback runs comes before the timers already due. With one, a slice that needs no read (the rest of a window, below) could follow the last slice before them: 44–51 event-loop stalls over 8 ms per dense read in profiling, against 0–1 with two.window()that asks for those bytes gets them with no second read. One that asks for other bytes lets the read ahead settle first. A lease has at most two native operations in flight, so the reader has at most four.checked, and reused bytes come from a read that did. A rewrite is still caught by the next stat or check.STALL_MS) while another session that can have a slice waits. The drain leaves it to end by itself and starts the next session's slice beside it.resetCommandHistory()aborts the reads of every slice begun before it. The slice gives up at once, and the drain ends. What it cannot do: the native operation already started keeps its reader lease until it settles, since a syscall cannot be interrupted, and a new read of that transcript waits for that lease.commandHistorySettled()resolves once no slice runs and none waits to start.commandHistorySize().drainingcounts the slices left running too.evidence) while a slice may be waiting for the disk. The slice drops the window or check it was reading then and reads again from the start. Without this, it took in a window read from the old position as the transcript's first bytes, until the next request or check corrected it.TranscriptBusyError(reader queue full, or a read past its 10 s deadline) backs off without using the failure budget (CodeRabbit thread onclaude-command-history.ts:98). Other errors count as on main.lstatSyncgives, so its versions compare withhistoryVersion.window()reads into one of two 1 MiB buffers per lease, taken from a pool of the buffers of leases that ended. A buffer goes back to the pool only once every native read into it has settled, a read ahead included. The deadline timer does not keep the process alive.Known limits
Not fixed here; each is written in the code's comments:
readSyncdoes. The fallback notices a slow call only once it returns. Network filesystems can't hit this, since they never take the fast path.statfsgives no fixed type for APFS; comparing the transcript directory's type with that of/should work, but I haven't tried it on a Mac, so it's left for a follow-up.dev:ino, aslstatSyncgives them). On a filesystem whose numbers pass 2^53, two files may share an identity.Tests
Every assertion from main's
claude-command-history.test.tsis unchanged: against main, that file changes only its imports. At the review's request (the second MEDIUM), the assertions on lease and turn counts in tests this PR added earlier now assert outcomes:setImmediatecalls than reads / 2commandHistorySettled(); read on some way but not through, nothing left queued, draining or timedMerging main with #2620 changed two of this PR's own tests (none of #2620's, none of main's), because of #2620's rules:
evidencefinds that rewrite first and starts over before any slice, so the turn-break went untested. It now rewrites the record after the end's line, so only a check finds it, and itsendsassertion is["a"], not["b"]. Dropping|| restartedfrom the turn-break still fails it.Every other assertion of those tests, and of every other test, is unchanged.
This round also moves the tests off real time (Terra LOW):
stillClock()), so a preempted read can't cross the 2 ms bar. 48 parallel runs oftranscript-reader.test.tsbeside 64 CPU spinners: 42 of 48 passed on 93f74fb, 48 of 48 now (also 48 of 48 with 16 spinners).slowReads,holdWindows) now say their reads waited off the event loop, and the one that answers at once says they didn't, so the tests behave the same whatever filesystem the host's/tmpis on.Date.now()past a deadline now set the system time inside the callback, or while the close is pending.Each test below fails with its fix undone; I undid each fix and ran the files:
next()'s (either alone holds the bound)Three tests have no fix to undo:
.catchdoes not fail "ends a cancelled lease's read ahead…": the lease's operation bookkeeping also handles that rejection.Four lines have no unit test:
One of #2620's tests sees less here: "shares one recheck within a version's tick among the requests that wait…" counts
fs.readSync, which on this branch is onlyvouchLines; the drain reads through the reader. Its assertion holds. A probe wrapping the reader'swindow()around the same five waits found no drain reads, as on main.The inherited same-millisecond matching issue is out of scope.
Possible follow-up, not in this PR:
waitCommandHistoryandcommandHistorystill calllstatSyncthroughhistoryVersion, as on main. On SSHFS that holds the event loop about 100 ms per call. #2620'svouchLinesis the same kind of read: synchronous, up to 4 MiB per call, run whenever the history is not vouched, which since #2620 includes any transcript checked within 3 s of its mtime. Moving either off the request path is a change to main's synchronous API.Validation
Toolchain: Bun 1.4.2, Node 22.23.4, herdr 0.9.3. The head is 7edf4df: c5103d0 (which merged main 05c9133) plus the test rework, the local-filesystem fast path, the changelog, opening during the filesystem lookup, the slice clock on the event loop, two commits strengthening one test, the reader tests' clock, and timing the open and close with the device check. The full benchmark below ran on b9b2acd; a local run on 7edf4df is after it.
bun run check faston 7edf4df: 10755 passed, 0 failed, 41 skipped in 663 files; workflow syntax, generated types and themes, typecheck and build all ok. It includes fix(claude): reconcile background commands after cold history reads #2608's tests (claude-subagents,claude-task-scan) andfile-viewandfile-pool, which usefile-tasks.ts.server/transcript-reader.test.tsandserver/claude-command-history.test.ts: 82 passed, 0 failed. The history tests, 8 copies at once, 3 rounds: 24 of 24 passed.GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.excludesFile GIT_CONFIG_VALUE_0=/dev/null bun run check run bun test server/claude-session.contract.test.ts server/api.contract.test.ts: 207 passed, 0 failed, on an isolated herdr 0.9.3.git diff upstream/main -- server/claude-subagents.ts server/claude-task-scan.ts server/file-tasks.ts server/claude-subagents.test.ts server/claude-task-scan.test.ts .github scriptsis empty against 05c9133.The review's cases ran as synthetic scripts against main and c5103d0 with the same output on both (listed in the earlier round). The review's repro, a held read of A while B is asked for, is the test "reads another transcript while one transcript's read is stuck on its disk", and its network-filesystem twin covers the stuck native read.
Measured with the same synthetic benchmark as before (gist below): b9b2acd against main 05c9133, interleaved, median [min–max]. The host also runs a VM using about 8.5 cores, so load was 9–13 and every sample is flagged; treat local differences under ~20% as noise, and CPU as the steadier figure.
Local disk, 5 runs each. Ratios are this branch over main per interleaved pair, so <1 is faster:
Sparse warm CPU is the figure the last review blocked on: 78 ms on main against 165 on c5103d0 in your run. Three full runs across this round's commits on this host put it at 1.16, 1.10 and 1.25 times main's (fd22a07, b994f29, b9b2acd), against 1.48 for c5103d0 in the same harness here. The branch's own sparse warm CPU held at 77–93 ms across those runs; main's moved between 64 and 88, which is most of the spread in the ratio. What remains is mostly JIT time for the extra code paths: in a profile of the second read in one process, main-thread work matched main's within 2 ms, and the pool threads did none. Sparse warm read time overlaps main's in every run (1.02–1.15).
Cold sparse costs ~20% more CPU than main: on a cold read the first slow call (over 2 ms) moves the rest of the lease to the thread pool, which is what keeps a local disk that stalls from blocking the event loop for more than one call.
The longest event-loop gap now matches main's. On the fast path, slices take only what fits in their 4 ms, as main's do. Loop-gap max for the 1 MB session reads 4.6–4.8 ms against main's ~1 ms, but that is how the harness samples, not a longer block. The sampler records a gap only when its 2 ms tick fires before the read resolves, so a read that ends on sync work never has its last gap counted. Main's small read usually ends inside its first gap. One main run that got a second tick recorded 5.4 ms. A probe that also counts the last gap found the longest block at 4.3–5.1 ms on main and 3.3–5.4 ms here: parsing the 1 MiB window inside one slice.
Local disk on 7edf4df against main 05c9133, 5 runs each: sparse warm 61 → 67 ms (ratio 1.15 [1.04–1.62]), CPU ratio 1.22 [1.13–1.68]; dense warm 0.95, dense cold 0.86; longest event-loop gap, sparse warm 4.6 / 4.7 ms and dense warm 6.6 / 6.4 ms. Sparse cold CPU came out 1.39 [1.07–1.76] in this run (1.22 on b9b2acd); a cache miss now also stats the directory beside its
statfs, and every bench read is a fresh process, so each pays it once. I haven't separated that from load; your spot-check on a quiet host will show it better.Over SSHFS: sshfs 3.7.6 with
cache=no,direct_io,no_readahead, 14 ms round trip, 3 runs each. These are unchanged from c5103d0; network filesystems never take the fast path. An interleaved SSHFS run of c5103d0 against b9b2acd's parent gave ratios of 1.00 and 1.02 for the small-session rows.Main sets the slice's 4 ms clock before its blocking read, and one 1 MiB read over SSHFS takes ~110 ms, so each slice parses about one record and the next slice reads the same chunk again. This branch's single ~100 ms stall per read fits one round trip of the synchronous
lstatSyncinhistoryVersion(see the follow-up).System calls on the transcript, page cache warm, under strace:
Main with fix(claude): recheck a same-size history rewrite within one timestamp tick #2620 reads the dense transcript about 4.6 times over; this branch reads it once.
A small session's extra ~3 ms is once per process: starting Bun's thread pool for the first
statfsand open of a directory, and the first run of the reader's code paths. After that, on a local filesystem, a read makes no thread-pool round trip, and compiled it is within noise of main's (2.3 against 2.1 ms in the profile).Not run locally: the integration and browser suites, SSH, native Windows and macOS. Upstream CI covers them.
The benchmark is a standalone harness anyone can run: https://gist.github.com/duytbp/470e90421331b31ccd953de200d6aae0 (synthetic transcripts only;
bench.sh <rev>for local,MODES=sshfsadds the SSHFS lab, which needs Linux and sudo).Optional follow-up for the owner: adding
server/transcript-reader.test.tsandserver/claude-command-history.test.tsto the Native Windows and remote Windows test lists. That is a workflow change, so it is left out until approved.All fixtures are synthetic files in temporary directories. No live herdr session or real transcript was used.