Skip to content

fix(web): bound session catalog and history read cost - #871

Merged
tt-a1i merged 3 commits into
mainfrom
cursor/bound-session-read-cost-5259
Oct 16, 2026
Merged

tt-a1i merged 3 commits into
mainfrom
cursor/bound-session-read-cost-5259

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fixes #834. Every Web session read scanned full JSONL history, and pagination did not bound that load.

  • Snapshot listing called Pi SessionManager.listAll, which reads and parses every session file (and builds allMessagesText) before keeping at most 500. Sidebar fork and unread evidence then opened those files again.
  • History and source pagination called synchronous SessionManager.open, which parses the whole file on the request thread before applying the projection limit.
  • Transcript search used that same unbounded catalog first. Its 250-file / 16MiB / 1s budget and AbortSignal did not cover the pre-scan.

Coordinated with #835 / PR #854: this change does not touch the live-message SSE budget or the files that PR edits.

Value

An unchanged catalog is not read again. Append, rename, delete, and replacement invalidate only the affected file. Search cancellation and the byte/time budget include the catalog pre-scan, so a cancelled or oversized query does not keep scanning the directory. Opening a non-current session reads the file asynchronously, parses long history in timer turns so the request thread can run other work, and a later page reuses that result instead of reading the file again. Pi remains the parser and the branch authority.

Approach

  • SessionCatalog caches Pi-compatible metadata by file identity (dev, ino, size, mtimeNs, ctimeNs). A repeated unbounded list shares one in-flight scan. A bounded list used by search stops scheduling body reads on abort, on the 16MiB cap, or at the deadline. Compact structural entries feed the existing fork and turn-evidence readers, so the sidebar does not open every file.
  • SessionLoader reads the file asynchronously and parses it with Pi's parseSessionEntries. A file of at most 4000 lines is one parse. A longer file yields with setTimeout(0) before each 4000-line turn, including the first, so a caller timer can run between turns (setImmediate would starve those timers). The result is SessionManager.inMemory with the real session path, cached by file identity for four sessions. Writes still go through SessionManager.open.
  • Search subtracts catalog bytes and elapsed time from the existing limits. A remaining limit below 1 is a partial result, not a zero limit passed into the search contract.

Validation

Same synthetic script as #834, plus /proc/self/io rchar during each call and a repeating setTimeout(0) probe for the longest gap (maxStallMs). Node v22.23.3, linux x64, one process. The issue's published numbers were Node 26.8.1 on another machine; the before/after pair below is this machine. Before, timerDelayMs matched total time because SessionManager.open and listAll held the thread until they returned. After, that single start-of-call timer runs during the async read, so maxStallMs is the main-thread delay.

The catalog script grows one directory through 100, then 500, then 1000 files of about 64KiB. Before, every call re-read every body (about 4× the file bytes: listAll plus sidebar opens). After, a call reads a body only when the file identity is not cached. The 1000-file first call reads 31.39MiB because the earlier 500 files are already cached; a cold 100-file directory reads 6.28MiB, matching the file bytes. A repeat reads nothing (stat does not increment rchar). 1000 files still project 500 and omit 500. History still projects 40 entries.

Case Before ms Before read MiB Before stall ms After ms After read MiB After stall ms
catalog 100 repeat 0 67.8 26.29 20.5 6.28 3.1
catalog 100 repeat 1 57.7 26.28 2.7 0 2.0
catalog 100 repeat 2 53.9 26.28 2.4 0 1.8
catalog 500 repeat 0 268.2 131.42 63.3 25.11 10.2
catalog 500 repeat 1 274.7 131.42 12.8 0 6.1
catalog 500 repeat 2 227 131.42 12.3 0 8.6
catalog 1000 repeat 0 471.7 196.15 75.1 31.39 9.6
catalog 1000 repeat 1 438.4 196.15 19.1 0 7.8
catalog 1000 repeat 2 460 196.15 15.6 0 7.7
history 1000 repeat 0 (1.13 MiB) 10.1 1.13 10.5 8.6 1.13 4.1
history 1000 repeat 1 7 1.13 7.3 2.9 0 2.9
history 10000 repeat 0 (11.29 MiB) 54.3 11.3 56.1 49.2 11.29 8.0
history 10000 repeat 1 42.1 11.3 44.6 2.8 0 2.7
history 50000 repeat 0 (56.56 MiB) 214 56.56 225 242.8 56.56 31.3
history 50000 repeat 1 225.8 56.56 237.4 5.8 0 5.8

Before stall is the issue's timerDelayMs (it could not run until the synchronous parse finished). After stall is maxStallMs. The 56MiB history still takes about the same wall time on the first read, because the bytes are still parsed, but the longest timer gap is 31.3ms instead of 225ms. The repeat does not read the file; the remaining few milliseconds are projecting the cached branch.

Correctness tests compare catalog fields with SessionManager.listAll and branch, fork, and unread results with SessionManager.open plus the existing readers. The long-history test checks branch length, leaf id, and the earliest entry against that oracle, and counts setTimeout(0) turns scheduled after the body read returns. A fully synchronous parse schedules none (verified by removing the yields). The tests do not use wall-clock timeouts, retries, or widened test timeouts.

On e6ecc22943dd08231a6442f21352c89694cdcae1: bun run check passed. bun run test exited 0: Node tests 2667 total, 2658 passed, 9 skipped, 0 failed; UI tests 99 files, 1531 passed. CI run 38922966105 completed with conclusion success, including both Web E2E shards.

Impact

  • User-visible behavior: session list, history, and branch projection stay aligned with Pi. A search whose catalog pre-scan exhausts the 16MiB or 1s budget returns a partial result instead of scanning the rest of the directory first. Long non-current sessions no longer block the request thread for the whole parse.
  • Model-visible context/tools: none.
  • Runtime/lifecycle: catalog and loader caches are per adapter. There is no session-parse worker.
  • Persisted config/data: none. Session files are unchanged.
  • Compatibility or risk: a rename that changes ctime re-reads that file only. The first read of a long session still parses the whole file, in bounded turns; a later read of the same identity does not.
Open in Web Open in Cursor 

cursoragent and others added 3 commits October 16, 2026 14:32
Cache Pi session metadata by file identity and parse non-current history off the request thread, so an unchanged catalog, a cancelled search, and a later history page do not read or synchronously parse every JSONL body again.

Fixes #834

Co-authored-by: tt-a1i <tt-a1i@users.noreply.github.com>
The installed Node types do not treat fs.Stats as generic. Keep the bigint file identity structural, and format the session read tests.

Co-authored-by: tt-a1i <tt-a1i@users.noreply.github.com>
Parse non-current history with Pi on the parent, in 4000-line turns
separated by timers, and reuse the result while the file identity matches.
A worker made the first snapshot wait on startup and kept a later prompt
from being sent.

Co-authored-by: tt-a1i <tt-a1i@users.noreply.github.com>
@tt-a1i
tt-a1i marked this pull request as ready for review October 16, 2026 15:27
@tt-a1i
tt-a1i merged commit 708a44b into main Oct 16, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(web): 会话读取反复扫描全部历史,分页未限制底层加载成本

2 participants