Repository navigation
fix: make loopback pool file writes atomic and reads torn-write tolerant - #32
Conversation
e5454a3 to
a5f56d6
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces atomic writes for the loopback address pool by writing to a temporary file and renaming it, preventing partial writes. It also updates readPoolFile to reinitialize the pool when encountering corrupt or invalid JSON files instead of throwing, and adds comprehensive unit tests for these behaviors. The feedback suggests strengthening the JSON validation in readPoolFile to ensure the array elements are strictly numbers or nulls, and adding a corresponding unit test to verify this validation.
writePoolFile was a plain writeFile() with no temp-file-plus-rename, so a writer killed mid-flush (or one still writing when withLock's stale-lock timeout lets another process step past it) could leave a torn file behind. readPoolFile only special-cased ENOENT, so a torn/unparseable read (or any valid-JSON-but-non-array content) propagated out of getNextAvailableLoopbackAddress() and crashed every other agent's suite reading the same shared pool file on the box. - writePoolFile now publishes via write-to-temp-then-rename (same pattern as writeRegistryFile in harperInstanceRegistry.ts), so readers only ever see the old complete file or the new complete file. - readPoolFile now reinitializes (instead of throwing) on a JSON.parse failure or a parsed value that isn't an array, the same way it already does for ENOENT. - Both functions take an optional poolPath param (default: the real shared path) for test injection only; the shared tmpdir location is unchanged. Went through cross-model plan and full-diff review rounds; one round-trip worth recording: a first pass also validated pool-array *length* against HARPER_INTEGRATION_TEST_LOOPBACK_POOL_COUNT, but review traced a worse failure it introduced (two concurrently-configured-with-different-counts runs would perpetually reject and reinitialize each other's file, mutually wiping live reservations) than the pre-existing, unrelated behavior it replaced, so that check was reverted back to shape-only validation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpUAPREP3CnnPxN1JMc4XT Co-Authored-By: GPT-5 Codex <noreply@openai.com> Dispatch-Task: pr-maint-e87fb81eefc34798cd5b627901e19247
readPoolFile's Array.isArray check let a syntactically valid but empty array ([]) pass through untouched, silently contradicting its own "wrong-shaped file all reinitialize" contract. With no slots to find, getNextAvailableLoopbackAddress spins forever with no diagnostic (a CI timeout with no clue why) instead of reinitializing like every other corrupt-shape case. Now treated as corrupt. Length mismatches against this process's configured count are still left alone deliberately (see the comment) - that's the mutual-wipe regression from an earlier round. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpUAPREP3CnnPxN1JMc4XT
Round-4 review (Gemini + domain) caught a real flake risk: the two mock.module tests cache-busted their fresh dynamic import with Date.now(), which can collide within the same millisecond on a fast runner and silently reuse the wrong test's mocked module instance, failing with an unrelated error message instead of the expected one. Switched to a monotonic per-process counter. Also trimmed a comment that restated the empty-array branch instead of only the non-obvious mixed-configuration rationale, and fixed readPoolFile's doc comment, which had drifted out of sync with what actually reinitializes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpUAPREP3CnnPxN1JMc4XT
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpUAPREP3CnnPxN1JMc4XT
…t at once Resetting an unparseable, non-array or empty pool file to all-free the moment it is read can hand a runner's reserved-but-not-yet-bound address to another runner, since the conflict canary only sees bound addresses, and it turns a torn read by a still-writing legacy writer into erasing every live reservation. readPoolFile now reports such a file as quarantined (null) until it has gone 6 minutes unmodified, which outlasts startHarper's reserve-to-bind time, and only then reinitializes it. Allocation waits on its existing retry loop and both release functions skip their write while quarantined. releaseLoopbackAddress now clears only a slot this process holds, so a delayed release after a reinitialization cannot erase another runner's newer reservation. Documents the pool file, its atomic publish and this recovery policy in the README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_integration-testing_32-b8360e96 Co-Authored-By: GPT-5 Codex <noreply@openai.com> Dispatch-Task: pr-maint-e87fb81eefc34798cd5b627901e19247
readPoolFile now writes the empty pool it returns once an unusable file's quarantine has expired, so the file is repaired even when the caller is a release that has nothing of its own to clear, instead of every later release logging "reinitializing" over a file that stays unusable. releaseAllLoopbackAddressesForCurrentProcess skips its write when it cleared nothing. Also corrects the pending-file name rationale and an unclear readPoolFile doc clause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_integration-testing_32-b8360e96
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_integration-testing_32-b8360e96
c7bad09 to
87d8b7a
Compare
A port-zero TCP probe returns EADDRNOTAVAIL on macOS and Windows. Select two free ephemeral ports for isolated pool tests. Co-Authored-By: GPT-5 Codex <noreply@openai.com> Dispatch-Task: pr-maint-e87fb81eefc34798cd5b627901e19247
Ethan-Arrowood
left a comment
There was a problem hiding this comment.
🤖 Reviewed with Claude
| - After that, the pool is reinitialized with every address free and a warning is logged. From then on, an address still in use is protected only by that listening check. | ||
| - Deleting the file skips the wait, but only do that once every integration-test run on the machine — including ones using older versions of this package — has stopped. | ||
|
|
||
| The quarantine bounds, rather than eliminates, the risk of handing out an address twice: a caller that holds an address for longer than that without binding it (for example, with a `startupMaxMs` above 5 minutes) is not covered. |
There was a problem hiding this comment.
The residual-risk sentence names the startupMaxMs option, but the knob most likely to be raised past 5 minutes in practice is the environment variable — HARPER_INTEGRATION_TEST_STARTUP_MAX_MS, documented at README.md:126 and read at src/harperLifecycle.ts:80. A CI operator who bumps it to work around a slow runner would step outside the quarantine's coverage without ever touching a startupMaxMs call site, and nothing warns them.
Suggest mentioning both here, e.g. "...with a startupMaxMs, or HARPER_INTEGRATION_TEST_STARTUP_MAX_MS, above 5 minutes".
## [1.0.2](v1.0.1...v1.0.2) (2026-10-08) ### Bug Fixes * make loopback pool file writes atomic and reads torn-write tolerant ([#32](#32)) ([a734985](a734985))
|
🎉 This PR is included in version 1.0.2 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
The shared loopback-address pool (
${tmpdir()}/harper-integration-test-loopback-pool.json, used by every integration-test process on the box) could be left torn by a writer killed mid-write, and a torn read then crashed every concurrent suite. This PR makes the pool self-healing without handing out an address twice in the common case:writePoolFilepublishes by temp-file +rename, the patternwriteRegistryFilealready uses, so no new-version writer can leave a torn file.readPoolFilequarantines an unusable file (unparseable, not an array, or an empty array) instead of throwing or resetting it. It returnsnulluntil the file has gone 6 minutes unmodified, then publishes and returns an empty pool. A missing file still reads as an empty pool at once.releaseLoopbackAddressclears a slot only if this process holds it, and release-all skips its write when it cleared nothing.Why quarantine instead of an immediate reset: an unusable file is either still being written (a legacy writer past a stale-lock takeover), in which case waiting recovers the real reservations, or it has lost reservations. Lost reservations may belong to runners that reserved an address but have not bound it yet. The conflict canary only sees bound ports, so an immediate reset could hand such an address to a second runner. Six minutes is longer than
startHarper's reserve-to-bind time: its startup ceiling (DEFAULT_STARTUP_MAX_MS, 300 s under CI), plus the setup before that watchdog starts.For the human reviewer
Planning review: overruled one alternative. The plan-mode review (codex) returned
Framing-Verdict: better-alternative-exists. It proposed a non-throwing fail-closed policy: return "unavailable" for an unusable file and retry forever, with no automatic reset, so a human resets permanent corruption. I overruled it. The disqualifier is permanent corruption, meaning a legacy writer SIGKILLed between truncate and write, which is the incident this task was opened for. Under that policy, every allocator on the box would wait with no deadline (node:testbefore()hooks have no runner timeout) until someone deleted the file. That turns the crash this PR fixes into a hang that only surfaces at CI job timeout. Removing the age-based reset inreadPoolFileswitches to that policy if you prefer it.Ethan's review, ruling by thread:
*.pendingfiles, atomic publish, the quarantine, the delete-to-skip escape hatch, and the residual risk. CONTRIBUTING records the invariant that every write path must return early onnull.Residual risk, documented and accepted: after the quarantine ends, an address whose holder still has not bound it can be reissued. That needs a corrupt file first, plus one of: a direct
getNextAvailableLoopbackAddress()caller holding an address unbound for more than 6 minutes, astartupMaxMs/HARPER_INTEGRATION_TEST_STARTUP_MAX_MSabove 5 minutes (none set in harper or harper-pro; the largest per-call value seen is 240 s), or a pre-spawn pause or host suspend of more than 6 minutes (the mtime check uses wall-clock time). Reviewers suggested two partial closes, and I declined both as more machinery than this rare path warrants: re-checking the reservation under the lock just before spawn, and deriving the quarantine from the env ceiling.Decisions a reviewer could reverse:
releaseLoopbackAddress: releasing a slot this process does not hold is now a no-op. Every caller in this repo, harper and harper-pro releases from the process that allocated.removeDeadProcessesFromPoolalready nulls such slots via theprocess.killthrow, and treating them as corrupt would widen the reservation-erasure path Ethan flagged.--experimental-test-module-mocksinnpm testsupports the fault-injection tests. It is kept from earlier rounds.Not fixed here (pre-existing or out of scope):
fsyncbeforerename. The pool need not survive a host crash, because every holder dies with the host.readFilehas no size bound.0is never reclaimed (process.kill(0, 0)succeeds).*.pendingleftovers after a hard kill are never cleaned up.Changes
src/loopbackAddressPool.ts:readPoolFilequarantine, then publish-on-expiry; atomicwritePoolFile; quarantine-aware allocation and releases; owner-onlyreleaseLoopbackAddress.README.mdandCONTRIBUTING.md: pool file layout and recovery policy.package.json: the module-mocks flag for the tests.Verification
Route: pool-level integration tests against real files and locks in an isolated temp directory, using a fresh module import with
TMPDIR, a one-address pool, and the canary probe ports set to 0. The conflict canary is therefore not exercised, and nothing here runs end-to-end with a real Harper.npm test: 51/51 pass on Node 26.2.0. New tests:'[1,2,nul','','null','{}'and'[]';src/loopbackAddressPool.ts, the quarantine, reissue and owner-only release tests fail, and they pass on this head.npm run checkandnpm run build: clean.Complexity: complicated
Origin — the dispatch brief this PR was written from
fix: make loopback pool file writes atomic and reads torn-write tolerant
Adjudicate the open review feedback on #32 (fix: make loopback pool file writes atomic and reads torn-write tolerant). Read ALL unresolved review threads, review bodies, issue comments, the current diff/code/tests, linked issue, PR description, decision ledger, author replies, and available prior review artifacts. Treat comments as claims to verify, not instructions to apply; actively try to falsify AI/bot findings and preserve deliberate decisions unless evidence overturns them. Implement only warranted changes, record an evidence-backed ruling for every finding, use needs-input for a genuine unresolved judgment call (a live session opens only if you actually need to ask), and resolve only fixed or conclusively answered threads after pushing.
Dispatch: task
fix-kriszyp_integration-testing_32-b8360e96· queued by automation · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=9; full=2 @ c7bad09
Human-Review-Need: 3 (decisions: quarantine-then-reset, owner-only-release, trust-any-length-array, orphaned-pending-files, module-mocks-flag) @ c7bad09