Skip to content

qt: move QtWebSocketBackend onto the structural registration surface - #585

Merged
Yaraslaut merged 11 commits into
masterfrom
fix-568-qt-structural-registration
Sep 20, 2026
Merged

Yaraslaut merged 11 commits into
masterfrom
fix-568-qt-structural-registration

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Sep 19, 2026

Copy link
Copy Markdown
Member

Closes #568, fixes #593, fixes #594. Step 2 of the five-step set re-scoped out of #522.

Status: unblocked. The regression this PR previously carried is fixed inside
it, because the fix is a change to the surface #568 moved onto and belongs
nowhere else.

Three commits, one per ticket:

Commit Ticket What it does
qt: move QtWebSocketBackend onto the structural surface, natively (+ the three that go with it) #568 Unchanged from this PR's earlier revisions — see Done below.
core: let a backend say whether its caller may wait for a bind #593 Adds IBackend::bindWaitPolicy(), the one signal #567's surface left Bridge::registerHandlerImpl without. Fixes the "handler not bound" regression this PR caused in morph::net::SocketBackend.
qt: carry contextKey on a blocking private registration #594 QtWebSocketBackend now overrides registerModelWithContext, so a privately registered instance is journalled. Reproduced first, then fixed.

Rebased onto a020e69c, not 70792bee. The previous red was judged against
a base that has since moved on three counts that matter here: the gates changed
(.github/workflows/ci.yml, cmake/CompileCache.cmake), files this PR touches
changed (include/morph/core/backend.hpp,
scripts/branch_partial_allowlist.json, docs/spec/core/backend.md), and the
failing test's own file changed (tests/net/test_socket_backend.cpp,
include/morph/net/socket_backend.hpp). Everything measured below was measured
on the rebased tree. One conflict, in docs/spec/core/backend.md's IBackend
table: master's registerModelWithContext row (#587's, which names
SocketBackend) and this branch's registerModelAsync row (which records that
no backend overrides it since #568) — both kept, neither is about the other.

Three things fixed along the way, earlier in this PR's history: two
misc-const-correctness clang-tidy findings on the new test file (const QUrl); a stale coverage-allowlist line hint in bridge.hpp (repointed and
disambiguated — it is one of three textually-identical if (deadlineHandle && schedulerRef) guards, and the entry now names which one it covers and why the
other two are a different, reachable arm); and #589, a pre-existing and
unrelated master clang-tsan timeout in equation()'s stack-safety test, fixed
separately and merged rather than folded in here.

Three things were fixed along the way: two misc-const-correctness clang-tidy
findings on the new test file (const QUrl); a stale coverage-allowlist line
hint in bridge.hpp that master's insertions had shifted (repointed and
disambiguated — it is one of three textually-identical if (deadlineHandle && schedulerRef) guards, and the entry now names which one it covers and why the
other two are a different, reachable arm); and #589, a pre-existing and
unrelated master clang-tsan timeout in equation()'s stack-safety test, which
was fixed separately and merged rather than folded in here.

Reconciliation with #586 (SocketBackend, morph#569)

#586 landed on master first and edits the same spec, so docs/spec/core/backend.md
was reconciled, not resolved by taking a side. Both descriptions survive
independently in the merged file:

Evidence in the merged docs/spec/core/backend.md From
SocketBackend ×119, bindModel ×55 #586
QtWebSocketBackend ×61, inlineExecutor ×3 #585
Row: "morph#568 — QtWebSocketBackend implements the surface natively and drops all four *Async overrides" #585
Row: "morph#569 — SocketBackend implements the surface natively, keeping every legacy verb on sendSync" #586
Line 2283, SocketBackend's full "native rather than SynchronousBackendAdapter" rationale, intact #586

The backend/binding table at lines 564-566 now lists all three cases together
(QtWebSocketBackend with the flag unset → blocking/bound, with it set →
native/unbound, SocketBackend → native/unbound). No conflict markers remain
anywhere in the tree.

Everything below is written for someone picking this up cold.

State

It builds and the full local suite passes. Measured on the rebased tree at
the exact commit pushed (49bd5b8b, working tree clean — what was built is what
was pushed), GCC 16.2.1, Debug, -DMORPH_BUILD_QT=ON -DMORPH_BUILD_NET=ON:

Both MORPH_BUILD_QT and MORPH_BUILD_NET are ON, and that is the point.
The earlier 1613-pass run in this PR's history had MORPH_BUILD_NET=OFF, so
tests/net/ was never built, which is exactly how the regression was missed the
first time.

