Skip to content

fix(ingest): make the code clause of the vm guard load-bearing, and guard the teardown properly - #649

Open
josephismikhail wants to merge 6 commits into
mainfrom
fix/loader-guard-coverage
Open

fix(ingest): make the code clause of the vm guard load-bearing, and guard the teardown properly#649
josephismikhail wants to merge 6 commits into
mainfrom
fix/loader-guard-coverage

Conversation

@josephismikhail

Copy link
Copy Markdown
Contributor

Follow-up to #621, which merged at its first commit — these six review findings are not on main.

The one that matters: my code clause had zero coverage

#621 widened the vm guard to code || message, with tests. But the real vm error carries both a matching code and a matching message, so || short-circuits on the message and the code branch is never reached.

Mutation-verified: replacing the whole predicate with the message-only test left the file 4/4 green.

That is exactly the pre-#613 form. So a later "simplification" back to it ships green — and then Node rewords the message, which is the precise hazard the code check was added for, and the fallback silently stops firing with nothing pointing at the guard. The test file claimed the gate was protected "in either direction". It was protected in one.

There is now a case only the code clause can satisfy: the real error, its real code, and the message replaced with something that does not match. Derived from a genuine failure like the others. Both clauses are mutation-validated now — deleting either fails a different test.

The teardown guards, which were in the wrong places

Finding Why the previous fix did not work
backend?. optional chain was inert the scenario it names is a beforeEach that throws before assigning backend — but the unguarded backend.unknownPaths dereference is two lines above it and throws first
home/repo unguarded force: true suppresses ENOENT, not the argument-type check: rmSync(undefined, …) throws ERR_INVALID_ARG_TYPE, raising a second unrelated error and skipping the repo removal entirely — leaking the tree in exactly the case cleanup exists for
the comment overclaimed it said the decoupling protects against a stop() that "outruns the hook timeout". It does not — on a hook timeout the promise is rejected from outside while the await is still pending, so no finally runs at all

The last one is now fixed where it has to be: stop() can no longer hang. It calls closeAllConnections() before close() and races a 2s deadline, because close() waits for every open socket and a single stray keep-alive was enough to leave the hook pending. The comment now claims only what it delivers.

Two smaller ones

  • The loader's JSDoc had drifted onto the wrong function — the new block was inserted after the existing one instead of replacing it, so both attached to the predicate and importModule was left undocumented.
  • (await realVmFailure()) as Error then original.message would crash with an opaque TypeError in the scenario the file explicitly anticipates (a host that gains a dynamic-import hook returns null). Both sites assert it first.

Verification

  • 16 tests pass across the two files
  • Full ix-cli suite: 1736 passed; the 3 failures are the read-containment symlink tests that need Windows Developer Mode locally and pass on CI's windows-2022 runner
  • tsc --noEmit and eslint clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01B5bkc5oUL4SapwDE13UgHt

josephismikhail and others added 5 commits September 7, 2026 08:18
…uard the teardown properly

Review of #621, which merged at its first commit. Six findings, all real.

MEDIUM -- the `code` clause I added had ZERO coverage. The real vm error
carries both a matching code and a matching message, so `||` short-circuits
on the message and the code branch is never reached: replacing the whole
predicate with the message-only test left the file 4/4 green. That is the
pre-#613 form, so a later "simplification" back to it would ship green, and
then Node rewords the message -- the exact hazard the code check exists for
-- and the fallback silently stops firing. The test file claimed the gate
was protected "in either direction"; it was protected in one.

There is now a case only the code clause can satisfy: the real error with
its real code and the message replaced by something that does not match.
Derived from a genuine failure, like the others. Both clauses are
mutation-validated now -- deleting either one fails a different test.

LOW -- the `backend?.` optional chain added last round was inert. The
scenario it names is a `beforeEach` that throws before assigning `backend`,
but the unguarded `backend.unknownPaths` dereference is two lines ABOVE it
and throws the TypeError first. Guarded where it actually runs.

LOW -- `home` and `repo` were not guarded the way `backend` was, and
`force: true` does not cover it: it suppresses ENOENT, not the argument
type check, so `rmSync(undefined, ...)` throws ERR_INVALID_ARG_TYPE. On a
first `beforeEach` that fails inside `mkdtempSync` that raised a second,
unrelated error and skipped the `repo` removal entirely -- leaking the tree
in precisely the case cleanup exists for.

