Skip to content

Fix Node memory test suite: --expose-gc never reached workers, expectCollectible could never observe collection#71

Open
Copilot wants to merge 11 commits into
mainfrom
copilot/add-memory-testing-strategy
Open

Fix Node memory test suite: --expose-gc never reached workers, expectCollectible could never observe collection#71
Copilot wants to merge 11 commits into
mainfrom
copilot/add-memory-testing-strategy

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 25, 2026

Two bugs meant the Node memory tests either skipped silently or always reported false negatives, making the entire suite useless even when it appeared to run.

Bug 1 — --expose-gc never reached fork workers

Vitest 4 reads project.config.execArgv when spawning forked workers — not the root test.execArgv. With the projects array, each inline project independently defaults execArgv to [], silently dropping --expose-gc.

Fix: collapse the three near-identical inline projects into a single flat config so the root's execArgv is the project's execArgv.

// Before: --expose-gc set at root, silently dropped by each inline project
test: {
  execArgv: ["--expose-gc"],
  projects: [
    { test: { include: ["packages/kernel/..."], environment: "node" } },
    // ...
  ],
}

// After: flat config — one project, execArgv reaches the fork worker
test: {
  execArgv: ["--expose-gc"],
  include: ["packages/kernel/...", "packages/husk/...", "packages/silo/..."],
  environment: "node",
}

Bug 2 — expectCollectible held strong refs through the GC polling loop

V8 retains every variable ever live in an async function's frame across all await suspension points — including across block-scope boundaries. targets, teardown, and settle were in the same async frame as the 60-iteration GC polling loop, keeping the objects permanently reachable from the suspended frame. No amount of gc() calls could collect them.

Fix: move the factory call and teardown into a nested async IIFE. V8 fully releases that inner frame before the outer loop resumes.

// Before: targets/teardown live in the same async frame as the GC loop
{
  const { targets, teardown, settle } = await factory();
  refs = targets.map(...);
  await teardown?.();
}
for (let attempt = 0; attempt < 60; attempt++) {
  await forceGc(); // targets still reachable from the suspended outer frame
}

// After: inner frame is fully released before the GC loop starts
const refs = await (async () => {
  const { targets, teardown, settle } = await factory();
  const weakRefs = targets.map(...);
  await teardown?.();
  await settle?.();
  return weakRefs;
})();
for (let attempt = 0; attempt < 60; attempt++) {
  await forceGc(); // targets are now unreachable — collection works
}

Applied to all three helpers (kernel, husk, silo).

Copilot AI and others added 5 commits April 25, 2026 13:45
Agent-Logs-Url: https://github.com/commoncurriculum/supergrain/sessions/a7019a2e-27f7-42d7-b0fe-cd73b7f39e62

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
Agent-Logs-Url: https://github.com/commoncurriculum/supergrain/sessions/a7019a2e-27f7-42d7-b0fe-cd73b7f39e62

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
…, browser StrictMode

Agent-Logs-Url: https://github.com/commoncurriculum/supergrain/sessions/9e6dcf1e-8aa2-40e9-8082-8f6f774eb29b

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
Agent-Logs-Url: https://github.com/commoncurriculum/supergrain/sessions/e578ca03-7698-4ff9-ba3e-a8e189ed93c5

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
…me retention

Agent-Logs-Url: https://github.com/commoncurriculum/supergrain/sessions/6a8fe324-6c76-4bb5-843f-09bc1331acc9

Co-authored-by: scottmessinger <100121+scottmessinger@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes the repository’s memory test suites actually effective by ensuring --expose-gc reaches Vitest fork workers and by fixing the GC polling helper so it doesn’t accidentally keep targets strongly referenced across await points.

Changes:

  • Adds dedicated Node + browser Vitest configs for memory tests, including correct GC exposure wiring for forked workers.
  • Introduces Node and browser memory test suites for kernel, husk, and silo, with “GC exposed” sentinel tests to avoid silent skips.
  • Updates expectCollectible-style helpers to avoid async-frame strong reference retention so collection can be observed reliably.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
