From 8da39ba2f100298af51ea2134afb2dc0f7c2c20b Mon Sep 17 00:00:00 2001 From: Tchips46 Date: Thu, 27 Aug 2026 22:07:50 +0200 Subject: [PATCH 1/4] fix(website): stop cross-tab OPFS cache race that breaks asset loading isManifestUpToDate() unconditionally returned false (the up-to-date branch fell through to the same `return false` as the stale branch), and the only caller of GameCache.updateCache() hardcoded force: true. Together these guaranteed every single page load fully cleared and rewrote the OPFS game file cache, even when nothing had changed. OPFS is shared per-origin across every open tab. Two tabs loading around the same time both ran the full clear-and-rewrite unconditionally, racing on the same shared files: one tab's write can invalidate a blob: URL another tab already handed out for the same file. In Chromium this surfaces as a fetch or load failing with net::ERR_UPLOAD_FILE_CHANGED, and that URL can never succeed again - the affected tab ends up with assets (e.g. its game's sprites) permanently failing to load. - isManifestUpToDate: return true on the matching-version branch instead of falling through to false. - index.ts: stop forcing an unconditional rebuild so the (now-working) up-to-date check actually gets used. - GameCache: serialize the destructive rebuild across tabs with the Web Locks API as defense-in-depth, re-checking cache freshness once the lock is held in case a concurrent tab already finished rebuilding for the same manifest while we were waiting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Em3JASkEqn8hgf9Wqx1Sn1 --- apps/website/src/cache/cache.ts | 57 ++++++++++++++++++++++++++++++--- apps/website/src/index.ts | 6 +++- apps/website/src/manifest.ts | 2 +- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/website/src/cache/cache.ts b/apps/website/src/cache/cache.ts index f5d0236..8541c97 100644 --- a/apps/website/src/cache/cache.ts +++ b/apps/website/src/cache/cache.ts @@ -10,18 +10,67 @@ import { Logger } from "../utils/logger.utils"; import { setVersion } from "../version"; import { setLoadingStatus, setLoadingTotalFiles } from "../window"; +/** + * The game-file cache lives in the Origin Private File System, which - unlike + * `localStorage` or an in-memory cache - is shared by every tab/document open on + * this origin. `Web Locks` serializes the destructive part of a rebuild (clearing + * the cache directory and rewriting every file) across those tabs, so a lock name + * scoped to this app/origin is enough; there is nothing tab-specific to add to it. + */ +const CACHE_LOCK_NAME = "nanoforge-game-cache"; + +/** + * Runs `fn` while holding the cross-tab cache lock. Without this, two tabs + * rebuilding the shared OPFS cache at the same time can race: one tab's + * `directory.clear()` or file rewrite can invalidate a `blob:` URL another tab + * already handed out for the same file (surfaces as e.g. a fetch/`` failing + * with `net::ERR_UPLOAD_FILE_CHANGED`, and that URL can never succeed again). + * + * Falls back to just running `fn` on browsers without the Web Locks API - such a + * browser loses the cross-tab protection, but behaves exactly as it did before + * this lock was introduced. + */ +async function withCacheLock(fn: () => Promise): Promise { + if (typeof navigator === "undefined" || !navigator.locks) return fn(); + // `LockGrantedCallback` is typed as `(lock) => T`, not `(lock) => T | PromiseLike`, + // even though the real API (like `setTimeout`/array callbacks elsewhere) happily + // awaits a callback that returns a promise before releasing the lock. Awaiting + // here lets `Awaited<...>` unwrap the resulting `Promise>` correctly + // instead of reaching for an `as` cast. + return await navigator.locks.request(CACHE_LOCK_NAME, fn); +} + export class GameCache { private readonly logger: Logger = new Logger("Cache"); private readonly fs: FileSystemManager = new FileSystemManager("game"); async updateCache(manifest: IManifest, force = false): Promise { this.logger.info("Starting cache game files"); - let extendedManifest: IExtendedManifest | undefined = await this._parseCache(manifest); - if (force || !isManifestUpToDate(manifest) || !extendedManifest) - extendedManifest = await this._updateCacheProcess(manifest); + + let extendedManifest = await this._tryReuseCache(manifest, force); + + if (!extendedManifest) { + extendedManifest = await withCacheLock(async () => { + // A concurrent tab may have already rebuilt the cache for this exact + // manifest while we were waiting for the lock - reuse its result instead + // of redundantly clearing and re-downloading everything a second time. + return ( + (await this._tryReuseCache(manifest, force)) ?? (await this._updateCacheProcess(manifest)) + ); + }); + } + setVersion(manifest.version); this.logger.info("Game files cached"); - return extendedManifest as IExtendedManifest; + return extendedManifest; + } + + private async _tryReuseCache( + manifest: IManifest, + force: boolean, + ): Promise { + if (force || !isManifestUpToDate(manifest)) return undefined; + return this._parseCache(manifest); } private async _updateCacheProcess(manifest: IManifest): Promise { diff --git a/apps/website/src/index.ts b/apps/website/src/index.ts index 991b999..864acf5 100644 --- a/apps/website/src/index.ts +++ b/apps/website/src/index.ts @@ -20,7 +20,11 @@ const runLoad = async () => { const manifest = await getManifest(); runWatcher(manifest.watch); const cache = new GameCache(); - const extendedManifest = await cache.updateCache(manifest, true); + // `force` defaults to false: when the OPFS cache already matches the current + // manifest version, reuse it instead of clearing and re-downloading every game + // file on every load. Forcing this unconditionally is also what used to make a + // cross-tab OPFS race guaranteed on every single page load (see GameCache). + const extendedManifest = await cache.updateCache(manifest); const [files, mainModule] = await loadGameFiles(extendedManifest); const env = await getEnv(); setLoadingStatus("Starting game"); diff --git a/apps/website/src/manifest.ts b/apps/website/src/manifest.ts index 8fa739c..9c3931a 100644 --- a/apps/website/src/manifest.ts +++ b/apps/website/src/manifest.ts @@ -15,5 +15,5 @@ export const isManifestUpToDate = (manifest: IManifest): boolean => { setLoadingStatus("Verifying manifest"); const currentVersion = getVersion(); if (!currentVersion || currentVersion !== manifest.version) return false; - return false; + return true; }; From 613e25a01b970fc8b4183eec5cc8cc2293a0dde7 Mon Sep 17 00:00:00 2001 From: Tchips46 Date: Thu, 27 Aug 2026 22:48:16 +0200 Subject: [PATCH 2/4] refactor(website): drop explanatory comments from cache fix Same behavior, less commentary in the diff. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Em3JASkEqn8hgf9Wqx1Sn1 --- apps/website/src/cache/cache.ts | 26 -------------------------- apps/website/src/index.ts | 4 ---- 2 files changed, 30 deletions(-) diff --git a/apps/website/src/cache/cache.ts b/apps/website/src/cache/cache.ts index 8541c97..969dbeb 100644 --- a/apps/website/src/cache/cache.ts +++ b/apps/website/src/cache/cache.ts @@ -10,33 +10,10 @@ import { Logger } from "../utils/logger.utils"; import { setVersion } from "../version"; import { setLoadingStatus, setLoadingTotalFiles } from "../window"; -/** - * The game-file cache lives in the Origin Private File System, which - unlike - * `localStorage` or an in-memory cache - is shared by every tab/document open on - * this origin. `Web Locks` serializes the destructive part of a rebuild (clearing - * the cache directory and rewriting every file) across those tabs, so a lock name - * scoped to this app/origin is enough; there is nothing tab-specific to add to it. - */ const CACHE_LOCK_NAME = "nanoforge-game-cache"; -/** - * Runs `fn` while holding the cross-tab cache lock. Without this, two tabs - * rebuilding the shared OPFS cache at the same time can race: one tab's - * `directory.clear()` or file rewrite can invalidate a `blob:` URL another tab - * already handed out for the same file (surfaces as e.g. a fetch/`` failing - * with `net::ERR_UPLOAD_FILE_CHANGED`, and that URL can never succeed again). - * - * Falls back to just running `fn` on browsers without the Web Locks API - such a - * browser loses the cross-tab protection, but behaves exactly as it did before - * this lock was introduced. - */ async function withCacheLock(fn: () => Promise): Promise { if (typeof navigator === "undefined" || !navigator.locks) return fn(); - // `LockGrantedCallback` is typed as `(lock) => T`, not `(lock) => T | PromiseLike`, - // even though the real API (like `setTimeout`/array callbacks elsewhere) happily - // awaits a callback that returns a promise before releasing the lock. Awaiting - // here lets `Awaited<...>` unwrap the resulting `Promise>` correctly - // instead of reaching for an `as` cast. return await navigator.locks.request(CACHE_LOCK_NAME, fn); } @@ -51,9 +28,6 @@ export class GameCache { if (!extendedManifest) { extendedManifest = await withCacheLock(async () => { - // A concurrent tab may have already rebuilt the cache for this exact - // manifest while we were waiting for the lock - reuse its result instead - // of redundantly clearing and re-downloading everything a second time. return ( (await this._tryReuseCache(manifest, force)) ?? (await this._updateCacheProcess(manifest)) ); diff --git a/apps/website/src/index.ts b/apps/website/src/index.ts index 864acf5..f5a58e3 100644 --- a/apps/website/src/index.ts +++ b/apps/website/src/index.ts @@ -20,10 +20,6 @@ const runLoad = async () => { const manifest = await getManifest(); runWatcher(manifest.watch); const cache = new GameCache(); - // `force` defaults to false: when the OPFS cache already matches the current - // manifest version, reuse it instead of clearing and re-downloading every game - // file on every load. Forcing this unconditionally is also what used to make a - // cross-tab OPFS race guaranteed on every single page load (see GameCache). const extendedManifest = await cache.updateCache(manifest); const [files, mainModule] = await loadGameFiles(extendedManifest); const env = await getEnv(); From af49577ea9d8b8baa89543cb204b963f05316d4e Mon Sep 17 00:00:00 2001 From: Tchips46 Date: Fri, 28 Aug 2026 00:09:09 +0200 Subject: [PATCH 3/4] fix(client): derive manifest version from served files, not a missing file getVersion() read public/version and fell back to a hardcoded "0.0.0" when that file didn't exist - which is the common case for local dev/build, since nothing writes it. Combined with the isManifestUpToDate() fix, the loader now correctly trusts a matching version and skips re-downloading - but since the version never actually changed, it would keep serving whatever got cached in OPFS on the very first load, forever, even after the game was rebuilt with different code. Fall back to a fingerprint (Bun.hash) of each served file's size + mtime instead of a constant. Verified against a live server: two /manifest calls with no file changes in between return the same version; editing a file and calling again returns a different one. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Em3JASkEqn8hgf9Wqx1Sn1 --- apps/client/src/manifest.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/client/src/manifest.ts b/apps/client/src/manifest.ts index 203c068..2d04ef3 100644 --- a/apps/client/src/manifest.ts +++ b/apps/client/src/manifest.ts @@ -2,15 +2,35 @@ import { updateFiles } from "./files"; import { MANIFEST } from "./server"; export const updateManifest = async (dir: string) => { - MANIFEST.version = await getVersion(); MANIFEST.files = []; await updateFiles(dir); + MANIFEST.version = await getVersion(dir); }; -const getVersion = async () => { +const getVersion = async (dir: string) => { try { return await Bun.file("public/version").text(); } catch { - return "0.0.0"; + return await fingerprintFiles(dir); } }; + +// Falls back to a fingerprint of the actual served files when there's no explicit +// `public/version` (the common case for local dev/build - there's nothing else here +// that changes across rebuilds). Without this, the version stays a constant "0.0.0" +// forever, so the loader always thinks its cached copy is up to date and never +// re-downloads a rebuilt game - stale code keeps getting served indefinitely. +const fingerprintFiles = async (dir: string): Promise => { + const stamps = await Promise.all( + MANIFEST.files.map(async ({ path }) => { + try { + const stat = await Bun.file(`${dir}${path}`).stat(); + return `${path}:${stat.size}:${stat.mtimeMs}`; + } catch { + return `${path}:missing`; + } + }), + ); + stamps.sort(); + return Bun.hash(stamps.join("|")).toString(16); +}; From 8cb7d58e0dc429f57dc1d81ef6adbaa4e74640ce Mon Sep 17 00:00:00 2001 From: Tchips46 Date: Fri, 28 Aug 2026 00:20:19 +0200 Subject: [PATCH 4/4] fix(website): remove coms --- apps/client/src/manifest.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/client/src/manifest.ts b/apps/client/src/manifest.ts index 2d04ef3..0970435 100644 --- a/apps/client/src/manifest.ts +++ b/apps/client/src/manifest.ts @@ -15,11 +15,6 @@ const getVersion = async (dir: string) => { } }; -// Falls back to a fingerprint of the actual served files when there's no explicit -// `public/version` (the common case for local dev/build - there's nothing else here -// that changes across rebuilds). Without this, the version stays a constant "0.0.0" -// forever, so the loader always thinks its cached copy is up to date and never -// re-downloads a rebuilt game - stale code keeps getting served indefinitely. const fingerprintFiles = async (dir: string): Promise => { const stamps = await Promise.all( MANIFEST.files.map(async ({ path }) => {