hex-tower-defence: loadout effect re-subscribes to the art loader's httpResources, looping until the renderer is killed #860

Closed
opened 2026-07-26 22:54:53 +00:00 by spikerj · 1 comment
Owner

Symptom

Landing on /hex-tower-defence with any Art Studio asset assigned froze the
tab within a second; Chrome then killed the renderer. No exception, nothing in
the console, chrome://gpu reported GPU process crash count: 0. Removing the
assignment from game-art-loadouts restored the page every time.

Size was irrelevant — a 109 MB export and a 4.55 MB / 17,215-triangle one killed
it identically.

Root cause

effect(() => {
  const desired = this.artLoadout.slots();
  void this.syncSlots(desired);        // imperative work, inside the reactive context
});

syncSlots runs imperatively, and part of what it touches reads signals:
applySlot resolves ArtAssetGameLoaderService, whose construction builds
ArtStudioService and its twelve httpResources — one fetching eagerly.

Signals read while an effect body is executing become that effect's
dependencies.
So the effect subscribed to those resources, re-ran the moment
any settled, resolved them again, and looped synchronously on the main thread
until the renderer was killed. Nothing threw, hence the empty console.

Why it only broke on reload

Picking an asset goes through onArtAssetPicked — a click handler, no reactive
context — so the identical applySlot call was harmless. Hydration goes through
the effect. "Works when I pick it, dies when I reload" is the discriminator, and
it is what identified this after a long hunt down the wrong paths.

Ruled out first (each measured, not reasoned)

suspect evidence
GPU / driver GPU process crash count: 0; other WebGL games fine
renderer options (logarithmicDepthBuffer, soft shadows) 8-step bisect passed on the affected Mac; hexatile-game sets the same flag and works
scene + HUD headless-Chromium harness: no leak, flat resources across 40 applyState calls
the three.js load path same harness with the real production GLB: fetch → parse → normalize → register → render, clean
asset size / triangles / texture fails on 4.55 MB / 17,215 tris
request storms exactly two API calls per load, then silence

Fix

untracked() around the imperative work, confining the effect to its one
legitimate dependency. Not theoretical: with untracked() removed the new
regression test does not merely fail, it kills the vitest worker
(Worker exited unexpectedly) — the same runaway loop in a different host.

