Skip to content

fix: make loopback pool file writes atomic and reads torn-write tolerant - #32

Merged
Ethan-Arrowood merged 8 commits into
mainfrom
fix/loopback-pool-atomicity
Oct 8, 2026
Merged

Ethan-Arrowood merged 8 commits into
mainfrom
fix/loopback-pool-atomicity

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

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:

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:test before() 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 in readPoolFile switches to that policy if you prefer it.

Ethan's review, ruling by thread:

  • "Preserve live reservations or fail closed" — the trace holds, and the quarantine is the fix. Ethan's suggested check is now a test: A reserves, the file is torn, and B's allocation stays pending across retries. Releases during the quarantine leave the torn file byte-identical, and B gets the address only after the file has aged past the quarantine.
  • Document the recovery behavior — README → Loopback Pool Utilities now covers the pool, lock and *.pending files, 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 on null.

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, a startupMaxMs / HARPER_INTEGRATION_TEST_STARTUP_MAX_MS above 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:

  • Owner-only release is a semantic change to the exported 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.
  • Any non-empty array of another length is trusted. This is kept from an earlier round: validating length against this process's configured count made two runs with different counts wipe each other's reservations.
  • Element types are not validated (Gemini's thread). Valid JSON with wrong-type slots is not a torn-write shape. removeDeadProcessesFromPool already nulls such slots via the process.kill throw, and treating them as corrupt would widen the reservation-erasure path Ethan flagged.
  • --experimental-test-module-mocks in npm test supports the fault-injection tests. It is kept from earlier rounds.

Not fixed here (pre-existing or out of scope):

  • The stale-lock takeover has no fencing; PR #33 handles that.
  • There is no fsync before rename. The pool need not survive a host crash, because every holder dies with the host.
  • readFile has no size bound.
  • A slot holding 0 is never reclaimed (process.kill(0, 0) succeeds).
  • Inert *.pending leftovers after a hard kill are never cleaned up.

Changes

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:
    • quarantine then publish-on-expiry for '[1,2,nul', '', 'null', '{}' and '[]';
    • a file deleted between read and stat reads as missing;
    • a lost reservation is not reissued until the quarantine ends, releases leave a quarantined file untouched, and the "unusable" warning is logged once;
    • owner-only release.
  • Fails-on-base check: run against the previous head's src/loopbackAddressPool.ts, the quarantine, reissue and owner-only release tests fail, and they pass on this head.
  • npm run check and npm 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-1

Review-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

@kriszyp
kriszyp force-pushed the fix/loopback-pool-atomicity branch from e5454a3 to a5f56d6 Compare September 15, 2026 15:39

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/loopbackAddressPool.ts Outdated
Comment thread test/loopbackAddressPool.test.ts Outdated

@Ethan-Arrowood Ethan-Arrowood left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed with Codex

Comment thread src/loopbackAddressPool.ts Outdated
Comment thread src/loopbackAddressPool.ts Outdated
kriszyp and others added 7 commits October 6, 2026 12:30
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
…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
@kriszyp
kriszyp force-pushed the fix/loopback-pool-atomicity branch from c7bad09 to 87d8b7a Compare October 6, 2026 19:01
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 Ethan-Arrowood left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed with Claude

Comment thread README.md
- 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

@Ethan-Arrowood
Ethan-Arrowood merged commit a734985 into main Oct 8, 2026
10 checks passed
@Ethan-Arrowood
Ethan-Arrowood deleted the fix/loopback-pool-atomicity branch October 8, 2026 20:14
github-actions Bot pushed a commit that referenced this pull request Oct 8, 2026
## [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))
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.0.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants