Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,49 @@ running the `triage-issue` skill (`.claude/skills/triage-issue/`). An author
labelling their own issue `triage: valid` records only that they thought it
worth filing, which every open issue already implies.

## Comments and documentation

A comment, and a page under `docs/`, states **what the code does now and why**.
Nothing else.

- **No history.** Not what the code used to do, not what was tried and
abandoned, not what a change replaced. `git blame` and `git log` hold that
accurately and permanently; a comment holds a copy that starts rotting the
moment the next change lands. Reach for blame when you need the story.
- **No issue numbers, no commit hashes.** A reader should not have to leave the
file — or reach a tracker — to understand the line in front of them. If a
ticket's reasoning is worth keeping, keep the *reasoning*, in the reader's
own words, at the place it applies.
- **Reasoning stays, and is the point.** "Why this and not the obvious
alternative", "this bound is what the hardware gives us", "this order matters
because the lock is held" — none of that is history. It is a current fact
about a current constraint, and it is the most valuable thing a comment
carries.

Length follows from the rules rather than from a limit: once the history and the
citations are gone, a block that ran for eighty lines is usually five.

The one exception is **public API documentation**, which is exempt from brevity
and not from the rules above. Doxygen runs with `WARN_AS_ERROR =
FAIL_ON_WARNINGS`, so every public symbol keeps complete
`@param`/`@tparam`/`@return` — write those fully, and still without a ticket
number in them.

A worked contrast:

```cpp
// BAD — history, a citation, and a reader sent elsewhere
// Until morph#604 this used notify_one, which lost a wakeup when two
// waiters blocked on different predicates (see also morph#489's comment).
// Restored to notify_all in a1b2c3d.
_cv.notify_all();

// GOOD — the current constraint, in place
// notify_all, not notify_one: waiters block on different predicates, so a
// single wakeup can land on one whose predicate is still false and be lost.
_cv.notify_all();
```

## Verify rather than assert

A control that reports success while measuring nothing is the failure mode this
Expand Down
6 changes: 3 additions & 3 deletions include/morph/core/async.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
/// schema, and they are the part of morph that costs almost nothing to compile.
/// Nothing here reaches glaze.
///
/// Measured on `master` @ c6f6d953, clang 22.1.8, `-O2 -fsyntax-only`, one
/// translation unit per header, best of three:
/// Measured with clang 22.1.8, `-O2 -fsyntax-only`, one translation unit per
/// header, best of three:
///
/// | header | CPU s | preprocessed lines |
/// |---|---|---|
Expand All @@ -23,7 +23,7 @@
/// A consumer that wants the primitives and reaches for `bridge.hpp` — the
/// obvious header, and the one every example includes — pays roughly three
/// times over for a schema generator and a JSON codec it never calls. This
/// header exists so the cheap path has a name (morph#573, step 4).
/// header exists so the cheap path has a name.
///
/// It is a facade and nothing else: it declares no symbol of its own, so
/// including it is exactly equivalent to including the four headers below.
Expand Down
153 changes: 72 additions & 81 deletions include/morph/core/backend.hpp

Large diffs are not rendered by default.

406 changes: 184 additions & 222 deletions include/morph/core/bridge.hpp

Large diffs are not rendered by default.

