Skip to content

Commit e9a1f5c

Browse files
fix(core): a stopped ObjectKernel removes its signal listeners and never exits the process (#22334)
Fixes #22286 Clause-②: no ## What was wrong With `gracefulShutdown: true` (the default), the `ObjectKernel` constructor called `registerShutdownSignals()`. That added one anonymous `process.on(signal, ...)` listener each for SIGINT, SIGTERM and SIGQUIT and kept no handle, so `shutdown()` could never remove them. A stopped kernel's handler still ran `shutdown()`, which warned and returned, and then called `safeExit(0)`. Every kernel a process built therefore left three handlers, and each one exited the process on the next signal. ## What changes (`packages/core/src/kernel.ts`) - `registerShutdownSignals()` stores each listener in a private `signalListeners` map. - `releaseShutdownSignals()` removes exactly those listeners and is idempotent. It runs at every transition into `stopped`: `shutdown()` completing, the shutdown-timeout branch, and a failed `bootstrap()`. A stopped kernel holds no listener. - `handleShutdownSignal()` exits the process only from the handler whose signal started the drain, and only once. If the kernel is already `stopping` or `stopped` when a signal arrives, the handler logs a warning and does not exit, because whoever is stopping the kernel owns what follows. - The `gracefulShutdown` JSDoc states the listener lifetime. There is no new option, export or accept-set change. **Why the listeners are removed at `stopped`, not when the drain begins.** The dispatch's suggested route removed them at the start of `shutdown()` and on the first signal. That would regress the existing duplicate-signal guard (`Shutdown already in progress, ignoring SIGINT`). While a kernel drains, its listener absorbs a repeated signal, for example a terminal Ctrl-C that reaches both the process group and a forwarding parent. With no listener left, that second signal gets Node's default action and kills the process mid-drain. So the listener stays installed through `stopping` and is removed at `stopped`. Pin 6 below covers this. ## Before / after Measured with a probe that stubs `process.exit` and invokes the listeners the kernels added, as a delivered signal would. Before = `packages/core/src/kernel.ts` at `f2626c71db` (blob `57d2340c`); after = this branch (blob `2307a7b5`). Counts are listeners added per signal (SIGINT / SIGTERM / SIGQUIT, always equal). | Scenario | Before | After | |---|---|---| | 5 kernels built, booted and stopped: listeners left | +5 | +0 | | One SIGTERM with those 5 stopped kernels plus 1 live kernel: `process.exit` calls | 6 × `exit(0)` | 1 × `exit(0)` | | Listeners left after that SIGTERM | +6 | +0 | | SIGTERM while the host's own `shutdown()` is draining: exits during the drain | 1 × `exit(0)` | 0 | | SIGINT, then SIGINT and SIGTERM again during that signal's drain: exits | 1 × `exit(0)`, after the drain | 1 × `exit(0)`, after the drain | | Listeners installed during that drain / after it | +1 / +1 | +1 / +0 | | Failed `bootstrap()`: listeners left | +1 | +0 | | 11 kernels built and stopped: `MaxListenersExceededWarning` | 3 (one per signal) | 0 | | Two live default kernels, one SIGTERM: exits | 2 × `exit(0)` | 2 × `exit(0)` (unchanged, see Acceptance notes) | | Family item: the host stops one of two `gracefulShutdown: false` kernels and its teardown hangs past `shutdownTimeout` | `exit(1)`, other kernel still `running` | unchanged | ## The family item: the teardown-timeout `process.exit(1)` is not folded in - **Reach, measured in process with a stubbed exit.** A host holding two `gracefulShutdown: false` kernels (cloud's per-environment shape) stops one of them. That kernel's teardown hangs past `shutdownTimeout`, and `shutdown()` calls `process.exit(1)` while the other kernel is still `running`. - **Why it is not folded.** No rule in the code makes `gracefulShutdown: false` mean "this kernel does not own the process". The only place that says so is the docs page `content/docs/protocol/kernel/lifecycle.mdx`: "ObjectStack ships the listeners only when `gracefulShutdown: true`; otherwise it stays out of the process-management business". The code pins the opposite. In `kernel.test.ts`, the test "still logs the timeout and still forces exit(1) when shutdown genuinely times out" builds its kernel with `gracefulShutdown: false` and asserts `exit(1)`. Folding the exit under `gracefulShutdown` would reverse that landed pin and give an existing option a second meaning. Any other fold needs a new option, which widens the public surface. So the question goes to the maintainer with a four-axis analysis (the os-dev report on the card), and this PR leaves the timeout exit unchanged. ## Tests New file `packages/core/src/kernel-signal-listeners.test.ts`, 9 tests. `process.exit` is stubbed throughout, and listeners added during a test are removed in `afterEach`. 1. Five kernels built and stopped leave the baseline listener count on SIGINT, SIGTERM and SIGQUIT. Positive control: a live kernel holds exactly one listener per signal. 2. One signal produces one exit with five stopped kernels and one live kernel in the process. 3. to 5. Control, one test per signal: a running kernel still drains (`kernel:shutdown`, then `destroy()`) and then exits once with code 0. 6. A repeated signal during a signal-driven drain is absorbed, and the listener is still installed while the kernel is `stopping`. It starts no second exit. 7. A signal that arrives while the host is stopping the kernel does not exit the process. 8. A kernel whose bootstrap failed releases its listeners. 9. `gracefulShutdown: false` installs no listener. **Reverse verification and ablations** (one-off, run at `74a027b369`). The test imports `./kernel` relatively, so it reads source: no `dist/` and no vitest alias are involved. Each leg restores `kernel.ts` with `git checkout HEAD --` and proves the restore by blob hash (`2307a7b5` equals HEAD) and an empty `git diff HEAD`. - **Leg A**, `kernel.ts` restored to the base `f2626c71db` and the test at HEAD: 8 failed, 1 passed (the `gracefulShutdown: false` control). The one-signal pin reads `expected [ +0, +0, +0, +0, +0, +0 ] to deeply equal [ +0 ]`. - **Leg B**, removal dropped: the anchor `process.removeListener(signal, listener);` is replaced with a no-op through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `2307a7b5` → `efc9b4e6`). 8 failed, 1 passed. The one-signal pin reads `expected { SIGINT: 6, SIGTERM: 6, SIGQUIT: 6 } to deeply equal { SIGINT: 1, SIGTERM: 1, SIGQUIT: 1 }`. - **Leg C**, the `stopping`/`stopped` guard in `handleShutdownSignal()` dropped (blob → `923bb1da`): exactly 1 failed, the host-stopping pin, `expected [ +0 ] to deeply equal []`. This guard has its own pin, and removing listeners alone does not cover it. **Suites**, run at `87a131389e`. That is this branch with `origin/main` `28bff18d0c` merged in; the merge touches neither `kernel.ts` nor the new test, whose blobs stay `2307a7b5` and `f7c6469d`. The dependency closure was rebuilt first. - `@objectstack/core` local project: 80 files, 2208 tests passed. Repo project: 3 files, 48 tests passed. - `@objectstack/runtime` local project: 340 files, 4773 passed, 19 skipped. Repo project: 3 files, 751 passed. Both ran against `@objectstack/core` built from this branch. - `pnpm --filter @objectstack/core typecheck` passed (tsc, examples, and `check:test-typecheck`: the test layer compiles and the ledger is unchanged at 4 entries). **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` at `87a131389e` derives 63 commands. All 63 ran and exited 0, and `--ran` with the recorded exit codes reports 63 run, 0 NOT-MEASURED, 0 UNRUN. Locally, `check-changeset-no-major` reports its clause-② LEVEL axis as not applicable because there is no `pull_request` payload, so CI reads this body for it. The CI-owned jobs (Test Core shards, Dogfood, Temporal Conformance, Build Core, the workspace type-check lanes) were not run locally. ## Acceptance notes - **Two live kernels that both own the signals still exit twice.** Each one drains and exits on the same signal, so in a real process the first exit ends the other kernel's drain. Neither kernel is stopped, so this falls outside the ruling. It is the same "who owns the process" question as the family item. Carrier: that decision. - **A kernel that is constructed and never bootstrapped keeps its listeners.** `shutdown()` on an `idle` kernel still throws `Kernel not running`, unchanged. Removing the listeners there would leave a kernel that is bootstrapped later with no signal handling. Carrier: none. - **A signal during a host-driven `shutdown()` is now logged and left to the host,** where before it exited the process at once. `objectstack serve` calls `shutdown()` itself only on the migrate-and-exit path, which then calls `process.exit(0)`. - **No other process-level listener.** `git grep` over `packages/core/src` finds no `process.on`/`once`/`addListener` besides these three signals: no `uncaughtException`, `unhandledRejection` or `beforeExit`. Outside core, `driver-sqlite-wasm`'s `beforeExit` listener already keeps its handle and removes it. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a995b8 commit e9a1f5c

3 files changed

Lines changed: 321 additions & 25 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): a stopped `ObjectKernel` removes its SIGINT/SIGTERM/SIGQUIT listeners and never exits the process
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** With `gracefulShutdown: true` (the default) the constructor added one listener per signal and kept no handle, so `shutdown()` never removed them. Every kernel a process built left three listeners behind, and each one called `process.exit(0)` on the next signal. A process that had built and stopped five kernels and still ran a sixth exited six times on one SIGTERM, and the first exit landed before the live kernel had drained. Eleven built-and-stopped kernels also tripped Node's `MaxListenersExceededWarning`.
10+
- **What a kernel does now.** It holds its listeners from construction until it reaches `stopped`, and removes them at that point, whichever way it got there: `shutdown()` completing, the shutdown timeout, or a failed `bootstrap()`. A stopped kernel has no listener, so it cannot handle a signal. While a kernel is `stopping` its listener stays installed, so a repeated signal is absorbed and does not trigger Node's default action mid-drain.
11+
- **Who exits the process.** Only the handler whose signal started the drain, and only once. A signal that arrives while the host is already stopping the kernel through `shutdown()` is logged and left to the host. The kernel no longer exits the process in that case.
12+
- **Unchanged.** A running kernel still drains and exits with code 0 on SIGINT, SIGTERM or SIGQUIT (code 1 if `shutdown()` throws). `gracefulShutdown: false` still installs nothing. A genuine shutdown timeout still calls `process.exit(1)`.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The process signal listeners an `ObjectKernel` installs (`gracefulShutdown`,
5+
* default `true`) belong to that kernel's LIFE, not to the process's.
6+
*
7+
* Before this file the constructor added one anonymous listener per signal
8+
* (SIGINT, SIGTERM, SIGQUIT), kept no handle, and `shutdown()` never removed
9+
* them. Every kernel a process built therefore left three handlers behind, and
10+
* each of them ran `shutdown()` (a no-op on a stopped kernel) and then
11+
* `process.exit(0)` on the next signal: five built-and-stopped kernels plus a
12+
* live one turned ONE signal into six exits, the first of them landing before
13+
* the live kernel had drained.
14+
*
15+
* How a signal is simulated here, and why not with `process.kill`: a real
16+
* signal reaches EVERY listener in the worker, the test runner's own included,
17+
* and its default action ends the worker. `deliver()` instead calls the
18+
* listeners this test's kernels added — exactly the set a delivered signal
19+
* would run on their behalf, in the same order — and `process.exit` is stubbed
20+
* throughout, so nothing here can end the run.
21+
*/
22+
23+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
24+
import { ObjectKernel } from './kernel';
25+
import type { ObjectKernelConfig } from './kernel';
26+
import type { Plugin } from './types';
27+
28+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGQUIT'] as const;
29+
type ShutdownSignal = (typeof SIGNALS)[number];
30+
type SignalListener = (signal: NodeJS.Signals) => void;
31+
32+
const NONE = { SIGINT: 0, SIGTERM: 0, SIGQUIT: 0 };
33+
const ONE_EACH = { SIGINT: 1, SIGTERM: 1, SIGQUIT: 1 };
34+
35+
describe('ObjectKernel process signal listeners', () => {
36+
let baseline: Map<ShutdownSignal, SignalListener[]>;
37+
let exits: Array<string | number | null | undefined>;
38+
let exitSpy: { mockRestore: () => void };
39+
40+
beforeEach(() => {
41+
baseline = new Map(SIGNALS.map((s) => [s, process.listeners(s) as SignalListener[]]));
42+
exits = [];
43+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => {
44+
exits.push(code);
45+
}) as never);
46+
});
47+
48+
afterEach(() => {
49+
// Leave the worker as it was found whatever the test did: on a tree
50+
// without the fix, the defect under test IS a listener left behind.
51+
for (const s of SIGNALS) {
52+
for (const l of added(s)) process.removeListener(s, l);
53+
}
54+
exitSpy.mockRestore();
55+
});
56+
57+
/** The listeners kernels built in this test hold on `signal`, oldest first. */
58+
const added = (signal: ShutdownSignal): SignalListener[] =>
59+
(process.listeners(signal) as SignalListener[]).filter((l) => !baseline.get(signal)!.includes(l));
60+
61+
const growth = () => ({
62+
SIGINT: added('SIGINT').length,
63+
SIGTERM: added('SIGTERM').length,
64+
SIGQUIT: added('SIGQUIT').length,
65+
});
66+
67+
/** What a delivered `signal` does to those listeners: every one runs, in order. */
68+
const deliver = (signal: ShutdownSignal) => {
69+
for (const l of added(signal)) l(signal);
70+
};
71+
72+
/** One macrotask: every handler that was going to reach `process.exit` has reached it. */
73+
const quiesce = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
74+
75+
const newKernel = (config: ObjectKernelConfig = {}) =>
76+
new ObjectKernel({ logger: { level: 'silent' }, skipSystemValidation: true, ...config });
77+
78+
/** A plugin whose `kernel:shutdown` subscriber holds teardown open until released. */
79+
const drainGate = (reached: string[] = []) => {
80+
let release!: () => void;
81+
const gate = new Promise<void>((resolve) => { release = resolve; });
82+
const plugin: Plugin = {
83+
name: 'drain-gate',
84+
version: '1.0.0',
85+
init: async (ctx) => {
86+
ctx.hook('kernel:shutdown', async () => {
87+
await gate;
88+
reached.push('drained');
89+
});
90+
},
91+
destroy: async () => { reached.push('destroy'); },
92+
};
93+
return { plugin, release: () => release() };
94+
};
95+
96+
it('five kernels built and stopped leave the baseline listener count on SIGINT, SIGTERM and SIGQUIT', async () => {
97+
for (let i = 0; i < 5; i++) {
98+
const kernel = newKernel();
99+
// Positive control: a live kernel DOES hold one listener per
100+
// signal, so the zero below is a removal and not an absence.
101+
expect(growth()).toEqual(ONE_EACH);
102+
await kernel.bootstrap();
103+
await kernel.shutdown();
104+
expect(growth()).toEqual(NONE);
105+
}
106+
expect(exits).toEqual([]);
107+
});
108+
109+
it('one signal produces one exit with five stopped kernels and one live kernel in the process', async () => {
110+
for (let i = 0; i < 5; i++) {
111+
const stopped = newKernel();
112+
await stopped.bootstrap();
113+
await stopped.shutdown();
114+
}
115+
const live = newKernel();
116+
await live.bootstrap();
117+
const listening = growth();
118+
119+
deliver('SIGTERM');
120+
await vi.waitFor(() => expect(live.getState()).toBe('stopped'));
121+
await quiesce();
122+
123+
expect(exits).toEqual([0]);
124+
// Only the live kernel was listening, and it gave its listeners back too.
125+
expect(listening).toEqual(ONE_EACH);
126+
expect(growth()).toEqual(NONE);
127+
});
128+
129+
// Control: the defect fix must not cost the one thing these listeners are
130+
// for. `objectstack serve` installs no signal handler of its own and relies
131+
// on exactly this path to drain and exit.
132+
it.each(SIGNALS)('a running kernel still drains and exits once on %s', async (signal) => {
133+
const reached: string[] = [];
134+
exitSpy.mockRestore();
135+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => {
136+
exits.push(code);
137+
reached.push(`exit:${String(code)}`);
138+
}) as never);
139+
const { plugin, release } = drainGate(reached);
140+
const kernel = newKernel();
141+
await kernel.use(plugin);
142+
await kernel.bootstrap();
143+
144+
deliver(signal);
145+
release();
146+
await vi.waitFor(() => expect(kernel.getState()).toBe('stopped'));
147+
await quiesce();
148+
149+
// Teardown ran to the end BEFORE the process was exited, and exactly once.
150+
expect(reached).toEqual(['drained', 'destroy', 'exit:0']);
151+
expect(exits).toEqual([0]);
152+
expect(growth()).toEqual(NONE);
153+
});
154+
155+
it('a repeated signal during a signal-driven drain is absorbed by the kernel and starts no second exit', async () => {
156+
const { plugin, release } = drainGate();
157+
const kernel = newKernel();
158+
await kernel.use(plugin);
159+
await kernel.bootstrap();
160+
161+
deliver('SIGINT');
162+
await vi.waitFor(() => expect(kernel.getState()).toBe('stopping'));
163+
// Still installed while draining. A terminal's Ctrl-C can reach the
164+
// process twice (the process group, and a parent that forwards it);
165+
// with no listener left, Node's default action would end the process
166+
// in the middle of the drain this signal started.
167+
expect(growth()).toEqual(ONE_EACH);
168+
deliver('SIGINT');
169+
deliver('SIGTERM');
170+
await quiesce();
171+
expect(exits).toEqual([]);
172+
173+
release();
174+
await vi.waitFor(() => expect(kernel.getState()).toBe('stopped'));
175+
await quiesce();
176+
177+
expect(exits).toEqual([0]);
178+
expect(growth()).toEqual(NONE);
179+
});
180+
181+
it('a signal that arrives while the host is stopping the kernel does not exit the process', async () => {
182+
const { plugin, release } = drainGate();
183+
const kernel = newKernel();
184+
await kernel.use(plugin);
185+
await kernel.bootstrap();
186+
187+
const stopping = kernel.shutdown();
188+
expect(kernel.getState()).toBe('stopping');
189+
deliver('SIGTERM');
190+
await quiesce();
191+
// The host owns this shutdown and what follows it; the drain it
192+
// started is still running and must not be cut short.
193+
expect(exits).toEqual([]);
194+
195+
release();
196+
await stopping;
197+
await quiesce();
198+
199+
expect(kernel.getState()).toBe('stopped');
200+
expect(exits).toEqual([]);
201+
expect(growth()).toEqual(NONE);
202+
});
203+
204+
it('a kernel whose bootstrap failed releases its listeners', async () => {
205+
const kernel = newKernel();
206+
await kernel.use({
207+
name: 'init-thrower',
208+
version: '1.0.0',
209+
init: async () => { throw new Error('init boom'); },
210+
});
211+
await expect(kernel.bootstrap()).rejects.toThrow('init boom');
212+
213+
expect(kernel.getState()).toBe('stopped');
214+
expect(growth()).toEqual(NONE);
215+
expect(exits).toEqual([]);
216+
});
217+
218+
it('gracefulShutdown: false installs no listener at all', async () => {
219+
const kernel = newKernel({ gracefulShutdown: false });
220+
expect(growth()).toEqual(NONE);
221+
await kernel.bootstrap();
222+
await kernel.shutdown();
223+
expect(growth()).toEqual(NONE);
224+
});
225+
});

