Skip to content

fix(@typegpu/gl): Release unused samplers while the root is alive - #3288

Open
iwoplaza wants to merge 2 commits into
mainfrom
fix/gl-sampler-retention
Open

iwoplaza wants to merge 2 commits into
mainfrom
fix/gl-sampler-retention

Conversation

@iwoplaza

@iwoplaza iwoplaza commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3287.

The GL root kept every sampler wrapper in an array until root.destroy(). It now tracks only the native handles, and a FinalizationRegistry deletes a sampler's native handle (and its bookkeeping entry) once the wrapper is collected. Pipelines still keep the samplers they use alive through their texture bindings. root.destroy() deletes the remaining handles exactly once and unregisters them.

On runtimes without FinalizationRegistry, the root still tracks only the native handles, but releases them only on root.destroy() (the previous behaviour), instead of failing to construct.

Tests (in garbageCollection.test.ts):

  • Unused samplers are collected and their native handles deleted while the root stays alive (fails on main).
  • A sampler stays alive while a pipeline uses it, then gets collected and deleted (fails on main).
  • root.destroy() deletes each remaining sampler exactly once, with no extra deletes from finalizers afterwards.
  • A retained sampler keeps neither its root nor sibling samplers alive.
  • Without FinalizationRegistry, samplers are still released on root.destroy().

Not done here:

  • Samplers with identical settings aren't deduplicated.
  • Textures are deliberately still held by the root until they're destroyed (via texture.destroy() or root.destroy()), for now. Only samplers are released when they become unreachable.

@iwoplaza iwoplaza changed the title fix(@typegpu/gl): release unused samplers while the root is alive fix(@typegpu/gl): Release unused samplers while the root is alive Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

pkg.pr.new

packages
Ready to be installed by your favorite package manager ⬇️

https://pkg.pr.new/software-mansion/TypeGPU/tgpu-gen@727adf7701074fc5af7cc4c27898547eaa181401
https://pkg.pr.new/software-mansion/TypeGPU/typegpu@727adf7701074fc5af7cc4c27898547eaa181401
https://pkg.pr.new/software-mansion/TypeGPU/@typegpu/cli@727adf7701074fc5af7cc4c27898547eaa181401
https://pkg.pr.new/software-mansion/TypeGPU/@typegpu/gl@727adf7701074fc5af7cc4c27898547eaa181401

benchmark
view benchmark

commit
view commit

@github-actions

Copy link
Copy Markdown

Bundle size comparison (import * as ... in PR vs import * as ... in target):

🟢 Decreased ➖ Unchanged 🔴 Increased ❔ Unknown
0 355 0 0

No notable changes.

If you wish to run a comparison for other, slower bundlers, run the 'Tree-shake test' from the GitHub Actions menu.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Resolution Time Benchmark

---
config:
  themeVariables:
    xyChart:
      plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
  title "Random Branching (🔴 PR | 🔵 main | 🟢 release)"
  x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
  y-axis "time (ms)"
  line [1.12, 2.15, 4.86, 7.53, 8.48, 13.10, 28.11, 27.79]
  line [1.06, 2.18, 5.12, 8.00, 9.13, 16.23, 28.97, 28.30]
  line [1.06, 2.19, 4.68, 7.62, 9.54, 13.99, 26.32, 27.18]
Loading
---
config:
  themeVariables:
    xyChart:
      plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
  title "Linear Recursion (🔴 PR | 🔵 main | 🟢 release)"
  x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
  y-axis "time (ms)"
  line [0.34, 0.56, 0.74, 0.93, 1.25, 1.28, 1.52, 1.73]
  line [0.35, 0.59, 0.82, 0.92, 1.24, 1.37, 1.57, 1.72]
  line [0.34, 0.53, 0.77, 0.89, 1.21, 1.35, 1.55, 1.71]
Loading
---
config:
  themeVariables:
    xyChart:
      plotColorPalette: "#E63946, #3B82F6, #059669"
---
xychart
  title "Full Tree (🔴 PR | 🔵 main | 🟢 release)"
  x-axis "max depth" [1, 2, 3, 4, 5, 6, 7, 8]
  y-axis "time (ms)"
  line [0.86, 2.49, 4.39, 8.39, 14.64, 29.39, 61.25, 125.42]
  line [1.02, 2.43, 4.14, 8.18, 14.76, 29.42, 63.56, 128.75]
  line [1.02, 2.31, 4.07, 7.54, 15.57, 29.82, 62.00, 126.88]
Loading

@iwoplaza
iwoplaza marked this pull request as ready for review October 28, 2026 17:13
Copilot AI balanced review requested due to automatic review settings October 28, 2026 17:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues are reported for the WebGL sampler-lifetime cleanup changes.

No open issues 🎉

What changed in this PR

Fixes #3287 by allowing unused WebGL sampler wrappers to be collected while their root remains alive, preventing long-lived roots from retaining every sampler.

Changes:

  • Tracks native sampler handles and uses FinalizationRegistry to release unused samplers.
  • Preserves cleanup on root.destroy(), including a fallback for runtimes without FinalizationRegistry.
  • Adds tests for collection, pipeline retention, root independence, and exactly-once cleanup.
File Description
packages/​typegpu-gl/​tests/​garbageCollection.test.ts Adds sampler lifetime and cleanup regression tests.
packages/​typegpu-gl/​src/​tgpuRootWebGL.ts Replaces wrapper retention with native-handle tracking and finalizer cleanup.

Review effort: Balanced

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found ( ◦°^°◦)

Ahoy — I verified the sampler GC fix end to end and found nary a leak nor a double-delete.

Reviewed changes

This run reviewed the full PR at 727adf7 (2 commits, 2 files).

  • Sampler tracking switched to native handles — #samplers (WebGLSamplerImpl[]) is replaced by #rawSamplers (Set<WebGLSampler>), so the root no longer strongly retains sampler wrappers.
  • FinalizationRegistry-based release — a per-root registry deletes a sampler's native handle once its wrapper is collected, with the #rawSamplers.delete(raw) guard making deletion idempotent.
  • destroy() preserves its contract — unregisters and deletes each remaining handle exactly once, then clears the set, so a late finalizer is a no-op.
  • No-FinalizationRegistry fallback — handles stay tracked but are released only on destroy() (previous behaviour) rather than failing to construct.
  • Five new GC tests — unused-sampler collection, pipeline retention then collection, exactly-once delete on destroy, a retained sampler not pinning its root or siblings, and the fallback path.

I confirmed the load-bearing invariant myself: pipelines retain the same WebGLSamplerImpl instance that createSampler returns (via TgpuWebGLRenderPipelineImpl.#textureBindings[].sampler, and asTgpuSampler is only a cast), so a sampler in use can never be finalized early. Verified locally: vitest packages/typegpu-gl → 159 passed / 2 skipped; tsc -p packages/typegpu-gl/tsconfig.json → exit 0.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

This branch has not been deployed

No deployments
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.

bug(@typegpu/gl): Samplers are retained for the root's lifetime

2 participants