Repository navigation
Description
Summary
The supported pattern for application shutdown cleanup is a 'close' listener on the plugin-API Scope:
export function handleApplication(scope) {
const service = startService();
scope.once('close', async () => {
await service.close();
});
}But nothing in the shutdown path waits for that cleanup. Async 'close' listeners race process.exit, and on an idle server the cleanup may not even start before the process exits. This came up as a support question from an app developer; the recommended answer only works best-effort today.
Mechanics (all on the worker thread, triggered by the SHUTDOWN ITC message)
scope.close()is fire-and-forget.componentLoader.ts:472registersonMessageByType(ITC_EVENT_TYPES.SHUTDOWN, () => scope.close()), andnotifyMessageListeners(server/threads/manageThreads.js:610-616) invokes typed listeners synchronously, dropping returned promises.- Async
'close'listener promises are dropped.Scope.close()(components/Scope.ts:181-187) awaits entry-handler and options teardown, then calls plainthis.emit('close')—EventEmitter.emitdoes not await listener promises. - Exit is sequenced only on HTTP drain.
threadServer.js:175-177doescloseServers().then(() => realExit(0)), whererealExitis the originalprocess.exit.closeServers()resolves when every server'sclose()callback fires (or a 5s per-server cap) — on an idle server that's near-immediate. - Because
scope.close()awaits entry-handler/options teardown before emitting'close', on a fast drain the'close'event may fire afterrealExit(0)has been queued — cleanup never starts. - The backstop timers (
threadTerminationTimeout= 10s worker-side atmanageThreads.js:655-663, 2× on the main thread beforeworker.terminate()atmanageThreads.js:296-306) are upper bounds only — nothing ever waits for scope cleanup, so finishing under the timeout doesn't help.
Note this affects graceful restarts too (dev watch, restart operation) — same SHUTDOWN message path via restartWorkers.
Impact
Apps that flush buffers, drain queues, or close connections to external services in a 'close' listener can be cut off mid-flight (or never run) on shutdown/restart. Typical symptoms: lost telemetry/batches, unclean client disconnects, external resources left open.
Suggested direction
- Make the worker's exit condition
Promise.allSettled([closeServers(), allScopeCloses])(bounded bythreadTerminationTimeout) instead ofcloseServers()alone. - Have
Scope.close()await async'close'listener results —emitcan't do this, so either iteraterawListeners('close')and await returned promises, or add an explicit registration API (e.g.scope.onClose(async () => ...)) and document'close'listeners as sync-only.
Docs follow-up (documenting the cleanup pattern + this caveat) tracked separately in the documentation repo.
Remaining loading/disposal liveness variant
The TypeStrip integration now awaits scope cleanup and separates load-verdict reporting from admission. A retained variant remains when SHUTDOWN unrefs the parent port while pending component loading or disposal completes only through an unreferenced callback: the worker's event loop can end before teardown finishes. This pass code-traced the variant at server/threads/manageThreads.ts:3226 and server/threads/threadServer.ts:278-292 on f3f3d9af630c8e41d57415da8c5b49f0374d4575; it did not reproduce it afresh. The current held-scope fixture uses referenced disposal timers, so it does not establish this case. A discriminating follow-up should require disposal to precede exit with an unreferenced completion source in both runtimes. Admission itself no longer blocks teardown.
Review-Deferral: c8fea45aee59d5a7 severity=minor category=reliability
🤖 Generated by OpenAI Codex; posted via @kriszyp.
Main-thread component loading during process exit
Review of Add opt-in native full-text analytics #3946 identified a separate, code-traced residual in the graceful shutdown code merged by Drain in-flight work and close cleanly on SIGTERM/SIGINT/SIGQUIT, bounded by threads.shutdownTimeout #3943. In multi-worker mode (threads.count > 0), main awaits loadRootComponents() in server/threads/socketRouter.ts, while shutdownForExit() in server/threads/threadServer.ts waits a componentsLoading promise assigned by the worker/single-thread startup route. If main receives a signal while component loading is unresolved, shutdown can sweep deferred scope closes before loading creates its last scope. A close registered after that sweep has no subsequent sweep before process exit.
This is a code-traced possibility, not a reproduced failure or an analytics regression. Add a deterministic main/multi-worker fixture that pauses component loading, initiates signal shutdown, creates a final scope, and requires that scope's async disposal to settle before exit under the existing deadline. Confirm the loading promise and disposal boundary share ownership before choosing a fix; preserve the already-correct worker and single-thread routes. No production incident or data loss is established.
Review-Deferral: e00b38df16d81c48 severity=nit category=reliability
Comment generated by kAIle (GPT-6)
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
kriszyp commentedon Sep 27, 2026
The premise here does not hold for the primary shutdown path, which makes the bug broader than "the await races
process.exit". Verified onorigin/main(2026-09-03).bin/run.ts'saddExitListeners()wires SIGINT/SIGQUIT/SIGTERM to:beginProcessShutdown()(server/threads/manageThreads.js:797-799) is a one-line flag setter —processShuttingDown = true. It does not callshutdownWorkers/restartWorkers, which is the only code that ever doesworker.postMessage({ type: SHUTDOWN })(manageThreads.js:637). The sole caller ofshutdownWorkersNow()in the whole repo isbin/restart.ts:89, i.e. the restart-service path.So on a real OS
SIGTERM/docker stop/killHarper, the main thread callsprocess.exit(0)directly — terminating every worker thread immediately — andSHUTDOWNis never posted to any worker. Scope'close'listeners do not race the exit; they never run at all, sync or async, at any delay. This issue's analysis (SHUTDOWN is delivered, then the asyncscope.close()loses the race) describes the restart/dev-reload path only.One correction to this issue's own citation while we're here:
componentLoader.ts:472's fire-and-forgetscope.close()is stale —d72d5b035(2026-06-16, pre-dating this issue) pluscomponents/componentLoader.ts:1002already wrap it intrackScopeClose(...). The awaiting half is done; the signalling half on the real shutdown path is not.Practically this means the documented app-teardown hook does not run in the way apps actually stop in production: a container stop.
(qa-explorer campaign, F-185.)
— Claude Opus 5.5
kriszyp commentedon Oct 23, 2026
A retained startup-specific variant from #562 review belongs with this shutdown-liveness issue. At
386f55f972e482ee56736c1215c8251f51535dd8,server/threads/manageThreads.ts:3226unrefsparentPortwhen SHUTDOWN arrives, whileserver/threads/threadServer.ts:278waits for the loading promise before running disposal. A loading step with no referenced handle can let the worker exit before cleanup. A preparation-lock wait can also outlast the worker's exit backstop.The branch's both-mode unreferenced-timer probe passes; it does not establish liveness for every native-storage wake or a load held beyond the deadline. Main controls also fail shutdown during loading, so this is retained as an explicitly limited lifecycle contract during the TypeStrip merge, rather than a claim that the merge repairs every shutdown race. The earlier review groups are
917206da96da,250770341ee4,1126b433b3f1,c86e355ec346anda2be04d0875d.Please include a cold-worker regression that gates real component preparation, sends SHUTDOWN, and verifies disposal precedes exit both when the pending step has no referenced handle and when preparation exceeds the backstop. Keep this separate from late listener creation after close (#3472) and the process-level signal path (#3452). No shipped-version boundary or production incident is established by this source-derived report.
🤖 Generated by OpenAI Codex; posted via @kriszyp.