Skip to content

feat: exit signals, startup and runtime-drop semantics as in OTP (ranks 5e, 5f, 5g) - #240

Draft
unbalancedparentheses wants to merge 221 commits into
mainfrom
feat/otp-exit-signals
Draft

unbalancedparentheses wants to merge 221 commits into
mainfrom
feat/otp-exit-signals

Conversation

@unbalancedparentheses

@unbalancedparentheses unbalancedparentheses commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Implements the accepted design docs/design/otp-exit-signals.md (#237) and its model verification/tla/Lifecycle.tla: ROADMAP ranks 5e (exit signals), 5f (startup part) and 5g (runtime-dropped actors). Breaking change for 0.8.

Stacked on #239 (feat/deferred-replies, rank 11). Retarget to main after #239 merges. The branch merges origin/main to pick up the design doc and Lifecycle.tla (#237), which #239 does not contain yet; those files disappear from this diff once #239 is rebased on main or this PR is retargeted.

Plan (from the design, in order)

  • 1. ExitReason #[non_exhaustive] + Killed, NoProc; ChildHandle::kill() untrappable, propagates Killed; single death point after stopped(); late non-kill signal replaces the reason unless killed (decision 8)
  • 2. ctx.exit / ChildHandle::exit with exit_signal/2 semantics; administrative stops unchanged (decisions 5, 6); parent exit ends a trapping child through stopped(); supervisor kills at shutdown_timeout
  • 3. stopped() skipped on kill, untrapped signal/link notice, runtime drop (decision 2)
  • 4. NoProc for link/monitor/request past D; late link from non-trapping caller → NoProc exit signal; ctx.try_link; unlink/demonitor fences
  • 5. Runtime drop → Killed in both modes (5g, decision 3)
  • 6. Supervisor startup (5f): any first-start failure aborts start(); ready-child exits deferred until after startup; start() returns after rollback; Supervision.tla/Cohorts.tla updates
  • 7. Rank 11 integration: ActorStopped(reason), NoProc after D (decision 4), DeferredReplies.tla exit-pending change + control
  • 8. Lifecycle trace mapping + controls, Loom, simulator tests, semantics.md, GUIDE, examples, MIGRATION (ethrex discovery note)
  • 9. Full gates

Progress is posted as checkpoint comments below.

…trait migration (WIP: tests not yet migrated)
…mits, delegation, bounds) and registry unit tests
…and drops its payloads outside the trace section

Final review (agy P1 2): JobSetCore::admit cancelled the replaced keyed
job (cancel_quietly) before JobSet::admit recorded the new job's Start,
inside trace::atomic. So the old job's dropped emits were recorded
before that Start (TraceJobs rejects it: in DeferredJobs.tla the old job
stops accepting at Start(new), CancelCore(ToReplace)); a linked old job
skipped link_cancel, so its caller's JobCancel was missing before the
dropped token's Discharge; and its user payloads (held result, scheduled
emits) were destroyed inside the process-wide trace section.

JobSetCore::admit_replacing hands the replaced job back uncancelled.
JobSet::admit records Start, then in the same section sets the old job's
cancellation flag, counts it and records link_cancel (cancel_replaced);
the suppressed held result and the scheduled emits are dropped after the
section, outside every lock. JobSetCore::admit (cancel inside) remains
for the Loom model. The model's ordering is unchanged: the code now
matches it.

Regression (fails without the fix): job_trace
keyed_replacement_drops_the_old_emit_after_the_new_start: the old keyed
job's emit is registered (handshake), a replacement follows; the
recorded Start of the replacement must precede the old emit's dropped
Fire (it recorded Start, Schedule, Fire, Start before). The new trace,
jobs_keyed_replace_scheduled, is accepted by TraceJobs (13/13).
…e the trace section

Final review (agy P2 3): JobState::cancel and JobState::notify (a job
deadline overtaking a held result) called JobCore::cancel/notify inside
trace::atomic, which dropped the suppressed HeldTerminal (user payload)
there, before Cancel/JobCancel or JobDeadline were recorded.

