Skip to content

Commit 2952fda

Browse files
committed
Harden gateway CLI lifecycle
1 parent 181b27a commit 2952fda

3 files changed

Lines changed: 263 additions & 41 deletions

File tree

apps/gateway/src/index.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,29 @@ import {
1414
createGatewayRequestHandler,
1515
createGatewayServer,
1616
parseCliArgs,
17+
runGatewayCli,
1718
startGateway,
1819
stopGateway,
1920
} from "./index.js";
2021

22+
function createTestIo() {
23+
let stderr = "";
24+
25+
return {
26+
io: {
27+
stderr: {
28+
write(chunk: string | Uint8Array) {
29+
stderr += String(chunk);
30+
return true;
31+
},
32+
},
33+
},
34+
get stderr() {
35+
return stderr;
36+
},
37+
};
38+
}
39+
2140
async function closeServer(server: Server): Promise<void> {
2241
await new Promise<void>((resolve, reject) => {
2342
server.close((error) => {
@@ -183,6 +202,10 @@ test("gateway rejects invalid direct helper options", async () => {
183202
() => startGateway({ config, configPath: 42 } as never),
184203
/configPath must be a string/,
185204
);
205+
await assert.rejects(
206+
() => startGateway({ config, configPath: "ray\0config.json" } as never),
207+
/configPath must not contain control characters/,
208+
);
186209
await assert.rejects(
187210
() => startGateway({ config, warmupRetry: null } as never),
188211
/warmupRetry must be an object/,
@@ -521,6 +544,103 @@ test("gateway parseCliArgs rejects ambiguous or malformed options", () => {
521544
assert.throws(() => parseCliArgs(["--config", 42] as unknown as string[]), /argv\[1\]/);
522545
});
523546

547+
test("runGatewayCli rejects malformed direct io and options before boot", async () => {
548+
const output = createTestIo();
549+
550+
await assert.rejects(() => runGatewayCli([], null as never), /gateway cli io must be an object/);
551+
await assert.rejects(
552+
() => runGatewayCli([], { stderr: {} } as never),
553+
/gateway cli io\.stderr\.write must be a function/,
554+
);
555+
await assert.rejects(
556+
() => runGatewayCli([], output.io, null as never),
557+
/gateway cli options must be an object/,
558+
);
559+
await assert.rejects(
560+
() => runGatewayCli([], output.io, { loadConfig: null } as never),
561+
/loadConfig must be a function/,
562+
);
563+
await assert.rejects(
564+
() => runGatewayCli([], output.io, { cwd: " /srv/ray" }),
565+
/cwd must be a path without surrounding whitespace/,
566+
);
567+
});
568+
569+
test("runGatewayCli reports boot failures to injected stderr", async () => {
570+
const output = createTestIo();
571+
const status = await runGatewayCli(["--config", "./missing.json"], output.io, {
572+
now: () => new Date("2026-05-14T00:00:00.000Z"),
573+
loadConfig: async () => {
574+
throw new Error("missing config");
575+
},
576+
});
577+
578+
assert.equal(status, 1);
579+
const parsed = JSON.parse(output.stderr) as {
580+
ts?: string;
581+
level?: string;
582+
message?: string;
583+
error?: { message?: string };
584+
};
585+
assert.equal(parsed.ts, "2026-05-14T00:00:00.000Z");
586+
assert.equal(parsed.level, "error");
587+
assert.equal(parsed.message, "gateway boot failed");
588+
assert.equal(parsed.error?.message, "missing config");
589+
});
590+
591+
test("runGatewayCli starts the gateway and wires signal shutdown", async () => {
592+
const output = createTestIo();
593+
const config = createDefaultConfig("tiny");
594+
const signals = new Map<NodeJS.Signals, () => void>();
595+
const exitCodes: number[] = [];
596+
const stoppedSignals: Array<NodeJS.Signals | undefined> = [];
597+
const fakeGateway = {
598+
logger: {
599+
error() {
600+
throw new Error("shutdown should not log errors");
601+
},
602+
},
603+
} as never;
604+
605+
const status = await runGatewayCli(["--config", "./ray.json"], output.io, {
606+
cwd: "/srv/ray",
607+
loadConfig: async (options) => {
608+
assert.deepEqual(options, {
609+
cwd: "/srv/ray",
610+
configPath: "./ray.json",
611+
});
612+
return {
613+
config,
614+
configPath: "/srv/ray/ray.json",
615+
};
616+
},
617+
startGateway: async (options) => {
618+
assert.equal(options.config, config);
619+
assert.equal(options.configPath, "/srv/ray/ray.json");
620+
return fakeGateway;
621+
},
622+
stopGateway: async (_gateway, options) => {
623+
stoppedSignals.push(options?.signal);
624+
},
625+
onSignal: (signal, listener) => {
626+
signals.set(signal, listener);
627+
},
628+
setExitCode: (code) => {
629+
exitCodes.push(code);
630+
},
631+
});
632+
633+
assert.equal(status, 0);
634+
assert.equal(output.stderr, "");
635+
assert.deepEqual(Array.from(signals.keys()), ["SIGINT", "SIGTERM"]);
636+
637+
signals.get("SIGTERM")?.();
638+
await new Promise((resolve) => setTimeout(resolve, 0));
639+
640+
assert.deepEqual(stoppedSignals, ["SIGTERM"]);
641+
assert.deepEqual(exitCodes, [0]);
642+
});
643+
524644
test("startGateway rejects malformed warmup retry options before listening", async () => {
525645
await assert.rejects(
526646
() =>

apps/gateway/src/index.ts

Lines changed: 139 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ interface CliOptions {
3131
configPath?: string;
3232
}
3333

34+
interface GatewayCliIo {
35+
stderr: {
36+
write(chunk: string | Uint8Array): unknown;
37+
};
38+
}
39+
40+
interface RunGatewayCliOptions {
41+
cwd?: string;
42+
loadConfig?: typeof loadRayConfig;
43+
startGateway?: typeof startGateway;
44+
stopGateway?: typeof stopGateway;
45+
onSignal?: (signal: NodeJS.Signals, listener: () => void) => unknown;
46+
setExitCode?: (code: number) => void;
47+
now?: () => Date;
48+
}
49+
3450
const MAX_GATEWAY_CLI_ARGS = 16;
3551
const MAX_GATEWAY_CLI_ARG_BYTES = 8_192;
3652
const MAX_GATEWAY_CONFIG_PATH_CHARS = 4_096;
@@ -84,6 +100,15 @@ const startGatewayOptionKeys = new Set([
84100
"configPath",
85101
"warmupRetry",
86102
]);
103+
const runGatewayCliOptionKeys = new Set([
104+
"cwd",
105+
"loadConfig",
106+
"startGateway",
107+
"stopGateway",
108+
"onSignal",
109+
"setExitCode",
110+
"now",
111+
]);
87112
const gatewayWarmupRetryOptionKeys = new Set(["initialDelayMs", "maxDelayMs"]);
88113
const stopGatewayOptionKeys = new Set(["signal", "timeoutMs"]);
89114
const acknowledgedExpectContinueRequests = new WeakSet<IncomingMessage>();
@@ -206,6 +231,56 @@ function assertOptionalGatewayFunction(value: unknown, label: string): void {
206231
}
207232
}
208233

234+
function assertGatewayCliIo(io: unknown): asserts io is GatewayCliIo {
235+
if (io === null || typeof io !== "object" || Array.isArray(io)) {
236+
throw new Error("gateway cli io must be an object");
237+
}
238+
239+
const stderr = (io as { stderr?: unknown }).stderr;
240+
if (
241+
stderr === null ||
242+
typeof stderr !== "object" ||
243+
Array.isArray(stderr) ||
244+
typeof (stderr as { write?: unknown }).write !== "function"
245+
) {
246+
throw new Error("gateway cli io.stderr.write must be a function");
247+
}
248+
}
249+
250+
function assertGatewayCliPathValue(value: unknown, label: string): asserts value is string {
251+
if (typeof value !== "string" || value.length === 0) {
252+
throw new Error(`${label} must be a non-empty path`);
253+
}
254+
255+
if (/[\0\r\n]/.test(value)) {
256+
throw new Error(`${label} must not contain control characters`);
257+
}
258+
259+
if (value.trim() !== value) {
260+
throw new Error(`${label} must be a path without surrounding whitespace`);
261+
}
262+
263+
if (value.length > MAX_GATEWAY_CONFIG_PATH_CHARS) {
264+
throw new Error(`${label} must be at most ${MAX_GATEWAY_CONFIG_PATH_CHARS} characters`);
265+
}
266+
}
267+
268+
function assertRunGatewayCliOptions(value: unknown): asserts value is RunGatewayCliOptions {
269+
assertGatewayOptionsObject(value, "gateway cli options");
270+
assertGatewayOptionKeys(value, "gateway cli options", runGatewayCliOptionKeys);
271+
272+
if (value.cwd !== undefined) {
273+
assertGatewayCliPathValue(value.cwd, "cwd");
274+
}
275+
276+
assertOptionalGatewayFunction(value.loadConfig, "loadConfig");
277+
assertOptionalGatewayFunction(value.startGateway, "startGateway");
278+
assertOptionalGatewayFunction(value.stopGateway, "stopGateway");
279+
assertOptionalGatewayFunction(value.onSignal, "onSignal");
280+
assertOptionalGatewayFunction(value.setExitCode, "setExitCode");
281+
assertOptionalGatewayFunction(value.now, "now");
282+
}
283+
209284
function assertGatewayHandlerDependencies(value: Record<string, unknown>): void {
210285
assertGatewayOptionsObject(value.config, "config");
211286
assertOptionalGatewayObject(value.runtime, "runtime");
@@ -765,7 +840,7 @@ function requireFlagValue(flag: string, value: string | undefined): string {
765840
}
766841

767842
function assertConfigPathFlagValue(value: string, flag: string): void {
768-
if (/[\r\n]/.test(value)) {
843+
if (/[\0\r\n]/.test(value)) {
769844
throw new Error(`${flag} must not contain control characters`);
770845
}
771846

@@ -2368,49 +2443,75 @@ export async function stopGateway(
23682443
}
23692444
}
23702445

2371-
async function main(): Promise<void> {
2372-
const cli = parseCliArgs(process.argv.slice(2));
2373-
const { config, configPath } = await loadRayConfig({
2374-
cwd: process.cwd(),
2375-
...(cli.configPath ? { configPath: cli.configPath } : {}),
2376-
});
2446+
export async function runGatewayCli(
2447+
argv: string[] = process.argv.slice(2),
2448+
io: GatewayCliIo = process,
2449+
options: RunGatewayCliOptions = {},
2450+
): Promise<number> {
2451+
assertGatewayCliIo(io);
2452+
assertRunGatewayCliOptions(options);
23772453

2378-
const gateway = await startGateway({
2379-
config,
2380-
...(configPath ? { configPath } : {}),
2381-
});
2454+
const loadConfig = options.loadConfig ?? loadRayConfig;
2455+
const start = options.startGateway ?? startGateway;
2456+
const stop = options.stopGateway ?? stopGateway;
2457+
const onSignal =
2458+
options.onSignal ??
2459+
((signal: NodeJS.Signals, listener: () => void) => {
2460+
process.on(signal, listener);
2461+
});
2462+
const setExitCode =
2463+
options.setExitCode ??
2464+
((code: number) => {
2465+
process.exitCode = code;
2466+
});
2467+
const now = options.now ?? (() => new Date());
23822468

2383-
const shutdown = async (signal: NodeJS.Signals) => {
2384-
try {
2385-
await stopGateway(gateway, { signal });
2386-
process.exit(0);
2387-
} catch (error) {
2388-
gateway.logger.error("gateway shutdown failed", {
2389-
signal,
2390-
error: serializeError(error),
2391-
});
2392-
process.exit(1);
2393-
}
2394-
};
2469+
try {
2470+
const cli = parseCliArgs(argv);
2471+
const { config, configPath } = await loadConfig({
2472+
cwd: options.cwd ?? process.cwd(),
2473+
...(cli.configPath ? { configPath: cli.configPath } : {}),
2474+
});
23952475

2396-
process.on("SIGINT", () => {
2397-
void shutdown("SIGINT");
2398-
});
2399-
process.on("SIGTERM", () => {
2400-
void shutdown("SIGTERM");
2401-
});
2402-
}
2476+
const gateway = await start({
2477+
config,
2478+
...(configPath ? { configPath } : {}),
2479+
});
24032480

2404-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
2405-
void main().catch((error) => {
2406-
console.error(
2407-
JSON.stringify({
2408-
ts: new Date().toISOString(),
2481+
const shutdown = async (signal: NodeJS.Signals) => {
2482+
try {
2483+
await stop(gateway, { signal });
2484+
setExitCode(0);
2485+
} catch (error) {
2486+
gateway.logger.error("gateway shutdown failed", {
2487+
signal,
2488+
error: serializeError(error),
2489+
});
2490+
setExitCode(1);
2491+
}
2492+
};
2493+
2494+
onSignal("SIGINT", () => {
2495+
void shutdown("SIGINT");
2496+
});
2497+
onSignal("SIGTERM", () => {
2498+
void shutdown("SIGTERM");
2499+
});
2500+
2501+
return 0;
2502+
} catch (error) {
2503+
io.stderr.write(
2504+
`${JSON.stringify({
2505+
ts: now().toISOString(),
24092506
level: "error",
24102507
message: "gateway boot failed",
24112508
error: serializeError(error),
2412-
}),
2509+
})}\n`,
24132510
);
2414-
process.exit(1);
2415-
});
2511+
return 1;
2512+
}
2513+
}
2514+
2515+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
2516+
process.exitCode = await runGatewayCli();
24162517
}

scripts/cli-entrypoints.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ import path from "node:path";
44
import test from "node:test";
55

66
const repoRoot = process.cwd();
7-
const maintenanceCliEntrypoints = [
7+
const embeddableCliEntrypoints = [
8+
"apps/gateway/src/index.ts",
89
"scripts/deploy-storage-preflight.ts",
910
"scripts/docs-link-check.ts",
1011
"scripts/package-runtime-coverage.ts",
1112
"scripts/test.mjs",
1213
];
1314

14-
test("maintenance CLI entrypoints avoid abrupt process exits", async () => {
15-
for (const relativePath of maintenanceCliEntrypoints) {
15+
test("embeddable CLI entrypoints avoid abrupt process exits", async () => {
16+
for (const relativePath of embeddableCliEntrypoints) {
1617
const contents = await readFile(path.join(repoRoot, relativePath), "utf8");
1718

1819
assert.doesNotMatch(contents, /\bprocess\.exit\s*\(/, relativePath);

0 commit comments

Comments
 (0)