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(()=>{constdesired=this.artLoadout.slots();voidthis.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
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Symptom
Landing on
/hex-tower-defencewith any Art Studio asset assigned froze thetab within a second; Chrome then killed the renderer. No exception, nothing in
the console,
chrome://gpureportedGPU process crash count: 0. Removing theassignment from
game-art-loadoutsrestored 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
syncSlotsruns imperatively, and part of what it touches reads signals:applySlotresolvesArtAssetGameLoaderService, whose construction buildsArtStudioServiceand its twelvehttpResources — 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 reactivecontext — so the identical
applySlotcall was harmless. Hydration goes throughthe 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)
GPU process crash count: 0; other WebGL games finelogarithmicDepthBuffer, soft shadows)hexatile-gamesets the same flag and worksapplyStatecallsFix
untracked()around the imperative work, confining the effect to its onelegitimate dependency. Not theoretical: with
untracked()removed the newregression 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)
onArtAssetPickedpersisted the assignment even whenapplySlothad 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.
applySlotnowreports whether the slot went live and the save is gated on it.
Follow-up worth doing separately
ArtStudioService's eagerly-fetchinghttpResourceis what made thiscatastrophic rather than merely wasteful. The workspace's own resources rule says
a service injected eagerly but wanted later should gate its request on an
undefinedparams value. Resolving that service from a game page pulls the wholestudio list; worth revisiting.
Resolved in spikersoft-angular PR #582 (merged to
master,9a542b20/249db37e). Verified againstorigin/master.(For the record: angular PR #584 — the Art Studio GLB cache — is unrelated and is not the fix, despite landing nearby.)
hex-tower-defence.component.ts:238-239now reads the tracked signal once and does the work insideuntracked:httpResources, so it can't retrigger itself.:485now gates the save on the slot actually going live —if (!(await this.applySlot(slotId, assignment))) { return; }.hex-tower-defence.component.spec.ts:263.The eager-
httpResourceitem noted in the ticket stays a separate follow-up, as scoped there.Closing.