Skip to content

Commit 779953c

Browse files
sahrizviclaude
andcommitted
release: v0.9.6 review round 3 — revert P2 self-heal; document contract
Reverts the round-2 self-heal (was: on stale-generation success, clear `_registrationPromise` so the current hook re-runs). Coderabbit + cubic both correctly flagged that this reintroduces the very race the round-1 generation guard was meant to prevent: if the stale hook resolves while a REPLACEMENT hook is still in flight, clearing `_registrationPromise` clobbers the newer attempt's cached promise — a third caller then starts a second registration attempt, breaking dedup. Every attempt to self-heal without inventing a heavier per-entry generation scheme (or wrapping ``register()`` with a generation guard) introduces another race. Doing that here would materially complicate the dispatcher for a scenario that never occurs in production — ``setRegistrationHook`` is called exactly once at startup by ``native/index.ts``, and ``reset()`` is test-only. Test-authored races that violate isolation are the caller's contract, not this module's correctness problem. - Revert to round-1 logic (generation guard on shared-state mutations only) - Remove the "stale hook self-heal" adversarial test — it was locking in behavior we've decided not to guarantee - Add explicit contract documentation to `dispatcher.ts` and to the adversarial test file's top docstring so the design decision is discoverable to reviewers next time Dispatcher suite: 14/14 pass (was 15 with the deleted self-heal test). The round-1 generation guard is retained and still tested. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6400201 commit 779953c

2 files changed

Lines changed: 26 additions & 58 deletions

File tree