Pre-existing, introduced with the original mapping feature (8461fd32, #353);
the #855 budget work only made it easier to reach.

Second, separate bug (this one from #855)

onArtAssetPicked persisted the assignment even when applySlot had refused it.
An asset rejected as "too detailed" was still written to the loadout and retried
on every subsequent page load — the player was stuck until the row was cleared in
the database, which happened twice during this investigation. applySlot now
reports whether the slot went live and the save is gated on it.

Follow-up worth doing separately

ArtStudioService's eagerly-fetching httpResource is what made this
catastrophic rather than merely wasteful. The workspace's own resources rule says
a service injected eagerly but wanted later should gate its request on an
undefined params value. Resolving that service from a game page pulls the whole
studio list; worth revisiting.

## Symptom Landing on `/hex-tower-defence` with **any** Art Studio asset assigned froze the tab within a second; Chrome then killed the renderer. No exception, nothing in the console, `chrome://gpu` reported `GPU process crash count: 0`. Removing the assignment from `game-art-loadouts` restored the page every time. Size was irrelevant — a 109 MB export and a 4.55 MB / 17,215-triangle one killed it identically. ## Root cause ```ts effect(() => { const desired = this.artLoadout.slots(); void this.syncSlots(desired); // imperative work, inside the reactive context }); ``` `syncSlots` runs imperatively, and part of what it touches reads signals: `applySlot` resolves `ArtAssetGameLoaderService`, whose construction builds `ArtStudioService` and its twelve `httpResource`s — one fetching eagerly. **Signals read while an effect body is executing become that effect's dependencies.** So the effect subscribed to those resources, re-ran the moment any settled, resolved them again, and looped synchronously on the main thread until the renderer was killed. Nothing threw, hence the empty console. ## Why it only broke on reload Picking an asset goes through `onArtAssetPicked` — a click handler, no reactive context — so the identical `applySlot` call was harmless. Hydration goes through the effect. "Works when I pick it, dies when I reload" is the discriminator, and it is what identified this after a long hunt down the wrong paths. ## Ruled out first (each measured, not reasoned) | suspect | evidence | |---|---| | GPU / driver | `GPU process crash count: 0`; other WebGL games fine | | renderer options (`logarithmicDepthBuffer`, soft shadows) | 8-step bisect passed on the affected Mac; `hexatile-game` sets the same flag and works | | scene + HUD | headless-Chromium harness: no leak, flat resources across 40 `applyState` calls | | the three.js load path | same harness with the real production GLB: fetch → parse → normalize → register → render, clean | | asset size / triangles / texture | fails on 4.55 MB / 17,215 tris | | request storms | exactly two API calls per load, then silence | ## Fix `untracked()` around the imperative work, confining the effect to its one legitimate dependency. Not theoretical: with `untracked()` removed the new regression test does not merely fail, it **kills the vitest worker** (`Worker exited unexpectedly`) — the same runaway loop in a different host. Pre-existing, introduced with the original mapping feature (`8461fd32`, #353); the #855 budget work only made it easier to reach. ## Second, separate bug (this one from #855) `onArtAssetPicked` persisted the assignment even when `applySlot` had refused it. An asset rejected as "too detailed" was still written to the loadout and retried on every subsequent page load — the player was stuck until the row was cleared in the database, which happened twice during this investigation. `applySlot` now reports whether the slot went live and the save is gated on it. ## Follow-up worth doing separately `ArtStudioService`'s eagerly-fetching `httpResource` is what made this catastrophic rather than merely wasteful. The workspace's own resources rule says a service injected eagerly but wanted later should gate its request on an `undefined` params value. Resolving that service from a game page pulls the whole studio list; worth revisiting.
Author
Owner

Resolved in spikersoft-angular PR #582 (merged to master, 9a542b20 / 249db37e). Verified against origin/master.

(For the record: angular PR #584 — the Art Studio GLB cache — is unrelated and is not the fix, despite landing nearby.)

  • The re-subscription loop is gone: hex-tower-defence.component.ts:238-239 now reads the tracked signal once and does the work inside untracked:
    effect(() => { const desired = this.artLoadout.slots(); untracked(() => void this.syncSlots(desired)); });
    
    with a load-bearing comment explaining why. The effect no longer takes a dependency on the loader's httpResources, so it can't retrigger itself.
  • A second bug found during the fix is also handled: :485 now gates the save on the slot actually going live — if (!(await this.applySlot(slotId, assignment))) { return; }.
  • Regression coverage at hex-tower-defence.component.spec.ts:263.

The eager-httpResource item noted in the ticket stays a separate follow-up, as scoped there.

Closing.

Resolved in spikersoft-angular PR #582 (merged to `master`, `9a542b20` / `249db37e`). Verified against `origin/master`. (For the record: angular PR #584 — the Art Studio GLB cache — is unrelated and is *not* the fix, despite landing nearby.) - The re-subscription loop is gone: `hex-tower-defence.component.ts:238-239` now reads the tracked signal once and does the work inside `untracked`: ```ts effect(() => { const desired = this.artLoadout.slots(); untracked(() => void this.syncSlots(desired)); }); ``` with a load-bearing comment explaining why. The effect no longer takes a dependency on the loader's `httpResource`s, so it can't retrigger itself. - A second bug found during the fix is also handled: `:485` now gates the save on the slot actually going live — `if (!(await this.applySlot(slotId, assignment))) { return; }`. - Regression coverage at `hex-tower-defence.component.spec.ts:263`. The eager-`httpResource` item noted in the ticket stays a separate follow-up, as scoped there. Closing.
Sign in to join this conversation.