type HotRestartShutdownPreparation = { skipDrain: boolean; }; type ShutdownLogger = { info(obj: object, msg: string): void; error(obj: object, msg: string): void; }; /** * 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) | undefined; stopEmbeddedPostgres: (() => Promise) | null; shutdownInstrumentation: () => Promise; shutdownSentry: () => Promise; log: ShutdownLogger; }): Promise { const { signal } = input; // 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"); } 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( load: () => Promise, 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) | null; waitForHeartbeatSchedulerIdle: () => Promise; }): 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, }; }