fix(v2.3): 5 FATAL bugs + 4 god-tier upgrades from post-ship audit

Post-ship audit surfaced 6 FATALs and 4 upgrades. Shipping 5 of the 6 +
all 4 upgrades. FATAL 4 (VRAM hemorrhage from un-pooled label canvases
in createTextSprite) is pre-existing, not from this session, and scoped
separately for a proper texture-pool refactor.

**FATAL 1 — Toast Silent Lobotomy** (stores/toast.ts)
Subscriber tracked events[0] only. When Svelte batched multiple events
in one update tick (swarm firing DreamCompleted + ConnectionDiscovered
within the same millisecond), every event but the newest got silently
dropped. Fixed to walk from index 0 until hitting lastSeen — same
pattern as Graph3D.processEvents. Processes oldest-first to preserve
narrative order.

**FATAL 2 — Premature Birth** (graph/nodes.ts + graph/events.ts)
Orb flight is 138 frames; materialization was 30 frames. Node popped
fully grown ~100 frames before orb arrived — cheap UI glitch instead
of a biological birth. Added `addNode(..., { isBirthRitual: true })`
option that reserves the physics slot but hides mesh/glow/label and
skips the materializing queue. New `igniteNode(id)` flips visibility
and enqueues materialization. events.ts onArrive now calls igniteNode
at the exact docking moment, so the elastic spring-up peaks on impact.

**FATAL 3 — 120Hz ProMotion Time-Bomb** (components/Graph3D.svelte)
All physics + effect counters are frame-based. On a 120Hz display every
ritual ran at 2x speed. Added a `lastTime`-based governor in animate()
that early-returns if dt < 16ms, clamping effective rate to ~60fps.
`- (dt % 16)` carry avoids long-term drift. Zero API changes; tonight's
fast fix until physics is rewritten to use dt.

**FATAL 5 — Bezier GC Panic** (graph/effects.ts birth-orb update)
Flight phase allocated a new Vector3 (control point) and a new
QuadraticBezierCurve3 every frame per orb. With 3 orbs in flight that's
360 objects/sec for the GC to collect. Rewrote as inline algebraic
evaluation — zero allocations per frame, identical curve.

**FATAL 6 — Phantom Shockwave** (graph/events.ts)
A 166ms setTimeout fired the 2nd shockwave. If the user navigated
away during that window the scene was disposed, the timer still
fired, and .add() on a dead scene threw unhandled rejection. Dropped
the setTimeout entirely; both shockwaves fire immediately in onArrive
with different scales/colors for the same layered-crash feel.

**UPGRADE 1 — Sanhedrin Shatter** (graph/effects.ts birth-orb update)
If getTargetPos() returns undefined AFTER gestation (target node was
deleted mid-ritual — Stop hook sniping a hallucination), the orb
turns blood-red, triggers a violent implosion in place, and skips
the arrival cascade. Cognitive immune system made visible.

**UPGRADE 2 — Newton's Cradle** (graph/events.ts onArrive)
On docking the target mesh's scale gets bumped 1.8×, so the elastic
materialization + force-sim springs physically recoil instead of the
orb landing silently. The graph flinches when an idea is born into it.

**UPGRADE 3 — Hover Panic** (stores/toast.ts + InsightToast.svelte)
Paused dwell timer on mouseenter/focus, resume on mouseleave/blur.
Stored remaining ms at pause so resume schedules a correctly-sized
timer. CSS pairs via `animation-play-state: paused` on the progress
bar. A toast the user is reading no longer dismisses mid-sentence.

**UPGRADE 4 — Event Horizon Guard** (components/Graph3D.svelte)
If >MAX_EVENTS (200) events arrive in one tick, lastProcessedEvent
falls off the end of the array and the walk consumes ALL 200 entries
as "fresh" — GPU meltdown from 200 simultaneous births. Detect the
overflow and drop the batch with a console.warn, advancing the
high-water mark so next frame is normal.

Build + test:
- npm run check: 0 errors, 0 warnings
- npm test: 251/251 pass
- npm run build: clean static build
This commit is contained in:
Sam Valladares 2026-04-20 16:33:25 -05:00
parent f40aa2e086
commit ec614fed85
6 changed files with 242 additions and 52 deletions

View file

@ -121,9 +121,23 @@
if (ctx) disposeScene(ctx);
});
// 120Hz Governor. All physics and effect counters are frame-based
// (orb.age++, forceSim.tick, materialization frames). On a ProMotion
// display the browser drives rAF at 120 FPS, which would double-speed
// every ritual. Clamping to ~60 FPS keeps the visual timing identical
// across displays without rewriting every counter to use delta time.
// The `- (dt % 16)` carry avoids long-term drift.
let govLastTime = 0;
function animate() {
animationId = requestAnimationFrame(animate);
const time = performance.now() * 0.001;
const now = performance.now();
if (govLastTime === 0) govLastTime = now;
const dt = now - govLastTime;
if (dt < 16) return;
govLastTime = now - (dt % 16);
const time = now * 0.001;
// Force simulation
forceSim.tick(edges);
@ -174,6 +188,20 @@
fresh.push(e);
}
if (fresh.length === 0) return;
// Event Horizon Guard. If the last-processed reference fell off the
// end of the capped array (burst of >MAX_EVENTS events in one tick),
// the walk above consumed the ENTIRE buffer — we'd try to animate
// 200 simultaneous births and melt the GPU. Detect the overflow and
// drop this batch on the floor; state is already current via
// lastProcessedEvent pointing forward.
if (fresh.length === events.length && events.length >= 200) {
// eslint-disable-next-line no-console
console.warn('[vestige] Event horizon overflow: dropping visuals for', fresh.length, 'events');
lastProcessedEvent = events[0];
return;
}
lastProcessedEvent = events[0];
const mutationCtx: GraphMutationContext = {