From f2c05d1c09fa71cb7ced3c4ee315277faccfc74b Mon Sep 17 00:00:00 2001 From: roowus Date: Sat, 29 Aug 2026 00:11:14 -0700 Subject: [PATCH] fix(portal): a failed plan park degrades to vanilla instead of hanging the boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to an owner report (overlay stuck on "Loading TSPML…"). Reproducing their exact four-mod set against production boots clean (frame mounts, game alive, 3decspeed 1/1 applied via the numeric-enum fix, husplits honestly token-not-found), so the hang was client state — but the investigation found the launcher could be bricked by its own diagnostics: the park chain had no rejection handler, and planReady gates the iframe mount, so ANY throw while parking (a corrupted stored record, a Cache API failure) left the overlay spinning forever with nothing in the log. Two changes, one philosophy — the same degrade the route already applies to a bad plan: - the boot park step catches: mount the game vanilla, log the failure loudly, set planReady. - buildUserPatchPlan and the page's PML-mixin persist merge read non-array mixins/pmlMixins fields as nothing (Array.isArray, not ?? []) — a hand-edited or mid-deploy record can no longer throw in the spread. Portal 734 -> 735 tests. --- source/portal/app/play/page.tsx | 129 +++++++++++++---------- source/portal/lib/user-patches.ts | 11 +- source/portal/tests/user-patches.test.ts | 10 ++ 3 files changed, 93 insertions(+), 57 deletions(-) diff --git a/source/portal/app/play/page.tsx b/source/portal/app/play/page.tsx index 93aa598..a9b68e1 100644 --- a/source/portal/app/play/page.tsx +++ b/source/portal/app/play/page.tsx @@ -537,61 +537,76 @@ export default function PlayPage(): ReactElement { // `stored` stays the thing shown and persisted. const running = runningMods(); let cancelled = false; - planChainRef.current = planChainRef.current.then(async () => { - const r = await parkUserPatchPlan(running); - if (cancelled) return; - parkedFingerprintRef.current = r.fingerprint; - servedFingerprintRef.current = r.fingerprint; // the first frame loads THIS plan - planSetsRef.current = r.sets; - setMixinOverCap(r.overCap); - setMixinEnvSkipped(r.envSkipped); - if (!r.cacheOk) { - setMixinNotice('Storage for mixin plans is unavailable — user-mod mixins will not be applied this session.'); - } - // #43: the physics plan is parked in the SAME step, before `planReady` - // releases the iframe. The wasm is fetched well after the bundle, so this - // has slack the mixin plan does not — but gating both on one flag keeps - // "the plan is parked" a single fact rather than two racing ones. - const p = await parkPhysicsPlan(running); - if (cancelled) return; - parkedPhysicsRef.current = p.fingerprint; - servedPhysicsRef.current = p.fingerprint; - setPhysicsExcluded(p.excluded); - if (!p.cacheOk) { - setPhysicsNotice('Storage for physics plans is unavailable — physics patches will not be applied this session.'); - } - setPlanReady(true); - log( - r.sets > 0 - ? `mixin plan parked (${r.sets} mod${r.sets === 1 ? '' : 's'} with patches)` - : 'mixin plan parked (empty — no user mixins)', - ); - log( - p.patches > 0 - ? `physics plan parked (${p.patches} patch${p.patches === 1 ? '' : 'es'})` - : 'physics plan parked (empty — no physics patches)', - ); - // Prewarm the transformed bundle: the serverless babel pass costs - // seconds, and without this it only starts AFTER the SW dance + iframe - // mount + game HTML parse. Firing the GET now runs it in parallel — - // the server memoizes the in-flight promise, so the game's real request - // piggybacks on this one instead of recomputing. Only when no mixin - // plan is parked: with a plan the SW replays bundle GETs as per-request - // POST composes (#62), so this would double the server work for an - // output that gets discarded, while the base memo it warms is not the - // path the game will take. The body is drained (not cancelled): an - // aborted body shows up as a failed request in devtools/smokes, and the - // extra parallel download is trivial next to the transform time saved. - if (r.sets === 0) { - log('prewarming the game bundle…'); - void fetch(`/api/proxy/main.bundle.js?version=${GAME_VERSION}`, { credentials: 'omit' }) - .then(async (res) => { - await res.arrayBuffer(); - log(`game bundle prewarmed (server cache: ${res.headers.get('x-tspml-bundle-cache') ?? 'n/a'})`); - }) - .catch(() => log('game bundle prewarm failed (non-fatal)')); - } - }); + planChainRef.current = planChainRef.current + .then(async () => { + const r = await parkUserPatchPlan(running); + if (cancelled) return; + parkedFingerprintRef.current = r.fingerprint; + servedFingerprintRef.current = r.fingerprint; // the first frame loads THIS plan + planSetsRef.current = r.sets; + setMixinOverCap(r.overCap); + setMixinEnvSkipped(r.envSkipped); + if (!r.cacheOk) { + setMixinNotice('Storage for mixin plans is unavailable — user-mod mixins will not be applied this session.'); + } + // #43: the physics plan is parked in the SAME step, before `planReady` + // releases the iframe. The wasm is fetched well after the bundle, so this + // has slack the mixin plan does not — but gating both on one flag keeps + // "the plan is parked" a single fact rather than two racing ones. + const p = await parkPhysicsPlan(running); + if (cancelled) return; + parkedPhysicsRef.current = p.fingerprint; + servedPhysicsRef.current = p.fingerprint; + setPhysicsExcluded(p.excluded); + if (!p.cacheOk) { + setPhysicsNotice('Storage for physics plans is unavailable — physics patches will not be applied this session.'); + } + setPlanReady(true); + log( + r.sets > 0 + ? `mixin plan parked (${r.sets} mod${r.sets === 1 ? '' : 's'} with patches)` + : 'mixin plan parked (empty — no user mixins)', + ); + log( + p.patches > 0 + ? `physics plan parked (${p.patches} patch${p.patches === 1 ? '' : 's'})` + : 'physics plan parked (empty — no physics patches)', + ); + // Prewarm the transformed bundle: the serverless babel pass costs + // seconds, and without this it only starts AFTER the SW dance + iframe + // mount + game HTML parse. Firing the GET now runs it in parallel — + // the server memoizes the in-flight promise, so the game's real request + // piggybacks on this one instead of recomputing. Only when no mixin + // plan is parked: with a plan the SW replays bundle GETs as per-request + // POST composes (#62), so this would double the server work for an + // output that gets discarded, while the base memo it warms is not the + // path the game will take. The body is drained (not cancelled): an + // aborted body shows up as a failed request in devtools/smokes, and the + // extra parallel download is trivial next to the transform time saved. + if (r.sets === 0) { + log('prewarming the game bundle…'); + void fetch(`/api/proxy/main.bundle.js?version=${GAME_VERSION}`, { credentials: 'omit' }) + .then(async (res) => { + await res.arrayBuffer(); + log(`game bundle prewarmed (server cache: ${res.headers.get('x-tspml-bundle-cache') ?? 'n/a'})`); + }) + .catch(() => log('game bundle prewarm failed (non-fatal)')); + } + }) + // A park failure must NEVER hang the launcher. `planReady` gates the + // iframe mount, and before this catch a rejection anywhere above (a + // corrupted stored record, a Cache API failure mid-write) left the boot + // overlay stuck on "Loading TSPML…" forever with nothing in the log — + // the launcher bricked by its own diagnostic silence. The degrade is the + // same one the route applies to a bad plan: mount the game VANILLA, say + // so loudly, and let the player keep playing. + .catch((e) => { + log(`✗ parking the mod plans failed — mounting the game WITHOUT mod patches: ${(e as Error).message}`); + parkedFingerprintRef.current = ''; + servedFingerprintRef.current = ''; + planSetsRef.current = 0; + setPlanReady(true); + }); return () => { cancelled = true; }; @@ -790,7 +805,9 @@ export default function PlayPage(): ReactElement { const rid = r.manifest.id; const entry = collectedMixins.find((e) => e.id === rid); if (entry === undefined || typeof rid !== 'string') return r; - const existing = r.pmlMixins ?? []; + // Array.isArray for the same reason buildUserPatchPlan guards: a + // hand-edited or mid-deploy record must never throw in the merge. + const existing = Array.isArray(r.pmlMixins) ? r.pmlMixins : []; const known = new Set(existing.map((p) => JSON.stringify(p))); const fresh = entry.report.mixins.filter((p) => !known.has(JSON.stringify(p))); if (fresh.length === 0) return r; diff --git a/source/portal/lib/user-patches.ts b/source/portal/lib/user-patches.ts index 9f129d2..a7d4df0 100644 --- a/source/portal/lib/user-patches.ts +++ b/source/portal/lib/user-patches.ts @@ -200,7 +200,16 @@ export function buildUserPatchPlan(mods: readonly UserModRecord[]): { // not a pasted mixins.json — either can be absent. One set per mod, both // kinds together, because the caps and the report are per mod, not per // patch kind. - const patches = [...(mod.mixins ?? []), ...(mod.pmlMixins ?? [])]; + // + // Array.isArray rather than `?? []`: localStorage is hand-editable and + // has survived many deploys — a truthy NON-array here (an object, a + // string) would throw in the spread below, and a throw in plan parking + // used to hang the boot overlay forever. Non-array shapes read as + // "nothing to carry", which is the truth of them. + const patches = [ + ...(Array.isArray(mod.mixins) ? mod.mixins : []), + ...(Array.isArray(mod.pmlMixins) ? mod.pmlMixins : []), + ]; if (!mod.enabled || patches.length === 0) continue; const modId = userModId(mod); if (modId === null) continue; // id-less mods pre-fail in the loader anyway diff --git a/source/portal/tests/user-patches.test.ts b/source/portal/tests/user-patches.test.ts index 4f62864..b527d06 100644 --- a/source/portal/tests/user-patches.test.ts +++ b/source/portal/tests/user-patches.test.ts @@ -64,6 +64,16 @@ describe('buildUserPatchPlan', () => { ]); }); + it('reads a non-array mixins/pmlMixins field as nothing, rather than throwing', () => { + // localStorage is hand-editable and has survived many deploys; a truthy + // non-array here used to throw in the spread and — before the boot chain + // grew its catch — hang the launcher on "Loading TSPML…" forever. + const junk = { ...record({ id: 'junk', format: 'pml' }), pmlMixins: { nope: 1 } }; + const junk2 = { ...record({ id: 'junk2' }), mixins: 'not an array' }; + const { plan } = buildUserPatchPlan([junk, junk2]); + expect(plan.sets).toEqual([]); + }); + it('caps a PML splice by its func exactly as an inject is capped', () => { // The payload field differs but the exposure is the same: the func is // spliced into the served bundle, so its size is bounded at ADD time.