morph::backend provides the execution backends that own model instances and
dispatch actions against them. The abstraction spans several header files:
backend.hpp—ActionCall,IBackend, error types,LocalBackend.remote.hpp—RemoteServer,SimulatedRemoteBackend.qt/qt_websocket_backend.hpp/qt/qt_websocket_server.hpp(namespacemorph::qt) —QtWebSocketBackend(the client-sideIBackendover a real WebSocket transport) andQtWebSocketServer(the transport in front of aRemoteServer). These are the concrete remote transport that givesDisconnectedError,setReconnectHandler, and the reconnect lifecycle their meaning;SimulatedRemoteBackendis the in-process stand-in for the same shape.net/socket_backend.hpp/net/socket_server.hpp(namespacemorph::net, opt-in via the CMake optionMORPH_BUILD_NET, off by default) —SocketBackendandSocketServer, a Qt-free reference transport that speaks the same RFC 6455 WebSocket framing as the Qt transport above, over raw POSIX (BSD) sockets. Wire-interoperable withQtWebSocketBackend/QtWebSocketServer— both sides round-trip the samewire::Envelope.
Bridge (in bridge.hpp) holds one active backend at a time and can swap it
atomically via Bridge::switchBackend(). Every backend follows the same
contract: register and deregister models, dispatch actions, cancel pending work,
and react to backend changes.
- The dispatch struct —
ActionCall - The abstract interface —
IBackend - Connect/disconnect notifications
- Why registration needs a non-blocking path
- The structural registration surface —
bindModelandpromoteModel - Error types
LocalBackend— in-process executionRemoteServer— server-side message handler- Server-side observability
- Serving action schemas
PayloadCompleteness— enforcing the action-evolution policyLimitPolicy— opt-in resource limits- Connection scopes
SimulatedRemoteBackend— adapter for testingQtWebSocketBackend— client-side WebSocket transportQtWebSocketServer— server-side WebSocket transportSocketBackend/SocketServer— raw-socket WebSocket transport- Lifetime & ownership
- Failure modes
- Thread context
- API reference
executeInto— settling the caller's own completion- Design decisions
- Cross-references
- Limitations
detail::ActionCall bundles everything needed to dispatch one action. It has
three callables — one for each execution path — so the same ActionCall can be
used locally or serialised for a remote round-trip:
| Field | Type | Purpose |
|---|---|---|
modelTypeId |
std::string_view |
String id of the target model type (from ModelTraits). |
actionTypeId |
std::string_view |
String id of the action type (from ActionTraits). |
action |
std::shared_ptr<void> |
Type-erased owner of the action object the two callables below read. |
serializeAction |
std::string (*)(const void* action) |
Serialises action to JSON. Only called on the remote path. |
deserializeResult |
std::shared_ptr<void> (*)(std::string_view) |
Deserialises a JSON reply into the opaque result. Only called on the remote path. Never reads the action. |
localOp |
std::shared_ptr<void> (*)(IModelHolder&, void* action) |
Executes action directly against a model holder. Only called on the local path. |
localOpAsync |
void (*)(IModelHolder&, std::shared_ptr<void> action, const std::shared_ptr<TaskResumer>&, core::async::StopToken, LocalDone) |
Set instead of localOp when the action's handler returns core::async::Task: starts it on the model's strand and reports the result or exception through LocalDone when the Task completes. Only called on the local path. See coroutines.md. |
stopSource |
std::shared_ptr<core::async::StopSource> |
Null unless Bridge::executeVia armed an execute deadline for a Task handler; the deadline requests stop on it and LocalBackend hands its token to the handler. |
session |
morph::session::Context |
Session context. Local backends thread it through a thread-local before invoking localOp; remote backends serialise it into the wire envelope. |
There is one member function, serializeBody(), which pairs
serializeAction with the action it reads and throws
std::runtime_error if serializeAction is null. Every remote backend calls
it rather than invoking the pointer itself, so the borrow is closed in one
place instead of three.
Bridge::executeVia builds an ActionCall on every call, including calls
a LocalBackend serves and that therefore never touch serializeAction or
deserializeResult. The obvious alternative — three std::functions, each
capturing a shared_ptr<Action> — makes that apparatus cost heap allocations
whichever path the call takes: libstdc++'s small-object buffer is available only to a
trivially copyable target, and a captured shared_ptr is not one, so each
stateful callable allocated. Type ids carried as std::string copies of
compile-time constants allocate in the same way whenever an id exceeds the
15-character SSO threshold, so they are string_views here.
The behaviour of each callable is a constant of (Model, Action), not of the
call, so it is addressed rather than copied: a stateless function pointer,
parameterised on the action it operates on. The action object itself still
needs a home — it is the one genuinely per-call thing — and that home is
action, a single make_shared<Action>, which the type erasure needed anyway.
Measured with tests/bench/bench_dispatch_allocations.cpp
(morph_bench_alloc, clang 22.1.8 / libstdc++ 16.2.1, Release, 15 processes
per configuration), the std::function shape against this one: a local
Ping -> Pong round trip costs a median of 16.98 heap allocations per call
(range 16.89–17.05) against 13.79 (range 13.71–14.03), and 1245.7 against
1151.1 bytes per call. The three allocations are the modelTypeId string and
the serializeAction and localOp closure targets.
serializeAction and localOp borrow the action; action owns it.
A backend that defers either call beyond the ActionCall's own lifetime must
carry a copy of the action handle with it — LocalBackend::execute moves it
onto the strand alongside localOp for exactly that reason.
deserializeResult never reads the action, which is what lets
SocketBackend and QtWebSocketBackend park it in their pending-reply tables
long after the ActionCall is gone.
modelTypeId and actionTypeId are views, so their referents must outlive the
dispatch. The production path satisfies this by construction:
ModelTraits<M>::typeId() is constexpr and returns a view of the string
literal BRIDGE_REGISTER_MODEL was given. A hand-built ActionCall must use a
literal or a string that outlives the call, not a temporary.
detail::IBackend is the abstract interface every backend implements. Bridge
holds a unique_ptr<IBackend> and delegates all model operations to it.
| Method | Purpose |
|---|---|
registerModel(typeId, factory) |
Registers a new model instance, returns its opaque ModelId. |
registerModelWithContext(typeId, factory, contextKey) |
Same as registerModel, additionally passes a stable identity (e.g. account id). Default implementation drops contextKey and forwards to registerModel — correct for LocalBackend where the factory closure already captures identity. Every backend whose instances live behind a wire protocol carries contextKey across: SimulatedRemoteBackend and SocketBackend override it, and QtWebSocketBackend carries it on every bindModel (its synchronous verbs refuse). Not cosmetic — RemoteServer::attachLogIfConfigured skips the LogProvider lookup entirely on an empty contextKey, so a wire backend that drops the key leaves the instance with no action log rather than a log missing a field. |
bindModel(request, cbExec) |
Acquires a model instance and returns a Completion<ModelId> delivered on cbExec: a private instance, register-or-attach on a key, or a re-point, selected by the request's shape. The only acquire verb Bridge calls, with its owner as cbExec — see The structural registration surface. |
setOwner(affinity) |
Tells the backend the owner its caller (the Bridge) runs on; called when the bridge installs it. A backend that keeps state for the bridge's verbs records it and checks, in a debug build, that each verb runs there. Default: ignored. |
promoteModel(request, cbExec) |
Files an already-live instance under a key and returns a Completion<ModelId> delivered on cbExec. The structural counterpart of assignPrimary. |
instances(typeId, cbExec) |
Lists the live shared keys of typeId through a Completion<vector<string>> delivered on cbExec. What Bridge::instancesOf (and so BridgeHandler::instances()) asks. Default: answers from listInstances and settles before returning. |
deregisterModel(mid) |
Removes the model identified by mid. |
execute(mid, call, cbExec) |
Dispatches call against the model identified by mid. Returns a Completion<std::shared_ptr<void>>. |
notifyBackendChanged() |
Called by Bridge::switchBackend() after all handlers are re-registered. |
cancelPending(exc) |
Resolves every still-pending completion with exc. Called on the outgoing backend during switchBackend() and in Bridge's destructor. After this call, any later setValue/setException on those states is a no-op. |
setReconnectHandler(handler, exec) |
Installs a callback the backend posts to exec when it reconnects to its peer — never run on the backend's own thread. Fires only on the second and later connects, never the first — used by Bridge, which passes its owner, to re-bind its handlers after a drop. Used by backends with transport (QtWebSocketBackend, SocketBackend). nullptr clears it. Default implementation is a no-op. |
setConnectHandler(handler) |
Installs a callback invoked on every successful connect, including the first — the complementary hook setReconnectHandler deliberately skips (see Connect/disconnect notifications). Default implementation is a no-op. |
setDisconnectHandler(handler) |
Installs a callback invoked whenever the transport drops, before any reconnect is scheduled. Default implementation is a no-op. |
setSession(session) |
Installs the session::Context stamped onto every control envelope (register, registerShared, attach, assign, deregister) this backend subsequently builds. Pushed by Bridge::setDefaultSession() and Bridge::switchBackend(). Default implementation is a no-op. See Session propagation to control envelopes. |
setReconnectHandler exists so Bridge can re-register every live
HandlerBinding after a transport drop and re-establish; it deliberately
fires only on the second and later connects — on the first connect there
is nothing yet to re-register. That leaves two gaps a UI reflecting live
connection state needs closed:
- First connect.
waitForConnected()(where a concrete backend offers one, e.g.QtWebSocketBackend) answers this, but it blocks the calling thread. On a browser/WASM target that hangs the page outright; even on desktop it means blocking startup on a network round-trip. - Disconnect. There was no hook at all: a client learned the socket dropped only indirectly, when a later action failed.
setConnectHandler/setDisconnectHandler close both, on IBackend itself
(not only on QtWebSocketBackend) with the same no-op-default pattern
setReconnectHandler already established — a UI observing connection state
shouldn't have to downcast to a concrete backend type to do it, and a
backend with no meaningful connection state (LocalBackend) simply never
invokes either. setConnectHandler's callback fires on every successful
connect, first included; setDisconnectHandler's fires whenever the
transport drops, before any reconnect is scheduled, so an observer sees
the disconnected state even when a retry follows immediately (an instant
successful reconnect must not look, from the UI's perspective, like nothing
happened). Both are invoked on the backend's own thread, and nullptr
clears either. The reconnect handler differs on purpose: it re-binds the
bridge's handlers, which is owner work, so the backend posts it to the executor
it was installed with.
QtWebSocketBackend is currently the only backend that overrides either:
its connected/disconnected QWebSocket signal slots invoke
_connectHandler/_disconnectHandler (if installed) at the same points
they already invoke _reconnectHandler/schedule a reconnect — see that
section below.
Bridge::executeVia stamps Bridge::defaultSession() onto the ActionCall
passed to execute() (see bridge.md), so execute envelopes
always carry the current session. Control messages — register,
registerShared and attach (bindModel with a key), assign
(assignPrimary), and deregister (deregisterModel) — are different: each
is built directly inside the concrete backend, which has no other route to
the Bridge's session except IBackend::setSession. Every wire-backed
implementation stamps the session setSession last installed onto these
envelopes too, so RemoteServer::authorizeRegister sees the caller's
identity and the owner principal it records at register time reflects the
registering session — which is what IAuthorizer::authorizeInstance's
ownership check relies on for every instance a Bridge registers (see
session.md).
Bridge calls IBackend::setSession in two places: once from its
constructor (with the just-constructed, typically empty, default session) and
again every time setDefaultSession() installs a new one; switchBackend()
also calls it on the incoming backend, before its
per-binding re-registration loop runs, so every register/registerShared
envelope built while re-registering handlers on the new backend already
carries the current session. A wire-backed backend that overrides
setSession — SimulatedRemoteBackend, SocketBackend, QtWebSocketBackend
— stores the session and reads it back into every control envelope's
session field it subsequently builds. LocalBackend does not override
setSession: the local path never serialises a Context onto a wire
envelope, so there is nothing to stamp.
registerModel/registerModelWithContext are synchronous: a backend whose
registration requires a round trip can only implement them by blocking the
calling thread until the reply arrives. On the Qt thread that means a nested
QEventLoop, and on a WASM main thread Qt refuses to spin one at all
(WaitForMoreEvents is not supported on the main thread without asyncify), so
such a blocking call aborts the page; QtWebSocketBackend therefore refuses
the synchronous verbs (std::logic_error) and registers only through
bindModel. And a
BridgeHandler constructed inside a running action is on a pool thread that is
not its bridge's owner, so its registration has to be posted rather than made
there. Both reasons lead to one non-blocking acquire verb, bindModel, whose
reply is a Completion delivered on an executor the caller names.
Queueing before the first connect. A non-blocking private bind made
before QtWebSocketBackend's socket has finished connecting is queued, not
failed — the ordering a single-threaded WASM client must use, since it can never
block waiting for the connection to settle. The queued request is sent, in FIFO
order, the moment connected fires next (the first connect included, before
the reconnect handler is posted); if the socket is torn down before ever
connecting, cancelPending rejects each queued request's Completion exactly
once. A keyed bind carries no queue: it is rejected with "disconnected"
immediately. See QtWebSocketBackend's own section below.
Two verbs, on IBackend, carry every acquire and promote case:
| Verb | Signature | Covers |
|---|---|---|
bindModel |
virtual Completion<ModelId> bindModel(BindRequest, IExecutor& cbExec) |
A private instance, register-or-attach on a key, re-point to a key, give an instance up and bind privately |
promoteModel |
virtual Completion<ModelId> promoteModel(PromoteRequest, IExecutor& cbExec) |
Filing a live instance under a key (assignPrimary) |
BindRequest's shape — not a verb name — selects the behaviour:
primary |
current |
Meaning |
|---|---|---|
| empty | ModelId{0} |
Private instance, never enters the shared directory. |
| non-empty | ModelId{0} |
Register-or-attach on (typeId, primary). |
| non-empty | non-zero | Re-point from current to (typeId, primary). |
| empty | non-zero | Give current up and bind a private instance instead. |
One verb rather than one per case, because the cases degrade into each other
exactly this way (a register-or-attach with an empty key is a private
register; a re-point from nothing is a register-or-attach), and because every
case shares the one property that matters: the reply is a Completion, and
the caller names the executor it is delivered on. Both request types own their
strings: a bind may outlive the frame that issued it.
Completion<T> posts its handlers to the executor it was built with
(completion.md). Passing that executor into the verb makes the delivery
thread an argument rather than a property of the backend:
- The continuation runs where
cbExecsays, whatever thread the backend settles on. cbExecis a reference, not a pointer: "deliver nowhere" cannot be spelled.
Bridge passes its owner, so every bind and promote reply is a task on the
owner and touches the bridge's state there — see
bridge.md. A backend that
settles before returning (LocalBackend, SimulatedRemoteBackend, the
default) has its outcome read and applied by the call that issued it, so a
handler over it is bound when its constructor returns.
tests/test_backend_registration_surface.cpp pins the delivery: a backend
settles from a thread asserted not to be the caller's, and the continuation
runs only when the caller's MainThreadExecutor is drained, on the draining
thread.
A backend that overrides nothing gets IBackend's bindModel/promoteModel.
The default bindModel has no shared directory: every shape binds a private
instance through registerModelWithContext, and a non-zero current is
released once the replacement is acquired, so a throwing acquire never strands
the caller with neither instance. The default promoteModel calls
assignPrimary and echoes mid back, including for assignPrimary's
documented no-op cases, which are not failures. Both settle before returning,
and an exception from the verb they call rejects the Completion rather than
propagating, so a caller has one failure channel.
LocalBackend and SimulatedRemoteBackend override bindModel to keep their
shared directories (register, register-or-attach, re-point), still settling
before returning. QtWebSocketBackend and SocketBackend override it with a
genuinely non-blocking path.
A decorator (morph::backend::SynchronousBackendAdapter) that wraps an
IBackend and runs every verb of it on one control strand over an executor
named at construction:
auto local = std::make_shared<morph::backend::LocalBackend>(pool);
morph::backend::SynchronousBackendAdapter adapter{local, control};The control strand is the wrapped backend's one owner, so a backend that keeps
its state without a lock (LocalBackend) stays correct behind it, and the
caller's thread never pays for a blocking control call.
- It does not make blocking non-blocking. The wrapped backend still blocks.
What changes is which thread pays:
bindModel/promoteModelrun the wrapped backend's ownbindModel/promoteModelon the strand (withinlineExecutor(), since the strand is where it settles) and settle the caller'sCompletion, on the caller's executor, from there. A single-threaded WASM main thread has no such executor to offer, which is whyQtWebSocketBackendimplements the surface natively instead. - The executor is required. An adapter that ran the call inline when handed
nothing would be a
bindModelthat blocks on some configurations and not others. - It belongs to its caller's owner (
setOwner, not forwarded — the wrapped backend's owner is the strand).bindModel,promoteModelandcancelPendingkeep the list of pending binds there, without a lock. - It cancels its own pending binds rather than only the wrapped backend's.
A bind settles from a task on the strand, holding a promise the wrapped
backend never sees, so a plain forward of
cancelPendingwould reach none of them, and a bind cancelled by~BridgeorswitchBackendwould go on to resolve successfully afterwards. The adapter keeps aweak_ptrto each dispatched promise and rejects the live ones first (with the same amortised compaction asLocalBackend's pending list), then posts the wrapped backend's owncancelPendingto the strand. - It stops a control call the strand has not started yet. Each dispatched
task carries a
PendingControlrecord — its promise plus anatomic_bool cancelled— and checks the flag before running;cancelPendingsets it before rejecting. Otherwise a queued bind would still run after the caller was told it was cancelled, leaving an instance nothing will ever deregister. A task already running cannot be recalled. - The synchronous verbs wait for the strand (
registerModel,registerModelWithContext,assignPrimary,listInstances), so they must not be called from the executor's only thread. - Ordering and teardown. Every verb is queued on the one strand, in the
order issued.
~SynchronousBackendAdapterwaits for every queued and in-flight call (ModelStrands::drain), so the executor must still be running tasks when the adapter is destroyed. The single-threaded WebAssembly build has no thread to wait for, and drops the calls still queued.
QtWebSocketBackend and SocketBackend override bindModel and settle the
Completion when their reply arrives. The request-shape mapping is the same in
both — empty primary with a zero current is a private register; empty
primary with a live current gives that instance up first and then binds
privately; a non-empty primary with a zero current is a shared register;
with a live one it is an attach. They differ in transport and in gating:
SocketBackend::bindModelposts the request to the I/O loop and settles from the loop when the reply arrives — always non-blocking. See The structural registration surface, natively.QtWebSocketBackend::bindModelbuilds the envelope, assigns acallId, sends, and returns an unsettledCompletionthatonTextMessagesettles — always non-blocking.
There is one send path per backend (sendControl / sendControlAsync) and one
pending map, so the "encode before recording the pending entry" invariant and
the env.session stamp each exist once. tests/qt/test_qt_websocket.cpp pins
the Qt path end to end against a real RemoteServer (queue before connect,
register-or-attach, re-point, degrade-to-private, reject on a dead socket,
promote), and examples/common/testkit/test_wasm_registration_path_native.cpp
pins it through Bridge.
The bind is a
Completiondelivered on the bridge's owner. A backend that can settles it before returning.executedispatches at once when the binding'scurrentIdis set, and chains on the bind otherwise.
registerHandler never blocks and never waits: it issues bindModel with the
owner as the delivery executor and returns. A failed bind rejects every held
call with the bind's error; a handler destroyed while its bind is in flight
rejects every held call with HandlerDestroyedError. No caller gates on
registration.
| Backend | bindModel |
Bound when registerHandler returns |
|---|---|---|
LocalBackend, SimulatedRemoteBackend, test doubles that override nothing |
settles before returning | yes |
A backend wrapped in SynchronousBackendAdapter |
settles from the adapter's control strand | no — execute chains on the bind |
QtWebSocketBackend |
native, settles from onTextMessage |
no — execute chains on the bind |
SocketBackend |
native, settles from the I/O loop | no — execute chains on the bind |
A backend that violates the one-settle contract by firing twice cannot be
observed doing it from Bridge: CompletionState drops the second settle
before any bridge code sees it. A backend that throws out of bindModel —
IBackend is a public extension point — has the throw applied as the bind's
failure.
Five exception types are thrown into in-flight Completions. The first four are
raised by a backend; ClientTimeoutError is raised by Bridge itself, but is
declared alongside them so callers catch every dispatch failure from one header:
| Type | Trigger | Purpose |
|---|---|---|
BackendChangedError |
Bridge::switchBackend() runs |
GUI can retry on the new backend or surface a "backend changed" message. |
BridgeDestroyedError |
Bridge is destroyed |
In-flight completions are cancelled because the bridge is gone. |
DisconnectedError |
Transport drops mid-call (e.g. WebSocket disconnect) | Framework retries the call on reconnect if the backend supports it; otherwise the GUI's .onError(...) runs. |
TimeoutError |
Server-side LimitPolicy::executeTimeout elapses |
Distinguishes a bounded-wait timeout from any other err reply, so callers can retry or surface a specific "request timed out" message. |
ClientTimeoutError |
Client-side Bridge::setExecuteDeadline elapses with no reply of any kind |
Bounds the caller's wait when nothing comes back at all (a dropped frame, a hung server). Unlike TimeoutError it carries no evidence the server ever saw the request — see completion.md, "Client-side execute deadline". |
LocalBackend is the concrete in-process backend. It owns a
ModelStrands (core-cpp's KeyedStrands over the IExecutor& worker pool,
typically a ThreadPoolExecutor; see executor.md) and a
detail::InstanceDirectory holding its live model instances.
Both it and RemoteServer keep those instances in one
detail::InstanceDirectory (core/detail/instance_directory.hpp): one record
per instance holding its holder, owner principal, attach count, directory key
and hydration state, plus a (typeId, primary) index and a per-type index for
listInstances. It holds no lock: it belongs to its backend's owner — the
bridge's owner for LocalBackend, the server strand for RemoteServer — and
the neighbouring decisions (the maxLiveModels admission check, the
connection-scope update) are made in the same owner task, so none can straddle
a directory change. Register-or-attach, the lazy eviction of an instance whose
first action failed, and promotion by assignPrimary are each written once,
there, rather than once per backend.
One owner. The registry, the pending list and the record of running Task
handlers belong to the caller's owner (the bridge's, given through setOwner)
and are touched only there, without a lock; each verb checks it in a debug
build. Only the strand tasks the backend posts run elsewhere, and they reach
nothing of the backend but what each carries — the holder, the call, and the
cancel record below.
Lifecycle:
bindModel— overridden to keep the shared directory: register (private), register-or-attach (a key), or re-point (a key and a livecurrent), on the owner, settled before returning.registerModel— increments a counter, calls the factory, records the new id in_changeAwareif the holder'sisBackendChangeAware()istrue, files the holder in the instance directory, returns the newModelId.deregisterModel— releases one attachment through the instance directory, which unfiles and destroys the instance in a single step when the last one goes away;_changeAwareis erased only when the instance is actually destroyed.execute— looks up the holder; ifmidis unknown it immediately resolves the completion withstd::runtime_error("model not found: id=<n>"). Otherwise it tracks the completion in the pending list, postslocalOpon the model's strand (serialised per-model), sets up aScopedContext(fromcall.session) before callinglocalOp, and returns theCompletion. The strand task also emitsexecuteLatencyMs/executeInFlight/executeErrorsand callsbeginSpan/endSpanaroundlocalOp— see observability.md. BothregisterModelandderegisterModelemitregisterCount/deregisterCount.cancelPending— takes the pending list, deliversexcto every still-live state, requests stop on every Task handler still running (itsLocalRun::stopSource), and re-arms the compaction threshold. A run that has not started yet reads the cancel record its task carries and fails without starting: the record is an immutable node the owner pushes and publishes with a release store, read by the run on its strand with an acquire load. See The pending list and its amortised compaction.notifyBackendChanged— looks up only the models recorded in_changeAware(populated at registration fromIModelHolder::isBackendChangeAware()— a compile-time answer per model type, nodynamic_cast); then postsholder->onBackendChanged()(theIModelHolderbase virtual) onto each such model's strand (the holder captured byshared_ptr). Cost is O(change-aware models), not O(all models). Delivery is asynchronous and serialised against that model'sexecutetasks, on a pool thread rather than the owner. It runs without a session, even while a Task handler of that model instance is suspended: the strand installs a suspended handler's session around the coroutines resumed on its instance only, and a posted callable such as this one is not one of them (see coroutines.md, "The handler's resumer"). The same holds for an action queued behind the handler, which installs its own session when it starts. A detached chain that a finished handler A left behind is a resumption, though: when it comes back after handler B of the same instance started, it runs under B's session and B's resumer.setReconnectHandler/setConnectHandler/setDisconnectHandler— no-op (no transport to (dis)connect).setSession— not overridden (the default no-op stands): the local path never serialises aContextonto a wire envelope, so there is nothing to stamp.
Each model instance gets its own strand so actions are serialised per-model without a global lock on the pool.
_pending is a vector<weak_ptr<CompletionState<shared_ptr<void>>>> owned by
the backend's owner. It exists for exactly one reader — cancelPending, which swaps it
out and fails everything still live on a backend swap or a ~Bridge. Nothing
else consults it, and nothing unlinks from it when a completion settles: an entry
simply becomes a dead weak_ptr that cancelPending's weak.lock() skips.
Dead entries therefore have to be reclaimed by a sweep, and the question is only
how often. Sweeping on every execute makes admitting one call cost one
atomic weak_ptr::expired() load per entry already in the list, under the mutex, before any work starts — so a
burst of n costs O(n²). Measured against one parked model on an 8-core Linux
box (clang 22.1.8, -O2), timing only the execute() calls themselves:
| queued executes | total admission time | mean per admission | mean over the last 10% |
|---|---|---|---|
| 1 000 | 0.59 ms | 0.59 µs | 0.82 µs |
| 4 000 | 5.01 ms | 1.25 µs | 2.19 µs |
| 16 000 | 77.8 ms | 4.86 µs | 9.57 µs |
| 32 000 | 362 ms | 11.3 µs | 24.1 µs |
Total time quadruples per doubling of n and the per-admission cost doubles — the O(n²)/O(n) pair, not an artefact of some constant.
The sweep is now amortised: trackPending sweeps only when _pending.size()
reaches _compactAt, and each sweep re-arms _compactAt at twice the number of
entries that survived it (floor 32, below which sweeping costs more than it
reclaims). A sweep costs O(size) and at least _compactAt / 2 appends must
happen before the next one, so admission is amortised O(1) at any depth. On the
same benchmark the 32 000-execute case drops from 362 ms to 7.6 ms — within noise
of the 7.6 ms measured with the sweep deleted outright, so what is left is the
make_shared, the registry lookup and the strand post, not the sweep.
Two properties are the price and the guarantee:
- Memory. The list is bounded at twice the live count plus the floor, rather
than at exactly the live count.
trackedPendingCount()makes that observable; the fixture intests/test_backend_extra.cppdrives 3 072 settled-and-dropped admissions past 48 parked ones and measures 112 entries left, against the 3 120 an uncompacted list would hold. cancelPendingis unchanged. It never saw dead entries in the first place —weak.lock()has always skipped them — so carrying them for longer changes nothing it observes. Every live state admitted across every sweep is still reached, which is what the same fixture's second assertion checks.cancelPendingalso resets_compactAtto the floor, since it has just emptied the list.
What is not guarded by a test is the admission latency itself: a wall-clock assertion on a shared CI runner would be a flake rather than evidence, so the numbers above come from a benchmark and the test guards only the bound and the cancellation.
RemoteServer receives JSON envelopes (morph::wire::Envelope) from any
transport and executes the corresponding model operations via an
ActionDispatcher. Authorization is delegated to an IAuthorizer that defaults
to allow-all. It derives from std::enable_shared_from_this<RemoteServer>.
One owner: the server strand. The server owns one strand over its worker
pool (exec::OwnerStrand, reachable as strand()). Its state — the instance
registry and shared-instance directory, the connection scopes, the in-flight
count, the drain waiters, ready and the shutdown flag — is touched only in
tasks on that strand, so none of it has a lock. Configuration is a
ServerConfig, fixed at construction and read without a lock because nothing
writes it afterwards. The model instances themselves run on their own strands
(ModelStrands, one per ModelId), as on LocalBackend.
Cross-thread surface. Every public member is callable from any thread:
| Member | What crosses | Answered |
|---|---|---|
handle(msg, reply[, cid]) |
Decodes on the calling thread, posts one task to the server strand | reply, once, from a pool thread |
handleInline(msg[, cid]) |
Posts a control envelope to the server strand and waits (inline when already on it) | Its return value |
openConnection() |
Draws the id from an atomic, posts the scope's creation | The id, at once |
closeConnection(cid) |
Posts | — |
health(replyExec) |
Posts | A Completion<HealthStatus> settled on the strand, delivered on replyExec |
beginShutdown() |
Posts | — |
drainedWithin(deadline, replyExec) |
Posts | A Completion<bool> settled on the strand, delivered on replyExec |
payloadCompleteness(), strand() |
Read immutable members | At once |
A task posted to the server strand by one thread runs after every task that
thread posted before it, so a verb called after another on one thread sees its
effect: an envelope handed to handle() after beginShutdown() returned is
refused; a health() asked after closeConnection() counts the reclaimed
models as gone.
Must be heap-allocated via std::make_shared. Every task the server posts
— to its own strand, to a model's strand, to its timers — captures
shared_from_this(), so the server outlives its queued work however the last
external reference is dropped.
Wire format. All requests and replies are JSON morph::wire::Envelope. The
kind field discriminates the message types:
kind |
Request fields | Reply | Notes |
|---|---|---|---|
register |
typeId, [contextKey] |
ok with modelId (body empty) |
Authenticates the caller (_authorizer->authenticate(env.session)), stamping the verified principal onto env.session.principal (clearing it when unauthenticated) exactly as execute does, then consults _authorizer->authorizeRegister(env.session, typeId) — a false reply is err "unauthorized" and no instance is created. Only then creates the model via the ModelRegistryFactory and records the (already-verified) principal as its owner. Empty typeId → err "register requires a typeId" (checked before authorization). If contextKey is non-empty, consults ServerConfig::logProvider (if set) and, when it returns a non-null log, calls holder->attachActionLog(log, contextKey). The assigned modelId is an opaque (non-sequential) value — see below. |
deregister |
modelId |
ok or err |
Consults authorizeInstance against the recorded owner (denied → err "unauthorized"); otherwise erases the model and its owner entry from the registry. |
execute |
modelId, modelType, actionType, body, session |
ok with body or err |
See the execute flow below. |
hello |
protocolVersion |
ok with body = ProtocolRange, or err "protocol version unsupported" |
Protocol-version negotiation, exchanged once per connection before any register/execute. Carries no session and is not authorized — orthogonal to IAuthorizer. The range's capabilities lists "cancel". See wire.md. |
cancel |
cancelCallId, session |
ok (the cancel's own callId), always |
Stamps the verified principal, then requests stop on the run of the execute filed under cancelCallId on the same connection, if the cancel's verified principal is the execute's and the cancel passes the execute's own authorize and authorizeInstance. Anything else — unknown, finished, another connection's, another principal's, refused, unscoped — is the same ok and changes nothing. See wire.md. |
schemas |
typeId, session |
ok with body = {actionType: schema}, or err |
Empty typeId → err "schemas requires a typeId" (checked before authorization). Authenticates, then consults _authorizer->authorize(env.session, typeId, {}) — the same type-level read hook instances uses; denied → err "unauthorized". Answers from ActionDispatcher::schemasJson(typeId); a type with no registered actions yields {}, not an error. See Serving action schemas. |
Execute flow. Admission runs on the server strand (dispatchExecute), the
action on the model's strand. Admission, in order:
- In-flight cap. With
LimitPolicy::maxInFlightExecutesset and the in-flight count at it →err "server busy". The count is server-strand state, so the check and the increment in step 7 cannot be separated by another admission: the cap is exact. - Authorize.
_authorizer->authorize(env.session, env.modelType, env.actionType). Denied →err "unauthorized"(with the request'scallId), no dispatch. - Authenticate / make the principal authoritative. After
authorizesucceeds, the server calls_authorizer->authenticate(env.session). If it returns a value, the server overwritesenv.session.principalwith that verified principal before building theScopedContext. So model code that readssession::current()->principalon the remote path sees the identity the authorizer extracted from a valid token, not the client's asserted claim (Context::principalis untrusted wire input on its own). Ifauthenticatereturnsnullopt— a non-verifying authorizer, including the defaultAllowAllAuthorizer, or a token that passedauthorizebut expired in the window beforeauthenticate— the server clearsenv.session.principalto the empty string. The client's unverified claim is therefore never presented to the model as authoritative: the worst case is an empty principal, never an attacker-chosen one (see security.md). This is the only place the principal is made authoritative; the verifying implementation lives inSigningAuthorizer(session_auth.hpp, cross-ref security.md). - Look up the model in the registry, with its recorded owner and
hydration state. Missing →
err "model not found"(withcallId), no dispatch. (The remote message is the bare string"model not found", without the id — unlike theLocalBackendpath, which resolves the completion withstd::runtime_error("model not found: id=<n>").) - Per-instance authorize.
authorizeabove saw only the model type; this step consults_authorizer->authorizeInstance(env.session, env.modelType, env.actionType, modelId, owner)with the target instance id and its recorded owner. Denied →err "unauthorized"(withcallId), no dispatch. The default hook allows all;env.sessionalready carries the verified principal stamped in step 3, so the hook compares the recorded owner against it. - Payload completeness (opt-in; see
PayloadCompleteness). - Reserve the in-flight slot, arm
LimitPolicy::executeTimeoutif one is configured, file the call as cancellable if its handler is a Task and it arrived on a connection scope (see thecancelrow above), and post to the model's strand a task that enters the instance's action gate, installs aScopedContextfrom the (now possibly rewritten)env.session, callsdispatch(modelType, actionType, *holder, body)on the server's dispatcher, and repliesokwith the serialised result. Anystd::exceptionthrown by the dispatch is caught on the strand and returned aserr exc.what()with thecallId. The task holds the server (shared_from_this()) and the model's holder, so neither can go before the reply is delivered; it reads the dispatcher viaself->_dispatcher, never a bare reference capture.
The reply is sent exactly once, by whichever of the model strand's finish and
the executeTimeout gets there first. Before sending it, that path posts the
in-flight decrement — and the call's withdrawal from the cancellable calls —
back to the server strand, so a health() or
drainedWithin() asked by someone who has seen the reply already counts it as
finished.
Any envelope that fails to decode produces err carrying the decode
exception's message, and a log line (see Server-side
observability). An unrecognised kind produces
err "unknown envelope kind: <kind>". Any std::exception thrown while
handling a decoded envelope — including one out of an IAuthorizer hook during
admission — is caught and returned as an err reply carrying exc.what() and
the request's callId. Nothing is held across it: the next envelope is simply
the next task on the server strand.
Opaque model ids. RemoteServer assigns each new instance's id by running
an internal monotonic counter through detail::OpaqueIdGenerator — a keyed,
4-round Feistel permutation over the 64-bit space, keyed once at construction
from std::random_device. The Feistel structure guarantees the mapping is a
bijection, so two different counter values never collide (uniqueness holds for
the server's whole lifetime, short of the practically-unreachable 2^64
wraparound); the per-round secret keys are what make the output opaque
rather than merely "scrambled" — an attacker who observes one id cannot invert
a public, unkeyed mixing function to recover the counter and predict the next
one. ModelId's reserved sentinel 0 ("unbound", see strand.hpp) is
actively skipped: RemoteServer draws a fresh counter value and re-permutes
if the result is ever 0 (a 1-in-2^64 event for a random key). Ids remain
plain std::uint64_t on the wire — the Envelope and its modelId field are
unchanged; only the assigned values are not sequential. This is
defence-in-depth, not a substitute for authorizeInstance: a caller who
independently learns a valid id (from its own register, or a leak) can still
target it, so per-instance ownership remains the actual authorization
boundary — see security.md.
handle(msg, reply) — asynchronous entry point. Decodes the envelope once,
on the calling thread, and posts it to the server strand, which dispatches by
kind and calls reply exactly once. A three-argument overload,
handle(msg, reply, cid), additionally attributes any register in msg to a
connection scope — see "Connection scopes" below.
handleInline(msg) — synchronous entry point for control envelopes
(register, deregister, attach, assign, instances, schemas,
hello, cancel), the path SimulatedRemoteBackend uses. It posts the envelope to the
server strand and blocks until the reply is written; when the caller is
already on the server strand — host code the strand itself is running, such as
a model constructor or a LogProvider that registers another model — it runs
inline instead, since waiting there would wait on itself. It rejects
execute up front: an execute reply is produced later, on the model's
strand, after handleInline would have returned, so handleInline decodes
the envelope first and, if its kind is execute, returns an err reply
("handleInline does not support execute (reply is asynchronous)") without
dispatching. A malformed envelope is dispatched like any other and gets the
canonical decode-error reply.
Why it waits rather than running inline. Running a control envelope inline
on the caller's thread would touch the registry off its owner. The wait is
safe where the caller is not the thread the server strand needs: the strand
runs on the pool, so a pool thread inside a running action (a handler
constructed from an action handler — the "synchronous re-entry" case in
concurrency_and_lifetimes.md)
waits while another pool thread runs the strand's task. It cannot be satisfied
when no other pool thread is free: a one-thread pool calling handleInline
from inside its own task, or every pool thread blocked in handleInline at
once. That is the price of one owner on a verb that must answer synchronously;
registration becoming asynchronous removes it.
ServerConfig — everything configured rather than learned, given to the
constructor (RemoteServer(pool[, authorizer], config[, dispatcher, registry])) and fixed for the server's life. No member of RemoteServer
changes it.
| Field | Default | Meaning |
|---|---|---|
limits |
all 0 |
LimitPolicy, below. |
logProvider |
null | LogProvider consulted, on the server strand, whenever an instance is constructed with a non-empty contextKey (every register, and every attach that creates). How RemoteServer attaches action logs to instances it creates on behalf of remote clients — the factory closure, on the client side, cannot capture a server-side log. |
healthHandler |
null | Called with the server's HealthStatus once from the constructor (ready, nothing live, nothing in flight), and again on the server strand when readiness changes (beginShutdown()). |
minProtocolVersion, maxProtocolVersion |
kProtocolVersion both |
The inclusive range answered to hello. min > max makes the constructor throw std::invalid_argument. |
payloadCompleteness |
Lenient |
See PayloadCompleteness. |
using LogProvider = std::function<std::shared_ptr<morph::journal::IActionLog>(
std::string_view modelType, std::string_view contextKey)>;A deployment that wants a different configuration constructs a different server; nothing in the server needs to reason about a limit or a range changing under an admission it is making.
health() — a readiness snapshot, detailed in
observability.md. Returns a Completion<HealthStatus>
settled on the server strand, HealthStatus{ready, liveModels, inFlight}:
liveModels from the registry, inFlight from the in-flight count — the same
count LimitPolicy::maxInFlightExecutes gates, drainedWithin() waits on and
the executeInFlight metric reports. ready starts true and is flipped to
false, once and for good, by beginShutdown() — there is no un-shutdown.
The completion is delivered on the replyExec the caller passes, the owner it
attaches its callbacks from: a Completion's handlers are attached and run on
one executor, and only the caller knows which one it is on.
Metrics and tracing. The register/deregister branches emit
registerCount/deregisterCount; admission and the in-flight decrement emit
executeInFlight (both on the server strand), and the model strand's task
emits executeLatencyMs/executeErrors and calls beginSpan/endSpan around
the ActionDispatcher::dispatch call. All are no-ops unless a sink is installed
via morph::observe::setMetricSink/setTraceSink — see
observability.md.
The guarantee:
For one
modelId, the order in whichhandle()was called for itsexecuteenvelopes is the order the model runs them.
It is two strands' order, composed. handle() posts each envelope to the
server strand in the order it is called; the server strand admits them in that
order and posts each admitted one to its model's strand in that order; the
model's strand runs its tasks in the order they were posted. Nothing between
the transport and the model runs concurrently with another request for the
same model, so there is no window in which two of them could swap.
Why admission is on the server strand, not on the model's strand. A
single hop — the transport posting an execute straight to its model's strand
and admitting it there — is shorter, but admission reads the registry, which
belongs to the server strand. It would also queue a lookup behind the model's
running action: an execute for a model whose connection just closed would not
learn "model not found" until the action in front of it finished
(tests/test_remote_connection_scope.cpp, "an in-flight execute completes
safely across a disconnect", holds that it answers at once). The server strand
never runs a handler, only admissions and control envelopes, so a rejection is
answered in its own turn and never waits on a busy model.
The cost is that admissions for every model are serial: the authorizer's hooks, the registry lookup and (when enabled) the payload-completeness parse run one at a time on the server strand. The handlers themselves still run in parallel, one strand per model.
What is not ordered. Only same-model requests, and only relative to
handle() call order:
- Across models, execution is not ordered — each model's strand runs independently; only their admissions share the server strand.
- Across threads, "send order" means "
handle()call order". A single transport connection delivers messages on one thread, so for one client the two coincide. Two connections callinghandle()concurrently for the same model have no send order between them to preserve, and get whichever order their posts reach the server strand in. - Completion order is not constrained beyond what the model's action gate gives: how long each action takes is the model's business.
tests/test_remote_execute_ordering.cpp pins the guarantee: a first request
whose admission is held for 200ms on a two-thread pool still runs before the
request sent after it; a request refused at shutdown or by a throwing
authorizer hook, or rejected between two others, holds nothing for the ones
around it; and two threads calling handle() for one model against a
one-thread pool, with one of them stalled inside the pool's post, neither
deadlock nor swap.
ServerConfig::minProtocolVersion/maxProtocolVersion set the inclusive
range this server advertises in reply to "hello". Both default to
kProtocolVersion — this build's single supported version — so an
unconfigured server's behavior only changes for clients that opt into sending
"hello" in the first place. An empty range (min > max) makes the
constructor throw std::invalid_argument. See
wire.md for the full negotiation story,
including how SimulatedRemoteBackend (synchronously, over handleInline)
and QtWebSocketBackend (as a Completion settled by the reply) each expose
an opt-in negotiateProtocolVersion.
RemoteServer answers the "schemas" kind with
ActionDispatcher::schemasJson(typeId) — the {actionType: schema} document
for every action registered under that model type, each schema carrying the
action's x-payloadFingerprint and x-payloadShape. It is the only way a
client that is not linked against the model's C++ can learn an action's shape.
The gate is authorize(session, typeId, {}), deliberately the same hook
"instances" uses: a description discloses field names, bounds, rules and the
payload fingerprint of every action on the type, so it must not be reachable by
a caller the server would not let execute — while still letting a deployer
refuse describing a type without refusing using it.
SimulatedRemoteBackend::fetchActionSchemas(typeId) is the client-side
accessor, built on the same synchronous handleInline path as
registerModel/negotiateProtocolVersion and opt-in in the same sense. See
wire.md, "Serving action schemas".
ServerConfig::payloadCompleteness chooses whether an execute body must
carry every field the action's served schema marks required
(payloadCompleteness() reads it back):
| Value | Behaviour |
|---|---|
Lenient (default) |
The action codec's lenient decode is the only gate, and a missing field is a default-constructed one. |
RequireDeclaredFields |
An execute whose body carries no key for a required field is refused with err "payload missing required field(s): <names>", before the in-flight slot is reserved and before Model::execute runs. |
The check runs after authorization (its diagnostic names the action's own
field, which is exactly what "schemas" discloses and so is owed the same
gate) and before the in-flight reservation, so a payload that will not be
dispatched never consumes a slot. It costs one extra parse of body, which is
part of why it is not on by default; the deciding reason is that enabling it by
default would itself be the non-additive wire change the policy it enforces
forbids without a kProtocolVersion bump. Read the full derivation — including
what is not mechanically checkable — in
wire.md, "Enforcing the policy".
ServerConfig::limits is an optional, connection-agnostic resource policy.
Every field defaults to 0 ("unbounded"), so an unconfigured server applies no
limit:
| Field | Default | Enforcement |
|---|---|---|
executeTimeout |
0 (disabled) |
A timer arms when an execute is admitted. If it fires first, the server replies err "timeout" and the eventual strand result (if the model finishes later) is discarded via a shared once-flag — handle()'s reply-exactly-once contract holds regardless of which path resolves first. An ordinary handler keeps running to completion on its strand; morph never interrupts it. A handler returning core::async::Task is also asked to stop, through its stop token, and unwinds at its next stop-aware co_await (see docs/spec/core/coroutines.md, "Execute deadlines"). |
maxLiveModels |
0 (unbounded) |
Checked on the server strand before register constructs a new instance, and again after the construction and before the insert (the construction is host code that may itself register through handleInline); over the cap → err "too many models". Exact: nothing else inserts between the check and the insert. A shared register/attach that finds its key already live takes a reference and is not counted against the cap. |
maxInFlightExecutes |
0 (unbounded) |
The in-flight count, server-strand state, incremented when an execute is admitted and decremented (posted back to the strand) when its reply is sent — success, exception, or timeout, whichever resolves the call first; at the cap → err "server busy", no dispatch. Exact, for the same reason. |
A server-side execute timeout surfaces to a caller as morph::backend::TimeoutError
(alongside BackendChangedError/BridgeDestroyedError/DisconnectedError) rather
than a generic std::runtime_error, on both SimulatedRemoteBackend and
QtWebSocketBackend.
The timer that enforces executeTimeout is
morph::async::detail::TimeoutScheduler (include/morph/core/timeout_scheduler.hpp),
one per RemoteServer, created by the constructor when executeTimeout is
positive — so a server that does not use the feature pays nothing — on a
private exec::IoLoop. Created once and never replaced, it is cancelled from
the model strand's finish without a lock. The class lives in
morph::async::detail rather than morph::backend::detail because Bridge
uses the same primitive for the client-side setExecuteDeadline — see
completion.md, "Client-side execute deadline".
RemoteServer can optionally attribute registered models to a
transport-assigned connection, so a transport can reclaim every model a
connection created when that connection goes away. ConnectionId
(morph::backend::ConnectionId) is a std::uint64_t alias; 0 is reserved
and means unscoped — the meaning the two-argument handle() and
handleInline() always have. Scoping is strictly opt-in; enabling it changes
nothing for a caller that never uses it.
openConnection()returns a fresh non-zeroConnectionIdat once and posts the creation of its empty scope to the server strand, ahead of anyhandle()the same thread makes next. Call once per accepted transport connection.- The scoped
handle(msg, reply, cid)overload attributes anyregister(or register-or-attachattach) decoded frommsgtocid's scope: theModelIdis recorded in acid → (ModelId → count)map, server-strand state next to the instance directory, so scope membership can never desync from instance existence. The count lets one connection hold more than one reference to the same shared instance (e.g. two handlers on one connection attaching the same key) without either reference leaking the other's release. - A
deregisterreleases exactly the reference the requesting connection holds — decrementingcid's own scope entry for thatModelId, using thecidthe deregister call itself carries, never whichever connection happened to attach the instance last. A shared instance can have several owning connections at once; crediting the release to the wrong one would either strand a reference no one will ever decrement, or let one connection's deregister silently consume another's hold. A connection that holds no reference to the id — it never attached it, has already released every reference it had, or its scope is closed — releases nothing, and is answeredok, as an unknown id is. Every shipped client sendsderegisterfire-and-forget and reads no reply, and anerrthere would only let a caller tell a live id it does not hold from one that is gone. The same rule covers the instance anattachre-point gives up: it is released only if the requesting connection holds it. The unscoped path (ConnectionId0) records no references, so itsderegisteralways releases one. closeConnection(cid)posts, to the server strand, the erasure of every model still recorded incid's scope (its directory record and the per-instance connection entry) exactly as thederegisterpath does, then drops the scope itself. Passing0, an unknowncid, or acidalready closed is a no-op — idempotent by construction.closeConnectiondoes not consultIAuthorizer. It is server-side housekeeping triggered by the transport observing its own connection close, not a caller-attributed action — synthesising aderegisterenvelope instead would need a session/token to passauthorizeInstance, which an ownership-enforcing authorizer would rightly reject, and would require the transport to parse everyregisterreply to learn which ids it owns. Only in-process transport code can reachcloseConnection, and the transport is already inside the server's trust boundary (see security.md).- Cleanup never races a running
execute: admission copies the instance'sshared_ptr<IModelHolder>into the model strand's task, so an in-flight action keeps the holder alive until its task completes;closeConnectiononly removes the registry's reference, preventing new lookups (see concurrency_and_lifetimes.md). - A
registerthat reaches the server strand after its scope was closed is refused witherr "connection closed"and no instance is retained. The transport's disconnect callback can postcloseConnectionwhile aregisterthe client sent just before dropping is still queued behind it. The scope is looked up, never default-created: recreating it would strand that model (and every later one on the dead id) in a scope nothing closes a second time — an unbounded leak that, withLimitPolicy::maxLiveModelsset, wedges the server permanently aterr "too many models". SimulatedRemoteBackendkeeps using the unscoped path (its "connection" is the process itself) — it is unaffected by connection scopes.
beginShutdown() enters shutdown: every subsequent register, attach and
execute envelope is rejected with err "server shutting down" (checked once,
first, on the server strand — before authorization or registry lookups run);
deregister (and any other envelope kind) is still served so clients can tear
down cleanly during the drain window. It is posted, so an envelope handed to
handle() after it returned is refused, and one admitted before it — even one
the same client sent a moment earlier — runs to its reply. Idempotent, and
irreversible — there is no un-shutdown; a restarted service constructs a fresh
RemoteServer. It also flips health()'s ready to false and, if
ServerConfig::healthHandler is set, calls it with the post-shutdown snapshot,
on the server strand — the mechanism that lets an orchestrator stop routing to
a server that is draining.
drainedWithin(deadline, replyExec) returns a Completion<bool> settled on the
server strand and delivered on replyExec, the caller's owner: true once the in-flight count is zero — at once if it already is —
or false when deadline elapses first (a deadline of 0 answers from the
count as it stands). A waiter is server-strand state; its deadline is a timer
on a TimeoutScheduler the strand creates on first use, whose expiry is posted
back to the strand. Nothing blocks: a caller that wants to wait attaches to the
completion. "In-flight" is the count LimitPolicy::maxInFlightExecutes gates
and health()'s inFlight field reads: incremented when an execute is
admitted and decremented, on the strand, as its reply is sent on every
resolving path (ok, err, or a LimitPolicy::executeTimeout firing first).
The standard sequence an operator (or QtWebSocketServer::closeGracefully,
below) follows is beginShutdown() then drainedWithin(deadline): new work
fails fast while old work finishes, and once drained the existing teardown
rules (concurrency_and_lifetimes.md) apply
trivially, because every queue is already empty. morph never preempts a
running action to force a drain — a model that can run unboundedly long
bounds itself; the deadline bounds the caller's wait, not the model.
morph::log exists and both RemoteServer and QtWebSocketServer have
access to it, but several outcomes a client cannot distinguish from each
other used to produce no server-side record at all — the operator questions
"did the request arrive? was it rejected? how many clients are connected?
why did that one drop?" had no server-side answer. Four points now log, each
a one-line call at a point the code already reaches:
RemoteServer::replyUndecodable— undecodable envelope (logError, prefixed[dispatchMessage]). Without it, a client that swallows its own error (or is malformed precisely because it is confused) leaves no trace of a request that never dispatched at all. Logs the connection id, the exception text, the byte count, and a truncated (256-byte) prefix of the raw payload. The payload is the most useful field for diagnosing why a client sent something malformed — and the most likely to contain application data, hence truncated rather than logged in full; server logs should already be treated as operationally sensitive (they also carry exception text and, on other lines, connection ids), and this is not a general redaction mechanism.RemoteServer::dispatchEnvelope— one line per successfully-decoded request (logDebug, prefixed[dispatchMessage]).dispatchEnvelopeis the one place every kind funnels through, on the server strand, so it is the natural spot: connection id,kind,callId,typeId/modelId/modelType/actionType(whichever the kind populates), and the body's byte count. Deliberately omits the session principal (personal data in many deployments; attribution is still possible after the fact by correlatingcallId/connection id with the journal or action log where authentication is configured) and the request body itself (already covered, truncated, on the decode-failure path above — logging every successful body by default would be far higher volume and duplicate what the action log already records forexecute). Atdebugbecause of that volume; a deployment that wants a quieter default raisesmorph::log::setLogLeveltoinfoor higher (the library's own default minimum level isdebug— seelogger.hpp— so this line does appear unless a deployer has already opted into a quieter stream).QtWebSocketServer::onNewConnection— connection refused bymaxConnections(logWarn). To the client this looks exactly like the server being down; the operator previously had no way to learn the cap was hit, which is the one piece of information that would explain the symptom. Names the current live count and the configured cap.QtWebSocketServer::onNewConnection/onDisconnected— connect and disconnect (logInfo). Neither was recorded before, so there was no way to reconstruct how many clients were live, or why one went away. Connect logs the connection id and the live count (including the new connection); disconnect logs the connection id, the live count (after removal), and the WebSocket close code + reason — captured from the socket before it is torn down, since those are the useful part of "why did that one drop?".
SimulatedRemoteBackend implements IBackend by forwarding all calls through
a RemoteServer. Control operations (registerModel, registerModelWithContext,
deregisterModel) are forwarded synchronously via RemoteServer::handleInline;
execute is forwarded asynchronously via RemoteServer::handle and returns a
Completion that resolves on the server's reply. Intended for testing and
in-process simulation of remote execution.
Key design choices:
registerModelandregisterModelWithContextusehandleInline(synchronous control message) — thefactoryargument is ignored because model construction is delegated to the server'sModelRegistryFactory.deregisterModellikewise useshandleInline.negotiateProtocolVersionsends a"hello"viahandleInlineand classifies the reply withwire::interpretHelloReply— opt-in, not called automatically (see wire.md).executeserialises the action viacall.serializeAction(), builds anexecuteenvelope, callshandle()(asynchronous), and returns aCompletionthat resolves when the server's reply is deserialised viacall.deserializeResult().notifyBackendChangedis a no-op — models live in theRemoteServer, not locally.cancelPendingsnapshots and resolves pending completions, same pattern asLocalBackend.setSessionstores the session (guarded by its own mutex); every subsequently builtregister/registerShared/attach/assign/deregisterenvelope'ssessionfield is set from it before thehandleInlinecall — see Session propagation to control envelopes.
Connection scope. The default constructor, SimulatedRemoteBackend(RemoteServer&),
carries ConnectionId{0} — the server's "unscoped" sentinel — on every call it
makes, exactly as before connection scopes existed. A second constructor,
SimulatedRemoteBackend(RemoteServer&, ConnectionId), takes a ConnectionId
obtained from server.openConnection() and threads it through every
register/registerShared/attach/assign/instances/deregister/execute
call this backend makes (via the three-argument RemoteServer::handle/
two-argument handleInline(msg, cid) overloads) — the in-process equivalent of
what QtWebSocketServer/morph::net::SocketServer give a real transport
connection (see "Connection scopes" above). This lets a test construct several
independently-scoped simulated clients against one RemoteServer and exercise
connection-scoped state deterministically: two such backends sharing a key
reach one instance and their deregisterModel/closeConnection release only
their own reference, and closeConnection(cid) reclaims exactly what that one
backend registered. The backend does not call closeConnection on its own
destruction — unlike a real socket there is no single unambiguous "this
connection is gone" moment to hook here — so a caller that wants the scope
reclaimed calls server.closeConnection(cid) explicitly (or lets the server
itself be destroyed).
morph::qt::QtWebSocketBackend is the concrete IBackend that talks to a
RemoteServer over a real WebSocket (ws:// or wss://). It owns a
QWebSocket and opens the connection to serverUrl in its constructor. It holds
no local model objects — every model lives on the server, exactly like
SimulatedRemoteBackend, but across an actual socket instead of an in-process
call.
Threading. Single-threaded: must be constructed and used on the Qt event
loop thread. Every verb, the QWebSocket signal slots and the reconnect timer
run on the socket's thread — a Bridge over it calls from its owner, which
for a Qt application is that thread — so _connected, _nextCallId, the
reconnect state and the pending tables (_pending, _pendingControl,
_queuedRegistrations, _pendingDeregisters) need no lock. Each site that
touches the tables asserts, in a debug build, that it runs on the socket's
thread. The reconnect handler is posted to the executor it was installed with,
never run from the connected slot.
Every request is asynchronous. Each one carries a fresh non-zero callId
from the one counter (++_nextCallId), and its reply settles a Completion
delivered on the executor the caller names. Nothing waits for a reply inside a
call: on the Qt thread waiting would mean a nested QEventLoop, which a WASM
main thread cannot spin.
bindModel— the request's shape selectsregister, a sharedregister, orattach(see Backends with a genuinely non-blocking path). Every shape carriescontextKey: the server constructs the holder itself, andRemoteServer::attachLogIfConfiguredreturns before consulting itsLogProviderwhen the envelope'scontextKeyis empty, so a dropped key leaves the instance unjournalled.tests/qt/test_qt_websocket.cpp, "a private registration carries contextKey to the server's log provider", pins it against a realQtWebSocketServerand asserts on the provider. A private bind made before the socket has finished connecting is queued (_queuedRegistrations) rather than failed, and flushed — each entry assigned a call-id and sent, in FIFO order — from theconnectedslot, the first connect included, before the reconnect handler is posted. If the backend is destroyed (or the socket disconnects) before the queue is flushed,cancelPendingdrains it and rejects each entry'sCompletionexactly once. A keyed bind on a disconnected socket rejects at once.promoteModel— sendsassign; the documented no-op cases resolve without sending.instances(typeId, cbExec)— sendsinstances; the reply's body is the key list.BridgeHandler::instances()reaches it throughBridge::instancesOf.negotiateProtocolVersion(replyExec)— sendshelloand classifies the reply (see Protocol negotiation).registerModel(and soregisterModelWithContext, whichIBackendforwards to it),assignPrimary,listInstances— the synchronousIBackendverbs that would have to wait for a reply — throwstd::logic_errornaming their completion form.deregisterModel— fire-and-forget: if_connected, it sends aderegisterenvelope and returns without waiting for the ack; if disconnected, it does nothing. ItscallIdis filed in_pendingDeregistersonly so the reply is recognised and dropped. An undelivered or lostderegisterdoes not leak the model indefinitely when the server side is aQtWebSocketServer: its connection scope reclaims every model this client registered at the next disconnect (see "Connection scopes" and Limitations).execute— if not connected, resolves the returnedCompletionimmediately withDisconnectedError. Otherwise it records the completion state +deserializeResult+cbExecin_pending[callId], serialises the action, and sends theexecuteenvelope.
Reply routing. onTextMessage decodes each incoming frame and routes it by
callId. _pending is checked first (an execute reply): ok →
deserialize(body) into the completion's value (deserialisation exceptions
become the completion's error), any other kind → std::runtime_error(message).
_pendingControl is checked next (every other request — same callId
namespace, separate map because each settles its own value type): the entry is
removed from the map, then its reply function settles the caller's
Completion, since settling may run a continuation that re-enters the
backend. _pendingDeregisters last, whose replies are discarded. A callId
matching none of them — a late reply for an already-cancelled call, or 0 —
is dropped. A frame that fails to decode fails every pending request
(cancelPending with a protocol error): its callId is unreadable and the
peer's framing can no longer be trusted.
Because execute replies are matched on callId, concurrent in-flight execute
calls are supported; RemoteServer/QtWebSocketServer echo the request callId
in the reply (see wire.md).
Reconnect lifecycle. Configured by QtWebSocketBackendConfig (aliased as
QtWebSocketBackend::Config):
| Field | Default | Meaning |
|---|---|---|
reconnectEnabled |
true |
Whether to auto-reconnect after an unsolicited disconnect. |
initialReconnectDelay |
500 ms |
Delay before the first reconnect attempt. |
maxReconnectDelay |
30 s |
Upper bound on the exponential backoff. |
backoffMultiplier |
2.0 |
Multiplier applied to the delay after each failed attempt. |
The state machine:
- On
connected: sets_connected, resets the backoff delay toinitialReconnectDelay, releases a parkedwaitForConnected(). Fires_connectHandler(if installed) unconditionally — every successful connect, first included. It then fires the_reconnectHandleronly on subsequent connects (_everConnectedwas already true) — never on the first connect, because initial handler registration is driven by theBridgeHandlerconstructors, not the reconnect path. See Connect/disconnect notifications. - On
disconnected: clears_connected, then fires_disconnectHandler(if installed) — before the reconnect scheduling below, so an observer sees the disconnected state even when a retry follows immediately. Then immediately callscancelPending(DisconnectedError{}), resolving every in-flight execute withDisconnectedError. If not shutting down,reconnectEnabled, and the socket had ever connected, it schedules a reconnect with the current backoff delay, then multiplies the delay bybackoffMultiplier(capped atmaxReconnectDelay) for the next attempt. A connection that never succeeded the first time is not retried. attemptReconnectre-opens the socket; if it fails,QWebSocketfiresdisconnectedagain and the cycle repeats with the grown backoff.
Bridge installs a _reconnectHandler (via setReconnectHandler) that
re-registers every live HandlerBinding so model ids stay valid after the
server assigns fresh ones on the new connection (cross-ref bridge.md).
setConnectHandler/setDisconnectHandler are independent of that — an
application installs them directly on the backend (not through Bridge) to
drive its own connection-state UI.
setSession(session) stores the session in _session (this backend is
single-threaded — Qt event loop thread only — so no lock is needed); every
subsequently built register/registerShared/attach/assign/deregister
envelope's session field is set from it before sending. See
Session propagation to control envelopes.
waitForConnected(timeoutMs = 5000) pumps a local QEventLoop until the
socket connects or the timeout elapses; returns the current _connected flag.
A desktop or test convenience, called on the Qt thread; a WASM main thread
cannot spin it, and binds without waiting instead (a private bind made before
the connect is queued) or reacts to setConnectHandler.
negotiateProtocolVersion(replyExec) sends
a "hello" and returns a Completion the reply settles, classified via
wire::interpretHelloReply. Opt-in: intended to be sent once, after the
socket connects and before any bindModel/execute, but nothing enforces that
ordering and nothing sends it automatically. Rejects with std::runtime_error
if the server explicitly rejects the version or the socket is not connected,
and with DisconnectedError if it drops before the reply. See
wire.md. The reply also tells the
backend whether the server honours "cancel"; see
Cancelling a remote call on the server.
TLS. Pass a QSslConfiguration to enable wss://. Build it with
tlsVerifyingConfig() (CA-verified, the recommended production default) or
tlsPinnedConfig(cert) (pinned-certificate, for self-signed deployments) —
both in qt_tls.hpp. tlsInsecureNoVerify() (QSslSocket::VerifyNone)
disables peer verification and is for local development and tests only (see
security.md, "Transport security"). A plain ws:// client
against a wss:// server never connects (and vice versa).
SSL-less Qt builds (QT_NO_SSL, including the standard Qt-for-WebAssembly
configuration). Both qt_websocket_backend.hpp/.cpp (client) and
qt_websocket_server.hpp/.cpp (server) guard every QSslConfiguration use
behind #ifndef QT_NO_SSL: the tls constructor parameter (_tls member on
the client) does not exist at all when Qt itself was configured without SSL
(that type isn't provided in that configuration, so there's no value to
accept or ignore — the constructor's arity itself changes). The server
additionally guards QWebSocketServer::SecureMode, which Qt also omits under
QT_NO_SSL: such a server always constructs in NonSecureMode, and
listen()'s plaintext-exposure guard (see above) treats it as hasTls = false unconditionally. Both files compile into the same morph_qt_impl
target (CMakeLists.txt), so both had to be fixed together — a build is only
SSL-less-Qt-compatible as a whole if every translation unit in the target is.
wss:// still works on such a build regardless: in a WASM/browser
deployment the browser terminates TLS before Qt's QWebSocket ever sees the
connection, so the only thing genuinely unavailable is configuring TLS from
C++ (client certificates, pinning). qt_tls.hpp's helpers
(tlsVerifyingConfig/tlsPinnedConfig/tlsInsecureNoVerify) are a separate,
opt-in header that still requires SSL support to compile — nothing calls it
unless it asks for TLS configuration explicitly, so this is unaffected by
QT_NO_SSL in practice. Verified by a try_compile() guard
(tests/qt/CMakeLists.txt) that forces QT_NO_SSL against this project's
normal SSL-enabled Qt, compiling both the client and the server (plus a
manually-generated moc translation unit for the server's Q_OBJECT vtable)
into one executable: Qt's own <QSslConfiguration> header self-guards on the
identical macro regardless of how Qt was actually built, so this reliably
reproduces (and catches a regression of) the same failure an actual
SSL-less Qt hits, without needing one.
Destruction. The destructor sets _shuttingDown, stops the reconnect timer,
disconnects all QWebSocket signals (so no slot touches members mid-teardown),
abort()s the socket (TCP RST, no close handshake), then calls cancelPending
again as a safety net (in case the owner did not run it first — e.g. the backend
was used outside a Bridge), and finally drains the Qt event queue so the socket's
internal state machine settles before its QObject destructor runs.
morph::qt::QtWebSocketServer is a QObject that fronts a RemoteServer with a
real listening socket. It does not own the RemoteServer — it holds
RemoteServer& _server by reference, so the server's owning shared_ptr must
outlive the transport (see Lifetime & ownership).
Connection scope. QtWebSocketServer opts every client into RemoteServer's
connection scope end to end, so a client crash or dropped socket reclaims its
models instead of leaking them:
- Accept (
onNewConnection) calls_server.openConnection()and stores the returnedConnectionIdin the client'sClientState(keyed byQWebSocket*in_clients, alongside the rate-limit/handshake bookkeeping below). - Message (
onTextMessage) looks up the sender'sClientStateand forwards through the scoped_server.handle(msg, reply, cid)overload instead of the two-argument one, so anyregisterin the frame is attributed to that connection. - Disconnect (
onDisconnected) calls_server.closeConnection(cid)before removing the socket from_clients— the step that reclaims every model the connection registered. - Shutdown (
close(), and the destructor that calls it) callscloseConnectionfor every remaining client before aborting its socket, so an orderly server stop also reclaims every client's instances.
Observability. onNewConnection logs at morph::log::LogLevel::info once
a connection is admitted (connection id, live count including the new one);
a connection refused for being over cfg.maxConnections logs at warn
instead (naming the live count and the configured cap) before the socket is
closed. onDisconnected logs at info (connection id, live count after
removal, QWebSocket::closeCode()/closeReason() captured before teardown).
See Server-side observability.
Flow. listen() binds to the requested TCP port on cfg.bindAddress
(QtWebSocketServerConfig, default QHostAddress::LocalHost — today's
behavior, unchanged) and starts accepting — unless cfg.bindAddress is
non-loopback, no TLS configuration was passed to the constructor, and
cfg.allowPlaintextExposure is false, in which case listen() refuses: it
returns false without binding and logs at morph::log::LogLevel::error (see
security.md, "Transport security"). port() returns the
bound port (useful when constructed with port 0 to let the OS assign a free
one); close() (and the destructor) stops accepting, reclaims every remaining
client's connection scope, and aborts/deleteLaters every client socket. Each
accepted QWebSocket is tracked in _clients together with its ConnectionId;
on its disconnected signal both the scope and the socket are reclaimed and
removed.
Message handling. For every text frame from a client, onTextMessage calls
the scoped RemoteServer::handle(msg, reply, cid) (asynchronous — dispatched to
the server's worker pool). The reply callback captures a QPointer<QWebSocket>
(a weak handle) and marshals the send back onto the Qt thread via
QMetaObject::invokeMethod(..., Qt::QueuedConnection): the reply is produced on
a pool thread but QWebSocket::sendTextMessage must run on the Qt thread. If the
client socket was destroyed before the reply is ready, the QPointer is null and
the reply is silently dropped. A malformed frame produces an err reply from the
RemoteServer and does not disconnect the client or affect other clients.
Multi-client / concurrency. One server serves many clients; each client
registers its own model instances on the shared RemoteServer, so per-client
model state is isolated. Because handle() posts to the pool, replies for
different clients (and different calls) can be produced concurrently on separate
pool threads and are each marshalled back to their originating socket.
TLS. Constructing with a QSslConfiguration puts the QWebSocketServer into
SecureMode (wss://); without one it runs in NonSecureMode.
Graceful shutdown (closeGracefully(deadline)). The transport-level
counterpart to RemoteServer::beginShutdown()/drainedWithin(): it calls
QWebSocketServer::pauseAccepting() (no new connections), then
beginShutdown() on the RemoteServer (new register/execute now fail
fast on every existing connection), then asks drainedWithin(deadline) for an
answer delivered on its own thread through a QtExecutor, and pumps the Qt
event loop until it answers — so the reply callbacks
onTextMessage already queued via QMetaObject::invokeMethod actually run
while it waits. Because drainedWithin()'s in-flight count can reach zero a
moment before that queued reply callback has actually flushed the bytes over
the socket, closeGracefully pumps a short additional settle window (bounded
by whatever is left of deadline) before proceeding, so a reply that just
landed is not closed out from under. It then sends every still-connected
client a real close frame (CloseCodeGoingAway, reason "server shutting down") instead of an abort, pumps the event loop again for the remainder of
deadline to let that handshake flush, and finally calls the existing
close() for whatever deadline did not leave time to finish gracefully
(which also reclaims each remaining client's connection scope, same as
close() always has). deadline bounds the whole sequence from the moment
closeGracefully is called: a drain that used the full budget leaves no time
for the close handshake before the hard stop, while a drain that finishes
early leaves the remaining budget for it. Returns true if the drain
finished within deadline, false if the hard stop had to reclaim
stragglers. Purely additive and opt-in: a server that never calls it behaves
exactly as today, and close() itself is unchanged.
Resource limits. QtWebSocketServerConfig (aliased QtWebSocketServer::Config,
declared outside the class for the same "fully-parsed-before-default-argument"
reason as QtWebSocketBackendConfig) bounds per-connection resource usage:
| Field | Default | Enforcement |
|---|---|---|
maxConnections |
0 (unbounded) |
A connection accepted beyond this count is closed immediately in onNewConnection, before any signal is wired or the socket is tracked. Logged at morph::log::LogLevel::warn, naming the live count and the cap — see Server-side observability. |
maxMessageBytes |
wire::kMaxEnvelopeBytes |
Checked against the UTF-8 byte length of every incoming frame before it reaches RemoteServer::handle(); an oversized frame gets an immediate err reply and is never dispatched. The reply carries the rejected call's callId, recovered by wire::detail::peekCallId's bounded prefix scan since the frame is deliberately never decoded. A zeroed callId would not merely fail to resolve the execute — 0 is the client's synchronous-reply discriminator, so it would resume an unrelated parked register/deregister with another call's reply. |
messagesPerSecond |
0 (unbounded) |
A per-connection token bucket (capacity = messagesPerSecond, refilled continuously). A frame that finds an empty bucket is refused — it never reaches RemoteServer — and answered with an err "rate limited" addressed to that frame's own callId. Not queued, and the connection is not closed. |
handshakeTimeout |
0 (disabled) |
A one-shot timer per connection; if no frame arrives before it fires, the socket is closed. Cancelled on the first frame. Because QWebSocketServer::newConnection() only fires after the WS (and TLS, in SecureMode) opening handshake completes, this in practice bounds time-to-first-frame after that point, not the handshake itself. |
idleTimeout |
0 (disabled) |
A shared ~1-second housekeeping sweep closes any connection whose last frame is older than idleTimeout; the actual close can lag the configured value by up to the sweep interval. |
bindAddress |
QHostAddress::LocalHost |
The address listen() binds to (see "Flow" above). |
allowPlaintextExposure |
false |
Deliberate opt-out of the exposure guard: set true only to knowingly serve plaintext on a non-loopback bindAddress (see "Flow" above). |
morph::net::SocketBackend (include/morph/net/socket_backend.hpp) and
morph::net::SocketServer (include/morph/net/socket_server.hpp) are the
Qt-free reference transport: they speak the same RFC 6455 WebSocket framing as
QtWebSocketBackend/QtWebSocketServer — plaintext ws:// only, no TLS — over
raw POSIX (BSD) sockets instead of QWebSocket/QWebSocketServer. The module
is header-only, gated behind the CMake option MORPH_BUILD_NET (default
OFF; Linux/macOS only — see Limitations), and depends on nothing but morph
and the core-cpp modules morph already links: the HTTP/1.1 Upgrade handshake
(Sec-WebSocket-Key/Sec-WebSocket-Accept, via a hand-rolled SHA-1 and
core-cpp's core::base64::encode) and the masked/unmasked text-frame codec are
implemented in include/morph/net/detail/ (sha1.hpp, ws_handshake.hpp,
ws_frame.hpp, tcp_socket.hpp), and the sockets, the listener and the
timers underneath are core-cpp's core::net event loop's. Because both
transports round-trip the same
wire::Envelope, a SocketBackend client and a QtWebSocketServer
interoperate (and vice versa) with no protocol changes on either side.
Reconnect handlers are posted to the executor they were installed with.
SocketBackend never runs the handler installed by setReconnectHandler on
the I/O loop: when a connection after the first completes, the loop posts the
handler to the executor passed with it — the bridge's owner, for Bridge's
handler. The handler re-registers through bindModel, whose replies this same
loop delivers, so nothing on the reconnect path waits for the loop.
SocketBackend overrides bindModel/promoteModel itself rather than being
wrapped in SynchronousBackendAdapter.
- The transport already has the machinery. The I/O loop demultiplexes
replies by
callIdforexecute, andRemoteServerechoescallIdon every control reply it sends —register,registerShared,attachandassignall answer withmakeOk(env.callId, {}, mid). A control call is therefore the same shape as an execute, and the native path needs no protocol change, no server change and no new thread. It is a secondPendingCallTable, sharing the execute table'scallIdcounter so an id can never be ambiguous between the two. - A bind cannot block anything.
bindModelposts its frame to the loop and returns before the reply exists; the loop settles theCompletionwhen the reply arrives, and the continuation runs on the caller's executor. There is no wait for any thread to block — the loop's own included — whichever thread issues the call.
The synchronous control verbs (registerModel, registerModelWithContext,
assignPrimary, listInstances) post the same request and wait on a future
for its reply, so several may be in flight at once, each matched by callId;
on the loop's own thread they throw instead of waiting on themselves.
cancelPending sweeps both tables, so a disconnect rejects an in-flight bind
with DisconnectedError and a waiting synchronous verb with "<verb> failed: disconnected".
tests/net/test_socket_backend.cpp pins it: several binds in flight at once,
matched by callId, with the replies delivered back to front; and a reconnect
handler's body runs as a task of the executor it was installed with, never on
the loop.
Threading — one I/O loop per process. Neither SocketBackend nor
SocketServer owns a thread for its I/O. Every socket, every connection,
every pending call, the reconnect backoff and the accept flow live on an
morph::exec::IoLoop (include/morph/core/io_loop.hpp): one
core::net::PlatformLoop and, natively, the one thread that runs it. An
application constructs one IoLoop and passes it to every component built on
it — SocketBackend(loop, url), SocketServer(loop, server, port),
TimeoutScheduler(loop), NetworkMonitor(loop, …) — the way LocalBackend
takes its pool. The loop is injected rather than a process-wide singleton the
framework starts on first use: the dependency is then visible in every
constructor, there is no global to reset between tests, and an application
decides how many loops it runs (one, normally). Each component also keeps its
old constructor, which builds a private IoLoop — one loop, and one thread,
for a caller with no loop to share.
Each component's state is touched only in tasks its loop runs. Its public verbs
post to the loop and return; the ones that must answer do so from an atomic
(connected, port) or an id allocated before the post (TimeoutScheduler's
handle). Its destructor runs its close on the loop — inline when it runs on
the loop's own thread, posted and waited for otherwise — so it never waits on
itself.
| Component | Cross-thread surface | Everything else |
|---|---|---|
SocketBackend |
execute, bindModel, promoteModel, deregisterModel, cancelPending post; waitForConnected posts a waiter and blocks the caller; the synchronous control verbs post their frame and wait on a future for the reply; setSession, setReconnectHandler post |
socket, both pending tables, call-id counter, session, reconnect handler, reconnect delay and backoff timer, connect waiters: loop only |
SocketServer |
listen(), close() run on the loop and wait; port() is atomic; a RemoteServer reply is posted from the replying thread |
listener, connections, writers: loop only |
The flows are core::async::Tasks spawned on the loop. SocketBackend runs
one attempt flow per connection attempt — dial through core::net::connect
(its budget is connectTimeout; a literal address never leaves the loop, a
hostname is resolved on core-cpp's resolver pool), the RFC 6455 handshake,
then the read loop — and, when the connection ends, arms a loop timer for the
backoff before the next attempt. Each connection has one writer flow that
drains its queued frames one 64 KiB chunk at a time; a loop timer armed around
each chunk's write (sendTimeout) closes the socket if the write makes no
progress, which ends the connection like any other disconnect.
morph::net keeps its own WebSocket framing (detail/ws_frame.hpp) and
handshake text (detail/ws_handshake.hpp); only the socket underneath is
core-cpp's. detail/ws_connection.hpp holds the loop-side pieces both roles
share: the connection with its outgoing queue, the writer flow, and the
handshake header read.
Because the loop is shared, a callback that runs on it — a
TimeoutScheduler callback, a NetworkMonitor probe or callback, a
completion delivered on inlineExecutor() — must not block. The synchronous
control verbs throw when called on the loop's thread (the reply they would
wait for is read by that thread), so that mistake fails rather than hangs.
Blocking calls like SocketBackend::waitForConnected()/the synchronous
registerModel genuinely block the calling thread with no event-loop pumping
of their own — fine against a SocketServer peer, but calling them directly
from the one thread that owns a QCoreApplication a QtWebSocketServer
peer depends on would starve that peer's own accept/handshake machinery. See
tests/net_qt_interop/test_net_qt_interop.cpp's waitForConnectedPumpingQt/
makeHandlerPumpingQt helpers for the pattern such a caller needs (poll with
a short timeout while pumping QCoreApplication::processEvents()).
SocketBackend — client-side IBackend. Implements every IBackend
method with the same observable semantics as QtWebSocketBackend:
registerModel is synchronous (parks on a condition variable instead of a
nested event loop; a register whose reply never arrives unblocks with
"register failed: disconnected" rather than hanging); deregisterModel is fire-and-forget
(same trade-off; an undelivered or lost deregister against a SocketServer
peer does not leak the model, because SocketServer participates in
RemoteServer's connection-scope contract exactly as QtWebSocketServer
does — see below); execute serialises the action on the calling thread and
posts the envelope; the loop assigns a monotonic callId, files the pending
record and writes the frame, and the reply flow finds the record and settles
the completion. setSession stores the session under its own mutex and every
subsequently built register/registerShared/attach/assign/deregister
envelope's session field is set from it before sending — see
Session propagation to control envelopes.
Reconnect is configured by SocketBackendConfig (aliased
SocketBackend::Config), with the same four fields and defaults as
QtWebSocketBackendConfig (reconnectEnabled, initialReconnectDelay,
maxReconnectDelay, backoffMultiplier) plus connectTimeout (default 5 s,
the whole dial), sendTimeout (default 30 s, one chunk's write) and
handshakeTimeout (default 10 s, the whole handshake response read, from its
first byte to the header's end). waitForConnected(timeout = 5000ms) posts a
waiter the loop releases when a connection completes, and blocks the calling
thread on it until then or the timeout — the non-Qt equivalent of pumping the
Qt event loop. The constructor takes a ws:// URL string (wss:// throws
immediately, before any loop is started — see Limitations) and posts the first
connection attempt.
SocketServer — server-side transport. Fronts a RemoteServer by
reference — the same non-owning-reference lifetime rule as QtWebSocketServer
applies (see Lifetime & ownership). listen() binds 127.0.0.1:port (0
lets the OS assign a free port, exactly like the Qt transport) with
SO_REUSEADDR, hands the listener to the loop (core::net::adoptListener)
and spawns the accept flow; each accepted connection gets a flow that performs
the server-side handshake (bounded as a whole by handshakeTimeout), then
reads framed text messages and calls the scoped
RemoteServer::handle(msg, reply, cid). A server built with the loop-less
constructor creates its loop on the first listen(); a process out of
descriptors gets false rather than a throw.
Connection scope. SocketServer opts every client into RemoteServer's
connection scope end to end, matching QtWebSocketServer:
- Accept mints a
ConnectionIdvia_server.openConnection()and stores it on the per-connection state. - Dispatch passes that id to the three-argument
handle(), so everyregisteron the connection is attributed to its scope. - Teardown calls
closeConnection(cid)once per connection, however it ends — failed handshake, peer close, read error, a stalled write, orclose(), which does it for every connection still open before it returns.
The reply callback runs on a RemoteServer worker-pool thread. It encodes
the frame there and posts it to the loop through a weak handle
(IoLoop::weak()), so a reply that outlives the loop is dropped rather than
posted to freed memory; on the loop, a connection that closed meanwhile drops
it, the same behaviour QtWebSocketServer's QPointer gives. close() (also
run by the destructor) is idempotent, and so is concurrent use of it on a live
object: each call is one loop task, and the loop runs them one at a time. It
closes the listener — the accept flow resumes with Cancelled on a later turn
and ends — and every connection's socket, which resumes each connection's
parked read the same way. port() reads 0 afterwards, and the port is free
to rebind, matching QtWebSocketServer.
A failed accept is retried or ends the flow, by whether it can recur.
Exhaustion (EMFILE, ENFILE, ENOBUFS, ENOMEM) and any other failure the
next attempt may not repeat waits 50 ms and accepts again, so a process out of
descriptors keeps its listener and recovers when descriptors are freed. A
listener that can no longer accept at all ends the flow instead: one that has
stopped listening (EINVAL), a closed or foreign descriptor
(EBADF/ENOTSOCK) or a non-stream socket (EOPNOTSUPP) fails every attempt
the same way. core-cpp reports these as BadHandle/Unsupported, or, in
releases inside the supported range that predate that classification, as
SystemError carrying the errno; both are recognised. The server then drops
the listener, so port() reads 0 and a later listen() binds afresh.
Connections already accepted are untouched.
The listener's non-blocking mode stops at the listener.
TcpSocket's fd-adopting constructor clears O_NONBLOCK on every descriptor it
takes ownership of, so a connection from accept() or tryAccept() is always
blocking, whatever mode the listener it came from is in. The transports no
longer read through TcpSocket — the loop's sockets are non-blocking by
design — but the tests and any blocking caller do: macOS/BSD propagate a
listening socket's O_NONBLOCK onto the sockets accept(2) returns (POSIX
permits this; Linux does not do it), and a blocking reader of such a socket
fails on EAGAIN before its peer has written.
SocketBackend and QtWebSocketBackend tell the server when they abandon an
execute, by sending cancel {cancelCallId} (see
wire.md), so a server-side Task handler stops
instead of running on for a reply nobody reads. Each sends one only to a
server that advertised "cancel" in its "hello" reply, so each sends none
until the application has called negotiateProtocolVersion — opt-in, like
the handshake itself. Once called, the backend re-sends "hello" after every
reconnect and forgets the capability on every disconnect: a new connection may
reach a different server.
A call is cancelled on the server when:
- Its stop is requested — an execute deadline (
Bridge::setExecuteDeadline) or any other holder of the call'sActionCall::stopSource. The backend registers a stop callback on that source once the call has itscallIdand its frame is queued. The callback runs on whichever thread requested the stop, so it touches no table:SocketBackend's postsrequestCancel(callId)to the I/O loop;QtWebSocketBackend's sets the call's flag and starts a zero-interval single-shot wake timer through the member-nameQMetaObject::invokeMethod(&timer, "start", Qt::QueuedConnection)— which allocates nothing, unlike the functor overload — and the timer's drain, on the Qt thread, sends a cancel for each flagged call. Either way the owner sends the cancel only if the call is still waiting for its reply, and the pending table stays the owner's alone, with no lock. A stop after the reply sends nothing: the callback is deregistered with the pending entry. A call with no stop source —Bridgegives one only to a call whose handler is a Task, the one kind a server can stop — never registers one. cancelPendingsweeps it —~BridgeandBridge::switchBackendcall it. Every drained execute is cancelled, with or without a stop source: in a client-only buildBridgecannot tell a Task handler from an ordinary one, and the server answers a cancel for an unstoppable call with the same no-opok.
The cancel carries the session the execute carried (kept with the pending
entry for a stoppable call; the backend's control session otherwise), since
the server honours it only under the execute's verified principal. It is sent
fire-and-forget under a fresh callId filed nowhere, so its ok is dropped
by the reply router.
Ordering, and why SocketBackend::cancelPending being posted is acceptable.
SocketBackend::cancelPending is posted to the I/O loop (G0 on return), so
the cancels it sends sit behind whatever the loop already had queued. That is
acceptable, for three reasons. The cancel can never overtake the execute it
names: the execute was posted to the same loop before it, the loop runs its
tasks in order and writes one connection's frames in order, and the server
handles one connection's messages in order, so the execute is always admitted
before its cancel is read. What waits is only the server's handler, which
runs on for at most as long as the loop takes to drain its queue — work that
never blocks — and which already ran to its end before cancels existed. And
the caller's own guarantee does not depend on it: the calls are settled with
the cancel's exception in the same loop task, before any reply to them can be
delivered. A stop-triggered cancel is posted in the same way, from the stopping
thread, and is ordered the same.
Teardown. ~Bridge calls cancelPending and then destroys the backend,
so the cancels are queued just before the backend closes its socket. Each
backend's close therefore lets queued frames out first: SocketBackend's
close is a close-after-flush on the loop (the write still bounded by
sendTimeout), and QtWebSocketBackend's destructor flushes its socket,
without blocking, before the abort. A close that discarded queued frames
would drop exactly these cancels; the ~Bridge tests in both suites fail
when it does (measured on both backends). QtWebSocketBackend sends nothing from its own
destructor's sweep — its socket is already aborted there — so a backend
destroyed without its owner's cancelPending cancels nothing.
The policy table in
concurrency_and_lifetimes.md
gives the remote backends' rows. Their work column — a Task handler on a
server that advertised "cancel" is asked to stop — is measured over a real
loopback, through ~Bridge and through the execute deadline, in
tests/net/test_socket_backend.cpp and tests/qt/test_qt_websocket.cpp
([cancel]). Their G-levels are read, not measured.
The backends hold references, not owning pointers, to the resources they run on. Getting the destruction order wrong is a use-after-free, so the invariants are:
- The worker pool must outlive the backend. Every backend takes an
IExecutor& workerPoolby reference (LocalBackend,RemoteServer) and wraps runs its strands on it. The pool (typically aThreadPoolExecutor) must be destroyed after the backend that references it — and, in practice, after theBridgethat owns the backend. Destroying the pool first leaves the strand pointing at freed storage. RemoteServermust be created viastd::make_shared. It derives fromstd::enable_shared_from_this<RemoteServer>; every public verb butopenConnection's id and the two accessors capturesshared_from_this()into the task it posts. Constructing it on the stack and calling one throwsstd::bad_weak_ptr(see ARCHITECTURE.md "RemoteServer must be heap-allocated").~RemoteServercloses the server strand first. Every task on it holds the server, so when the destructor runs none is queued; closing waits for a task still running on another thread (and returns at once from inside the server's own last task), before any member the strand's tasks touch goes.- The
RemoteServershared_ptr must outlive every referencingSimulatedRemoteBackendand every transport front (QtWebSocketServer,morph::net::SocketServer).SimulatedRemoteBackend,QtWebSocketServer, andSocketServerall storeRemoteServer& _server— a non-owning reference — and forward client messages through it. If the server's owning shared_ptr is released while such an adapter still references it, subsequent calls dereference a dangling reference. Thehandle()path is self-protecting for tasks already in flight (each captures a shared_ptr copy), but the reference member is not — the caller must keep the server alive for the adapter's whole lifetime. (QtWebSocketBackend/SocketBackend, by contrast, hold noRemoteServerreference: they are clients that reach the server only over the socket.) - The
exec::IoLoopmust outlive every component built on it.SocketBackend,SocketServer,TimeoutSchedulerandNetworkMonitorborrow the loop they were given, and each destructor runs its close on it and waits: a loop destroyed first would never run that close. Destroy the components, then the loop. A component built with its loop-less constructor owns a private loop and destroys it last, after its own close. - Pending strand tasks capture shared_ptr copies, so model destruction
mid-flight is safe. Both backends'
executestrand tasks capture the modelholderbyshared_ptrcopy (andRemoteServer's also captures the reply callback and the movedEnvelope). AderegisterModelthat erases the map entry while a task is queued or running only drops the map's reference; the in-flight task holds its own, so the holder stays alive until the task completes.RemoteServer's tasks additionally keep the server itself alive viashared_from_this().closeConnectionerases the same map entries as an explicitderegister, so the same guarantee covers it: it never races a runningexecuteinto use-after-free, only prevents new lookups. - A backend must outlive every call into it — destroying one while a thread
is parked inside it is undefined. This is the same rule the destruction
ordering table in
concurrency_and_lifetimes.md
states for a call on a handler whose
Bridgeis gone; no backend is exempt from it. It is worth spelling out formorph::net::SocketBackend, because its blocking calls are made from threads other than the loop that owns its state:waitForConnected()blocks on a future and then reads the backend's connected flag, and~SocketBackenddoes not wait for a parked waiter, so destroying the backend from another thread frees the storage that read touches — a data race, whatever the waiter's remaining timeout. A caller that wants to abandon a wait must bound it with thetimeoutargument and let it return before destruction begins; there is no cancel.
| Situation | Local (LocalBackend) |
Remote (RemoteServer / SimulatedRemoteBackend) |
|---|---|---|
register with an unregistered typeId |
N/A — the local factory closure constructs the instance directly; there is no registry lookup and no type-id failure. | ModelRegistryFactory::create(typeId) fails → the catch in dispatchEnvelope replies err "unknown model type: <typeId>". Remote registration therefore requires the model to have been macro-registered with BRIDGE_REGISTER_MODEL. SimulatedRemoteBackend::registerModelWithContext turns that err into a thrown std::runtime_error("register failed: unknown model type: <typeId>"). |
register with an empty typeId |
N/A | err "register requires a typeId". |
execute against an unknown model id |
Completion resolves with an untyped std::runtime_error("model not found: id=<n>"). |
err "model not found" (bare, no id); SimulatedRemoteBackend surfaces it as a thrown/onError std::runtime_error("model not found"). |
| Action handler throws | Caught on the strand; completion resolves with the thrown exception. | Caught on the strand; err exc.what() reply, which the client re-throws into the completion. |
| Envelope fails to decode | N/A | err <decode exception message> (no callId echoed — it couldn't be parsed). |
Unrecognised kind |
N/A | err "unknown envelope kind: <kind>". |
Over the WebSocket transport (QtWebSocketBackend) the same server-side rows
apply, plus transport-level failures the in-process backends cannot hit:
| Situation | QtWebSocketBackend |
|---|---|
execute while the socket is disconnected |
Completion resolves immediately with DisconnectedError. |
| Socket drops with execute calls in flight | The disconnected slot calls cancelPending(DisconnectedError{}), resolving every pending completion with DisconnectedError. Bridge may retry on reconnect. |
Reply arrives for an unknown/cancelled callId |
Dropped silently. |
register reply is err (e.g. unknown model type) |
bindModel's Completion rejects with std::runtime_error(message). |
| Malformed reply frame | Every pending request is rejected with a protocol error: the callId is unreadable, and the peer's framing can no longer be trusted. |
morph::net::SocketBackend gives the same guarantees over its own transport
(its synchronous verbs wait on a future, off the loop's thread):
| Situation | SocketBackend |
|---|---|
execute while the socket is disconnected |
Completion resolves immediately with DisconnectedError. |
| Socket drops with execute calls in flight | The loop's disconnect handling sweeps both pending tables, resolving every pending completion with DisconnectedError. |
Reply arrives for an unknown/cancelled callId |
Dropped silently. |
register reply is err (e.g. unknown model type) |
registerModel throws std::runtime_error("register failed: " + message). |
register reply never arrives (never connected, or disconnects mid-call) |
The posted request is rejected with DisconnectedError when the connection drops or the backend closes; the waiting verb rethrows it as std::runtime_error("register failed: disconnected") — never hangs. Called on the loop's own thread, or after the loop has stopped, it throws at once instead of waiting. |
There is no typed "model not found" exception on either path — callers that
need to distinguish it from any other std::runtime_error have only the message
string to go on, and the local and remote messages differ (see the table). The
typed error hierarchy (BackendChangedError, BridgeDestroyedError,
DisconnectedError) covers only lifecycle/transport cancellation, not
per-call dispatch failures.
An ActionCall's three callables run on three different threads across a remote
round-trip; model and GUI authors must not assume any two share a thread:
| Callable | Runs on |
|---|---|
serializeAction |
The calling / GUI thread — SimulatedRemoteBackend::execute invokes it synchronously while building the envelope, before handing off to the pool. |
deserializeResult |
The reply / pool thread — invoked inside the handle() reply callback when the server's ok arrives (for SimulatedRemoteBackend, that is a RemoteServer worker-pool thread). |
localOp |
The model strand (LocalBackend only) — posted on the per-ModelId strand, serialised against other actions for the same model. Never invoked on the remote path. |
On the server side, handle()'s calling thread — the transport's — decodes
the envelope; RemoteServer then runs authorize/authenticate and the model
lookup on the server strand, and ActionDispatcher::dispatch (and the
ScopedContext) on the model's strand. Both strands run on the server's pool
(see Per-model execute ordering).
Completion callbacks (.then/.onError) are delivered via the cbExec
executor passed to execute, independent of all of the above.
Over the WebSocket transport the split is different again:
| Callable | Runs on (QtWebSocketBackend) |
|---|---|
serializeAction |
The Qt event-loop thread — execute invokes it while building the envelope. |
deserializeResult |
The Qt event-loop thread — invoked in onTextMessage when the matching reply frame arrives. |
localOp |
Never invoked (no local models). |
QtWebSocketBackend is single-threaded (Qt event loop). QtWebSocketServer
receives frames on the Qt thread, hands them to RemoteServer::handle (which
runs on the server strand / model strand as above), and marshals the reply back
onto the Qt thread before sendTextMessage.
morph::net::SocketBackend splits the same callables between the caller and
its I/O loop instead of the Qt thread:
| Callable | Runs on (SocketBackend) |
|---|---|
serializeAction |
The calling thread — execute invokes it while building the envelope, before posting it to the loop. |
deserializeResult |
The I/O loop's thread — invoked when the matching reply frame arrives. |
localOp |
Never invoked (no local models). |
Unlike QtWebSocketBackend, SocketBackend's execute/registerModel/
deregisterModel may be called from any thread, because each posts to the
loop that owns the state (the synchronous verbs excepted on the loop's own
thread, where they throw). bindModel/promoteModel are callable from any
thread, the loop's included: they park on nothing, and their continuations run
on the caller's cbExec rather than on the loop that settles them.
morph::net::SocketServer receives frames on the loop, hands them to
RemoteServer::handle (server strand / model strand, as above), and the reply,
produced on whichever thread finishes the work, is posted back to the loop,
which queues it on the connection's writer. SocketServer::close() is callable
from any thread, including concurrently with another close() on the same live
server: each call is a loop task, so the loop serialises them.
| Member | Type | Notes |
|---|---|---|
modelTypeId |
std::string_view |
Target model type id. Referent must outlive the dispatch. |
actionTypeId |
std::string_view |
Target action type id. Referent must outlive the dispatch. |
action |
std::shared_ptr<void> |
Owns the action object; may be null when the callables ignore it. |
serializeAction |
std::string (*)(const void*) |
JSON serialiser; remote path only. Borrows action. |
deserializeResult |
std::shared_ptr<void> (*)(std::string_view) |
JSON deserialiser; remote path only. Does not read action. |
localOp |
std::shared_ptr<void> (*)(IModelHolder&, void*) |
Direct execution; local path only. Borrows action. |
session |
morph::session::Context |
Session context for the call. |
serializeBody() |
std::string serializeBody() const |
Calls serializeAction(action.get()); throws std::runtime_error if the pointer is null. |
| Method | Signature | Notes |
|---|---|---|
registerModel |
virtual ModelId registerModel(const string&, function<unique_ptr<IModelHolder>()>) |
Pure virtual. |
registerModelWithContext |
virtual ModelId registerModelWithContext(const string&, function<unique_ptr<IModelHolder>()>, string_view) |
Default: drops contextKey, calls registerModel. |
bindModel |
virtual Completion<ModelId> bindModel(BindRequest, IExecutor& cbExec) |
Default: binds a private instance through registerModelWithContext for every shape (no shared directory), releases a non-zero current after acquiring, and settles before returning. See The structural registration surface. |
promoteModel |
virtual Completion<ModelId> promoteModel(PromoteRequest, IExecutor& cbExec) |
Default: calls assignPrimary inline and settles with request.mid. |
instances |
virtual Completion<vector<string>> instances(const string&, IExecutor& cbExec) |
Default: calls listInstances inline and settles with its answer (a throw rejects). QtWebSocketBackend overrides it with an instances request. |
setOwner |
virtual void setOwner(const exec::detail::OwnerAffinity&) |
Default: ignored. Called by Bridge when it installs the backend. |
deregisterModel |
virtual void deregisterModel(ModelId) |
Pure virtual. |
execute |
virtual Completion<shared_ptr<void>> execute(ModelId, ActionCall, IExecutor*) |
Pure virtual. |
notifyBackendChanged |
virtual void notifyBackendChanged() |
Pure virtual. |
cancelPending |
virtual void cancelPending(const exception_ptr&) |
Pure virtual. |
setReconnectHandler |
virtual void setReconnectHandler(function<void()>, IExecutor*) |
Default: no-op. The handler is posted to the executor after the second and later connects, never run on the backend's thread. Both null clears. |
setConnectHandler |
virtual void setConnectHandler(const function<void()>&) |
Default: no-op. Fires on every successful connect, first included. |
setDisconnectHandler |
virtual void setDisconnectHandler(const function<void()>&) |
Default: no-op. Fires whenever the transport drops, before any reconnect is scheduled. |
setSession |
virtual void setSession(session::Context) |
Default: no-op. Stamped onto every control envelope (register/registerShared/attach/assign/deregister) subsequently built. See Session propagation to control envelopes. |
| Field | Type | Meaning |
|---|---|---|
BindRequest::typeId |
std::string |
String type-id of the model. |
BindRequest::factory |
std::function<unique_ptr<IModelHolder>()> |
Constructs the holder. Local path only; not called on an attach to a live shared instance. |
BindRequest::contextKey |
std::string |
Entity key for the action log; empty if none. Owned, not a view. |
BindRequest::primary |
std::string |
Canonical primary key; empty means a private instance. Owned, not a view. |
BindRequest::current |
ModelId |
Instance currently held; non-zero makes the bind a re-point. |
PromoteRequest::mid |
ModelId |
Live instance to promote. |
PromoteRequest::typeId |
std::string |
Model type id — the directory's first key component. |
PromoteRequest::primary |
std::string |
Key to file mid under. |
| Method | Notes |
|---|---|
SynchronousBackendAdapter(shared_ptr<IBackend> inner, IExecutor& blockingExec) |
Throws std::invalid_argument if inner is null. blockingExec is MORPH_LIFETIMEBOUND and must keep running tasks until the destructor's wait completes. |
wrapped() |
The wrapped backend; never null. |
bindModel(request, cbExec) |
Posts inner->bindModel(request, inlineExecutor()) onto the control strand; settles the returned Completion on cbExec from there. Never blocks the caller. |
promoteModel(request, cbExec) |
Posts inner->promoteModel(request, inlineExecutor()) onto the control strand; settles on cbExec. |
cancelPending(exc) |
On the caller's owner: sets each still-unsettled bindModel/promoteModel record's cancelled flag and rejects its promise with exc, then posts inner->cancelPending(exc) to the strand. Not a plain forward: those promises are settled from _control tasks the wrapped backend has never heard of. The flag is what stops a task still queued on _control from making its control call after the caller was told the bind was cancelled; a task already inside that call is unaffected. |
setOwner(affinity) |
Records the caller's owner; not forwarded (the wrapped backend's owner is the control strand). |
every other IBackend verb |
Run on the control strand. The synchronous ones (registerModel, registerModelWithContext, assignPrimary, listInstances) wait for it, so they must not be called from the executor's only thread. |
| Type | Base | Message |
|---|---|---|
BackendChangedError |
std::runtime_error |
"backend changed before completion resolved" |
BridgeDestroyedError |
std::runtime_error |
"bridge destroyed before completion resolved" |
DisconnectedError |
std::runtime_error |
"transport disconnected before completion resolved" |
TimeoutError |
std::runtime_error |
"execute timed out on the server" |
ClientTimeoutError |
std::runtime_error |
"execute timed out waiting for any reply" |
| Method | Notes |
|---|---|
explicit LocalBackend(IExecutor& workerPool) |
Constructs with a strand around workerPool. |
registerModel(typeId, factory) |
Atomically increments _nextId, files the holder as a private instance in _instances, on the owner; also records the id in _changeAware when the holder is backend-change-aware. typeId is accepted for interface compatibility but not used. |
deregisterModel(mid) |
Releases one attachment through _instances, on the owner; erases from _changeAware when that destroys the instance. |
notifyBackendChanged() |
Looks up the models recorded in _changeAware, on the owner, then posts onBackendChanged() (the IModelHolder base virtual — no dynamic_cast) onto each such model's strand. Cost is O(change-aware models). |
execute(mid, call, cbExec) |
Posts call.localOp on the model's strand with ScopedContext. Returns a Completion. |
cancelPending(exc) |
Snapshots _pending, delivers exc to each live state, and re-arms the compaction threshold. |
trackedPendingCount() |
[[nodiscard]] std::size_t trackedPendingCount() const — size of _pending, on the owner. Not the in-flight count: between sweeps the list also holds entries whose state is already destroyed. An upper bound on in-flight, and the observable that makes the compaction policy's memory cost measurable. For in-flight calls, use Bridge::pendingCalls(). |
| Method | Notes |
|---|---|
RemoteServer(workerPool, dispatcher, registry) |
Allow-all authorizer, default ServerConfig. |
RemoteServer(workerPool, authorizer, dispatcher, registry) |
Custom authorizer; null → allow-all. Default ServerConfig. |
RemoteServer(workerPool, config, dispatcher, registry) |
Allow-all authorizer, config fixed for the server's life. Throws std::invalid_argument if config.minProtocolVersion > config.maxProtocolVersion. Calls config.healthHandler once before returning. |
RemoteServer(workerPool, authorizer, config, dispatcher, registry) |
Both. |
handle(msg, reply) |
Async: decodes on the calling thread, posts to the server strand, calls reply once from a pool thread. Unscoped (cid == 0). Callable from any thread; calls from one thread are handled in call order — see Per-model execute ordering. |
handle(msg, reply, cid) |
Like handle(msg, reply), additionally attributing any register in msg to connection cid's scope. cid == 0 behaves exactly like the two-argument overload. |
handleInline(msg) |
Sync: runs the control envelope on the server strand — posted and waited for, or inline when already on it — and returns the reply JSON. Rejects execute — returns an err reply without dispatching, because an execute reply is produced asynchronously after this call returns. Needs a pool thread other than the caller's free to run the strand. Unscoped. |
openConnection() |
Returns a fresh non-zero ConnectionId at once; its empty scope is opened on the server strand, before the caller's next handle(). |
closeConnection(cid) |
Posts: erases every model still recorded in cid's scope (as deregister would) and drops the scope. cid == 0, unknown, or already-closed is a no-op — idempotent. Bypasses IAuthorizer by design. |
payloadCompleteness() |
The ServerConfig::payloadCompleteness given at construction. |
strand() |
The server strand, as an exec::IExecutor: runningOn(server.strand()) is true inside every task on the server's state. |
health(replyExec) |
[[nodiscard]] Completion<HealthStatus> health(IExecutor& replyExec) — readiness/liveModels/inFlight, answered on the server strand; delivered on replyExec, the executor the caller attaches from (borrowed: it must outlive the delivery). See observability.md. |
beginShutdown() |
Posts: subsequent register/attach/execute envelopes get err "server shutting down"; deregister still served. A client therefore cannot re-attach to a shared instance during the drain window. Idempotent, irreversible. Flips ready to false and calls ServerConfig::healthHandler, on the server strand. |
drainedWithin(deadline, replyExec) |
[[nodiscard]] Completion<bool> drainedWithin(std::chrono::milliseconds deadline, IExecutor& replyExec) — settled on the server strand: true once no execute is in flight, false if deadline elapses first; delivered on replyExec, the executor the caller attaches from (borrowed: it must outlive the delivery). Blocks nothing. |
| Field | Notes |
|---|---|
limits |
LimitPolicy; all-zero (default) applies no limit. |
logProvider |
LogProvider, consulted on the server strand for an instance created with a non-empty contextKey; null attaches no log. |
healthHandler |
std::function<void(const HealthStatus&)>: called from the constructor, then on the server strand when readiness changes. |
minProtocolVersion, maxProtocolVersion |
Inclusive hello range; default {kProtocolVersion, kProtocolVersion}. |
payloadCompleteness |
PayloadCompleteness; default Lenient. |
| Method | Notes |
|---|---|
explicit SimulatedRemoteBackend(RemoteServer& server) |
References the server. |
registerModel(typeId, factory) |
Delegates to registerModelWithContext(typeId, {}, {}). |
registerModelWithContext(typeId, factory, contextKey) |
Sends register envelope via handleInline. factory ignored. |
deregisterModel(mid) |
Sends deregister envelope via handleInline. |
negotiateProtocolVersion() |
Opt-in: sends hello via handleInline, classifies the reply via wire::interpretHelloReply. Throws on an explicit version rejection. |
execute(mid, call, cbExec) |
Serialises, sends execute via handle, returns Completion that resolves on reply. |
notifyBackendChanged() |
No-op. |
cancelPending(exc) |
Snapshots _pending, delivers exc to each live state. |
| Member | Type | Default |
|---|---|---|
reconnectEnabled |
bool |
true |
initialReconnectDelay |
std::chrono::milliseconds |
500 ms |
maxReconnectDelay |
std::chrono::milliseconds |
30 s |
backoffMultiplier |
double |
2.0 |
| Method | Notes |
|---|---|
QtWebSocketBackend(serverUrl, dispatcher = defaultDispatcher(), registry = defaultRegistry(), tls = nullopt, cfg = Config{}) |
Opens the socket to serverUrl in the constructor. dispatcher/registry params are accepted but unused (models live on the server). tls non-null → wss://. tls is not declared at all when Qt is built with QT_NO_SSL (see above). |
QtWebSocketBackend(serverUrl, tls, cfg = Config{}) |
Overload that skips the unused dispatcher/registry pair: a caller who only needs tls/cfg reaches them directly, without naming morph::model::detail::defaultDispatcher()/defaultRegistry() explicitly. Delegates to the main constructor with both defaulted. Not declared on a QT_NO_SSL build (no tls parameter to distinguish it from the (serverUrl, cfg) overload below). |
QtWebSocketBackend(serverUrl, cfg) |
Overload that skips dispatcher/registry and tls together — the common case for a caller that only wants to set a Config field (e.g. reconnectEnabled) over a plaintext ws:// connection. Delegates to the main constructor with dispatcher/registry defaulted and (on an SSL-enabled build) tls = std::nullopt. |
bindModel(request, cbExec) |
Builds the envelope request's shape names — register, shared register, or attach — assigns a fresh callId (the same counter execute uses), files the pending entry in _pendingControl[callId] and sends. The Completion settles later from onTextMessage (or from cancelPending on a disconnect). A private bind on an unconnected socket is queued in _queuedRegistrations instead; a keyed one rejects with "disconnected". |
promoteModel(request, cbExec) |
Sends assign through the same path. An empty primary or zero mid resolves with request.mid without sending. |
instances(typeId, cbExec) |
Sends instances through the same path; the reply's body is decoded into the key list. Rejects with "disconnected" when not connected. |
waitForConnected(timeoutMs = 5000) |
Pumps a local QEventLoop until connected or timeout; returns _connected. Not for a WASM main thread. |
negotiateProtocolVersion(replyExec) |
Opt-in: sends hello through the same path and settles the returned Completion with wire::interpretHelloReply's classification; records whether the server advertised "cancel", and re-sends hello after every reconnect. Rejects on an explicit version rejection, when not connected, or on a drop. |
registerModel(typeId, factory), assignPrimary(...), listInstances(typeId) |
Throw std::logic_error: each would have to wait for a reply. registerModelWithContext is IBackend's default, which forwards to registerModel. |
deregisterModel(mid) |
Fire-and-forget — sends only if connected, does not wait for the ack. Carries a non-zero callId from the same counter execute uses, recorded in _pendingDeregisters so onTextMessage recognises the reply and drops it. |
execute(mid, call, cbExec) |
Assigns a callId, sends execute, returns a Completion. Immediate DisconnectedError if not connected. |
notifyBackendChanged() |
No-op. |
cancelPending(exc) |
On the socket's thread, takes _pending, _pendingControl and _queuedRegistrations out, sends a cancel for each execute when connected to a server that advertised "cancel", then delivers exc to each — the exception itself, so a request rejected by a dropped socket carries the same DisconnectedError an execute does. |
setReconnectHandler(handler) |
Stores the handler; invoked on the Qt thread after every subsequent connect. nullptr clears. |
setConnectHandler(handler) |
Stores the handler; invoked on the Qt thread after every successful connect, first included. nullptr clears. |
setDisconnectHandler(handler) |
Stores the handler; invoked on the Qt thread whenever the socket drops, before reconnect scheduling. nullptr clears. |
| Member | Type | Default |
|---|---|---|
maxConnections |
std::size_t |
0 (unbounded) |
maxMessageBytes |
std::size_t |
wire::kMaxEnvelopeBytes |
messagesPerSecond |
std::size_t |
0 (unbounded) |
handshakeTimeout |
std::chrono::milliseconds |
0 (disabled) |
idleTimeout |
std::chrono::milliseconds |
0 (disabled) |
bindAddress |
QHostAddress |
QHostAddress::LocalHost |
allowPlaintextExposure |
bool |
false |
listen() refuses (returns false, logs at morph::log::LogLevel::error) when
bindAddress is not loopback, no TLS configuration was passed to the
constructor, and allowPlaintextExposure is false. Loopback binds and any
bind with a TLS configuration are unaffected — this is a new, additive guard,
not a behavior change to the existing loopback-only default.
| Method | Notes |
|---|---|
QtWebSocketServer(server, port = 0, tls = nullopt, cfg = QtWebSocketServerConfig{}, parent = nullptr) |
Fronts RemoteServer& server. tls non-null → SecureMode. cfg bounds per-connection resources (see above). Does not start listening. |
listen() |
Binds to cfg.bindAddress:port and starts accepting; returns success. Refuses (returns false, logs at error level) a non-loopback cfg.bindAddress with no tls and cfg.allowPlaintextExposure == false. |
port() |
Bound port (OS-assigned when constructed with 0). |
close() |
Stops accepting; calls closeConnection for every remaining client (reclaiming its models) before aborting and deleteLatering its socket. Also run by the destructor. |
closeGracefully(deadline) |
Opt-in graceful stop: pause accepting, beginShutdown(), wait up to deadline for the drain (pumping the event loop, plus a short settle window for a reply that just landed), send real close frames (CloseCodeGoingAway) to survivors, then close() for stragglers. Returns whether the drain finished before deadline. |
| Member | Type | Default |
|---|---|---|
reconnectEnabled |
bool |
true |
initialReconnectDelay |
std::chrono::milliseconds |
500 ms |
maxReconnectDelay |
std::chrono::milliseconds |
30 s |
backoffMultiplier |
double |
2.0 |
connectTimeout |
std::chrono::milliseconds |
5 s |
sendTimeout |
std::chrono::milliseconds |
30 s |
handshakeTimeout |
std::chrono::milliseconds |
10 s |
| Method | Notes |
|---|---|
SocketBackend(loop, serverUrl, cfg = Config{}) |
Parses serverUrl (ws:// only — throws immediately on wss://) and posts the first connection attempt to loop, an exec::IoLoop that must outlive the backend. |
explicit SocketBackend(serverUrl, cfg = Config{}) |
The same, on a private IoLoop the backend owns. The URL is parsed before that loop is started. |
~SocketBackend() |
Runs its close on the loop — inline on the loop's own thread, posted and waited for otherwise: closes the connection, retires the backoff timer, rejects every pending call — a waiting synchronous verb's included — with DisconnectedError. Frames already queued — the cancels and deregisters of a cancelPending just before — are written before the socket closes, bounded by sendTimeout. Never waits for a dial in progress. |
waitForConnected(timeout = 5000ms) |
Posts a waiter the loop releases on the next completed connection, and blocks the calling thread on it until then or the timeout; returns the current connected state. On the loop's own thread it answers at once. The backend must outlive the call — destroying it while a thread is parked here is undefined, and there is no cancel (see Lifetime & ownership). |
registerModel(typeId, factory) |
Forwards to registerModelWithContext with an empty contextKey; factory ignored. |
registerModelWithContext(typeId, factory, contextKey) |
Synchronous: posts register carrying contextKey and waits on a future for the loop to settle the reply, so the server's LogProvider is consulted for a private registration exactly as it is for a shared one. factory ignored. Throws on err reply or disconnect, and on the loop's own thread. Any number may be in flight, matched by callId. |
bindModel(request, cbExec) |
Native override of the structural surface. Posts the envelope request's shape names and returns immediately; the loop gives it a non-zero callId, files it and writes it; every shape carries request.contextKey, the private one included; the loop settles the Completion when the reply arrives, delivered on cbExec. Any number may be in flight. Rejects with DisconnectedError when the socket is down or drops first, or with std::runtime_error{"<verb> failed: <server message>"}. |
promoteModel(request, cbExec) |
The assign counterpart of bindModel, on the same path; resolves with request.mid echoed back. An empty primary or a zero mid resolves without sending, matching assignPrimary's guards. |
deregisterModel(mid) |
Fire-and-forget — posted only if connected, does not wait for the ack. The loop gives it a non-zero callId from the same counter execute uses, so its unawaited ok is never taken for another call's reply. Needs no pending-id bookkeeping of its own: the reply router already drops a non-zero callId that is not pending. |
execute(mid, call, cbExec) |
Serialises the action on the calling thread and posts the envelope; the loop assigns a callId, files the pending record and writes the frame. Returns a Completion, already rejected with DisconnectedError if not connected. Callable from any thread; any number may be in flight. |
notifyBackendChanged() |
No-op. |
cancelPending(exc) |
Posted: the loop drains both pending tables — the execute calls and the bindModel/promoteModel control calls — and delivers exc to each state. Covers every call issued before it. When the server advertised "cancel", sends one for each drained execute first. |
negotiateProtocolVersion(replyExec) |
Opt-in: posts a hello on the callId-multiplexed path and settles the returned Completion with wire::interpretHelloReply's classification; records whether the server advertised "cancel", and re-sends hello after every reconnect. Rejects on an explicit version rejection, and with DisconnectedError when the socket is down or drops. |
setReconnectHandler(handler, exec) |
Posted: the loop stores the handler and its executor, and after every subsequent connect posts the handler to exec — never runs it on the loop. nullptr clears. |
setSession(session) |
Posted: the loop stores the session it stamps onto every control envelope it builds afterwards. |
| Member | Type | Default |
|---|---|---|
backlog |
int |
64 |
handshakeTimeout |
std::chrono::milliseconds |
10 s |
sendTimeout |
std::chrono::milliseconds |
30 s |
handshakeTimeout bounds the whole RFC 6455 Upgrade read (accept to the
terminating \r\n\r\n), as one loop timer that closes the connection when it
runs out: a peer that dribbles one byte at a time cannot stretch it, the way it
could stretch a per-read bound to handshakeTimeout times the header's 64 KiB
cap. SocketBackendConfig::handshakeTimeout is the same whole-read bound on
the client side. sendTimeout bounds each 64 KiB chunk of a reply's write,
with the same mechanism and default as SocketBackendConfig::sendTimeout:
without it, a peer that stops reading fills the kernel send buffer and its
replies queue on the loop forever, while the connection goes on executing
actions whose replies go nowhere. When it runs out the connection is retired —
its socket closed, its models reclaimed. Both are 0-disables opt-outs;
deliberately not opt-in, since neither has the false-positive risk that ruled
out an idleTimeout (reaping an idle-by-design desktop client with no
keepalive to tell "idle" from "dead" apart).
| Method | Notes |
|---|---|
SocketServer(loop, server, port = 0, cfg = Config{}) |
Fronts RemoteServer& server on loop, an exec::IoLoop that must outlive it. Does not start listening. |
SocketServer(server, port = 0, cfg = Config{}) |
The same, on a private IoLoop the first listen() creates. |
listen() |
On the loop, and waited for: binds 127.0.0.1:port with SO_REUSEADDR, hands the listener to the loop and starts the accept flow; returns success. false if the port cannot be bound or, for the loop-less constructor, the loop cannot be created (a process out of descriptors). |
port() |
Bound port (OS-assigned when constructed with 0), or 0 when not listening — including after the listener stopped being able to accept. Atomic; callable from any thread. |
close() |
On the loop, and waited for (inline on the loop's own thread): closes the listener, reclaims every connection's models (closeConnection) and closes its socket. port() reads 0 afterwards and the port is free to rebind. Idempotent; also run by the destructor. Concurrent callers on a live object are safe — each call is one loop task, run one at a time. Racing close() against the destructor remains out of contract, as for any member call. |
IBackend carries two dispatch entry points:
| Verb | Shape | Who implements it |
|---|---|---|
execute(mid, call, cbExec) |
returns Completion<std::shared_ptr<void>> |
Pure virtual. Every backend. |
executeInto(mid, call, cbExec, sink) |
settles an async::detail::ISettleSink the caller owns |
Default-implemented, forwarding to execute. Overridden by LocalBackend only. |
Bridge::executeVia dispatches through executeInto, handing down a sink that
is the typed CompletionState<R> its caller holds. That removes the
.then/.onError block that used to forward the backend's erased completion
into the typed one — six allocations per call of the 14.06 a local round trip
took, measured down to 8.06 (see
bridge.md).
Why a default rather than a pure virtual. execute is implemented by five
production backends and roughly ten test doubles. Making executeInto pure
would be a fifteen-site change to gain an allocation on one path. The default
is exactly the forwarding block it replaces, so a backend that does not
override it costs precisely what it costs today — no gain, no regression.
The trap this creates, and how it is closed. LocalBackend overrides
executeInto, so a subclass of LocalBackend that overrode only execute
would be bypassed for every bridge dispatch and would intercept nothing but the
handful of direct execute callers — silently, with every test it still
reaches passing. LocalBackend::execute is therefore final, which turns
that into a compile error naming the fix: derive from LocalBackend and
override executeInto, the primitive both entry points share. A backend
deriving straight from IBackend is unaffected — it overrides execute and
inherits the default executeInto, which forwards to it.
Pending tracking follows the sink. LocalBackend::_pending holds
weak_ptr<ISettleSink> rather than weak_ptr<CompletionState<shared_ptr<void>>>,
and cancelPending calls settleException on each. A cancelPending racing a
reply settles the same sink twice, which ISettleSink's contract requires the
implementation to absorb — see
completion.md.
| Decision | Choice | Why |
|---|---|---|
Dual-path ActionCall |
Three callables: localOp, serializeAction, deserializeResult |
The same ActionCall struct works for both local and remote execution without an if (isRemote) branch at the call site — each backend uses the field(s) it needs. |
Callables are function pointers, not std::functions |
std::string (*)(const void*) etc., with the action in ActionCall::action |
Every call builds all three, whichever path it takes, so a stateful callable charges an allocation to calls that never invoke it. The behaviour is a constant of (Model, Action); only the action is per-call. Measured at 3 allocations per local round trip. The cost is an explicit borrow: see Lifetime contract. |
Type ids are string_views, not std::strings |
modelTypeId, actionTypeId |
They are always views of constexpr string literals from the registration macros, so copying them into a std::string bought nothing and allocated whenever an id exceeded the SSO threshold ("CreateSwimlane" is 14 characters; the margin is one character wide). |
registerModelWithContext |
Virtual with a default that drops contextKey |
LocalBackend's factory closure already captures identity, so there is nothing to forward — which is why the default drops the key rather than being pure virtual. A backend whose instances are constructed on the far side of a wire protocol has no such closure, so the envelope is the only channel the identity has: SimulatedRemoteBackend and SocketBackend both override it so the server's LogProvider can attach an action log. The default being permissive is what lets a wire backend ship without an override and silently stop journalling private registrations; the price of that permissiveness is that "is this a wire backend?" has to be answered by hand for each new transport. |
RemoteServer heap requirement |
std::enable_shared_from_this |
Every task the server posts captures shared_from_this() — the server must outlive any in-flight message. |
RemoteServer state on one strand |
exec::OwnerStrand over the pool; execute admitted there, run on the model's strand |
The registry, scopes, in-flight count and readiness need no lock, and per-model order is two strands' FIFO rather than a ticket gate. Admission is not on the model's strand, so a rejection never waits on a busy model — see Per-model execute ordering. |
RemoteServer configuration |
ServerConfig at construction, no setters |
Read on the strands without a lock because nothing writes it afterwards. |
handleInline |
Synchronous; caller-restricted to control messages | Safe to call from a worker-pool thread (e.g. from a BridgeHandler constructor). It is meant for register/deregister only; an execute envelope is rejected with an err reply, because dispatchExecute posts to the strand and would reply after handleInline returns (writing into an already-destroyed reply buffer). The rejection is now enforced by the code, matching the documented intent. |
SimulatedRemoteBackend factory ignored |
Model construction delegated to RemoteServer's ModelRegistryFactory |
The factory closure lives on the client side; the server owns the actual instances. |
cancelPending snapshots |
Weak-ptr snapshot under lock, then resolves outside | Avoids holding the lock while delivering exceptions to each state, preventing deadlock if a callback re-enters the backend. |
_pending compacted amortised, not intrusively |
Sweep when size() >= _compactAt, re-arm at twice the survivors |
The intrusive alternative is to give CompletionState a slot index and unlink on settle, making both registration and removal O(1) with no sweep at all. Rejected. It pushes a back-reference to the backend's table into a type shared by every backend, and puts a cross-thread unlink — a lock, or a post to the owner — on the settle path of every completion, turning a cost paid once per burst into work paid by every strand thread on every result, on the exact path Completion's value-handling contract exists to keep cheap. The amortised sweep buys the same O(1) admission for one size_t of state confined to LocalBackend, at the cost of a list bounded at 2× the live count instead of exactly it. |
setReconnectHandler |
Default no-op | Only backends with a transport layer (e.g. QtWebSocketBackend) need to react to reconnects. LocalBackend and SimulatedRemoteBackend never invoke it. |
setConnectHandler/setDisconnectHandler on IBackend, not only QtWebSocketBackend |
Same no-op-default pattern as setReconnectHandler |
Connection state is a property of any transport-backed backend; a UI observing it shouldn't have to downcast to a concrete backend type. A purely local backend has no meaningful connection state, so the base-class hook is simply inert for it — no behavior change, matching the existing setReconnectHandler precedent exactly. |
setDisconnectHandler fires before reconnect scheduling |
Ordering choice, not incidental | An instant successful reconnect must not look, from an observer's perspective, like nothing happened — the disconnected state must be visible even when the very next thing that happens is a fresh connected. |
| Strand-per-model | ModelStrands (core-cpp's KeyedStrands) serialises actions per ModelId |
Actions against the same model run sequentially; different models can run in parallel. No global lock on the pool. |
Overwrite session.principal on remote execute |
authenticate() result replaces the client claim before dispatch |
The client-asserted Context::principal is untrusted; a verifying authorizer makes the token-derived identity authoritative so session::current()->principal inside a model is trustworthy. Non-verifying authorizers return nullopt and change nothing. |
| Opaque model ids | Monotonic counter run through a keyed 4-round Feistel permutation (detail::OpaqueIdGenerator), key drawn from std::random_device at construction |
Guarantees uniqueness (Feistel networks are bijections for any round function) while making ids unguessable without the key; self-contained, no external crypto dependency — same posture as the reference HMAC-SHA256 in session_auth.hpp. |
WebSocket deregisterModel is fire-and-forget |
Send-only, no nested event loop | A synchronous deregister would need a nested QEventLoop, which is typically driven from a destructor (~BridgeHandler) and can trip Qt asserts. A lost/undelivered deregister no longer leaks indefinitely: QtWebSocketServer's connection scope reclaims the model at the next disconnect (see Limitations). |
Connection-scoped cleanup bypasses IAuthorizer |
closeConnection never calls authorize/authorizeInstance/authenticate |
It is server housekeeping triggered by the transport's own connection-close event, not a caller action; synthesising a deregister envelope would need a token to pass ownership checks and would require the transport to learn ids by parsing replies — recording the owning connection at register time is simpler and cannot desync. |
callId-multiplexed replies |
Every request carries a non-zero callId from one counter per connection, the fire-and-forget deregister included, on both WebSocket transports; a reply with callId == 0 names no request and is dropped |
Lets one socket carry many requests in flight and match each reply to its Completion without waiting for any of them. deregister takes an id too, filed only so its reply is recognised and discarded rather than mistaken for another request's. |
| Reconnect handler skipped on first connect | Fired only when _everConnected was already true |
The initial handler registration is driven by BridgeHandler constructors; firing the reconnect handler on the very first connect would double-register. |
| No reconnect for never-connected sockets | disconnected schedules a retry only if _everConnected |
A socket that never reached the server (bad URL / refused) fails fast via waitForConnected returning false, rather than backing off forever. |
| Server reply marshalled to the Qt thread | QMetaObject::invokeMethod(..., QueuedConnection) with a QPointer |
RemoteServer::handle produces the reply on a pool thread, but QWebSocket::sendTextMessage must run on the Qt thread; the weak QPointer drops the reply cleanly if the client disconnected meanwhile. |
executeTimeout implementation |
A morph::async::detail::TimeoutScheduler per RemoteServer, created by the constructor when ServerConfig::limits.executeTimeout is positive, on a private exec::IoLoop (one thread running core-cpp's PlatformLoop), not a per-call thread |
IExecutor has no delayed-post primitive and RemoteServer is transport-agnostic (cannot assume Qt's QTimer). One thread amortizes across every timed call, and a server configured without the feature pays no cost. The deadlines are the loop's own timers, so nothing polls: an armed timer is what bounds the loop's next wait. |
messagesPerSecond algorithm |
Per-connection token bucket, capacity = rate, continuous refill; on empty the frame is refused with an err reply, and the connection is left open |
Simplest correct rate limiter; allows a legitimate one-second burst without penalizing an otherwise well-behaved client. Refusing rather than closing keeps a transient burst from taking down the connection. The frame is answered rather than discarded because a reply costs nothing at the protocol level and is the difference between a caller's Completion failing and it hanging: the id is recovered by the same bounded prefix scan (peekCallId) the maxMessageBytes branch uses, so no decode of a frame that will not run is needed. |
Graceful shutdown drains via a shared in-flight counter, not a new IExecutor::waitIdle |
RemoteServer counts its own accepted-but-unreplied executes rather than adding a general drain API to IExecutor |
The drain condition morph can define precisely — "every accepted execute has replied" — lives at the server layer, where the work is counted; executor.md's "no graceful drain / waitIdle" limitation is deliberately left as-is for raw executor users. |
| Backend-change-awareness captured at registration | IModelHolder::isBackendChangeAware() (compile-time answer per model type) + LocalBackend::_changeAware, maintained by registerModel/deregisterModel |
Replaces a per-notifyBackendChanged-call dynamic_cast sweep over every live model with a virtual query done once at registration, and a lookup restricted to the models that actually opted in. No RTTI dependency; cost is O(change-aware models) instead of O(all models). No change to the model-facing contract (IBackendChangedSink, BackendChangedMixin) or to when/where onBackendChanged() runs. |
morph::net's I/O model |
One exec::IoLoop (a core-cpp PlatformLoop and its one thread) injected into every component, instead of a thread per component or the Qt event loop |
Lets SocketBackend/SocketServer run with no GUI event loop and no Qt dependency, and puts every socket, timer and probe of a process on one owner: the components' state needs no lock, and a process runs one I/O thread rather than one per connection. Injected rather than a framework-owned singleton so the dependency is visible in each constructor and nothing global outlives a test. SocketBackend stays callable from any thread (QtWebSocketBackend is pinned to its event-loop thread) because every verb posts to the loop. |
morph::net frame/handshake implementation |
Hand-rolled RFC 6455 (SHA-1 + HTTP Upgrade + frame codec), with base64 from core-cpp, not a WebSocket library | The spec's own interop requirement (a morph::net client/server must talk to the real Qt transport and vice versa) rules out a bespoke non-WebSocket framing; hand-rolling avoids adding a dependency beyond core-cpp, which morph links anyway, and RFC 6455's core (handshake + frame codec, including fragment reassembly) is a small, bounded surface. |
WsFrameReader reassembles fragments |
Accumulates continuation frames and returns only the completed message | Fragmentation is not an exotic case: a peer fragments whenever a message exceeds its outgoing frame size, and Qt's QWebSocket defaults that to 512 KiB. Rejecting fragments broke interop with the transport this project ships, for every payload past that size. Control frames interleaved between fragments pass through untouched, and the reassembled total is bounded by wire::kMaxEnvelopeBytes so a stream of tiny continuations cannot grow the buffer without limit. |
WsFrameReader rejects RFC 6455-illegal frames instead of tolerating them |
Masking direction, RSV bits, opcode range, control-frame framing, Close status code, minimal length encoding and text-payload UTF-8 are all checked; a violation throws out of tryExtractFrame() and the call site drops the connection |
The interop requirement above makes what the reader refuses part of the transport's contract rather than an implementation detail: a tolerant reader accepts ten classes of illegal frame, and a peer that sends one here gets disconnected instead. The reader is given its role at construction (expectMasked) because §5.1 is directional — a server MUST reject an unmasked client frame and a client MUST reject a masked server frame, and that rule is the anti-cache-poisoning defence, not a formality. Text UTF-8 is validated incrementally, since a multi-byte sequence may straddle a fragment boundary. On the sending side the mask key is drawn per frame from a thread-local std::random_device rather than a thread-local std::mt19937, whose state a peer can reconstruct from 624 observed keys (§5.3); random_device has no reproducible state to recover, and holding it thread-local keeps the entropy source open instead of reacquiring it on every outbound message. |
Registration continuation delivered via a caller-supplied IExecutor&, not on the backend's thread |
bindModel/promoteModel return a Completion<ModelId> built with the caller's executor |
A per-verb non-blocking twin can only state its threading contract in prose, and a violation of it is a use-after-free. Making the executor an argument moves the choice of delivery thread from fifteen implementors that know nothing about the caller's teardown to the one caller that does, and turns it from a @note into a value a call site must produce. Rejected: matching execute's IExecutor* — a null pointer makes Completion drop every handler silently, which is the same unobservable failure the surface removes. |
One bindModel instead of three acquire verbs |
Behaviour selected by BindRequest's shape (primary empty?, current zero?) |
The three verbs already degrade into each other exactly along those two fields, so naming them separately stated the same distinction three times — and tripled it again for the *Async twins. The default implementation still routes each shape to the legacy verb it names, so a backend that overrides only some of the three is unaffected. |
SynchronousBackendAdapter is a decorator, not a base class or a CRTP mixin |
Wraps shared_ptr<IBackend> and forwards every verb |
It must work on LocalBackend and eleven test doubles without modifying them, which rules out anything they would have to derive from. Cost is one forwarding method per unchanged verb; benefit is that an unmodified blocking backend reaches the new surface at all. |
| The adapter's blocking executor is required, not defaulted | Constructor parameter with no default; null inner throws |
"Where does the blocking happen" is the only question the class exists to answer. An adapter that silently ran the call inline when handed nothing would block on some configurations and not others — contract by configuration, which is the thing being removed. |
SocketBackend runs reconnect handlers on a dedicated thread |
Not on the I/O loop that completes the connection | A reconnect handler re-registers models via the synchronous control path, which waits for a reply only the loop's read flow can deliver. Inline, that wait blocks the very thread that would satisfy it, deadlocking the transport with no timeout. Still load-bearing even though bindModel cannot deadlock this way: the blocking verbs can, and a caller may still reach them. |
SocketBackend implements bindModel/promoteModel natively |
Not wrapped in SynchronousBackendAdapter, although it overrides none of the four *Async verbs |
The I/O loop already demultiplexes replies by callId for execute and RemoteServer echoes callId on every control reply, so the non-blocking path costs a second PendingCallTable and no protocol change. Wrapping instead would park a thread per bind for a round trip this transport need not park for. The adapter's reconnect-handler property, the other reason to consider it, does not apply: it forwards setReconnectHandler and the blocking verbs straight through, so a wrapped SocketBackend would run reconnect control calls exactly where it does today. |
LocalBackend's IExecutor& workerPool, RemoteServer's workerPool,
dispatcher and registry, and SimulatedRemoteBackend's RemoteServer& are
all marked MORPH_LIFETIMEBOUND (morph/attributes.hpp) — the "must outlive"
column of the destruction-ordering table, restated where the compiler can check
it. See concurrency_and_lifetimes.md.
| Spec | Relationship |
|---|---|
| bridge.md | Bridge owns one IBackend and swaps it via switchBackend(); BridgeHandler/HandlerBinding carry the contextKey that reaches registerModelWithContext. executeVia builds the ActionCall. |
| session.md | Context, IAuthorizer::authorize/authenticate/authorizeInstance/authorizeRegister, ScopedContext, session::current(). The principal-overwrite contract is specified there and enforced here. |
| security.md | Threat model for RemoteServer: authorization coverage, the untrusted client principal, and what register/deregister do not check. |
| wire.md | Envelope, encode/decode, makeOk/makeErr/makeRegister/makeDeregister, and the kind discriminator the server switches on. |
| registry.md | ModelRegistryFactory::create (remote model construction, BRIDGE_REGISTER_MODEL), ActionDispatcher::dispatch (the remote execute call site), and the Loggable policy. |
| completion.md | Completion<shared_ptr<void>> returned by execute, the CompletionState the backends track for cancelPending, and cbExec callback delivery. |
| offline.md | NetworkMonitorConfig (the sibling struct whose declaration-order rationale QtWebSocketBackendConfig mirrors) and the disconnect/reconnect story the QtWebSocketBackend transport participates in. |
| executor.md | IExecutor / ThreadPoolExecutor (the server worker pool); qt/qt_executor.hpp's QtExecutor is the cbExec a Qt host uses to deliver completion callbacks onto the Qt thread, while a morph::net::SocketBackend host uses a plain ThreadPoolExecutor/MainThreadExecutor instead — no Qt event loop required. |
| observability.md | The morph::observe metrics/trace seam wrapping RemoteServer/LocalBackend dispatch, and RemoteServer::health()/ServerConfig::healthHandler. |
| testing_strategy.md | fuzz_dispatch_execute fuzzes RemoteServer::handle directly; the soak test (test_soak_switch_backend.cpp) cycles switchBackend between LocalBackend and SimulatedRemoteBackend under load; the load benchmark (bench_dispatch_latency.cpp) baselines dispatch throughput/latency; the adversarial run (test_qt_websocket_adversarial.cpp) drives a hostile client against QtWebSocketServer and exercises the default (unconfigured) LimitPolicy/QtWebSocketServerConfig. |
- Local and remote are not fully interchangeable. The GUI-facing API is
identical, but the two paths construct models differently.
LocalBackendruns the caller-supplied factory closure, which can capture arbitrary dependencies and need not be default-constructible.RemoteServerignores the factory and constructs via theModelRegistryFactory, which requires the model to be default-constructible and macro-registered (BRIDGE_REGISTER_MODEL). A model that works locally can therefore fail at remoteregisterwitherr "unknown model type: ..."(or fail to compile the registration if it is not default-constructible). Parity between the two paths is a property of the model, not something the framework guarantees. registerauthorization and id opacity are both opt-in.RemoteServerassigns model ids by running a monotonic counter through a keyed 64-bit Feistel permutation (detail::OpaqueIdGenerator), so ids are no longer sequential/trivially guessable — but this narrows enumeration, it does not replace authorization: a caller who independently learns a valid id can still target it.registeris now gated by the optionalIAuthorizer::authorizeRegisterhook, consulted after authentication and before instance creation; its default allows everything, so an unconfigured server still lets any reachable client create instances of any known type.executeandderegisterremain gated by the optionalauthorizeInstancehook (also allow-all by default) in addition toexecute's type-levelauthorizestep. A hardened multi-tenant deployment overridesauthorizeRegisterandauthorizeInstance. See security.md.- Connection-scoped cleanup is opt-in, and only
QtWebSocketServeruses it among the shipped transports.RemoteServerreclaims a connection's models automatically only when the transport participates in the scope contract (openConnection/ the scopedhandle(msg, reply, cid)/closeConnection— see above).QtWebSocketServeropts in end to end, so a WebSocket client crash or drop now reclaims its models instead of leaking them. A transport that never callsopenConnection/closeConnection— or that keeps using the unscoped two-argumenthandle()/handleInline()— gets none of this:SimulatedRemoteBackenddeliberately stays on the unscoped path (its "connection" is the process itself), so its models still live until an explicitderegisteror process exit, unchanged from before this feature.morph::net::SocketServeropts in exactly asQtWebSocketServerdoes, so aSocketBackendclient that disconnects without an explicitderegisterModelhas its models reclaimed rather than leaked. Deregistration therefore remains the caller's responsibility only for a path that does not go through a scope-aware transport. - WebSocket transport is single-threaded and Qt-bound.
QtWebSocketBackendmust live on the Qt event loop thread; there is no way to drive it from a plain worker thread, andwaitForConnectedpumps a nestedQEventLoopon that thread. Completion callbacks reach the GUI only ifcbExec(typicallyQtExecutor) posts back to the Qt loop.morph::net::SocketBackenddoes not have this limitation (see above) — but a test or app that mixes aSocketBackend/SocketServerwith aQtWebSocketServer/QtWebSocketBackendpeer on the same thread that owns theQCoreApplicationmust still pump Qt events while any blockingmorph::netcall is outstanding, or the Qt-side peer starves (see theSocketBackend/SocketServerThreading note above). morph::netis Linux/macOS only. The listener is bound throughTcpSocket, a thin wrapper over POSIXsys/socket.h, before the loop adopts it;MORPH_BUILD_NET=ONon Windows produces amessage(WARNING ...)and builds nothing. Windows/Winsock2 support is documented future work, not implemented today. Teardown does not depend on the kernel: closing the listener and each connection's socket resumes their parked flows through the loop on every platform.- A blocking callback stalls the whole loop. Every socket, timer and probe
built on one
IoLoopshares its thread, so aTimeoutSchedulercallback, aNetworkMonitorprobe or callback, or a completion delivered oninlineExecutor()that blocks, stalls every connection on that loop until it returns. The synchronous control verbs throw on the loop's thread; anything else is the caller's to keep short. - A hostname is resolved off the loop, on core-cpp's resolver pool. A
numeric address never leaves the loop's thread; a name goes to
core::net::defaultAsyncResolver(), a small fixed pool core-cpp owns process-wide. morph::nethas no TLS.SocketBackend/SocketServerspeak plaintextws://only;parseWsUrlthrows immediately on awss://URL. Awss://variant needing a TLS library (e.g. OpenSSL) is future work.morph::netnever sends a fragmented message.encodeWsFramealways writes one completeFIN=1frame. Incoming fragments are reassembled (see the design-decision table), so this is one-directional and not a practical limitation for morph's own traffic — awire::Envelopeis always one JSON line, and the frame format's 64-bit extended-length field already covers up towire::kMaxEnvelopeBytesin a single frame.- A protocol violation drops the connection without sending a Close frame.
WsFrameReaderrejects a frame that is masked in the wrong direction (an unmasked client→server frame, or a masked server→client one), has any RSV bit set (no extension is ever negotiated), carries a reserved opcode (0x3-0x7,0xB-0xF), is a control frame that is fragmented or longer than 125 bytes, is a Close frame whose status code is not one the IANA close-code registry allows on the wire (1000-1003, 1007-1014, 3000-4999 — note 1012-1014 were registered after RFC 6455 §7.4.1's own table) or whose reason phrase is not valid UTF-8, uses a non-minimal extended-length encoding, or is a text message whose payload is not valid UTF-8. Each of those throws out oftryExtractFrame();SocketBackend::drainFrames/SocketServer::drainFramescatch it, stop reading and tear the connection down — no1002/1007/1009Close frame is sent first, so the peer learns only that the connection went away, and neither side logs which check failed. Sending the RFC status code needs the reader's error model to change from throwing tostd::expected<..., WsProtocolError>at both call sites; that is separable work and is not done here. A Close frame from the peer is a different path and is not a violation: it is echoed back carrying the peer's own status code (§5.5.1), where it used to be echoed empty. - A dial in progress outlives
~SocketBackendby up toConfig::connectTimeout. The destructor does not wait for it: the dial's flow holds the loop-side state, finds it closed when the dial completes, and drops the socket. Until then it holds a descriptor and, for a hostname, a resolver-pool slot. RemoteServer::handleInlineneeds a free pool thread. It waits for the server strand, which runs on the pool; a one-thread pool calling it from inside its own task, or every pool thread blocked in it at once, never gets its reply. SeehandleInline(msg).RemoteServeradmits everyexecuteon one strand. The authorizer's hooks, the registry lookup and the optional payload parse run one at a time across all models; a slow authorizer slows admission for every model, though never the handlers, which run on their own strands.- Graceful shutdown never preempts a running action.
beginShutdown(),drainedWithin(), andcloseGracefully()only stop new work from arriving and wait for old work to finish; a model whose action runs longer than the caller'sdeadlinestill finishes on its strand afterdrainedWithinanswersfalse(and aftercloseGracefully's hard stop reclaims the connection). This is intentional — morph never interrupts a strand task — but it does mean a model with no self-imposed bound can makecloseGracefullyalways hit its hard stop.