Skip to content

Actor as a pull-driven generator: one-turn protocol, close(), scheduler without global new - #185

Open
kotbegemot wants to merge 3 commits into
masterfrom
actor-generator
Open

kotbegemot wants to merge 3 commits into
masterfrom
actor-generator

Conversation

@kotbegemot

Copy link
Copy Markdown
Contributor

Summary

The actor becomes a pull-driven generator: its message loop is a coroutine in a frame from the actor's memory resource, and resume() pulls one step of it, like next(). The turn — the right to call resume() — lives in the mailbox; the send() that unblocks a parked mailbox takes it (needs_sched). A suspended behavior keeps the turn (verdict resume), and the actor parks only once its frame is suspended. Results never wake anyone. docs/LIFECYCLE.md describes the model end to end.

Protocol and lifecycle

  • detail/actor_protocol.hpp: one participant counter plus destroying; no CAS loops. The actor_state word, resume_impl and their helpers are gone.
  • Contract violations stop the process instead of corrupting it:
    • in every build: resume() without a turn, after close(), or from inside a behavior; a second delete; a delete that cannot drain (bounded wait);
    • in debug builds only: two resume() calls at once, and an actor awaiting its own send().
  • close() → unique_future<void>: a marker queued behind everything sent so far; idempotent. Once its future is ready, destroying the actor is safe while the scheduler runs.
  • delete is a destroying operator delete; actor classes must be final. A suspended chain unwinds before ~Actor, and its callers get broken_pipe.
  • A message lives as long as its behavior. dispatch() is [[nodiscard]] and passes a method's error code (co_return ec;) on to the caller instead of aborting.

Memory and scheduler

  • sharing_scheduler(resource, threads, max_throughput): the job queue (a ring of job_ptr values) and the workers come from resource. Once warmed up, send → enqueue → resume does no global allocation.
  • Any T&& method parameter is a compile error.
  • Other fixes:
    • a promise nobody took a future from frees its state;
    • dropping a unique_future cancels a suspended producer;
    • try_complete_finalize() no longer leaks.

Removed

  • ref_counted, intrusive_ptr, make_counted;
  • unique_future::actor_promise;
  • dead behavior_t members (is_awaited_ready() is merged into take_awaited_continuation());
  • mailbox push_front, size, empty and message::prev;
  • test/contended-resume, test/intrusive_ptr, tests_stress_intrusive_ptr, test/race-condition/TESTING.md.

Breaking changes

Migration guides are in CHANGELOG.md.

  • The scheduler constructor takes a std::pmr::memory_resource* first.
  • Actor classes must be final.
  • No T&& method parameters.
  • A contended or nested resume(), or one without a turn, is a contract violation. Before, it returned awaiting.

Test plan

  • Debug ctest (clang, macOS): 170/170.
  • Sanitizer matrix on the final dispatch() version: Release 171, ASan 170, TSan 169 + rt_tuple run alone, UBSan 170, exceptions ON 170.
  • GCC 11.5 and GCC 13 in Docker: full ctest 170/170 each, 0 warnings. GCC 12 and Clang 14 (libstdc++ 12): smoke build and run.
  • Relacy model (Docker gcc:13): 5/5, all four mutants caught.
  • leaks --atExit: 0 across every test binary and balancer.
  • Benchmarks: step 4 at 0.84–0.87 of the original; step 7 within noise.
  • CI: Clang 16–18, and the full suite on GCC 12 and Clang 14.

Deferred, not in this PR:

  • destroying an actor while its job sits in a running scheduler's queue — the actor cannot detect it; the rule is close() or stop() first;
  • trimming the mailbox API to push_back/pop_front/park/close.

🤖 Generated with Claude Code

kotbegemot and others added 3 commits September 25, 2026 09:19
…er without global new

The actor's loop is a coroutine generator in a frame from the actor's resource; resume()
pulls one step of it, like next(). The turn -- the right to call resume() -- lives in the
mailbox: a blocked mailbox holds it, the send() that unblocks it takes it (needs_sched).
A suspended behavior keeps the turn (verdict `resume`); the actor parks only once its frame
is suspended. Results never wake anyone.

