Summary
An execute envelope refused by RemoteServer's shutdown gate takes a per-model ordering ticket that it never releases. This violates an invariant the file states explicitly about itself.
Found while documenting the execute-ordering gate for #342.
The mechanism
handleImpl takes the ticket before posting to the pool, for any well-formed execute with a non-zero modelId (include/morph/core/remote.hpp:337-340):
if (auto peek = ::morph::wire::decode(msg); peek.kind == "execute" && peek.modelId != 0) {
::morph::exec::detail::ModelId const mid{peek.modelId};
ticket.emplace(mid, takeExecuteTicket(mid));
}
It then posts, and the pool thread calls dispatchMessage(msg, reply, cid, ticket) (:349).
dispatchMessage's shutdown gate returns before reaching dispatchExecute, and therefore before any rejectAndRelease (:937-941):
if ((env.kind == "register" || env.kind == "execute" || env.kind == "attach") &&
_shuttingDown.load(std::memory_order_acquire)) {
reply(::morph::wire::encode(::morph::wire::makeErr("server shutting down", env.callId)));
return;
}
execute is in that list, so the ticket taken at :337 is dropped without release.
The invariant it breaks
Stated in the file itself, at :1211-1218:
Every early-return branch below that follows the ticket-taking site must release it (via releaseExecuteTicket) before returning — an unreleased ticket permanently stalls every later ticket for the same model.
The shutdown gate is exactly such an early-return branch, and it does not release.
Why it is benign today, and why it should still be fixed
beginShutdown() is irreversible — nothing un-sets _shuttingDown (see the comment at :1698). So every later execute for that model is refused at the same gate and never reaches awaitExecuteTurn. Nothing is left waiting on the stalled ticket, and no caller hangs.
What remains is a leaked gate map entry on a shutting-down server, and — more importantly — a stated invariant that is false. The next person to add an early return after the ticket site will read :1211-1218, see it presented as universally held, and reasonably assume the shutdown path already honours it. An invariant with a live exception that the comment does not mention is a trap, independent of today's blast radius.
Verification status
Read from code on master c429195c, not reproduced. handleImpl's ticket site, dispatchMessage's gate, and the invariant comment were each read directly; the early return is plainly upstream of every rejectAndRelease in that function. No test was written to observe a leaked entry.
Suggested direction
Either release the ticket in the shutdown branch before returning, or move the gate above the ticket-taking site in handleImpl so a refused envelope never takes one. The second is arguably cleaner — a message that will be refused outright has no ordering to preserve — but it moves a shutdown check onto the transport thread, which is a deliberate placement decision (:333-341 takes the ticket there precisely so send order is captured before the pool can reorder), so it deserves a moment's thought rather than a mechanical move.
Whichever is chosen, :1211-1218's wording should end up true rather than aspirational.
Summary
An
executeenvelope refused byRemoteServer's shutdown gate takes a per-model ordering ticket that it never releases. This violates an invariant the file states explicitly about itself.Found while documenting the execute-ordering gate for #342.
The mechanism
handleImpltakes the ticket before posting to the pool, for any well-formedexecutewith a non-zeromodelId(include/morph/core/remote.hpp:337-340):It then posts, and the pool thread calls
dispatchMessage(msg, reply, cid, ticket)(:349).dispatchMessage's shutdown gate returns before reachingdispatchExecute, and therefore before anyrejectAndRelease(:937-941):executeis in that list, so the ticket taken at:337is dropped without release.The invariant it breaks
Stated in the file itself, at
:1211-1218:The shutdown gate is exactly such an early-return branch, and it does not release.
Why it is benign today, and why it should still be fixed
beginShutdown()is irreversible — nothing un-sets_shuttingDown(see the comment at:1698). So every laterexecutefor that model is refused at the same gate and never reachesawaitExecuteTurn. Nothing is left waiting on the stalled ticket, and no caller hangs.What remains is a leaked gate map entry on a shutting-down server, and — more importantly — a stated invariant that is false. The next person to add an early return after the ticket site will read
:1211-1218, see it presented as universally held, and reasonably assume the shutdown path already honours it. An invariant with a live exception that the comment does not mention is a trap, independent of today's blast radius.Verification status
Read from code on master
c429195c, not reproduced.handleImpl's ticket site,dispatchMessage's gate, and the invariant comment were each read directly; the early return is plainly upstream of everyrejectAndReleasein that function. No test was written to observe a leaked entry.Suggested direction
Either release the ticket in the shutdown branch before returning, or move the gate above the ticket-taking site in
handleImplso a refused envelope never takes one. The second is arguably cleaner — a message that will be refused outright has no ordering to preserve — but it moves a shutdown check onto the transport thread, which is a deliberate placement decision (:333-341takes the ticket there precisely so send order is captured before the pool can reorder), so it deserves a moment's thought rather than a mechanical move.Whichever is chosen,
:1211-1218's wording should end up true rather than aspirational.