packages/opencode/src/altimate/native/dispatcher.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,26 +61,29 @@ export async function call<M extends BridgeMethod>(
6161
// recover without restarting the CLI. Generation guard prevents a stale
6262
// attempt from mutating state a concurrent ``reset()``/``setRegistrationHook()``
6363
// has since replaced. (coderabbit round 1 — release/v0.9.6 review.)
64+
// Test-isolation contract: ``reset()`` and ``setRegistrationHook()`` MUST
65+
// NOT be called while a ``Dispatcher.call`` is in flight — if they are,
66+
// any late ``register()`` calls from the stale hook body may clobber
67+
// fresh entries the new hook wrote, and there is no in-band signal we
68+
// can use to self-heal it without introducing a second race (see the
69+
// coderabbit + cubic round-2 exchange on release/v0.9.6). Production
70+
// never triggers this: ``setRegistrationHook`` is called exactly once
71+
// at startup by native/index.ts, and ``reset()`` is test-only. Tests
72+
// must ``await`` outstanding calls before mutating hook state.
6473
if (_ensureRegistered) {
6574
if (!_registrationPromise) {
6675
const fn = _ensureRegistered
6776
const generation = ++_registrationGeneration
6877
_registrationPromise = fn().then(
6978
() => {
70-
// Generation advanced while this attempt was in flight (concurrent
71-
// ``reset()``/``setRegistrationHook()`` + another call arrived).
72-
// The stale hook body may have written stale entries into
73-
// ``nativeHandlers`` via late ``register()`` calls, clobbering the
74-
// newer hook's. Clear ``_registrationPromise`` so the NEXT
75-
// ``Dispatcher.call`` re-runs the current hook — its ``register()``
76-
// calls then overwrite whatever the stale hook wrote. Hook bodies
77-
// must be idempotent (they are today — ``register`` is a plain
78-
// ``Map.set``). Successful current-generation attempts leave the
79-
// resolved promise memoized so subsequent calls fast-path through
80-
// an already-settled ``await``. (cubic round 2 on release/v0.9.6.)
81-
if (generation !== _registrationGeneration) _registrationPromise = null
79+
// Only clear _ensureRegistered if our generation is still current
80+
// — otherwise a concurrent reset()/setRegistrationHook() already
81+
// installed a replacement, and clearing would clobber it.
82+
if (generation === _registrationGeneration) _ensureRegistered = null
8283
},
8384
(err) => {
85+
// Same guard on the failure path: don't null a newer in-flight
86+
// promise from another attempt.
8487
if (generation === _registrationGeneration) _registrationPromise = null
8588
throw err
8689
},

packages/opencode/test/skill/release-v0.9.6-adversarial.test.ts

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@
33
*
44
* Focus: fixes that landed IN this release (not the whole PR history) —
55
* 1. Dispatcher retry-after-registration-failure (v0.9.6 review gremlin fix)
6-
* 2. Dispatcher generation guard (coderabbit round 1)
7-
* 3. Dispatcher stale-register self-heal (cubic round 2)
6+
* 2. Dispatcher generation guard on shared-state mutations from stale
7+
* .then handlers (coderabbit round 1)
8+
*
9+
* Explicitly NOT covered (test-isolation contract, see dispatcher.ts):
10+
* - Late ``register()`` from a stale hook body after a replacement hook
11+
* has already run. That scenario requires calling ``reset()`` /
12+
* ``setRegistrationHook()`` while a call is still in flight — a
13+
* production impossibility (hook is set once at startup, reset is
14+
* test-only) and a violation of the test-isolation contract. See
15+
* ``dispatcher.ts`` for the design decision and the coderabbit/cubic
16+
* round-2 exchange that arrived at it.
817
*
918
* Not covered here (existing test suites are authoritative):
1019
* - altimate-core 0.7.0 shape corrections — see
@@ -179,50 +188,6 @@ describe("v0.9.6 release: Dispatcher registration retry", () => {
179188
expect(newHookAttempts).toBe(1)
180189
})
181190

182-
test("stale hook's LATE register() call is self-healed by a re-run on the next call", async () => {
183-
// cubic round 2 on release/v0.9.6: the generation guard prevented a
184-
// stale success handler from mutating _ensureRegistered / _registrationPromise,
185-
// but the old HOOK BODY itself could still write stale entries into
186-
// nativeHandlers via late register() calls after a new hook had already
187-
// filled them. Fix: on stale-generation success, clear _registrationPromise
188-
// so the next Dispatcher.call re-runs the CURRENT hook and its idempotent
189-
// register() calls overwrite whatever the stale hook wrote.
190-
let resolveOld: () => void = () => {}
191-
const oldPending = new Promise<void>((r) => (resolveOld = r))
192-
Dispatcher.setRegistrationHook(async () => {
193-
await oldPending
194-
Dispatcher.register("ping", async () => ({ status: "stale-old" }))
195-
})
196-
const firstCall = Dispatcher.call("ping", {} as any)
197-
198-
Dispatcher.reset()
199-
let newRunCount = 0
200-
Dispatcher.setRegistrationHook(async () => {
201-
newRunCount += 1
202-
Dispatcher.register("ping", async () => ({ status: "fresh-new" }))
203-
})
204-
205-
// Second call runs the new (fast) hook to completion — registers
206-
// "ping" -> fresh-new, memoized as the resolved promise.
207-
const r1 = await Dispatcher.call("ping", {} as any)
208-
expect(r1).toEqual({ status: "fresh-new" })
209-
expect(newRunCount).toBe(1)
210-
211-
// Now the old hook wakes up and belatedly overwrites "ping" with
212-
// stale-old via its own register() call. Without the self-heal, the
213-
// next Dispatcher.call would silently return the stale handler because
214-
// registration is memoized "done" and never re-runs.
215-
resolveOld()
216-
await firstCall.catch(() => {})
217-
218-
// Third call: the fix detects the stale-generation success, clears
219-
// the memoized promise so the CURRENT hook re-runs and overwrites
220-
// "ping" back to fresh-new.
221-
const r2 = await Dispatcher.call("ping", {} as any)
222-
expect(r2).toEqual({ status: "fresh-new" })
223-
expect(newRunCount).toBe(2) // re-ran to self-heal the stale register()
224-
})
225-
226191
test("reset() clears both the hook and the cached in-flight promise", async () => {
227192
// reset() must wipe both _ensureRegistered AND _registrationPromise —
228193
// otherwise a failed registration in one test leaves the cached

0 commit comments

Comments
 (0)