Repository navigation
Description
Story
As a person running xmd repl for hours with a Sidekick, I want the process to keep working as the entries history grows, so a long coordination session ends when I leave it rather than when memory runs out.
Observed behavior
A live xmd repl (compiled 0.13.1 recovery build, revision caa92fb8) whose entries.jsonl grew from 356 MB to 366 MB was sampled passively: two point samples at 04:39:04Z and 04:40:07Z, then a continuous series every thirty seconds from 04:41:16Z to 04:50:26Z (9 minutes 10 seconds, 19 ticks). That process appears in 11 of the 19 ticks, from 04:41:16Z to 04:46:21Z; its resident size moved between 2.3 GB and 4.8 GB across those 13 samples, with swings of about 2 GB between consecutive samples. It then exited normally (exit code 0 at 04:46:35Z) when its owner closed it with Control-C after its delivery completed; that exit is not memory evidence. A sibling REPL with a 22.7 MB history sat at about 200 MB (2 point samples, 19 ticks) and fresh ones at about 150–220 MB.
A previous coordinator REPL on the same build reached a 536,365,110-byte entries.jsonl and exited. The person running it reports the exit as an out-of-memory failure; no independent exit record was found, so the historical cause stays unknown. That history is 505,778 bytes short of V8's maximum string length (0x1fffffe8 = 536,870,888 UTF-16 units).
What that history holds (measured by streaming it, metadata only):
| count | bytes | |
|---|---|---|
| records | 954 | 536,365,110 |
root close records |
23 | 525,110,323 (98%) |
| records over 10 MB | 11 | 517,206,095 |
exec records (captured gh run view … --log output) |
4 | ~9.9 MB |
| everything else | 916 | ~1.3 MB |
One captured CI log (a 4.6 MB string bound as a root value) appears again inside the bindings member of every later entry's root close; the largest close record is 57 MB and holds roughly a dozen such copies. The Sidekick history (sidekick.jsonl) is 2.9 MB over 574 records and is not a factor.
Reproduction
An isolated harness imports the checkout's own packages/cli/src/repl/{journal,model,entries}.ts and packages/core/src/json.ts at e4dbb84f under Deno 2.9.7 (V8 15.0, default heap limit 4192 MB) and performs the copy sequence a session performs when it opens a history: readRecords, the RetainedReplStream constructor, readAll() for session start, readAll() for the session observer, two projectRepl projections, the next entry's entryInitialBindings plus core's parseJsonObject copy, and the running entry's EntrySegmentStream snapshot. The journal is synthetic: twelve entries, each publishing one large string binding, each root close carrying every inherited binding, exactly the shape above. No real history bytes are used.
| synthetic journal | parsed | + stream clone | + readAll #1 | + readAll #2 | + model ×2 | peak RSS | outcome |
|---|---|---|---|---|---|---|---|
| 92 MB, one-byte strings | 208 MB | 298 MB | 388 MB | 478 MB | 478 MB | 1.0 GB | ok |
| 370 MB, one-byte strings | 755 MB | 1,115 MB | 1,475 MB | 1,835 MB | 1,835 MB | 3.9 GB | ok |
| 508 MB, two-byte strings | 1,524 MB | 2,514 MB | 3,504 MB | — | — | 7.1 GB | Fatal JavaScript out of memory: Reached heap limit |
| 554 MB, any | — | — | — | — | — | — | Cannot create a string longer than 0x1fffffe8 characters from readTextFile |
Heap figures are heapUsed after a forced GC with every earlier copy still referenced. Each structuredClone copy of the stream costs one file-size of heap (two for two-byte strings), takes 80–270 ms, and the two projections together add under 1 MB because detach shares string values. Four structuredClone calls on the largest event (49–68 MB of JSON) add 240–330 MB.
The historical history holds two-byte strings (its captured CI logs carry U+FEFF marks), so it is the third row's case.
Confirmed findings
-
Every entry's root close re-records the whole inherited root environment.
carriedBindings(execute.ts#L2009) writeseligibleRootBindings(computed-bindings.ts#L199) into the close, and the next entry inherits all of it throughentryInitialBindings(model.ts#L538) andinitialRootBindings(execute.ts#L4267). A large value bound once is therefore written again by every later entry, so the file grows quadratically in the number of entries. This is the intended durable contract (an entry hands on the root as it stood), and it is what produced 525 MB of close records from under 15 MB of distinct captured output. -
The REPL holds at least two complete copies of the history in memory for the session's lifetime, and every read is a full deep copy.
RetainedReplStreamkeeps every event (journal.ts#L69) andreadAll()returnsthis.events.map(structuredClone)(journal.ts#L80-L82). The session calls it once to start (session.ts#L612) and once more for the array it keeps and appends to (session.ts#L1499-L1505);admissible()projects that array again on every submission (session.ts#L1062). The projection itself is cheap; the copies are not. -
Each append materializes the new record four more times.
EntrySegmentStream.appendclones the event, the physicalRetainedReplStream.appendclones it again, announces a third clone to the observer, and the segment keeps a fourth (entries.ts#L284-L291, journal.ts#L85-L90), beside the serialized line. In the synthetic harness, four such clones of the largest event cost 240–330 MB of heap. The live process's swings of about 2 GB between samples are a correlation only: repeated copies plausibly contribute, and the remaining transient allocation and reference lifetimes were not measured. -
A history cannot be reopened at all once it exceeds V8's maximum string length.
readRecordsreads the whole file through onereadTextFile(journal.ts#L191). The historical history was within 0.1% of that ceiling when it stopped. -
The rest of the suspected owners are not the retainer. Terminal listeners are removed in the owner's teardown (terminal-host.ts#L171-L185), the frozen
ReplModelshares string values, the Sidekick writer holds a 2.9 MB record list, and the renderer copies only the current frame.
Hypotheses (not established)
- The historical coordinator's exit was V8's heap limit being reached while opening or appending to its 536 MB two-byte history. The harness reaches that limit at 508 MB two-byte; whether the live process holds the start-time array as long as the harness does is unverified.
- Resident size above the held heap (7 GB peak against 3.5 GB heap) is V8 garbage from the discarded clones rather than a further owner.
- Journal growth is driven by documents that bind captured command output as root values; a document that binds nothing large keeps a small history. Whether such a history also grows memory monotonically over hours is not measured.
Acceptance
- Opening and continuing an
xmd replhistory of 500 MB with two-byte content stays under the default V8 heap limit, measured with the harness above or an equivalent test underpackages/cli/tests/. - Appending one record costs memory proportional to that record, not to the history: the stream keeps one copy and hands out views rather than deep copies, or an equivalent that a regression proves.
- An entry's root close carries each inherited value at most once across the history, or the reopening reader tolerates the duplication without materializing every copy; whichever is chosen, replaying every existing history in
packages/cli/tests/fixtures/repl/and therepl-historysuite still passes. - A history past the string ceiling is refused with a sentence naming the limit instead of an uncaught
readTextFileerror, and the record-by-record reader no longer depends on one string. - Negative control: the same harness against the current revision still exceeds the limit at 508 MB two-byte, so the measurement discriminates.
Next experiment
Run the compiled binary itself, under an isolated data directory, against the synthetic 508 MB two-byte journal, and record whether xmd repl xmd://repl/<execution>/entries opens; then sample its RSS across ten appends of a 50 MB close. That separates the harness's reference lifetimes from the real session's, and says whether the fix belongs in the stream, the session, or the close record.
Evidence
Synthetic generator and harness: a Python script writing twelve entries of one large binding each, and a Deno script importing the three REPL modules above under --v8-flags=--expose-gc. Passive samples: ps resident size every thirty seconds beside stat of each process's entries.jsonl; no live process was signalled, attached to, or messaged.
Activity
Sort the fixture inventory with a method the Node typecheck has
Reduce memory use when opening and continuing REPL histories (#997)