Repository navigation
fix(history): keep every Pi session in Earlier sessions when label scans hit the deadline - #2410
Conversation
The shared listing deadline discarded unfinished scans and rejected the entire list. A slow transcript could never finish, and stopping at that file also hid older sessions, even when their labels were already cached. Establish up to 50 newest-first rows with stat-checked header reads bounded to one 64 KiB chunk per candidate and a shared ten-second header deadline. Keep long-header progress instead of permanently refusing it. Reject ineligible headers before caching and reuse checked Windows eligibility. Spend the remaining shared two-second label deadline on labels, retaining bounded parser checkpoints under the existing stat key. Keep completed cached labels and return null unfinished labels; later opens resume stable files. Preserve queue limits and EOF torn-read validation. Relax #2377's "never incomplete labels" policy because optional labels must not imply that sessions are absent. Established rows survive label read errors, changes and deletion without publishing torn labels. Queue saturation, shutdown and unavailable headers with no checked rows retain the compatible retryable 503 response. Keep #2402's reader/server deadline injection for the long-store tests. Fail-first: five regressions against rebased origin/main 7a6f8b0 produced 0 pass, 5 fail with PiMetadataBusy. The earlier staged implementation also failed queued/first-chunk/all-older-row cases (0 pass, 3 fail), and the cached older-row case (0 pass, 1 fail). Round-two HIGH reproductions failed 0/9 before the fixes; both header timeout cases also failed. All 11 now pass. The complete fixed reader suite passes all 60 tests. The combined reader/history suites pass 159 tests, including explicit no-row refusal for swapped symlinks and mismatched descriptor identities. Browser QA uses the real components and an isolated fictional bridge with injected read time: origin/main shows an error after seven requests; the fix shows every row with fallback IDs and fills labels on reopening. Desktop and phone captures are retained locally under .omo/run/. Final validation: isolated history API cases 7 pass; check fast exit 0, source unchanged and verified, including typecheck, build and 8805 passing unit tests with 35 platform skips across 557 files.
A newest-first traversal of 257 headers larger than 64 KiB churned the 256-entry cache. Each open evicted the next checkpoint before reaching it, so even the second open returned busy instead of completing a header. Stop the header traversal after 50 unfinished headers, independently of the 50-row limit. This keeps one traversal well below cache capacity and lets the next open complete the newest headers. Completed ineligible headers do not consume this progress budget. Preserve the accepted behavior for an unfinished header on the first response and for busy when no header could be checked. Fail-first: a frozen-clock fixture with 257 valid 70 KiB headers failed on 98df12ca (0 pass, 1 fail: PiMetadataBusy on the second open). With this fix, the same test passes and returns the newest 50 labeled rows on that open, without rereading their first chunks. The full reader suite passes 61 tests. Validation: isolated history API cases 7 pass. Required check fast exits 0 with unchanged-source verification: typecheck, build and 8806 unit tests pass, with 35 skips across 557 files.
Limiting unfinished headers alone missed completed headerless files cached as null. With six empty files between each of 50 long headers, a traversal touched 344 paths and churned the 256-entry cache before any header could complete on reopening. Bound a traversal to 200 potentially cached file touches, including null and future cached results. Label scans revisit only that set, so one traversal cannot evict its own checkpoints. Only explicitly rejected headers are exempt because they never enter the cache; discovery still bounds their I/O and the 260-foreign-header regression keeps working. Document the invariant and the accepted low-likelihood starvation limit for actively growing oversized headers. Fail-first on 606cf7b2: the deterministic 344-file reproduction gave 0 pass, 1 fail (PiMetadataBusy on the second open). After the fix, the same test gives 1 pass, 0 fail and completes the 29 long headers among the first 200 cache-bearing files without rereading their prefixes. The full Pi reader suite passes 62 tests. Validation: isolated history API cases 7 pass. Required check fast exits 0 with unchanged-source verification: 8807 unit tests pass, 0 fail, 35 skip across 557 files; typecheck, build and generated-file checks also pass.
Caching completed null results made 200 static empty JSONL files consume every cache-bearing touch on each open, returning a false empty list even though a real older session remained in discovery. Do not retain completed headerless results, and make their null returns free in the cache-touch budget. Keep counting all retained entry touches, including hits: insertion-only counting is unsafe because hits can promote old entries past newly inserted checkpoints before the next eviction. If a cutoff leaves discovery unfinished and no row, return busy rather than an empty success. Export PI_CACHE_ENTRIES and derive the touch cap as three quarters of it, so the no-self-eviction bound cannot silently exceed the cache capacity. Fail-first on 5405fbbc: both 200- and 201-empty-file regressions returned empty arrays (0 pass, 2 fail). Both now list the older real session on the first and second opens (2 pass, 0 fail), caching only that real session. The interleaved-empty-file regression now recovers all 50 long headers on the second open. The full Pi reader suite passes 64 tests. Validation: isolated history API cases 7 pass. Required check fast exits 0 with unchanged-source verification: 8809 unit tests pass, 0 fail, 35 skip across 557 files; typecheck, build and generated-file checks also pass.
Discarding every completed headerless result restarted unchanged files larger than 64 KiB on later opens. They repeatedly consumed unfinished header slots and delayed an older valid Pi session. Cache the stat-validated empty result only when the file needed more than one chunk. Notify the listing for these retained null results, including cache hits, so they consume the same traversal budget as other entries. Single-chunk headerless files remain uncached and free. Fail-first on 90da040d: 150 unchanged 65537-byte whitespace files ahead of a valid session still returned PiMetadataBusy on the fourth open (0 pass, 1 fail). The same regression now passes: the fourth open lists the real session, subsequent opens read zero transcript bytes, and appending a valid session invalidates the completed-empty cache entry correctly. The full reader suite passes 65 tests, including the 200- and 201-empty single-chunk cases. The pre-existing 400-check discovery bound is unchanged. Validation: isolated history API cases 7 pass. Required check fast exits 0 with unchanged-source verification: 8810 unit tests pass, 0 fail, 35 skip across 557 files; typecheck, build and generated-file checks also pass.
📝 Summary
Merge Risk:
|
|
Merging c9c90d8. Pi 'Earlier sessions' is no longer emptied by the 2 s listing deadline: every session gets a row (its ID until its label is read), labels fill in on later opens and the scan resumes where it stopped; 503 session_history_busy is kept for a full queue, a stopped reader or nothing checkable, which the client already retries. Builds on @IdrisGit's reader from #2377. Reviewed by the merge pass after the lane's seven rounds: no HIGH; the client's retry and the ID fallback stay compatible with older clients; 170 reader/history tests, 7 API contract tests and typecheck pass. One MEDIUM, reproduced with an injected 2.1 s header check, goes to follow-up #2411: when checking file headers alone uses up every listing's deadline, label reading never advances (rows stay, labels stay as IDs) (server/session-history.ts:509-510,553). Known limit, accepted: 192+ header-less files over 64 KiB newer than every session keep a retryable 503 (Pi never writes such files; main has the same bounded-discovery shape). |
Found by the flake-hunt lane while it fixed the #2377 timing flakes (#2402); Dori decided to fix it in its own PR. On a slow PC, Earlier sessions for Pi could become an error that never cleared.
The bug (MEDIUM, user-visible)
@IdrisGit's streaming reader (#2377) reads each Pi transcript in full, so native names and first prompts show even when they sit deep in the file. A listing has one shared two-second deadline. By design, a scan that cannot finish returned a retryable error, never incomplete labels:
session_history_busy. The client retried up to six times, then emptied the list and showed "Pi sessions could not be read right now. Try again." with a Retry button.Why "never incomplete labels" is relaxed
#2377's rule kept a half-read file from showing a wrong label. What it actually cost was the whole list. This PR keeps the part that matters and drops the part that hurt:
So the list can be incomplete in its labels, but never wrong, and never empty because one file is slow.
Design
Credit to @IdrisGit: #2377's bounded streaming reader is kept whole. Its 64 KiB chunks, checked descriptors, stat-keyed cache, EOF validation and queue limits all stay. This PR changes what a deadline does and adds resumable progress:
Proof
PiMetadataBusy). Each review finding was reproduced as a test before its fix:bun run check fastpassed (8,810 unit tests).Browser, before and after (one transcript's scan exceeds an injected deadline)
Not run locally: the full Integration and native Windows lanes (CI runs them). The Windows alias case is covered deterministically.
Reviews
This PR changes server code and the history API's behaviour, so both reviewers ran, over three rounds.
omo-native-code-reviewer: APPROVE (WATCH) in round 3.
Codex (Sol 6.1 in round 1, Terra 6.1 from round 2, per the owner's switch).
Round 1 reproduced the hidden-older-rows gap. Fixed.
Round 2 raised three HIGHs and a MEDIUM; each HIGH was reproduced as a failing test first, then fixed:
Round 3 confirmed those fixed and raised three more HIGHs:
EIOon the only file). Kept, on purpose. Without one checked header there is no trustworthy session identity to list. A 200 with no rows would claim "no earlier sessions", which is the false answer round 1 removed. The 503 makes the client retry.Round 4, on the delta, found the same eviction class through headerless files: empty
.jsonlfiles cached asnullbut not counted toward the cap. Fixed in 5405fbbc: one traversal touches at most 200 files that can take a cache entry, below the 256 slots. A 344-file repro failed before and passes after.Round 5, on 5405fbbc: both reviewers found that 200 empty
.jsonlfiles newer than a real session made every listing return an empty array. Fixed in 90da040d: completed headerless results are not cached and do not count. The cap is derived from the exportedPI_CACHE_ENTRIES(3/4 = 192). A cutoff with no rows gives the retryable 503, never an empty 200. Repros: 200 and 201 empty files, 0/2 before, 2/2 after. Cache hits still count, because counting only insertions would let a hit promote an old entry past new checkpoints; omo agreed.Round 6, on 90da040d: omo APPROVE. Terra's last MEDIUM, the same case as omo's L13: a headerless file over 64 KiB restarted from byte 0 on every open, delaying older sessions. Fixed in 69415701: a completed empty result is cached only for files larger than one chunk, keyed by stat identity, and counts like any cached entry. Terra's 150-file repro failed before (0/1) and passes after (1/1); later unchanged opens read zero bytes.
Follow-up, outside this PR: Terra noted that origin/main's existing 400-check discovery bound can still produce an empty list, if enough newer empty files fill the budget before a real session is discovered. That limit predates this PR.
Known limits (documented in docs/development.md)