‎packages/core/src/kernel.ts‎

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ export interface ObjectKernelConfig {
6969
/** Default plugin startup timeout in milliseconds */
7070
defaultStartupTimeout?: number;
7171

72-
/** Whether to enable graceful shutdown */
72+
/**
73+
* Whether to enable graceful shutdown. When true (the default) the kernel
74+
* listens for SIGINT, SIGTERM and SIGQUIT from construction until it stops:
75+
* a signal drains it and then exits the process. Its listeners are removed
76+
* when it reaches `stopped`, so a stopped kernel never handles a signal.
77+
*/
7378
gracefulShutdown?: boolean;
7479

7580
/** Graceful shutdown timeout in milliseconds */
@@ -116,6 +121,23 @@ export class ObjectKernel {
116121
*/
117122
private pluginStartupDurations: Map<string, number> = new Map();
118123
private shutdownHandlers: Array<() => Promise<void>> = [];
124+
/**
125+
* The process signal listeners this kernel installed (`gracefulShutdown`),
126+
* kept so the kernel can take them back (#22286). Filled by
127+
* `registerShutdownSignals()` and emptied by `releaseShutdownSignals()` at
128+
* every transition into `stopped`. Before this map the listeners were
129+
* anonymous and never removed: every kernel a process built left three of
130+
* them behind, and each ran `process.exit(0)` on the next signal, so one
131+
* SIGTERM in a process that had built six kernels exited it six times —
132+
* the first exit landing while the live kernels were still draining.
133+
*/
134+
private signalListeners: Map<NodeJS.Signals, () => void> = new Map();
135+
/**
136+
* Set by this kernel's own signal handler when IT starts the shutdown. Only
137+
* that handler exits the process, and only once (a repeated signal during
138+
* its drain is absorbed, not acted on).
139+
*/
140+
private signalShutdownStarted = false;
119141
/**
120142
* Name of the plugin whose init() is currently executing (Phase 1 is
121143
* sequential, so at most one). Lets a getService miss during init name
@@ -513,6 +535,9 @@ export class ObjectKernel {
513535
this.logger.info('✅ Bootstrap complete');
514536
} catch (error) {
515537
this.state = 'stopped';
538+
// A kernel whose boot failed is stopped for good (`shutdown()` on it
539+
// is a no-op), so this is the only place its listeners can go.
540+
this.releaseShutdownSignals();
516541
throw error;
517542
}
518543
}
@@ -566,9 +591,11 @@ export class ObjectKernel {
566591
);
567592

568593
this.state = 'stopped';
594+
this.releaseShutdownSignals();
569595
this.logger.info('✅ Graceful shutdown complete');
570596
} catch (error) {
571597
this.state = 'stopped';
598+
this.releaseShutdownSignals();
572599

573600
if (error === shutdownTimeoutError) {
574601
// GENUINE timeout: `performShutdown()` is still running and has
@@ -899,32 +926,64 @@ export class ObjectKernel {
899926
return resolvePluginOrder(this.plugins);
900927
}
901928

929+
/**
930+
* Listen for the shutdown signals for as long as this kernel lives (#22286).
931+
* Each listener is kept in `signalListeners`, so `releaseShutdownSignals()`
932+
* can remove exactly the ones this kernel added and nobody else's.
933+
*/
902934
private registerShutdownSignals(): void {
935+
if (!isNode) return;
903936
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
904-
let shutdownInProgress = false;
905-
906-
const handleShutdown = async (signal: string) => {
907-
if (shutdownInProgress) {
908-
this.logger.warn(`Shutdown already in progress, ignoring ${signal}`);
909-
return;
910-
}
911-
912-
shutdownInProgress = true;
913-
this.logger.info(`Received ${signal} - initiating graceful shutdown`);
914-
915-
try {
916-
await this.shutdown();
917-
safeExit(0);
918-
} catch (error) {
919-
this.logger.error('Shutdown failed', error as Error);
920-
safeExit(1);
921-
}
922-
};
923-
924-
if (isNode) {
925-
for (const signal of signals) {
926-
process.on(signal, () => handleShutdown(signal));
927-
}
937+
for (const signal of signals) {
938+
const listener = () => { void this.handleShutdownSignal(signal); };
939+
this.signalListeners.set(signal, listener);
940+
process.on(signal, listener);
941+
}
942+
}
943+
944+
/**
945+
* Give the process's signals back: remove every listener this kernel added.
946+
* Idempotent. Called at each transition into `stopped` and deliberately NOT
947+
* when the drain begins — while the kernel is `stopping` its listener is
948+
* what absorbs a repeated signal (a terminal's Ctrl-C can arrive twice: once
949+
* to the process group and once from a parent that forwards it). With no
950+
* listener left, Node's default action would end the process mid-drain.
951+
*/
952+
private releaseShutdownSignals(): void {
953+
for (const [signal, listener] of this.signalListeners) {
954+
process.removeListener(signal, listener);
955+
}
956+
this.signalListeners.clear();
957+
}
958+
959+
/**
960+
* One signal, at most one exit — and only from the handler that started
961+
* the drain. A kernel that is already `stopping` or `stopped` when the
962+
* signal arrives was stopped by someone else (the host calling
963+
* `shutdown()`), who owns what happens next: exiting here would cut that
964+
* drain short, which is how the first of six exits used to land.
965+
*/
966+
private async handleShutdownSignal(signal: NodeJS.Signals): Promise<void> {
967+
if (this.signalShutdownStarted) {
968+
this.logger.warn(`Shutdown already in progress, ignoring ${signal}`);
969+
return;
970+
}
971+
if (this.state === 'stopping' || this.state === 'stopped') {
972+
this.logger.warn(
973+
`Received ${signal} while the kernel is already ${this.state} — whoever is stopping it decides what follows; this kernel will not exit the process`,
974+
);
975+
return;
976+
}
977+
978+
this.signalShutdownStarted = true;
979+
this.logger.info(`Received ${signal} - initiating graceful shutdown`);
980+
981+
try {
982+
await this.shutdown();
983+
safeExit(0);
984+
} catch (error) {
985+
this.logger.error('Shutdown failed', error as Error);
986+
safeExit(1);
928987
}
929988
}
930989

0 commit comments

Comments
 (0)