diff --git a/.changeset/collect-garbage-when-idle.md b/.changeset/collect-garbage-when-idle.md index af8758df..444165e2 100644 --- a/.changeset/collect-garbage-when-idle.md +++ b/.changeset/collect-garbage-when-idle.md @@ -3,4 +3,4 @@ '@gpuix/react': patch --- -`collectGarbageWhenIdle()` runs one full garbage collection once the app has stopped committing for a while, so the heap of a long session comes back down after a burst of work. It watches the new `appliedBatchCount()` and the process's CPU, and returns a function that stops it. +`collectGarbageWhenIdle()` runs one full garbage collection (and, under Bun, `Bun.shrink()` to release the freed pages and rebuildable engine caches) once the app has stopped committing for a while, so the heap of a long session comes back down after a burst of work. It watches the new `appliedBatchCount()` and the process's CPU, and returns a function that stops it. diff --git a/.changeset/profiler-journal-lazy-residency.md b/.changeset/profiler-journal-lazy-residency.md new file mode 100644 index 00000000..03ff0958 --- /dev/null +++ b/.changeset/profiler-journal-lazy-residency.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +The foreground profiler journal's 4 MiB ring is allocated zeroed, so it is resident only as far as entries are recorded rather than written up front when the app starts. Nothing reads the journal outside the host's hang detection, so an app that does not use that holds about 4 MB less. diff --git a/README.md b/README.md index f1577fd5..01feb1b9 100644 --- a/README.md +++ b/README.md @@ -1848,9 +1848,11 @@ import { collectGarbageWhenIdle } from '@gpuix/react' const stop = collectGarbageWhenIdle() // checks every 5s, collects after two quiet checks ``` -It uses `Bun.gc(true)`, or `gc()` where Node runs with `--expose-gc`, and does -nothing in a runtime that has neither. Quiet means no mutation batch reached a -renderer and the process is under half a core. The batch count decides, not CPU +It uses `Bun.gc(true)` followed by `Bun.shrink()`, which also hands the freed pages +and rebuildable engine caches back (about 45 MB on a long chat thread), or `gc()` +where Node runs with `--expose-gc`, and does nothing in a runtime that has neither. +Quiet means no mutation batch reached a renderer and the process is under half a +core. The batch count decides, not CPU alone, because a focused text field blinks its caret in native code and redraws twice a second. Pass `activity`, `collect`, `checkMs`, `quietChecks` or `busyCpu` to change what it watches or how often. diff --git a/packages/native/js/__tests__/idle-gc.test.ts b/packages/native/js/__tests__/idle-gc.test.ts index 5b80d538..5b02e5d3 100644 --- a/packages/native/js/__tests__/idle-gc.test.ts +++ b/packages/native/js/__tests__/idle-gc.test.ts @@ -56,6 +56,30 @@ describe("collectGarbageWhenIdle", () => { expect(collect).not.toHaveBeenCalled() }) + it("collects under Bun with a full collection and then a shrink, which releases the freed pages", () => { + const calls: string[] = [] + vi.stubGlobal("Bun", { gc: (force: boolean) => calls.push(`gc:${force}`), shrink: () => calls.push("shrink") }) + try { + collectGarbageWhenIdle({ activity: () => 0, checkMs: 1000, quietChecks: 1 }) + vi.advanceTimersByTime(1000) + expect(calls).toEqual(["gc:true", "shrink"]) + } finally { + vi.unstubAllGlobals() + } + }) + + it("still collects under a Bun that has no shrink", () => { + const calls: string[] = [] + vi.stubGlobal("Bun", { gc: (force: boolean) => calls.push(`gc:${force}`) }) + try { + collectGarbageWhenIdle({ activity: () => 0, checkMs: 1000, quietChecks: 1 }) + vi.advanceTimersByTime(1000) + expect(calls).toEqual(["gc:true"]) + } finally { + vi.unstubAllGlobals() + } + }) + it("counts applied mutation batches by default", () => { const renderer = { applyBatch: () => [] as number[] } const queue = createMutationQueue(renderer) diff --git a/packages/native/js/idle-gc.ts b/packages/native/js/idle-gc.ts index 2fcde38b..4805ec9d 100644 --- a/packages/native/js/idle-gc.ts +++ b/packages/native/js/idle-gc.ts @@ -7,7 +7,10 @@ export interface IdleGcOptions { * moves on every commit, scroll-driven render and typed character. */ activity?: () => number - /** Runs the collection. Defaults to `Bun.gc(true)`, then `globalThis.gc` (Node's `--expose-gc`). */ + /** + * Runs the collection. Defaults to `Bun.gc(true)` followed by `Bun.shrink()`, or else + * `globalThis.gc` (Node's `--expose-gc`). + */ collect?: () => void /** How often to look, in ms. Default 5000. */ checkMs?: number @@ -28,10 +31,18 @@ function cpuMs(): number { function defaultCollector(): Collector | undefined { const runtime = globalThis as unknown as { - Bun?: { gc(force: boolean): void } + Bun?: { gc(force: boolean): void; shrink?(): void } gc?: () => void } - if (runtime.Bun) return () => runtime.Bun!.gc(true) + if (runtime.Bun) { + const bun = runtime.Bun + // A collection alone leaves the freed pages and the caches the engine rebuilds on demand + // in place; shrinking releases them (about 45 MB on a long chat thread). + return () => { + bun.gc(true) + bun.shrink?.() + } + } if (runtime.gc) return () => runtime.gc!() return undefined } diff --git a/zed b/zed index c2e9f882..74bdb018 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit c2e9f882030ca95cee55d206512a0a3ee6cd72b0 +Subproject commit 74bdb0185063002bacb40585b293c8ae8525ef32