LOW -- the decoupling comment claimed protection against a `stop()` that
"outruns the hook timeout". It does not: on a hook timeout the promise is
rejected from outside while the `await` is still pending, so no `finally`
runs at all. The comment now claims only what it delivers, and the real fix
is in `stop()`, which can no longer hang: it calls `closeAllConnections()`
before `close()` and races a 2s deadline, because `close()` waits for every
open socket and one stray keep-alive was enough to leave the hook pending.

LOW -- the loader's JSDoc had drifted onto the wrong function. The new
block was inserted after the existing one rather than replacing it, so both
attached to the predicate and `importModule` was left undocumented.

LOW -- `(await realVmFailure()) as Error` then `original.message` would
crash with an opaque TypeError in the scenario the file explicitly
anticipates (a host that gains a dynamic-import hook returns null). Both
sites assert it first.

16 tests pass across the two files; full suite 1736 passed with the 3
read-containment symlink failures that need Windows Developer Mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5bkc5oUL4SapwDE13UgHt
…eak audible

Review of #649. Three findings; the first is a hole in the fix I added last
round.

MEDIUM -- the three teardown guards were inert for every test after the
first. `home`, `repo` and `backend` are describe-scoped and `afterEach`
never reset them, so from test 2 onward they held the PREVIOUS test's
values -- which makes the guards decorative in precisely the scenario their
own comments name. A `beforeEach` that throws inside `mkdtempSync` left
`backend !== undefined` passing against the previous test's already-stopped
fake, `stop()` closing an already-closed server, and both `rmSync` calls
aimed at already-deleted paths, while the directory this test had just
created leaked.

They are cleared at the top of `beforeEach` now, and the fake is
constructed before anything that can throw, so each test's teardown can
only ever see its own state. That rather than `string | undefined`, which
would have meant a non-null assertion at forty use sites for no extra
safety.

Proven rather than argued, with a throwaway probe of the same shape (since
removed). What the failing test's `afterEach` observes:

  with the reset      { home: "home-2", repo: "" }       guard skips
  without it          { home: "home-2", repo: "repo-1" }  stale, acts anyway

LOW -- when `stop()`'s 2s deadline won the race, the leak it was added to
prevent happened silently: the server stayed listening, which is the open
handle that keeps a vitest worker from exiting, and every later `afterEach`
also paid 2s. Trading a hang for a leak is still right, but it has to be
audible or it surfaces as a mysteriously slow file rather than a message.
It now reports which branch won, and clears the timer when `close()` wins.

LOW -- the new code-clause test assumed the real error's `code` matches
without asserting it, in a file where every other premise is asserted. Test
2 in that same file exists because a module runner rethrows this failure
under its own code, so the day that happens at the `realVmFailure()` level
this would have failed as a bare "expected false to be true" pointing at
the predicate rather than at the input that changed.

16 tests across the two files; full suite 1792 passed, the 3 failures being
the read-containment symlink tests that need Windows Developer Mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5bkc5oUL4SapwDE13UgHt
…t the predicate reads

Round-2 review of #649. Three low findings, all about comments claiming
more than the code does -- which is the defect this PR keeps being about.

The `if (home)` / `if (repo)` guards are inert, and the comment said they
were the mechanism. They are not: the same commit clears both to `""` at
the top of `beforeEach`, so neither is ever undefined by teardown, and
`rmSync("")` is a silent no-op on Node 26 anyway -- checked, alongside
`rmSync(undefined)`, which throws ERR_INVALID_ARG_TYPE. So the scenario the
comment named could not occur and deleting the guards would change nothing.

That mattered because the rationale was on the wrong half. A reader who
trusted it would remove the "redundant" reset, leaving a stale path in the
`rmSync` call, which no-ops under `force: true` while the directory the
failed `beforeEach` just created leaks -- with nothing red. The reset now
carries the reasoning; the guards say only what they are, which is a cheap
explicit statement of intent that keeps the reset from looking redundant.

`backend` is the same shape with the opposite asymmetry: it has no
sentinel, so it is only correct because it is constructed before anything
that can throw. Nothing said so, and a statement inserted above it would
silently leave the teardown asserting against the previous test's fake and
passing. That invariant is now written down where it can be violated.

And the assertion meant to prove the message clause cannot carry the
code-clause test read `reworded.message`, while the predicate reads
`String(err)` -- they differ by the error's name prefix. A fixture that
ever carried the phrase in its NAME would satisfy the predicate's message
branch while that assertion still passed, and the test would go green
without the code clause, reopening the exact gap it exists to close. It
delegates to `String(reworded)` now, the same expression the predicate uses.

