fix: harden identity HTTP transport
This commit is contained in:
+44
-22
@@ -12,6 +12,7 @@ import { createApiApp } from "./app.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
|
||||
const connectionTimeoutMs = 10_000;
|
||||
const operationTimeoutMs = 10_000;
|
||||
|
||||
export interface RunningApi {
|
||||
readonly app: Hono;
|
||||
@@ -65,6 +66,7 @@ export async function startApi(
|
||||
): Promise<RunningApi> {
|
||||
const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, {
|
||||
connectionTimeoutMs,
|
||||
operationTimeoutMs,
|
||||
});
|
||||
|
||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
@@ -89,21 +91,53 @@ export async function startApi(
|
||||
throw error;
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
// Memoizes one shared promise rather than a boolean flag: a boolean only prevents a *second*
|
||||
// cleanup from starting, but a caller that arrives while the first cleanup is still in flight
|
||||
// would previously get an already-resolved `undefined` immediately, before the listener had
|
||||
// actually drained or the pool had actually closed. Storing the promise itself means every
|
||||
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
|
||||
// observes it complete (or fail) exactly once.
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
shutdownPromise = (async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
})();
|
||||
}
|
||||
shuttingDown = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
return shutdownPromise;
|
||||
};
|
||||
|
||||
return Object.freeze({ app, server, shutdown });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires `SIGINT`/`SIGTERM` to `running.shutdown()` and only then exits — extracted from `main()`
|
||||
* so it can be exercised directly in tests with a fake `running`/`exit`, without sending a real
|
||||
* process signal or invoking the real `process.exit` (which would terminate the test runner
|
||||
* itself). Production composition (`main()`, below) calls this with real `process.exit`.
|
||||
*/
|
||||
export function installShutdownSignalHandlers(
|
||||
running: Pick<RunningApi, "shutdown">,
|
||||
exit: (code: number) => void = (code) => process.exit(code),
|
||||
): void {
|
||||
let signalReceived = false;
|
||||
const handleSignal = (): void => {
|
||||
if (signalReceived) {
|
||||
return;
|
||||
}
|
||||
signalReceived = true;
|
||||
running
|
||||
.shutdown()
|
||||
.then(() => exit(0))
|
||||
.catch(() => exit(1));
|
||||
};
|
||||
process.on("SIGINT", handleSignal);
|
||||
process.on("SIGTERM", handleSignal);
|
||||
}
|
||||
|
||||
function readConfigFromEnvironment(): {
|
||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||
readonly environment: "development" | "production";
|
||||
@@ -138,19 +172,7 @@ export async function main(): Promise<void> {
|
||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||
console.log(`Ekklesia API listening on ${config.host}:${boundPort}`);
|
||||
|
||||
let signalReceived = false;
|
||||
const handleSignal = (): void => {
|
||||
if (signalReceived) {
|
||||
return;
|
||||
}
|
||||
signalReceived = true;
|
||||
running
|
||||
.shutdown()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
};
|
||||
process.on("SIGINT", handleSignal);
|
||||
process.on("SIGTERM", handleSignal);
|
||||
installShutdownSignalHandlers(running);
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
|
||||
Reference in New Issue
Block a user