Reject a Harper node where a test context is expected - #31
Open
kriszyp wants to merge 1 commit into
Open
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a safety guard to prevent users from accidentally passing a Harper node instead of the test context object to lifecycle functions like startHarper, killHarper, and teardownHarper. It brands published nodes with a non-enumerable Symbol and asserts on this brand to throw clear, actionable TypeErrors. The feedback suggests strengthening the type validation in assertHarperTestContext to explicitly reject arrays (which have a typeof of 'object') and adding a corresponding unit test to verify this behavior.
kriszyp
force-pushed
the
kris/teardown-shape-guard
branch
2 times, most recently
from
August 21, 2026 15:28
74d3b27 to
78274d6
Compare
`startHarper`, `setupHarperWithFixture`, `killHarper` and `teardownHarper` all take the test context and reach the instance through `ctx.harper`. Passing the node instead was silently accepted by every one of them: - the teardown pair returned early on the absent `ctx.harper`, exactly as they do for a context whose Harper was never started, so the call did nothing, said nothing, and the node kept running — holding its fixed ports, install directory and loopback slot — until the runner exited; - `startHarper(ctx.harper)` type-checks, because every field of `HarperTestContext` is optional. It read `dataRootDir`/`hostname` off the node (both undefined), so it created a *second* install directory, claimed a *second* loopback address, and wrote the new node to `node.harper`. The original `ctx.harper` still pointed at the old instance, so teardown recycled the first address while the second Harper ran on a leaked pool slot. A suite restarting per test exhausts the pool. The objects published as `ctx.harper` now carry a module-private, non-enumerable symbol, applied at the single point where a node becomes `ctx.harper`, and all four entry points reject an argument holding it. An array reaches the same no-op — `[ctxA, ctxB].harper` is undefined — so it is rejected too, with advice to tear each context down separately. An unbranded object with no `harper` is still a no-op, since a `before` hook that throws before `startHarper` legitimately leaves an empty context. A nullish or non-object argument gets a clear error instead of an unhelpful property access. Provenance rather than field-name sniffing: the check runs precisely when `ctx.harper` is falsy — the "before hook threw" path — so rejecting on a name like `httpURL`, `dataRootDir` or `process` would have buried a real startup failure under a bogus type error whenever a caller's own context happened to use one. No caller can own the symbol. The remedy in the message is per-direction. Telling a caller who reached a *start* function with a live node to wrap it would have them publish a fresh node over `ctx.harper` and abandon the instance already running, so those two say to pass the context the node came from instead. Known gap: a shallow copy of a node keeps its enumerable `process` but loses the brand, so passing a copy still no-ops. Closing it would mean rejecting any object holding a live `ChildProcess`, which is the false positive above. harper-pro's `decode-drop-recovery` stress test lost both of its nodes to the teardown case for three weeks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> BREAKING CHANGE: a call that passes the node where the context belongs now throws a TypeError instead of being silently accepted. Any suite doing that today passes while leaking a Harper instance, and will go red on upgrade — the point of the change, but it is a visible behavior change, so it is released as a major rather than slipped into a patch. Consumers on a `^0.7.x` range opt in deliberately.
kriszyp
force-pushed
the
kris/teardown-shape-guard
branch
from
August 21, 2026 15:35
78274d6 to
7fa6ee3
Compare
kriszyp
marked this pull request as ready for review
August 21, 2026 15:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
startHarper,setupHarperWithFixture,killHarperandteardownHarperall take the test context and reach the instance throughctx.harper. Passing the node instead was silently accepted by every one of them. The objects published asctx.harpernow carry a module-private, non-enumerable symbol, and all four entry points reject an argument holding it.The teardown pair returned early on the absent
ctx.harper— the same path a context whose Harper was never started takes — so the call did nothing, said nothing, and the node kept running, holding its ports, install directory and loopback slot until the runner exited.startHarper(ctx.harper)is worse and type-checks, because every field ofHarperTestContextis optional: it readdataRootDir/hostnameoff the node (both undefined), so it built a second install directory, claimed a second loopback address, and wrote the new node tonode.harperwhilectx.harperstill pointed at the old instance. A suite restarting per test exhausts the address pool.An array reaches the same no-op —
[ctxA, ctxB].harperis undefined — so it is rejected too, with advice to pass each context separately. An unbranded object with noharperis still a no-op, so teardown after abeforehook that threw stays safe.Found in harper-pro's
decode-drop-recoverystress test, which lost both of its nodes to the teardown case for three weeks — HarperFast/harper-pro#745 fixes that call site and is the only misuse anywhere in the workspace (I scanned harper, harper-pro and symphony).For the human reviewer
This is released as a major, deliberately. The commit carries a
BREAKING CHANGE:footer, so semantic-release cuts 1.0.0 rather than 0.7.2. A suite that passes a node today is green-but-leaky and goes red on upgrade — that is the entire point, but it is a visible behavior change and a patch would deliver it with nothing in the release notes. The side benefit is that^0.7.xconsumers (harper-pro included) opt in on their own schedule instead of being surprised, which also removes any ordering dependency on #745. Drop the footer if you would rather ship a patch; it is a one-line change.Provenance, not shape — so a copy still slips through. I first wrote this as a field-name sniff (
operationsAPIURL/httpURL/dataRootDir). Review talked me out of it: the check runs only whenctx.harperis falsy, which is exactly the "before hook threw" path, so a caller-ownedprocess,httpURLordataRootDirwould have stacked a bogus "you passed a node" error on top of the real startup failure. The brand has no false-positive class at all. Its cost is thatteardownHarper({ ...ctx.harper })keeps the enumerableprocess, loses the brand, and no-ops as before. Closing that would mean rejecting any object holding a liveChildProcess— reintroducing the false positive above. Same reason I did not add the symmetric assertion tosendOperation(which takes the node): requiring a brand can be wrong for a hand-built node pointed at an existing instance, whereas rejecting a brand never can.Throwing can turn a leak of one into a leak of the rest. A cleanup loop —
for (const c of [ctxA, node, ctxC]) await teardownHarper(c)— previously leaked onlynode; nownodethrows andctxCnever reaches its port wait, address release orrm(dataRootDir). Processes are still reaped by theprocess.once('exit')handler and the slot stays PID-parked, so the amplified part is disk plus a late kill, and it takes an already-misusing caller. The alternative is to recognize the branded node and tear it down anyway with a warning: no leak, but nothing fails, and a CI warning is easy to never read. I kept the throw because the silent no-op is the defect; this is the call I'd most like overruled if you disagree.Both publish sites rest on inspection; the funnel they share is tested directly. Every node reaches
ctx.harperthrough onepublishHarperNode, and a test asserts that function brands what it assigns (verified load-bearing: removing the brand there fails exactly that test). I originally pinned thesetupHarperWithFixturesite by calling it for real, but review was right that it dragged a Harper-free suite into claiming a machine-global loopback slot and depending onscripts/setup-loopback.sh— a guard test that could go red, or hang on a saturated pool, for reasons unrelated to the guard. So a refactor that bypassed the funnel at either site would keep CI green. Reaching those lines needs a real install and address allocation, which is a change to how this suite bootstraps.markHarperNodeis exported from the module but not fromindex.ts, following therunHarperCommandprecedent in AGENTS.md. It is still importable by deep path, and it is what lets the tests brand their own objects rather than going through the production sites — which is the root of point 4.Verification
Route: the repo's full gate plus a before/after behavioral probe. CI runs exactly
npm run check,npm run build,npm test; all three pass locally on Node 24.Behavioral proof — a full node shape (every field a started node has, plus a live child) passed unwrapped to
teardownHarper:7112556TypeError: teardownHarper(ctx) expects the test context…Mutation-tested the new coverage: removing the brand from the
setupHarperWithFixturepublish site fails exactly one test (the node published by setupHarperWithFixture is branded) and nothing else.Both remedy strings were read from a live run, because a generated one was actively harmful: the message is built per direction, so the start pair says "Pass the context the node came from" rather than "Wrap it" — advising a caller with a live node to wrap it would publish a fresh node over
ctx.harperand abandon the running instance. A test asserts the start pair never emitsWrap it:.A like-for-like fails-on-base run of the new tests is not constructible — they import
markHarperNode, which does not exist on base, so the file fails to load rather than reaching an assertion. The probe above is the behavioral equivalent. An earlier field-sniffing iteration of these tests did run clean on base: 8 failures, allMissing expected rejection, with both no-op control tests passing on base and on the branch.Dismissed after checking: a reported TS2367 blocker on
ctx === null. Neither tsconfig setsstrict/strictNullChecksand neither extends a base config, sonullis assignable and the comparison has overlap; both compilers CI invokes pass. The guard parameter is typedunknownregardless, so the runtime check and the type now agree.Not run: any real Harper start/stop cycle — this suite has no Harper to start. See point 4.
Complexity: medium
Review-Coverage: authored=claude; ran=gemini; adjudicated=domain; blocked=codex(exit-1); declined=cursor-grok,cursor-composer; rounds=13 @ 7fa6ee3
Human-Review-Need: 4 (decisions: brand-vs-duck-typing, throw-vs-warn, release-type, shape-only-scope, one-directional, test-only-exports) @ 7fa6ee3