Both clauses re-checked after the change and still load-bearing: dropping
either one fails a different test. 16 tests across the two files; full
suite 1792 passed with the 3 read-containment symlink failures that need
Windows Developer Mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5bkc5oUL4SapwDE13UgHt
…wn guard stops being inert

Review of #649 found this PR's own bug reproduced one level up, in the hook
that fixes it.

`afterEach` guarded its assertion with `if (backend !== undefined)`, and the
comment justified keeping it by naming the failure it catches: "a throw
introduced above the assignment ... the assertion would run against the
previous test's fake and pass." In that exact scenario the guard does nothing.
`backend` is declared `let backend: FakeBackend`, describe-scoped, and is only
ever assigned a fresh fake -- never reset. So a statement inserted above the
assignment that throws leaves `backend` holding the PREVIOUS test's fake,
`!== undefined` is true, and the assertion runs against the wrong object and
passes. The guard discriminates only on the very first test in the file.

Executed rather than reasoned about, in a five-test file mirroring the hook
structure, with test 2's `beforeEach` throwing above the assignment:

  OLD ran against fake-1, unknownPaths=[]
  OLD ran against fake-1, unknownPaths=[]     <- test 2, stale fake, passes
  NEW ran against fresh-1, unknownPaths=[]
  NEW skipped                                 <- test 2

The old guard asserts against `fake-1` twice. That is the silent pass, and it
also confirms vitest runs `afterEach` after a `beforeEach` throw, which the
whole guard depends on.

`home` and `repo` already carry a sentinel -- the `""` reset at the top of
`beforeEach` -- and `backend` was the one that did not. It gets the equivalent
now: `backendIsCurrent`, cleared first and set immediately after the
construction, so the window where the flag is false is exactly the window
where `backend` is stale. `backend` itself cannot use `undefined` as its
sentinel: it is read as a plain `FakeBackend` at ~35 call sites in the tests
below, and widening the type would put a narrowing burden on every one of
them for no gain in the hook.

The stop is guarded on the same flag, replacing `await backend?.stop()`. Two
comments went with it:

The optional chain was justified by "a `beforeEach` that throws in
`mkdtempSync` leaves it unassigned" -- false since `fde7e65` moved the
construction ABOVE both `mkdtempSync` calls, and contradicted by the comment
twenty lines up in the same hook. A reader trusting it would conclude the
construction order is not load-bearing and move it back down, reintroducing
the vacuous teardown with nothing red.

And `String.fromCharCode(10)` is now `"\n"`, matching the literal in
`fixture()` in this same file and 20+ writes in `ingest.ts`. It was an
artifact of how the line was written, not a constraint, and reading as one
sends the next person looking for a rule that does not exist.

16 tests in the two touched files, full suite 1792, typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5bkc5oUL4SapwDE13UgHt
…away from

Second review round on #649. Five findings, and the sharpest one is that the
sentinel I added last round is dormant: nothing between the reset and the
assignment can throw, so swapping the guard for a literal `true` leaves the
suite green. The commit message claiming it "stops being inert" was wrong and
this one says so.

What the flag buys is not coverage but correctness WHEN it fires, which is
where it differs from the `!== undefined` it replaced -- that one was dormant
AND could not have worked, because the stale fake is never undefined. The
comment now states the dormancy outright rather than implying a test covers
it.

The same finding named a hole that IS reachable: the flag was never cleared
on the way out, so it read `true` from the first test onward. A file- or
project-level `beforeEach` added later that throws before this describe's own
would find it set and assert against the stale fake -- the exact bug, through
the one door the flag did not cover. It is cleared at the end of `afterEach`
now, so it means "constructed for the test now running".

And a real leak, fixed rather than re-documented. `realpathSync(mkdtempSync())`
leaves `home` empty when the OUTER call throws -- the directory exists by then,
so the `if (home)` cleanup skips a tree already on disk. The assignment is
split, so the variable always names whatever was created:

  OLD  home-dirs before=24 after=25  LEAKED=1
  NEW  home-dirs before=25 after=25  LEAKED=0

That is the two forms with `realpathSync` forced to throw after a successful
`mkdtempSync`, which is the only way the failure is reachable. The guarded
`rmSync` comment had credited itself with preventing a leak that could not
happen, while this one sat two lines above it.

