src/restart.js
Rama master · réplica de lectura del repositorio original.
/**
* One-click restart plumbing.
*
* Two concerns, both dependency-light and unit-testable (all side-effecting
* handles are injected — server / stopLs / exitFn / abortSse):
*
* - detectSupervisor(): is there a process supervisor that will relaunch us
* after we exit? Exiting with no supervisor would take the gateway down
* into nothing, so the /restart endpoint refuses in that case.
* - gracefulRestart(): abort SSE → drain in-flight (bounded) → stop the LS
* pool → exit with a supervisor-relaunch code (75 / EX_TEMPFAIL, matching
* selfUpdateRestartExitCode()).
*/
import { existsSync } from 'node:fs';
import { log } from './config.js';
/**
* Detect whether we're running under a process supervisor that will relaunch
* the gateway after a non-zero exit.
*
* @param {NodeJS.ProcessEnv} [env] injected for testability
* @returns {{ supervised: boolean, kind: 'systemd'|'pm2'|'docker'|'override'|null }}
*/
export function detectSupervisor(env = process.env) {
// Explicit operator override wins first — lets someone run under a
// supervisor we don't auto-detect (runit, s6, supervisord, a bespoke wrapper)
// opt in rather than getting a false refusal.
if (env.WINDSURFAPI_RESTART_SUPERVISED === '1') {
return { supervised: true, kind: 'override' };
}
// systemd exports INVOCATION_ID for every service instance; JOURNAL_STREAM is
// present when stdout/stderr are wired to the journal (also systemd).
if (env.INVOCATION_ID || env.JOURNAL_STREAM) {
return { supervised: true, kind: 'systemd' };
}
// PM2 sets pm_id on each managed process; PM2_HOME points at its runtime dir.
if (env.pm_id != null || env.PM2_HOME) {
return { supervised: true, kind: 'pm2' };
}
// Docker with a restart policy (--restart / restart:) relaunches the
// container's PID 1 on exit. We can't read the restart policy from inside,
// but /.dockerenv is a reliable "we're in a container" marker; the operator
// is responsible for the restart policy just as with systemd's Restart=.
try {
if (existsSync('/.dockerenv')) {
return { supervised: true, kind: 'docker' };
}
} catch { /* existsSync should never throw, but never let detection crash */ }
return { supervised: false, kind: null };
}
/**
* Gracefully drain then exit so a supervisor relaunches us.
*
* Ordering: abort SSE → close the HTTP server (wait for in-flight, bounded by
* drainMs) → stop the LS pool (awaited so SIGTERM lands before we exit and
* reparent surviving children to init) → exit.
*
* Everything with a side effect is injected so this is unit-testable without a
* real server, real LS children, or a real process.exit.
*
* @param {object} opts
* @param {string} opts.reason human label for the logs
* @param {number} [opts.drainMs] max time to wait for in-flight drain (default 10s)
* @param {object} [opts.server] http.Server-like with close(cb); null skips drain
* @param {Function} [opts.stopLs] async LS graceful-stop; awaited if provided
* @param {Function} [opts.abortSse] abort-active-SSE hook; called first if provided
* @param {Function} [opts.exitFn] exit function (default process.exit)
* @param {number} [opts.exitCode] exit code (default 75 / EX_TEMPFAIL)
*/
export async function gracefulRestart({
reason,
drainMs = 10000,
server = null,
stopLs = null,
abortSse = null,
exitFn = process.exit,
exitCode = 75,
} = {}) {
log.info(`restart: begin graceful restart (${reason || 'unspecified'})`);
// Detach P1: refuse new admissions and drop frozen turns before the LS pool
// is stopped. Entries are in-memory, so nothing persists across the restart.
try {
const registry = await import('./detached-registry.js');
registry.beginDetachDrain();
} catch (e) {
log.warn(`restart: detach drain failed (continuing): ${e.message}`);
}
// (a) Abort in-flight SSE streams so long-lived streaming responses don't
// pin the drain for the full drainMs window.
if (typeof abortSse === 'function') {
try {
const n = abortSse('server restarting');
if (n) log.warn(`restart: aborted ${n} active SSE stream(s)`);
} catch (e) {
log.warn(`restart: abortSse failed (continuing): ${e.message}`);
}
}
// (b) Close the HTTP server and wait for in-flight requests, bounded by
// drainMs so a stuck request can never hang the restart forever.
if (server && typeof server.close === 'function') {
const inflight = server.getActiveRequests?.();
log.info(`restart: draining${inflight != null ? ` ${inflight}` : ''} in-flight request(s) (up to ${drainMs}ms)`);
try {
if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
} catch { /* best-effort */ }
await new Promise((resolve) => {
let done = false;
const finish = (why) => {
if (done) return;
done = true;
clearTimeout(timer);
log.info(`restart: drain ${why}`);
resolve();
};
const timer = setTimeout(() => finish('timeout — forcing exit'), drainMs);
try {
server.close(() => finish('complete'));
} catch (e) {
log.warn(`restart: server.close threw (continuing): ${e.message}`);
finish('close-error');
}
});
}
// (c) Stop the LS pool and wait for children to actually exit, so SIGTERM
// lands before process.exit reparents survivors to init (the H-4 orphan
// race — orphan language_server processes hold pool ports).
if (typeof stopLs === 'function') {
try {
log.info('restart: stopping LS pool before exit');
await stopLs();
} catch (e) {
log.warn(`restart: stopLs failed (continuing): ${e.message}`);
}
}
// (d) Exit with the supervisor-relaunch code.
log.info(`restart: exiting with code ${exitCode} for supervisor relaunch`);
return exitFn(exitCode);
}