#122

`destroyAll()` + `Symbol.dispose` on all destroyable resources

Openmatiasngfopened 4mo ago

Part of #114.

Part of the "dogfood API" umbrella. Package: @vgpu/core. Purely additive.

Context

Teardown in real vgpu consumers is a long flat list of .destroy() calls. The Eve hero shader's dispose() (render.ts:702-729 in vercel/eve) is ~25 consecutive lines of x.destroy() / bloomTargets?.scene.destroy(), and the same six bloom-target destroys are duplicated a second time inside the resize guard (render.ts:396-401). Adding a new resource means remembering to add a line in two places, or leaking. There is no batch utility and no integration with the TC39 explicit-resource-management standard (using, DisposableStack).

The groundwork already exists in core: Texture.destroy() and Buffer.destroy() are idempotent (texture.ts:37-41, buffer.ts:24-28), Device.destroy() cascades to its readback pool (device.ts:59-64), and all three classes already carry a dispose() alias (texture.ts:43-45, buffer.ts:30-32, device.ts:66-68). This proposal adds the two missing pieces: a batch call, and the standard dispose symbol.

Proposed API

// @vgpu/core

/** Anything with a destroy() or dispose() method — covers Texture, Buffer, Device,
 *  Shader, PingPong, raw GPUTexture/GPUBuffer, and native DisposableStack. */
export type Destroyable = { destroy(): void } | { dispose(): void };

/** Destroy every given resource. Variadic and flat (spread arrays at the callsite).
 *  null/undefined entries are skipped, so optional resources compose directly.
 *  Calls destroy() when present, else dispose(). No ordering guarantee.
 *  Safe to combine with `using`/double-destroy: vgpu destroy() is idempotent. */
export function destroyAll(
  ...resources: ReadonlyArray<Destroyable | null | undefined>
): void;
// Symbol.dispose alias on Texture, Buffer, Device (and PingPong / future resources).
// Runtime-safe symbol resolution — the same fallback TypeScript's `using` emit uses:
const disposeSymbol: typeof Symbol.dispose =
  Symbol.dispose ?? (Symbol.for("Symbol.dispose") as typeof Symbol.dispose);

class Texture {
  destroy(): void;          // existing, idempotent
  dispose(): void;          // existing alias
  [disposeSymbol](): void { this.destroy(); }   // NEW — enables `using` / DisposableStack
}

Implementation (whole utility, ~7 lines):

export function destroyAll(
  ...resources: ReadonlyArray<Destroyable | null | undefined>
): void {
  for (const r of resources) {
    if (!r) continue;
    if ("destroy" in r) r.destroy();
    else r.dispose();
  }
}

Support story. vgpu is on TS 5.7 (Symbol.dispose typing landed in 5.2); the only repo change needed is adding "ESNext.Disposable" to lib in tsconfig.base.json (currently ["ES2022","DOM"]). At runtime, Symbol.dispose is native in Node ≥ 18.18 and current evergreen browsers; the Symbol.for("Symbol.dispose") fallback above matches TypeScript's downlevel using emit, so using works on every runtime consumers target — vgpu ships no polyfill. Note that DisposableStack itself is native only in newer runtimes (Node 24+, 2025-era browsers), which is exactly why the zero-dependency destroyAll carries its weight today.

Example

// Eve teardown (render.ts:702-729) — the resource list stays visible at the dispose site:
dispose() {
  destroyAll(
    gpuMesh.vertexBuffer, gpuMesh.indexBuffer, gpuMesh.lineIndexBuffer,
    insideParams.buffer, backDepthParams.buffer,
    outsideParams.buffer, outsideParams.fallbackBackMaterial, outsideParams.fallbackBackDepth,
    opaqueOutsideParams.buffer, opaqueOutsideParams.fallbackBackMaterial, opaqueOutsideParams.fallbackBackDepth,
    wireParams.buffer, wireParams.fallbackBackMaterial, wireParams.fallbackBackDepth,
    envBgParams.buffer, blurParamsBuffer, compositeParamsBuffer, previewMode,
    bloomTargets?.scene, bloomTargets?.backMaterial, bloomTargets?.backDepth,        // nullish-
    bloomTargets?.backSurfaceDepth, bloomTargets?.horizontal, bloomTargets?.vertical, // tolerant
  );
}

// Lexically scoped code (tests, offline render harness) — the standard does the rest:
using device = await createDevice();
using staging = device.createBuffer({ size, usage: ["map_read", "copy_dst"] });
// ...automatic cleanup at scope exit, even on throw

// Non-lexical lifetimes (React unmount) — native DisposableStack, no vgpu group class needed:
const stack = new DisposableStack();
const tex = stack.use(device.createTexture({ ... }));
return () => stack.dispose();

Same example without the util

// Today (render.ts:702-729, abridged — 25 lines of this):
dispose() {
  gpuMesh.vertexBuffer.destroy();
  gpuMesh.indexBuffer.destroy();
  gpuMesh.lineIndexBuffer.destroy();
  insideParams.buffer.destroy();
  backDepthParams.buffer.destroy();
  outsideParams.buffer.destroy();
  outsideParams.fallbackBackMaterial.destroy();
  outsideParams.fallbackBackDepth.destroy();
  // ...11 more lines...
  bloomTargets?.scene.destroy();
  bloomTargets?.backMaterial.destroy();
  bloomTargets?.backDepth.destroy();
  bloomTargets?.backSurfaceDepth.destroy();
  bloomTargets?.horizontal.destroy();
  bloomTargets?.vertical.destroy();
}
// And no `using` support at all: try/finally by hand in every test and harness.

Alternatives

  • Symbol.dispose only, no destroyAll — makes batch teardown depend on DisposableStack, which is not yet native everywhere and forces .use() registration at creation time, scattering the ownership list. Eve's dispose is a flat list read in one place; destroyAll preserves that shape at ~7 lines of implementation cost. Rejected.
  • destroyAll accepting nested arrays — spread already flattens at the callsite (destroyAll(...bloomTargets, extra)); recursion adds type and behavior surface for zero expressiveness. Rejected (explicit > magic).
  • DisposableGroup / auto-registration on device.create* — a competing group abstraction the standard already provides, plus hidden lifetime coupling that breaks intentional early destroys (e.g. Texture.resize). Rejected — the standard is the group abstraction.

Activity

0

No activity yet.