fix(vtk): stop renders and release resources when a view is torn down - #932
Open
PaulHax wants to merge 6 commits into
Open
fix(vtk): stop renders and release resources when a view is torn down#932PaulHax wants to merge 6 commits into
PaulHax wants to merge 6 commits into
Conversation
✅ Deploy Preview for volview-dev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
PaulHax
force-pushed
the
view-teardown-render-guard
branch
2 times, most recently
from
August 27, 2026 23:37
75dd748 to
f61f935
Compare
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.
PaulHax
force-pushed
the
view-teardown-render-guard
branch
from
August 28, 2026 01:27
f61f935 to
55636c8
Compare
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Contexts are never handed back. Deleting a
vtkOpenGLRenderWindowonlydecrements 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 everylayout 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.
requestRenderand thebatchForNextTaskrender are reachable from vtk event handlers and from promises that settle after
the view is gone, so a request can arrive after
onScopeDisposeand leave atimer queued behind a view that is going away. On
mainsuch a render did notreach the mappers:
interactor.delete()empties the interactor model, whichmakes
isAnimating()return true, and that check is the first statement of bothrender 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.
useWidgetManagerregistered no disposal, sovtkWidgetManager.delete()never ran and its interactor, camera and resizesubscriptions stayed live.
delete()is also all it does: it never touches thehardware selector.
vtkOpenGLHardwareSelectorallocates a framebuffer, a colortexture, 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 releasesthem, so one set per view accumulated across layout changes.
Change
Six commits.
fix(vtk): stop renders and release resources when a view is torn downrequestRendershort circuits once the scope is disposed.removed.
releaseWidgetManager.tsreleases the hardware selector's framebuffer andcolor texture against the root render window, then
manager.delete()dropsits 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.
releaseRenderWindow.tsreleases GPU resources and hands the browsercontext back via
WEBGL_lose_context.beginContextReleasereturns the losestep separately, for owners whose teardown chain deletes the view for them,
and runs it from a
finallyso a throw in between cannot strand the context.It acts only on a root render window: vtk.js proxies
getContextandreleaseGraphicsResourcesfrom a child to its root, so a child handed to itwould free what its siblings are still drawing with.
deleteInteractor.tsowns the drop-the-pending-frame-then-delete pair.interactor.delete()cancels outstanding animations, and each cancellationrenders through a view its callers have already deleted; vtk.js' public
cancelAnimation()is a no-op inside the post-wheel extension window, so thepending rAF is dropped directly. It replaces the copy
useVtkViewkeptinline, and picks up a second caller in the next commit.
fix(rendering): release the volume thumbnailer's WebGL context on unmountcreateVolumeThumbnailergainsdelete(), called fromuseVolumeThumbnailing'sonBeforeUnmountand guarded so a failure cannotabort the rest of the unmount. Teardown errors go through
logError, whichwalks
error.causechains, so the underlying WebGL error is not hidden behindthe outer message.
vtkGenericRenderWindow.delete()runssetContainer, whichunbinds 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 afinally.vtkRenderWindowbeforescene.delete(), so a scheduled render walks an empty list instead of adeleted node.
deleteInteractor, so its pending animation frameis dropped rather than rendered through the view
scene.delete()just tookapart.
releaseOpenGLRenderWindowreturns early when the view is already deleted, soa second call cannot throw.
mapper, the two function proxies, and the colour and opacity functions.
fix(rendering): defer thumbnailer deletion until captures settlecaptureImages()finishes its render on a zero-delay timer, so deleting therender 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 renderbufferFramebuffer.releaseGraphicsResources()deletes the framebuffer but not thedepth renderbuffer
populateFramebuffer()created, so one renderbuffer leakedper disposed view on the shared context.
fix(rendering): track in-flight captures instead of chaining cyclesThe 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.Promise.allSettledof just those, and each cycle's preset chain starts fresh.
onBeforeUnmountand scope stop bails on theunmount sentinel instead of overwriting it and rendering through a deleted
scene.
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 deletedregistered earlier than the final cleanup, so
releaseWidgetManageralwayssaw
rootView.isDeleted()and the selector framebuffer release was deadcode 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.
getSelectedDataForXY, which a viewteardown can outrun now that the widget manager is actually deleted, so the
pick re-checks
isDeleted()after the await.Known gaps
volume mappers chain
unregisterGraphicsResourceUserintodelete(), sotheir textures on the shared context are freed when a child view closes, but
vtkOpenGLPolyDataMapperhas nodelete()override and no public releasepath, and a child view's
releaseGraphicsResourcesproxies 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.
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.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.
runs are headless with
--enable-unsafe-swiftshader, and software renderingdoes 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.