^ticket(45): what the page keeps, an audit
2026-10-14, Matthew and Claude. ^ticket(45) on the dashboard; the series report it feeds is ^ticket(53). Branch measured: #2597 at c4e411f9c2. The question ^report(5) left open ("what memory these take has not been measured") is answered here in part.
The leak
A tab on #2597, the Full Body slide, the shirt-width slider set again and again: the live heap after two forced GCs grows about 85 MB per edit, steadily, with no plateau: 399, 482, 562, 656, 749, 830, 907 MB over six edits; +1,388 MB after 15. Measured in headless Chromium with precise memory info, first through chromedriver, then over the DevTools protocol directly (node's WebSocket).
Ruled out, each by an experiment
| Suspect | Experiment | Growth per edit |
|---|---|---|
The whole-program memos (Statics.mk, MakeTerm's three) | every one held to a single entry | about 70 MB (and redraws 18 to 31 s) |
| The undo history | capped at 2 states | about 92 MB |
| Both together | memos at 1, undo at 2 | about 73 MB |
The projector view cache (ProjectorView.ViewCache, never pruned) | its store turned off | about 83 MB (399, 643, 896 MB over six edits) |
| ShareCodec's tables (the page's table of values the worker sent; its set of hashes it sent) | a census of the tab's Maps and Sets (Runtime.queryObjects) after 0, 2 and 5 edits | bounded: the page's table went 121,723, 164,424, then reset at its 200,000 cap and refilled to 119,228, while the heap kept growing |
How, where heap snapshots fail
- Heap snapshots crash the tab.
HeapProfiler.takeHeapSnapshotkills this page's renderer at once, at 560 MB and at 400 MB alike (snap Chromium; the crash handler's ptrace errors in its log at that second). So no retainer paths. - V8's sampling heap profiler instead: started after two warm-up edits, stopped after four more, keeping only what is still alive: 390 MB sampled. It says who allocated what stays, not who holds it. The development bundle names its functions and carries
/*<<src/...>>*/comments, so each frame maps to a Reason source line. - What stays, by who made it: ValueHash's hash memo (98 MB: a WeakMap entry lives exactly as long as its object, so this is a symptom of the objects living); the worker's replies, unmarshaled (46 MB); the program's types and statics (
ConstructorMap,Typ,Statics.add, about 50 MB); the layout made through the livelit projector (Measured, viaLivelitProj, 16 MB); terms (MakeTerm,Skel). Whole versions of the editor's state, not one cache's entries.
The weak tables
Every weak structure in #2597 is a JavaScript WeakMap: no OCaml Weak or Ephemeron anywhere, so js_of_ocaml's handling of those does not come into it. A WeakMap's entry lives as long as its key, which is the trouble with them: what they keep depends on what keeps their keys, and that is written nowhere.
| Where | Key, value | Verdict |
|---|---|---|
ValueHash memo (util/ValueHash.re:24) | any block, its hash [h0, h1, size] | small values; the 98 MB above is a symptom |
ValueHash.Pairs (Equality.re:152) | a term, [the other term, the answer]: holds the other term strongly | made fresh for each equality call, so it goes with the call |
TypHash (term/TypHash.re:43) | a type, its hash | small |
IdentityTable: CallMemo's forms, entries, costly, tries (dynamics/CallMemo.re:68, 140-154) | a function body, its id-free form or its remembered calls (8 per body) | bounded per body; an entry's inputs could reach an environment |
IdentityTable: Ascriptions' unroll_memo, has_type (Ascriptions.re:79, 107) | a type, its unrolling; a value, a short list (and has_type is reset) | small |
IdentityMemo (util/IdentityMemo.re:13) | an object, [f(x)] | fine for a pure f; non-objects not kept |
The strong tables (every module-level store on the main thread)
Inventoried by a read-only pass over src/web, src/haz3lcore, src/language and src/util. Its headline: nothing on the ordinary editing path adds a program version per edit by its construction. Every store sized like a program holds one version and is replaced by the next. So what grows must be a table keyed by ids that change from edit to edit; the highlighted rows are those.
| Where | Key, value | Bound | Risk |
|---|---|---|---|
Statics.module_livelits (statics/Statics.re:37) | a module expression's id, the livelits it binds (each with its elaborated definition) | replaced or removed only when that module is analysed again; ids that disappear stay | high if a slide's modules get fresh ids each edit |
LivelitProj's last_good_view (LivelitProj.re:1167) | a livelit's id, its last good virtual DOM, whose handlers close over the statics and the whole dynamics map | replaced per id, never removed | medium: one render per id ever seen |
LivelitProj's view_memo, computed_shapes (1178, 628) | a livelit's id, its definition's elaboration and view or shape | replaced per id; removed only on an error | medium |
AppStore (globals/Globals.re:96, AppStore.re:41) | a projector's id, an app's model, update and view closures and their environment, its subscriptions | AppStore.gc is never called (a TODO at Page.re:452 says so) | medium-high where MVU apps are used and ids change |
stitch_term (derivation/DerivationExercise.re:486) | derivation editors, a stitched term: whole Editor.ts | Core.Memo.general with no bound | high, but in derivation mode only |
ScratchMode's last_saved_agent (ScratchMode.re:244) | a slide's key, its agent chat | replaced per slide | low-medium |
StructureShareSexp's two maps (util/StructureShareSexp.re:7-8) | ids, sexps or objects | reset after each root, but not if a converter raises: then they grow for good | low-medium (an exception path) |
One version each: MakeTerm's refs (MakeTerm.re:177-247), ProbeTargets' expansion (ProbeTargets.re:123, 126), ProbeFocus.cia_key, AutoProbePerform's root segment | the last parse's maps; one full version of terms, layout and statics | replaced when their inputs change | low: one stale version each |
Bounded caches: Typ's signature cache (16), TyDiCtx.fields_cache (16), ExplainThis (1000), Code's three (10,000 tokens), consistent_memo (4096), ProbeUtil (32) | small keys; some values reach older modules' types | bounded | low |
Chains of versions, checked and not found. No model keeps the version before it: CachedSyntax keeps the inputs it was last made from, but never an earlier CachedSyntax; Editor's old_statics are arguments, never stored; EvalResult's streaming fields are cleared when the result lands; the evaluator's state and its incremental cache refer to each other, but replay_state unties the knot, so caches do not chain.
Side findings, worth tickets of their own: the derivation-mode memo with no bound; AppStore.gc never called; StructureShareSexp without a try/finally; HazelDB opening an IndexedDB connection for every save and never closing it; an agent's tool results keeping two whole-program segments per edit in its chat.
Next
- Count, don't guess: a build of #2597 that reports, after every edit, the size of each highlighted table (and the ruled-out ones, for comparison). The one that grows with every edit is the holder; then clear it in an experiment and measure.
- Its fix: an owner and a lifetime for that table (entries for the ids of the program shown now, dropped when the program moves on), not a weaker reference.
- The same check on #2635.
- Then the series report (
^ticket(53)).