Repository navigation
Conversation
Ethan-Arrowood
left a comment
There was a problem hiding this comment.
seems like the review picked up some major issues with this implementation.
🤖 Reviewed with Claude
| async function writePoolFile(pool: LoopbackPool): Promise<void> { | ||
| async function writePoolFile(pool: LoopbackPool, lockToken: string): Promise<void> { | ||
| await assertLockHeld(lockToken); | ||
| await writeFile(HARPER_LOOPBACK_POOL_PATH, JSON.stringify(pool)); |
There was a problem hiding this comment.
The fencing fix closes the "superseded writer clobbers the successor's claim" hole, but the pool file is still published by a truncating in-place write — which is the hole harperInstanceRegistry.writeRegistryFile was explicitly changed to avoid. Its comment at src/harperInstanceRegistry.ts:293-302 describes this exact failure.
Concrete scenario: a process is SIGKILLed between writeFile's truncate and its final write (the kill this whole stale-lock machinery exists to survive). The pool file is left as torn JSON, e.g. [null,null,nu. readPoolFile (src/loopbackAddressPool.ts:235-246) only special-cases ENOENT, so JSON.parse throws a SyntaxError that propagates out of withLock — it isn't a LockOwnershipLostError, so the retry loop rethrows it. Every subsequent getNextAvailableLoopbackAddress, releaseLoopbackAddress, and releaseAllLoopbackAddressesForCurrentProcess on that machine then fails permanently, with no self-healing and no "delete this file to reset" hint (contrast src/harperInstanceRegistry.ts:280). A narrower version of the same thing happens without a kill: a reclaimer that stole the lock as stale can readPoolFile while the previous holder's writeFile is mid-flight and observe a partial file.
Suggested fix — mirror the registry:
async function writePoolFile(pool: LoopbackPool, lockToken: string): Promise<void> {
await assertLockHeld(lockToken);
const pendingPath = `${HARPER_LOOPBACK_POOL_PATH}.${process.pid}.${++pendingWriteCounter}.${randomBytes(8).toString('hex')}.pending`;
try {
const pendingFileHandle = await open(pendingPath, 'wx');
try {
await pendingFileHandle.writeFile(JSON.stringify(pool));
} finally {
await pendingFileHandle.close();
}
await rename(pendingPath, HARPER_LOOPBACK_POOL_PATH);
} catch (error) {
await unlink(pendingPath).catch(() => {});
throw error;
}
}That also gives sweepStalePendingPoolFiles something real to clean up (a writer killed between open and rename leaks the pending file, which is the only way one can appear).
To confirm the current behavior without the fix: write [null,null,nu to ${tmpdir()}/harper-integration-test-loopback-pool.json and call getNextAvailableLoopbackAddress() — expected result today is an unhandled SyntaxError on every call until the file is deleted by hand. Worth adding as a regression test alongside the fix.
There was a problem hiding this comment.
Good catch, and the scenario is right. It is, though, the subject of fix: make loopback pool file writes atomic and reads torn-write tolerant, which is open and being reworked right now (its read side is moving to quarantine-then-reinitialize). Landing a second copy of the same writer here would put two diverging writePoolFile versions in flight, so this PR stays limited to lock ownership.
The merge obligation is in the PR description. Whichever PR lands second must:
- keep
assertLockHeldimmediately before therename, after the pending file closes - thread the lock token into fix: make loopback pool file writes atomic and reads torn-write tolerant #32's internal reinitialize-and-publish write
I'm leaving this thread open so you can confirm that split.
— Claude Opus 5.5
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Nothing on this branch writes `<pool>.<pid>.<n>.<hex>.pending` files: the pool is still written in place, and the pending-then-rename writer lives in the separate atomic-publication PR. The sweep was matching a filename no production code produces, verified only against hand-made fixtures, and its naming contract with that PR was untested. A reaper belongs with the writer that leaks the files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_integration-testing_33-33b5cb8c
A section whose lock is taken over as stale before it publishes now warns and reruns at most three times, then throws LockOwnershipLostError. A one-off stall past the 10s stale threshold still recovers transparently; repeated takeovers (fresh locks judged stale through clock or mtime skew) now fail loudly instead of wedging teardown with no output. Also documents that the ownership check narrows, but does not close, the check-then-write window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_integration-testing_33-33b5cb8c
The mocked createServer never emits `error`, so the conflict canary reports every port free regardless of these values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_integration-testing_33-33b5cb8c
A holder that stalls past the stale timeout between creating the lock and writing its token can find the path already holds a successor's lock, so unlinking by path on the write failure could delete it. The empty lock is now left for stale reclaim, as the instance-registry lock already does. Also documents release's check-then-unlink window and notes the new LockOwnershipLostError rejection in the README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_integration-testing_33-33b5cb8c
Also align the documented test command and allocator lock-loss error. Co-Authored-By: GPT-5 Codex <noreply@openai.com> Dispatch-Task: pr-maint-8c5461fc9b5574d777500f74f01ad393
Co-Authored-By: GPT-5 Codex <noreply@openai.com> Dispatch-Task: pr-maint-8c5461fc9b5574d777500f74f01ad393
7e33e57 to
738ee0e
Compare
|
You have some conflicts that need to be resolved. |
⊙ Problem
The loopback address pool is shared by test processes. If holder A reads the pool, its lock is reclaimed as stale, holder B publishes a claim, and A resumes, A can overwrite B's newer snapshot. The deterministic regression test exercises that interleave with isolated pool and lock files (test).
💡 Solution
Every lock acquisition writes a per-acquisition token. Pool publication checks that token immediately before writing, and release compares it before attempting to unlink. If a check observes that another process took over the lock, the whole callback is retried from a fresh pool read with a
[loopback-pool]warning, up to three retries; a fourth observed loss rejects withLockOwnershipLostError. The comparisons expose common takeovers but leave narrow races between check and write, read and unlink, and competing stale reclaimers' stat and unlink operations.⚖️ Alternatives
teardownHarpercan reject before removing the install directory.src/harperInstanceRegistry.ts. That extraction remains follow-up work; the two locks currently differ in fencing, poll interval, acquisition timeout, and publication method.🔧 Changes
Product and architecture tour
A stale holder retries from current pool state
The lock still uses age-based crash recovery after 10 seconds. A token lets a holder detect when it can see that its lock was replaced;
withLockthen reruns the callback, which reads the pool again. The checks are observations, so an ownership change can still race after a check.A superseded holder starts a fresh attempt
The ownership check catches the interleave covered by the regression test; it does not make the age-based reclaim operation atomic.
sequenceDiagram participant A as Stale holder participant L as Lock file participant B as New holder participant P as Pool file A->>L: acquire token A; read pool B->>L: reclaim stale lock; write token B B->>P: publish claim B A->>L: check token A L-->>A: token B A->>A: warn and retry A->>P: read current pool; publish a new attemptsrc/loopbackAddressPool.tsmints a PID, module counter, and random token on acquisition; after a token-write error it closes the handle but leaves the path for stale reclaim (failure path).releaseLockcompares the token before unlinking (release);assertLockHeldrejects a visible mismatch or missing file, andwritePoolFilecalls it before the pool write (check, publish).withLockwarns and retries a lost-ownership callback three times; allocation and both release paths pass the acquisition token through, and the allocator's JSDoc names the finalLockOwnershipLostErrorrejection (allocation, release paths, error contract).README.mddocuments the warning and rejection behavior after four consecutive takeovers.package.jsonenables Node's experimental module mocks for tests;CONTRIBUTING.mdandAGENTS.mddocument the test runner flag and its use.✅ Verification
The regression tests use fresh module instances and isolated pool files. They cover a writer superseded after reading, a replacement lock preserved during release, a successor lock preserved after token-write failure, and the three-retry cap; the socket probe is mocked with
node:netso the tests do not probe host ports on other platforms (interleave and socket fixture, release and failed-write cases, retry cap).npm run check,npm run build, andnpm testpassed on Node 26.2.0 after the fixture and documentation updates: 56 passed, 12 platform-specific skips, 0 failed.🤖 Generated by OpenAI Codex; posted via @kriszyp.
Related PRs: #32 overlaps (atomic publication and torn-write tolerance are adjacent; carry the ownership check immediately before its rename); #34 independent (installed CLI resolution shares README.md); #35 independent (restart cleanup shares CONTRIBUTING.md and README.md); #36 independent (log-tail behavior shares README.md); #40 independent (foreign-listener rejection is already in main; this PR fences pool writers)
Complexity: medium
Review-Coverage: authored=codex; ran=gemini,claude,cursor-composer,codex; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=9; full=5 @ 738ee0e
Review-Attention: read ~4m (decisions: do-less-alternative, lock-primitive, retry-policy) @ 738ee0e