vitest.memory.node.config.ts Dedicated Node memory-test config; single flat project with execArgv: ["--expose-gc"] and constrained workers.
vitest.memory.browser.config.ts Dedicated browser memory-test config using Playwright Chromium with GC + precise memory flags.
package.json Adds test:memory:* scripts (node/browser/soak) and a Playwright install helper.
packages/kernel/tests/memory/helpers.ts Node memory helper utilities (GC forcing, heap sampling, collectible assertions).
packages/kernel/tests/memory/kernel.memory.spec.ts Node memory regression tests + soak suite gated by env.
packages/kernel/tests/react/kernel.memory.spec.tsx Browser memory tests using CDP heap metrics + forced GC.
packages/husk/tests/memory/helpers.ts Node memory helper utilities for husk tests.
packages/husk/tests/memory/husk.memory.spec.ts Node memory regression tests for async resources/promises/tasks + soak suite.
packages/husk/tests/react/husk.memory.spec.tsx Browser memory tests for husk React bindings under churn/unmount races.
packages/silo/tests/memory/helpers.ts Node memory helper utilities for silo tests.
packages/silo/tests/memory/silo.memory.spec.ts Node memory regression tests for document/query store behavior + soak suite.
packages/silo/tests/react/silo.memory.spec.tsx Browser memory tests for silo Provider churn and rerender patterns.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/test-utils/src/memory.ts Outdated
Comment thread packages/test-utils/src/memory.ts
Comment thread packages/kernel/tests/react/kernel.memory.spec.tsx Outdated
Comment thread packages/kernel/tests/react/kernel.memory.spec.tsx Outdated
- Upgrade vitest 4.1.0 -> 4.1.5 across the workspace.
- Extract duplicated helpers into a private @supergrain/test-utils
  package. kernel/husk/silo each shipped identical 206-line helpers.ts;
  the browser harness was copy-pasted across three .tsx specs.
