Skip to content

Completion<T> has no value-handling contract: N + 2M copies per settle, and T must be copyable #553

Description

@Yaraslaut

morph::async::Completion<T> does not state what it does with T — what type
requirements it imposes, how many times it copies the value, or which of those
copies a caller can avoid. Measured, the answer is worse than it needs to be,
and one part of it is a plain defect.

Design written up in full at
docs/superpowers/specs/2026-09-16-completion-value-contract-design.md
(branch completion-value-contract).

What was measured

Instrumented T counting copy/move constructions, N handlers attached before
the completion settles and M after. Real output:

setValue with N handlers attached BEFORE settling, M attached AFTER:
  before=0 after=0  ->  copies=0 moves=1
  before=1 after=0  ->  copies=1 moves=5
  before=2 after=0  ->  copies=2 moves=6
  before=3 after=0  ->  copies=3 moves=7
  before=0 after=1  ->  copies=2 moves=4
  before=0 after=2  ->  copies=4 moves=7
  before=1 after=1  ->  copies=3 moves=8

The budget is N + 2M. Two problems:

  1. The N is paid whether or not the handler wants it. Handlers are erased
    as std::function<void(T)>, so the value is materialised once per handler
    even when the handler only reads it.
  2. One of the 2M is a defect. attachThen's fire-now path copies the
    stored value into savedVal, then the closure captures savedVal by
    copy
    before moving it into the handler. One of the two buys nothing. This
    is present on master and is not fixed by Fix a permanent RemoteServer deadlock and a lost completion value #546.

T must also be copy-constructible. Completion<std::unique_ptr<int>> does not
compile — three sites, in setValue's fan-out and attachThen's fire-now
path. Fixing those alone would not be enough: IExecutor::post takes
std::function<void()>, std::function requires a CopyConstructible target,
and the dispatch closure captures a T.

Proposed contract

  • std::move_constructible<T> is the whole type requirement. Copyability
    becomes an obligation of the individual handler that takes T by value,
    reported where that handler is written.
  • Exactly one copy per by-value handler; zero per const T& handler,
    independent of handler count and of whether they attached before or after
    settling.
  • The value is observed, never consumed — which is what morph#520 already
    requires, made structural rather than conventional.

Mechanism is two changes: onOk becomes
std::vector<std::function<void(const T&)>>, and CompletionState gains
enable_shared_from_this so the dispatch closure reads the value in place.
One erased type accepts every spelling callers already write — verified:

const-ref handler             -> copies = 0
by-value  handler             -> copies = 1
legacy std::function<void(T)> -> copies = 1

so every existing .then(...) call site keeps compiling. Move-only T then
works as a side effect, with no change to IExecutor::post.

Measured impact

Per settle, handlers taking const T&. large is a 20-field struct of
heap-allocated strings, small an int wrapper. All variants heap-allocate
the state, as morph does.

  Small  h=0  today=    14.6   A=    17.6   B=    23.0
  Small  h=1  today=    43.0   A=    55.2   B=    54.2
  Small  h=3  today=    70.9   A=    82.5   B=    82.9
  Large  h=0  today=   314.8   A=   335.1   B=   331.3
  Large  h=1  today=  1140.4   A=   370.0   B=   369.7
  Large  h=3  today=  2103.0   A=   398.3   B=   400.5

A = enable_shared_from_this, B = shared_ptr<const T> storage. 5.3x on a
large T with three handlers, 3.1x with one.
A and B are indistinguishable
once handlers are attached; B costs 8.4ns for an allocation that buys nothing
when none are, which is why A.

Costs, recorded rather than hidden: small T loses ~12ns per settle to an
atomic refcount pair; the large-T win needs const T& handlers (by-value
measures 678ns/1323ns); and then()'s parameter type changes — source
compatible for lambdas and for existing std::function<void(T)> objects, but
public.

Verification status

Measured, on batch/519-520 (PR #546) at aa185d8b, with GCC 15 -O2 on
Linux x86-64:

  • the copy/move counts, with an instrumented T against the real header;
  • the benchmark table, against faithful standalone models of the three
    value paths — not against a patched completion.hpp;
  • that std::function<void(const T&)> accepts all three handler spellings;
  • that a move-only T fans out to several const T& handlers under design A.

Not verified: no implementation exists yet, so none of this is measured
against real morph dispatch; the Bridge end-to-end copy count is reasoned from
reading the code, not counted; and the numbers are one machine, one compiler.

Baseline matters here. These are measured against the tree with #546
applied. On master (2407ffd) setValue still does
auto savedVal = std::move(*value), so a handler attached after settling
receives a moved-from husk — morph#520, fixed in #546 and not before it.
This work depends on #546 landing first, or "the value is observed, never
consumed" would be built on a setValue that consumes it.

Bridge

The action path is already copy-free and the contract records it rather than
changing it: Executor is
std::function<Completion<std::string>(void*, std::string_view)>,
executeImpl stores one shared_ptr<Action>, and models take const Action&.
The only cost is Bridge::execute(Action action) by value — one move if the
caller moves. The large-DTO case is Completion<Result> on local typed
dispatch, which the change above covers directly.

Audited all 309 .then( call sites: every handler that moves its parameter
takes it by value first, so none breaks. bridge.hpp:2207 and :2241 chain
one completion into another, where setValue(T) must own a value — one copy
there is a floor, not an oversight.

What would close this

  • The copy budget holds exactly (not "at most") under a counting fixture for
    0/1/3 const T& handlers, by-value handlers, late attachers and mixed sets.
  • Completion<std::unique_ptr<int>> instantiates and fans out.
  • A by-value handler on a move-only T is rejected, pinned in
    tests/compile_checks/.
  • Each new test observed to fail against the unfixed code before being kept.

What would change the verdict

  • If real morph payloads are dominated by Completion<std::string> of
    already-serialised JSON, the large-T win is mostly theoretical and the
    ~12ns small-T regression is the only effect that lands. A copy-count
    measurement over a real dispatch would settle it, and has not been done.
  • If the ~12ns matters somewhere measured, a sizeof(T) fast path becomes
    worth its second code path through setValue — deliberately rejected here,
    since that function is already where morph#520 came from.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreSubsystem: coreenhancementNew feature or requesttriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions