Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/collect-garbage-when-idle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions .changeset/profiler-journal-lazy-residency.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions packages/native/js/__tests__/idle-gc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 14 additions & 3 deletions packages/native/js/idle-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion zed
Loading