Skip to content

fix(runtime): unref the game-state watcher so it can't pin the event loop - #7

Merged
TheArchitectit merged 2 commits into
TheArchitectit:masterfrom
davidj4tech:fix/game-state-watcher-unref
Jul 29, 2026
Merged

fix(runtime): unref the game-state watcher so it can't pin the event loop#7
TheArchitectit merged 2 commits into
TheArchitectit:masterfrom
davidj4tech:fix/game-state-watcher-unref

Conversation

@davidj4tech

Copy link
Copy Markdown
Contributor

The bug

ensureGameStateWatcherImpl() opens one fs.watch per MegaRuntime. An
fs_event handle that is active and referenced holds node's event loop open,
and only dispose() released it — so any process that never reaches
session_shutdown finishes all its work and then sits there forever.

That is exactly what the extension test files do. A node diagnostic report
(node --report-on-signal --report-signal=SIGUSR2) taken at hang time shows ten
referenced fs_event handles, one per runtime the file constructs:

fs_event  is_active=True  is_referenced=True     x10
async     is_active=True  is_referenced=False
timer     is_active=True  is_referenced=False

The watcher is a cache-eviction convenience, never a reason to keep the process
alive — so it is now unref()'d at creation, exactly like the perf sampling
interval. While pi is running there are always other referenced handles, so the
watcher still fires normally; dispose() still closes it for prompt fd release.

Result

Run directly, each file exiting on its own (the file exiting is the assertion):

file before after
extensions/mega-runtime/state.test.js never exits 4s
extensions/mega-cache-replay.test.js never exits 6s
extensions/mega-teamrun.test.js never exits 13s

Why this hid for so long

process.getActiveResourcesInfo() does not report fs_event handles, so at
hang time it shows only stdio pipes — which reads as "a pending promise, no open
handle" and sent earlier investigation toward PGlite.
MEGACOMPACT_PGLITE_DISABLED=true did make the files exit, but only because that
path never reaches bindRepo()ensureGameStateWatcher().

Relationship to #5 and #6

This is a third, independent holder of the event loop, and it does not
supersede either earlier fix. I re-measured #5's premise to be sure: opening both
indexes and then closing them exits in ~3s, while leaving them open either stalls
~13s or never exits at all — so closing on shutdown remains necessary. #6's
bounded open is untouched by any of this.

The second commit is comment-only. The note added alongside #5 stated 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, so that comment is
corrected in place rather than left to mislead the next reader; the measured
close-vs-leave-open numbers replace the hand-wavy phrasing. The perf interval
genuinely is not a culprit — it is unref'd.

Not fixed here

extensions/mega-compact.test.ts still hangs, but on an unrelated defect: it
wedges inside its final cleanup test at ~99% CPU, blocked in PGlite's
synchronous execProtocolRawSync on the INSERT INTO memory_index from
upsertMemoryEmbedding(). Because the loop is blocked, the
Promise.race(…, setTimeout(3000)) guard in that test can never fire. Ruled out
so far: zero/NaN vectors, duplicate vectors, bulk inserts, close racing an
in-flight upsert, use-after-close, and the data itself (replaying the file's real
tuples into a fresh index runs at 1-2ms each). Worth its own issue.

Also unrelated and pre-existing: the test "S28: non-length stopReasons do not arm
the length-stop flag" fails identically on unmodified master.

…loop

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.
…ins the loop

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.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@TheArchitectit
TheArchitectit merged commit 2e78032 into TheArchitectit:master Jul 29, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants