diff --git a/extensions/mega-compact.ts b/extensions/mega-compact.ts index 1566d99..d8bf216 100644 --- a/extensions/mega-compact.ts +++ b/extensions/mega-compact.ts @@ -87,10 +87,20 @@ export default function (pi: ExtensionAPI) { // The PGlite indexes (vectorIndex / memoryIndex) are lazily opened module // singletons. closeVectorIndex()/closeMemoryIndex() existed but had no // non-test callers, so a session left both open: PGlite is WASM Postgres and - // its handles keep node's event loop alive, so `pi -p` produced its answer - // and then hung until killed rather than exiting. dispose() only released the - // fs.watch handle and the perf interval, neither of which was the culprit - // (the interval is unref'd). + // leaving it open makes process exit nondeterministic — measured on a + // teardown that opens both indexes, closing exits in ~3s while leaving them + // open either stalls ~13s or never exits at all. That is why `pi -p` produced + // its answer and then hung until killed. + // + // NOTE: an earlier revision of this comment claimed the fs.watch handle "was + // not the culprit". That was wrong. A node diagnostic report + // (--report-on-signal) taken at hang time shows the game-state watcher as an + // active AND referenced fs_event handle, which pins the event loop on its + // own — independently of PGlite, and on any path that never reaches + // session_shutdown. It is now unref'd at creation + // (ensureGameStateWatcherImpl), so it can no longer hold the process open; + // dispose() still closes it here for prompt fd release. The perf interval + // genuinely is not a culprit — it is unref'd. // // Both closes are idempotent and safe when the index was never opened, and // the next initVectorIndex()/initMemoryIndex() re-opens lazily. diff --git a/extensions/mega-runtime/game-state.ts b/extensions/mega-runtime/game-state.ts index 874d626..a0e94ce 100644 --- a/extensions/mega-runtime/game-state.ts +++ b/extensions/mega-runtime/game-state.ts @@ -14,6 +14,8 @@ import { disposePerf, type PerfContext } from "./perf.js"; export interface GameWatcherLike { close(): void; + /** fs.FSWatcher has it; test doubles need not. */ + unref?(): unknown; } export interface GameStateContext { @@ -142,6 +144,13 @@ export function ensureGameStateWatcherImpl(self: GameStateContext, view: GameSta } }, ); + // The watcher is a cache-eviction convenience, never a reason to stay + // alive: an active+referenced fs_event handle holds node's event loop + // open, so a runtime that was never dispose()d (every extension test, and + // any `pi -p` run that skips session_shutdown) hangs the process after all + // work is done. unref'd like the perf interval — while pi runs there are + // always other referenced handles, so the watcher still fires normally. + self.gameStateWatcher.unref?.(); self.gameStateWatchDir = self.currentStateDir; } catch { /* non-fatal: missing dir / platform issue — next snapshot re-queries */