Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/config/cicd-stable-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
41 changes: 37 additions & 4 deletions src/proxy/routing-invalidation-redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(promise: Promise<T>, label: string): Promise<T> {
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<void>((resolve) => setTimeout(resolve, 0));
}
if (outcome === undefined) {
throw new Error(`${label} did not settle within 200 event-loop turns`);
Comment thread
kojiwakayama marked this conversation as resolved.
}
if (!outcome.ok) throw outcome.error;
return outcome.value;
}

function createFakeRedisServer() {
const subscriptions = new Map<RoutingInvalidationRedisClient, Map<string, RedisListener>>();
Expand Down Expand Up @@ -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 () => {
Expand Down
62 changes: 59 additions & 3 deletions src/react/compat/ssr-adapter/string-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -26,6 +30,42 @@ function createReadableSSRStream(html: string): ReadableStream<Uint8Array> {
});
}

function createManualDeadlineRuntime() {
let now = 0;
let nextHandle = 1;
const pending = new Map<number, () => void>();
return {
runtime: {
now: () => now,
setTimer: (callback: () => void, _delayMs: number) => {
const handle = nextHandle++;
pending.set(handle, callback);
return handle as unknown as ReturnType<typeof setTimeout>;
},
clearTimer: (handle: ReturnType<typeof setTimeout>) => {
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<typeof createManualDeadlineRuntime>,
): Promise<void> {
for (let turn = 0; turn < 200; turn++) {
if (runtime.pendingCount() > 0) return;
await new Promise<void>((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);
Expand Down Expand Up @@ -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;
Expand All @@ -211,19 +253,24 @@ 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);
});

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;
Expand All @@ -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);
});
Expand All @@ -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<typeof setTimeout>,
clearTimer: () => {},
});
__injectReactDOMServerForTests({
renderToString: () => "<div>must not render</div>",
renderToStaticMarkup: () => "<div>unused</div>",
Expand Down
15 changes: 10 additions & 5 deletions src/react/compat/ssr-adapter/string-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<typeof setTimeout> | undefined;
let expired = false;
let rejectTimeout!: (error: Error) => void;
Expand All @@ -36,20 +41,20 @@ function createRenderDeadline(timeoutMs: number): RenderDeadline {
}
rejectTimeout(error);
};
timeoutId = setTimeout(expire, timeoutMs);
timeoutId = runtime.setTimer(expire, timeoutMs);

return {
error,
expiresAt,
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;
},
};
Expand Down
27 changes: 27 additions & 0 deletions src/react/compat/ssr-adapter/timeout.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>;
clearTimer(handle: ReturnType<typeof setTimeout>): 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;
Expand All @@ -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 {
Expand Down
73 changes: 43 additions & 30 deletions src/server/project-env/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((_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<void>();
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<Response>((_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;
}
});

Expand Down