diff --git a/src/config/cicd-stable-release.test.ts b/src/config/cicd-stable-release.test.ts index b40e6f4fe2..49d7bf1285 100644 --- a/src/config/cicd-stable-release.test.ts +++ b/src/config/cicd-stable-release.test.ts @@ -52,7 +52,7 @@ async function createRepository(): Promise<{ }> { const cwd = await Deno.makeTempDir(); const scriptPath = await Deno.realPath( - "scripts/ci/stable-release-requested.sh", + new URL("../../scripts/ci/stable-release-requested.sh", import.meta.url), ); await git(cwd, "init", "--quiet"); diff --git a/src/proxy/routing-invalidation-redis.test.ts b/src/proxy/routing-invalidation-redis.test.ts index 5f6347d426..744e8982cb 100644 --- a/src/proxy/routing-invalidation-redis.test.ts +++ b/src/proxy/routing-invalidation-redis.test.ts @@ -14,6 +14,30 @@ const ROUTING_INVALIDATION_ACK_PREFIX = `${ROUTING_INVALIDATION_CHANNEL}:ack:`; const EVENT_SIGNATURE_DOMAIN = "vf-proxy-routing-invalidation:event:v1"; const ACK_SIGNATURE_DOMAIN = "vf-proxy-routing-invalidation:ack:v1"; const TEST_NOW_MS = 1_800_000_000_000; +const TIMEOUT_NOT_UNDER_TEST_MS = 600_000; + +async function settleWithin(promise: Promise, label: string): Promise { + let outcome: + | { ok: true; value: T } + | { error: unknown; ok: false } + | undefined; + void promise.then( + (value) => { + outcome = { ok: true, value }; + }, + (error) => { + outcome = { error, ok: false }; + }, + ); + for (let turn = 0; turn < 200 && outcome === undefined; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + if (outcome === undefined) { + throw new Error(`${label} did not settle within 200 event-loop turns`); + } + if (!outcome.ok) throw outcome.error; + return outcome.value; +} function createFakeRedisServer() { const subscriptions = new Map>(); @@ -198,16 +222,25 @@ describe("proxy routing invalidation Redis bus", () => { redisUrl: "redis://example.test:6379", expectedReplicas: 2, replicaId: "replica-a", - acknowledgementTimeoutMs: 20, + acknowledgementTimeoutMs: TIMEOUT_NOT_UNDER_TEST_MS, createClient: redis.createClient, integritySecret, onInvalidate: () => {}, }); + assert(bus); - const result = await bus?.publish(createEvent()); + const publish = bus.publish(createEvent()); + try { + const result = await settleWithin( + publish, + "single-recipient invalidation", + ); - assertEquals(result, { acknowledged: 1, converged: false, recipients: 1 }); - await bus?.close(); + assertEquals(result, { acknowledged: 1, converged: false, recipients: 1 }); + } finally { + await bus.close(); + await publish.catch(() => undefined); + } }); it("keeps overlapping publish acknowledgement subscriptions isolated", async () => { diff --git a/src/react/compat/ssr-adapter/string-renderer.test.ts b/src/react/compat/ssr-adapter/string-renderer.test.ts index 3da7bbff98..e33a1b6a7a 100644 --- a/src/react/compat/ssr-adapter/string-renderer.test.ts +++ b/src/react/compat/ssr-adapter/string-renderer.test.ts @@ -9,7 +9,11 @@ import { resetReactCache, } from "./server-loader.ts"; import { renderToStaticMarkupAdapter, renderToStringAdapter } from "./string-renderer.ts"; -import { resetSSRAdapterTimeoutForTests, setSSRAdapterTimeoutForTests } from "./timeout.ts"; +import { + resetSSRAdapterTimeoutForTests, + setSSRAdapterDeadlineRuntimeForTests, + setSSRAdapterTimeoutForTests, +} from "./timeout.ts"; import { getServerRenderContext } from "../../server-render-context.ts"; type ReadableOptions = NonNullable< @@ -26,6 +30,42 @@ function createReadableSSRStream(html: string): ReadableStream { }); } +function createManualDeadlineRuntime() { + let now = 0; + let nextHandle = 1; + const pending = new Map void>(); + return { + runtime: { + now: () => now, + setTimer: (callback: () => void, _delayMs: number) => { + const handle = nextHandle++; + pending.set(handle, callback); + return handle as unknown as ReturnType; + }, + clearTimer: (handle: ReturnType) => { + pending.delete(handle as unknown as number); + }, + }, + advance(milliseconds: number) { + now += milliseconds; + const callbacks = [...pending.values()]; + pending.clear(); + for (const callback of callbacks) callback(); + }, + pendingCount: () => pending.size, + }; +} + +async function waitForDeadline( + runtime: ReturnType, +): Promise { + for (let turn = 0; turn < 200; turn++) { + if (runtime.pendingCount() > 0) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("SSR render did not arm its deadline within 200 event-loop turns"); +} + describe("react/compat/ssr-adapter/string-renderer", () => { afterEach(() => { __injectReactDOMServerForTests(null); @@ -194,7 +234,9 @@ describe("react/compat/ssr-adapter/string-renderer", () => { it("cancels a buffered stream that stops making progress", async () => { let cancelled = false; let stringRenderCalls = 0; + const deadline = createManualDeadlineRuntime(); setSSRAdapterTimeoutForTests(5); + setSSRAdapterDeadlineRuntimeForTests(deadline.runtime); __injectReactDOMServerForTests({ renderToString: () => { stringRenderCalls += 1; @@ -211,11 +253,14 @@ describe("react/compat/ssr-adapter/string-renderer", () => { >, }); - await assertRejects( + const assertion = assertRejects( () => renderToStringAdapter(React.createElement("div")), Error, "SSR timeout", ); + await waitForDeadline(deadline); + deadline.advance(5); + await assertion; assertEquals(stringRenderCalls, 0); assertEquals(cancelled, true); }); @@ -223,7 +268,9 @@ describe("react/compat/ssr-adapter/string-renderer", () => { it("bounds stream setup even when the renderer ignores its abort signal", async () => { let signal: AbortSignal | undefined; let stringRenderCalls = 0; + const deadline = createManualDeadlineRuntime(); setSSRAdapterTimeoutForTests(5); + setSSRAdapterDeadlineRuntimeForTests(deadline.runtime); __injectReactDOMServerForTests({ renderToString: () => { stringRenderCalls += 1; @@ -236,11 +283,14 @@ describe("react/compat/ssr-adapter/string-renderer", () => { }, }); - await assertRejects( + const assertion = assertRejects( () => renderToStringAdapter(React.createElement("div")), Error, "SSR timeout", ); + await waitForDeadline(deadline); + deadline.advance(5); + await assertion; assertEquals(stringRenderCalls, 0); assertEquals(signal?.aborted, true); }); @@ -250,6 +300,12 @@ describe("react/compat/ssr-adapter/string-renderer", () => { let observed: Error | undefined; let thrown: unknown; setSSRAdapterTimeoutForTests(1); + let clockReads = 0; + setSSRAdapterDeadlineRuntimeForTests({ + now: () => clockReads++ < 3 ? 0 : 1, + setTimer: () => 1 as unknown as ReturnType, + clearTimer: () => {}, + }); __injectReactDOMServerForTests({ renderToString: () => "
must not render
", renderToStaticMarkup: () => "
unused
", diff --git a/src/react/compat/ssr-adapter/string-renderer.ts b/src/react/compat/ssr-adapter/string-renderer.ts index f318665adb..f24ba1f77f 100644 --- a/src/react/compat/ssr-adapter/string-renderer.ts +++ b/src/react/compat/ssr-adapter/string-renderer.ts @@ -3,7 +3,11 @@ import { isCompiledBinary, rendererLogger as logger } from "#veryfront/utils"; import { SpanNames } from "#veryfront/observability"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { getProjectReact, getReactDOMServer } from "./server-loader.ts"; -import { getSSRAdapterTimeoutMs, getSSRBufferLimitBytes } from "./timeout.ts"; +import { + getSSRAdapterDeadlineRuntime, + getSSRAdapterTimeoutMs, + getSSRBufferLimitBytes, +} from "./timeout.ts"; import type { SSROptions } from "./types.ts"; import { wrapWithServerRenderContext } from "../../server-render-context.ts"; @@ -19,9 +23,10 @@ interface RenderDeadline { } function createRenderDeadline(timeoutMs: number): RenderDeadline { + const runtime = getSSRAdapterDeadlineRuntime(); const controller = new AbortController(); const error = new Error(`SSR timeout: buffered React render exceeded ${timeoutMs}ms`); - const expiresAt = performance.now() + timeoutMs; + const expiresAt = runtime.now() + timeoutMs; let timeoutId: ReturnType | undefined; let expired = false; let rejectTimeout!: (error: Error) => void; @@ -36,7 +41,7 @@ function createRenderDeadline(timeoutMs: number): RenderDeadline { } rejectTimeout(error); }; - timeoutId = setTimeout(expire, timeoutMs); + timeoutId = runtime.setTimer(expire, timeoutMs); return { error, @@ -44,12 +49,12 @@ function createRenderDeadline(timeoutMs: number): RenderDeadline { promise, signal: controller.signal, throwIfExpired() { - if (!expired && performance.now() < expiresAt) return; + if (!expired && runtime.now() < expiresAt) return; expire(); throw error; }, dispose() { - if (timeoutId !== undefined) clearTimeout(timeoutId); + if (timeoutId !== undefined) runtime.clearTimer(timeoutId); timeoutId = undefined; }, }; diff --git a/src/react/compat/ssr-adapter/timeout.ts b/src/react/compat/ssr-adapter/timeout.ts index 4e4a874ccb..7a69d94762 100644 --- a/src/react/compat/ssr-adapter/timeout.ts +++ b/src/react/compat/ssr-adapter/timeout.ts @@ -1,6 +1,22 @@ import { SSR_MAX_BUFFERED_BYTES, SSR_TIMEOUT_MS } from "#veryfront/config/defaults.ts"; let timeoutOverrideMs: number | undefined; +let deadlineRuntimeOverride: SSRAdapterDeadlineRuntime | undefined; + +export interface SSRAdapterDeadlineRuntime { + now(): number; + setTimer( + callback: () => void, + delayMs: number, + ): ReturnType; + clearTimer(handle: ReturnType): void; +} + +const defaultDeadlineRuntime: SSRAdapterDeadlineRuntime = { + now: () => performance.now(), + setTimer: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimer: (handle) => clearTimeout(handle), +}; export function getSSRAdapterTimeoutMs(): number { return timeoutOverrideMs ?? SSR_TIMEOUT_MS; @@ -13,8 +29,19 @@ export function setSSRAdapterTimeoutForTests(timeoutMs: number): void { timeoutOverrideMs = timeoutMs; } +export function getSSRAdapterDeadlineRuntime(): SSRAdapterDeadlineRuntime { + return deadlineRuntimeOverride ?? defaultDeadlineRuntime; +} + +export function setSSRAdapterDeadlineRuntimeForTests( + runtime: SSRAdapterDeadlineRuntime, +): void { + deadlineRuntimeOverride = runtime; +} + export function resetSSRAdapterTimeoutForTests(): void { timeoutOverrideMs = undefined; + deadlineRuntimeOverride = undefined; } export function getSSRBufferLimitBytes(override: number | undefined): number { diff --git a/src/server/project-env/fetcher.test.ts b/src/server/project-env/fetcher.test.ts index d5ffede604..ee1546b4c6 100644 --- a/src/server/project-env/fetcher.test.ts +++ b/src/server/project-env/fetcher.test.ts @@ -445,61 +445,74 @@ describe("project-env/fetcher", () => { }); it("does not call the internal endpoint after management authorization times out", async () => { + const originalFetch = globalThis.fetch; const paths: string[] = []; - const { server, port } = createMockServer(async (req: Request) => { - paths.push(new URL(req.url).pathname); - await new Promise((resolve) => setTimeout(resolve, 40)); - return Response.json({ data: [] }); - }); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(new Error("management timeout")), 10); + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + paths.push(new URL(input instanceof Request ? input.url : input).pathname); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }) as typeof fetch; try { - const error = await assertRejects(() => - fetchFromMockApi( - port, - { username: "runtime-user", password: "runtime-pass" }, - controller.signal, - ) + const assertion = assertRejects(() => + withInternalCredentials("runtime-user", "runtime-pass", () => + fetchProjectEnvVars( + "https://api.veryfront.test", + "my-project", + "env-123", + "test-token", + controller.signal, + )) ); + controller.abort(new Error("management timeout")); + const error = await assertion; assertInstanceOf(error, Error); assertEquals(error.message, "management timeout"); assertEquals(paths, ["/projects/my-project/environment-variables"]); } finally { - clearTimeout(timeoutId); - await server.shutdown(); + globalThis.fetch = originalFetch; } }); it("does not fall back after the internal request times out", async () => { + const originalFetch = globalThis.fetch; const paths: string[] = []; - const { server, port } = createMockServer(async (req: Request) => { - const path = new URL(req.url).pathname; + const internalRequestStarted = Promise.withResolvers(); + const controller = new AbortController(); + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + const path = new URL(input instanceof Request ? input.url : input).pathname; paths.push(path); if (path === "/projects/my-project/environment-variables") { - return Response.json({ data: [] }); + return Promise.resolve(Response.json({ data: [] })); } - await new Promise((resolve) => setTimeout(resolve, 40)); - return Response.json({ data: [{ key: "API_KEY", value: "late" }] }); - }); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(new Error("internal timeout")), 10); + internalRequestStarted.resolve(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }) as typeof fetch; try { - await assertRejects(() => - fetchFromMockApi( - port, - { username: "runtime-user", password: "runtime-pass" }, - controller.signal, - ) + const assertion = assertRejects(() => + withInternalCredentials("runtime-user", "runtime-pass", () => + fetchProjectEnvVars( + "https://api.veryfront.test", + "my-project", + "env-123", + "test-token", + controller.signal, + )) ); + await internalRequestStarted.promise; + controller.abort(new Error("internal timeout")); + await assertion; assertEquals(paths, [ "/projects/my-project/environment-variables", "/internal/project-environment-variables", ]); } finally { - clearTimeout(timeoutId); - await server.shutdown(); + globalThis.fetch = originalFetch; } });