Skip to content

Commit 63ea716

Browse files
committed
test(core): drop the duplicate shutdown-timeout pin; add the changeset
The 'still forces exit when teardown hangs' pin I added duplicated the pre-existing #5274 test, which hangs teardown the same way and asserts the same three things. Point at it from the #10604 block instead. Changeset: patch on @objectstack/core. It is a bug fix, but it lands in published source and changes what an embedding host's process does at teardown, which is the kind of thing release notes are compiled from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
1 parent c36d1f8 commit 63ea716

2 files changed

Lines changed: 45 additions & 44 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
The kernel's two `Promise.race` timeout guards — the startup guard around each
6+
plugin's `init`/`start`, and the shutdown guard around `performShutdown()` —
7+
now reclaim **both** halves of the guard when the race settles: the timer is
8+
cleared *and* the losing promise is settled (#10604).
9+
10+
Neither site settled its loser, so the timeout promise and the reaction
11+
`Promise.race` held on it were retained for the life of the process — four
12+
leaking promises per showcase test run under `vitest --detectAsyncLeaks`, now
13+
zero. The two hand-rolled copies had also drifted into doing opposite halves of
14+
the same cleanup: the startup site cleared its timer and never `unref`'d, the
15+
shutdown site `unref`'d and never cleared. Both now go through one internal
16+
`TimeoutGuard`, so they cannot drift apart again. No exported API changes.
17+
18+
**Behaviour change, at the shutdown guard:** the shutdown timer is no longer
19+
`unref()`d. Two consequences for an embedding host (CLI, auth-proxy, test
20+
runner):
21+
22+
- After a **successful** shutdown, no timer is left armed. Previously the guard
23+
survived its own race and stayed scheduled to fire against a kernel already
24+
`'stopped'`. That late rejection was *handled* — `Promise.race` had attached a
25+
rejection handler to it — so this was never an unhandled-rejection risk; it
26+
was retained work and a wakeup after teardown.
27+
- When teardown **hangs**, the guard now actually fires. An unref'd timer does
28+
not keep the event loop alive, so a process with nothing else to run could
29+
exit silently — status 0, teardown incomplete — before `shutdownTimeout`
30+
elapsed, leaving `Shutdown timed out — forcing exit` and its `exit(1)`
31+
unreachable in exactly the case they exist for. Reclaiming on settle keeps the
32+
guard ref'd exactly as long as the race is undecided, which is the guarantee
33+
the startup guard already had (#4813).
34+
35+
If your host relied on a hung `shutdown()` letting the process fall out of the
36+
event loop on its own, it will now wait up to `shutdownTimeout` (default 60s)
37+
and then hard-exit with status 1. Lower `shutdownTimeout` in the kernel config
38+
to shorten that window.

‎packages/core/src/kernel.test.ts‎

Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,12 @@ describe('ObjectKernel', () => {
363363
// unref'd and never cleared. Two hand-rolled copies of one race, each
364364
// doing the opposite half, and neither settling the promise the race
365365
// still held a reaction on — four leaking promises per showcase run.
366-
// Both sites now go through `TimeoutGuard`, so these pin the wiring.
366+
// Both sites now go through `TimeoutGuard`, so this pins the wiring.
367+
//
368+
// The companion assertion — that reclaiming the guard did not DISARM
369+
// it — is 'still logs the timeout and still forces exit(1) when
370+
// shutdown genuinely times out (#5274)' below, which hangs teardown
371+
// past `shutdownTimeout` and is unchanged by this fix.
367372

368373
it('leaves no timer armed once shutdown has settled', async () => {
369374
vi.useFakeTimers();
@@ -398,51 +403,9 @@ describe('ObjectKernel', () => {
398403
}
399404
});
400405

401-
it('still forces exit when teardown hangs past shutdownTimeout', async () => {
402-
// The companion to the assertion above, and the one that makes it
403-
// safe: reclaiming the guard must not disarm it. Dropping the
404-
// `unref()` is what keeps this reachable at all — an unref'd guard
405-
// lets an otherwise-idle process exit silently instead of reporting
406-
// the timeout.
407-
const exitSpy = vi
408-
.spyOn(process, 'exit')
409-
.mockImplementation((() => undefined) as never);
410-
411-
try {
412-
const k = new ObjectKernel({
413-
logger: { level: 'error' },
414-
gracefulShutdown: false,
415-
skipSystemValidation: true,
416-
shutdownTimeout: 50,
417-
});
418-
const errorSpy = vi.spyOn(
419-
(k as unknown as { logger: Record<'error', (...a: unknown[]) => void> }).logger,
420-
'error',
421-
);
422-
423-
await k.use({
424-
name: 'hanging-teardown',
425-
version: '1.0.0',
426-
init: async () => {},
427-
// Never settles: `performShutdown()` awaits every destroy().
428-
destroy: () => new Promise<void>(() => {}),
429-
} as PluginMetadata);
430-
431-
await k.bootstrap();
432-
await k.shutdown();
433-
434-
expect(errorSpy).toHaveBeenCalledWith(
435-
'Shutdown timed out — forcing exit',
436-
expect.objectContaining({ message: 'Shutdown timeout exceeded' }),
437-
);
438-
expect(exitSpy).toHaveBeenCalledWith(1);
439-
expect(k.getState()).toBe('stopped');
440-
} finally {
441-
exitSpy.mockRestore();
442-
}
443-
}, 2000);
444406
});
445407

408+
446409
describe('Startup Failure Rollback', () => {
447410
it('should rollback started plugins on failure', async () => {
448411
let plugin1Destroyed = false;

0 commit comments

Comments
 (0)