From 8e28429fff701a602cc850a1448f2b9addc39add Mon Sep 17 00:00:00 2001 From: davidj4t <65033682+davidj4tech@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:09:07 +1000 Subject: [PATCH 1/2] fix(runtime): unref the game-state watcher so it can't pin the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An fs_event handle that is active AND referenced holds node's event loop open. ensureGameStateWatcher() opened one per MegaRuntime and only dispose() released it — so any process that never reaches session_shutdown finished all its work and then sat there forever. That is exactly what the extension test files did: a node diagnostic report taken at hang time showed ten referenced fs_event handles, one per runtime the file constructs. The leak was invisible to process.getActiveResourcesInfo(), which does not report fs_event, which is why earlier passes concluded "pending promise, no handle" and blamed PGlite (MEGACOMPACT_PGLITE_DISABLED=true only appeared to fix it because that path never reaches bindRepo -> ensureGameStateWatcher). The watcher is a cache-eviction convenience, never a reason to keep the process alive — unref'd like the perf interval. While pi runs there are always other referenced handles, so it still fires normally. state.test.js, mega-cache-replay.test.js and mega-teamrun.test.js now exit on their own (4s / 6s / 13s; previously unbounded). mega-compact.test.js still hangs on an unrelated PGlite spin. --- extensions/mega-runtime/game-state.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 */ From 30fe8001c98eeea378ff44f44cf91bbf7e15f737 Mon Sep 17 00:00:00 2001 From: davidj4t <65033682+davidj4tech@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:36:34 +1000 Subject: [PATCH 2/2] docs(mega-compact): correct the session_shutdown comment about what pins the loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment written alongside the PGlite-close fix asserted that dispose() "only released the fs.watch handle and the perf interval, neither of which was the culprit". The fs.watch handle WAS a culprit: a node diagnostic report (--report-on-signal) taken at hang time lists the game-state watcher as an active AND referenced fs_event, which pins the event loop by itself — independently of PGlite, and on any path that never reaches session_shutdown. Also replaces the hand-wavy "its handles keep node's event loop alive" with the measured behavior: on a teardown that opens both indexes, closing exits in ~3s, while leaving them open either stalls ~13s or never exits at all. Both closes remain correct and necessary. Comment-only; no behavior change. --- extensions/mega-compact.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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.