Protocol and lifecycle
- detail/actor_protocol.hpp: a participant count plus `destroying`, no CAS loops; the
  actor_state word, resume_impl and their helpers are gone.
- Contract violations stop the process: resume() without a turn, after close() or from
  inside a behavior; a second delete; a delete that cannot drain (bounded wait); in debug,
  two resume() calls at once and an actor awaiting its own send().
- close() returns unique_future<void>: a marker behind everything sent so far; idempotent;
  once ready, delete is safe while the scheduler runs.
- delete is a destroying operator delete; actor classes must be final. A suspended chain
  unwinds before ~Actor and its callers get broken_pipe.
- A message lives as long as its behavior; dispatch() is [[nodiscard]] and passes a
  method's error code (co_return ec) on to the caller instead of aborting.

Memory and scheduler
- sharing_scheduler(resource, threads, max_throughput): a ring of job_ptr values and the
  workers come from the resource; send -> enqueue -> resume allocates nothing globally.
- Any T&& method parameter is a compile error. A promise nobody took a future from frees
  its state; dropping a unique_future cancels a suspended producer; try_complete_finalize
  no longer leaks when the future is released between the CAS and the fallback.

Removed: ref_counted, intrusive_ptr, make_counted, unique_future::actor_promise, dead
behavior_t members (is_awaited_ready() merged into take_awaited_continuation()), mailbox
push_front/size/empty, message::prev, test/contended-resume, test/intrusive_ptr,
tests_stress_intrusive_ptr, test/race-condition/TESTING.md.

Tests: protocol-violations, actor-teardown, close, message-lifetime, dispatch-nodiscard,
dispatch-error-code, behavior-member-only, scheduler-no-new, rvalue-param-prohibited,
park-window one_step; a Relacy model of the turn handover, close() and delete (test/relacy,
ALLOW_RELACY, its own CI workflow); detect_leaks=1 in the sanitizer CI.

Compiler workarounds: GCC 13 ICEs on assigning a co_yield result in a template; GCC 11
lost the value of a temporary awaited with a class-type await result.

Docs: docs/LIFECYCLE.md (the turn, verdicts, close(), contract checks), CLAUDE.md,
PROMISE_FUTURE_GUIDE.md, CHANGELOG.md with migration guides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- behavior_t::promise_type::operator new: GCC 11.4 deduces the implicit object argument's
  type as Actor&, so has_resource_method<const Self> formed a pointer to a reference. Strip
  the reference before the check.
- scheduler_t keeps its workers in a plain array from its resource instead of a
  std::pmr::vector: libstdc++ 11 destroys a vector's elements through the deprecated
  polymorphic_allocator::destroy, which Clang 18 reports and -Werror fails. A destructor now
  frees the workers start() made.

Checked in ubuntu:22.04 with the CI's flags (WARNINGS_AS_ERRORS=ON, benchmarks, examples):
g++-11 11.4 and clang++-18, Debug, EXCEPTIONS_DISABLE=ON: builds clean, ctest 170/170.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The mailbox keeps push_back, pop_front, try_block and close, with their old signatures; nothing
replaces what goes.

- try_unblock() is gone: it handed out a turn without a message.
- blocked() is gone: pop_front() on a parked mailbox now stops the process with the
  "resume() on a parked actor" violation. In release it used to unpark the mailbox silently,
  handing the turn to nobody; only the actor's entry check stood in the way.
- closed() is gone: close() of a parked actor also frees the loop's frame (parked, it holds no
  behavior and no message), so resume() after close() stops at the entry check
  (!loop_ || loop_.done()), and the check after a failed park became unreachable.
- lifo_inbox.hpp is untouched; the Relacy model mirrors the new calls.

Red first: test/mailbox-api pins try_unblock()'s absence (try_compile); actor-teardown sees the
loop frame freed at close(); protocol_violation_release_pop_parked_mailbox carried on before.
Green: local Debug; Relacy 5/5 with the four mutants; ubuntu:22.04 with the CI's flags
(WARNINGS_AS_ERRORS, benchmarks, examples): g++-11 11.4 and clang++-18, Debug, 172/172.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant