Skip to content

fix(vtk): stop renders and release resources when a view is torn down - #932

Open
PaulHax wants to merge 6 commits into
Kitware:mainfrom
PaulHax:view-teardown-render-guard
Open

fix(vtk): stop renders and release resources when a view is torn down#932
PaulHax wants to merge 6 commits into
Kitware:mainfrom
PaulHax:view-teardown-render-guard

Conversation

@PaulHax

@PaulHax PaulHax commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

Contexts are never handed back. Deleting a vtkOpenGLRenderWindow only
decrements vtk.js' own counter. The browser keeps the WebGL context alive until
the canvas is collected, and browsers cap how many contexts a page may hold, so
past the cap the oldest is force lost and new ones fail. The volume thumbnailer
makes this reachable in normal use: it owns a render window of its own, and the
rendering panel that creates it sits behind v-if="canShow3DControls", so every
layout change that adds or removes a 3D view rebuilds it. There was no disposal
path at all.

Attribution, since it is easy to get backwards: the layout-change context
exhaustion is fixed entirely by the thumbnailer work. Regular views do not leak
a context per layout change, because a child view draws through its parent's and
the only owner is VtkRenderWindowParent, whose disposal runs at app teardown.
The rest of this PR is hardening.

Render requests outlive the view. requestRender and the batchForNextTask
render are reachable from vtk event handlers and from promises that settle after
the view is gone, so a request can arrive after onScopeDispose and leave a
timer queued behind a view that is going away. On main such a render did not
reach the mappers: interactor.delete() empties the interactor model, which
makes isAnimating() return true, and that check is the first statement of both
render paths. The observable defect is the stray timer.

The widget manager was never deleted, and its picking buffers still are not
freed by vtk.js.
useWidgetManager registered no disposal, so
vtkWidgetManager.delete() never ran and its interactor, camera and resize
subscriptions stayed live. delete() is also all it does: it never touches the
hardware selector. vtkOpenGLHardwareSelector allocates a framebuffer, a color
texture, and a depth renderbuffer on the shared context the first time picking
captures, has no delete() override, and nothing in vtk.js 36.2.1 releases
them, so one set per view accumulated across layout changes.

Change

Six commits.

fix(vtk): stop renders and release resources when a view is torn down

  • requestRender short circuits once the scope is disposed.
  • A child render window is unregistered from its parent before its view node is
    removed.
  • The view's widget manager is torn down on scope dispose. New
    releaseWidgetManager.ts releases the hardware selector's framebuffer and
    color texture against the root render window, then manager.delete() drops
    its subscriptions. Both steps are guarded, so a manager that never captured is
    a no-op and the release cannot skip the renderer, render window and interactor
    cleanup that follows.
  • New releaseRenderWindow.ts releases GPU resources and hands the browser
    context back via WEBGL_lose_context. beginContextRelease returns the lose
    step separately, for owners whose teardown chain deletes the view for them,
    and runs it from a finally so a throw in between cannot strand the context.
    It acts only on a root render window: vtk.js proxies getContext and
    releaseGraphicsResources from a child to its root, so a child handed to it
    would free what its siblings are still drawing with.
  • New deleteInteractor.ts owns the drop-the-pending-frame-then-delete pair.
    interactor.delete() cancels outstanding animations, and each cancellation
    renders through a view its callers have already deleted; vtk.js' public
    cancelAnimation() is a no-op inside the post-wheel extension window, so the
    pending rAF is dropped directly. It replaces the copy useVtkView kept
    inline, and picks up a second caller in the next commit.

fix(rendering): release the volume thumbnailer's WebGL context on unmount

  • createVolumeThumbnailer gains delete(), called from
    useVolumeThumbnailing's onBeforeUnmount and guarded so a failure cannot
    abort the rest of the unmount. Teardown errors go through logError, which
    walks error.cause chains, so the underlying WebGL error is not hidden behind
    the outer message.
  • Ordering matters: vtkGenericRenderWindow.delete() runs setContainer, which
    unbinds interactor events and deletes the API specific render window, so both
    must still be alive when it runs. Resources are released first, then
    scene.delete(), then the interactor, then the context from a finally.
  • The API specific view comes off the inner vtkRenderWindow before
    scene.delete(), so a scheduled render walks an empty list instead of a
    deleted node.
  • The interactor goes through deleteInteractor, so its pending animation frame
    is dropped rather than rendered through the view scene.delete() just took
    apart.
  • releaseOpenGLRenderWindow returns early when the view is already deleted, so
    a second call cannot throw.
  • The rest of the pipeline goes with it: inner render window, renderer, actor,
    mapper, the two function proxies, and the colour and opacity functions.

fix(rendering): defer thumbnailer deletion until captures settle

captureImages() finishes its render on a zero-delay timer, so deleting the
render window while a capture is pending crashed that callback and left the
capture promise unresolved. Unmount now waits for in-flight captures before
deleting; the remaining presets bail out through the existing interrupt
sentinel. The tracking mechanism was reworked by a later commit, below.