JobCore::notify_keeping_held hands the displaced result back (notify
wraps it for the other callers); JobState::cancel uses
cancel_keeping_held. Both now drop the held result after the trace
section, after their events.

Regression (fails without the fix): job_trace
cancel_drops_a_held_result_outside_the_trace_section: a job's result (a
probe) is held behind a far emit (handshake: the work returned); the
probe's destructor needs the trace section from another thread within
1 s; cancelling the job used to drop it inside the section. Its trace
jobs_cancel_held is accepted (14/14).
…d insert

InsertError is #[non_exhaustive], so a client matching Duplicate and Full
still needed a wildcard arm that could only drop the token: a future
variant would silently answer its caller ReplyDropped (seen porting
ethrex rlpx/connection/server.rs).

InsertError::kind() returns a #[non_exhaustive] InsertErrorKind, and
into_parts() the reason and the token together, so a caller answers the
token for any reason, known or not (`_ => token.overloaded()`).
InsertErrorKind is exported with InsertError (crate root, tasks,
threads). The doc comment, MIGRATION, GUIDE, the client mapping and the
deferred_replies example show the pattern.

Test: reply::pending::tests::a_refusal_gives_its_kind_and_its_token.
…(0.8)

#265's X3 linked-reason oracle (an actor stopped only through a link
publishes its peer's reason, and a Normal peer exit does not stop it)
predates 0.8's late links: a link made after the peer's death point
delivers a NoProc exit signal, so a non-trapping caller ends NoProc
whatever the peer's reason (E4), as the existing X3 Exit-reason oracle
already accepts. With it, the corpus and 3000 generated inputs pass.
…, never hang)

