core: stop a cancelled control call reaching the wrapped backend, and pin parkIfInFrame's unreachable double-claim arm (fixes #636, fixes #648) - #654
Conversation
…fixes #636) `SynchronousBackendAdapter::cancelPending` rejected the completions the adapter itself produced (morph#619) and did nothing about the work behind them. A task still queued on `_control` reached the head of the strand afterwards and made its blocking control call anyway -- a real `registerModelWithContext`/`registerModelShared`/`attachModel` on the wrapped backend -- and its `resolve` then found the state already rejected and did nothing. The caller was told the bind was cancelled while the registration went through: a live instance on a backend whose `Bridge` is gone, or which `switchBackend` has just replaced, that nothing will ever `deregisterModel` because no caller ever learned its id. Settling a promise cannot stop a task, so the check has to be inside the task. Each dispatch now allocates a `PendingControl` -- the promise plus an `atomic_bool cancelled` -- which the strand task reads (acquire) before calling `op()` and `cancelPending` sets (release) before rejecting. `_pending` tracks those records weakly, exactly as it tracked the bare promises, so the expiry-means-settled bookkeeping and the amortised compaction are unchanged. Only the queued-but-not-started window closes, which is all this adapter can close: it has no way to interrupt a blocking verb it does not implement, so a task already inside `op()` still completes. That limit is now written into the header and the spec as a limit rather than as a defect. The test is the point. Asserting that the completion was rejected passes on the pre-fix code and proves nothing, so the new case observes the *wrapped backend* instead: `GatedBackend::entered` counts entries to a control call, call 1 holds the strand open so call 2 is provably queued-and-not-started, and a third call dispatched after the cancellation acts as a FIFO probe -- its arrival proves call 2's task has already run and declined. Two entries, not three. Verified by mutation, both sections: with the `cancelled` check disabled the case fails on `CHECK(inner->entered.load() == 2)` with `3 == 2`. GCC 16.2.1, Debug, Linux: 1557 passed, 1 failed as expected (22955 assertions). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ee places that misdescribe it (fixes #648) The ticket asked whether the arm should be deleted or allowlisted. Neither: it is kept and it is now *tested*, which is strictly better than both -- deletion removes a caller-invariant safety net to satisfy a coverage gate, and an allowlist entry records "nothing reaches this" forever without ever detecting the day something does. What was measured, on this branch, GCC 16.2.1, Debug, Linux: * Replacing `return true` in the arm with `std::abort()` and running the whole suite (1556 cases, 22932 assertions) plus `morph_net_tests` (191 cases, 1112 assertions) fires it **zero** times. So the ticket's premise holds, and by direct reachability rather than by reading: `handoff.fired` is never true on entry, because all eight call sites are `.then`/`.onError` on one `Completion` and a `CompletionState` settles once. * Deleting the arm outright leaves the whole suite green, `DoubleFiringBackend` included (`completions == 1`). The ticket is therefore not wrong, and nothing in the tree would have noticed the deletion. That second measurement is the actual problem: dead defensive code whose removal no test detects. `parkIfInFrame` is a free function in `detail`, and the invariant that makes the arm unreachable belongs to every *current* caller, not to the function -- a ninth site that does not park one `Completion`'s outcome would need it again. So the arm stays and a new case calls `parkIfInFrame` directly, twice on one handoff, asserting the second claim returns `true` and leaves the first outcome intact. With the arm deleted that case, and only that case, fails (1556 passed, 1 failed). Three statements in the tree disagreed about this and one of them was simply false: * `tests/test_async_registration.cpp:2160` said the guard "must swallow the second, already-claimed callback". Stale since morph#571 -- what makes that test pass is `CompletionState`, which drops the second settle before any `Bridge` code sees it. Rewritten to say so, and to point at the new case. * `docs/spec/core/backend.md:638` said the guard is kept "because `parkIfInFrame` is also called from the dispatching frame". That is false: the dispatching frame calls `claimHandoff`/`awaitHandoff`, and `git grep` finds no other caller. Replaced with the measured reason. * `bridge.hpp`'s own comment said only that a backend gets one callback. It now carries the reachability argument and what it rests on. Decided separately, as the ticket asked: the `try`/`catch (...)` around the dispatch stays untouched. Unlike the double-claim arm it is not dead -- any out-of-tree `IBackend` override that throws out of `bindModel` reaches it, `IBackend` is a public extension point, and `ThrowingDispatchBackend` already exercises it in-tree. Covered defensive code, not dead code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…nd.hpp and bridge.hpp
Mechanical, no disposition changed. `scripts/check_mutation_survivors.py`
reported three and the shared resolver behind `check_branch_coverage.py`
reported three more.
Two of the six were reported as ambiguous rather than drifted, because the
cited text appears more than once. Both were resolved by reading the
candidates, not by taking the first:
* backend.hpp `registerCount` emission: 1087 (`LocalBackend::registerModel`,
which the entry's own reason names) vs 1106 (`registerModelShared`).
* bridge.hpp `if (deadlineHandle && schedulerRef)`: 1679 is the `catch` block
that decrements `_pendingCalls` and cancels the deadline before rethrowing,
which is what the entry says it covers; 1703 and 1792 are the
`.then()`/`.onError()` continuations the same entry explicitly excludes.
The third mutation-survivors entry (`executeInFlight`) was likewise ambiguous
between the `fetch_add` at 1251 and the `fetch_sub` at 1296; the reason says
"the increment side", so 1251.
The two prose line numbers inside the bridge.hpp:1679 entry's own reason are
refreshed with it (1686/1775 -> 1703/1792, and its cross-reference to the
sibling entry, 1540 -> 1557). Those are free text the gate does not audit,
which is exactly why they rot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner verificationThe spec statement really is false, and this is the finding I care most about. Master's
Every and the dispatching frame calls What makes it worth more than a typo fix: #648 quoted that same sentence as a candidate allowlist reason. Taking the allowlist route as the ticket wrote it would have copied a false statement into the file whose entire purpose is to hold reasons somebody checked. Correcting it inside the #648 commit is right — it is the same contradiction the ticket is about, not a separate finding. My brief asked for the wrong mutation test, and the lane explained why. I said: delete the guard and confirm And the conclusion it reached is better than either option the ticket offered. Deletion removes a caller-invariant safety net to satisfy a coverage gate; an allowlist entry records "nothing reaches this" permanently without detecting the day something does. Keeping the guard and pinning it with a direct-call test dominates both — and the lane demonstrated the gap it closes: with the arm deleted the whole suite stays green including Deciding the I verified #655 myself rather than relaying it: Reproduces. Worth noting in its favour that the empty-input message is honest — it says "touches no files" rather than claiming to have checked something — so the exposure is a broken upstream pipe rather than a lying gate. The lane labelled the severity half as weak, correctly. And it re-ran the gate with a real path list before resting the branch's claim on it, which is the part that matters here. Not verified by me: the abort instrumentation and the #636 before/after (both need a full build), and anything about coverage — Not merged: CI incomplete at hand-off. Next sweep counts the checks. 🤖 Generated with Claude Code |
The `clang-format` leg was red on 9 violations across the lines this branch
added:
tests/test_async_registration.cpp:2206:23 auto const first =
tests/test_async_registration.cpp:2211:62 parkIfInFrame(
tests/test_backend_registration_surface.cpp:773 TEST_CASE("… cancelPending …
tests/test_backend_registration_surface.cpp:800 adapter.promoteModel(…
(and four more)
Not a version skew: `ci.yml` pins `CLANG_VERSION: "22"` and the local binary is
clang-format 22.1.8, which reproduces all nine. Running it over the two files
leaves none.
Whitespace only. Verified by comparing both files before and after with all
whitespace stripped -- the token streams are identical, so no test, assertion
or string literal changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner: clang-format was red; fixed in
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Runner, sweep 06:41 — one failure diagnosed, one deliberately left alone
One parameter in the new
I am deliberately not pushing the clang-tidy fix yet. So the order is: let this run finish, read the Worth flagging for that diagnosis: this branch's #636 test is timing-shaped — it holds a strand open so a second call is provably queued-and-not-started — and the lane that wrote it said plainly that "TSan and the slower runners are where it would misbehave if it is going to." 🤖 Generated with Claude Code |
`clang-tidy-diff` was red on the one finding this branch introduced:
include/morph/core/backend.hpp:953:45: error: parameter name 'p' is too
short, expected at least 3 characters [readability-identifier-length]
Renamed `p` to `dispatched`, and the `@param` line with it -- the Docs
workflow runs Doxygen with WARN_AS_ERROR=FAIL_ON_WARNINGS, so a `@param` that
no longer names a parameter is its own failure.
The file's line count is unchanged, so no citation in
scripts/mutation_survivors.json or scripts/branch_partial_allowlist.json
moves; both resolvers still pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner: both failures resolved — and the ladder one was never this branch'sThe run completed, so the The failing step was This branch touches
Renamed On the sequencing: I held this one-line fix back last sweep rather than pushing it, because Fresh CI is running. If 🤖 Generated with Claude Code |
Two tickets on the core dispatch/cancellation tree. One behaviour fix, one
"this guard is dead, decide what to do about it" — resolved differently from
either option the ticket offered, with the reason measured rather than argued.
Branched from
0067b5bf. Three commits: one per ticket, one for the six linehints the edits moved.
#636 — a cancelled control call still registered on the wrapped backend
Landed.
SynchronousBackendAdapter::cancelPendingrejected the completionsthe adapter produced (morph#619) and did nothing about the work behind them. A
task still queued on
_controlreached the head of the strand afterwards andmade its blocking call anyway; its
resolvethen found the state rejected anddid nothing. The caller was told the bind was cancelled while the registration
went through — a live instance on a backend whose
Bridgeis gone, or whichswitchBackendjust replaced, that nothing will everderegisterModel.Settling a promise cannot stop a task, so the check is inside the task. Each
dispatch allocates a
PendingControl(the promise plus anatomic_bool cancelled); the strand task reads it (acquire) before callingop()andcancelPendingsets it (release) before rejecting._pendingtracks thoserecords weakly exactly as it tracked bare promises, so the
expiry-means-settled bookkeeping and the amortised compaction are unchanged.
Only the queued-but-not-started window closes. A task already inside
op()still completes — this adapter has no way to interrupt a blocking verb it does
not implement — and that is now recorded in the header and the spec as a limit
rather than as a defect.
The test, and why it is the hard part
A case asserting "the completion was rejected" passes on the pre-fix code and
proves nothing, which is the failure mode AGENTS.md names first. The new case
observes the wrapped backend instead:
GatedBackend::enteredcounts entries to a control call, not exits.provably queued-and-not-started rather than merely unobserved.
promises
cancelPendingsnapshotted, so it runs. The strand is FIFO, so itsarrival at the wrapped backend proves call 2's task has already run and
declined.
It fails before the fix. Disabling the
cancelledcheck and rebuilding,both sections fail:
#648 —
parkIfInFrame's double-claim guardLanded, but not as either option the ticket listed. The ticket offered
delete-or-allowlist. The guard is instead kept and tested, which dominates
both: deletion removes a caller-invariant safety net to satisfy a coverage
gate, and an allowlist entry records "nothing reaches this" forever without
ever detecting the day something does.
Measurements
Both on this branch, GCC 16.2.1, Debug, Linux.
1. The arm is unreached — measured directly, not inferred. Replacing its
return truewithstd::abort():Zero aborts.
handoff.firedis nevertrueon entry. This is stronger thanthe coverage run the ticket asked for: a coverage report shows an arm untaken,
whereas an
abort()shows the entry condition never holds. It matters herebecause the two are not the same — a second park while still in-frame would be
indistinguishable from the first in a deletion test, since both return
true.2. Deleting the arm leaves the suite green,
DoubleFiringBackendincluded(
completions == 1). So the ticket is not wrong. And that is exactly theproblem: dead defensive code whose removal nothing in the tree would notice.
What changed
parkIfInFrameis a free function indetail, and the invariant that makesthe arm unreachable belongs to every current caller, not to the function — a
ninth site that does not park one
Completion's outcome would need it again.So the arm stays, and a new case calls
parkIfInFramedirectly, twice on onehandoff, asserting the second claim returns
trueand leaves the firstoutcome intact. With the arm deleted, that case and only that case fails:
Three statements disagreed, and one was simply false
tests/test_async_registration.cpp:2160— "the guard must swallow thesecond, already-claimed callback". Stale since morph#571: what makes that
test pass is
CompletionState, which drops the second settle before anyBridgecode sees it. Rewritten to say so and to point at the new case.docs/spec/core/backend.md:638— the guard is kept "becauseparkIfInFrameis also called from the dispatching frame". This is false. The
dispatching frame calls
claimHandoff/awaitHandoff;git grep parkIfInFramefinds the definition, eight completion-callback call sites,and nothing else. Replaced with the measured reason. Worth flagging on its
own: the ticket quoted that sentence as a candidate allowlist reason, so
taking the allowlist route as written would have entered a false statement
into a file whose whole purpose is to hold checked reasons.
bridge.hpp's own comment said only that a backend gets one callback. Itnow carries the reachability argument and what it rests on.
The
catch (...)sibling — decided separately, left aloneThe ticket named the
try/catch (...)around the dispatch as the same classof finding. It is not. Unlike the double-claim arm it is not dead: any
out-of-tree
IBackendoverride that throws out ofbindModelreaches it,IBackendis a public extension point, andThrowingDispatchBackendalreadyexercises it in-tree. Deleting it would let an exception escape
execute()'sdocumented never-throws contract. Covered defensive code, not dead code — no
change, and the spec now says why.
Gate repoints (third commit)
Six line hints moved. Two were reported as ambiguous rather than drifted,
and both were resolved by reading the candidates rather than taking the first:
backend.hppregisterCount: 1087 (LocalBackend::registerModel, whichthe entry's own reason names) vs 1106 (
registerModelShared).bridge.hppif (deadlineHandle && schedulerRef): 1679 is thecatchblock that decrements
_pendingCallsand cancels the deadline beforerethrowing, which is what the entry says it covers; 1703 and 1792 are the
.then()/.onError()continuations the same entry explicitly excludes.The third (
executeInFlight) was ambiguous between thefetch_addat 1251and the
fetch_subat 1296; the reason says "the increment side". The twoprose line numbers inside the bridge entry's own reason are refreshed with it
(1686/1775 → 1703/1792, and its cross-reference 1540 → 1557) — free text the
gate does not audit, which is why it rots.
Review reasoning, inline
Done here rather than through
/code-review, which forks background agents.cancelledis release-stored bycancelPendingandacquire-loaded by the strand task. The flag alone would be fine with relaxed
(it gates only a branch on an atomic bool), but acquire/release means a task
that observes the cancellation also observes everything the cancelling
thread did first, which is what a caller reasoning about
~Bridgeorderingexpects. The promise itself is internally synchronised by
CompletionState.cancelPending: flag first, promise second. Reversed, atask could read a clear flag, run
op(), and then find its promise rejected— the pre-fix outcome, for a wider window than necessary.
few instructions before the store registers exactly as one already inside
op(). Stated in the header, the spec and the commit message, because thefix's value is the queued window and claiming more would be the thing this
repository keeps finding.
_pending's weak-expiry contract is preserved. The strand task holds theonly
shared_ptrto aPendingControl, as it held the only one to the barepromise, so entries still expire exactly when their task is destroyed and
the success path still erases nothing.
shared_ptr<PendingControl>and thewrapped backend's
shared_ptr, neverthis— unchanged from before.PendingControlis private;BindPromise(public,documented) keeps its meaning.
Verification summary
morph_tests(GCC 16.2.1, Debug, Linux)morph_net_tests-Weverythingbuild ofmorph_testsclang-tidy22.1.8 on both changed test TUsquantity.hppand untouchedbridge.hpp/backend.hpplines remain)check_mutation_survivors.pycheck_nolint_directives.shcheck_bidi_controls.pycheck_spec_citations.shcheck_spec_sync.sh(driven with the real path list, not empty stdin)branch_partial_allowlist.json/error_path_allowlist.jsonresolversNot verified
check_branch_coverage.pyneeds an lcov report and wasnot produced here; the allowlist entries were audited with its own
resolve_allowlist_source_line, which checks the citations but not whethera line is still reported partial.
sanitizer run. The
#636test is timing-shaped (it holds a strand open), soTSan and the slower runners are where it would misbehave if it is going to.
MORPH_BUILD_QT=OFF), so theabort()reachabilitymeasurement for core: parkIfInFrame's double-claim guard is unreachable from a backend now that every dispatch goes through one Completion #648 covers
morph_testsandmorph_net_testsonly.QtWebSocketBackendsettles inline on its!_connectedbranch, which is thecase
parkIfInFrameexists for — but a double settle there is stillblocked by
CompletionState, which is backend-independent.🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW