fix(dashboard): auto-invalidate index.html and view-chunk caches on mtime change

The dashboard server cached index.html and the Vite view-chunk manifest
forever with no invalidation. When the on-disk files changed (release
upgrade, rebuild) the server kept serving stale HTML referencing chunk
hashes that no longer existed, leaving the browser stuck on a blank
white page until the server was restarted.

Both caches now key on file mtime and refresh automatically. The
serveIndexHtml catch path also logs the failure and clears the
templated cache so the next request can recover instead of silently
404ing until restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-17 09:24:28 -07:00
parent c502820f68
commit 67aff5de80
4 changed files with 89 additions and 19 deletions

View File

@@ -0,0 +1,5 @@
---
"@fusion/dashboard": patch
---
Fix dashboard occasionally serving a blank/broken page until the server is restarted. The server cached `index.html` and the Vite view-chunk manifest forever with no invalidation, so any on-disk change (release upgrade, rebuild) left the server handing out stale HTML referencing chunk hashes that no longer existed. Both caches now invalidate automatically when the underlying file's mtime changes. The `serveIndexHtml` catch path also now logs the failure and clears the templated cache so a subsequent request can recover, instead of silently returning 404 forever.

View File

@@ -1,4 +1,4 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@@ -76,17 +76,44 @@ describe("view chunk manifest", () => {
const first = loadViewChunkManifest(clientDir); const first = loadViewChunkManifest(clientDir);
expect(first.agents).toBe("/assets/AgentsView-old.js"); expect(first.agents).toBe("/assets/AgentsView-old.js");
resetViewChunkManifestCache();
writeFileSync( writeFileSync(
manifestPath, manifestPath,
JSON.stringify({ JSON.stringify({
[VIEW_SOURCE_MAP.agents]: { file: "assets/AgentsView-new.js" }, [VIEW_SOURCE_MAP.agents]: { file: "assets/AgentsView-new.js" },
}), }),
); );
const refreshed = loadViewChunkManifest(clientDir);
expect(refreshed.agents).toBe("/assets/AgentsView-new.js");
const cached = loadViewChunkManifest(clientDir); rmSync(clientDir, { recursive: true, force: true });
expect(cached.agents).toBe("/assets/AgentsView-old.js"); });
it("cache auto-invalidates when manifest mtime changes", () => {
const clientDir = makeClientDir("mtime");
mkdirSync(join(clientDir, ".vite"), { recursive: true });
const manifestPath = join(clientDir, ".vite", "manifest.json");
writeFileSync(
manifestPath,
JSON.stringify({
[VIEW_SOURCE_MAP.agents]: { file: "assets/AgentsView-old.js" },
}),
);
const first = loadViewChunkManifest(clientDir);
expect(first.agents).toBe("/assets/AgentsView-old.js");
writeFileSync(
manifestPath,
JSON.stringify({
[VIEW_SOURCE_MAP.agents]: { file: "assets/AgentsView-new.js" },
}),
);
// Force a distinctly newer mtime so the cache key changes even on
// coarse-grained filesystems.
const future = new Date(Date.now() + 5_000);
utimesSync(manifestPath, future, future);
resetViewChunkManifestCache();
const refreshed = loadViewChunkManifest(clientDir); const refreshed = loadViewChunkManifest(clientDir);
expect(refreshed.agents).toBe("/assets/AgentsView-new.js"); expect(refreshed.agents).toBe("/assets/AgentsView-new.js");

View File

@@ -1,7 +1,7 @@
import express, { type Router } from "express"; import express, { type Router } from "express";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createSecureServer as createHttp2SecureServer, type Http2SecureServer } from "node:http2"; import { createSecureServer as createHttp2SecureServer, type Http2SecureServer } from "node:http2";
import type { Server as HttpServer } from "node:http"; import type { Server as HttpServer } from "node:http";
@@ -659,6 +659,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
let cachedIndexClientDir: string | null = null; let cachedIndexClientDir: string | null = null;
let cachedIndexHtml: string | null = null; let cachedIndexHtml: string | null = null;
let cachedIndexMtimeMs: number | null = null;
let cachedTemplatedIndexHtml: string | null = null; let cachedTemplatedIndexHtml: string | null = null;
const buildViewPreloadInjection = (chunkMap: Record<string, string>): string => { const buildViewPreloadInjection = (chunkMap: Record<string, string>): string => {
@@ -671,14 +672,29 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
? process.env.FUSION_CLIENT_DIR ? process.env.FUSION_CLIENT_DIR
: clientDir; : clientDir;
if (cachedTemplatedIndexHtml && cachedIndexClientDir === resolvedClientDir) { const indexPath = join(resolvedClientDir, "index.html");
// Invalidate the cache when index.html changes on disk (e.g. a release
// upgrade or rebuild replaces the file). Without this, the server keeps
// serving stale HTML pointing at chunk hashes that no longer exist —
// recoverable only by restarting the server.
let indexMtimeMs: number | null = null;
try {
indexMtimeMs = statSync(indexPath).mtimeMs;
} catch {
indexMtimeMs = null;
}
const dirChanged = cachedIndexClientDir !== resolvedClientDir;
const mtimeChanged = indexMtimeMs !== null && cachedIndexMtimeMs !== indexMtimeMs;
if (cachedTemplatedIndexHtml && !dirChanged && !mtimeChanged) {
return cachedTemplatedIndexHtml; return cachedTemplatedIndexHtml;
} }
const indexPath = join(resolvedClientDir, "index.html"); if (!cachedIndexHtml || dirChanged || mtimeChanged) {
if (!cachedIndexHtml || cachedIndexClientDir !== resolvedClientDir) {
cachedIndexHtml = readFileSync(indexPath, "utf8"); cachedIndexHtml = readFileSync(indexPath, "utf8");
cachedIndexClientDir = resolvedClientDir; cachedIndexClientDir = resolvedClientDir;
cachedIndexMtimeMs = indexMtimeMs;
} }
const chunkMap = loadViewChunkManifest(resolvedClientDir); const chunkMap = loadViewChunkManifest(resolvedClientDir);
@@ -698,8 +714,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-store, max-age=0"); res.setHeader("Cache-Control", "no-store, max-age=0");
res.status(200).send(html); res.status(200).send(html);
} catch { } catch (err) {
res.status(404).end(); console.error("[dashboard] serveIndexHtml failed:", err);
// Drop the cached HTML so the next request retries from disk rather
// than re-throwing the same failure until the server restarts.
cachedIndexHtml = null;
cachedTemplatedIndexHtml = null;
cachedIndexMtimeMs = null;
res.status(503).type("text/plain").send("Dashboard temporarily unavailable. Retrying...");
} }
}; };

