fix(dashboard): backdrop lifecycle races + GPU buffer leaks (Kimi-audited)

Kimi K2.7-code audit (max-thinking) found a CRITICAL race + leaks that Claude's
shallower pass missed; all verified and fixed:
- CRITICAL: late WebGPU boot after the 6s watchdog fired bootFallback() would
  set mode='webgpu' and start a SECOND rAF loop, orphaning the fallback loop.
  Fixed with a top-of-function + post-await abort guard in tryBootWebgpu and a
  single idempotent tick()/startLoop() dispatcher (running flag) — rAF for the
  draw loop is now scheduled in EXACTLY one place.
- GPU buffer leaks: partBuf/uBuf are now destroyed via releaseBuffers() on every
  early-exit, the catch path, and the device-lost handler (was leaked everywhere
  except the handle!=null onDestroy path).
- resize() now reconfigures the WebGPU context (was stretching after resize).
- drawWebgpu wrapped in try/catch -> bootFallback (one bad frame no longer kills
  the field permanently).
- fallback now honors the intensity prop + is frame-rate independent (dt*60);
  onVisibility resets fbLastT; onDestroy SSR-guard ordering.

svelte-check 0 errors / 967 files. Memory Cinema / Graph3D untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-27 14:24:10 -05:00
parent b8de5c9bf7
commit 85650f02fd

View file

