From 1f24b04ef802973d5468b2cb7238a44f8a88549a Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 5 Aug 2026 03:28:31 +0200 Subject: [PATCH 1/5] fix(cache): key proxy adapters by environmentName in preview mode ProxyFSAdapterManager asserts that a reused adapter's identity, including environmentName, matches the request, but the non-production branch of buildProxyManagerCacheKey omitted environmentName from the key. Callers that resolve environmentName inconsistently (null vs "preview") therefore collided on one cache entry, and every reuse threw a fatal identity mismatch. On staging this took preview rendering down entirely: 6/6 projects returned 500 "Cache path invariant violated", and it did not self-heal across restarts because the cache is in-memory and re-poisoned within minutes. Include environmentName in the key. The segment is omitted when unnamed, so existing keys stay byte-for-byte stable. --- src/cache/keys.test.ts | 21 +++++++++++++++++++++ src/cache/keys/builders/render.ts | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/cache/keys.test.ts b/src/cache/keys.test.ts index e912659b4e..ae6978c7e0 100644 --- a/src/cache/keys.test.ts +++ b/src/cache/keys.test.ts @@ -340,6 +340,27 @@ describe("cache/keys", () => { assertEquals(release.includes("release:release-1"), true); }); + it("separates preview environments so adapter identities cannot collide", () => { + const unnamed = buildProxyManagerCacheKey("example-project", false, null, "main"); + const preview = buildProxyManagerCacheKey("example-project", false, null, "main", "preview"); + + assertNotEquals(unnamed, preview); + }); + + it("separates distinct preview environment names on the same branch", () => { + const preview = buildProxyManagerCacheKey("example-project", false, null, "main", "preview"); + const staging = buildProxyManagerCacheKey("example-project", false, null, "main", "staging"); + + assertNotEquals(preview, staging); + }); + + it("keeps the branch key stable when no environment is named", () => { + assertEquals( + buildProxyManagerCacheKey("example-project", false, null, "main"), + buildProxyManagerCacheKey("example-project", false, null, "main", null), + ); + }); + it("separates canonical projects and credential principals", () => { const first = buildProxyManagerCacheKey( "reusable-slug", diff --git a/src/cache/keys/builders/render.ts b/src/cache/keys/builders/render.ts index de22c2c753..f64c0de8d7 100644 --- a/src/cache/keys/builders/render.ts +++ b/src/cache/keys/builders/render.ts @@ -138,7 +138,12 @@ export function buildProxyManagerCacheKey( } const source = encodeCacheSourceIdentity({ type: "branch", branch: branch ?? "main" }); - return `${CacheKeyPrefix.PROXY}:${projectSlug}:${mode}:${source.qualifier}${authorityKey}`; + // ProxyFSAdapterManager asserts environmentName matches on reuse, so it must + // be part of the key. Omitted when unnamed to keep existing keys stable. + const environmentQualifier = environmentName + ? `:env:${encodeCacheKeyLiteralSegment(environmentName)}` + : ""; + return `${CacheKeyPrefix.PROXY}:${projectSlug}:${mode}:${source.qualifier}${environmentQualifier}${authorityKey}`; } /** From d57fd83ca70b03e716f806ac75bad269accd38b1 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 5 Aug 2026 03:41:16 +0200 Subject: [PATCH 2/5] fix(hmr,cache): align adapter identity with the cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the environmentName keying fix. - hmr.handler.ts warmed the adapter without environmentName, which is the source of the null resolutions. Left as-is it would also warm a different adapter than renders use once the key includes environmentName, so its WebSocketManager would silently stop receiving pokes. - getAdapter normalized environmentName with `?? null`, so an empty string kept a distinct identity while the key treated it as unnamed — the same collision in a different guise. Normalize with the key's own predicate. - Production keys omit branch but the identity asserted on it, so a caller passing a branch in production collided with one that did not. Production content resolves from releaseId/environmentName and never reads branch, so pin it to null there. Tests: replace the tautological byte-stability assertion with a literal, cover delimiter escaping, and add identity coverage at the ProxyFSAdapterManager layer that actually returned the 500s. --- src/cache/keys.test.ts | 12 +- .../fs/veryfront/proxy-manager.test.ts | 116 ++++++++++++++++++ .../adapters/fs/veryfront/proxy-manager.ts | 6 +- .../handlers/preview/hmr.handler.test.ts | 38 ++++++ src/server/handlers/preview/hmr.handler.ts | 3 + 5 files changed, 172 insertions(+), 3 deletions(-) diff --git a/src/cache/keys.test.ts b/src/cache/keys.test.ts index ae6978c7e0..d4a1e0955a 100644 --- a/src/cache/keys.test.ts +++ b/src/cache/keys.test.ts @@ -357,7 +357,17 @@ describe("cache/keys", () => { it("keeps the branch key stable when no environment is named", () => { assertEquals( buildProxyManagerCacheKey("example-project", false, null, "main"), - buildProxyManagerCacheKey("example-project", false, null, "main", null), + "proxy:example-project:preview:main", + ); + }); + + it("escapes delimiters in an environment name", () => { + const forged = buildProxyManagerCacheKey("example-project", false, null, "main", "a:b"); + + assertEquals(forged.includes("a:b"), false); + assertNotEquals( + forged, + buildProxyManagerCacheKey("example-project", false, null, "main", "a"), ); }); diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts index 4d9877393c..ca63b20bcd 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts @@ -138,6 +138,122 @@ describe("ProxyFSAdapterManager", () => { }); }); + describe("adapter identity", () => { + function stubbedManager(): ProxyFSAdapterManager { + return createManager({ + adapterFactory: (config) => { + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = () => Promise.resolve(); + return adapter; + }, + }); + } + + it("keeps distinct preview environments on separate adapters", async () => { + const manager = stubbedManager(); + try { + const unnamed = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); + const preview = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + "preview", + "main", + ); + + assertNotStrictEquals(unnamed, preview); + } finally { + manager.dispose(); + } + }); + + it("reuses a preview adapter without a fatal identity mismatch", async () => { + const manager = stubbedManager(); + try { + const first = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + "preview", + "main", + ); + const second = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + "preview", + "main", + ); + + assertEquals(first, second); + } finally { + manager.dispose(); + } + }); + + it("treats an empty environment name as unnamed", async () => { + const manager = stubbedManager(); + try { + await manager.getAdapter("my-project", "test-token", undefined, false, null, "", "main"); + const unnamed = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); + + assertExists(unnamed); + } finally { + manager.dispose(); + } + }); + + it("ignores branch when resolving a production adapter identity", async () => { + const manager = stubbedManager(); + try { + await manager.getAdapter( + "my-project", + "test-token", + undefined, + true, + "release-42", + "Production", + "main", + ); + const withoutBranch = await manager.getAdapter( + "my-project", + "test-token", + undefined, + true, + "release-42", + "Production", + null, + ); + + assertExists(withoutBranch); + } finally { + manager.dispose(); + } + }); + }); + describe("exact production source", () => { it("rejects mutable environment selection without an immutable release", async () => { const manager = createManager(); diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.ts b/src/platform/adapters/fs/veryfront/proxy-manager.ts index 4e9d5076f2..c9e21f1e79 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.ts @@ -124,8 +124,10 @@ export class ProxyFSAdapterManager { const effectiveProductionMode = productionMode ?? false; const effectiveReleaseId = releaseId ?? null; - const effectiveEnvironmentName = environmentName ?? null; - const effectiveBranch = branch ?? (effectiveProductionMode ? null : "main"); + // Both must use the same predicate the cache key uses, or an identity that + // is not part of the key can still differ and fail the reuse assertion. + const effectiveEnvironmentName = environmentName || null; + const effectiveBranch = effectiveProductionMode ? null : (branch ?? "main"); if ( this.baseConfig.veryfront?.proxyMode === true && diff --git a/src/server/handlers/preview/hmr.handler.test.ts b/src/server/handlers/preview/hmr.handler.test.ts index 0718ba8bb9..1e6fc62511 100644 --- a/src/server/handlers/preview/hmr.handler.test.ts +++ b/src/server/handlers/preview/hmr.handler.test.ts @@ -547,4 +547,42 @@ describe("server/handlers/preview/hmr.handler", () => { assertEquals(result.response!.status, 501); }); }); + + describe("ensureAdapterInitialized", () => { + it("warms the adapter for the resolved environment", async () => { + let observed: Record | undefined; + const handler = new HMRHandler(); + const ctx = { + projectSlug: "demo-project", + proxyToken: "test-token", + projectId: "proj_123", + resolvedEnvironment: "preview", + requestContext: { branch: "main" }, + adapter: { + fs: { + isVeryfrontAdapter: true, + getUnderlyingAdapter: () => undefined, + isMultiProjectMode: () => true, + runWithContext: ( + _slug: string, + _token: string, + run: () => Promise, + _projectId: string, + options: Record, + ) => { + observed = options; + return run(); + }, + exists: () => Promise.resolve(true), + }, + }, + } as unknown as HandlerContext; + + await (handler as unknown as { + ensureAdapterInitialized(ctx: HandlerContext): Promise; + }).ensureAdapterInitialized(ctx); + + assertEquals(observed?.environmentName, "preview"); + }); + }); }); diff --git a/src/server/handlers/preview/hmr.handler.ts b/src/server/handlers/preview/hmr.handler.ts index 6aa325ebd3..11a0825343 100644 --- a/src/server/handlers/preview/hmr.handler.ts +++ b/src/server/handlers/preview/hmr.handler.ts @@ -248,6 +248,9 @@ export class HMRHandler extends BaseHandler { { productionMode: false, branch: ctx.requestContext?.branch ?? "main", + // Must match what renders resolve, or HMR warms a different adapter + // and its WebSocketManager never receives pokes. + environmentName: resolvedEnvironment, }, ); } catch (error) { From af94de7b6a9f7023a7ba4699e0bc7539144d1150 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 5 Aug 2026 03:52:01 +0200 Subject: [PATCH 3/5] fix(hmr): warm the adapter for the named environment, not the mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit passed `resolvedEnvironment` as `environmentName`. Those are different fields: `resolvedEnvironment` is the mode ("preview" | "production") and `environmentName` is the named environment. Since the guard above already narrows the mode to "preview", the value was a constant. That made the common case worse than before — HMR warmed "preview" while renders warm the actual environment name, so the two used different cache keys and HMR's per-adapter WebSocketManager invalidated an adapter no render reads. Pass `ctx.environmentName` and give the test a named environment distinct from the mode, so the fixture fails when the two are confused. --- src/server/handlers/preview/hmr.handler.test.ts | 5 +++-- src/server/handlers/preview/hmr.handler.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/server/handlers/preview/hmr.handler.test.ts b/src/server/handlers/preview/hmr.handler.test.ts index 1e6fc62511..7c01deda6a 100644 --- a/src/server/handlers/preview/hmr.handler.test.ts +++ b/src/server/handlers/preview/hmr.handler.test.ts @@ -549,7 +549,7 @@ describe("server/handlers/preview/hmr.handler", () => { }); describe("ensureAdapterInitialized", () => { - it("warms the adapter for the resolved environment", async () => { + it("warms the adapter for the named environment, not the mode", async () => { let observed: Record | undefined; const handler = new HMRHandler(); const ctx = { @@ -557,6 +557,7 @@ describe("server/handlers/preview/hmr.handler", () => { proxyToken: "test-token", projectId: "proj_123", resolvedEnvironment: "preview", + environmentName: "Development", requestContext: { branch: "main" }, adapter: { fs: { @@ -582,7 +583,7 @@ describe("server/handlers/preview/hmr.handler", () => { ensureAdapterInitialized(ctx: HandlerContext): Promise; }).ensureAdapterInitialized(ctx); - assertEquals(observed?.environmentName, "preview"); + assertEquals(observed?.environmentName, "Development"); }); }); }); diff --git a/src/server/handlers/preview/hmr.handler.ts b/src/server/handlers/preview/hmr.handler.ts index 11a0825343..cfd9bce69b 100644 --- a/src/server/handlers/preview/hmr.handler.ts +++ b/src/server/handlers/preview/hmr.handler.ts @@ -248,9 +248,10 @@ export class HMRHandler extends BaseHandler { { productionMode: false, branch: ctx.requestContext?.branch ?? "main", - // Must match what renders resolve, or HMR warms a different adapter - // and its WebSocketManager never receives pokes. - environmentName: resolvedEnvironment, + // The named environment, not the mode in `resolvedEnvironment`. Must + // match what renders resolve, or HMR warms a different adapter and + // its WebSocketManager never receives pokes. + environmentName: ctx.environmentName ?? null, }, ); } catch (error) { From f219f5cdb3eb7c3308789aa990970feb68d86719 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 5 Aug 2026 04:07:56 +0200 Subject: [PATCH 4/5] fix(cache): pin preview releaseId to null and harden identity tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit releaseId was the last field asserted in the adapter identity but absent from the preview cache key — the same asymmetry this branch fixed for branch and environmentName. Only upstream normalization kept it unreachable; nothing in getAdapter or the schema enforced that. Pin it to null outside production. Tests: the preview-reuse case passed unmodified against main, so it guarded nothing. Replace it with an interleaved unnamed/named/unnamed sequence that fails without the key fix. Assert adapter identity with assertStrictEquals rather than assertExists, which only proved no throw. Make the HMR stub's isVeryfrontAdapter a method, matching wrapper.ts. --- .../fs/veryfront/proxy-manager.test.ts | 63 ++++++++++++++++--- .../adapters/fs/veryfront/proxy-manager.ts | 2 +- .../handlers/preview/hmr.handler.test.ts | 2 +- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts index ca63b20bcd..a4cd67cfd4 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts @@ -5,6 +5,7 @@ import { assertExists, assertNotStrictEquals, assertRejects, + assertStrictEquals, assertThrows, } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; @@ -177,7 +178,7 @@ describe("ProxyFSAdapterManager", () => { } }); - it("reuses a preview adapter without a fatal identity mismatch", async () => { + it("reuses an unnamed preview adapter after a named one is created", async () => { const manager = stubbedManager(); try { const first = await manager.getAdapter( @@ -186,10 +187,10 @@ describe("ProxyFSAdapterManager", () => { undefined, false, null, - "preview", + null, "main", ); - const second = await manager.getAdapter( + const named = await manager.getAdapter( "my-project", "test-token", undefined, @@ -198,8 +199,18 @@ describe("ProxyFSAdapterManager", () => { "preview", "main", ); + const again = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); - assertEquals(first, second); + assertNotStrictEquals(first, named); + assertStrictEquals(first, again); } finally { manager.dispose(); } @@ -208,7 +219,15 @@ describe("ProxyFSAdapterManager", () => { it("treats an empty environment name as unnamed", async () => { const manager = stubbedManager(); try { - await manager.getAdapter("my-project", "test-token", undefined, false, null, "", "main"); + const empty = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + "", + "main", + ); const unnamed = await manager.getAdapter( "my-project", "test-token", @@ -219,7 +238,35 @@ describe("ProxyFSAdapterManager", () => { "main", ); - assertExists(unnamed); + assertStrictEquals(empty, unnamed); + } finally { + manager.dispose(); + } + }); + + it("ignores releaseId when resolving a preview adapter identity", async () => { + const manager = stubbedManager(); + try { + const withRelease = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + "release-7", + null, + "main", + ); + const withoutRelease = await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); + + assertStrictEquals(withRelease, withoutRelease); } finally { manager.dispose(); } @@ -228,7 +275,7 @@ describe("ProxyFSAdapterManager", () => { it("ignores branch when resolving a production adapter identity", async () => { const manager = stubbedManager(); try { - await manager.getAdapter( + const withBranch = await manager.getAdapter( "my-project", "test-token", undefined, @@ -247,7 +294,7 @@ describe("ProxyFSAdapterManager", () => { null, ); - assertExists(withoutBranch); + assertStrictEquals(withBranch, withoutBranch); } finally { manager.dispose(); } diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.ts b/src/platform/adapters/fs/veryfront/proxy-manager.ts index c9e21f1e79..ba9414b17c 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.ts @@ -123,7 +123,7 @@ export class ProxyFSAdapterManager { const getAdapterStartTime = performance.now(); const effectiveProductionMode = productionMode ?? false; - const effectiveReleaseId = releaseId ?? null; + const effectiveReleaseId = effectiveProductionMode ? (releaseId ?? null) : null; // Both must use the same predicate the cache key uses, or an identity that // is not part of the key can still differ and fail the reuse assertion. const effectiveEnvironmentName = environmentName || null; diff --git a/src/server/handlers/preview/hmr.handler.test.ts b/src/server/handlers/preview/hmr.handler.test.ts index 7c01deda6a..01c1210d32 100644 --- a/src/server/handlers/preview/hmr.handler.test.ts +++ b/src/server/handlers/preview/hmr.handler.test.ts @@ -561,7 +561,7 @@ describe("server/handlers/preview/hmr.handler", () => { requestContext: { branch: "main" }, adapter: { fs: { - isVeryfrontAdapter: true, + isVeryfrontAdapter: () => true, getUnderlyingAdapter: () => undefined, isMultiProjectMode: () => true, runWithContext: ( From 31cdf42a3f5f201eff91157b0fd3ebe7ce4d47b2 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 5 Aug 2026 04:20:17 +0200 Subject: [PATCH 5/5] docs(cache): correct the identity normalization comment It said Both while governing three normalizations, and sat below the first one it described. --- src/platform/adapters/fs/veryfront/proxy-manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.ts b/src/platform/adapters/fs/veryfront/proxy-manager.ts index ba9414b17c..c17b605733 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.ts @@ -123,9 +123,9 @@ export class ProxyFSAdapterManager { const getAdapterStartTime = performance.now(); const effectiveProductionMode = productionMode ?? false; + // All three must use the same predicate the cache key uses, or an identity + // that is not part of the key can still differ and fail the reuse assertion. const effectiveReleaseId = effectiveProductionMode ? (releaseId ?? null) : null; - // Both must use the same predicate the cache key uses, or an identity that - // is not part of the key can still differ and fail the reuse assertion. const effectiveEnvironmentName = environmentName || null; const effectiveBranch = effectiveProductionMode ? null : (branch ?? "main");