View File

@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
type TaskViewId = string; type TaskViewId = string;
@@ -29,7 +29,12 @@ export const VIEW_SOURCE_MAP: Record<TaskViewId, string> = {
"stash-recovery": "components/StashRecoveryView.tsx", "stash-recovery": "components/StashRecoveryView.tsx",
}; };
const manifestCache = new Map<string, Record<TaskViewId, string>>(); type ManifestCacheEntry = {
entries: Record<TaskViewId, string>;
mtimeMs: number | null;
};
const manifestCache = new Map<string, ManifestCacheEntry>();
const warnedMissingManifest = new Set<string>(); const warnedMissingManifest = new Set<string>();
const warnedMissingEntries = new Set<string>(); const warnedMissingEntries = new Set<string>();
@@ -43,16 +48,27 @@ function warnOnce(set: Set<string>, key: string, message: string): void {
export function loadViewChunkManifest(clientDir: string): Record<TaskViewId, string> { export function loadViewChunkManifest(clientDir: string): Record<TaskViewId, string> {
const cacheKey = resolve(clientDir); const cacheKey = resolve(clientDir);
const cached = manifestCache.get(cacheKey); const manifestPath = join(cacheKey, ".vite", "manifest.json");
if (cached) {
return cached; // Stat first so a release-upgrade that replaces the manifest invalidates
// the cache automatically. Without this, the server keeps handing out
// stale chunk paths and the browser 404s on every lazy view until restart.
let mtimeMs: number | null = null;
try {
mtimeMs = statSync(manifestPath).mtimeMs;
} catch {
mtimeMs = null;
}
const cached = manifestCache.get(cacheKey);
if (cached && cached.mtimeMs === mtimeMs) {
return cached.entries;
} }
const manifestPath = join(cacheKey, ".vite", "manifest.json");
if (!existsSync(manifestPath)) { if (!existsSync(manifestPath)) {
warnOnce(warnedMissingManifest, cacheKey, `[dashboard] View chunk manifest missing: ${manifestPath}`); warnOnce(warnedMissingManifest, cacheKey, `[dashboard] View chunk manifest missing: ${manifestPath}`);
const empty: Record<TaskViewId, string> = {}; const empty: Record<TaskViewId, string> = {};
manifestCache.set(cacheKey, empty); manifestCache.set(cacheKey, { entries: empty, mtimeMs });
return empty; return empty;
} }
@@ -73,12 +89,12 @@ export function loadViewChunkManifest(clientDir: string): Record<TaskViewId, str
resolvedEntries[viewId] = `/${entry.file}`; resolvedEntries[viewId] = `/${entry.file}`;
} }
manifestCache.set(cacheKey, resolvedEntries); manifestCache.set(cacheKey, { entries: resolvedEntries, mtimeMs });
return resolvedEntries; return resolvedEntries;
} catch { } catch {
warnOnce(warnedMissingManifest, `${cacheKey}:parse`, `[dashboard] Failed to parse view chunk manifest: ${manifestPath}`); warnOnce(warnedMissingManifest, `${cacheKey}:parse`, `[dashboard] Failed to parse view chunk manifest: ${manifestPath}`);
const empty: Record<TaskViewId, string> = {}; const empty: Record<TaskViewId, string> = {};
manifestCache.set(cacheKey, empty); manifestCache.set(cacheKey, { entries: empty, mtimeMs });
return empty; return empty;
} }
} }