16 changes: 7 additions & 9 deletions include/morph/core/callback_scope.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,12 @@ class CallbackToken {
/// the others — call it from the thread that owns the scope. It replaces the
/// `_state` handle itself, and reading a `shared_ptr` while another thread
/// assigns it is a data race on the handle: the control block's refcount is
/// atomic, the pointer object is not. This is a narrower promise than this
/// paragraph used to make, and the reason is a portability constraint, not
/// taste — see `_state`'s own comment. In the usage `reset()` exists for
/// atomic, the pointer object is not. That narrowing is a portability
/// constraint, not taste — see `_state`'s own comment. In the usage `reset()` exists for
/// (`void onNewQuery() { _callbacks.reset(); }`, the supersede verb) the owning
/// thread is the caller anyway. Within a single generation, the old guarantee
/// holds unchanged: `reset()` stops the outgoing generation before releasing
/// it, so a token holder that pinned it still observes refusal (morph#499).
/// thread is the caller anyway. Within a single generation the guarantee is
/// unconditional: `reset()` stops the outgoing generation before releasing it,
/// so a token holder that pinned it still observes refusal.
///
/// Identity, not a value: neither copyable nor movable. A moved-from scope would
/// have to either strand or silently retarget tokens already captured in flight;
Expand Down Expand Up @@ -237,8 +236,7 @@ class CallbackScope {
void requestStop() const noexcept {
// `_state` is never null: the sole constructor make_shared's it, the
// class is non-copyable and non-movable, and `reset()` always assigns a
// fresh value. The former `!= nullptr` guard was an unreachable branch
// and is gone (morph#499).
// fresh value. A `!= nullptr` guard here would be an unreachable branch.
_state->stopped.store(true, std::memory_order_release);
}

Expand Down Expand Up @@ -287,7 +285,7 @@ class CallbackScope {
/// the primary template and hard-errors on `is_trivially_copyable`. The
/// alternative, a mutex, would cost `token()`/`stopRequested()` their
/// `noexcept`. So the concurrency contract is narrowed instead; see this
/// class's own Thread safety paragraph (morph#499).
/// class's own Thread safety paragraph.
std::shared_ptr<detail::CallbackScopeState> _state;
};

Expand Down
27 changes: 13 additions & 14 deletions include/morph/core/completion.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ struct CompletionState : std::enable_shared_from_this<CompletionState<T>> {
// `std::function<void(T)>` object -- because each is invocable with
// `const T&`. A handler that wants its own value gets exactly one copy, at
// its own parameter binding, where the reader of that call site can see it;
// a handler that only observes pays nothing. Erasing as `void(T)` charged
// every handler a copy whether or not it wanted one (morph#553).
// a handler that only observes pays nothing. Erasing as `void(T)` would
// charge every handler a copy whether or not it wanted one.
std::vector<std::function<void(const T&)>> onOk;
std::vector<std::function<void(std::exception_ptr)>> onErr;
bool onErrAttached = false;
Expand All @@ -77,8 +77,8 @@ struct CompletionState : std::enable_shared_from_this<CompletionState<T>> {
// Store first, drain `onOk` last. `value` is this state's own
// store and is never moved out of: a `then()` attached *after*
// this point (attachThen's `ready && value` branch) reads it
// again, and moving out of it left it engaged but moved-from so a
// later attacher silently observed a husk (morph#520). The value
// again, and moving out of it would leave it engaged but
// moved-from, so a later attacher would silently observe a husk. The value
// is observed, never consumed -- structurally, now that no
// dispatch path can take it.
//
Expand Down Expand Up @@ -155,7 +155,7 @@ struct CompletionState : std::enable_shared_from_this<CompletionState<T>> {
// `exception_ptr` straight through (`.onError([state](auto e) {
// state->setException(e); })`) without inspecting it, so a guard
// at one producer would leave every other one able to reintroduce
// the same wedge. See issue #347.
// the same wedge.
error = exc ? exc
: std::make_exception_ptr(
std::runtime_error{"completion rejected with no exception (null exception_ptr)"});
Expand Down Expand Up @@ -192,11 +192,10 @@ struct CompletionState : std::enable_shared_from_this<CompletionState<T>> {
std::scoped_lock const lock{mtx};
if (ready && value) {
// Keep the state alive and read `value` in place rather than
// snapshotting it. The old shape copied `*value` into
// `savedVal` and then captured `savedVal` *by copy* before
// moving it into the handler -- two copies where the handler
// asked for at most one, and the reason a late attacher cost
// 2 copies rather than 1 (morph#553).
// snapshotting it. Copying `*value` into a local and then
// capturing that local *by copy* before moving it into the
// handler costs two copies where the handler asked for at most
// one, so a late attacher would pay 2 rather than 1.
fireNow = [self = this->shared_from_this(), handler = std::move(handler)]() { handler(*self->value); };
} else if (!ready) {
onOk.push_back(std::move(handler));
Expand Down Expand Up @@ -255,10 +254,10 @@ struct CompletionState : std::enable_shared_from_this<CompletionState<T>> {
/// completion state, instead of a second, erased completion whose only job is
/// to be forwarded into the first.
///
/// That forwarding cost six heap allocations per dispatch — the erased state,
/// Such forwarding costs six heap allocations per dispatch — the erased state,
/// the `.then` and `.onError` closures, their two handler vectors, and one of
/// the two posted settle tasks — of the 14.06 a local round trip took. Through
/// a sink it is one (morph#572, Part B).
/// the two posted settle tasks — against the 14.06 a local round trip takes.
/// Through a sink it is one.
///
/// @par Contract for implementers
/// - **Settle once.** `settleValue` and `settleException` are mutually
Expand Down Expand Up @@ -579,7 +578,7 @@ class Completion {

/// @brief Constructs a `Completion<T>`/`Promise<T>` pair sharing one settleable state.
///
/// The public "settleable promise" seam (issue #55): lets a caller — typically
/// The public "settleable promise" seam: lets a caller — typically
/// test code — construct a `Completion<T>` it can resolve or reject on demand,
/// without a full `Bridge`/`IBackend` round trip and without reaching into
/// `morph::async::detail::CompletionState<T>`. Everything `Completion(state,
Expand Down
69 changes: 29 additions & 40 deletions include/morph/core/detail/execute_order_gate.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,20 @@
/// @file
/// @brief Per-model execute-ordering gate, extracted out of `RemoteServer`.
///
/// `RemoteServer`'s per-model execute-ordering gate (`ExecuteGate` before this
/// extraction, `takeExecuteTicket`/`awaitExecuteTurn`/`releaseExecuteTicket`,
/// and `ExecuteTicketGuard` — all `private`) is a standalone concurrency
/// primitive that was trapped inside the server. Its contract — hand out
/// monotonic tickets per `ModelId`; block a ticket until its predecessor
/// releases; tolerate releases arriving out of order; erase the gate when
/// drained; tolerate a gate already erased — touches no wire format, no
/// authorizer, no model, no executor, no strand, and is expressible without
/// `RemoteServer` at all.
/// The per-model execute-ordering gate `RemoteServer` dispatches through. Its
/// contract — hand out monotonic tickets per `ModelId`; block a ticket until
/// its predecessor releases; tolerate releases arriving out of order; erase the
/// gate when drained; tolerate a gate already erased — touches no wire format,
/// no authorizer, no model, no executor and no strand, so it lives here rather
/// than inside the server.
///
/// It began as a behavior-preserving port of the logic that used to live
/// directly on `RemoteServer` -- `releasedOutOfOrder` and its out-of-order
/// release handling (issue #449) came across unchanged, and that field's own
/// doc comment carries the full history.
///
/// It is **no longer only that port**, and the locking in particular is not the
/// same. morph#519 added the atomic `takeAndPost`, a nested `Ticket` bound to
/// the exact `Gate` it was issued from (so a drain-and-recreate cannot redirect
/// a later `awaitTurn`), and a gate-wide `std::recursive_mutex` held across the
/// caller's `postFn`. Read `takeAndPost`'s doc before reasoning about lock
/// order here: it is `_enqueueMtx` then `_mtx`, never the reverse, and an
/// earlier revision that used one mutex *per model* deadlocked two threads
/// against each other.
/// `takeAndPost` takes a ticket and enqueues the work in one atomic step, the
/// `Ticket` it returns is bound to the exact `Gate` it was issued from (so a
/// drain-and-recreate cannot redirect a later `awaitTurn`), and a gate-wide
/// `std::recursive_mutex` is held across the caller's `postFn`. Read
/// `takeAndPost`'s doc before reasoning about lock order here: it is
/// `_enqueueMtx` then `_mtx`, never the reverse. One mutex *per model* instead
/// deadlocks two threads against each other.
namespace morph::backend::detail {

/// @brief Hands out monotonic per-`ModelId` tickets and lets callers block
Expand Down Expand Up @@ -68,10 +59,10 @@ class ExecuteOrderGate {
/// ahead of the workers), can find a *newer* `Gate` already sitting where
/// the old one used to be. Its `awaitTurn` then waits on that new gate's
/// independent counter for a ticket number it will never produce --
/// permanently (morph#519's own fix introduced this: the old two-step
/// `take()`-then-post() throttled the producer just enough that a gate
/// essentially never drained mid-burst; the atomic `takeAndPost` removes
/// that throttle). `Ticket` closes this by carrying the exact `Gate`
/// permanently. The atomic `takeAndPost` is what makes this reachable: a
/// two-step `take()`-then-post() throttles the producer just enough that a
/// gate essentially never drains mid-burst, and `takeAndPost` removes that
/// throttle. `Ticket` closes the hole by carrying the exact `Gate`
/// `shared_ptr` a ticket was issued from, so `awaitTurn(Ticket)` and
/// `release(Ticket)` operate on that object directly -- never a fresh
/// lookup, so a drain-and-recreate of the map entry cannot redirect them.
Expand Down Expand Up @@ -119,7 +110,7 @@ class ExecuteOrderGate {
// inserting an already-passed ticket number into `releasedOutOfOrder`
// a second time, which would sit there as the set's permanent
// minimum and quietly break every future out-of-order release for
// this gate (issue #449's own mechanism, from the wrong end).
// this gate.
std::shared_ptr<std::atomic<bool>> _released;
};

Expand Down Expand Up @@ -181,9 +172,9 @@ class ExecuteOrderGate {
/// diverge: caller A can take ticket 0 and then be pre-empted before
/// enqueueing, while caller B takes ticket 1 and enqueues immediately — so
/// a pool worker picks up ticket 1 first, blocks in `awaitTurn` waiting for
/// ticket 0, and A's own enqueued work never gets a worker to run on
/// (morph#519: exactly this, with `RemoteServer::handleImpl` and its worker
/// pool). Folding the enqueue into the same critical section as `take`
/// ticket 0, and A's own enqueued work never gets a worker to run on --
/// reachable with `RemoteServer::handleImpl` and its worker pool.
/// Folding the enqueue into the same critical section as `take`
/// makes that divergence impossible: whichever caller's `takeAndPost` runs
/// first for a given model gets both the lower ticket number and the
/// earlier enqueue slot, for any thread scheduling, because a second
Expand Down Expand Up @@ -388,7 +379,7 @@ class ExecuteOrderGate {
// ticket ahead of them has released too. Not an optimisation: it is
// what makes `nextToRun` mean "the lowest ticket not yet released"
// rather than "one past whichever ticket released last". See
// `releaseOnGateLocked` (issue #449). Ordered, because the release
// `releaseOnGateLocked`. Ordered, because the release
// loop consumes it from the front; small by construction (it holds at
// most the tickets in flight for one model, minus one).
std::set<std::uint64_t> releasedOutOfOrder;
Expand Down Expand Up @@ -426,12 +417,10 @@ class ExecuteOrderGate {
// immediately, without ever waiting for its turn, precisely so that
// ticket cannot hold up the live work behind it (`RemoteServer`'s
// rejection paths -- model not found, unauthorized, over limit, a
// shutdown gate -- all do exactly this). A later ticket releasing
// first therefore used to push `nextToRun` straight past an earlier
// ticket's number, whose waiter then had a predicate that could never
// become true again -- a caller parked forever (issue #449, the third
// occurrence of the stranded-ticket class #348 and #351 closed from
// the other end by making the release itself unmissable).
// shutdown gate -- all do exactly this). Applying a later ticket's
// release directly would push `nextToRun` straight past an earlier
// ticket's number, leaving that ticket's waiter with a predicate that
// can never become true again -- a caller parked forever.
//
// So an out-of-order release is recorded rather than applied, and
// `nextToRun` walks forward only over a contiguous run of released
Expand Down Expand Up @@ -504,9 +493,9 @@ class ExecuteOrderGate {
/// stalls every later ticket for the same model, because `awaitTurn` is a
/// `cv.wait` with no deadline. In `RemoteServer` (the sole caller today) that
/// rule used to be a per-call-site convention, and the convention was missed
/// twice: by a shutdown gate that returns before the one place that released
/// a ticket (issue #348), and by every exception that unwinds past a dispatch
/// function's early returns (issue #351). This holder makes the rule
/// two ways: by a shutdown gate that returns before the one place that
/// releases a ticket, and by an exception that unwinds past a dispatch
/// function's early returns. This holder makes the rule
/// structural instead of remembered: the ticket is owned from the moment it
/// is taken until the guard dies, so every exit path -- `return`, `throw`,
/// and any branch a later change adds -- releases it. Two members opt out
Expand Down
Loading
Loading