fix(runtime): make production commands executable

This commit is contained in:
chick
2026-07-18 15:12:48 +08:00
parent ae27b522ab
commit f89f393485
7 changed files with 205 additions and 25 deletions
@@ -44,7 +44,7 @@ export interface LegacyImportResult {
export type LegacyImportTransactionHook = (result: LegacyImportResult) => void;
interface NormalizedInstance extends Omit<LegacyImportInstancePreview, 'status'> {}
type NormalizedInstance = Omit<LegacyImportInstancePreview, 'status'>;
interface SourceRead {
readonly bytes: Buffer;
+2 -2
View File
@@ -1,5 +1,5 @@
import { createCanaryGateway } from './canary-gateway.ts';
import { readCanaryRuntimeOptions } from './canary-runtime.ts';
import { createCanaryGateway } from './canary-gateway.js';
import { readCanaryRuntimeOptions } from './canary-runtime.js';
const gateway = createCanaryGateway(readCanaryRuntimeOptions(process.env));
+164
View File
@@ -0,0 +1,164 @@
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
const repositoryRoot = resolve(import.meta.dirname, '../../..');
const gatewayToken = 'synthetic-runtime-test-token-at-least-32-bytes';
const cleanup: Array<() => Promise<void>> = [];
const execFileAsync = promisify(execFile);
interface RunningCommand {
readonly child: ChildProcessWithoutNullStreams;
output(): string;
}
function startPackageCommand(
script: 'canary' | 'start:production',
environment: NodeJS.ProcessEnv,
) {
const child = spawn('corepack', ['pnpm', '--filter', '@multi-simadmin/api', 'run', script], {
cwd: repositoryRoot,
env: { ...process.env, ...environment, FORCE_COLOR: '0' },
detached: true,
stdio: 'pipe',
});
let output = '';
child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
const command: RunningCommand = { child, output: () => output };
cleanup.push(() => stopCommand(command));
return command;
}
async function stopCommand(command: RunningCommand): Promise<void> {
if (command.child.exitCode !== null || command.child.signalCode !== null) return;
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) =>
command.child.once('exit', (code, signal) => resolveExit({ code, signal })),
);
if (command.child.pid === undefined) throw new Error('Runtime command has no pid');
process.kill(-command.child.pid, 'SIGTERM');
const result = await Promise.race([
exit,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Runtime did not stop after SIGTERM:\n${command.output()}`)),
5_000,
),
),
]);
// pnpm's wrapper reports 143 when its child handles the group SIGTERM cleanly.
expect(result.code === 0 || result.code === 143 || result.signal === 'SIGTERM').toBe(true);
}
async function waitForOutput(command: RunningCommand, text: string): Promise<void> {
const deadline = Date.now() + 10_000;
while (!command.output().includes(text)) {
if (command.child.exitCode !== null || command.child.signalCode !== null) {
throw new Error(`Runtime exited before becoming ready:\n${command.output()}`);
}
if (Date.now() >= deadline)
throw new Error(`Runtime readiness timed out:\n${command.output()}`);
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
}
}
async function listenOn(port: number): Promise<ReturnType<typeof createServer>> {
const server = createServer();
await new Promise<void>((resolveListen, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', resolveListen);
});
return server;
}
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
await new Promise<void>((resolveClose, reject) =>
server.close((error) => (error ? reject(error) : resolveClose())),
);
}
async function expectPortFree(port: number): Promise<void> {
const server = await listenOn(port);
await closeServer(server);
}
async function listenerPids(port: number): Promise<readonly string[]> {
try {
const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
return stdout.trim().split(/\s+/u).filter(Boolean).sort();
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === 1 || code === '1') return [];
throw error;
}
}
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
describe.sequential('executable package runtimes', () => {
it('starts the exact production command on 8790 and shuts down on SIGTERM', async () => {
await expectPortFree(8790);
const legacyListeners = await listenerPids(8788);
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-production-cli-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
const dataRoot = join(directory, 'data');
await mkdir(dataRoot);
const command = startPackageCommand('start:production', {
MULTI_SIMADMIN_DATA_ROOT: dataRoot,
MULTI_SIMADMIN_DATABASE_PATH: join(dataRoot, 'synthetic.sqlite'),
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
API_HOST: '127.0.0.1',
API_PORT: '8790',
});
await waitForOutput(command, 'API listening at http://127.0.0.1:8790');
const response = await fetch('http://127.0.0.1:8790/healthz', {
headers: { [GATEWAY_AUTH_HEADER]: gatewayToken },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: 'ok' });
expect(await listenerPids(8788)).toEqual(legacyListeners);
expect(command.output()).not.toContain(':8788');
expect(command.output()).not.toContain(gatewayToken);
await stopCommand(command);
await expectPortFree(8790);
expect(command.output()).not.toContain(gatewayToken);
}, 20_000);
it('starts the exact canary command on 8789 with synthetic dist and shuts down on SIGTERM', async () => {
const port = 8789;
await expectPortFree(port);
const legacyListeners = await listenerPids(8788);
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-canary-cli-'));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
await writeFile(join(directory, 'index.html'), '<main>synthetic canary</main>');
const command = startPackageCommand('canary', {
CANARY_DIST_DIR: directory,
CANARY_PORT: String(port),
CANARY_UPSTREAM_PORT: '8790',
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
});
await waitForOutput(command, `Canary gateway listening at http://127.0.0.1:${port}`);
const response = await fetch(`http://127.0.0.1:${port}/`);
expect(response.status).toBe(200);
expect(await response.text()).toBe('<main>synthetic canary</main>');
expect(await listenerPids(8788)).toEqual(legacyListeners);
expect(command.output()).not.toContain(':8788');
expect(command.output()).not.toContain(gatewayToken);
await stopCommand(command);
await expectPortFree(port);
expect(command.output()).not.toContain(gatewayToken);
}, 20_000);
});
+4 -4
View File
@@ -1,6 +1,6 @@
import { buildProductionControlPlane } from './production-control-plane.ts';
import { createRuntimeConfig } from './runtime-config.ts';
import { installBoundedShutdown } from './shutdown.ts';
import { buildProductionControlPlane } from './production-control-plane.js';
import { createRuntimeConfig } from './runtime-config.js';
import { installBoundedShutdown } from './shutdown.js';
try {
const config = createRuntimeConfig(process.env);
@@ -8,7 +8,7 @@ try {
databasePath: config.databasePath,
gatewayToken: config.gatewayToken,
});
await app.listen(config.api);
await app.listen({ host: config.api.host, port: config.api.port });
installBoundedShutdown(process, app, {
terminate: () => process.exit(1),
});