fix: bound module lifecycle shutdown
Nothing bounded shutdown, and a module that failed to start was never cleaned up. `startModules` appended to its rollback set only after `start()` resolved, so a hook that acquired a connection, registered a listener, or recorded partial initialization and then threw had its own `stop()` skipped, while every previously completed module was rolled back correctly. A module now joins the rollback set the moment its start hook is invoked: its own stop hook runs first, then earlier modules in reverse order. The original start issue stays first in the reported issues, and a failure stopping the failing module is appended without preventing the rest of the rollback. The consequence for module authors is explicit and documented — `stop()` may run after a partially completed `start()` and must be safe and idempotent about resources it may or may not hold. Neither stage of shutdown had a deadline. `shutdown()` awaited `server.close()` with no bound, so one open connection could hold it forever, and each stop or rollback hook was awaited without a bound, so a hook that never settled kept the pool open indefinitely. Both now have fixed, non-configurable defaults: 10 seconds per module hook and 30 seconds for the graceful HTTP drain. A hook that reaches its deadline records MODULE_STOP_TIMEOUT or MODULE_ROLLBACK_TIMEOUT and the sequence continues with the next module, so the whole stop sequence is finite in the started module count rather than one global ten-second bound. Timers that lose the race are cleared, the losing promise gets a permanent rejection handler so a late throw cannot become an unhandled rejection, and a timed-out hook is never awaited again. At the drain deadline the platform closes idle and then active connections through Node's own public closeIdleConnections/closeAllConnections, after server.close(), touching no private field, and stops waiting for the close callback. It then runs the bounded module stop sequence and closes the pool, so a drain timeout or a stop failure never skips later cleanup. Both are reported together as an ApiShutdownError carrying only fixed booleans and the already sanitized module error; a forced drain is a shutdown failure, not a success. Stated honestly rather than overstated: a deadline bounds how long the platform waits, not how long module code runs. An in-process promise cannot be canceled, and closing a socket does not prove a handler promise ended. What is guaranteed is that shutdown stops waiting and the pool closes afterwards, so late database work fails. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
800ecfdb92
commit
490b85b07c
+114
-12
@@ -34,6 +34,7 @@ export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_CAPABILITY_UNREGISTERED"
|
||||
| "MODULE_REGISTER_FAILED"
|
||||
| "MODULE_ROLLBACK_FAILED"
|
||||
| "MODULE_ROLLBACK_TIMEOUT"
|
||||
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||
| "MODULE_ROUTE_DUPLICATE"
|
||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||
@@ -47,7 +48,8 @@ export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_RUNTIME_IMPORT_FAILED"
|
||||
| "MODULE_RUNTIME_VERSION_MISMATCH"
|
||||
| "MODULE_START_FAILED"
|
||||
| "MODULE_STOP_FAILED";
|
||||
| "MODULE_STOP_FAILED"
|
||||
| "MODULE_STOP_TIMEOUT";
|
||||
|
||||
export interface ModuleRuntimeIssue {
|
||||
readonly code: ModuleRuntimeIssueCode;
|
||||
@@ -74,7 +76,16 @@ export interface StartedModuleRuntime {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {
|
||||
export interface ModuleRuntimeTimeoutOptions {
|
||||
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS`, applied to both normal stop and startup
|
||||
* rollback, so a deadline can be proven in milliseconds instead of ten seconds. Production
|
||||
* composition never sets this. */
|
||||
readonly hookTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions
|
||||
extends ModuleDiscoveryOptions,
|
||||
ModuleRuntimeTimeoutOptions {
|
||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||
@@ -489,18 +500,93 @@ async function registerModules(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's fixed wait for one module's `stop` hook, during either a normal stop or a startup
|
||||
* rollback. Not configurable in this slice — no environment variable, manifest field, or runtime
|
||||
* service — so every deployment bounds cleanup identically.
|
||||
*
|
||||
* This bounds *waiting*, not execution. An in-process promise cannot be canceled: a hook that
|
||||
* exceeds this deadline keeps running and may settle later, but it no longer delays the rest of
|
||||
* shutdown, and the pool closes after the bounded sequence, so its late database work fails. The
|
||||
* platform does not sandbox, terminate, or undo a trusted module's JavaScript.
|
||||
*/
|
||||
export const MODULE_STOP_HOOK_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface StopModulesOptions {
|
||||
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS` so a deadline can be proven without
|
||||
* waiting out the production value. Production composition never sets this. */
|
||||
readonly hookTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaits one hook under a deadline. The losing branch is always handled: the timer is cleared when
|
||||
* the hook wins, and a hook that loses gets a permanent no-op rejection handler attached so a late
|
||||
* throw can never surface as an unhandled rejection. A timed-out hook is never awaited again.
|
||||
*/
|
||||
async function awaitHookWithinDeadline(
|
||||
hook: Promise<void>,
|
||||
timeoutMs: number,
|
||||
): Promise<"completed" | "timed-out"> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Attached before the race so the rejection is considered handled even if the timeout wins.
|
||||
const observed = hook.then(
|
||||
() => "completed" as const,
|
||||
(error: unknown) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
observed.catch(() => undefined);
|
||||
try {
|
||||
return await Promise.race([
|
||||
observed,
|
||||
new Promise<"timed-out">((resolve) => {
|
||||
timer = setTimeout(() => resolve("timed-out"), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops modules in reverse order, giving each hook a bounded slice of time and continuing to the
|
||||
* next module whatever the previous one did. Every applicable hook is invoked at most once.
|
||||
*
|
||||
* The whole sequence is finite because the module count is finite and fixed at discovery: its
|
||||
* worst case is `startedModuleCount * MODULE_STOP_HOOK_TIMEOUT_MS`, not one global ten-second
|
||||
* bound.
|
||||
*/
|
||||
async function stopModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
|
||||
const issues: ModuleRuntimeIssue[] = [];
|
||||
for (const module of [...modules].reverse()) {
|
||||
const moduleId = module.discovered.manifest.id;
|
||||
const stop = module.definition.stop;
|
||||
if (!stop) {
|
||||
// No synthetic callback and no timer for a module that declares no stop hook.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await module.definition.stop?.(
|
||||
registry.createRuntimeContext(module.discovered, phase, services),
|
||||
const outcome = await awaitHookWithinDeadline(
|
||||
Promise.resolve(stop(registry.createRuntimeContext(module.discovered, phase, services))),
|
||||
timeoutMs,
|
||||
);
|
||||
if (outcome === "timed-out") {
|
||||
issues.push(
|
||||
runtimeIssue(
|
||||
phase === "rollback" ? "MODULE_ROLLBACK_TIMEOUT" : "MODULE_STOP_TIMEOUT",
|
||||
phase,
|
||||
moduleId,
|
||||
`Module ${moduleId} ${phase} did not complete within the platform deadline.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleRuntimeSignal) {
|
||||
issues.push(error.issue);
|
||||
@@ -509,8 +595,8 @@ async function stopModules(
|
||||
runtimeIssue(
|
||||
phase === "rollback" ? "MODULE_ROLLBACK_FAILED" : "MODULE_STOP_FAILED",
|
||||
phase,
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} ${phase} failed.`,
|
||||
moduleId,
|
||||
`Module ${moduleId} ${phase} failed.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -523,14 +609,21 @@ async function startModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
services: ModuleRuntimeServices,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<void> {
|
||||
const started: LoadedModule[] = [];
|
||||
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it
|
||||
// completes. A hook that acquired a connection, registered a listener, or recorded partial
|
||||
// initialization before throwing still has cleanup to do, and previously only the modules that
|
||||
// had already finished were rolled back — leaving the failing module's own resources behind. Its
|
||||
// `stop` hook therefore runs first, ahead of the earlier modules in reverse order, which is why
|
||||
// module authors must make `stop` safe after a partially completed `start`.
|
||||
const rollbackCandidates: LoadedModule[] = [];
|
||||
for (const module of modules) {
|
||||
rollbackCandidates.push(module);
|
||||
try {
|
||||
await module.definition.start?.(
|
||||
registry.createRuntimeContext(module.discovered, "start", services),
|
||||
);
|
||||
started.push(module);
|
||||
} catch (error) {
|
||||
const startIssue =
|
||||
error instanceof ModuleRuntimeSignal
|
||||
@@ -541,7 +634,13 @@ async function startModules(
|
||||
module.discovered.manifest.id,
|
||||
`Module ${module.discovered.manifest.id} startup failed.`,
|
||||
);
|
||||
const rollbackIssues = await stopModules(started, registry, "rollback", services);
|
||||
const rollbackIssues = await stopModules(
|
||||
rollbackCandidates,
|
||||
registry,
|
||||
"rollback",
|
||||
services,
|
||||
options,
|
||||
);
|
||||
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
|
||||
}
|
||||
}
|
||||
@@ -558,12 +657,13 @@ async function startModules(
|
||||
export async function startDiscoveredModuleRuntime(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
services: ModuleRuntimeServices,
|
||||
timeouts: ModuleRuntimeTimeoutOptions = {},
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||
const capabilityRegistry = new StagedCapabilityRegistry();
|
||||
const httpRoutes = new StagedModuleHttpRoutes();
|
||||
await registerModules(modules, capabilityRegistry, httpRoutes);
|
||||
await startModules(modules, capabilityRegistry, services);
|
||||
await startModules(modules, capabilityRegistry, services, timeouts);
|
||||
|
||||
const capabilities = capabilityRegistry.publish();
|
||||
const routes = httpRoutes.publish();
|
||||
@@ -583,7 +683,7 @@ export async function startDiscoveredModuleRuntime(
|
||||
routes,
|
||||
stop: (): Promise<void> => {
|
||||
stopPromise ??= (async () => {
|
||||
const issues = await stopModules(modules, capabilityRegistry, "stop", services);
|
||||
const issues = await stopModules(modules, capabilityRegistry, "stop", services, timeouts);
|
||||
if (issues.length > 0) {
|
||||
throw new ModuleRuntimeError(issues);
|
||||
}
|
||||
@@ -597,5 +697,7 @@ export async function startModuleRuntime(
|
||||
options: StartModuleRuntimeOptions,
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const discovered = await discoverModules(options);
|
||||
return startDiscoveredModuleRuntime(discovered, options.services);
|
||||
return startDiscoveredModuleRuntime(discovered, options.services, {
|
||||
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user