fix(vtk): release the selector framebuffer's depth renderbuffer

Framebuffer.releaseGraphicsResources() deletes the framebuffer but not the
depth renderbuffer populateFramebuffer() created, so one renderbuffer leaked
per disposed view on the shared context.

fix(rendering): track in-flight captures instead of chaining cycles

The first version of the deferral chained every thumbnailing cycle onto the
previous cycle's promise, so a single capture that never settles (lost context,
render that emits no image) would have wedged all future thumbnailing and kept
the deferred delete() from ever running, making the context leak permanent.

  • In-flight captures are tracked in a set; unmount awaits Promise.allSettled
    of just those, and each cycle's preset chain starts fresh.
  • A cycle that starts between onBeforeUnmount and scope stop bails on the
    unmount sentinel instead of overwriting it and rendering through a deleted
    scene.
  • The sentinel and current image id are re-checked after the capture resolves,
    so a capture that renders the newly selected image is dropped instead of
    stored under the previous image's id.

fix(vtk): release widget manager before its root view is deleted

  • In the standalone (no parent) path, the dispose hook that deletes the view
    registered earlier than the final cleanup, so releaseWidgetManager always
    saw rootView.isDeleted() and the selector framebuffer release was dead
    code there. The widget manager release now registers ahead of the view
    teardown; the child-path ordering (unregister from the parent render window,
    then drop the view node) is unchanged and still covered by the spec.
  • The select tool's pick handler awaits getSelectedDataForXY, which a view
    teardown can outrun now that the widget manager is actually deleted, so the
    pick re-checks isDeleted() after the await.

Known gaps

  • Per-view polydata VBO retention is not addressed. The OpenGL image and
    volume mappers chain unregisterGraphicsResourceUser into delete(), so
    their textures on the shared context are freed when a child view closes, but
    vtkOpenGLPolyDataMapper has no delete() override and no public release
    path, and a child view's releaseGraphicsResources proxies to the root,
    which would free what sibling views still draw with. Pre-existing, and needs
    a vtk.js-side fix rather than teardown ordering.
  • Deferred deletion is not bounded by a deadline. Unmount waits on the
    in-flight capture set with no timeout, so a capture that never settles would
    hold the thumbnailer, and its context, indefinitely. Releasing eagerly would
    break exactly those captures, so the wait stands; bounding it wants
    delete() to be safe with a capture pending rather than a timer.
  • Deferred deletion briefly overlaps contexts on remount. A remount creates
    its thumbnailer context synchronously while the old one waits for in-flight
    captures to settle. Releasing eagerly would break exactly those captures, so
    the overlap is accepted; it is now bounded by the in-flight set rather than
    an unbounded chain.
  • The context cap symptom is not reproducible in our e2e environment. Those
    runs are headless with --enable-unsafe-swiftshader, and software rendering
    does not enforce the live context limit that causes the failure on real GPUs.
    A spec that cycled the layout twenty times passed against unfixed code, so it
    was not kept. The release paths were verified against the vtk.js 36.2.1
    sources instead.

@netlify

netlify Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploy Preview for volview-dev ready!

Name Link
🔨 Latest commit 55636c8
🔍 Latest deploy log https://app.netlify.com/projects/volview-dev/deploys/6a90e41e0c84b900080a870b
😎 Deploy Preview https://deploy-preview-932--volview-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@PaulHax
PaulHax force-pushed the view-teardown-render-guard branch 2 times, most recently from 75dd748 to f61f935 Compare August 27, 2026 23:37
A render request can outlive its view: they arrive from vtk event handlers
and from promises that settle after dispose. requestRender and the batched
render now short circuit once the scope is disposed, so no stray timer is
left queued behind a view that is going away.

Unregister a child render window from its parent before removing its view
node, so the two steps cannot be observed out of order.

Release the WebGL context behind a render window when it is deleted. vtk.js
only decrements its own counter, so contexts stayed live against the browser
per page cap.
…ount

The thumbnailer holds a render window of its own, and the rendering panel it
belongs to is rebuilt whenever the layout gains or loses a 3D view. Without a
disposal path each rebuild left a live context behind, and browsers cap how
many a page may hold.
captureImages() finishes its render on a zero-delay timer, so deleting
the render window while a capture is pending crashes that callback and
leaves the capture promise unresolved. Track the thumbnailing chain and
delete the thumbnailer only after the active capture settles.
vtk.js Framebuffer.releaseGraphicsResources() deletes the framebuffer
but not the depth renderbuffer populateFramebuffer() created, so one
renderbuffer leaked per disposed view on the shared context.
A capture that never settles no longer blocks later thumbnail cycles or
the deferred thumbnailer deletion. Captures are tracked in a set that
unmount awaits, cycles bail once the unmount sentinel is set, and a
capture that resolves after the image changed is dropped instead of
stored under the old id.
In the standalone path the view was deleted by an earlier dispose hook,
so the isDeleted() guard skipped the selector framebuffer release. The
widget manager release now registers ahead of the view teardown. Select
tool picks guard against a widget manager deleted across the await.
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.

1 participant