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
sessionQuery.readEvent clones the whole log per call: a documented bounded read costs O(log length), stalling the Host (~257 s on a 4374-event session)
#7434
sessionQuery.readEvent is documented as "one full event plus a bounded raw-log window", but its implementation resolved the source through SessionCorpus.load, which detaches the complete log. Every single-event read therefore deep-cloned every event in the session.
A point read hides this. A per-seq scan is quadratic, and because it runs on the single Node thread it stops the whole Host from answering HTTP.
Measured impact
On a 4374-event session (1682 surface nodes):
Quantity
Value
Full-log structuredClone per readEvent
76.5 ms
Nodes × passes in one repair scan
1682 × 2
Blocked main-thread time
~257 s
After the fix (O(window))
milliseconds
Observed symptom: sending one message in a long-lived session pinned one core and made every HTTP request time out (the Web GUI showed all requests pending and conversation history would not load). The process stayed alive; only the event loop was blocked.
An 18 s CPU profile taken during the stall put 89.8 % of samples inside structuredClone, and a structuredClone tracer confirmed the call chain:
readSessionEvent (a plugin calling the documented bounded read, once per surface node)
→ sessionQuery.readEvent
→ SessionQueryEngine._readEvent
→ SessionCorpus.load ← resolves the full log
→ snapshotLive ← snapshotEvents().map(e => structuredClone(e))
The window itself was always small (before/after default to 0), so the retained result was one event — copied at the price of the entire log.
Root cause
_readEvent needed a header, an inherited-event count, one target event, and a bounded slice. It obtained them from load, whose contract is to detach everything:
constloaded=awaitthis._corpus.load(sessionId,signal)// clones every eventconsttarget=loaded.events[seq]constevents=loaded.events.slice(startSeq,endSeq+1).map(snapshotSessionEvent)
The copy is O(log length) per call, and the documented contract promises O(window). Any consumer reading one seq at a time pays the difference.
Fix (already implemented locally)
Split resolution from copying:
SessionCorpus.borrow resolves the same live-first source as loadwithout copying it; load is now borrow plus the detaching copy.
_readEvent calls borrow and snapshots only the retained window.
snapshotLive became dead code and was deleted.
Borrowing is safe because both sources are already immutable: a live Session returns its deeply frozen snapshot, and a persisted read owns the events it just materialized. The observable SessionEventWindow contract is unchanged — callers still receive a cloned header, cloned target, and cloned window events.
Verification: the package's 99 tests pass, typecheck passes, corpus.ts/index.ts reach 100 % coverage, and a new regression test asserts readEvent calls borrow once and never calls load — it fails against the previous full-log implementation (negative control verified).
Why this is worth reporting even though a plugin triggered it
The triggering plugin was calling a documented bounded read exactly as advertised. The defect is the contract/implementation mismatch in session-query, so it will affect the next per-seq reader too — the Host's own history paths go through readEvent as well. A consumer-side workaround (batching or caching in the caller) leaves the trap armed for everyone else.
Since this repository does not accept external pull requests, I am reporting it here with the fix and evidence. Happy to provide the patch, tests, and the Agent Note on request.
Environment
DSH web profile, source launch (apps/cli/src/bin.ts via tsx)
Windows 11, Node 24
packages/session-query/session-query at 7111db7281
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Summary
sessionQuery.readEventis documented as "one full event plus a bounded raw-log window", but its implementation resolved the source throughSessionCorpus.load, which detaches the complete log. Every single-event read therefore deep-cloned every event in the session.A point read hides this. A per-seq scan is quadratic, and because it runs on the single Node thread it stops the whole Host from answering HTTP.
Measured impact
On a 4374-event session (1682 surface nodes):
structuredCloneperreadEventO(window))Observed symptom: sending one message in a long-lived session pinned one core and made every HTTP request time out (the Web GUI showed all requests pending and conversation history would not load). The process stayed alive; only the event loop was blocked.
An 18 s CPU profile taken during the stall put 89.8 % of samples inside
structuredClone, and astructuredClonetracer confirmed the call chain:The window itself was always small (
before/afterdefault to0), so the retained result was one event — copied at the price of the entire log.Root cause
_readEventneeded a header, an inherited-event count, one target event, and a bounded slice. It obtained them fromload, whose contract is to detach everything:The copy is
O(log length)per call, and the documented contract promisesO(window). Any consumer reading one seq at a time pays the difference.Fix (already implemented locally)
Split resolution from copying:
SessionCorpus.borrowresolves the same live-first source asloadwithout copying it;loadis nowborrowplus the detaching copy._readEventcallsborrowand snapshots only the retained window.snapshotLivebecame dead code and was deleted.Borrowing is safe because both sources are already immutable: a live Session returns its deeply frozen snapshot, and a persisted read owns the events it just materialized. The observable
SessionEventWindowcontract is unchanged — callers still receive a cloned header, cloned target, and cloned window events.Verification: the package's 99 tests pass, typecheck passes,
corpus.ts/index.tsreach 100 % coverage, and a new regression test assertsreadEventcallsborrowonce and never callsload— it fails against the previous full-log implementation (negative control verified).Why this is worth reporting even though a plugin triggered it
The triggering plugin was calling a documented bounded read exactly as advertised. The defect is the contract/implementation mismatch in
session-query, so it will affect the next per-seq reader too — the Host's own history paths go throughreadEventas well. A consumer-side workaround (batching or caching in the caller) leaves the trap armed for everyone else.Since this repository does not accept external pull requests, I am reporting it here with the fix and evidence. Happy to provide the patch, tests, and the Agent Note on request.
Environment
apps/cli/src/bin.tsvia tsx)packages/session-query/session-queryat7111db7281All reactions