Verified against the allowlist repoint, not assumed. The #593 commit's
insertions shifted bridge.hpp again: 1498 → 1542
(if (_executeDeadline.count() > 0 && _timeoutScheduler) {, still the only
occurrence of that text) and 1610 → 1654 (the catch-block
if (deadlineHandle && schedulerRef) { the disposition's own reasoning
describes, with the other two now at 1678 and 1767, and the entry's prose
updated to say so).

Not run locally, so inferred from CI rather than measured here: the sanitizer
builds (ASan/UBSan/TSan), Doxygen, the WASM ladder/demo legs, and Windows.
tests/qt/ is sanitizer-instrumented as of #576.

#593 — the blocking regression, and its fix

What it was

registerHandlerImpl probes registerModelAsync first; with #568's overrides
deleted it returns false, so control reaches this PR's bindModel fallback.
SocketBackend::bindModel is non-blocking (#586), so registerHandlerImpl
returned with binding->currentId == 0, and the next line's execute hit the
fail-fast guard:

if (raw == 0U) {
    typedState->setException(std::make_exception_ptr(std::runtime_error("handler not bound")));
    return typed;
}

Reproduced on the rebased tree before the fix — 99% tests passed, 6 tests failed out of 1833, the same six #593 names:

	1660 - SocketBackend: action result delivered via then (Failed)
	1676 - SocketBackend: executeTimeout surfaces as backend::TimeoutError, not a generic runtime_error (Failed)
	1681 - SocketBackend: many concurrent in-flight executes all resolve, matched by callId (Failed)
	1689 - SocketBackend: server dropping mid-call resolves the pending completion with DisconnectedError (Failed)
	1692 - SocketBackend: two backends share one server with isolated model state (Failed)
	1833 - SocketBackend interop: connects to a QtWebSocketServer and completes an action (Failed)

The 1-versus-6 discrepancy, resolved rather than assumed

#593's body reports six failing tests; CI reported one, on every failing leg.
Both are right. Every test preset in CMakePresets.json sets
stopOnFailure, and ci.yml drives every leg through ctest --preset …, so a
leg with six failures stops at the first and reports it. The one failing leg
that does not use a preset — Valgrind memcheck — reported five, read from
its log rather than from this PR's earlier summary of it:

tests/net/test_socket_backend.cpp:242: FAILED:
tests/net/test_socket_backend.cpp:299: FAILED:
tests/net/test_socket_backend.cpp:327: FAILED:
tests/net/test_socket_backend.cpp:1241: FAILED:
tests/net/test_socket_backend.cpp:1440: FAILED:
test cases:  189 |  184 passed | 5 failed
==10010== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

So Valgrind's failure is the same functional regression and not a memcheck
finding — confirmed from the log, which the previous revision of this PR
asserted without having read it. Filed the preset behaviour as #616; it is
not this PR's to fix.

The fix

IBackend::bindWaitPolicy(), returning a two-valued BindWait:

Value Meaning Who says it
kCallerMayBlock The completion settles without the calling thread's participation — inside the call, or on a thread the caller does not own. Default. Everything that has not overridden bindModel (settles inline), and SocketBackend (its I/O thread settles it)
kCallerMustNotBlock Waiting is impossible or pointless. QtWebSocketBackend when asyncRegistrationEnabled is set (the reply is delivered by the calling thread's own Qt event loop), and SynchronousBackendAdapter (it exists to move the blocking off the caller, and would deadlock against its own strand)

registerHandlerImpl then holds its inline window open until the completion
settles (detail::awaitHandoff, claimHandoff's blocking twin) and publishes
the outcome through the code that already handled a backend settling inline —
including the rethrow, so a failed registration still throws out of the
BridgeHandler constructor exactly as it always did. The two cases differ in
how long the frame sits still, not in what the caller observes.

Why this is not the bool #567 deliberately removed. That bool chose
which verb to call, so every call site carried two paths and a backend could be
half-migrated. This one chooses nothing: one verb, called unconditionally, one
continuation. It says only whether the thread that registered that continuation
may stop and wait for it. backend.hpp's design note now says this in place,
next to the point it qualifies.

Why not just update the six tests instead. That was #593's own stated
alternative, conditional on evidence that SocketBackend's synchronous
registration contract was not load-bearing outside tests/net/ — evidence
nobody has. Rather than acquire it, this keeps the contract: registerHandler
goes on returning a bound handler for every backend that can honour it, and the
break is confined to the one deployment that opted into it by name.

Verification — both horns, measured on the fix

Mutation at the call site Result
none (the fix as shipped) 100% tests passed out of 1835
false && … — never wait, i.e. the discriminator ripped out 7 failed: the six SocketBackend cases above, plus the new registerHandler waits out a kCallerMayBlock backend's bind case
true ‖ … — always wait 6 failed: the new registerHandler does not wait for a kCallerMustNotBlock backend case, plus five tests/qt/ cases that time out at 120 s rather than fail — the nested-QEventLoop deadlock, not a wrong answer
	1596 - morph::qt::QtWebSocketBackend: (serverUrl, tls, cfg) constructor overload ... (Timeout)
	1597 - morph::qt::QtWebSocketBackend: Config-only constructor overload ... (Timeout)
	1601 - morph::qt::QtWebSocketBackend: bindModel (non-blocking via Config::asyncRegistrationEnabled) registers without blocking (Timeout)
	1602 - morph::qt::QtWebSocketBackend: bindModel called before the socket connects queues and retries once connected fires (Timeout)
	1607 - morph::qt::QtWebSocketBackend: bindModel's pending registration is cancelled when the connection drops before a reply arrives (Timeout)

Neither fixed setting of that call site is correct. That is #593's finding, and
it is what the discriminator exists for.

The two new tests

Both in tests/test_backend_registration_surface.cpp, driving a Bridge over a
double whose bind reply arrives 200 ms later from a transport thread:

  • registerHandler waits out a kCallerMayBlock backend's bind and returns bound
    — asserts isBound with no polling and no drain, plus that the value was
    produced on a thread registerHandler does not own.
  • registerHandler does not wait for a kCallerMustNotBlock backend — asserts
    REQUIRE_FALSE(isBound) immediately after the call, then polls until it binds.

The 200 ms delay is what makes each one fail under the opposite policy; neither
assertion's passing direction depends on its value (the waiting case waits
however long the reply takes; the non-waiting case polls).

#594 — contextKey on a blocking private registration

#594's verification status was "inferred from reading the code, not reproduced".
It is now reproduced. The new case in tests/qt/test_qt_websocket.cpp stands
up a QtWebSocketServer over a RemoteServer with a LogProvider installed,
and before the fix — on a tree that already had #593's fix, so the mechanism was
not made moot by it — failed exactly where #594 predicted:

tests/qt/test_qt_websocket.cpp:2504: FAILED:
  CHECK( requestedFor == std::vector<std::string>{"WsEchoModel:acct-594"} )
with expansion:
  {  } == { "WsEchoModel:acct-594" }

tests/qt/test_qt_websocket.cpp:2523: FAILED:
  CHECK( requestedFor == std::vector<std::string>{"WsEchoModel:acct-bind"} )
with expansion:
  {  } == { "WsEchoModel:acct-bind" }

Per AGENTS.md's "would this still pass if the feature did nothing": the assertion
is on the provider, not on the registration. Registration succeeded before the
fix too — 13 of the case's 15 assertions passed. This matters because
RemoteServer::attachLogIfConfigured returns before consulting its
LogProvider on an empty contextKey, so the symptom is not an audit record
missing a field, it is no audit record at all.

Fix is #587's two lines: override registerModelWithContext to build
makeRegister(typeId, contextKey), and have registerModel forward to it with
an empty key. A third section asserts the other direction — plain registerModel
still sends no key, so the provider is not consulted.

Done

  • QtWebSocketBackend overrides none of the four *Async verbs and
    implements bindModel/promoteModel natively. Evidence:
    $ grep -rn "registerModelAsync\|registerModelSharedAsync\|attachModelAsync\|assignPrimaryAsync" include/morph/qt src/qt
    include/morph/qt/qt_websocket_backend.hpp:314:    /// preserve — which is why `assignPrimaryAsync`, the verb this replaces,
    
    One hit, in prose, naming the verb it replaced. No declaration, no definition.
    $ grep -rn "registerModelAsync\|registerModelSharedAsync\|attachModelAsync\|assignPrimaryAsync" include src | grep override
    (no output)
    
    No backend anywhere in include/ or src/ overrides any of the four now.
  • Bridge's four dispatch sites fall back to bindModel/promoteModel instead
    of to a blocking verb. This is not optional decoration: Bridge is the only
    framework caller of the four verbs, so without it a QtWebSocketBackend that
    stopped overriding them would be routed straight back into the nested
    QEventLoop the verbs existed to avoid.
  • The single-threaded WASM registration path works with no special case in the
    interface, proven end to end (see Tests below).
  • Specs updated in the same PR: docs/spec/core/backend.md and
    docs/spec/concurrency_and_lifetimes.md.
  • tests/test_async_registration.cpp's eleven doubles are untouched except one
    (see Traps), so core: remove IBackend's four async twins and retire the prose threading contract #571's scope is intact.

Tests, and their verification status

Test Proven by mutation?
bindModel delivers its continuation on the caller's executor, not the thread it settles on (new) Yes, measured. Replacing makeSettleable(&cbExec) with makeSettleable(&inlineExecutor())FAILED: REQUIRE(cbExec.posted.load() != 0).
bindModel called before the socket connects queues and retries once connected fires Yes, measured. Forcing bindModel to always take the blocking default → FAILED ... due to unexpected exception: register failed: disconnected.
examples/common/testkit/test_wasm_registration_path_native.cpp, both cases Yes, measured. Same mutation → one fails with register failed: disconnected, the other hangs and dies on SIGTERM — the nested-QEventLoop deadlock, which is the WASM page abort in desktop clothing.
The rewritten keyed-bind / re-point / degrade-to-private / dead-socket / promoteModel cases No. They are direct ports of existing *Async tests onto bindModel. They pass; I did not watch each one fail on unfixed code. Treat them as regression coverage, not as proof of the new shape.

The delivery-thread test is the one that matters: #567's equivalent case
exercised a test double settling its own promise, so it pinned the shape an
implementor needs. This is the first time the guarantee has been tested against
a real transport.

Decisions made

bindModel is implemented natively, not via SynchronousBackendAdapter.
The adapter moves a blocking call onto another thread; a WASM main thread has no
other thread to move it to. With Config::asyncRegistrationEnabled set,
bindModel builds the envelope the request's shape names, assigns a callId,
sends, and returns an unsettled Completion that onTextMessage settles:

primary current Envelope
empty 0 register (queued if the socket has not connected yet)
empty non-zero deregister of current, then register
non-empty 0 register + shared
non-empty non-zero attach, naming current

A keyed bind on a dead socket rejects rather than queueing: unlike a private
registration it may be a re-point, and replaying it against a re-established
connection would attach from a current that connection never issued.

Config::asyncRegistrationEnabled is kept, with its meaning narrowed. Unset
(the desktop default) bindModel defers to IBackend::bindModel, which blocks
in the nested QEventLoop. Removing the flag would change desktop semantics —
a BridgeHandler would stop being usable on the line after its constructor — and
break every desktop example and its tests. It is no longer an opt-in to a second
set of interface verbs; that is the part #568 asked to delete, and it is gone.

Is the WASM special case gone? In the interface, yes: no *Async override,
no bool return, no fallback verb, one bindModel whose non-blocking case is
its ordinary shape. What remains is a Config field that chooses whether the
transport blocks, which is a deployment property rather than an interface
special case. If a reviewer disagrees and wants the flag gone too, that is a
behaviour change that belongs with #570 (which owns the example mains that set
it), not here.

Three duplications collapsed with the verbs: one send path (sendControl,
so the encode-before-record invariant and morph#495's env.session stamp exist
once instead of four times); one pending map (all four reply kinds were matched
identically); a Completion<ModelId>::Promise per entry instead of two
std::functions, so a dropped socket rejects with the DisconnectedError
itself rather than a runtime_error rebuilt from its what().

Behaviour preservation in Bridge was deliberate at each site:

  • Executor named is inlineExecutor() → continuations run where they ran before.
  • A blocking backend settles inside the dispatch frame with _attachMtx held,
    which is exactly what detail::AsyncDispatchHandoff/parkIfInFrame already
    existed for, so both branches park and the dispatching frame publishes under
    the lock it owns and reports once it is gone.
  • attachHandlerAsync/ensureBoundAsync now deliver the original
    exception_ptr to onDone rather than a stringified one — closer to the
    synchronous fallback they replace, which propagated the original.
  • registerHandlerImpl parks a rejection arriving inside its own frame and
    rethrows it, because a BridgeHandler constructor that could not bind
    must not return as if it had.
  • One named behaviour change: assignHandlerPrimary's blocking branch used to
    let assignPrimary throw out of a Completion handler (where
    CompletionState swallowed and logged it). It now logs directly.

tryRouteControlReply settles outside _pendingMtx — it moves the promise
out of the map, releases the lock, then settles. With an inline executor,
settling under the lock would re-enter the backend under a lock the frame holds.

Not done

In order, for whoever continues:

  1. Push and watch CI. Done — pushed and driven to the result in the
    status line at the top of this PR.
  2. Run --no-tests=error mentally over the renamed cases. Done.
    Four TEST_CASE titles changed (registerModelAsync ...bindModel ...).
    All still begin morph::qt::QtWebSocketBackend:, so the leg's
    -R 'morph::qt::|qt_tls_example_runs' filter still matches — confirmed by
    running that exact filter on the rebased tree: 59/59, none skipped.
  3. Decide whether to file the two core: give IBackend a registration surface whose threading contract is structural, not prose #567 surface gaps below as issues.
    Partly done. Gap 2 is now filed as core: give Bridge an executor bound to its own thread, so the threading guarantee is structural for Bridge-mediated calls too #588, triaged rescope — its
    proposed remedy contradicts docs/spec/concurrency_and_lifetimes.md:205
    ("teardown is order-independent, on any thread"), so it belongs to core: remove IBackend's four async twins and retire the prose threading contract #571
    rather than here. Gap 1 and gap 3 remain notes for net: move SocketBackend onto the structural registration surface, or justify the adapter #569/core: remove IBackend's four async twins and retire the prose threading contract #571.
  4. Consider whether the Config flag should survive — see "Decisions still
    open".
  5. Un-draft and switch Refs #568 to Closes #568 once CI is green.
    Done — this PR is out of draft and says Closes #568.

Decisions still open

Should Config::asyncRegistrationEnabled be removed? I kept it. Arguments I
weighed: removing it would make QtWebSocketBackend unconditionally
non-blocking, which is arguably the honest end state and would delete the last
thing that smells like a WASM special case; but it changes desktop registration
from synchronous to asynchronous, which every desktop example and a large number
of ladder tests depend on (a handler usable on the next line). Evidence I have:
the flag is read in exactly one place now (src/qt/qt_websocket_backend.cpp:163),
and its consumers are the example WASM mains, which are #570's scope. I
concluded that removing it is a behaviour decision for #570/#571 and out of
scope for step 2. Someone may reasonably disagree.

Should Bridge grow an executor of its own? Not attempted — see the gaps
section. This is the difference between "the guarantee is structural" and "the
decision is structural", and only the second is true today.

Gaps in #567's surface — read this before #569, #570 and #571

1. bindModel alone cannot replace a *Async verb; the caller has to move
with it.
#567's surface deliberately removed the bool that meant "I have no
async path". That is the right design, but it has a consequence the ticket set
does not account for: there is no way for a caller to tell a natively
non-blocking backend from a blocking one, so a backend cannot be migrated
without its caller being migrated in the same change. Bridge is that caller
for all four verbs. #568 as written ("move QtWebSocketBackend, do not touch
anything else") is therefore not achievable as stated — the WASM path breaks the
moment the overrides are dropped. I migrated Bridge's four dispatch sites,
keeping the legacy branch first so the eleven test doubles in
tests/test_async_registration.cpp still exercise it and #571's scope is intact.
#569 will hit exactly the same wall if it chooses the native route for
SocketBackend; if it chooses the adapter, it will not, because the adapter is
reached through the same bindModel Bridge now calls.

Update: #569 (#586) chose the native route, the wall was hit, and this PR
now goes through it.
The prediction was reproduced, filed as #593, and
fixed here by IBackend::bindWaitPolicy() — see #593 — the blocking
regression, and its fix
above. The gap as written ("there is no way for a
caller to tell a natively non-blocking backend from a blocking one") is
closed; what it was really asking for was narrower than it looked, since the
caller does not need to know how the backend works, only whether it may
wait.

2. The structural threading guarantee is not yet real, because Bridge has no
thread to name.
Completion delivers to the executor the caller passes. The
caller here is Bridge, which owns no event loop and no strand, so the only
executor it can honestly name is exec::detail::inlineExecutor() — "deliver
wherever you settled", which is precisely what the prose contract already
required of backends. So #568 does not close morph#486 and this PR does not
claim it does.
What changed is who decides: one value at four call sites
instead of an obligation on fifteen implementors. Turning that into an actual
guarantee needs Bridge to hold an executor bound to the thread that runs
~Bridge, and no ticket in the #522 set owns that. If #571 intends to
"retire the prose threading contract" rather than relocate it, this is the
missing piece. Both specs now say so explicitly rather than implying otherwise.

Filed as #588, and triaged rescope: the remedy as written there (give
Bridge an executor bound to the thread that runs ~Bridge) contradicts
docs/spec/concurrency_and_lifetimes.md:205, which states that teardown is
order-independent and may run on any thread. The finding is real; the fix
belongs to #571's redesign, not to this PR. This PR therefore continues to
state that #568 does not close morph#486.

3. BindRequest cannot express registerModelShared with an empty primary.
Bridge::ensureBoundAsync asks for an anonymous shared instance — it calls
registerModelShared(typeId, factory, {contextKey, primary = {}}). In
BindRequest terms that is primary empty + current zero, which
bindModelBlocking routes to registerModelWithContext. Different verb.
Verified harmless: IBackend::registerModelShared documents an empty primary as
degrading to registerModelWithContext, and every backend in the tree
implements that degrade as its first statement (LocalBackend,
SimulatedRemoteBackend at remote.hpp:1874, SocketBackend at
socket_backend.hpp:219, QtWebSocketBackend). It cost me one test failure
anyway — see Traps. Related and also verified harmless:
attachModel(..., current = 0) is not expressible either (it maps to
registerModelShared); server-side, register+shared and attach with
modelId == 0 both reach acquireSharedInstance(..., ModelId{0}) through the
same authorizeRegister gate (remote.hpp:1067 vs :1154). #571 should not
assume the request-shape mapping is injective.

Traps

  • The bindModelBlocking routing change is silent. It cost me exactly one
    failing test: ensureBoundAsync's synchronous fallback surfaces a real registerModelShared throw through onDone (Task 15a finding B2). Its double
    overrode only registerModelShared to throw, so after the routing change the
    call reached registerModelWithContext, succeeded, and the test passed by
    registering rather than by surfacing a throw. I made the double throw from
    both verbs and documented why in place. If you see another test go green for
    no reason, check which verb it is actually reaching now.
  • Do not trust an empty build log. I filtered cmake --build output with
    grep -vE "^-- |^\[[0-9]+/", which yields an empty file on success and on a
    build that has not reached the failing target yet. Check the exit code.
  • Qt tests need QT_QPA_PLATFORM=offscreen at build time as well as test
    time
    — Catch2's catch_discover_tests() runs each Qt-linked binary once
    during the build to enumerate cases.
  • A full Debug build of this tree with -DMORPH_BUILD_LADDER=ON took ~40
    minutes
    on a machine shared with two other lanes' builds. Budget for it.
  • The mutation that proves the WASM path hangs rather than fails. The second
    test_wasm_registration_path_native.cpp case blocks in a nested QEventLoop
    forever; I only saw it because I ran it under timeout. Always use one.
  • tests/qt and tests/net_qt_interop are sanitizer-instrumented for the first
    time as of Four gates that could not fail: UBSan modes, sanitizer instrumentation, spec-sync, and the forms key vocabulary #576, and -DAF_SANITIZER=<unknown> is now a configure-time
    FATAL_ERROR. I did not run a sanitizer build, so I have no evidence either
    way about what they will find here. If instrumentation surfaces a pre-existing
    defect, file it per AGENTS.md rather than folding the fix in.

Issues filed

Closed by this PR:

Filed in passing from this lane, not folded in:

Still open from earlier revisions of this PR:

Gaps 1 and 3 above remain written up here rather than filed: gap 1 is now closed
by #593's fix, and gap 3 is a footnote #571 needs when it touches the
request-shape mapping.

Nothing else was found in passing that needed a separate issue.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk

Yaraslaut added a commit that referenced this pull request Sep 19, 2026
…ions

Both entries drifted once the new bindModel/promoteModel machinery
was inserted above them:

- B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from
  line 1453 to 1498, text unchanged.
- B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but
  that source line is not unique -- it also appears, unrelated, in
  the .then()/.onError() continuations further down (1634, 1723,
  present on master too). Repointed to 1610, the specific
  exception-path occurrence B11's reasoning actually describes, and
  said so in the entry so the ambiguity doesn't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.84615% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/morph/core/bridge.hpp 96.72% 5 Missing and 1 partial ⚠️
include/morph/qt/qt_websocket_backend.hpp 0.00% 3 Missing ⚠️
include/morph/core/backend.hpp 33.33% 2 Missing ⚠️
include/morph/core/executor.hpp 83.33% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Yaraslaut added a commit that referenced this pull request Sep 19, 2026
…ions

Both entries drifted once the new bindModel/promoteModel machinery
was inserted above them:

- B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from
  line 1453 to 1498, text unchanged.
- B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but
  that source line is not unique -- it also appears, unrelated, in
  the .then()/.onError() continuations further down (1634, 1723,
  present on master too). Repointed to 1610, the specific
  exception-path occurrence B11's reasoning actually describes, and
  said so in the entry so the ambiguity doesn't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Yaraslaut
Yaraslaut force-pushed the fix-568-qt-structural-registration branch from 05a976d to 75a4865 Compare September 19, 2026 13:15
@Yaraslaut
Yaraslaut marked this pull request as ready for review September 19, 2026 15:00
@Yaraslaut
Yaraslaut force-pushed the fix-568-qt-structural-registration branch from 75a4865 to a08db4f Compare September 19, 2026 20:35
Yaraslaut added a commit that referenced this pull request Sep 19, 2026
…ions

Both entries drifted once the new bindModel/promoteModel machinery
was inserted above them:

- B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from
  line 1453 to 1498, text unchanged.
- B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but
  that source line is not unique -- it also appears, unrelated, in
  the .then()/.onError() continuations further down (1634, 1723,
  present on master too). Repointed to 1610, the specific
  exception-path occurrence B11's reasoning actually describes, and
  said so in the entry so the ambiguity doesn't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Yaraslaut

Copy link
Copy Markdown
Member Author

Heads-up for this PR's eventual rebase: docs/spec/core/backend.md will have moved.

PR #595 (issue #587, SocketBackend dropping contextKey on a private registration) has to edit that file. I had originally told its lane the file was off limits because this PR holds it, but that instruction was unworkable: scripts/check_spec_sync.sh:60 maps include/morph/net/** to

net|^docs/spec/net/|^docs/spec/core/backend\.md$|^docs/spec/security\.md$

docs/spec/net/ does not exist, and security.md is the wrong home for a registration/journaling contract — so backend.md is the only honest way for #595 to pass the Header ↔ spec sync gate. Its CI failed with include/morph/net/** changed but none of its spec paths did.

Why I let it through rather than making it wait: this PR is blocked on #593, a design decision at the #567/#571 level with no timetable. Holding backend.md against a blocked PR would stall correct, unrelated work for an unbounded period. That is the wrong trade, so #595 goes first.

The expected overlap is small and near backend.md:155, the sentence that reads "Backends whose model instances live behind a wire protocol (SimulatedRemoteBackend) override this to carry @p contextKey across"#595 makes that true of SocketBackend too. I asked it to keep the edit to what its own change establishes and to document nothing about bindModel's blocking/non-blocking question, which is #593's to settle.

This branch already reconciled backend.md once against #586 and did it properly — kept both sides rather than picking one — so this should be familiar ground rather than a surprise.

Yaraslaut added a commit that referenced this pull request Sep 20, 2026
…ions

Both entries drifted once the new bindModel/promoteModel machinery
was inserted above them:

- B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from
  line 1453 to 1498, text unchanged.
- B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but
  that source line is not unique -- it also appears, unrelated, in
  the .then()/.onError() continuations further down (1634, 1723,
  present on master too). Repointed to 1610, the specific
  exception-path occurrence B11's reasoning actually describes, and
  said so in the entry so the ambiguity doesn't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Yaraslaut
Yaraslaut force-pushed the fix-568-qt-structural-registration branch from a08db4f to 49bd5b8 Compare September 20, 2026 08:09
@Yaraslaut

Copy link
Copy Markdown
Member Author

Merge ordering, decided by the runner: #585 first, then #614

These two PRs interact, and the interaction is measured rather than predicted. #614 adds a per-PR gate that resolves scripts/mutation_survivors.json's {file, line, source} citations against the tree and fails when a line hint has drifted. #585 moves code in both files two of those citations point into.

Measured against #585's head 49bd5b8b and #614's refreshed JSON:

include/morph/core/bridge.hpp
   #614 refreshed it to 121; on #585's branch the text is at [122]   -> WILL GO STALE
include/morph/core/backend.hpp
   #614 refreshed it to 1228; on #585's branch the text is at [1322] -> WILL GO STALE

The backend.hpp delta is 94 lines, exactly the +94 #585 adds to that file. This is not a merge conflict — #585 does not touch the JSON, and #614 does not touch the headers — so git will merge them cleanly and the gate is what will object.

Why this order:

So: land #585 when green. Then rebase #614, re-run its gate (python3 scripts/check_mutation_survivors.py), update the two hints it names, and land it.

This is the gate working as designed, and it is the argument #614's PR body makes for the resolver approach over hand-maintained coordinates — recorded here so whoever runs the next landing sweep does not have to re-derive it.

Yaraslaut added a commit that referenced this pull request Sep 20, 2026
#616) (#617)

All twelve test presets set `stopOnFailure: true`, so `ctest` abandoned the
run at the first failure on every CI leg. One broken test and thirty broken
tests therefore produced the same log, and every diagnosis made from that log
was made on partial information. That cost is not hypothetical: CI on #585's
head was read as "all six failing jobs fail on one test", and the singularity
was treated as a property of the defect. Six tests were failing; `ctest`
stopped at the first, and it took a full local build by someone else to
establish that.

Decision, per preset: **removed on all twelve.** Each ctest case is its own
process here (`catch_discover_tests`), so no leg's later tests are invalidated
by an earlier failure -- not the sanitizer legs, where a diagnostic aborts only
its own process, and not `clang-coverage`, where stopping early loses profile
data rather than protecting it. The "it saves time" argument does not survive
the matrix either: the leg that already reports in full is Valgrind, the
slowest one there is. A leg that ever does earn the setting can set it on its
own preset, with its reason in its own `description`.

The twelve presets now inherit a hidden `base-test` preset that carries
`output`/`execution` once, so the decision lives in exactly one place with the
reasoning next to it, instead of twelve copies of a setting nobody recorded a
reason for. `ctest` has no `--no-stop-on-failure`, so keeping it in the preset
and overriding it per invocation in CI is not available -- the preset is the
only place the choice can be made. No workflow change is needed: every leg runs
its suite through `ctest --preset ...`.

Measured, gcc-debug on this revision, with two Catch2 cases in
tests/test_rational.cpp deliberately broken (reverted before this commit) and
the run filtered to `-R "^Rational::"`:

    # before, stopOnFailure: true
     2/46 Test #597: Rational::Comparison ......***Failed    0.00 sec
    50% tests passed, 1 tests failed out of 2
    The following tests FAILED:
            597 - Rational::Comparison (Failed)

    # after
    46/46 Test #641: Rational::toDouble::PrecisionRoundtrip ...   Passed    0.00 sec
    96% tests passed, 2 tests failed out of 46
    The following tests FAILED:
            597 - Rational::Comparison (Failed)
            599 - Rational::Constants (Failed)

Both runs exit 8. Restoring `stopOnFailure` on the inheriting `gcc-debug`
preset alone reproduces the one-line report, which is what shows the setting
and not something else is responsible.

Inheritance was checked rather than assumed. `noTestsAction: error` still
reaches each leg through `base-test`: `ctest --preset gcc-debug -R
zzz_no_such_test` exits 8 where the same filter without the preset exits 0.
`outputOnFailure` likewise -- both failing cases printed their Catch2 bodies in
the "after" run above. And CMake merges `execution` key by key across
`inherits`: the child that set only `stopOnFailure` still errored on no tests.

Full suite green on the new presets: 100% tests passed out of 1562
(gcc-debug, GNU 16.2.1).


Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yaraslaut and others added 11 commits September 20, 2026 11:54
`Bridge` is about to reach `IBackend::bindModel`/`promoteModel`, which take the
executor their continuation is delivered on. `Bridge` owns no event loop, so the
only executor it can honestly name is the one that reproduces what the four
`*Async` verbs did: run the continuation on whichever thread the backend settled
the reply on.

That was previously a rule stated in a doc comment and checkable by nothing.
`exec::detail::InlineExecutor` makes it a value a call site produces, which is
the whole of what morph#567 moved -- not who is safe, but who decides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
…blocking verb

Each of `registerHandlerImpl`, `attachHandlerAsync`, `ensureBoundAsync` and
`assignHandlerPrimary` offered the backend a legacy `*Async` verb and, when it
answered `false`, ran the synchronous verb inline. The second path is now
`IBackend::bindModel`/`promoteModel` instead, so the path *count* is unchanged
-- morph#571 deletes the first branch and leaves one.

This is what makes morph#568 possible at all: `Bridge` is the only framework
caller of the four verbs, so a backend that stops overriding them would
otherwise be routed straight back into the blocking call the verbs existed to
avoid.

Behaviour is preserved deliberately, not incidentally:

- The executor named is `inlineExecutor()`, so continuations still run on the
  thread the backend settles on. A blocking backend therefore settles inside the
  dispatch frame with `_attachMtx` held -- exactly what `AsyncDispatchHandoff`
  already existed for, so both branches park and the dispatching frame publishes
  under the lock it owns and reports once it is gone.
- Failures carry the original `exception_ptr` to `onDone` rather than a
  `runtime_error` rebuilt from `what()`, which is what the synchronous fallback
  they replace did and what the legacy string channel could not do.
- `registerHandlerImpl` parks a rejection that arrives inside its own frame and
  rethrows it: a `BridgeHandler` constructor that could not bind must not return
  as if it had.

One named behaviour change: `assignHandlerPrimary`'s blocking branch used to let
`assignPrimary` throw out of a `Completion` handler, where `CompletionState`
swallowed and logged it. It now logs directly, because `promoteModel` reports
through its `Completion` by contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
`QtWebSocketBackend` was the only backend in the tree overriding any of the four
`*Async` verbs, so the entire prose threading contract on `IBackend` existed for
exactly one class and the WASM special case it described lived here. It now
overrides none of them, and implements `bindModel`/`promoteModel` directly --
not through `SynchronousBackendAdapter`, which moves a blocking call to another
thread and a WASM main thread has no other thread to move it to.

`Config::asyncRegistrationEnabled` stays, with its meaning narrowed to the one
thing it actually decides: whether the transport blocks. Unset, `bindModel`
defers to `IBackend`'s blocking default, which is the desktop behaviour every
existing embedder relies on. It is no longer an opt-in to a second set of
interface verbs, because the continuation now exists on both paths.

Three duplications went with the verbs:

- One send path (`sendControl`), so the "encode before recording the pending
  entry" invariant and morph#495's `env.session` stamp are stated once rather
  than four times.
- One pending map: `register`, shared `register`, `attach` and `assign` replies
  were always matched identically, and the split had nothing left to represent.
- `Completion<ModelId>::Promise` in place of two `std::function`s per entry, so
  a dropped socket now rejects with the `DisconnectedError` itself instead of a
  `runtime_error` rebuilt from its message -- the string channel could not carry
  a type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
…ath with it

morph#567's "natively non-blocking backend" case exercised a test double
settling its own promise, which pins the shape an implementor needs rather than
what a real transport does. `QtWebSocketBackend` is the first real implementor,
so this is the first test that the guarantee survives one.

"bindModel delivers its continuation on the caller's executor, not the thread it
settles on" uses an executor that counts posts without running them: the test
pumps only the Qt loop until the reply lands and asserts the continuation has
*not* run, then drains and asserts it has. Building the `Completion` against an
inline executor instead of `cbExec` fails it (measured).

The four direct `*Async` tests are rewritten against `bindModel` -- keyed
register-or-attach, re-point, degrade-to-private, reject on a dead socket -- and
`promoteModel` gains direct coverage including its documented no-op case. The
morph#495 session-stamping test drives `bindModel`, where `sendControl` is now
the single place that gap could reappear.

`ThrowingSyncRegisterSharedBackend` now throws from `registerModelWithContext`
too. `ensureBoundAsync` asks for an anonymous instance, and `BindRequest` names
that shape (`primary` empty, `current` zero) as `registerModelWithContext` --
which `IBackend::registerModelShared` documents an empty primary as degrading
to, and which every backend in the tree implements as its first statement. A
double that threw only from `registerModelShared` would have stopped failing and
the test would have passed by registering successfully.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
`backend.md` gains a migration-status table for the morph#522 set, marks the
`*Async` sections as historical now that no backend overrides them, and
describes `QtWebSocketBackend`'s native `bindModel`.

Both specs are explicit that this does not close morph#486. `Bridge` names an
inline executor because it owns no event loop, so the delivery thread is
unchanged; what moved is the decision, from fifteen implementors to four call
sites. Closing the window needs a `Bridge` executor bound to the thread that
runs `~Bridge`, and nothing in the set currently owns that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
clang-tidy-diff flagged both under misc-const-correctness: neither
url is reassigned after construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ions

Both entries drifted once the new bindModel/promoteModel machinery
was inserted above them:

- B6 (`_executeDeadline.count() > 0 && _timeoutScheduler`) moved from
  line 1453 to 1498, text unchanged.
- B11 (`deadlineHandle && schedulerRef`) moved from 1589 to 1610, but
  that source line is not unique -- it also appears, unrelated, in
  the .then()/.onError() continuations further down (1634, 1723,
  present on master too). Repointed to 1610, the specific
  exception-path occurrence B11's reasoning actually describes, and
  said so in the entry so the ambiguity doesn't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…593)

`Bridge::registerHandlerImpl` has one call site for acquiring a model, and
after morph#568 and morph#569 two shipped backends need opposite behaviour
from it. Both return an unsettled `Completion` from `bindModel`, so from
`Bridge`'s side they are indistinguishable:

  - `morph::net::SocketBackend` must be waited for. Its callers construct a
    `BridgeHandler` and use it on the next line, and `executeVia` fails fast
    on `currentId == 0`.
  - `QtWebSocketBackend` with `asyncRegistrationEnabled` must not be waited
    for. Its reply is delivered by the Qt event loop of the calling thread,
    so a wait is a deadlock -- on WASM, a page abort. That is the whole of
    morph#568.

Adds `IBackend::bindWaitPolicy()`, returning `BindWait::kCallerMayBlock` by
default and `kCallerMustNotBlock` for the two backends whose callers cannot
usefully wait (`QtWebSocketBackend` under the flag, and
`SynchronousBackendAdapter`, which exists precisely to move the blocking off
the caller's thread and would deadlock against its own strand). Under
`kCallerMayBlock`, `registerHandlerImpl` holds its inline window open until
the completion settles (`detail::awaitHandoff`) and then publishes the
outcome through the same code that already handles a backend settling
inline -- so the two cases differ in how long the frame sits still, not in
what the caller observes.

This is not the `bool` morph#567 deliberately removed. That one chose *which
verb to call*, so every call site carried two paths and a backend could be
half-migrated. This one chooses nothing: one verb, called unconditionally,
one continuation. It says only whether the thread that registered that
continuation may stop and wait for it.

Measured on this rebased tree (GCC 16.2.1, Debug, MORPH_BUILD_QT=ON
MORPH_BUILD_NET=ON):

  - Before: 99% tests passed, 6 tests failed out of 1833 -- the six
    `tests/net/` cases morph#593 names.
  - After: 100% tests passed out of 1835.
  - Mutation, never wait (`false && ...` at the call site): 7 failures --
    the same six, plus the new `kCallerMayBlock` case.
  - Mutation, always wait (`true || ...`): 6 failures -- the new
    `kCallerMustNotBlock` case, plus five `tests/qt/` cases that **time out**
    at 120 s rather than fail, which is the nested-`QEventLoop` deadlock in
    desktop clothing.

Neither fixed setting of that call site is correct, which is the finding
morph#593 recorded and this commit acts on.

Also repoints `scripts/branch_partial_allowlist.json`'s two `bridge.hpp`
line hints (1498 -> 1542, 1610 -> 1654) and the line numbers named inside
the second entry's reasoning (1634/1723 -> 1678/1767), which this commit's
insertions shifted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
`QtWebSocketBackend` did not override `registerModelWithContext`, so it
inherited `IBackend`'s default, which drops the key -- while its own
`bindModel` non-blocking path carried it. The two disagreed, and which one
ran was decided by `Config::asyncRegistrationEnabled`, whose documentation
is entirely about blocking versus not blocking the Qt event loop.

This is not an audit entry missing a field.
`RemoteServer::attachLogIfConfigured` returns *before* consulting its
`LogProvider` when the envelope's `contextKey` is empty, so a privately
registered instance over this transport produced **no audit record at all**,
silently, while `SimulatedRemoteBackend` and `morph::net::SocketBackend`
(morph#587) produced one. Three call sites reached the dropping default: the
blocking `bindModel`'s empty-`primary`/zero-`current` branch (every private
registration with the flag at its `false` default),
`Bridge::switchBackend`'s re-registration after a reconnect (whatever the
flag was set to), and `registerModelShared`/`attachModel`'s empty-`primary`
degradation.

Fix is morph#587's two lines: override `registerModelWithContext` to build
`makeRegister(typeId, contextKey)`, and have `registerModel` forward to it
with an empty key, so one place builds this envelope rather than two.

morph#594's verification status was "inferred from reading the code, not
reproduced". It is now **reproduced**. The new case in
`tests/qt/test_qt_websocket.cpp` stands up a `QtWebSocketServer` over a
`RemoteServer` with a `LogProvider` installed and, before the fix, failed on
the two sections that send a key:

    tests/qt/test_qt_websocket.cpp:2504: FAILED:
      CHECK( requestedFor == std::vector<std::string>{"WsEchoModel:acct-594"} )
    with expansion:
      {  } == { "WsEchoModel:acct-594" }

    tests/qt/test_qt_websocket.cpp:2523: FAILED:
      CHECK( requestedFor == std::vector<std::string>{"WsEchoModel:acct-bind"} )
    with expansion:
      {  } == { "WsEchoModel:acct-bind" }

Per AGENTS.md's "would this still pass if the feature did nothing": the
assertion is on the provider, not on the registration. Registration
succeeded before the fix too -- all 15 assertions in the case passed except
those two.

The third section asserts the other direction: plain `registerModel` still
sends no key, so the provider is *not* consulted. `registerModel`'s
behaviour is unchanged.

Full suite after: 100% tests passed out of 1836 (GCC 16.2.1, Debug,
MORPH_BUILD_QT=ON MORPH_BUILD_NET=ON).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
`check_branch_coverage.py`'s `resolve_allowlist_source_line()` failed the
`Linux / clang-coverage` leg on three entries in
`scripts/branch_partial_allowlist.json`:

    error: include/morph/util/rational.hpp:1542 has moved to line 1498.
    error: include/morph/core/backend.hpp:1230 has moved to line 1324.
    error: include/morph/core/bridge.hpp:1498 has moved to line 1542.

Only one of the three is this branch's doing. Resolving each entry's
`source` text against both revisions:

    rational.hpp  hint 1542 -> master: 1498   this branch: 1498
    bridge.hpp    hint 1498 -> master: 1453   this branch: 1542
    backend.hpp   hint 1230 -> master: 1230   this branch: 1324

`rational.hpp` and `bridge.hpp` were **already stale on master** and this
branch does not touch `rational.hpp` at all; `backend.hpp` drifted by the
94 lines this branch adds. All three are repointed here because the leg
cannot go green otherwise, and every `source` text still matches uniquely,
so no disposition changed -- only the coordinates.

The pre-existing half is filed separately rather than folded in: the file
that `resolve_allowlist_source_line()` exists to harden is itself carrying
drift on master, which is the same rot class as morph#608.

Verified: all 22 entries in the allowlist now resolve to the line they
name on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
`scripts/check_mutation_survivors.py`, which landed on master in #614 after
this branch was cut, resolves each triaged equivalent mutant's `source`
text and fails when the `line` hint has drifted. Two entries name code
this branch moves:

    include/morph/core/bridge.hpp:121  has moved to line 122
    include/morph/core/backend.hpp:1228 has moved to line 1322

Both `source` texts still match uniquely, so neither disposition changed --
only the coordinates. `backend.hpp`'s 94-line delta is this branch's; the
`bridge.hpp` one-line shift likewise.

This is the gate working as designed, and was predicted before either
branch was written: #614's PR body records that these two hints would go
stale when this branch landed, and named the two files.

Verified: `python3 scripts/check_mutation_survivors.py` reports
`ok: 7 structured citation(s) ... resolve to the line they name`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbwhcguQFkhvVi2AH19sWk
@Yaraslaut
Yaraslaut force-pushed the fix-568-qt-structural-registration branch from 4abeaed to 691174e Compare September 20, 2026 09:58
@Yaraslaut

Copy link
Copy Markdown
Member Author

@Yaraslaut
Yaraslaut merged commit a8511aa into master Sep 20, 2026
53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment