Files
PaperClipAI/server/src/shutdown.ts
T
DottaandPaperclip c7b68b52b0 fix: preserve sandbox credentials and Git state across restarts
Park idle native sandbox sessions before app shutdown, adopt retained legacy workspaces without restaging Git, and carry explicitly bound GitHub access through both OpenCode launch filters.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-08 21:49:19 -05:00

196 lines
6.6 KiB
TypeScript

type HotRestartShutdownPreparation = {
skipDrain: boolean;
};
type ShutdownLogger = {
info(obj: object, msg: string): void;
error(obj: object, msg: string): void;
};
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);
}
}
/**
* 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;
closeIdleSandboxSessions?: () => Promise<{ closed: number; busy: number; failed: number }>;
sandboxSessionTimeoutMs?: number;
stopEmbeddedPostgres: (() => Promise<void>) | null;
shutdownInstrumentation: () => Promise<void>;
shutdownSentry: () => Promise<void>;
log: ShutdownLogger;
}): Promise<void> {
const { signal } = input;
if (input.closeIdleSandboxSessions) {
let timer: NodeJS.Timeout | null = null;
const timeoutMs = input.sandboxSessionTimeoutMs ?? 30_000;
try {
const result = await Promise.race([
input.closeIdleSandboxSessions(),
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
timer.unref?.();
}),
]);
if (result === null || result.failed > 0) {
input.log.error(
{ signal, timeoutMs, result },
"Idle sandbox native session checkpoint incomplete during shutdown",
);
} else {
input.log.info({ signal, result }, "Idle sandbox native sessions parked for shutdown");
}
} catch (err) {
input.log.error({ signal, err }, "Idle sandbox native session shutdown failed");
} finally {
if (timer) clearTimeout(timer);
}
}
// 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<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,
};
}