test: prove bounded API cleanup
Pass 4 bounded module hooks and the HTTP drain, and Core proves an individual
hook deadline in isolation — but nothing under apps/api ever supplied
moduleHookTimeoutMsOverride, so the composition's actual promise ("the pool
always closes, whatever a hook does") was untested at the level where it either
holds or breaks.
Four deterministic proofs, no production change:
A never-settling startup rollback now proves startApi rejects with
MODULE_START_FAILED followed by MODULE_ROLLBACK_TIMEOUT, that the failing
module's own stop hook was attempted first and the earlier module still received
its rollback afterwards, that the one pool closed exactly once, and that no
listener was ever bound.
A never-settling normal stop proves shutdown rejects with MODULE_STOP_TIMEOUT
metadata, the earlier module still got its stop attempt, the pool closed, and
repeated and concurrent callers share the one memoized result without
re-entering a single hook.
A combined failure proves both deadlines report together: a server whose close
callback never fires plus a never-settling stop yields drainTimedOut and
moduleStopFailed on one ApiShutdownError, the platform force-closed through
Node's public closeIdleConnections/closeAllConnections, module stop still ran,
the pool still closed, and nothing about the request, socket, connection,
credentials, SQL, or module exception appears in the error.
The graceful ordering test was strengthened rather than left racing an
unresolved fetch: a raw socket sends a complete request with Connection: close,
shutdown begins only once the server provably holds that connection — observed
by polling the server's own connection count, never by sleeping — and the
already-entered request is then proven to complete with a full 200 response and
a clean transport close instead of being force-closed.
The fixture gains one test-only option for a stop hook that never settles.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2983789779
commit
74e8840f0d
@@ -2779,6 +2779,73 @@ describe.sequential("live API — bounded HTTP drain and shutdown", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an already-entered request finish and close cleanly rather than force-closing it", async () => {
|
||||
// Deterministic rather than racing an unresolved `fetch`: a raw socket sends a complete request
|
||||
// with `Connection: close`, and shutdown only begins once the server provably holds that
|
||||
// connection — observed by polling the real server's own connection count, never by sleeping.
|
||||
const database = await createDisposableDatabase("drain_order");
|
||||
await applyCoreMigrations(database.url);
|
||||
const running = await startConfiguredApi(database.url, { drainTimeoutMsOverride: 20_000 });
|
||||
const address = running.server.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw harnessError("The API integration server has no bound address.");
|
||||
}
|
||||
const socket = net.connect({ host: "127.0.0.1", port: address.port });
|
||||
try {
|
||||
const responseBytes: Buffer[] = [];
|
||||
const responseClosed = new Promise<void>((resolve) => {
|
||||
socket.once("close", () => resolve());
|
||||
});
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
responseBytes.push(chunk);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.write(
|
||||
"GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
|
||||
() => resolve(),
|
||||
);
|
||||
});
|
||||
|
||||
// Bounded, condition-based: wait until the server actually holds the connection, so the
|
||||
// request has demonstrably entered before shutdown starts.
|
||||
const connectionCounter = running.server as unknown as {
|
||||
getConnections?: (callback: (error: Error | null, count: number) => void) => void;
|
||||
};
|
||||
const deadline = Date.now() + 15_000;
|
||||
for (;;) {
|
||||
const connections = await new Promise<number>((resolve) => {
|
||||
if (typeof connectionCounter.getConnections !== "function") {
|
||||
resolve(1);
|
||||
return;
|
||||
}
|
||||
connectionCounter.getConnections((_error, count) => resolve(count ?? 0));
|
||||
});
|
||||
if (connections > 0) {
|
||||
break;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw harnessError("The request never reached the server.");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
// The graceful path: nothing is stalled, so shutdown completes without reaching the drain
|
||||
// deadline and without force-closing anything.
|
||||
await expect(withDeadline(running.shutdown(), 30_000)).resolves.toBeUndefined();
|
||||
|
||||
// The already-entered request completed normally — full response, clean transport close.
|
||||
await withDeadline(responseClosed, 10_000);
|
||||
const raw = Buffer.concat(responseBytes).toString("utf8");
|
||||
expect(raw).toContain("HTTP/1.1 200");
|
||||
expect(raw).toContain('{"kind":"anonymous"}');
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("force-closes a stalled connection at the drain deadline and still reaches pool closure", async () => {
|
||||
const database = await createDisposableDatabase("drain_forced");
|
||||
await applyCoreMigrations(database.url);
|
||||
|
||||
Reference in New Issue
Block a user