net: put SocketBackend on the structural registration surface natively - #586
Merged
Merged
Conversation
Yaraslaut
added a commit
that referenced
this pull request
Sep 19, 2026
…'s insertions The unreachable `default:` case moved from line 615 to 813 once the native bindModel/promoteModel machinery was inserted above it; the coverage-object checker flagged the stale hint (text still matches, just the wrong line). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This was referenced Sep 19, 2026
`SocketBackend` overrode none of the four `*Async` verbs and reached
morph#567's `bindModel`/`promoteModel` through the defaults, which block.
It is now on the surface natively rather than through
`SynchronousBackendAdapter`, because the evidence says a wrapper would
re-block a transport that does not need to block:
- The I/O thread already demultiplexes replies by `callId` for
`execute`, and `RemoteServer` echoes `callId` on every control reply
(`makeOk(env.callId, ...)` for register / registerShared / attach /
assign), so a control call is the same shape as an execute. No
protocol change and no server change.
- The blocking path runs through `sendSync`, which admits exactly one
synchronous control call across the whole backend and throws
`"a synchronous call is already in flight (reentrant use)"` on a
second. A wrapper's strand would serialise binds against each other
but not against `listInstances` or a legacy `registerModel` on
another thread, so every bind would still take that token.
- The adapter's one reconnect-related property does not apply here:
`setReconnectHandler` is forwarded to the wrapped backend, and
`Bridge`'s handler calls the blocking verbs, so a wrapped
`SocketBackend` would run reconnect control calls exactly where it
runs them today.
`bindModel` sends the envelope its request's shape names with a non-zero
`callId` from the same counter `execute` uses, files the pending
`Completion<ModelId>` in a second `PendingCallTable`, and lets the read
loop settle it. No thread parks: a bind never enters `sendSync`, never
waits on `_syncCv`, and therefore can never wait on the one thread that
would satisfy it. `promoteModel` is the same path, echoing its request's
`mid` and keeping `assignPrimary`'s two local guards. `cancelPending`
now sweeps both tables, so a disconnect rejects an in-flight bind rather
than stranding it.
Every legacy verb is untouched and still uses `sendSync` with
`callId == 0`, so callers morph#570/#571 have yet to migrate see no
change at all.
The tests pin the property the decision rests on, not just the result: a
bind is accepted and settles while the synchronous channel is still
parked (a blocking bind fails there with the reentrant-use error), and
four binds are in flight at once and are matched by `callId` with the
replies delivered back to front.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
The spec had left two things open for this ticket, and both are now written down where the next reader will look. `SocketBackend` gets a "The structural registration surface, natively" section stating why it is not wrapped in `SynchronousBackendAdapter`, what stays the same (every legacy verb, and the wire for every legacy caller), and what the tests actually pin. The reconnect-handler question is answered in three parts rather than one, because the honest answer is not a single word. Through the adapter the hazard would be *untouched* — not relocated — because `setReconnectHandler` and the blocking verbs are both forwarded through unchanged, so a wrapped `SocketBackend` would run reconnect control calls exactly where it runs them today. Natively, a bind cannot have the hazard at all, since it parks nothing and so cannot wait on the thread that would satisfy it. But the hazard is still in the backend and the dedicated handler thread is still load-bearing, because the blocking verbs still park on `_syncCv` and `Bridge`'s reconnect handler still calls them — it becomes unreachable from that path only in morph#570. The adapter's own bullet is corrected to carry the proviso it was missing, and the section restates that none of this touches morph#486. Two stale statements fixed while here, both about the handler thread the argument above depends on: the API table and `setReconnectHandler`'s own doc comment said the reconnect handler runs on the I/O thread, which is precisely what the dedicated thread exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
clang-tidy's bugprone-empty-catch on the new reconnect-hazard test. The catch was there because *how* the parked `registerModel` ends is not what the test asserts -- but that is exactly the thing a reader needs when the test fails, so it is now recorded and checked rather than discarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
…'s insertions The unreachable `default:` case moved from line 615 to 813 once the native bindModel/promoteModel machinery was inserted above it; the coverage-object checker flagged the stale hint (text still matches, just the wrong line). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
force-pushed
the
net-569-socket-backend-structural-surface
branch
from
September 19, 2026 13:15
b639795 to
dd4e690
Compare
…stroyed after it TSan caught a real data race on CI (pthread_cond_destroy): five of this file's new TEST_CASEs declared `backend` before `callerExec`, so C++'s reverse local-destruction order tore down `callerExec` (destroying its condition variable) before `backend`'s destructor had joined the I/O thread -- which can still call post() -> _cv.notify_all() on that executor right up until the join completes. ~SocketBackend() joins _ioThread (and _handlerThread) before doing anything else, so swapping the declaration order is sufficient: `callerExec` now outlives `backend`, closing the window. A sixth TEST_CASE already had the safe order (backend nested inside a SECTION, callerExec in the enclosing scope) and needed no change. Verified: all 7 [registration-surface] tests and the full 189-case net suite still pass after reordering (Debug, no sanitizer -- the race is timing-dependent and wasn't expected to reproduce locally without TSan). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
marked this pull request as ready for review
September 19, 2026 15:00
This was referenced Sep 19, 2026
Merged
Yaraslaut
added a commit
that referenced
this pull request
Sep 19, 2026
…587) (#595) * net: carry contextKey on SocketBackend's private registration (fixes #587) `SocketBackend` left `IBackend::registerModelWithContext` unoverridden, so its default dropped `contextKey`, and the native `bindModel` path added in #586 dropped it again by omission at the `wire::makeRegister` call site. The consequence is stronger than "a log missing its entity key". `RemoteServer::attachLogIfConfigured` returns *without consulting its `LogProvider` at all* when the envelope's `contextKey` is empty, so an instance registered privately over `morph::net` was not journalled -- no audit record -- while the same registration over `SimulatedRemoteBackend` was. It failed open. `backend.hpp`'s own doc comment on the default already states the rule: backends whose instances live behind a wire protocol override this to carry the key across. `SocketBackend` is such a backend and did not. Both edges now do: - `bindModel`'s private branch passes `request.contextKey` to `makeRegister`; - `registerModelWithContext` is overridden, mirroring `SimulatedRemoteBackend::registerModelWithContext`, and `registerModel` forwards to it with an empty key. `registerModelShared` and `attachModel` degrade to `registerModelWithContext` when `primary` is empty, so their private paths are fixed with it. The shared and attach shapes already carried the key and are untouched, as is `registerModel`, which has no key to send. The obsolete comment at the `bindModel` call site explaining the drop is removed. Verification: reproduced end-to-end over a real socket, not inferred. The new test in `tests/net/test_socket_backend.cpp` stands up a `SocketServer` over a `RemoteServer` with a `LogProvider` installed and asserts the provider was consulted with the key -- and that the log it returns records the executed action under that `entityKey`. With the fix reverted it fails on exactly those assertions (`{ } == { "SbEchoModel:acct-587" }`, `0 == 1` entries, and `{ } == { "SbEchoModel:acct-blocking" }`); registration itself succeeded both before and after, which is why nothing asserts on that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH * coverage: refresh the socket_backend.hpp line hint the header edit shifted The `default:` disposition in `scripts/branch_partial_allowlist.json` pins a line number as a hint; the fix above moved that label from 813 to 826. The gate's own message says the disposition itself is still sound -- "The text still matches, so nothing is wrong with the disposition -- update the `line` hint" -- and the pinned `source` text (`default:`) is unchanged, so this is a hint refresh, not a new or widened suppression. No entry is added, removed or reworded. Verified: `sed -n '826p' include/morph/net/socket_backend.hpp` prints `default:`, and the file still parses as JSON. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH * docs: record that SocketBackend now carries contextKey on a private bind Three spots in docs/spec/core/backend.md stated, correctly before this branch and incorrectly after it, that `SimulatedRemoteBackend` is the only backend overriding `registerModelWithContext`. - The `IBackend` method table now names both wire backends, and says why the override is not cosmetic: `attachLogIfConfigured` skips the `LogProvider` lookup entirely on an empty key, so dropping it leaves the instance with no action log rather than a log missing a field. - `SocketBackend`'s API reference gains a `registerModelWithContext` row, `registerModel` becomes the empty-key forwarder it now is, and `bindModel`'s row records that every shape carries `request.contextKey`, the private one included. The empty-`primary` degrades of `registerModelShared`/`attachModel` are named where they land. - The design-decisions row for the permissive default says what the permissiveness costs, since it is what let this ship unnoticed. Scope held to what this branch establishes. Nothing here touches `bindModel`'s blocking/non-blocking question (morph#593) or `QtWebSocketBackend`, whose own drop is real, unchanged, and filed as morph#594 -- `:1215` still describes it accurately. Verified: `scripts/check_spec_sync.sh` over this branch's file list reports "Spec sync OK: 10 sub-domain(s) classified", and `scripts/check_spec_citations.sh` reports "Prose lint OK" with 837 references and 73 cited sections scanned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #569.
Update: driven to CI-green. Since the section below was written, this branch was rebased onto current master and two things were fixed to get here: a stale coverage-allowlist line hint in
socket_backend.hppthat this PR's own insertions had shifted, and a genuine ThreadSanitizer data race caught on CI — fiveTEST_CASEs intests/net/test_socket_backend.cppdeclared theirSocketBackendbefore theirMainThreadExecutor, so C++'s reverse local-destruction order tore the executor's condition variable down whileSocketBackend's still-running I/O thread could still callpost()on it.~SocketBackend()joins the I/O thread before returning, so reordering the declarations (executor before backend, so it outlives it) closes the window; details in the fix commit. Also fixed: #589, a pre-existing, unrelated masterclang-tsantimeout inequation()'s stack-safety test (fixed separately and merged, since it affected every PR based on master, not just this one). All 51 checks are green.Where I got to
Read
AGENTS.md,docs/spec/core/backend.md(the wholeIBackend/ structural-surface /morph::net/ design-decision material),docs/spec/concurrency_and_lifetimes.md's destruction-ordering rules,include/morph/core/backend.hpp(BindRequest/PromoteRequest/bindModel/promoteModel/SynchronousBackendAdapter),include/morph/core/completion.hpp,include/morph/core/detail/reply_router.hpp,include/morph/core/remote.hpp's reply construction,include/morph/core/bridge.hpp'sinstallReconnectHandler, and all ofinclude/morph/net/socket_backend.hppplustests/net/test_socket_backend.cpp.Decided first, then built. Implementation, tests and the spec update are done; everything below marked measured was run.
Measured locally (GCC 16.2.1, Debug,
-DMORPH_BUILD_NET=ON):morph_net_tests: 1094 assertions in 189 test cases, all passing.scripts/check_spec_sync.shfed the real diff: fails on the code commit alone, passes with the doc commit — so the gate is live on this change, not vacuous.scripts/check_spec_citations.sh,scripts/check_catch_test_names.sh: clean.clang-tidy-diff.pyoverorigin/master...HEADwith clang-tidy 22, per the CI recipe: clean (onebugprone-empty-catchfound and fixed in the last commit).Not run (this is the CI risk on the draft): the non-net test suites, the Qt legs,
tests/net_qt_interop, the sanitizer legs (ASan/UBSan/TSan — relevant, since #576 now instruments the net suites), the DoxygenWARN_AS_ERRORdocs build, and a clang-20 build (local clang is 22).The decision: (2), native — and the evidence that decided it
SocketBackendnow overridesbindModel/promoteModelitself. Three findings decided it, in descending order of weight:1. The adapter's one reconnect-related property does not apply to this backend. This is the load-bearing one, and it is a fact about the code rather than a preference.
SynchronousBackendAdapterforwardssetReconnectHandlerstraight to the wrapped backend (backend.hpp:1041), andBridge::installReconnectHandler(bridge.hpp:1910) re-registers through the blockingregisterModelShared/registerModelWithContext, which the adapter also forwards unchanged. So the spec's bullet "a control call issued from a reconnect handler runs on the strand" holds only for a handler that issues it throughbindModel, and no handler in the tree does. A wrappedSocketBackendwould run its reconnect control calls exactly where it runs them today. Choosing the wrapper would have meant adopting it for a benefit that is, here, vacuous.2. A wrapper would keep every bind inside
sendSync's one-call token.sendSyncadmits exactly one synchronous control call across the whole backend and throws"a synchronous call is already in flight (reentrant use)"on a second. The adapter's strand serialises binds against each other, butlistInstancesand the legacyregisterModelare not on that strand — andSocketBackendis explicitly documented as safe to drive from several threads at once, unlikeQtWebSocketBackend. The wrapper would therefore hide the restriction for binds while leaving the cross-path collision, and would park a thread per bind for a round trip. The native path takes no token at all.3. The machinery already exists, and the protocol already supports it. The I/O thread demultiplexes replies by
callIdforexecute, andRemoteServerechoescallIdon every control reply —makeOk(env.callId, {}, mid.v)atremote.hpp:751(registerShared),:846(attach),:1144(register),:1175(assign). So a native control call is the same shape as an execute: a secondPendingCallTablesharing_pending's counter, and a branch in the reply router. No protocol change, no server change, no new thread, and interop withQtWebSocketServeris untouched because both sides go through the sameRemoteServer::handle.Against native: it is more code in a transport than a wrapper would have been. That is real, and it is the whole of the case for (1) once finding 1 removes the other one. It did not outweigh three properties that are all about correctness rather than effort.
Every legacy verb is untouched and still uses
sendSyncwithcallId == 0, so callers #570/#571 have yet to migrate see no behaviour change and no wire change.The reconnect-handler deadlock hazard — the explicit answer
The hazard: a control call parks on
_syncCvawaiting a reply only the I/O thread's read loop can deliver, so running one on that thread blocks the thread that would satisfy it.SocketBackendprevents it today by invoking reconnect handlers on a dedicated_handlerThread(onConnected()).Three parts, because one word would be wrong:
setReconnectHandlernor the blocking verbs would have gone near the strand.bindModelnever enterssendSync, never waits on_syncCv, and returns before its reply exists, so there is no wait for any thread to block — including the I/O thread itself. That is structural: it follows from the signature returning aCompletionrather than aModelId, and it holds whichever thread issues the call._handlerThreadis still load-bearing. The blocking verbs still park on_syncCvandBridge's reconnect handler still calls them. A hazard-free route now exists; the hazardous one has not been removed or moved. It becomes unreachable from the reconnect path only when ladder: move the example GUIs and the WASM spike onto the structural registration surface #570 putsinstallReconnectHandleronbindModel— and only then is dropping the handler thread a question worth asking. I did not drop it, and nothing here should be read as saying it can be.None of this touches morph#486: no lock is added and nothing here knows about a caller's teardown. #567 moved the choice of delivery thread from the implementor to the caller, and this change does exactly that much for this backend and no more.
The test, and the mutation proving it is not vacuous
The claim above rests on "a native control call never parks", so the test pins that, not just the functional result.
SocketBackend: a bind settles while the synchronous control channel is still parked: a legacyregisterModelis parked on_syncCvagainst aFakeWsServerthat deliberately withholds itscallId == 0reply; with that token held, abindModelis issued, reaches the wire with a non-zerocallId, is answered alone, and settles — while the synchronous call is still parked (CHECK_FALSE(syncReturned.load())).Mutation — rename
SocketBackend::bindModelso the blocking default is used instead, rebuild, run:A second test,
several binds are in flight at once and are matched by callId with replies out of order, fails by hanging under the same mutation (the first bind would block for a reply sent only after all four are issued) — the same failure convention the existinga reconnect handler that re-registers does not deadlock the transporttest uses, which ctest's per-testTIMEOUT 120turns into a failure.Also added: the
BindRequestshape table over a realSocketServerpluspromoteModeland its two local guards; a delivery-thread test (the continuation does not run until the caller'sMainThreadExecutoris pumped, and then runs on the pumping thread); disconnect rejection both before and during flight; the server'serrmessage surfacing with the same"<verb> failed: ..."wording the blocking verbs use; and a reconnect handler re-binding through the new surface without waiting — that last one is explicitly commented as a parity test, not evidence about the hazard, since the handler thread makes it pass either way.Gaps in #567's surface that #568 / #570 / #571 should know about
None that needs a change to
core/backend.hpp— the surface was sufficient as-is, and I did not touch that header. Three things worth carrying forward:bindModel. I corrected that bullet in the spec. This matters most to ladder: move the example GUIs and the WASM spike onto the structural registration surface #570: movingBridge::installReconnectHandlerontobindModelis what actually makes the property true anywhere.cancelPendingis a second obligation the surface does not name. A backend overridingbindModelnatively must also sweep its new pending state incancelPending, or an in-flight bind hangs forever on a disconnect/teardown instead of rejecting. Nothing inIBackend's docs says so. qt: move QtWebSocketBackend onto the structural registration surface and delete the WASM special case #568 needs the same forQtWebSocketBackend.promoteModel's "resolve, do not reject" no-op cases are a server-side property, not a client-side one. For this transport they hold becauseRemoteServeranswersassignwithokin those cases; a backend whose peer answerserrwould have to translate. Worth a sentence inIBackend's docs if core: remove IBackend's four async twins and retire the prose threading contract #571 is editing there anyway.Filed / to file
One finding, not folded in per AGENTS.md and not yet filed (the wind-down came first):
SocketBackenddropscontextKeyon a private registration. It does not overrideregisterModelWithContext, so the default forwards toregisterModeland discards it — whilewire::makeRegisteraccepts acontextKeyandSimulatedRemoteBackenddoes override the verb to carry it across. The consequence is that a server-side action log has no entity key for instances registered privately overmorph::net. Status: inferred from reading the code, not reproduced. I deliberately preserved the drop inbindModelso the native path stays bit-for-bit identical to the blocking one, with a comment saying why.Two stale statements corrected in passing, because the argument above depends on them: the
SocketBackendAPI table andsetReconnectHandler's own doc comment both said the reconnect handler runs on the I/O thread, which is precisely what the dedicated thread exists to prevent.Not done — ordered
masterfirst (Give Completion<T> a value-handling contract, and stop a benchmark racing on a dead frame #579 oncompletion.hppand qt: move QtWebSocketBackend onto the structural registration surface and delete the WASM special case #568 on the Qt backend are both in flight; qt: move QtWebSocketBackend onto the structural registration surface and delete the WASM special case #568 edits the same spec file, so expect a conflict indocs/spec/core/backend.md).PendingCallTableand the reply-router branch.WARN_AS_ERRORbuild accepts the new public overloads'@param/@returnblocks.tests/net_qt_interopstill passes (not built locally — no Qt in this environment).contextKeyissue above.Refs #569toCloses #569.Traps
scripts/check_catch_test_names.shpasses anyway (it escapes), so nothing catches it — renamed.REQUIREwith a livestd::threadin scope turns a readable assertion failure intoterminate()+ SIGABRT. The decisive test now releases and joins the parked thread before the failing assertion, which is the difference between the mutation output quoted above and a core dump.scripts/check_spec_sync.shrun bare reports "the change touches no files" — it reads a path list on stdin. It has to be fedgit diff --name-only <base> HEADto mean anything, which is exactly the "a control that reports success while measuring nothing" shapeAGENTS.mdwarns about.🤖 Generated with Claude Code
https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6