Two comments stated things that are not true. The stop guard said a throw in
`mkdtempSync` "leaves this test's own fake live and needing the stop" -- but
`start()` runs BELOW those calls, so the fake never listened and `stop()`
returns at its own `if (!this.server)`. And `FakeBackend.stop`'s timeout
message said it was "leaving it open": `close()` releases the listening handle
synchronously, so the port is free and what can still hold the worker is a
socket opened after `closeAllConnections()`. The branch is also effectively
unreachable -- that call destroys every socket first -- which the comment now
says instead of implying it is a live hazard.

Finally, `ingestion-loader.test.ts`'s premise assertion checked `String(code)`
where the predicate requires `typeof code === "string"` before `startsWith`.
A non-string that stringifies the same way -- the shape the NUMERIC-code test
exists for -- passed the premise and failed two lines later as a bare
`expected false to be true` against the predicate, which is the confusion the
premise assertion was added to prevent. It pins `typeof` too.

16 tests in the touched files; full suite 1791 with the 3 known Windows
symlink failures; typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5bkc5oUL4SapwDE13UgHt
@josephismikhail

Copy link
Copy Markdown
Contributor Author

The red check is a known flake, not this PR

Test (windows-2022 · node 22) failed on 4d8046b at parse-pool.test.ts:137b2.ts returning null in takes a worker that faults while IDLE out of the free list.

This PR does not touch parse-pool.test.ts. Its diff is limited to ingest-files.test.ts, ingestion-loader.test.ts and ingestion-loader.ts.

That test waits a flat 120ms for a worker 'error' event to be delivered before dispatching two parses; when the machine is busy the event arrives late and the second parse reaches an already-dying worker. Measured locally: 0 of 10 runs failed idle, 1 of 8 under saturating CPU load.

Fixed in #651, which waits on the pool's own signal that onError has run instead of on a duration. Once that merges this leg should go green with no change here.

…other

Third review round on #649. The medium is the same principle the outer
`finally` in this hook already argues for -- "the steps do not share fate" --
not applied to the inner one.

Three statements sat in that block: remove `home`, remove `repo`, clear
`backendIsCurrent`. `rmSync` with `force` suppresses ENOENT and nothing else,
so one EBUSY or EPERM on `home` -- a handle under `IX_LOCK_DIR`, which is
`home/locks`, or a `.git` handle, both ordinary on Windows -- threw and
skipped the other two. That leaks the `repo` tree this block exists to
remove, and leaves the flag `true`, reopening the exact door the reset was
added last round to close.

Measured, each arm with its own TEMP so the count is only what that run left:

  old shape (shared fate) + EBUSY on home    repo dirs leaked: 11, tests 11 FAILED
  new shape (independent) + EBUSY on home    repo dirs leaked:  0, tests 11 passed

The second column is the half I had not thought about. In the old shape the
teardown throw propagates and fails every test in the file, replacing
whatever they were actually asserting -- including the `unknownPaths` failure
this hook exists to report. So the shared fate did not merely leak; it
destroyed the diagnosis too.

`backendIsCurrent = false` now runs first, with nothing above it that can
throw, and each removal goes through a `removeTree` helper that catches per
directory and reports the leak on stderr rather than raising. Raising there
would put the teardown's problem in front of the test's.

Two smaller ones:

`FakeBackend.stop`'s timeout branch was justified by "a socket opened AFTER
`closeAllConnections()`". That cannot happen: `Promise.race` builds its array
eagerly, so `server.close()` runs in the same synchronous tick and the
listening handle is gone before any later turn of the loop. I invented that
scenario last round. The branch stays as a backstop against `close()` never
calling back at all, and now says so -- and stays a message rather than a
throw, because the failure it replaces is a hook that never settles, which
takes the hook timeout and runs NEITHER `finally`, leaking both trees and
leaving the env pointed at a deleted home. `closeAllConnections?.()` loses
its `?.`: `engines.node` is >=22 and the method landed in 18.2.

And an attribution: the comment said a throw "inside `mkdtempSync`" leaked
the directory the test had just created. A `mkdtempSync` throw creates
nothing. The leak that was reachable came from `realpathSync` throwing after
the directory existed, which is what the split assignment fixes -- the
mechanism was right and that one sentence pointed at the wrong call.

16 tests in the touched files; full suite 1792; typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5bkc5oUL4SapwDE13UgHt
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.

1 participant