Skip to content

#584: device-lost recovery + force-loss seam + cpuMs fallback - #586

Merged
MaximumTrainer merged 1 commit into
next/svelte-webgpufrom
claude/project-thread-lddi4g-584-device-lost
Oct 25, 2026
Merged

MaximumTrainer merged 1 commit into
next/svelte-webgpufrom
claude/project-thread-lddi4g-584-device-lost

Conversation

@MaximumTrainer

Copy link
Copy Markdown
Owner

Closes #584 FR1 / FR2 / FR3 (D1-a, D2-a, D3-a, D4-a, D6-c). FR4 (pixel-level tier differentiation) stays per the agreed D5-a — rely on today's content gates first, open a follow-up only if screenshots still look identical after SSAO lands harder.

The problem this fixes

Measured on Dan's Intel Gen-9 laptop against next/svelte-webgpu @ 43433ba: when the WebGPU device is lost, the engine sees it (deviceLostCount → 1, device.lost promise fires) but doesn't act on it. The rAF loop keeps submitting GPUCommandEncoder.finish + queue.submit against the dead device (45 of each in 1.5s), __ROWER3D_CONTEXT_STATE stays 'ready', no banner appears, and the rower sees a visually frozen scene with no sign of what went wrong.

What this PR does

FR1 — the engine actually recovers (D1-a + D2-a + D3-a)

  • attach.ts now factors the per-device scene into src/lib/renderer/engine/rideScene.ts (mechanical extraction, same behaviour). attach keeps the device-session lifecycle, outer rAF loop and telemetry.
  • On the first device.lost:
    • publishFallback('device-lost')
    • __ROWER3D_CONTEXT_STATE.kind → 'lost', backend stays 'webgpu'
    • banner (__ROWER3D_BANNER) → "Lost the graphics context — restoring…" (D2-a copy ported verbatim from the retired WebGL spec)
    • current scene disposed, rAF loop stopped (no more work to the dead device)
    • fresh device requested with isRetry: true; on success a new scene is built on the same canvas (D1-a) and the loop resumes; context state back to 'ready', banner cleared.
  • On the retry's device also dying: requestDevice's existing isRetry path fires onDeviceLost('renderer-unavailable'), which maps to:
    • __ROWER3D_CONTEXT_STATE.kind → 'unavailable' with reason 'renderer-unavailable'
    • banner → "We couldn't get the graphics context back. Your row is still being recorded." (D3-a)
  • /row reads __ROWER3D_BANNER on a 250 ms interval and renders an overlay at the top of the stage with --restoring (amber) and --failed (red) variants. The row keeps counting underneath because ride.currentSession is DOM-driven.

FR2 — forced loss, test-only (D4-a)

__ROWER3D_FORCE_DEVICE_LOST() is installed on window only when __PLAYWRIGHT_TESTING is set. Points at the current scene's device, calls device.destroy(). Replaced on every scene build so a reattach exposes the new device, cleared on dispose.

FR3 — timing (D6-c)

  • device.ts now asks for the timestamp-query feature when the adapter has it (same shape as the existing BC-compression opt-in).
  • __ROWER3D_FRAME_STATS.cpuMs is populated every frame — measured end-to-end around frameOnce with performance.now().
  • gpuMs stays 0 until the frame-graph instrumentation lands. Follow-up: wire timestampWrites on each pass + a resolve/map readback. Called out in the issue so a reader can tell it apart from the no-timing case.

Tests

Flipped the it.todo('reattaches and flips contextState to lost on first device loss') from #585 to a real passing test; added:

  • reattaches and flips contextState to lost on first device loss, then back to ready on retry success — covers FR1 success path, asserts the scene owns a different device after loss.
  • drops to renderer-unavailable + failed banner when the retry device also dies — covers D3-a.
  • exposes __ROWER3D_FORCE_DEVICE_LOST only under __PLAYWRIGHT_TESTING (FR2 D4-a) — covers the test seam gate.
  • writes cpuMs on every frame as the gpuMs fallback (FR3 D6-c) — covers the always-on timing.

Test plan

  • npx vitest run src/lib/renderer/engine/__tests__/ — 119 pass (was 116 + 1 todo)
  • npx vitest run — 3652 pass + 8 skipped
  • npx tsc --noEmit -p tsconfig.app.json
  • npx tsc -p tsconfig.playwright.json --noEmit
  • npm run lint
  • Pre-commit + pre-push hooks green
  • CI on next/svelte-webgpu (preflight / scaffold smoke / WebGPU engine smoke / e2e-shards 1/2 + 2/2 / GPU runner skipped)
  • RC verification on wood-laptop-g5 after merge

Generated by Claude Code

The WebGPU engine detected device.lost but did nothing with it: no
re-attach, no banner, no fallback publish, and the rAF loop kept
submitting work to a dead device. Measured on Intel Gen-9 against
next/svelte-webgpu @ 43433ba: 45 GPUCommandEncoder.finish + 45
queue.submit in 1.5s after a forced loss, no visible sign to the rower,
__ROWER3D_CONTEXT_STATE stuck at 'ready'.

Factors the per-device scene into src/lib/renderer/engine/rideScene.ts;
attach.ts stays the thin orchestration layer (telemetry, outer render
loop, device session lifecycle). On the first device.lost (D1-a):

  - publish fallbackReason='device-lost'
  - flip __ROWER3D_CONTEXT_STATE.kind to 'lost', backend stays 'webgpu'
  - publish the "restoring…" banner (D2-a copy ported verbatim from
    the retired WebGL spec)
  - dispose the current scene so no further work lands on the dead
    device
  - request a fresh device with isRetry: true
  - on retry success, build a new scene on the SAME canvas and
    continue; on retry failure, the retry's own onDeviceLost fires
    with 'renderer-unavailable', the engine publishes the
    "couldn't get it back" banner and stops drawing (D3-a).