Planted-bug follow-up (#265): under planted bug 20,
tasks::actor::tests::backend_blocking_handles_send_and_request hung the
job instead of failing (its assertion failed, then the Tokio runtime's
teardown waited forever for the Blocking backend's thread); other unit
tests under concurrency/src also waited without a bound.

concurrency/src/test_wait.rs gains HANG (120 s, as
tests/support/deadline.rs), `bounded(name, body)` and `within(name,
future)`. Every #[test] under concurrency/src whose body starts, calls,
joins or waits on actors (162 tests in 18 files) runs its whole body
(runtime and teardown included) through `bounded`, on a thread named
after the test; a #[tokio::test] body goes through `within`. A body
still running after 120 s fails the test by name; a panic in the body,
an assertion or a #[should_panic] test's expected one, is re-raised
unchanged. No unit test runs on paused Tokio time, so none is exempted.

Checked: with planted bug 20 applied by hand (its 0.7 patch does not
apply), backend_blocking_handles_send_and_request fails after 120 s with
"timed out after 120s: ...backend_blocking_handles_send_and_request did
not finish". Unit tests pass in the default, no-default and
failpoints+lifecycle-trace+metrics feature sets (227, 110, 234).
…empty mailbox

Release gate (burst_async -37% vs main): the tasks run loop built a fresh
CancellationToken::cancelled() future for every message and select()ed
it against the mailbox receive, registering and dropping a Notify waiter
per message. The loop now creates it once and polls it only when the
receive is pending. The receive still checks is_cancelled() before taking
a message, so a stop is observed exactly where it was (the same
lifecycle-trace step), and a waiting loop is still woken by the cancel.

Measured on fede (ABBA, cores 12-15, heavy shared load): burst of 512
outstanding raw requests +6.9% (4.53M -> 4.84M/s); sequential requests
unchanged. Crate tests, clippy and the sim suite (315) pass.
…en under the slot lock

PendingReply::reply (a reply from the owner's own turn) went through the
general deferred-reply protocol: CAS OPEN -> WRITING, lock, read the
deadline, write, CAS WRITING -> REPLIED. In its own turn nothing can
shorten the deadline (only a deferred token can) and the owner's death
point cannot run, so the only concurrent transitions are the caller's
(abandonment, its timer's expiry), each a single CAS out of OPEN.
Slot::reply_in_turn writes the value under the slot lock and publishes it
with one CAS OPEN -> REPLIED (the same linearization point and trace
event, "ok"); a lost CAS hands the value back (CallerGone, Expired). A
reply at or past the deadline keeps the WRITING protocol. Deferred tokens
(ReplyTo) are unchanged.

Loom: reply_in_turn_vs_abandon, reply_in_turn_vs_expiry,
polled_receiver_vs_reply_in_turn. Crate tests, sim (315), reply/job/
lifecycle traces (48/48 accepted) pass.
…e receive

tokio-util's CancellationToken::is_cancelled locks the token's node. The
loop head re-checked what the receive already checks before taking each
message (it returns None once cancelled, which ends the loop), so a stop
is observed at the same step with one lock per message instead of two.
Registry::end_turn built and consumed an empty Vec through commit() on
every turn. The commit now runs only when the turn recorded drops (the
same outcome classification).
The 0.8 port of the baseline driver gave every raw request a 10 s
deadline (send_request(work, Deadline::after(10 s))), while 0.7's
request_raw, which the release gate compares against, had none. A raw
receiver with a deadline registers it in the owner's deadline heap on
its first pending poll (the registry lock, and a deadline-service wake
when it is the earliest), so the comparison charged 0.8 for work 0.7 did
not do. Deadline::Never is request_raw's documented 0.8 equivalent
(MIGRATION); the cost of a deadline is measured separately.
…al state, no condvar

The reply slot keeps its packed atomic word as the single source of truth
for every 0.8 guarantee (deferral credits, the death-point reply fence,
deadlines, at-most-once, terminal kinds). What changed is what sits beside it:

- `Inner` is the value, the caller's waker, a `parked` flag and a boxed
  `Side` created only by `defer()` (the owner's credit pool and the token's
  watchers). A plain request no longer carries a `Vec` of watchers and an
  `Option<Arc<CreditPool>>`; dropping its slot is two `None` checks and a
  null check.
- Threads-mode `recv()` registers a cached per-thread unparker as the
  slot's waker and parks (`park_timeout` with a deadline): the `Condvar` is
  gone. `parked` marks that waker, so the death point's discharge still
  wakes blocked receivers before any other waker (death-boundary rule).
- The credit return happens in the transition's lock section (an atomic,
  no user code); `Wake` carries only wakers.

Measured and rejected: an `AtomicWaker` beside a value mutex (the
"lock-free waker" variant). It costs two more read-modify-writes per
request than one lock for both: burst_raw 118.3M vs 114.9M before.

Callgrind (micro, instructions; seq paths vary with scheduler parking, burst
is the stable one):
  burst_raw  114.9M -> 110.0M  (per request 2201 -> 2107; framework code 865 -> 798)
  seq_raw    108.6M -> 123.6M  (scheduler idle/park noise: framework code per request 1083 -> 1034)
  seq_default 137.1M -> 154.7M (framework code per request 1305 -> 1282)

Loom (`reply_slot_loom` 23 tests, `job_core_loom` 8, `registry_turn_loom` 3):
all pass. New: parked_receiver_vs_reply_in_turn, parked_receiver_vs_batched_close
(close_collect, notify_parked, run_wake), watcher_and_receiver_vs_deferred_reply,
polled_receiver_vs_shorten_and_reply. The Loom park model is a mutex+condvar
token (Loom's own unpark also wakes a thread blocked in join). Negative
check: removing the under-lock recheck in recv_blocking makes Loom report
a deadlock (lost wakeup). No linearization point moved; no TLA+ change.
…message reference count)

The tasks run loop cloned the caller's slot (`envelope.caller()`, an
`Arc<dyn Closeable>`) for `DeathFirst` on every message and dropped it after
the turn: two atomic read-modify-writes per request on the slot's count,
which the caller's thread also touches.

Now `Envelope::handle` takes `&mut self` and returns the turn's future with
the caller it answers (`Dispatch<'a>`): the envelope keeps the request's
slot for the whole turn, the handler's `PendingReply` borrows it
(`PendingReply::lent`, a `Cow`), and `DeathFirst` borrows it as a `Caller`.
A reference is taken only when it must outlive the turn: `defer()`
(`ReplyTo` owns its slot), an obligation dropped unanswered (recorded on the
turn), and the rare runtime drop of a pending turn (`Caller::retain`, then
answered at the death point as before). Payloads move out through `Option`s
at the one dispatch. Threads mode never cloned and is unchanged.

`DeathFirst` now returns the turn's caught panic instead of resuming it,
and the handler's future is built under a plain `catch_unwind`
(`dispatch`): the turn no longer runs inside a second
`AssertUnwindSafe(async {..}).catch_unwind()` state machine.

`Slot::poll` clones the waker before taking the lock on the first
registration: one lock section instead of two.

Callgrind (micro, burst_raw 51200 + warm-up): 110.0M -> 110.4M (framework
code per request 798 -> 801: the saved work is two contended RMWs, which
instruction counts do not show; the turn's cost moved from the removed
async wrapper into the run loop). seq_raw 123.6M -> 117.9M, seq_default
154.7M -> 151.0M (scheduler-noise dominated).

Loom: reply_slot_loom 24 (new lent_reply_vs_receiver_last_reference: the
in-turn reply through the borrow versus the caller taking or abandoning,
either side dropping the last reference, the value dropped exactly once),
job_core_loom 8, registry_turn_loom 3: all pass. No linearization point
moved; death_boundary and failpoints suites pass (the runtime-drop path
answers the lent caller first, as before).
…st guard

`DeathFirst` exists for one case: the runtime dropping the actor while a
turn is pending, when the turn's caller, the deferred tokens and the queued
requests must be answered before the turn's future is destroyed. A turn
that completes in its first poll is never pending at an await point, so the
run loop now polls it once itself (`poll_caught`, the same `catch_unwind`)
with no await point before it returns, and builds `DeathFirst` only for a
turn that stays pending (`DeathFirst::pending`, which returns `Pending`
without polling the future a second time in the same task poll: its waker
is registered). A panic in the first poll drops the future as before
(`drop_panicked`, a destructor panic disposed of) and is the handler's
panic. The turn bookkeeping (`begin_turn`, `end_turn`, `dropped`) is
inlined.

Callgrind (micro, burst_raw 51200 + warm-up): 110.4M -> 106.2M (per request
2115 -> 2034; framework code 801 -> 717). seq_raw 117.9M -> 112.2M,
seq_default 151.0M -> 148.2M.

No concurrency protocol changed: Loom models unchanged and passing
(reply_slot_loom 24, job_core_loom 8, registry_turn_loom 3); death_boundary
and failpoints suites (runtime drop of a pending turn, panics while building
or polling a turn) pass.
…ut another clock read

`request`, `request_with_timeout` and the protocol `request` helper send
with `Deadline::after(timeout)` (one clock read, the deadline fixed at send)
and then arm the runtime timer on the first pending poll. Arming computed
`deadline - now_ticks()`: a second clock read per request, besides Tokio's
own. Now `ReplyReceiver::into_timed_future_after(timeout)` records the
timeout (u32 milliseconds, rounded up: the timer wheel's resolution; it fits
in the future's existing padding, so `future_layout` budgets hold) and the
send-time deadline in `armed`, and the first arm sleeps the timeout.

Semantics: the request was sent at most the send-to-poll gap ago, so the
timer fires at or after the deadline, never before; expiry still needs
`now >= deadline` (`DeferredReplies.tla` `Expire(r)`), and the reply side
still decides delivered-vs-expired by its own read of the deadline. A
deadline shortened between send and the first poll (`armed != deadline`)
re-arms from the clock as before. No linearization point moved; no model
change.

Callgrind seq_default (micro, 20000): framework instructions per request
unchanged within scheduler noise (the saved work is one vDSO clock read,
which callgrind counts inside the scheduler's park timing). Validated
together with the next commit: fmt, clippy (3 configurations), workspace
tests, failpoints + death_boundary, sim 315/315, Loom 24+8+3.
…wner's registry

Every reply slot cloned the owner's `Arc<Registry>` at send and dropped it
when the slot was freed: two atomic read-modify-writes per request on the
registry's reference count, a cache line shared by every sender and by
whichever thread frees the slot (in a burst, the producer and the consumer).
Removing them measured burst_raw -22.6% -> -10.0% against main
(interleaved, 6 reps, cores 12-15, load ~36).

The owner was used for three things; each now has another source:
- the caller's final reason on `STOPPED`: the registry's close stores its
  sealed final reason in the slot (`set_stopped_reason`, in the lazily
  created side state) before the `STOPPED` CAS, and the caller reads it
  from the slot after seeing `STOPPED` (the same order the registry gave:
  the reason is sealed before any slot closes);
- an undispatched envelope at the owner's death point: mailbox disposal
  now passes the registry (`Envelope::discharge(replies)`,
  `SlotHandle::dispose`), so a queued request is still held and answered
  with the final reason, in both modes and for a never-polled actor;
- an awaited raw receiver's deadline (the owner's heap): a request with a
  deadline keeps its owner, as before.
Lifecycle-trace builds keep the owner in every slot (trace events name
it), so recorded traces are unchanged. A `SlotHandle` dropped undisposed
(only a refused admission, whose caller gets `NoProc` from the send itself
and drops the receiver) is closed `STOPPED` at once, as an ownerless slot
always was.

Hot slot functions (`reply_in_turn`, `poll`, `after`) are marked
`#[inline]`: the added code had pushed them out of line.

Callgrind (micro): burst_raw 106.2M -> 105.7M (per request 2034 -> 2026);
seq_raw 112.2M -> 118.2M and seq_default 148.2M -> 147.7M (scheduler
noise). The removed RMWs are contention, not instructions.

fmt, clippy (all features, no default features, failpoints), workspace
tests, failpoints + death_boundary, sim 315/315, Loom reply_slot 24 /
job_core 8 / registry_turn 3: all pass. No linearization point moved.
9799c4c was staged hunk by hunk with zero-context patches, and git placed
four of its insertions at the wrong lines of `reply/mod.rs` (inside
`ReplyReceiver::recv`, `try_recv`'s doc comment and `ReplyFuture::poll`'s
signature): that commit and 9f65a06 do not compile. This moves them to
the intended places (`into_timed_future`, `IntoFuture`, the `ReplyFuture`
struct and `into_timed_future_after` after `into_timed_future`). The
resulting tree is the one validated for 9799c4c and 9f65a06: fmt, clippy
(three configurations), workspace tests, failpoints + death_boundary, sim
315/315, Loom 24 + 8 + 3.
…e without the lock

Once the owner's turn has replied and released its (lent) reference, the
caller's receiver is usually the slot's only owner. `ReplyFuture::poll`
now checks for `REPLIED` and a strong count of one, takes the slot with
`Arc::try_unwrap` and moves the value out of it (`Slot::into_reply`, the
mutex consumed by value): one CAS instead of the lock, the unlock and the
final reference-count decrement. When the slot is still shared (the owner
not yet past its turn, a deferred token, a racing weak upgrade), it falls
back to the locked take as before.

Ordering: the caller read `REPLIED` with acquire after the publishing CAS,
which follows the value's write; `try_unwrap` makes the caller the slot's
only owner, so nothing else can read or write it. Safe code only.

Measured (micro burst_raw, 16 interleaved runs each, cores 12-15, load
~45): 5.108M -> 5.407M requests/s (+5.9%). Callgrind burst_raw 105.7M ->
105.3M (per request 2026 -> 2018).

Loom: new `unshared_take_vs_lent_reply_and_release` (the caller's
lock-free take racing the owner's in-turn reply through its lent reference
and its release: the replied value, exactly once, by either path);
reply_slot_loom 25, job_core_loom 8, registry_turn_loom 3 pass. fmt,
clippy (three configurations), workspace tests, failpoints +
death_boundary, sim 315/315 pass. No linearization point moved.

CHANGELOG: one Unreleased bullet for this round's request-path work.
… heap at send

The gate's burst_async stayed ~20% behind main although its instructions
per request were already lower (4384 vs 4464, callgrind): the cost was the
allocator. With 0.7's 48-byte `Ack` reply, the `Arc<Slot<Ack>>` was 136
bytes, a 144-byte chunk, just above glibc's fast-bin limit (128); main's
oneshot is 112. In a burst, frees overflow the per-thread cache and every
slot went through the unsorted bin and consolidation, and the slower
allocator turned into parking (5-10x the voluntary context switches).
Raising `glibc.malloc.mxfast` to 160 confirmed it (cand 1.42M -> 2.01M/s,
main unchanged).

The slot is now 16 bytes smaller (120 -> 104 for `Ack`):
- `parked` became the word bit `PARKED` (set under the slot lock before a
  blocking receiver stores its unparker; the terminal transition reads it
  from the word it installed, so a racing CAS retried with it);
- the slot keeps no owner outside lifecycle-trace builds (`SlotOwner = ()`).
  The final reason already came from the slot; an undispatched envelope is
  disposed of through the mailbox; and the one remaining use, a raw
  receiver awaited with a deadline, is served at send: the public
  `send_request` with a deadline puts it in the owner's heap then and sets
  the word bit `TRACKED`, so the awaited receiver waits for the heap's
  expiry without an owner. `request`, `request_with_timeout` and the
  `request` helper send through `send_request_with(.., raw = false)` /
  `Receiver::send_request_timed` and arm their own timer as before.

Behaviour (documented in semantics.md R3, deferred-replies.md, CHANGELOG):
a raw request's deadline is registered at send instead of at its
receiver's first pending poll, so a receiver never awaited expires on time
too. Expiry still needs `now >= deadline`; no linearization point moved.
`shutdown_publication` gates the producer's first large allocation after
admission; its loop now sends without a deadline (a deadline's heap entry
would allocate first) and still exercises the mailbox block allocation.

Measured (gate harness bins, interleaved, cores 12-15, load ~40, 5 runs):
burst_async main 2490k, previous head 2290k, this 4386k req/s; rpc_async
main 235k, previous 254k, this 248k. Callgrind micro burst_raw 105.3M ->
104.1M (per request 2018 -> 1995), seq_default 148.1M -> 130.7M.

fmt, clippy (all features, no default features, failpoints), workspace
tests, failpoints + death_boundary, sim 315/315, Loom reply_slot 25 /
job_core 8 / registry_turn 3, lifecycle traces 93/93 accepted with 90
negative-control counterexamples, model-sync events/models/paths: pass.
…ep on the 0.8 port; outcomes keyed by test in exit_signals_sim
…TP's gen_server:call

A reply no longer reads the clock. It writes its value under the slot lock
and publishes it with one CAS `OPEN -> REPLIED` (a deferred reply holds the
owner's reply permit across it, the death-point fence). The deadline is
decided only by an expiry `OPEN -> TIMED_OUT`: the caller's runtime timer,
the owner's deadline heap, or a `[deadline]` check (`is_closed`, `defer`,
attach, `shorten_deadline`). A reply that loses that race finds the slot
`TIMED_OUT`, gets its value back as `Delivery::Expired`, and the caller
sees `RequestTimeout`; a reply that lands after the deadline instant but
before the expiry runs is `Delivered` and the caller gets the value, as an
OTP reply already in the caller's mailbox is received. The `WRITING` slot
state (the reply's deadline read between its CAS and its publication) is
gone, and with it the registry close's wait for it.

Checked for dependents of the reply-side check: `shorten_deadline` keeps
its semantics (lower under the slot lock while open, then the `[deadline]`
check, which races the reply on the word; exactly one wins); job deadlines
(`JobOptions::deadline`, `pipe_reply`) are a separate mechanism and are
unchanged; the death boundary (fence, registry close, batched discharge)
no longer waits for `WRITING` and is otherwise unchanged (death_boundary,
failpoints pass).

Model (verification/tla/DeferredReplies.tla): `TryReply` discharges `ok`
whenever the slot is open (no `now < dl`); `Expire` alone discharges
`timeout`; `NoSuccessAfterDeadline` is `okAt <= dl` (a reply can land at
the deadline instant before the expiry, never after it: the clock does not
pass an open slot's deadline); `CallerConsistent` uses `okAt <= sent`; the
`lateOk` ghost drops `now < dl`. Negative control `LateSuccess` now models
the new ordering's failure: a reply that overrides an expired slot
(counterexample to NoSuccessAfterDeadline). Trace mapping
(TraceReplies.tla): a reply's discharge is `OPEN -> REPLIED`, so a
"timeout" is always `Expire`; trace_check.py needed no change.

Docs: semantics.md R3, deferred-replies.md (state machine, rules 1-2,
deadline sources, verification), GUIDE (Delivery), MIGRATION, CHANGELOG,
`Delivery::Expired` rustdoc.

Results:
- TLC (check-tla --deep --only MCDeferredReplies*, <= 4 workers):
  MCDeferredReplies 27,961,270 states generated, 5,503,955 distinct, no
  error; MCDeferredRepliesDeep 76,892,564 generated, 15,141,666 distinct,
  no error.
- Traces: 93/93 accepted; negative controls: 130 counterexamples
  (MCDeferredReplies/LateSuccess violates NoSuccessAfterDeadline), all
  trace mutations rejected.
- Loom: reply_slot 25 (reply_vs_expiry and reply_vs_shortening restated:
  exactly one of reply and expiry wins the word; a won reply is delivered
  even at the deadline, a lost one returns Expired and the caller reads
  TimedOut), job_core 8, link_fence 10, registry_turn 3.
- fmt, clippy (3 configurations), workspace tests, failpoints +
  death_boundary, sim 320/320, model-sync events/models/paths + unit tests.
- Callgrind (micro; before = f66429c): burst_raw 104.1M -> 103.4M,
  seq_raw 112.6M -> 105.8M, seq_default 130.7M -> 137.0M (seq paths are
  scheduler-noise dominated; the reply path no longer reads the clock).
…ceiver waits, found by a key in the word

f66429c put every raw `send_request` deadline in the owner's heap at send.
Each entry held a `Weak` to its slot, so a slot answered long before its
deadline kept its allocation alive until the entry was popped or the heap
compacted, and every send paid the registry lock and a heap push: micro
burst_dl fell to -35% against main's raw request.

Now the slot records its owner's registry key (`Registry::key`, nonzero)
in the word's top 40 bits at construction (no transition touches them;
the slot still holds no reference to its owner and stays in the small
size class). An awaited raw receiver that finds its slot still open looks
the owner up by key (`Registry::by_key`, a global key -> Weak<Registry>
map written once per actor instance and cleared at its drop) and puts the
deadline in the heap once, as before f66429c; if the owner is gone it
falls back to the runtime timer. A request answered before its receiver
waits never touches the heap. The `TRACKED` word bit is gone. This also
restores the earlier contract (registration at the receiver's first
pending poll): semantics.md R3, deferred-replies.md and CHANGELOG are
updated, and `shutdown_publication` goes back to its original deadline.

Measured (micro, cores 4-7, load ~14, 6 interleaved runs each): burst_dl
3.43M -> 5.50M req/s (main raw, no deadline: 6.43M, -14.5%); burst_raw
-2.9%. What remains of burst_dl is the deadline itself: `Deadline::after`
(a clock read) and its conversion to ticks, ~145 instructions per request
(callgrind: Deadline::after 65, to_ticks 80); the heap work is now under
0.1% of instructions.

Callgrind (micro): burst_raw 103.4M -> 104.6M, seq_raw 105.8M -> 118.1M,
seq_default 137.0M -> 141.2M (seq paths are scheduler-noise dominated;
this change touches only raw deadline requests); burst_dl 113.6M.

fmt, clippy (3 configurations), workspace tests, failpoints +
death_boundary, sim 320/320, Loom reply_slot 25 / job_core 8 / link_fence
10 / registry_turn 3, traces 93/93 accepted with 130 negative-control
counterexamples, model-sync events/models/paths: pass. No linearization
point moved; no model change.
…Reply skips its drop

The CI hot-path gate (benchmarks/hotpath.py, callgrind instructions per
operation against main) measured async_ct_send_p1/p2 at +10.9%/+11.1%.
Per operation, the plain-send path paid for request machinery: a
`MessageEnvelope` with a detached `SlotHandle`, a `lent` field and their
drops; `PendingReply::reply` on the detached obligation (the macro's send
handler answers `()`), out of line, followed by the obligation's `Drop`.

- `send` builds a `SendEnvelope` (`enqueue_send`): just the message; its
  dispatch builds a detached `PendingReply` and lends no caller. Requests
  keep `MessageEnvelope`.
- `PendingReply::reply` is `#[inline]` and consumes the obligation through
  `ManuallyDrop`: once its slot is taken there is nothing left for `Drop`
  (fields are a reference and a marker). `Drop` itself is `#[inline]`.

Semantics unchanged: a sent message still runs inside a turn (the turn
phase classifies ReplyTo drops in it), and its `PendingReply` is detached
as before.

Gate (base 0791ace = main, Ir/op):
  async_ct_send_p1        855 -> 948 (+10.9%)  now 841 (-1.6%)
  async_ct_send_p2        849 -> 944 (+11.1%)  now 837 (-1.5%)
  async_ct_burst         3756 -> 4161          now 4119 (+9.7%)
  async_ct_request_seq   3479 -> 3709          now 3667 (+5.4%)
  async_ct_request_conc16 2589 -> 2923         now 2881 (+11.3%)
  threads_request_seq    3255 -> 2326          now 2269 (-30.3%)

fmt, clippy (3 configurations), workspace tests, failpoints +
death_boundary, sim 320/320, Loom reply_slot 25 / job_core 8 / link_fence
10 / registry_turn 3, traces 93/93 accepted with 130 negative-control
counterexamples, model-sync: pass. No model change.
…nd a leaner in-turn reply

Request-path trims measured with the CI hot-path gate (benchmarks/hotpath.py
gate, callgrind Ir/op against main 0791ace):

- `reply::clock` is the one place deadlines read time (`now`, `after`):
  today the precise `spawned_rt` clock, so a cheaper source can replace it
  without touching the reply protocol (that change awaits a decision and is
  not part of this commit). `request`/`request_with_timeout` fix their
  deadline as `clock::after(timeout)` in ticks (no `Instant::checked_add`,
  no `Deadline` round trip); `send_request_with` and `reply_slot` take
  ticks. The public `Deadline` API is unchanged.
- `PendingReply`'s in-turn reply (`Slot::reply_in_turn`) takes a fast
  path: a slot answered in its own turn was never deferred (`defer()`
  consumes the obligation), so it holds no credit, job or side state: one
  CAS under the slot lock, then the receiver's waker. Any other word
  (closed first, or unexpectedly deferred) takes the general `publish`.
- The first timer's millisecond count is computed in 64-bit arithmetic
  (no 128-bit division); `ReplyReceiver` and `SlotHandle` drops are
  inlined.

Gate (base 0791ace, Ir/op; before = 155682f):
  async_ct_burst          4119 -> 4102 (+9.2%)
  async_ct_request_seq    3667 -> 3612 (+3.8%)
  async_ct_request_conc16 2881 -> 2826 (+9.2%)
  async_ct_send_p1/p2     unchanged (841/837, -1.6%/-1.5%)

No semantics change: the deadline is the same instant (send time plus the
timeout, in ticks), and the in-turn fast path is the same linearization
(`OPEN -> REPLIED` under the slot lock) as `publish`.

fmt, clippy (3 configurations), workspace tests, failpoints +
death_boundary, sim 320/320, Loom reply_slot 25 / job_core 8 / link_fence
10 / registry_turn 3, traces 93/93 accepted with 130 negative-control
counterexamples, model-sync: pass.

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