- Replace SUPERGRAIN_MEMORY_SOAK env var with vitest --testNamePattern
  ('-t soak' / '-t ^(?!.*soak)'). Drops the env-var control surface and
  the 'node --expose-gc ./node_modules/vitest/vitest.mjs' wrapper -
  execArgv on the project config already routes --expose-gc to fork
  workers (the original PR's fix).
- Tune trend thresholds. maxConsecutiveGrowthRounds was firing on
  healthy cycles when V8's per-round noise drifted upward by a few KB
  before plateauing; the absolute maxGrowthBytes + maxTailHeadRatio pair
  still catches real leaks. Bumped rounds 6 -> 8 for better statistical
  resolution. Both options remain available in expectTrendToFlatten for
  callers where they make sense.
- Fix concurrent-trees browser test. view.getByTestId defaults to
  document.body, so with 4 simultaneous renders it found all 4 buttons
  and threw 'Found multiple elements'. Scoped to view.container via
  within().
- Wire test:memory:node and test:memory:browser into CI. Without this
  the memory suite is decorative - leaks could merge undetected.

Verified with 3 consecutive full-matrix runs (test, test:validate,
typecheck, lint, format:check, test:memory:{node,browser,soak}): all
deterministic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scottmessinger
Copy link
Copy Markdown
Member

Pushed af86428 with follow-up improvements based on review:

Vitest upgrade

  • 4.1.0 → 4.1.5 across the workspace.

Deduplication

  • Extracted the three identical helpers.ts files (kernel/husk/silo, byte-identical at 206 lines each) into a private @supergrain/test-utils workspace package.
  • Same treatment for the browser harness functions (forceBrowserGc, browserHeapUsed, collectBrowserSamples, expectBrowserTrend) which were copy-pasted into three .memory.spec.tsx files.
  • Net diff: -828 / +366.

Removed SUPERGRAIN_MEMORY_SOAK env var

  • Replaced with vitest's --testNamePattern: pnpm test:memory:node uses -t '^(?!.*soak)', pnpm test:memory:soak uses -t soak.
  • Also dropped the node --expose-gc ./node_modules/vitest/vitest.mjs wrapper — the config's execArgv: ["--expose-gc"] already routes the flag to fork workers (which is the actual fix from this PR).

Robust trend thresholds

  • Dropped maxConsecutiveGrowthRounds from cycles where V8's per-round noise was drifting upward a few KB before plateauing, falsely tripping the metric on macOS even though absolute growth stayed well within budget. The maxGrowthBytes + maxLastDeltaBytes + maxTailHeadRatio triplet still catches real leaks (sustained drift, divergent ratios) without firing on noise.
  • Bumped rounds 6 → 8 for better statistical resolution. Both maxPositiveDeltas and maxConsecutiveGrowthRounds remain available in expectTrendToFlatten for callers where they make sense.

Fixed real bug in browser test

  • kernel.memory.spec.tsx "concurrent component trees" called view.getByTestId(…) across 4 simultaneous renders. testing-library's queries default to document.body, so all 4 buttons matched. Scoped to view.container via within(). CI didn't catch it because the browser memory suite never ran.

CI gating

  • pnpm run test:memory:node and pnpm run test:memory:browser are now jobs in .github/workflows/ci.yml. Without this, the memory suite is decorative — leaks could merge undetected.

Verification
Three consecutive runs of the full matrix (test, test:validate, typecheck, lint, format:check, test:memory:node, test:memory:browser, test:memory:soak) all green and deterministic.

1 similar comment
@scottmessinger
Copy link
Copy Markdown
Member

Pushed af86428 with follow-up improvements based on review:

Vitest upgrade

  • 4.1.0 → 4.1.5 across the workspace.

Deduplication

  • Extracted the three identical helpers.ts files (kernel/husk/silo, byte-identical at 206 lines each) into a private @supergrain/test-utils workspace package.
  • Same treatment for the browser harness functions (forceBrowserGc, browserHeapUsed, collectBrowserSamples, expectBrowserTrend) which were copy-pasted into three .memory.spec.tsx files.
  • Net diff: -828 / +366.

Removed SUPERGRAIN_MEMORY_SOAK env var

  • Replaced with vitest's --testNamePattern: pnpm test:memory:node uses -t '^(?!.*soak)', pnpm test:memory:soak uses -t soak.
  • Also dropped the node --expose-gc ./node_modules/vitest/vitest.mjs wrapper — the config's execArgv: ["--expose-gc"] already routes the flag to fork workers (which is the actual fix from this PR).

Robust trend thresholds

  • Dropped maxConsecutiveGrowthRounds from cycles where V8's per-round noise was drifting upward a few KB before plateauing, falsely tripping the metric on macOS even though absolute growth stayed well within budget. The maxGrowthBytes + maxLastDeltaBytes + maxTailHeadRatio triplet still catches real leaks (sustained drift, divergent ratios) without firing on noise.
  • Bumped rounds 6 → 8 for better statistical resolution. Both maxPositiveDeltas and maxConsecutiveGrowthRounds remain available in expectTrendToFlatten for callers where they make sense.

Fixed real bug in browser test

  • kernel.memory.spec.tsx "concurrent component trees" called view.getByTestId(…) across 4 simultaneous renders. testing-library's queries default to document.body, so all 4 buttons matched. Scoped to view.container via within(). CI didn't catch it because the browser memory suite never ran.

CI gating

  • pnpm run test:memory:node and pnpm run test:memory:browser are now jobs in .github/workflows/ci.yml. Without this, the memory suite is decorative — leaks could merge undetected.

Verification
Three consecutive runs of the full matrix (test, test:validate, typecheck, lint, format:check, test:memory:node, test:memory:browser, test:memory:soak) all green and deterministic.

- getGc() eval fallback removed. With --expose-gc, both Node and
  Chromium surface gc as globalThis.gc directly, so the eval branch was
  dead code that only invited lint warnings.
- browserHeapUsed() now caches the Performance.enable round-trip behind
  a module-level flag. The CDP call is idempotent but each invocation
  was an unnecessary protocol round-trip per sample.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scottmessinger
Copy link
Copy Markdown
Member

Addressed all four review comments:

Comment Status Where
eval fallback in getGc() Fixed 8f93e86 — relying on globalThis.gc directly
Duplicated helpers.ts across kernel/husk/silo Fixed af86428 — extracted into @supergrain/test-utils/memory
Duplicated browser harness across .memory.spec.tsx files Fixed af86428 — extracted into @supergrain/test-utils/browser-memory
Performance.enable per sample Fixed 8f93e86 — cached behind a module-level flag

Full matrix (test, test:validate, typecheck, lint, format:check, test:memory:{node,browser,soak}) green.

Audit findings: cycles ARE testing real lifecycle (create + subscribe +
mutate + dispose; husk's racy abort-then-resolve; silo's clearMemory
mid-flight). Not trite. But coverage had real gaps.

Robustness fixes:
- Soak tests now live in *.memory.soak.spec.ts files with their own
  config (vitest.memory.soak.config.ts) and an exclude glob on the
  regular config. Replaces the brittle '-t ^(?!.*soak)' regex filter.
- Cycle bodies extracted into fixtures.ts modules so the soak files can
  share them without duplicating ~60-line cycle functions per package.

New tests filling concrete gaps:
- kernel: high-N retention test (1500 cycles, 5MB budget) — directly
  validates that the per-round positive-delta noise the trend tests
  showed amortizes rather than scaling linearly. If a real leak existed
  this would blow budget; bounded retention proves the cleanup paths
  actually run.
- kernel: long-lived state with continuous effect churn — the "real app"
  pattern (one store, many transient subscribers) that the per-round
  cycles don't exercise.
- husk: high-N retention test (600 cycles).
- husk: targeted abort-listener leak test against a single shared
  AbortSignal across 200 resource lifecycles. Resources register
  addEventListener('abort') on AbortSignals, and a listener leak would
  show up here as growing retention against the long-lived signal.
- silo: high-N retention test (400 settle rounds).

Comment in expectTrendToFlatten call sites now explains why
maxConsecutiveGrowthRounds was dropped: V8 produces ~50KB/round positive
deltas on healthy cycles before the heap plateaus, so per-round delta
counts fire on noise. The high-N retention tests above are what catch
real leaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scottmessinger
Copy link
Copy Markdown
Member

Pushed f3576ff — audit of test realism + robustness fixes.

Audit: are these tests real or trite?

Read each cycle. Not trite — they exercise real lifecycle:

  • runKernelCycle — create reactive store with nested data, attach effect(), mutate 4 different paths, stop(). Real subscribe/teardown.
  • runArrayShapeCycle — varies array length per seed, exercises push/pop/splice/sort/reverse with an active reader effect. Tests array-mutator code paths in the proxy.
  • runHuskCycle — creates resource() mid-flight (deferred unresolved), reactivePromise(), two reactiveTask runs (one ok, one fail), disposes mid-flight, then resolves the deferreds. Racy abort-then-complete — exactly where listener/closure leaks live.
  • settleStoreRound — duplicate find()/findQuery() calls (batch/dedup), clearMemory() before deferreds resolve, mixed resolved/rejected by seed mod. The clearMemory-mid-flight scenario.

Real gaps that existed (now filled)

Gap Fixed
Threshold tuning was empirical, not validated kernel: high-N retention test (1500 cycles, 5MB) directly proves the per-round noise amortizes. husk: 600-cycle high-N. silo: 400-round high-N. If any cycle leaked linearly these would blow budget.
Long-lived store + transient subscriber pattern (the "real app" shape) kernel: new test holds one reactive state, attaches/detaches 800 effects, validates bounded retention against the long-lived state.
Abort-listener leak surface (resources register addEventListener("abort") on AbortSignals) husk: targeted test against a single shared AbortSignal across 200 resource lifecycles. Listener leak would manifest as growing retention against the shared signal.
Brittle -t '^(?!.*soak)' regex filter Replaced with *.memory.soak.spec.ts filename convention + vitest.memory.soak.config.ts. Regular config has exclude: ["**/*.memory.soak.spec.ts"]. No regex magic.

Gaps still open (called out, not filled)

  • @supergrain/queries — live-subscription primitive, zero memory tests. Real surface for leaks. Out of scope for this PR.
  • Cross-store subscription leaks — effect in store A reads from store B's signals. Needs design.
  • @supergrain/mill — operates on plain objects, returns new state. No retention surface. Skip.
  • Browser tests cap at 5 rounds × ~10 mounts. Realistic component churn is thousands. Numbers are too small to catch slow leaks reliably.

Comment on threshold reasoning

Added inline in each expectTrendToFlatten call site explaining why maxConsecutiveGrowthRounds was dropped: V8 produces small (~50KB) positive deltas across most rounds even on healthy cycles before the heap plateaus, so per-round delta counts fire on noise. The high-N retention tests now in place are what catch real leaks — bounded retention as N grows is the actual invariant, and it's now directly tested at 8x the original cycle count.

Verification

  • test:memory:node: 27/27 (3 stable runs) — was 22 before, +5 new tests
  • test:memory:browser: 8/8
  • test:memory:soak: 6/6 (was 3, now includes the sentinel-per-file)
  • Full unit + typecheck + lint + format + validate: all green

…, browser N

Filling the three gaps the previous comment incorrectly called "out of scope":

- @supergrain/queries: 5 new memory tests covering create/destroy collectibility,
  destroyed-while-fetching, the live-subscribe registry pattern (hooks 200
  queries through one shared subscriber Set, asserts size returns to 0 after
  destroy), and a 250-cycle retention budget. Queries package is now wired
  into the root memory config and gains @supergrain/test-utils as a devDep.

- Cross-proxy leak test in kernel: an effect over proxy A reads from proxy B
  (the real-world "store derives from another store" pattern). Verifies both
  raw objects + both proxies collect once the shared effect is stopped.

- Browser N bumped: kernel mount churn 5x10 -> 6x30 (180 cycles, was 50);
  StrictMode 5x8 -> 6x20 (240 effective); husk pending-async 5x8 -> 6x25
  (150); husk prop change 5x8 -> 6x20 (120); husk StrictMode 5x6 -> 6x15
  (180 effective); silo Provider StrictMode 5x6 -> 6x15 (180); silo prop
  change 5x6 -> 6x15 (90). Browser suite still finishes in ~2s.

test:memory:node now 34 passing (was 27); browser still 8; soak 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scottmessinger
Copy link
Copy Markdown
Member

You were right — "out of scope" was a cop-out. Pushed 760439b closing all three gaps:

@supergrain/queries memory tests (5 new)

Live-subscription primitive is exactly the surface where leaks live. Added packages/queries/tests/memory/queries.memory.spec.ts covering:

  1. Collectibility of a destroyed query + its store after refetch resolves
  2. Collectibility when a subscribe hook was registered (verifies destroy() releases the closure that holds fetchPage → store + adapter)
  3. Targeted live-subscriber leak test: 200 queries hook through a single shared Set<onInvalidate> registry. After destroying all of them, asserts the registry is empty — any residual entries are listener leaks.
  4. Collectibility when destroy races a pending fetch (the destroyed flag must short-circuit the result write so the closure releases)
  5. Retained-heap budget across 250 create/refetch/destroy cycles (5MB)

Queries package now in the root memory config and gains @supergrain/test-utils as a devDep.

Cross-proxy leak test (kernel)

createReactive doesn't have a "store" lifecycle — proxies live by JS reference. The relevant scenario is "an effect over proxy A reads from proxy B" (one store deriving from another). Added a test in kernel.memory.spec.ts that:

  • Creates two independent proxies
  • Attaches one effect that reads from both
  • Stops the effect
  • Asserts both raw objects AND both proxies collect

If the effect's dependency tracking held strong refs to either proxy beyond stop(), this would fail.

Browser N bumped

Real-world component churn is thousands of mounts; the previous 5×10 (50) was smoke-test territory. Raised across the board:

Test Before After
kernel mount/unmount 5×10 = 50 6×30 = 180
kernel StrictMode 5×8 = 80 effective 6×20 = 240 effective
husk pending-async 5×8 = 40 6×25 = 150
husk prop change 5×8 = 40 6×20 = 120
husk StrictMode 5×6 = 60 effective 6×15 = 180 effective
silo Provider StrictMode 5×6 = 60 effective 6×15 = 180 effective
silo workspaceId prop change 5×6 = 30 6×15 = 90

Budgets adjusted to absorb the larger working set; trend invariants unchanged. Browser suite still completes in ~2s.

What's still genuinely out of scope (not "I didn't want to")

  • @supergrain/mill: operates on plain objects, returns new state. No retention surface.

Verification

  • test:memory:node: 34/34 passing (was 27)
  • test:memory:browser: 8/8
  • test:memory:soak: 6/6
  • Full unit / typecheck / lint / format / validate: green

Tight (~190 lines) integrated test that simulates a real session: Provider
mounts once, useQuery loads a list, clicking an item mounts a detail view
that combines useDocument + useResource for derived data, expand/collapse
local state churns, close unmounts, paginate flips query params. Repeat
60 user actions per session, 5 sessions, all under React.StrictMode.

This is the lifecycle that matters for production confidence — Provider +
query subscribe/unsubscribe + document subscribe/unsubscribe + conditional
component mount/unmount + prop changes + full teardown integrated. If any
path retains references across the unmount boundary, retained heap climbs
across rounds and trips the budget or tail/head ratio.

Total churn per run: ~1500 detail mount/unmount events (StrictMode 2x) +
50 query param changes + 600 expand toggles. Suite still finishes in ~2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scottmessinger
Copy link
Copy Markdown
Member

Pushed fd09283 — one realistic app test that exercises the integrated lifecycle.

packages/silo/tests/react/realistic-app.memory.spec.tsx (~190 lines) defines a tiny but full-featured app:

  • <Provider> wraps a real DocumentStore config (one model, one query, batched async adapters)
  • <App> uses useQuery("itemList", { page }) to fetch a paginated list
  • Clicking an item flips local state → mounts <ItemDetail>
  • <ItemDetail> combines useDocument (silo) + useResource (husk) — the doc subscription drives a derived async resource that recomputes when the doc changes
  • Local useState for expand/collapse churns inside the detail
  • [Close] unmounts the detail; [Next] flips query params

The test then simulates a real user session: mount the whole tree under React.StrictMode, run 60 user interactions (item selects, expand/collapse, closes, paginations), unmount, repeat for 5 sessions. Total churn per run: ~1500 detail mount/unmount events (StrictMode 2x) + 50 query param changes + 600 expand toggles.

If any of these release paths leaks — Provider teardown, query unsubscribe, document handle release, useResource cleanup, useState scope teardown — heap climbs across the 5 sessions and trips either the absolute budget or the tail/head ratio.

This is the test that addresses the actual question: does the library leak when used as documented in a realistic workflow? It exercises every primitive (silo Provider, useQuery, useDocument, kernel tracked() per-component scope, husk useResource, async adapters, state-driven mount/unmount) in one integrated session.

Browser suite still finishes in ~2s; node memory suite (34/34) and soak (6/6) unchanged.

The realistic-app test exercises Provider lifecycle, query churn, prop
changes, conditional component mount/unmount, useDocument + useResource
+ tracked() integration, and StrictMode all under ~1500 effective mount
events per run. Four targeted tests strictly subsume by it:

- silo "StrictMode Provider churn" — realistic mounts Provider under
  StrictMode 5x with much more churn.
- silo "changing workspaceId props" — realistic flips query params ~50x
  per session via the page state.
- husk "remounts with changing seed props" — realistic remounts ItemDetail
  with changing id ~300x per session.
- husk "StrictMode double-mount churn" — realistic wraps the entire app
  in StrictMode.

Deletes the entire silo browser memory spec file (its only purpose was
those two tests).

Keeping:
- husk "unmount while async pending" — racy unmount-mid-fetch case the
  realistic test doesn't hit because it awaits flushAsync between actions.
- All 3 kernel browser tests — kernel-only diagnostic value (no silo, no
  husk dependencies). When realistic-app fails, these triage whether the
  leak is in tracked()/useReactive vs the silo/husk integration.
- All node memory tests — different surface, surgical correctness checks.

Browser test count 9 -> 5; suite still finishes in ~2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scottmessinger
Copy link
Copy Markdown
Member

Pushed cd8e791 removing 4 browser tests now strictly subsumed by the realistic-app test:

Removed Subsumed by realistic-app
silo: StrictMode Provider churn Mounts Provider under StrictMode 5x with much heavier churn
silo: changing workspaceId props Flips query params ~50× per session via page state
husk: remounts with changing seed props Remounts ItemDetail with changing id ~300× per session
husk: StrictMode double-mount churn Whole app wrapped in StrictMode

Deleted packages/silo/tests/react/silo.memory.spec.tsx outright (its only purpose was the two redundant tests). Net diff -265 / +4.

Kept on purpose:

  • husk: "unmount while async pending" — the racy unmount-mid-fetch path. The realistic-app test awaits flushAsync between actions, so it doesn't quite hit this race organically. Worth its own test.
  • 3 kernel browser tests — kernel-only, no silo or husk dependencies. Diagnostic value: when realistic-app fails, these triage whether the leak is in tracked()/useReactive vs the silo/husk integration layer.
  • All node memory tests (34) — different surface entirely (no React, surgical correctness via expectCollectible and high-N retention budgets).

Browser test count: 9 → 5. Suite still finishes in ~2s.

Final shape:

Layer Tests What it proves
Node — collectibility ~14 Specific objects GC after their dispose API is called
Node — retention budgets ~14 No per-cycle leak above ~3KB across 1500-cycle cumulative tests
Node — soak 6 Long-run trends stay flat across 800-1000 cycles
Browser — kernel 3 tracked() + useReactive isolated diagnostic
Browser — husk racy unmount 1 Abort-mid-flight cleanup
Browser — realistic app 1 The integrated lifecycle that matters for production

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.

3 participants