Live: the shader teardown can no longer race its own construction

The new cycling assertion caught a real defect on CI: vite8-react-insert
reached CYCLING with #impeccable-live-shader still painted over the page.

showShaderOverlay is async. It appends its canvas synchronously, then
awaits createImageBitmap and finishes the GL setup before it publishes
shaderState. hideShaderOverlay returned early on a null shaderState, so a
teardown that landed inside that window did nothing, and the construction
then published itself over a session that had already left GENERATING,
with no teardown left to run. The scroll tick kept repositioning it,
which is why the CI page.html shows the canvas sized from the capture
rect but styled to the cycling anchor.

Every teardown now bumps a shader epoch before it does anything else, and
a construction pins the epoch it owns and abandons its canvas (releasing
the GL context) at every point past an await and before any publish,
including both bitmap-fallback publishes. A teardown also drops a shader
node that no shaderState owns, so an already-orphaned canvas cannot
survive one.

Reproduced by widening the append-to-publish window: with a 400ms delay
after uiAppend, vite8-react-insert failed with the CI error and the probe
showed the teardown arriving at CYCLING with shaderState still null.
The same run passes with this change, as does a 1500ms window on insert
and plain. Locally that window is about 4ms, which is why it only showed
on a slower runner.

The four remaining setLiveState('CYCLING') sites that did not lower the
loader now do: the SSE done handler (the one route that can reach CYCLING
from GENERATING), the Svelte republish remount, and the two accept
failure recoveries.

The e2e assertion already waits up to 5s for the shader to clear, so it
was never racing a legitimate teardown; it is left as it is.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
This commit is contained in:
Paul Bakaus
2026-09-04 14:31:25 +05:00
committed by Abdul Wahab
co-authored by Claude Code
parent 6d5f78eebf
commit f7c92d9eb9
2 changed files with 107 additions and 1 deletions
+61
View File
@@ -564,6 +564,67 @@ describe('live-browser source contracts', () => {
);
});
it('never leaves a shader behind when the teardown races its construction (#719)', () => {
// showShaderOverlay appends its canvas, then awaits createImageBitmap and
// the GL setup before it publishes shaderState. A teardown inside that
// window found shaderState null, returned, and then watched the
// construction publish itself over a session that had already reached
// CYCLING, with no teardown left to run. On a slow runner that is the
// generating loader frozen over a page that already cycles.
const hideStart = SOURCE.indexOf('function hideShaderOverlay()');
const hide = SOURCE.slice(hideStart, SOURCE.indexOf('\n function ', hideStart + 10));
assert.match(
hide,
/shaderEpoch \+= 1;[\s\S]{0,120}?if \(!shaderState\) \{/,
'the epoch must be bumped before the no-state early return, or an in-flight construction never hears about the teardown',
);
assert.match(hide, /removeStrayShaderNode\(\);/, 'a teardown must also drop a shader node no state owns');
const showStart = SOURCE.indexOf('async function showShaderOverlay(');
const show = SOURCE.slice(showStart, SOURCE.indexOf('\n async function handleAccept', showStart));
assert.match(show, /const epoch = shaderEpoch;/, 'the construction must pin the epoch it owns');
assert.match(
show,
/const abandoned = \(node, gl\) => \{[\s\S]{0,80}?if \(epoch === shaderEpoch\) return false;[\s\S]{0,200}?return true;/,
'abandoning must remove the canvas and release the GL context',
);
assert.match(
show,
/if \(abandoned\(canvas, gl\)\) return;\n shaderState = \{ canvas, gl, program, texture,/,
'the publish must be guarded by the epoch it pinned',
);
const awaitIdx = show.indexOf('await createImageBitmap(blob)');
assert.ok(awaitIdx > 0, 'createImageBitmap is the await this guards');
assert.ok(
show.indexOf('if (abandoned(canvas, gl))', awaitIdx) > awaitIdx,
'the bitmap await must be followed by an abandonment check',
);
for (const call of ['showShaderBitmapFallback(canvas, blob);']) {
let at = show.indexOf(call);
assert.ok(at > 0, call);
while (at > 0) {
const before = show.slice(Math.max(0, at - 220), at);
assert.match(before, /abandoned\(canvas, (?:gl|null)\)/, 'every fallback publish must be epoch guarded');
at = show.indexOf(call, at + 1);
}
}
});
it('lowers the shader on every route that sets CYCLING (#719)', () => {
// resumeSession reaches CYCLING through setLiveState(resumedState), which
// its own block covers; every literal site has to lower the loader too.
const sites = [...SOURCE.matchAll(/setLiveState\('CYCLING'\);/g)].map((m) => m.index);
assert.ok(sites.length >= 8, `expected the known CYCLING sites, saw ${sites.length}`);
for (const at of sites) {
const after = SOURCE.slice(at, at + 260);
assert.match(
after,
/hideShaderOverlay\(\);/,
`a setLiveState('CYCLING') at offset ${at} does not lower the generating shader`,
);
}
});
it('prefers the wrapper that actually holds variants over the first match (#719)', () => {
// A target inside a `.map()` renders one wrapper per item, and an agent
// that relocates the wrapper out of the shared primitive live-wrap