mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
Bound initial shutdown waits and finish workspace repairs before teardown. Share a 20-second budget between waits before the heartbeat run drain. Retain the original workspace recovery stop promise and join it after the run drain and finalizers, before application and database teardown. Keep that join for hot restart when the run drain is skipped. Log step and drain durations and bound telemetry flush separately after the drain. Verification: all 154 tests across the three relevant shutdown/startup suites and every server source/test typecheck layer passed. Both actual server lifecycle variants reproduce premature teardown without the fix. All applicable CI passed and exact-head Greptile Apex scored 5/5. Co-Authored-By: Devin Foley <devinfoley@users.noreply.github.com> Co-Authored-By: Paperclip <noreply@paperclip.ing>
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
type HotRestartShutdownPreparation = {
|
|
skipDrain: boolean;
|
|
};
|
|
|
|
type ShutdownLogger = {
|
|
info(obj: object, msg: string): void;
|
|
error(obj: object, msg: string): void;
|
|
};
|
|
|
|
export const PRE_DRAIN_SHUTDOWN_BUDGET_MS = 20_000;
|
|
|
|
export type ShutdownStepResult<T> =
|
|
| { outcome: "completed"; value: T; durationMs: number }
|
|
| { outcome: "timed_out"; durationMs: number }
|
|
| { outcome: "failed"; error: unknown; durationMs: number };
|
|
|
|
export async function runBoundedShutdownStep<T>(input: {
|
|
step: string;
|
|
signal: "SIGINT" | "SIGTERM";
|
|
timeoutMs: number;
|
|
run: () => Promise<T>;
|
|
log: ShutdownLogger;
|
|
now?: () => number;
|
|
}): Promise<ShutdownStepResult<T>> {
|
|
const now = input.now ?? Date.now;
|
|
const startedAt = now();
|
|
const timeoutMs = Math.max(0, input.timeoutMs);
|
|
let timer: NodeJS.Timeout | null = null;
|
|
try {
|
|
const result = await Promise.race([
|
|
Promise.resolve()
|
|
.then(input.run)
|
|
.then(
|
|
(value) => ({ outcome: "completed" as const, value }),
|
|
(error: unknown) => ({ outcome: "failed" as const, error }),
|
|
),
|
|
new Promise<{ outcome: "timed_out" }>((resolve) => {
|
|
timer = setTimeout(() => resolve({ outcome: "timed_out" }), timeoutMs);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
const durationMs = now() - startedAt;
|
|
const fields = { signal: input.signal, step: input.step, durationMs, timeoutMs };
|
|
if (result.outcome === "completed") {
|
|
input.log.info(fields, "shutdown step completed");
|
|
return { outcome: "completed", value: result.value, durationMs };
|
|
}
|
|
if (result.outcome === "failed") {
|
|
input.log.error({ ...fields, err: result.error }, "shutdown step failed; continuing shutdown");
|
|
return { outcome: "failed", error: result.error, durationMs };
|
|
}
|
|
input.log.info(fields, "shutdown step timed out; continuing shutdown without it");
|
|
return { outcome: "timed_out", durationMs };
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
export function createPreDrainShutdownBudget(input: {
|
|
signal: "SIGINT" | "SIGTERM";
|
|
budgetMs?: number;
|
|
log: ShutdownLogger;
|
|
now?: () => number;
|
|
}) {
|
|
const now = input.now ?? Date.now;
|
|
const deadline = now() + (input.budgetMs ?? PRE_DRAIN_SHUTDOWN_BUDGET_MS);
|
|
return {
|
|
run<T>(step: string, run: () => Promise<T>) {
|
|
return runBoundedShutdownStep({
|
|
step,
|
|
signal: input.signal,
|
|
timeoutMs: deadline - now(),
|
|
run,
|
|
log: input.log,
|
|
now,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function drainRunExecutionFinalizersForShutdown(input: {
|
|
signal: "SIGINT" | "SIGTERM";
|
|
drain: (() => Promise<void>) | null;
|
|
timeoutMs?: number;
|
|
log: ShutdownLogger;
|
|
}): Promise<"drained" | "timed_out" | "unavailable"> {
|
|
if (!input.drain) return "unavailable";
|
|
const timeoutMs = input.timeoutMs ?? 5_000;
|
|
let timer: NodeJS.Timeout | null = null;
|
|
try {
|
|
const result = await Promise.race([
|
|
input.drain().then(() => "drained" as const),
|
|
new Promise<"timed_out">((resolve) => {
|
|
timer = setTimeout(() => resolve("timed_out"), timeoutMs);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
if (result === "timed_out") {
|
|
input.log.info(
|
|
{ signal: input.signal, timeoutMs },
|
|
"bounded heartbeat execution finalizer drain timed out",
|
|
);
|
|
}
|
|
return result;
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
type ShutdownHttpListener = {
|
|
listening: boolean;
|
|
close(callback?: (err?: Error) => void): unknown;
|
|
closeIdleConnections?: () => void;
|
|
closeAllConnections?: () => void;
|
|
};
|
|
|
|
/**
|
|
* Stops the HTTP listener from accepting new requests and waits, for at most
|
|
* `timeoutMs`, for the open connections to finish. Idle keep-alive sockets
|
|
* close at once; whatever is still open when the grace period ends is closed
|
|
* forcibly, so the teardown never hangs on a long-lived client. Call this
|
|
* before the database pool ends, so no request can reach a route after
|
|
* `sql.end()` and fail with a connection-ended error.
|
|
*/
|
|
export async function closeHttpListenerForShutdown(input: {
|
|
server: ShutdownHttpListener;
|
|
signal: "SIGINT" | "SIGTERM";
|
|
timeoutMs?: number;
|
|
drainRequests?: (signal: AbortSignal) => Promise<void>;
|
|
log: ShutdownLogger;
|
|
}): Promise<"closed" | "timed_out" | "not_listening"> {
|
|
if (!input.server.listening) return "not_listening";
|
|
const timeoutMs = input.timeoutMs ?? 5_000;
|
|
let timer: NodeJS.Timeout | null = null;
|
|
const controller = new AbortController();
|
|
try {
|
|
const closed = new Promise<"closed">((resolve) => {
|
|
input.server.close((err) => {
|
|
if (err && (err as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") {
|
|
input.log.error({ err, signal: input.signal }, "HTTP listener close failed");
|
|
}
|
|
resolve("closed");
|
|
});
|
|
input.server.closeIdleConnections?.();
|
|
});
|
|
return await Promise.race([
|
|
input.drainRequests ? input.drainRequests(controller.signal).then(() => {
|
|
input.server.closeIdleConnections?.();
|
|
return "closed" as const;
|
|
}) : closed,
|
|
new Promise<"timed_out">((resolve) => {
|
|
timer = setTimeout(() => {
|
|
input.log.info(
|
|
{ signal: input.signal, timeoutMs },
|
|
"HTTP listener drain timed out; closing the remaining connections",
|
|
);
|
|
input.server.closeAllConnections?.();
|
|
resolve("timed_out");
|
|
}, timeoutMs);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
controller.abort();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs the final, ordered teardown of the server. It awaits the application
|
|
* service cleanup first, so a live setup-token login session stops and releases
|
|
* its sandbox lease before the database and the provider stop. The caller runs
|
|
* `process.exit(0)` only after this helper resolves, so an orderly shutdown
|
|
* never leaves a sandbox lease or confidential login state alive past the
|
|
* process exit.
|
|
*
|
|
* A step that rejects does not stop the teardown. The helper logs the error and
|
|
* continues to the next step. A failed setup-token lease release stays a
|
|
* durable record for the startup reaper; the helper surfaces it in the log
|
|
* instead of blocking the exit path.
|
|
*/
|
|
export async function finalizeServerShutdown(input: {
|
|
signal: "SIGINT" | "SIGTERM";
|
|
shutdownAppServices: (() => Promise<void>) | undefined;
|
|
/**
|
|
* Stops the HTTP listener and drains its connections (see
|
|
* `closeHttpListenerForShutdown`). Runs first, while every application
|
|
* service is still available to the requests being drained, so no request
|
|
* runs against a half-dismantled service or an ended pool.
|
|
*/
|
|
closeHttpListener?: (() => Promise<unknown>) | null;
|
|
/**
|
|
* Waits for every run-failure Sentry report still in flight. Runs after the
|
|
* application services and before the database pool ends, so a report that
|
|
* started just before shutdown still gets its database read and reaches
|
|
* Sentry before `shutdownSentry` flushes and closes the client.
|
|
*/
|
|
drainPendingRunFailureReports?: (() => Promise<void>) | null;
|
|
/**
|
|
* Ends the server's PostgreSQL client pools. Runs after the application
|
|
* services (which still need the database) and before the embedded
|
|
* provider stops, so the backends close in order and none outlive the
|
|
* process.
|
|
*/
|
|
closeDatabase?: (() => Promise<void>) | null;
|
|
stopEmbeddedPostgres: (() => Promise<void>) | null;
|
|
shutdownInstrumentation: () => Promise<void>;
|
|
shutdownSentry: () => Promise<void>;
|
|
log: ShutdownLogger;
|
|
}): Promise<void> {
|
|
const { signal } = input;
|
|
|
|
// Stop accepting requests and drain the open ones before any service goes
|
|
// away, so a request that is still in flight sees a fully working server.
|
|
if (input.closeHttpListener) {
|
|
try {
|
|
await input.closeHttpListener();
|
|
} catch (err) {
|
|
input.log.error({ err, signal }, "HTTP listener shutdown failed");
|
|
}
|
|
}
|
|
|
|
// Await the application service cleanup, so a live setup-token login session
|
|
// releases its sandbox lease before the database and the provider stop. A
|
|
// rejected cleanup stays durable for the reaper; it does not block the exit.
|
|
try {
|
|
await input.shutdownAppServices?.();
|
|
} catch (err) {
|
|
input.log.error({ err, signal }, "Application service shutdown failed");
|
|
}
|
|
|
|
// Wait for every in-flight run-failure Sentry report before the database
|
|
// pool ends. `reportRunFailure` is fire-and-forget: without this wait, a
|
|
// report that started just before shutdown can lose its database read to
|
|
// the pool end below, or lose its Sentry call to the flush further down.
|
|
if (input.drainPendingRunFailureReports) {
|
|
try {
|
|
await input.drainPendingRunFailureReports();
|
|
} catch (err) {
|
|
input.log.error({ err, signal }, "run-failure report drain failed");
|
|
}
|
|
}
|
|
|
|
// End the client pools once nothing needs them any more. Without this the
|
|
// process exit leaves the pooled backends to PostgreSQL's own TCP keepalive
|
|
// reaping, and a restart loop can pile up enough of them to hit
|
|
// `max_connections` before the next boot gets a connection.
|
|
if (input.closeDatabase) {
|
|
try {
|
|
await input.closeDatabase();
|
|
} catch (err) {
|
|
input.log.error({ err, signal }, "Database client shutdown failed");
|
|
}
|
|
}
|
|
|
|
if (input.stopEmbeddedPostgres) {
|
|
input.log.info({ signal }, "Stopping embedded PostgreSQL");
|
|
try {
|
|
await input.stopEmbeddedPostgres();
|
|
} catch (err) {
|
|
input.log.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
|
|
}
|
|
}
|
|
|
|
// Flush buffered OTel spans before the process goes away; without this await
|
|
// the exporter's final batch is dropped on exit.
|
|
await input.shutdownInstrumentation();
|
|
|
|
// Flush buffered Sentry events before the process goes away; without this
|
|
// await the last events are dropped on exit.
|
|
await input.shutdownSentry();
|
|
}
|
|
|
|
const COORDINATED_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"] as const;
|
|
|
|
type ShutdownSignalTarget = {
|
|
rawListeners(eventName: string): Function[];
|
|
removeListener(eventName: string, listener: (...args: any[]) => void): unknown;
|
|
};
|
|
|
|
/**
|
|
* Some dependencies eagerly install process signal handlers as an import side
|
|
* effect. Paperclip must remain the sole owner of SIGINT/SIGTERM ordering: its
|
|
* handler first snapshots live heartbeat runs and only then stops embedded
|
|
* infrastructure. Remove only listeners added by the supplied import, while
|
|
* preserving every listener that was already registered.
|
|
*/
|
|
export async function loadWithoutCoordinatedShutdownSignalHooks<T>(
|
|
load: () => Promise<T>,
|
|
signalTarget: ShutdownSignalTarget = process,
|
|
) {
|
|
const listenersBeforeLoad = new Map(
|
|
COORDINATED_SHUTDOWN_SIGNALS.map((signal) => [
|
|
signal,
|
|
signalTarget.rawListeners(signal),
|
|
]),
|
|
);
|
|
|
|
let loaded: T;
|
|
try {
|
|
loaded = await load();
|
|
} finally {
|
|
for (const signal of COORDINATED_SHUTDOWN_SIGNALS) {
|
|
const remainingBeforeLoad = [...(listenersBeforeLoad.get(signal) ?? [])];
|
|
for (const listener of signalTarget.rawListeners(signal)) {
|
|
const existingIndex = remainingBeforeLoad.indexOf(listener);
|
|
if (existingIndex >= 0) {
|
|
remainingBeforeLoad.splice(existingIndex, 1);
|
|
continue;
|
|
}
|
|
signalTarget.removeListener(signal, listener as (...args: any[]) => void);
|
|
}
|
|
}
|
|
}
|
|
|
|
return loaded;
|
|
}
|
|
|
|
export async function coordinateHeartbeatSchedulerShutdown<
|
|
TPreparation extends HotRestartShutdownPreparation,
|
|
>(input: {
|
|
signal: "SIGINT" | "SIGTERM";
|
|
prepareHotRestartShutdown: ((signal: "SIGINT" | "SIGTERM") => Promise<TPreparation>) | null;
|
|
waitForHeartbeatSchedulerIdle: () => Promise<void>;
|
|
}): Promise<{
|
|
hotRestart: TPreparation | null;
|
|
preparationError: unknown;
|
|
waitedForSchedulerIdle: boolean;
|
|
}> {
|
|
let hotRestart: TPreparation | null = null;
|
|
let preparationError: unknown = null;
|
|
|
|
// The signal handler stops the scheduler before entering this coordinator.
|
|
// Quiesce any callback that was already in flight before querying running
|
|
// rows for the shutdown snapshot, otherwise a late queue claim can create a
|
|
// run that is absent from both the snapshot and the selective drain set.
|
|
await input.waitForHeartbeatSchedulerIdle();
|
|
|
|
if (input.prepareHotRestartShutdown) {
|
|
try {
|
|
hotRestart = await input.prepareHotRestartShutdown(input.signal);
|
|
} catch (err) {
|
|
preparationError = err;
|
|
}
|
|
}
|
|
|
|
return {
|
|
hotRestart,
|
|
preparationError,
|
|
waitedForSchedulerIdle: true,
|
|
};
|
|
}
|
|
|
|
export async function finishServiceShutdown(
|
|
stop: () => Promise<void>,
|
|
cleanup: () => Promise<void>,
|
|
): Promise<void> {
|
|
const failures: unknown[] = [];
|
|
let retryStop = false;
|
|
try { await stop(); } catch { retryStop = true; }
|
|
try { await cleanup(); } catch (error) { failures.push(error); }
|
|
if (retryStop) {
|
|
try { await stop(); } catch (error) { failures.unshift(error); }
|
|
}
|
|
if (failures.length === 1) throw failures[0];
|
|
if (failures.length > 1) throw new AggregateError(failures, "Service shutdown failed");
|
|
}
|