This reference is for Donner developers working on Geode, the editor, or the GPU runtime under donner/gpu/. It states who owns each GPU object, how a backend is chosen on each platform, and what the runtime does when the GPU fails. The native embedding guide walks through the same contracts from a windowed host. Design 0053 summarizes the cutover that produced the runtime, and its original text records how the runtime got here.
The runtime is internal to Donner. For v0.8 the maintainer decided that there is no native embedding surface beyond internal callers: the editor, the in-tree embed example and Donner's own tests. Bazel visibility confines the runtime and Geode device targets to Donner's packages and //examples. Applications render through the public donner::svg::Renderer API.
//donner/svg/renderer:renderer_geode itself is public, and RendererGeode.h declares the shared-context constructor RendererGeode(std::shared_ptr<geode::GeodeDevice>) and setTargetTexture(const gpu::Texture&). The targets that define the types they take are visible only inside the repository: //donner/svg/renderer/geode:geode_device_api, which owns GeodeDevice.h, //donner/svg/renderer/geode:geode_device, and the //donner/gpu package's libraries. That visibility keeps a target outside Donner from depending on them. Donner's .bazelrc disables layering_check, so visibility does not stop a dependent of renderer_geode from reaching their headers; the seam is internal because of the v0.8 decision together with that visibility rule.
flowchart TD renderer["RendererGeode"] --> context["geode::GeodeDevice (logical context)"] context --> device["gpu::Device (runtime device)"] context --> owner["geode::GeodePhysicalDeviceOwner"] owner --> root["geode::GeodeGpuRoot"] root --> loss["gpu::DeviceLostState"] device --> metal["gpu::metal::MetalDevice"] device --> vulkan["gpu::vulkan::VulkanDevice"] device --> browser["gpu::browser::BrowserDevice"] vulkan --> shared["gpu::vulkan::VulkanSharedRoot"] browser --> js["library_donner_gpu.js and navigator.gpu"]
| Layer | Type | Responsibility |
|---|---|---|
| Renderer | svg::RendererGeode | Translates RendererInterface draws into Geode encoders on one logical context. |
| Logical context | geode::GeodeDevice | Pipelines, filter engine, caches, counters, deferred retirement and snapshot readback for one rendering client. Renders through one runtime device. |
| Physical owner | geode::GeodePhysicalDeviceOwner | Shared by every context over one root. Keeps the root and the first context's runtime device alive. |
| Backend root | geode::GeodeGpuRoot | The selected backend, its capabilities and the loss condition its devices share. On Vulkan it also retains the VulkanSharedRoot. |
| Runtime device | gpu::Device | Validated resource creation, queue writes, submission, mapping, surfaces and cross-device texture registration. One per context. |
| Backend | MetalDevice, VulkanDevice, BrowserDevice | Translate validated operations into the platform API through the protected on* hooks of gpu::Device. |
A runtime device names a texture in one of three roles:
| Role | Created by | Owner of the allocation | Ends |
|---|---|---|---|
| Owned texture | Device::createTexture() | The device that created it | The handle is destroyed; the allocation goes once its last submission completes and no export holds it |
| Registration | Device::registerTexture() | The producer; the consumer holds a read-only alias | The consumer releases it; its hold on the allocation drops after the consumer's last submission naming it |
| Acquired frame | Device::acquireCurrentTexture() | The surface | presentSurface(), abandonCurrentTexture() or a reconfiguration invalidates it |
Device::ownsTextureBacking() is true only for an owned texture, so a caller can never free memory a surface or another device still owns.
A host render target is a RendererGeode role over one of these, not a fourth kind of texture: RendererGeode::setTargetTexture() takes an owned texture or an acquired frame of the renderer's own device and keeps only its identity, for every frame until clearTargetTexture(). The caller keeps the texture live from beginFrame() through endFrame().
Cross-device registration has a producer half and a consumer half. Device::exportTexture() runs on the producer's thread and returns a gpu::TextureExport: an immutable, thread-safe token that holds the allocation. Device::registerTexture() runs on the consumer's thread, reads only the token, and limits the registration's usage to Sampled and CopySrc. Work the consumer submits is ordered after the producer work the registration covers: by the shared queue on Vulkan and in the browser, and by a GPU-side wait on the producer's shared event on Metal, where each runtime device has its own command queue. An acquired surface frame cannot be exported on Metal or Vulkan, because the surface can recycle it at presentation. Bytes of released textures that a token or registration still holds are reported by Device::sharedTextureTailBytes(). Geode's consumers register through geode::RegisterOrderedTexture(). The full contract is in the original design's cross-device texture registration section.
Explicit captures are CPU consumers by design. Ordinary document composition and presentation retain pixels on the GPU on every platform. Browser raster workers publish resource identities; the application thread owns the textures and canvas and uses the desktop compositor to draw tiles directly into the acquired backbuffer. No per-tile browser objects, ImageBitmaps or pixel bytes cross workers, and presentation adds no intermediate framebuffer copy.
//donner/editor/wasm/tests:boot_presentation_test checks actual browser readback calls and compositor/handoff/final-capture accounting for an ordinary document. The worker's published bitmap/texture payload counts distinguish GPU resources from CPU tiles. CPU-authored images and font atlases remain legitimate uploads; bounded screenshot/export captures remain explicit reads. The existing sample/font-preview lane still produces CPU thumbnail bitmaps. This separate path is measured here; it is not evidence that the whole browser UI is GPU resident. Its explicitPreviewReadbackTotal counts actual capture calls, including cancelled captures. The browser gate reconciles the raw readback delta with that explicit lane and requires zero document-stage readbacks and bitmap tiles.
Tear down in the reverse order of construction:
Teardown on a lost root depends on the backend:
| Backend | Runtime device | Platform | Shader projection | Devices over one root share | Reports progress |
|---|---|---|---|---|---|
| Native Metal | gpu::metal::MetalDevice | macOS | MSL | The system default MTLDevice; each device has its own command queue | Yes |
| Native Vulkan | gpu::vulkan::VulkanDevice | Linux | SPIR-V | One instance, logical device and graphics queue (VulkanSharedRoot) | Yes |
| Browser | gpu::browser::BrowserDevice | WebAssembly | WGSL | The GPU owner's one GPUDevice and its queue | No |
Each product links only the shader projection its devices consume; see WGSL shader compilation.
Native builds (donner/svg/renderer/geode/GeodeNativeRoot.h) resolve the backend in this order:
geode::SelectGpuRoot() never falls back to another backend:
| Situation | Result |
|---|---|
| The resolved backend opens | A root. A caller-named or environment-named backend is logged once, for example [Geode] GPU backend: native Vulkan, requested by DONNER_GPU_BACKEND=vulkan. |
| A caller-named or platform-default backend cannot be opened or served | A null root; the caller decides what to do. |
| DONNER_GPU_BACKEND names no backend, for example wgpu | The process aborts: [Geode] DONNER_GPU_BACKEND=wgpu names no GPU backend; accepted values: metal, vulkan |
| DONNER_GPU_BACKEND names a backend this process cannot open, for example metal on Linux | The process aborts. |
| The Vulkan presentation options contradict each other or the resolved backend | The process aborts. |
geode::ResolveGpuBackendKind() reports which backend a selection resolves to, or why its request or options are invalid, without opening a device; it does not probe whether the backend is available.
A window that presents on Linux needs a root whose queue can present to that window, so the editor and the embed example create the VkSurfaceKHR first and complete the root against it (VulkanDevice::CreatePresentationProbe(), VulkanDevice::CompletePresentationRoot(), geode::AdoptNativeVulkanRoot()). The editor takes that path when its request resolves to native Vulkan; any other request goes through SelectGpuRoot() and fails closed there.
WebAssembly builds compile GeodeBrowserRoot.h instead, selected by the //donner/svg/renderer/geode:browser_backend build setting that --config=wasm-geode and --config=editor-wasm enable, because a page cannot set DONNER_GPU_BACKEND. A selection that names nothing takes the browser backend. An invalid value, or one naming a native backend, aborts. A caller-named native backend returns a null root when the variable is empty and aborts when it is set.
//donner/svg/renderer/geode:geode_native_root_tests covers the precedence and refusals, among them GeodeNativeRoot.UnnamedBackendUsesTheNativePlatformDefault, GeodeNativeRoot.CallerChoiceOutranksEnvironment, GeodeNativeRoot.VulkanPresentationRequiresTheVulkanBackend and GeodeNativeRootDeathTest.ExplicitWgpuRequestCannotFallBackToNative. EditorWindowBackendTest.OpensOnTheBackendTheProcessSelected in //donner/editor/tests:editor_window_tests opens the editor window on the selected backend.
The maintainer decided the GPU matrix:
Browser presentation is qualified separately, on Chromium, WebKit and real Safari, under the cutover acceptance gates. Physical iOS is outside the cutover's GPU qualification matrix; physical-iPhone checks are v0.8 release preparation in #1420. Design 0064 records which CI lanes exercise each combination.
Every runtime operation validates its descriptors, handles, ranges, usage and device identity, in release builds too, and reports failure as a gpu::GpuError inside gpu::Result<T> or gpu::Status. Invalid input never aborts. GpuErrorType::DeviceLost is terminal: in-flight work cannot complete, its results cannot be trusted, and retrying on the same device cannot recover. The enforced limits, such as 16,384 texels a side and 1 GiB per buffer, are in donner/gpu/GpuLimits.h.
Untrusted SVG controls geometry, images, filter graphs, dimensions and repetition. It never supplies native handles, shader source or command streams: every shader is a frozen artifact that Donner's compiler produced at build time, browser object identifiers come from a space that is never reused, and UI draw data names textures by generation-checked UiTextureId values.
Loss belongs to the root. One gpu::DeviceLostState is shared by the root, every runtime device and context over it, and any host callback that holds it. It is sticky: once set it never clears. It is declared in one of two ways:
The declaring call runs every gpu::DeviceLossRelease registered on the condition, releasing work held behind waits that only the lost root could satisfy, and returns true. Neither function logs. Donner's own callers log once, through gpu::LogDeclaredDeviceLoss(), when their call is the one that set the condition: [gpu] Device declared lost: <reason>. GeodeDevice::markDeviceLost() and markDeviceLostAfterWaitTimeout() declare and log together. A host that calls DeclareDeviceLost() directly prints nothing unless it also calls LogDeclaredDeviceLoss(). Always declare through one of these functions; storing the flag directly skips the releases and the attribution.
On a lost root:
Nothing recovers a lost root. Recovery means a new root and new contexts: CreateOverPhysicalDeviceOwner() refuses a lost owner, and the headless cache discards lost contexts. A browser worker asks for a new GPUDevice only after every runtime device over the lost one is released. On Vulkan a new root works only if the lost root's teardown proved its work complete; otherwise Vulkan stays disabled until the process restarts (see Retirement order).
The runtime's contract (donner/gpu/DeviceLost.h) is that no thread Donner relies on blocks on the GPU without a bound: a hung device surfaces as a declared loss instead of a hung process. The Vulkan backend's drain after a lost submission is the documented exception: it has no Donner deadline, and the Vulkan specification requires it to return.
A wait whose bound exists to detect a hung device measures time without progress, not the time its whole backlog takes: a serial completes only after everything queued ahead of it, so a fixed budget would declare a slow device that is still working lost. gpu::Device::waitForSerialUnlessStalled() gives up only once gpu::Device::lastProgress() is the bound in the past, and has no total limit while work keeps completing. The default bound is geode::kDefaultGpuWaitTimeout, five seconds; the Vulkan texture upload allows 60 seconds.
| Backend | Progress |
|---|---|
| Metal | A command buffer of the device completing, work committed to an idle device, and, while a submission waits on another device's work, that device's progress. |
| Vulkan | Each command buffer a VulkanDevice records from a submitted command stream ends by setting an event, and the shared queue progresses whenever any device over the root sees one set. A texture upload or a swapchain's frame handover sets no event; submitted while no tracked work is outstanding, it starts the clock, so a wait for it is timed from its submission, or from the last event seen ahead of it, and is not refreshed while it runs. |
| Browser, recording | No progress is reported, so a stall-bounded wait bounds the whole wait instead. |
The unit of progress is one command buffer, so a single command buffer that runs longer than the bound is indistinguishable from a hang.
Waits judged by progress:
Waits whose budget is the caller's latency policy keep a fixed budget and declare nothing by themselves: gpu::Device::waitForSerial(), the snapshot capture deadline (expiry is counted in captureTimeouts), the editor's 250 ms admission recheck and swapchain image acquisition. Two Vulkan waits keep a fixed five-second budget because the queue's progress cannot judge them: a swapchain's wait for a present to finish, and the teardown proof after a submission whose completion is unknown.
QueueIdleWaitsOutABacklogThatKeepsProgressing and QueueIdleDeclaresTheLossOnceTheBacklogStopsProgressing run for both native backends in //donner/svg/renderer/geode:geode_device_tests. //donner/gpu/metal/tests:metal_submission_backstop_tests, //donner/gpu/vulkan/tests:vulkan_queue_progress_tests and EditorWindowPolicyTest.AnOverdueFrameBehindADeviceStillMakingProgressIsNotTimedOut cover the rest. The rationale and measurements are in the original design's bounded GPU waits section.
| SurfaceStatus | Meaning | Recovery |
|---|---|---|
| Success | The frame is usable. | Draw, then present it. |
| Outdated | The configuration no longer matches the window; a usable frame may still come with it. | Reconfigure to the current extent and acquire again. |
| Timeout | No frame became available in time. | Skip this frame and try again. |
| Lost | The platform object is gone. | Create a new surface from a fresh native handle. |
| DeviceLost | The root is lost. | Stop. Neither reconfiguring nor a new surface recovers. |
The editor applies this through AcquirePresentationFrame() and SurfaceFrameActionFor():
The editor registers the application pthread as GPU owner before creating devices. App and raster runtime devices share that owner's GPUDevice and queue. Standalone modules without a registered owner keep their device on the calling worker. The first logical device requests the browser device; later clients reuse it, and the last client releases it.
BrowserGpuOwner sends synchronous JavaScript primitives through a prewarmed Emscripten notification queue to an owner-only FIFO. A notification can execute its single request inline when the FIFO had no backlog. After 32 inline requests or 2 ms of callback CPU, further work waits for a later browser pulse; only that pulse renews the budget. These thresholds stop starting more callbacks; they do not preempt one already running. Deferred work executes in a detached batch, so new arrivals cannot extend it. The editor defers asynchronous GPU batches during canvas acquisition, drawing and retirement, and prevents nested UI frames during dispatch.
The editor's BrowserGpuOwnerWait adapter services notifications and a detached batch between one-millisecond futex wait slices on the registered app owner. Emscripten's automatic queue processing only covers the main runtime thread; PROXY_TO_PTHREAD does not give the app pthread that role. Other threads keep the original wait implementation. The adapter checks the caller's absolute deadline before starting another queue drain, bounds each remaining wait slice, and propagates non-timeout outcomes. An already running browser callback is not preempted and can finish after that deadline; the wait probe records its maximum queue drain duration and counts GPU callbacks actually executed inside a wait. The queue is initialized before raster clients start. Callbacks must not allocate or lock C++ state, yield, wait for another caller, or wait for a Promise. Prepared command batches contain at most 1024 operations; the first refusal stops replay before submission. A ten-second dispatch deadline terminates the module instead of returning borrowed memory to a queued callback. Descriptor assembly, shared identity bookkeeping and mapped storage remain on callers.
//donner/editor/wasm/tests:browser_presentation_regression_test exercises delayed owner pulses, actual inline/pulse/futex dispatch counts, and coalesced animation-frame admission. The native //donner/gpu/browser:browser_tests target covers detached FIFO order, reentrant enqueue and callbacks that reclaim their borrowed task storage.
Browser renderer teardown releases resources without waiting for queue completion; submitted commands retain browser resources. Editor shutdown yields the app event loop until the raster loop exits before joining it, allowing pending device/capture promises to settle.
The browser settles a device request asynchronously, through promises that cannot run while the requesting thread holds the event loop. gpu::browser::BrowserDeviceRequest::settle() therefore yields the thread to the browser in short slices. Geode allows each request ten seconds (kBrowserDeviceSettleSeconds in GeodeBrowserRoot.cc), both for the root and for each runtime device. A request that fails or does not settle leaves no root, GeodeDevice::CreateHeadless() returns null, and a RendererGeode without a device draws nothing.
Each failure is logged as one fixed-format line, [Geode/browser/acquire] stage=<stage> outcome=<outcome>. The stage is selection (the root), runtime (a context's runtime device) or root (building the first context over a selected root).
| Outcome | Meaning |
|---|---|
| api_unavailable | The worker has no navigator.gpu. |
| adapter_null | requestAdapter() resolved without an adapter. |
| adapter_rejected | requestAdapter() threw or rejected. |
| device_rejected | requestDevice() rejected or resolved without a device. |
| device_install_failed | The device arrived but could not be installed. |
| request_failed | The request failed for another reason. |
| deadline_pending | The request had not settled within the ten-second window. |
| context_failed | The device was ready, but the runtime device over it could not be built. |
| construction_failed | A Geode context could not be built over the selected root. |
The browser smoke suite (donner/editor/wasm/tests/smoke.spec.ts) records only lines in this format and reports any other line with the prefix as an invalid marker. In the main CI workflow's macOS job, after the test or browser-test step fails, or on a pull request labelled ci:browser-gpu-diagnostics, a step checks the browser test logs. It runs a serial browser GPU comparison when the label is set, or when a log contains stage=selection outcome=deadline_pending or a browser stall-diagnostics deadline.
WebGPU errors that no call can return, such as a validation error in recorded work, are logged to the console as [Geode/browser] Uncaptured error: <message>.
The runtime's own validation is always on and is the contract. Platform validation layers are a test-time check of the backends underneath it:
The cutover gates require zero Metal API and Vulkan synchronization-validation errors on the exercised native workloads. No CI lane enables the Vulkan layers; for the cutover the maintainer accepted per-feature development runs of synchronization validation on lavapipe, which reported no findings. A lane that must have a device fails rather than skips without one: the Metal device targets fail under CI and skip on a developer machine (DONNER_REQUIRE_METAL_DEVICE), and the Vulkan targets fail when DONNER_REQUIRE_VULKAN=1 is set.
The editor has no in-app GPU error message. A GPU failure shows up in the window, the log and, in the browser, the statistics the page publishes.
At startup:
While running:
The loss is logged once per root as [gpu] Device declared lost: <reason>. Recovery means restarting the editor or reloading the page.