Skip to content

Resolve UI memory leaks#1578

Draft
elopez wants to merge 5 commits into
masterfrom
leaks-ui
Draft

Resolve UI memory leaks#1578
elopez wants to merge 5 commits into
masterfrom
leaks-ui

Conversation

@elopez

@elopez elopez commented Jun 10, 2026

Copy link
Copy Markdown
Member

No description provided.

elopez and others added 5 commits June 11, 2026 12:10
Lens-based execution leaves VM record fields (contract storage maps,
keccak preimages, constraints, logs) as thunk chains referencing every
intermediate VM of a replayed sequence. Storing such a VM -- in
EchidnaTest.vm on falsification/optimization progress and on every
successful shrink step -- retains the whole execution history until the
field is demanded, which reporting only does once at campaign end. The
interactive UI's periodic test snapshots observe these chains
continuously, where they accounted for hundreds of MB of standing heap
(retainer profiling: addAliasConstraints/accessUnboundedMemory sets;
forcing cut the standing Map.Bin census band from 298 MB to 63 MB on a
20-failing-test benchmark).

Add forceVMData to collapse the accumulating fields when a VM is about
to be stored, and force NewCoverage.transactions so queued/retained
events no longer pin each sequence's VMResults and returndata through
the `fst <$> results` thunk.

Co-Authored-By: Claude <noreply@anthropic.com>
The Brick UI retained large amounts of heap on long campaigns; with 20
always-failing tests the same workload that holds a flat 12.9 MB in
--format text reached 915 MB live (4.2 GB peak residency) and kept
growing for the whole interactive session. Several compounding causes,
each measured with heap censuses and cost-centre/retainer profiles:

- The 200 ms ticker queued CampaignUpdated events carrying full
  [EchidnaTest]/[WorkerState] snapshots; with a slow consumer the
  bounded-at-1000 channel could pin up to a thousand snapshot
  generations. Publish the snapshot through an always-latest IORef and
  send a payload-free CampaignTick instead (stale ticks drop harmlessly).
- The tests pane was rebuilt on every tick, allocating ppTx/trace
  pretty-printing structures per test per 200 ms which accumulate;
  with --shrink-limit 0 this leaked 1.6 MB/s indefinitely (527 MB after
  6 min vs 35 MB after this change). Cache the pane keyed on a cheap
  (state, value) digest and rate-limit rebuilds to once per second.
- Widget and state structures captured test records (and their VM
  snapshots) through unevaluated show/format thunks; force the rendered
  strings and store a VM-free copy of the tests in UIState.
- The log pane history was an unbounded Seq, also rendered with an O(n)
  fold per frame; cap it at 5000 entries.
- Sever the previous campaignWidget before building the new one and
  replace the state strictly (WHNF) so superseded generations don't
  chain through widget closures or lazy modify.

A residual remains on the always-failing stress workload: data created
during the falsification/shrink burst (~700 MB) stays reachable through
closures inside brick's customMainWithVty event loop (retainer sets
"(N)customMainWithVty,m..."), surviving all application-side stripping;
pinning it down needs ghc-debug-grade tooling. Typical campaigns with
few failing tests are unaffected by that residual.

Co-Authored-By: Claude <noreply@anthropic.com>
The interactive (Brick/vty) UI retained one UIState generation per rendered
frame, unbounded: vty parks the last Picture in an IORef (lastPicRef) for
resize redraws, and Brick builds that Picture lazily -- its image/viewport
nodes are renderFinal/viewportScroll thunks closing over the UIState they
were rendered from, which through the prior render closes over the previous
UIState. The single unforced Picture is thus a thunk chain reaching back
through every frame, pinning each generation's campaigns/WorkerState/GenDict
and (on falsifying contracts) its test VM snapshots.

ghc-debug on a live process showed a single retainer path with 788 chained
renderFinal/viewportScroll thunk pairs off lastPicRef. Measured: +12 kB/s
unbounded on a trivial non-falsifying contract (interactive), and 681 MB
pinned on a 20-failing-assert stress contract.

Fixes:
- UI.hs: deep-force each Picture before handing it to vty, so lastPicRef
  holds concrete images instead of a UIState-closing thunk chain (primary).
- UI.hs: force the VM-stripped test copies; `t { vm = Nothing }` was a thunk
  retaining the VM-bearing original.
- Campaign.hs: bind NewCoverage.transactions strictly; `force (...)` on a
  non-strict field was itself a thunk pinning the sequence's VMResults.
- Exec.hs: forceVMData now also forces vm.tx.subState, whose lazily-consed
  Expr EAddr elements otherwise chain a stored VM to every intermediate VM
  of its transaction.

Validated: trivial-contract interactive growth +12,071 -> +517 B/s (UIState
generation growth +4.1 MB -> +0); stress-contract live heap 681 -> 39 MB
flat; ghc-debug generation chain 788 -> 0.
updateOpenTest binds result = getResultFromVM vm' lazily; EchidnaTest.result
is a lazy field, so every falsified or optimized test stored in testRefs --
and every VM-free copy pushed as a TestFalsified/TestOptimized event --
retained the unevaluated thunk and with it the full post-checkETest VM,
one extra VM snapshot per test. Force the result in the two branches that
store it. The binding itself must stay lazy: on the fall-through path
checkETest can return a VM with no result, and forcing there crashes the
worker (getResultFromVM calls error). Same hygiene bang in shrinkTest.
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.

1 participant