Repository navigation
feat: exit signals, startup and runtime-drop semantics as in OTP (ranks 5e, 5f, 5g) - #240
Draft
unbalancedparentheses wants to merge 221 commits into
Draft
unbalancedparentheses wants to merge 221 commits into
unbalancedparentheses wants to merge 221 commits into
Conversation
…trait migration (WIP: tests not yet migrated)
…h, pipe_reply* and JobCtx emits (WIP)
…ith on owned native threads
…ith on owned native threads
…mits, delegation, bounds) and registry unit tests
…ocks, diverging bodies)
…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.
…rt) into feat/otp-exit-signals
…(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.
…t/otp-exit-signals
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the accepted design
docs/design/otp-exit-signals.md(#237) and its modelverification/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 tomainafter #239 merges. The branch mergesorigin/mainto pick up the design doc andLifecycle.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)
ExitReason#[non_exhaustive]+Killed,NoProc;ChildHandle::kill()untrappable, propagatesKilled; single death point afterstopped(); late non-kill signal replaces the reason unless killed (decision 8)ctx.exit/ChildHandle::exitwithexit_signal/2semantics; administrative stops unchanged (decisions 5, 6); parent exit ends a trapping child throughstopped(); supervisor kills atshutdown_timeoutstopped()skipped on kill, untrapped signal/link notice, runtime drop (decision 2)NoProcfor link/monitor/request past D; late link from non-trapping caller →NoProcexit signal;ctx.try_link; unlink/demonitor fencesKilledin both modes (5g, decision 3)start(); ready-child exits deferred until after startup;start()returns after rollback;Supervision.tla/Cohorts.tlaupdatesActorStopped(reason),NoProcafter D (decision 4),DeferredReplies.tlaexit-pending change + controlProgress is posted as checkpoint comments below.