Summary
Found while fixing #619, and deliberately not folded into it: #619 is about
the completion the adapter produced never being settled by cancelPending;
this is about the work behind that completion still happening after the
cancellation. Fixing one does not fix the other, and they need different
mechanisms.
SynchronousBackendAdapter::dispatch (include/morph/core/backend.hpp) posts a
task to its private _control strand:
_control.post(kControlStrand, [shared, op = std::move(op)]() mutable {
try {
shared->resolve(op());
} catch (...) {
shared->reject(std::current_exception());
}
});
After #619, cancelPending rejects shared. It does nothing to op. A task
that has not started yet still runs op() when it reaches the head of the
strand, which for a bind is inner->bindModelBlocking(request) — an actual
registerModelWithContext/registerModelShared/attachModel on the wrapped
backend. The resolve that follows is a no-op on an already-rejected state, so
the caller is told the bind was cancelled while the registration goes through
anyway.
Why it matters
cancelPending's two callers are ~Bridge (bridge.hpp) and
Bridge::switchBackend. In the ~Bridge case the instance is created on a
backend whose Bridge is gone, so nothing will ever deregisterModel it: it
is a leaked live instance for as long as the backend lives. In the
switchBackend case it is created on the outgoing backend after the swap.
Severity today is latent for the same reason #619's is: grep -rn SynchronousBackendAdapter finds no production call site on this revision, only
tests/test_backend_registration_surface.cpp and prose. morph#571, which
proposes making this surface the default path, is what would make it live.
Verification status
Reproduced, as a side observation of #619's own regression test, on the
laneCORE-batch-615-619-620 branch (base a8511aa6), GCC 16.2.1, Debug,
Linux. The test wraps a backend whose registerModelWithContext blocks until
released, cancels while it is blocked, then releases it. Real output from that
case, which passes:
All tests passed (92 assertions in 11 test cases)
The assertions that record this behaviour are, after the cancellation has
already been delivered as an error:
inner->letGo();
REQUIRE(morph::testing::waitUntil([&] {
callerExec.runOnce();
return inner->finished.load() == 1;
}));
inner->finished is incremented by the wrapped backend's control call on its
way out, so == 1 is the observation: the registration completed after
cancelPending returned. The test asserts it because that is current
behaviour; it is the assertion that would have to change if this issue is
fixed.
What I did not verify: that any real backend leaks anything as a result. No
production call site exists to leak through, so the leak is inferred from
reading ~Bridge/switchBackend (neither deregisters an instance it never
learned the id of), not measured.
Sketch of a fix (not implemented)
Give each dispatched task a shared_ptr<std::atomic_bool> cancellation flag
alongside its promise, set every live one in cancelPending, and have the task
return without calling op() when it is set. That closes the "queued but not
started" window, which is all that can be closed: a call already inside
op() cannot be recalled, and SynchronousBackendAdapter has no way to
interrupt a blocking verb it does not implement.
What would change the verdict
- Close it if a maintainer decides the adapter is documented as best-effort
here — that a cancelled bind may still register — in which case
IBackend::cancelPending's contract wants that carve-out written down, since
the wrapped backend's own cancelPending does not have it.
- Raise severity if morph#571 lands and this adapter becomes a production
path, or if any caller is found that relies on cancelPending meaning "no
further registration will reach the backend".
Summary
Found while fixing #619, and deliberately not folded into it: #619 is about
the completion the adapter produced never being settled by
cancelPending;this is about the work behind that completion still happening after the
cancellation. Fixing one does not fix the other, and they need different
mechanisms.
SynchronousBackendAdapter::dispatch(include/morph/core/backend.hpp) posts atask to its private
_controlstrand:After #619,
cancelPendingrejectsshared. It does nothing toop. A taskthat has not started yet still runs
op()when it reaches the head of thestrand, which for a bind is
inner->bindModelBlocking(request)— an actualregisterModelWithContext/registerModelShared/attachModelon the wrappedbackend. The
resolvethat follows is a no-op on an already-rejected state, sothe caller is told the bind was cancelled while the registration goes through
anyway.
Why it matters
cancelPending's two callers are~Bridge(bridge.hpp) andBridge::switchBackend. In the~Bridgecase the instance is created on abackend whose
Bridgeis gone, so nothing will everderegisterModelit: itis a leaked live instance for as long as the backend lives. In the
switchBackendcase it is created on the outgoing backend after the swap.Severity today is latent for the same reason #619's is:
grep -rn SynchronousBackendAdapterfinds no production call site on this revision, onlytests/test_backend_registration_surface.cppand prose. morph#571, whichproposes making this surface the default path, is what would make it live.
Verification status
Reproduced, as a side observation of #619's own regression test, on the
laneCORE-batch-615-619-620branch (basea8511aa6), GCC 16.2.1, Debug,Linux. The test wraps a backend whose
registerModelWithContextblocks untilreleased, cancels while it is blocked, then releases it. Real output from that
case, which passes:
The assertions that record this behaviour are, after the cancellation has
already been delivered as an error:
inner->finishedis incremented by the wrapped backend's control call on itsway out, so
== 1is the observation: the registration completed aftercancelPendingreturned. The test asserts it because that is currentbehaviour; it is the assertion that would have to change if this issue is
fixed.
What I did not verify: that any real backend leaks anything as a result. No
production call site exists to leak through, so the leak is inferred from
reading
~Bridge/switchBackend(neither deregisters an instance it neverlearned the id of), not measured.
Sketch of a fix (not implemented)
Give each dispatched task a
shared_ptr<std::atomic_bool>cancellation flagalongside its promise, set every live one in
cancelPending, and have the taskreturn without calling
op()when it is set. That closes the "queued but notstarted" window, which is all that can be closed: a call already inside
op()cannot be recalled, andSynchronousBackendAdapterhas no way tointerrupt a blocking verb it does not implement.
What would change the verdict
here — that a cancelled bind may still register — in which case
IBackend::cancelPending's contract wants that carve-out written down, sincethe wrapped backend's own
cancelPendingdoes not have it.path, or if any caller is found that relies on
cancelPendingmeaning "nofurther registration will reach the backend".