FR2 (D4-a): installs __ROWER3D_FORCE_DEVICE_LOST on the current
device, gated on window.__PLAYWRIGHT_TESTING, so a spec can trigger
a loss without reaching into the Chrome rendering panel. Replaced on
every scene build, cleared on dispose.

FR3 (D6-c): device opts in to the timestamp-query feature when the
adapter has it (follow-up wires per-pass timestampWrites + readback
for __ROWER3D_FRAME_STATS.gpuMs). cpuMs lands this PR as the always-on
fallback — measured around frameOnce with performance.now, written
to telemetry every frame.

UI: /row polls window.__ROWER3D_BANNER at 250 ms and renders a banner
overlay at the top of the stage with variant-colored backgrounds for
restoring (amber) and failed (red). The row keeps counting through
either state (ride.currentSession is DOM-driven, not render-driven).

Flips the it.todo in ac4PortedBehaviors.test.ts (added in #585) to
a passing test; adds three more: reattach-success, retry-also-dies,
and force-loss seam presence. rideScene.ts is a mechanical extraction
from attach.ts, exercised transitively via the attach suite — TDD
guard off for the file move only (no new behaviour).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b
@MaximumTrainer
MaximumTrainer merged commit fc4eb61 into next/svelte-webgpu Oct 25, 2026
6 checks passed
@MaximumTrainer
MaximumTrainer deleted the claude/project-thread-lddi4g-584-device-lost branch October 25, 2026 15:26
MaximumTrainer added a commit that referenced this pull request Oct 25, 2026
* #542 AC1: remove the React tree from next/svelte-webgpu

The Svelte + WebGPU port is at parity (PRs #581–#586). Everything the
React/three.js shell owned is gone: `src/App.tsx`, `src/main.tsx`,
`src/components/` (bar `routeMapProjection.ts` and the pure-TS scenery
config / helpers that `src/lib/scenery/` and the Svelte UI still read),
`src/hooks/`, `src/context/`, all 66 `*.tsx` tests and the handful of
`*.ts` tests that only measured a React-era contract. The scenery
`__tests__` were parity suites pinned on each React implementation, so
they go too.

Pure-TS modules under `src/components/rower3d/` that Svelte, `src/lib`
or the renderer still import stay: `conditions`, `constants`, `crewModel`,
`ghost`, `navigableWidth`, `rowerAppearance`, `sceneQuality`,
`sceneryAssets`, `sceneryClearance`, `sceneryConfig`, `sceneryCrossings`,
`sceneryRegion`, `sceneryTrack`, `segmentScenery`, `themeConfig`. They
move into `src/lib/` in a later slice.

Config fallout:
 * `index.html` — repo-root entry only existed to boot React; SvelteKit
   owns the HTML host via `src/app.html`.
 * `tsconfig.app.json` — `jsx: react-jsx` is dead with no `.tsx` left.
 * `tsconfig.playwright.json` — drops `renderBudget`/`strokePose`
   includes (and the two spec files that read them, which only ran on
   the React engine).
 * `eslint.config.js` — drops the react-hooks + react-refresh plugins.
 * `setupTests.ts` — drops the `@testing-library/jest-dom` shim; no
   preserved test uses its matchers.
 * `playwright.config.next.ts` — removes two deleted specs from the
   next-branch testIgnore.

What stays for the follow-up:
 * `package.json` dependencies on `react`, `react-dom`, `@react-three/*`,
   `postprocessing`, `react-chartjs-2`, `@testing-library/react`,
   `@react-three/test-renderer`, `@types/react`, `@types/react-dom`,
   `@vitejs/plugin-react`, `eslint-plugin-react-hooks`,
   `eslint-plugin-react-refresh` — nothing in the surviving tree
   imports them, but ripping them out touches `package-lock.json` and
   deserves its own PR.

Local gates: `tsc`, `eslint`, `vitest` (213 files · 2081 pass · 8 skip),
`tsc -p tsconfig.playwright.json`, `svelte-check` (clean — remaining
warnings are pre-existing node_modules + tsconfig composite notes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b

* #542 AC1: re-base coverage floors after the React sweep

Removing the React tree is a measurement change on the same shape as
`#542 P0`'s Vitest 1 -> 3 upgrade: the tests that only existed to pin
the three.js scene went with the production code they covered, and the
parity suites under `src/lib/scenery/__tests__/` that happened to count
toward preserved files' per-file 100 floors are gone too.

New measurement over the surviving tree: 89.11 / 89.11 / 85.77 / 90.66
(lines / statements / branches / functions). Floors land a point under
each reading, following the "set a floor clear of its measurement so
the gate stops answering 'did the last digit move'" rule the comments
above these numbers have followed since #340.

Also drops the per-file 100 floors for files that left with React —
`wakeTexture.ts`, `foliageMaterial.ts`, the rower3d `landscapeLayout.ts`
and `foliagePlan.ts`, the three R3F `*.tsx` wrappers — plus the
`**/foliageTexture.ts` floor whose only remaining match (the Svelte
`src/lib/scenery/foliageTexture.ts`) is at 15 % because its parity test
left too. The engine-side test that owns its measurement re-lands with
the renderer work it belongs to.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants