mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
fix: singleflight follower retry on leader disconnect + quiet disconnect handling (review M1/L1/L2)
Addresses the independent reviewer's APPROVE-WITH-CHANGES findings on PR #149: M1 (MEDIUM, F2 regression) — when a singleflight LEADER disconnected while queued, its RequestDisconnectedError rejected the SHARED promise, so live followers fell into respondUpstreamError's generic branch and got a spurious 500 on a healthy socket. Fix: keys.mjs singleflight() gains an optional follower-side `retryIf` predicate. When a follower joins an existing flight and the shared promise rejects with an error retryIf() accepts, the follower does NOT inherit the rejection — it re-enters singleflight with its OWN fn (the map entry is guaranteed already deleted: the delete-finally is attached upstream of the promise followers await), becoming the new leader or joining a retrying sibling's fresh flight. The leader's own rejection is never retried (it IS that client's disconnect). server.mjs passes retryIf = (err) => err instanceof RequestDisconnectedError && !res.destroyed, so a follower whose own client is also gone still propagates quietly. Callers without retryIf keep byte-for-byte pre-existing share-everything semantics (pinned by the existing failure-fan-out test). L1 (LOW) — a disconnect-while-queued on the non-streaming paths was recorded as a usage FAILURE row and logged as a [proxy] error: metric noise for a non-error. Both non-streaming catch blocks now early-return on RequestDisconnectedError without recordUsage(success:false) and without console.error — mirroring the streaming path, which returns silently. The disconnect remains observable at info level (concurrency_wait_cancelled, now also emitted with path:"tui" from callClaudeTui for parity with acquireClaudeSlot's -p log). L2 (LOW, test gap) — added a unit test for the abort-after-grant race: a waiter granted its slot whose signal aborts afterward must see no rejection, no queue corruption, and its slot released exactly once via the normal path (the semaphore detaches the abort listener at grant; the onAbort idx===-1 guard is the in-dispatch backstop). Tests: +3 (2× M1 in the singleflight section, 1× L2 in the F2 section) — suite is now 247/247 green. ALIGNMENT.md: unchanged declaration — still local concurrency/dedup infrastructure with no cli.js wire analogue; no endpoint, header, request field, or response field added or changed; /health shape untouched. cli.js citation declared ABSENT per CLAUDE.md hard-requirement #1 (not a Class A mirror change, not a Class B contract change). Verification: node --check server.mjs lib/tui/semaphore.mjs keys.mjs && npm test — 247 passed, 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -382,11 +382,25 @@ export function getCacheStats() {
|
||||
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
||||
const inflightMap = new Map();
|
||||
|
||||
export function singleflight(hash, fn) {
|
||||
// `retryIf` (optional, audit finding M1): a predicate applied on the FOLLOWER path only.
|
||||
// When a follower joins an existing flight and the shared promise rejects with an error for
|
||||
// which retryIf(err) is true (in practice: the LEADER's client disconnected while queued —
|
||||
// an error that is personal to the leader, not a verdict about the upstream), the follower
|
||||
// does NOT inherit that rejection. Instead it re-enters singleflight with its OWN fn: it
|
||||
// either becomes the new leader (the map entry is already deleted — see the finally below,
|
||||
// which runs before any follower's catch because it is attached upstream of the promise the
|
||||
// followers await) or joins a flight another retrying follower just created. The leader's
|
||||
// own rejection is never retried here — its error belongs to it (leader path returns the
|
||||
// bare promise). Callers that pass no retryIf get the exact pre-M1 share-everything behavior.
|
||||
export function singleflight(hash, fn, retryIf) {
|
||||
const existing = inflightMap.get(hash);
|
||||
if (existing) {
|
||||
existing.requesters++;
|
||||
return existing.promise;
|
||||
if (!retryIf) return existing.promise;
|
||||
return existing.promise.catch((err) => {
|
||||
if (!retryIf(err)) throw err;
|
||||
return singleflight(hash, fn, retryIf);
|
||||
});
|
||||
}
|
||||
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
||||
const promise = Promise.resolve().then(fn).finally(() => {
|
||||
|
||||
Reference in New Issue
Block a user