morph::exec provides a small set of executor abstractions that control
where and when posted tasks run. The design is intentionally minimal:
every executor accepts std::function<void()> tasks and guarantees they
execute at some point after post() returns, but the concurrency model,
threading, and serialisation semantics differ per implementation.
- Type overview
IExecutor— the abstract interfaceThreadPoolExecutorMainThreadExecutorQtExecutorStrandExecutorandModelId- Lifetime & ownership
- Thread safety
- Failure modes
- API reference
- Design decisions
- Limitations
- Cross-references
There are seven types, split across morph::exec (in executor.hpp),
morph::exec::detail (in strand.hpp), and morph::qt (in
qt/qt_executor.hpp):
| Type | Namespace | Purpose |
|---|---|---|
IExecutor |
morph::exec |
Abstract base: a single pure-virtual post(task). |
ThreadPoolExecutor |
morph::exec |
Fixed-size thread pool, FIFO queue, task exceptions logged (never propagate). |
MainThreadExecutor |
morph::exec |
Collects tasks from any thread, drains on runFor() from the owning thread. |
QtExecutor |
morph::qt |
Posts tasks to a Qt event loop; they run on the configured context object's thread (the QCoreApplication/GUI thread by default). |
ModelId |
morph::exec::detail |
Opaque 64-bit identifier for a model instance, used as a strand key. |
ModelIdHash |
morph::exec::detail |
Hash functor so ModelId can be an unordered_map key. |
StrandExecutor |
morph::exec::detail |
Per-key serialising wrapper — tasks with the same ModelId never overlap. |
IExecutor and the two thread-based concrete executors live in the public
morph::exec namespace. QtExecutor lives in morph::qt (in the separate
qt/qt_executor.hpp header) because it depends on Qt; only the GUI/bridge layer
pulls it in. StrandExecutor, ModelId, and ModelIdHash live in
morph::exec::detail because they are implementation details of the morph model
framework, not general-purpose utilities.
A pure-virtual post(std::function<void()>) that schedules a callable for
asynchronous execution. Thread-safe. How a task's exception is handled is left
to the implementation; the header's default wording says an exception "is
silently swallowed unless the implementation documents otherwise", and every
concrete executor here does document otherwise — the two thread-based ones
catch and log, QtExecutor defers to Qt (see Failure modes).
No implementation lets a task exception escape post() (the task has not run
yet when post() returns).
A fixed-size thread pool. The constructor spawns n worker threads; each worker
loops, waiting for tasks on a shared condition variable. Tasks are dispatched in
FIFO order from a single mutex-protected queue.
n is clamped to a minimum of 1. A pool with zero workers would accept
posted tasks that no thread could ever run, so every post() would hang forever
and any StrandExecutor built on it would deadlock in its destructor waiting on
_inFlight. Passing 0 therefore yields a usable single-worker pool rather than
a silently dead one; values ≥ 1 spawn exactly that many workers.
The destructor signals stop, notifies all workers, and joins every thread. The
workers drain the queue before exiting: once _stop is set the loop exits
only when _stop && _q.empty(), so workers keep popping and running
already-queued tasks (including strand lambdas re-posted from within a running
task) until the queue is empty. The join therefore blocks until every task
queued before destruction has run. The one thing not covered is a task
post()ed concurrently with or after destruction: it races the last worker's
exit and may be silently lost. Exceptions from tasks are caught in the worker
loop and logged via
morph::log::logError (see Failure modes); they never
propagate out of a worker, so one failing task neither kills its thread nor
aborts sibling tasks.
A single-thread executor that does not spawn its own thread. Tasks posted
from any thread are enqueued and only executed when the owning thread calls
runFor(timeout). Useful in tests or event loops that lack a native dispatcher.
runFor() runs queued tasks one by one for up to the given wall-clock
duration. It uses a condition-variable wait_until on the deadline, so it does
not return early when the queue drains — while time remains it keeps waiting
for newly posted tasks and only returns once the deadline is reached. If a task
throws a std::exception, the exception is logged (via morph::log::logError,
prefixed "[main-thread] callback threw: ") and execution continues with the
next task. Note that only std::exception-derived exceptions are caught; any
other thrown type propagates out of runFor().
Two additional step-oriented primitives sit alongside runFor() for callers
that want deterministic, non-blocking control instead of a timed pump:
runOnce()dequeues and runs at most one pending task and returns immediately —trueif a task was found and run (regardless of whether it threw),falseif the queue was empty. UnlikerunFor(), it never waits for a task to appear.drain()repeatedly calls the same dequeue-and-invoke step asrunOnce()until the queue is observed empty, then returns — with no wall-clock timeout and no wait for externally posted tasks. A task that posts new work while running extends the drain (the new task is still in the queue and gets consumed beforedrain()returns), butdrain()never blocks waiting on work from another thread the wayrunFor()'swait_untildoes.
All three share one dequeue-and-invoke step (pop under _m, then run outside
the lock with the same try/catch std::exception logging), so the exception
handling and locking discipline described above apply identically to
runOnce() and drain().
An IExecutor implementation that marshals tasks onto a Qt event loop. Lives
in morph::qt (header morph/qt/qt_executor.hpp) and is compiled only when Qt
is available; it is the GUI leg of the executor family and the counterpart the
bridge hands to backends as their main-thread executor.
The constructor takes an optional QObject* context, defaulting to
QCoreApplication::instance(). post() forwards the callable to
QMetaObject::invokeMethod(context, fn, Qt::QueuedConnection). Because the
connection is queued, the task always runs on the thread that owns context
(the application/GUI thread by default), regardless of which thread called
post(). post() is therefore thread-safe and returns immediately; the task
runs later, once that thread's event loop processes the queued event. The
caller does not need to be (or supply) a QObject — only the constructor
optionally takes one, to pick the target thread.
post() enqueues and returns. The queued Qt event outlives that call, so
joining the worker pool whose task made the call proves only that the
post() happened — never that the event was delivered. A task still
sitting on the Qt queue when its QtExecutor is destroyed would otherwise be
delivered against a freed executor and read _context off freed memory.
Each queued task therefore carries a weak observer of its executor's lifetime and does nothing if the executor is already gone.
This is not a theoretical hazard. Bridge::executeVia chains
three Completion objects per dispatched action, each settled from inside
the previous one's delivered callback, so a caller waiting only on its own
top-level completion can observe "done" while an intermediate post is still
queued; when that stale event is finally pumped, its body calls post() for
the next link. Before the guard this segfaulted ordinary uninstrumented
builds, not merely sanitizer runs (morph#127).
Dropping is the correct outcome rather than the lesser evil: a chain being
torn down has nobody left to observe its result, and
Completion's orphan logging already covers a completion
that never resolves. Owners consequently do not need to drain the event
loop before destroying an executor.
Boundary. The check assumes the executor is destroyed on the same thread that runs its context's event loop, which holds for every owner in this repository. Destroying one from another thread while its loop is mid-delivery still needs external synchronisation: this closes the "torn down with events still queued" hole, not a genuine cross-thread race.
Passing a non-default context — e.g. a plain QObject that has been
moveToThread()'d onto a worker QThread with its own event loop — lets a
QtExecutor dispatch to that worker thread instead of the GUI thread. This is
the standard way to get a QtExecutor-compatible IExecutor for a non-main
thread: give the worker thread a live QObject and construct the executor with
a pointer to it. QMetaObject::invokeMethod reads context->thread() at
dispatch time, so if context is reparented to a different thread after
construction, subsequently posted tasks follow it to the new thread.
Unlike MainThreadExecutor, QtExecutor needs no explicit runFor() drain —
the running Qt event loop is the dispatcher. It owns no queue and spawns no
thread, but it is not stateless and does have a shutdown concern of its own:
besides the context pointer it holds an _alive control block, and a task
still queued when the executor is destroyed is dropped rather than run — see
Teardown: queued tasks are dropped, not delivered
above.
Because the context object is not owned by QtExecutor, Qt drops the queued
invocation if that object (or the thread it lives on) is destroyed before
the event is processed — for the default context this means application
shutdown; for a custom worker-thread context, callers must keep the context
QObject alive at least as long as tasks may still be posted. Qt has no
visibility into QObjects captured inside the opaque std::function: a task
whose captured widget was deleted still runs and will dereference the dangling
pointer. There is no per-task implicit cancellation — callers that capture a
QObject must guard it themselves (e.g. QPointer or a liveness token) (see
Limitations).
A per-key serialising executor built on top of any IExecutor. Tasks posted
with the same ModelId key execute in FIFO order with no overlap, even when the
underlying executor is a thread pool. Tasks with different keys may run
concurrently.
ModelId is an opaque 64-bit identifier. Zero is reserved and means "not
bound". Non-zero values are assigned by the backend and are stable for the
lifetime of the model. It supports three-way comparison and can be used as an
unordered_map key via ModelIdHash.
Internally StrandExecutor maintains a map of ModelId → shared_ptr<Strand>
(shared state per key). A Strand holds a pointer to the base IExecutor, a
mutex, a pending queue, and a running flag. The executor also tracks an
_inFlight counter (guarded by the map mutex) that the destructor waits on.
_inFlight is incremented with the decision to dispatch, not lazily.
post() increments _inFlight in the same _mapMtx critical section that flips
running true and decides to schedule, before releasing the lock; the re-arm
step in the strand task likewise increments under the _mapMtx it already holds,
before the current run's own decrement. This closes an internal window that would
otherwise exist if the increment were deferred to a later _mapMtx acquisition
in scheduleNext: between releasing _mapMtx in post() and re-taking it to
count the dispatch, ~StrandExecutor could acquire _mapMtx, observe
_inFlight == 0, and destroy the map before the dispatched lambda touched it.
Because "decided to schedule" and "counted as in-flight" are now atomic under one
lock, and the re-arm's increment precedes the prior run's decrement, _inFlight
never dips to a spurious 0 across a scheduling hand-off. (This is distinct from
the caller-discipline rule below, which concerns a post() that genuinely
arrives after teardown has begun.)
The per-key serialisation invariant: at most one live Strand exists per
ModelId, and any strand that is (or becomes) running is the strand currently
stored in the map for that key. This is what guarantees a key's tasks never
overlap — a single strand runs them one at a time.
Two operations can violate that invariant if they interleave: post() pushing a
task and flipping running true, and scheduleNext's drain step clearing
running and erasing the map entry when the queue empties. Holding the
combined {_mapMtx, strand->mtx} lock only inside the drain step is not
enough: the earlier design took _mapMtx in post() only long enough to look
the strand up, released it, and then re-armed the strand under strand->mtx
alone. A concurrent drain could erase the strand in that gap, orphaning a live
strand — and the next post(key) would then create a second strand for the
same key, so two strands ran the key's tasks concurrently.
The fix makes both sides hold _mapMtx across their whole decision. post()
takes _mapMtx, does the slot lookup/create, and then — still holding
_mapMtx — takes strand->mtx to push the task and set running. The drain
step takes the same two locks in the same order. Because the map lookup, the
re-arm, and the erase are all serialised by _mapMtx, a strand that becomes
running in post() is guaranteed to still be the map entry, and the drain
never erases a strand whose pending queue is non-empty. The orphaning window is
gone. Lock order is always _mapMtx → strand->mtx; both sites acquire them as
two sequential scoped_locks in that order (rather than one scoped_lock over
the pair, whose std::lock back-off can grab them in address order), so a single
consistent order holds everywhere and there is no lock-ordering deadlock.
Each strand task is the point where the model's own code actually runs, so the
task wrapper catches exceptions and logs them via morph::log::logError (see
Failure modes) before deciding whether to keep the strand
running. A throw therefore neither stalls the strand nor skips the drain-and-erase
bookkeeping: the next queued task for that key still runs.
The destructor waits for all in-flight tasks to complete (_inFlight == 0)
before destroying the strand map.
Testing per-model ordering without naming StrandExecutor/ModelId (issue #55).
RemoteServer (see backend.md) owns a StrandExecutor internally, but every
task it ever dispatches — the top-level handle() post and the internal
per-model strand dispatch alike — funnels through the single IExecutor the
server was constructed with. A caller that wants a deterministic, hand-stepped
interleaving harness against RemoteServer's real per-model ordering does not
need to touch morph::exec::detail::StrandExecutor or
morph::exec::detail::ModelId at all: constructing the server against a
single-step, test-controlled IExecutor (see tests/test_support.hpp's
morph::testing::StepExecutor) and driving it one task at a time is enough —
RemoteServer's own wire replies carry the model id as a plain uint64_t
(wire::Envelope::modelId), so a test never needs the ModelId vocabulary
either.
StrandExecutor stores a raw pointer to the base IExecutor (_base, copied
into each Strand::base). It does not own the base and never extends its
lifetime. Two invariants make the arrangement safe, and violating either is a
latent bug:
-
The base
IExecutormust outlive theStrandExecutor. Every strand dispatch callsstrand->base->post(...), and~StrandExecutorblocks until the last of those dispatched lambdas has run. So the base must still be alive for the whole life of the strand, including the destructor's wait. -
The base must actually run — not lose — every task the strand posts.
~StrandExecutoronly returns once_inFlightreaches 0, and_inFlightis decremented inside the dispatched lambda, after the task runs._inFlightis incremented on the strand thread before the lambda is handed tobase->post(). If a posted lambda never runs — because it was handed to a pool that is already being destroyed or has already joined its workers — that decrement never happens and the strand destructor waits forever.
These two combine into the framework's most important ordering rule for these
types. ~ThreadPoolExecutor drains its queue (workers run every
already-queued task before joining), whereas ~StrandExecutor blocks until
_inFlight == 0. Draining is not enough to make arbitrary teardown order safe,
because the strand can still be dispatching while the pool tears down.
Therefore:
Always destroy the
StrandExecutorbefore the base pool it wraps.
If the base ThreadPoolExecutor is destroyed first, two things go wrong. A
strand lambda still in flight may call base->post() on a pool whose destructor
has run — undefined behaviour (use-after-free on the pool's queue/mutex). Even
absent UB, a lambda posted after the pool's workers have already observed
_stop && _q.empty() and exited is never run, so its --_inFlight never
happens and the subsequent ~StrandExecutor deadlocks on its condition variable
forever. With member declaration order this means the pool must be declared
before the strand (members destroy in reverse order), or the two must be torn
down explicitly in that order.
A second rule follows from the same wait: no post() may race with or follow
~StrandExecutor. The destructor takes _mapMtx and waits for
_inFlight == 0, but it does not block new post() calls. A post() that
arrives concurrently with (or after) destruction can enqueue work and re-arm a
strand after the destructor believed it had quiesced, reintroducing exactly the
data race the _inFlight wait exists to prevent. Callers must ensure all task
sources are shut down before the StrandExecutor is destroyed.
The strand map is self-cleaning: when a strand drains (its pending queue is
empty), scheduleNext clears running and erases the map entry under the
combined {_mapMtx, strand->mtx} lock. Live memory therefore tracks the set of
currently active models rather than every model ever seen — there is no
per-model registration to leak. The cost is allocation churn: a model that is
posted to in bursts allocates a fresh Strand each time its queue empties and
refills, rather than keeping one long-lived strand per key.
All four executors' post() methods are safe to call from any thread
concurrently.
ThreadPoolExecutorguards its queue and_stopflag with a single mutex_mand coordinates workers on_cv. Multiple workers pop under the lock, so the FIFO order is a total order across producers; task execution is concurrent across thenworkers.MainThreadExecutorguards its queue with_m.post()may be called from any thread, butrunFor()must be called only from the single owning ("main") thread; concurrentrunFor()calls are not supported.StrandExecutoruses two lock levels:_mapMtxprotects the_strandsmap and the_inFlightcounter, and eachStrand::mtxprotects that strand'spendingqueue andrunningflag. Both operations that can break the per-key invariant hold_mapMtxacross their whole decision:post()takes_mapMtx, does the slot lookup/create, and then — still holding_mapMtx— takesstrand->mtxto push and re-arm; the drain-and-erase step inscheduleNexttakes the same two locks in the same order. This serialises the lookup, the re-arm, and the erase, so a concurrentpost()can no longer re-arm a strand after a drain has erased it (which would orphan a live strand and let two strands for one key run concurrently). Lock order is always_mapMtx→strand->mtx, acquired as two sequentialscoped_locks (not onescoped_lockover the pair) so a single consistent order holds at every site and there is no lock-ordering deadlock. The net guarantee: tasks with the sameModelIdnever overlap; tasks with different keys may run in parallel on the base pool.QtExecutorholds only aQObject*context pointer; its thread safety is entirely Qt's.QMetaObject::invokeMethodwithQt::QueuedConnectionis documented as safe to call from any thread, and the queued event is dispatched serially by the event loop of whichever thread owns the context object (QCoreApplication's thread by default).
| Executor | What happens when a task throws |
|---|---|
ThreadPoolExecutor |
The worker loop catches it. std::exception is logged as "[thread-pool] task threw: " + what(); any other type is logged as "[thread-pool] task threw unknown exception". The worker keeps looping. |
StrandExecutor |
The strand task wrapper catches it. std::exception is logged as "[strand] task threw: " + what(); any other type is logged as "[strand] task threw unknown exception". The strand's drain/erase bookkeeping and _inFlight decrement still run, so the next task for the key proceeds. |
MainThreadExecutor |
runFor catches only std::exception, logged as "[main-thread] callback threw: " + what(), then continues with the next task. Any non-std::exception type propagates out of runFor() and is the caller's problem. |
QtExecutor |
No try/catch of its own. A throwing task propagates into whoever drives the target thread's event loop (QCoreApplication::exec by default, or the worker thread's loop for a custom context); Qt's default behaviour is to std::terminate. Tasks posted through it must not let exceptions escape. |
All logging goes through morph::log::logError. The design principle: a task
failure must never kill a worker/strand or abort sibling tasks, but it must also
never be invisible. Previously these exceptions were swallowed silently; they
are now logged. ThreadPoolExecutor and StrandExecutor catch (...) and so
contain every exception type; MainThreadExecutor deliberately narrows its
catch to std::exception (a non-standard throw surfaces on the drain thread
rather than being hidden).
| Member | Signature | Notes |
|---|---|---|
post |
virtual void post(std::function<void()> task) = 0 |
Thread-safe. Task runs after the call returns. Per-implementation exception handling (both concrete executors log; see Failure modes). |
| dtor | virtual ~IExecutor() = default |
| Member | Signature | Notes |
|---|---|---|
| ctor | explicit ThreadPoolExecutor(std::size_t n) |
Spawns max(n, 1) worker threads. n == 0 is clamped to 1 (a zero-worker pool would hang every task). |
| dtor | ~ThreadPoolExecutor() override |
Signals stop, then joins all workers, which drain the queue (run every already-queued task) before exiting. Tasks posted concurrently with or after destruction may be lost. |
post |
void post(std::function<void()> task) override |
Enqueues to FIFO; notifies one worker. Thread-safe. Task exceptions caught and logged in the worker loop. |
| Member | Signature | Notes |
|---|---|---|
post |
void post(std::function<void()> task) override |
Enqueues; notifies waiters. Thread-safe. Not executed until runFor()/runOnce()/drain(). |
runFor |
void runFor(std::chrono::milliseconds timeout) |
Runs tasks until the timeout deadline (blocks for new tasks while time remains; does not return early on an empty queue). Must be called from the owning thread. std::exceptions logged and skipped; other exception types propagate. |
runOnce |
bool runOnce() |
Dequeues and runs at most one pending task; returns immediately, never blocks. Returns true if a task ran, false if the queue was empty. Must be called from the owning thread. |
drain |
void drain() |
Runs tasks until the queue is empty; no wall-clock timeout, does not wait for externally posted tasks. Must be called from the owning thread. |
| Member | Signature | Notes |
|---|---|---|
| ctor | explicit QtExecutor(QObject* context = QCoreApplication::instance()) |
Stores context as the invokeMethod target. Defaults to the application instance (GUI thread). nullptr makes post() a no-op (matches QMetaObject::invokeMethod's handling of a null target). |
post |
void post(std::function<void()> fn) override |
Posts fn to context's event loop via QMetaObject::invokeMethod(context, ..., Qt::QueuedConnection); runs on whichever thread owns context at dispatch time. Thread-safe; returns immediately. |
| Member | Signature | Notes |
|---|---|---|
v |
uint64_t v{0} |
Raw id. 0 = unbound. |
operator<=> |
auto operator<=>(const ModelId&) const = default |
Three-way comparison. |
| Member | Signature | Notes |
|---|---|---|
operator() |
std::size_t operator()(ModelId mid) const noexcept |
Hashes mid.v. |
| Member | Signature | Notes |
|---|---|---|
| ctor | explicit StrandExecutor(IExecutor& base) |
Wraps base. |
| dtor | ~StrandExecutor() |
Blocks until _inFlight == 0. Requires the base to outlive it and to run every posted task — otherwise deadlocks (see Lifetime & ownership). |
post |
void post(ModelId key, std::function<void()> task) |
Enqueues for strand key. FIFO per key, concurrent across keys. Thread-safe. Task exceptions caught and logged. Must not race/follow the destructor. |
| Decision | Choice | Why |
|---|---|---|
| Task signature | std::function<void()> |
Simple, universal. Every executor accepts the same callable type. No return value, no cancellation. |
| Exception handling | Caught and logged, never propagated out of a worker/strand | A task failure must not crash unrelated tasks or vanish. ThreadPoolExecutor and StrandExecutor catch (...) and log via morph::log::logError; MainThreadExecutor narrows its catch to std::exception so a non-standard throw surfaces on the synchronous drain thread. See Failure modes. |
| ThreadPoolExecutor drain-on-dtor | Drain the queue, then join | Workers run every already-queued task before exiting, so a StrandExecutor's in-flight lambdas complete and decrement _inFlight as long as the pool outlives the strand. There is no public waitIdle/graceful-shutdown API; tasks posted after destruction begins may be lost, so the caller must still synchronise teardown order externally. |
MainThreadExecutor's runFor |
Wall-clock deadline | Lets the caller batch-process tasks without spinning. The condition-variable wait avoids busy-waiting. |
MainThreadExecutor's runOnce/drain |
Thin wrappers sharing runFor's dequeue-and-invoke step, added alongside it |
runOnce() steps exactly one task without blocking; drain() loops runOnce() until the queue is empty. Neither waits on new tasks from other threads, unlike runFor()'s deadline-scoped wait — this gives event-loop integrations and tests deterministic, non-blocking single-step control without replacing runFor()'s existing behavior. |
| ModelId zero | Reserved — "not bound" | A natural sentinel for optional/uninitialised model handles. |
StrandExecutor in detail |
Not a general-purpose utility | Exists only for the morph model framework's per-model serialisation. The ModelId key is specific to model instances. |
| StrandExecutor destructor | Waits for in-flight tasks | Without this, a pool thread running scheduleNext can access _strands after it has been destroyed (TSan: data race on destructor vs erase). |
| Strand per-key invariant | post() and drain-and-erase both hold _mapMtx across their whole decision |
Serialises lookup, re-arm, and erase so at most one live strand exists per key and any running strand is the map's current entry. Holding the combined lock only in the drain step was insufficient — post() re-armed under strand->mtx alone after releasing _mapMtx, so a drain could erase the strand in that gap, orphan it, and let a second strand for the same key run concurrently. |
No std::future / return value |
Fire-and-forget only | Executors schedule side-effect tasks. Callers that need results use shared state or futures externally. |
No std::executor conformance |
Custom interface, not std::executor |
C++26 std::executor is not yet widely available. This is a minimal in-house abstraction. |
QtExecutor via invokeMethod, not a QObject subclass |
Near-stateless free-standing IExecutor, target configurable via ctor |
Uses QMetaObject::invokeMethod(context, fn, Qt::QueuedConnection), so callers need no custom QObject, event type, or slot — they only optionally supply a QObject* to pick the target thread. Defaults to QCoreApplication::instance() so existing GUI-thread call sites are unaffected. Keeps the type a drop-in IExecutor holding a single pointer, with Qt's event loop as the sole dispatcher. |
QtExecutor in a separate morph::qt header |
Isolate the Qt dependency | The core executor family (executor.hpp, strand.hpp) stays Qt-free; only the GUI/bridge layer includes qt/qt_executor.hpp. Backends depend on IExecutor, never on Qt. |
These are honest, known gaps — accepted trade-offs, not bugs:
- Unbounded queues / no backpressure.
ThreadPoolExecutor,MainThreadExecutor, and eachStrand::pendingare all unboundedstd::queues. A producer that outruns consumption grows memory without limit;post()never blocks or rejects. There is no bounded-queue option, no high-water mark, and no way for a caller to learn the queue is backing up. - No cancellation. Once posted, a task cannot be cancelled or removed. The
signature is fire-and-forget
std::function<void()>with no token, handle, or future. There is no implicit cancellation either: by defaultQtExecutorposts againstQCoreApplication::instance(), so Qt drops a queued invocation only at application shutdown (or, for a customcontext, when that object or its thread is destroyed) — never because aQObjectcaptured inside the callable was deleted. Such a task still runs against the dangling capture. Callers must guard their own captures. - No graceful drain /
waitIdleonThreadPoolExecutor. The destructor drains already-queued tasks but there is no method to wait until the queue is empty, to flush pending work before shutdown, or to reject work posted during shutdown (such a task may be lost). Callers who need to coordinate around in-flight work must synchronise externally. (StrandExecutorwaits for_inFlight, but that is a lifetime-safety wait, not a general drain API, and it relies on the base pool still running the strand's dispatched lambdas.) - Strand allocation churn. The self-cleaning map (see
Lifetime & ownership) is good for memory — live entries
track active models — but a bursty model re-allocates a
Strandevery time its queue empties and refills, instead of reusing one long-lived strand per key.
completion.md—Completion<T>marshals its.then/.onErrorcallbacks through anIExecutor; the executor is how async results land on the right thread.error_handling.md— the framework-wide error-propagation story that the per-task logging here plugs into (currently also summarised under Error propagation in../../ARCHITECTURE.md).concurrency_and_lifetimes.md— the broader threading and teardown-ordering model; the "destroy strand before base pool" rule above is a concrete instance of it (see also Thread safety in../../ARCHITECTURE.md).bridge.md— the bridge wires backends to a GUI executor and a strand-backed dispatcher; it is the primary consumer of these types.