videoskilletdecisions Open the app ↗
On this page

0004 — Never destroy a GPUDevice that has been presenting

Status: accepted, 2026-08-07. Supersedes 0002.

Context#

0002 established that a tab stops being given animation frames after two or three WebGPU sessions, and modelled it as a budget: devices are scarce, count them, spend them carefully. Every route measured there fitted — page loads, hot updates, device-loss rebuilds — and the app was built on it.

Then a session reported five devices in one tab, which the budget model called impossible-in-practice, so the model got tested directly. It is wrong. The count is not what a tab runs out of.

Four runs, Firefox Nightly / Linux, one tab per arm, rAF sampled over 1.5 s after every step (scripts/devicetear.mjs):

Creating devices costs nothing. Four devices created and destroyed with no canvas involved, then four created with a configured swapchain and destroyed, then four created and held open:

plain    4x create + destroy, never presented     90 rAF/1.5s throughout
present  4x create + configure + destroy          90 rAF/1.5s throughout
keep     4x create + configure, all held open      90 rAF/1.5s throughout

Presenting and then tearing down costs the tab. Same document, no reload; each round creates a device, configures a swapchain, presents real frames through a render pass, and destroys the device:

cycle    round 1: presented  2 frames, then 103 rAF/1.5s
cycle    round 2: presented 76 frames, then   0 rAF/1.5s   *** rAF STOPPED ***

That is the whole difference. Identical page, identical frames presented, one arm destroying the device at the end of each round and one keeping it:

destroy  round 1: presented 51 frames, then  90 rAF/1.5s
destroy  round 2: presented 57 frames, then   0 rAF/1.5s   *** rAF STOPPED ***
keep     round 1: presented 58 frames, then  86 rAF/1.5s
keep     round 2: presented 56 frames, then  90 rAF/1.5s
keep     round 3: presented 53 frames, then  87 rAF/1.5s

Including across a reload, which is where the app was doing it to itself. The same minimal presenting page, reloaded four times in one tab, differing only in whether a pagehide handler destroys the device on the way out — which is what useEngine did, added on the reasoning that an abandoned device carries a wedged GPU into the next page:

destroy   load 1:  80 rAF/1.5s
destroy   load 2:   0 rAF/1.5s   *** rAF STOPPED ***
destroy   load 3:   0 rAF/1.5s   *** still dead
destroy   load 4:   0 rAF/1.5s   *** still dead
abandon   load 1:  85 rAF/1.5s
abandon   load 2:  87 rAF/1.5s
abandon   load 3:  85 rAF/1.5s
abandon   load 4:  83 rAF/1.5s
nopresent load 1-4: 90 rAF/1.5s each

So "reloading lands in the same hole" was true and was partly self-inflicted. The pagehide destroy — one line, written to make refreshes safer — is what made a refresh cost the tab. nopresent is the control: a device that never presented is free to destroy, so this is about the swapchain and not about devices.

The app, end to end. scripts/rafceiling.mjs --page=app reloads the real app in one tab. Before this change it recorded firstDeadSession: 2 (the run in 0002); after it:

app session 1: 72 rAF/1.5s  vis=visible      app session 5: 73 rAF/1.5s  vis=visible
app session 2: 72 rAF/1.5s  vis=visible      app session 6: 81 rAF/1.5s  vis=visible
app session 3: 71 rAF/1.5s  vis=visible      app session 7: 78 rAF/1.5s  vis=visible
app session 4: 81 rAF/1.5s  vis=visible      app session 8: 69 rAF/1.5s  vis=visible
                                             firstDeadSession: null

That arm is now a regression test for this decision rather than a demonstration of the fault.

What is measured is the behaviour. Why destroying a presenting device does this is inference: a configured swapchain is registered with the compositor and the tab's refresh driver, and destroying the device pulls the surface out from under a live registration, leaving the per-tab driver waiting on something that will never present again. Consistent with all four runs, unconfirmed — it needs Firefox's compositor side to say. The repro is ~40 lines and serves its own page, so it can go upstream as-is.

Decision#

The app never calls device.destroy().

Consequences#

Amendment, 2026-08-08 — the build ceiling is gone#

DOC_GPU_BUILD_LIMIT is removed. outOfGpuBudget() now asks one question: has this tab destroyed a presenting device. Nothing else refuses a session.

The ceiling was the last thing in here still shaped by 0002 — a count of creations, kept as a runaway backstop after the model that motivated it had been disproved. Two things were wrong with it.

A fast rebuild loop was already bounded, and not by this. RebuildPolicy gives up after three faults inside a minute, per fault kind, on a screen that explains itself. Anything that reached eight builds had to get there slowly.

What reaches it slowly is the case the policy deliberately forgives. RebuildPolicy resets when a replacement held — a laptop whose discrete card suspends under a hidden tab produces one loss per alt-tab, each rebuilt successfully, and the policy is written not to punish that. Every one of those still spent a build. So the ceiling ended a long healthy session on the ninth alt-tab, with a screen arguing it was "past what one was measured to survive" — against the measurement at the top of this file, where four devices created and held, all presenting, cost a tab nothing.

What repeated creation does still cost is the leak, and that is bounded by the document and was accepted here deliberately. It keeps the stage notice (gpuAtRisk(), unchanged at more than two builds), whose wording now says what is true of that case — the device keeps going away, each replacement empties VRAM, the session carries on — and offers the new-tab link only on the arm that has measured cause to.

Consequence: with the app never destroying devices, the surviving gate cannot fire in an ordinary session. It exists for the tab that ran ?gpudestroy=1 and then reloaded, which is exactly the hole this ADR documents.

Reproducing it#

node scripts/devicetear.mjs                 # all four arms, ~3 min, own page
node scripts/devicetear.mjs --arm=reload    # the one that explains refreshing

Each arm gets a fresh browser: the destroy arms leave a tab that cannot paint, and one of them took the whole browser process with it.