^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

SuspectExperimentGrowth per edit
The whole-program memos (Statics.mk, MakeTerm's three)every one held to a single entryabout 70 MB (and redraws 18 to 31 s)
The undo historycapped at 2 statesabout 92 MB
Both togethermemos at 1, undo at 2about 73 MB
The projector view cache (ProjectorView.ViewCache, never pruned)its store turned offabout 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 editsbounded: 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

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.

WhereKey, valueVerdict
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 stronglymade fresh for each equality call, so it goes with the call
TypHash (term/TypHash.re:43)a type, its hashsmall
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.

WhereKey, valueBoundRisk
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 stayhigh 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 mapreplaced per id, never removedmedium: 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 shapereplaced per id; removed only on an errormedium
AppStore (globals/Globals.re:96, AppStore.re:41)a projector's id, an app's model, update and view closures and their environment, its subscriptionsAppStore.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.tsCore.Memo.general with no boundhigh, but in derivation mode only
ScratchMode's last_saved_agent (ScratchMode.re:244)a slide's key, its agent chatreplaced per slidelow-medium
StructureShareSexp's two maps (util/StructureShareSexp.re:7-8)ids, sexps or objectsreset after each root, but not if a converter raises: then they grow for goodlow-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 segmentthe last parse's maps; one full version of terms, layout and staticsreplaced when their inputs changelow: 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' typesboundedlow

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

  1. 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.
  2. 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.
  3. The same check on #2635.
  4. Then the series report (^ticket(53)).