@ -238,6 +238,42 @@ fn fs(in: VsOut) -> @location(0) vec4f {
let startedAt = 0;
let lastT = 0;
// Tracks whether the tick() chain is currently live, so any caller can ask to
// (re)start the loop without risking a second parallel chain. `tick` clears it
// only when it actually stops (disposed); otherwise it keeps re-scheduling.
let running = false;
// Single rAF dispatcher: the ONLY place the draw loop is scheduled. Routing
// through one tick() means a mode switch, a mid-frame fallback, or a
// visibility change can never leave two self-scheduling chains racing.
function tick() {
if (disposed) { running = false; return; }
if (mode === 'webgpu') drawWebgpu();
else if (mode === 'fallback') drawFallback();
raf = requestAnimationFrame(tick);
}
// Idempotent loop starter: starts the chain only if it isn't already running,
// so a mid-frame bootFallback() (from drawWebgpu's catch) does NOT stack a
// second chain on top of the one tick() is about to schedule itself.
function startLoop() {
if (disposed || running) return;
running = true;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(tick);
}
function stopLoop() {
running = false;
cancelAnimationFrame(raf);
}
// Destroy any GPU buffers we may have created. Safe to call with either the
// committed `handle` or the in-flight boot locals — every early return,
// catch, and device-loss path funnels through here so a buffer can never leak.
function releaseBuffers(partBuf?: any, uBuf?: any) {
try { (partBuf ?? handle?.partBuf)?.destroy?.(); } catch {}
try { (uBuf ?? handle?.uBuf)?.destroy?.(); } catch {}
}
function pickCount(): number {
const small = (globalThis as any).innerWidth < 760;
let n = small ? Math.min(count, 12000) : count;
@ -265,15 +301,23 @@ fn fs(in: VsOut) -> @location(0) vec4f {
}
async function tryBootWebgpu() {
if (disposed || !gpuCanvas) return;
// Bail if the watchdog already handed control to Canvas2D (or we were
// disposed) before we even started — a late boot must never re-take over.
if (mode !== 'booting' || disposed || !gpuCanvas) return;
// Buffers tracked in locals so any early exit can release them before they
// ever reach `handle` (otherwise they leak on every late-return path).
let partBuf: any;
let uBuf: any;
const gpu = await bootVestigeGpu(gpuCanvas);
if (disposed) return;
// A 6s boot can resolve after the watchdog fired bootFallback(): if mode is
// no longer 'booting' (or we were disposed) abort so we don't start a 2nd loop.
if (disposed || mode !== 'booting') { releaseBuffers(partBuf, uBuf); return; }
if (!gpu) { bootFallback(); return; }
try {
const device = gpu.device;
const particleCount = pickCount();
const partData = buildParticles(particleCount);
const partBuf = device.createBuffer({
partBuf = device.createBuffer({
size: partData.byteLength,
usage: 0x80 | 0x8, // STORAGE | COPY_DST
mappedAtCreation: true
@ -281,7 +325,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
new Float32Array(partBuf.getMappedRange()).set(partData);
partBuf.unmap();
const uBuf = device.createBuffer({ size: 48, usage: 0x40 | 0x8 }); // UNIFORM | COPY_DST (3x vec4)
uBuf = device.createBuffer({ size: 48, usage: 0x40 | 0x8 }); // UNIFORM | COPY_DST (3x vec4)
const computeMod = device.createShaderModule({ code: COMPUTE_WGSL });
const renderMod = device.createShaderModule({ code: RENDER_WGSL });
@ -289,6 +333,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
layout: 'auto',
compute: { module: computeMod, entryPoint: 'main' }
});
if (disposed || mode !== 'booting') { releaseBuffers(partBuf, uBuf); return; }
const renderPipe = await device.createRenderPipelineAsync({
layout: 'auto',
vertex: { module: renderMod, entryPoint: 'vs' },
@ -307,7 +352,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
},
primitive: { topology: 'triangle-list' }
});
if (disposed) return;
if (disposed || mode !== 'booting') { releaseBuffers(partBuf, uBuf); return; }
const computeBind = device.createBindGroup({
layout: computePipe.getBindGroupLayout(0),
entries: [
@ -326,6 +371,7 @@ fn fs(in: VsOut) -> @location(0) vec4f {
gpu.device.lost?.then?.(() => {
if (disposed) return;
releaseBuffers(); // destroy this handle's buffers before dropping it
handle = null;
bootFallback();
});
@ -335,8 +381,9 @@ fn fs(in: VsOut) -> @location(0) vec4f {
resize();
startedAt = performance.now();
lastT = startedAt;
raf = requestAnimationFrame(drawWebgpu);
startLoop();
} catch {
releaseBuffers(partBuf, uBuf); // pipelines/binds may have failed mid-build
bootFallback();
}
}
@ -346,6 +393,18 @@ fn fs(in: VsOut) -> @location(0) vec4f {
if (mode === 'webgpu' && gpuCanvas) {
gpuCanvas.width = Math.floor(gpuCanvas.clientWidth * dpr);
gpuCanvas.height = Math.floor(gpuCanvas.clientHeight * dpr);
// Resizing the canvas drops the swapchain's configuration; re-apply it
// (same premultiplied alpha bootVestigeGpu used) or the next frame
// renders against a stale-sized / unconfigured context.
if (handle) {
try {
handle.gpu.context.configure({
device: handle.gpu.device,
format: handle.gpu.format,
alphaMode: 'premultiplied'
});
} catch {}
}
} else if (fallbackCanvas) {
fallbackCanvas.width = Math.floor(fallbackCanvas.clientWidth * Math.min(dpr, 1.25));
fallbackCanvas.height = Math.floor(fallbackCanvas.clientHeight * Math.min(dpr, 1.25));
@ -354,45 +413,52 @@ fn fs(in: VsOut) -> @location(0) vec4f {
function drawWebgpu() {
if (disposed || !handle || mode !== 'webgpu') return;
const now = performance.now();
const dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
const t = (now - startedAt) / 1000;
const { device, context } = handle.gpu;
try {
const now = performance.now();
const dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
const t = (now - startedAt) / 1000;
const { device, context } = handle.gpu;
// advance any active reactive effect; clear it once it has faded out
if (evtCode !== 0) {
evtAge += dt;
if (evtAge > EVENT_DURATION) evtCode = 0;
// advance any active reactive effect; clear it once it has faded out
if (evtCode !== 0) {
evtAge += dt;
if (evtAge > EVENT_DURATION) evtCode = 0;
}
const evtNorm = evtCode === 0 ? 0 : Math.max(0, 1 - evtAge / EVENT_DURATION);
const u = new Float32Array(12);
u[0] = gpuCanvas!.width; u[1] = gpuCanvas!.height; u[2] = clampDpr(1.5); u[3] = reduced ? 1 : 0;
u[4] = t; u[5] = dt; u[6] = intensity; u[7] = handle.particleCount;
// event vec4: code, fade(1->0), origin handled below via x/y, intensity
u[8] = evtCode; u[9] = evtNorm * evtIntensity; u[10] = evtX; u[11] = evtY;
device.queue.writeBuffer(handle.uBuf, 0, u);
const enc = device.createCommandEncoder();
const cp = enc.beginComputePass();
cp.setPipeline(handle.computePipe);
cp.setBindGroup(0, handle.computeBind);
cp.dispatchWorkgroups(Math.ceil(handle.particleCount / WORKGROUP));
cp.end();
const view = context.getCurrentTexture().createView();
const rp = enc.beginRenderPass({
colorAttachments: [
{ view, clearValue: { r: 0.02, g: 0.012, b: 0.05, a: 1 }, loadOp: 'clear', storeOp: 'store' }
]
});
rp.setPipeline(handle.renderPipe);
rp.setBindGroup(0, handle.renderBind);
rp.draw(6, handle.particleCount);
rp.end();
device.queue.submit([enc.finish()]);
} catch (err) {
// A single bad frame (e.g. device loss mid-submit) must not kill the
// loop forever — drop to the resilient Canvas2D field instead.
console.error('[BackdropEngine] WebGPU frame failed, falling back', err);
bootFallback();
}
const evtNorm = evtCode === 0 ? 0 : Math.max(0, 1 - evtAge / EVENT_DURATION);
const u = new Float32Array(12);
u[0] = gpuCanvas!.width; u[1] = gpuCanvas!.height; u[2] = clampDpr(1.5); u[3] = reduced ? 1 : 0;
u[4] = t; u[5] = dt; u[6] = intensity; u[7] = handle.particleCount;
// event vec4: code, fade(1->0), origin handled below via x/y, intensity
u[8] = evtCode; u[9] = evtNorm * evtIntensity; u[10] = evtX; u[11] = evtY;
device.queue.writeBuffer(handle.uBuf, 0, u);
const enc = device.createCommandEncoder();
const cp = enc.beginComputePass();
cp.setPipeline(handle.computePipe);
cp.setBindGroup(0, handle.computeBind);
cp.dispatchWorkgroups(Math.ceil(handle.particleCount / WORKGROUP));
cp.end();
const view = context.getCurrentTexture().createView();
const rp = enc.beginRenderPass({
colorAttachments: [
{ view, clearValue: { r: 0.02, g: 0.012, b: 0.05, a: 1 }, loadOp: 'clear', storeOp: 'store' }
]
});
rp.setPipeline(handle.renderPipe);
rp.setBindGroup(0, handle.renderBind);
rp.draw(6, handle.particleCount);
rp.end();
device.queue.submit([enc.finish()]);
raf = requestAnimationFrame(drawWebgpu);
// NOTE: no rAF here — tick() owns scheduling.
}
// ---- Canvas2D fallback (no WebGPU: pre-iOS-26 iPhones, old GPUs) ----------
@ -401,7 +467,6 @@ fn fs(in: VsOut) -> @location(0) vec4f {
if (disposed || !fallbackCanvas) return;
if (mode === 'fallback' && fbParts.length) return; // idempotent: don't double-start
if (bootWatchdog) { clearTimeout(bootWatchdog); bootWatchdog = null; }
cancelAnimationFrame(raf); // stop any in-flight loop before switching
mode = 'fallback';
resize();
const n = (globalThis as any).innerWidth < 760 ? 520 : 1400;
@ -411,7 +476,10 @@ fn fs(in: VsOut) -> @location(0) vec4f {
}));
startedAt = performance.now();
fbLastT = 0;
raf = requestAnimationFrame(drawFallback);
// startLoop() is a no-op if the chain is already live (e.g. called from
// drawWebgpu's catch inside tick) — tick simply routes to drawFallback
// next frame. When called cold (watchdog / no-WebGPU) it starts the chain.
startLoop();
}
function fbField(x: number, y: number, t: number): [number, number] {
// cheap pseudo-curl: orthogonal gradient of a sine field
@ -436,21 +504,25 @@ fn fs(in: VsOut) -> @location(0) vec4f {
ctx.fillStyle = 'rgba(5,3,13,0.18)'; // trail fade on void
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'lighter';
// Normalise motion to a 60fps reference so the field drifts at the same
// speed regardless of refresh rate (WebGPU advects by *dt; match it here).
const fstep = dt * 60;
for (const p of fbParts) {
const [fx, fy] = fbField(p.x, p.y, t);
p.vx = p.vx * 0.92 + fx * (reduced ? 0.3 : 1);
p.vy = p.vy * 0.92 + fy * (reduced ? 0.3 : 1);
// scale the flow force by the ambient `intensity` prop (was ignored)
p.vx = p.vx * 0.92 + fx * (reduced ? 0.3 : 1) * intensity;
p.vy = p.vy * 0.92 + fy * (reduced ? 0.3 : 1) * intensity;
// firewall shockwave: push outward from the origin
let crimson = 0;
if (fwFade > 0) {
const dx = p.x - ox, dy = p.y - oy;
const dist = Math.hypot(dx, dy) + 1e-4;
const reach = Math.exp(-dist * 2.2) * fwFade;
p.vx += (dx / dist) * reach * 0.01;
p.vy += (dy / dist) * reach * 0.01;
p.vx += (dx / dist) * reach * 0.01 * fstep;
p.vy += (dy / dist) * reach * 0.01 * fstep;
crimson = Math.min(reach * 1.6, 1);
}
p.x += p.vx; p.y += p.vy;
p.x += p.vx * fstep; p.y += p.vy * fstep;
if (p.x < 0) p.x += 1; if (p.x > 1) p.x -= 1;
if (p.y < 0) p.y += 1; if (p.y > 1) p.y -= 1;
const sp = Math.hypot(p.vx, p.vy) * 220;
@ -464,16 +536,19 @@ fn fs(in: VsOut) -> @location(0) vec4f {
ctx.fillRect(p.x * w, p.y * h, 2.2 + crimson * 1.5, 2.2 + crimson * 1.5);
}
ctx.globalCompositeOperation = 'source-over';
raf = requestAnimationFrame(drawFallback);
// NOTE: no rAF here — tick() owns scheduling.
}
function onResize() { resize(); }
function onVisibility() {
if (document.hidden) {
cancelAnimationFrame(raf);
stopLoop();
} else if (!disposed) {
// Reset BOTH clocks so neither loop sees a huge dt jump after the tab
// was backgrounded (fallback uses fbLastT, webgpu uses lastT).
lastT = performance.now();
raf = requestAnimationFrame(mode === 'webgpu' ? drawWebgpu : drawFallback);
fbLastT = performance.now();
startLoop(); // idempotent — never stacks a second chain
}
}
@ -505,10 +580,10 @@ fn fs(in: VsOut) -> @location(0) vec4f {
unsubFieldEvents?.();
if (bootWatchdog) { clearTimeout(bootWatchdog); bootWatchdog = null; }
if (!browser) return; // onDestroy also fires during SSR — no DOM there
cancelAnimationFrame(raf);
stopLoop();
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibility);
try { handle?.partBuf?.destroy?.(); handle?.uBuf?.destroy?.(); } catch {}
releaseBuffers(); // destroy GPU buffers so they don't outlive the component
handle = null;
});
</script>