You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Task now tracks the user future, pending reply locks, and (non-ethexe) critical hooks, letting message_loop reuse locks and enforce timeouts.
Added WakeSignals to manage reply lifecycle states—Pending, Ready, Timeout—so record_reply, poll, and timeout propagation share one typed registry, including optional reply hooks and automatic wake/cleanup
Refined Lock to store deadline and wait type
Sails async_runtime vs the async runtime in gstd
Sails reimplements the on-chain async loop, collapsing gstd's four global maps into two, moving per-message state into the structures it belongs to (wait locks into the Task, reply hooks into the WakeSignal), swapping the hasher, dropping waker machinery, and
adding a first-class timeout/expiry lifecycle with RAII cleanup. Same actor / wait / wake model underneath; leaner bookkeeping and more explicit edge-case handling on top.
gstd does 2-3 independent keyed map operations per send and touches all four maps on teardown.
Sails registers a wait with one signals insert plus one push into the owning task's heap, and
a hook rides inside the signal it belongs to — fewer hash lookups and no cross-map lifetime
coordination.
Hashing
gstd:hashbrown default hasher — mixes all 32 bytes of every MessageId.
Sails:IdMap = HashMap<.., BuildHasherDefault<IdHasher>>. MessageId is already a
cryptographic hash, so IdHasher takes its first 8 bytes verbatim. No mixing -> cheaper gas on
every map op.
Lock storage & "shortest wait" selection
gstd: nested HashMap<MessageId, BTreeMap<LockContext, Lock>>. LockContext is an enum — ReplyTo, Sleep, MxLockMonitor (the last serves gstd's Mutex/RwLock). On wait() it
iterates the inner map, filters deadline > now, and min_by. Lock::cmp compares deadline
only.
Sails:Task.locks: BinaryHeap<(Reverse<Lock>, Option<MessageId>)>. Earliest deadline is
the heap root; next_lock peeks/pops, lazily dropping locks that expired or whose signal is no
longer waited-for. Some(reply_to) = reply wait, None = sleep. No MxLockMonitor variant
(Sails doesn't ship gstd's mutexes). Its Lock::cmp breaks deadline ties UpTo < Exactly
(UpTo wins) — deterministic, where gstd leaves ties unordered.
Reply lifecycle & timeout semantics (the biggest behavioral difference)
gstd WakeSignal is a struct: { message_id, payload: Option<(Payload, ReplyCode)>, waker: Option<Waker> } — effectively two states (pending / has-payload). Timeout is not part of the
signal; it's computed externally in msg::async::poll via locks().is_timeout(...), and on
timeout the lock and signal entry are removed eagerly. Polling a missing entry panics.
Sails WakeSignal is a 3-variant enum: Pending { message_id, deadline, reply_hook } -> Ready { payload, reply_code } | Expired { expected, now, reply_hook }. Timeout is a
first-class, retained state. Expired entries are deliberately kept so that:
a deferred re-poll (e.g. select!/join! where another branch finished first and the MessageFuture is polled late) observes Err(Timeout) instead of panicking on a missing
entry;
a late reply can still fire a preserved reply_hook via record_reply;
final deletion is MessageFuture::drop's job (RAII), not poll's.
The expire() transition is shared by record_timeout (natural timeout), poll (deferred
timeout), and forget_future (cancellation), and drop/cancel is symmetric with timeout
(Pending+hook -> Expired+hook). gstd has none of this retain/RAII machinery — it's covered in
Sails by a dedicated test matrix.
Wakers
gstd: real waker plumbing — Task holds a waker_fn, each pending poll clones cx.waker() into the signal, and record_reply calls both waker.wake_by_ref() and exec::wake.
Sails:Context::from_waker(Waker::noop()) — no waker stored or cloned, ever. Re-entry is
driven solely by Syscall::wake(message_id). Valid because the loop drives exactly one future
per message and never needs intra-future notification; saves the per-poll waker clone.
Reply hooks
gstd: separate HooksMap; register panics on duplicate; executed in handle_reply_with_hookafterrecord_reply, removed in handle_signal. Decoupled from the
signal.
Sails: the hook lives inside the signal (Pending/Expired), so it travels through the
state transitions — the "late reply still fires the hook" and "cancellation preserves the hook"
behaviors fall out for free. One fewer map and lookup. send_one_way adds register_hook,
which stores an Expired-with-hook entry so fire-and-forget sends can still fire a hook from
outside message_loop.
Critical hooks
gstd: separate critical module (take_and_execute/take_hook) as global state; the loop
clears it on completion, handle_signal runs it.
Sails:critical_hook: Option<Box<dyn FnOnce(MessageId)>> is a Task field, set via with_task_mut, taken from the removed task in handle_signal. No separate global; compiled
out under ethexe.
message_loop & the reentrancy/aliasing fix
gstd: holds &mut task and polls Pin::new(&mut task.future)while that borrow (and the &mut FUTURES borrow) is live. Safe for gstd because wait/lock/hook registration goes through different maps, never re-borrowing FUTURES during the poll. It also carries a lock_exceeded flag, checked at loop entry, to panic on lock-ownership-time overrun.
Sails: because locks now live in the Task, registering a wait during a poll
(register_signal -> task.insert_lock) would re-borrow the tasks map while the future is
being polled. Sails fixes this structurally: Task.future is Option<...>, moved out with .take() before polling (so the &mut Task borrow ends), and every reentrant access
(register_signal, set_critical_hook, sleep_for) goes through a short-lived with_task_mut. Result: sound under Stacked/Tree Borrows, no Box-noalias hazard, no unsafe
— validated by a Miri test. There's no lock_exceeded flag; overrun relies on node-side
timeout plus the Expired transitions.
Sleep
gstd:LocksMap::insert_sleep/remove_sleep, keyed by wake-up block in the nested BTreeMap under a Sleep context.
Sails:sleep_for pushes an unkeyed (reply_to = None) lock into the task heap and returns
a MessageSleepFuture that resolves when block_height >= deadline. No separate sleep context.
Smaller but real
No Send bound on reply hooks (FnOnce() + 'static) — matches gstd's hook type; the client GstdParams::with_reply_hook dropped its old Send requirement to suit.
entry_ref/EntryRef API used in record_reply/forget_future/poll to avoid double
lookups.
No mutex/async_init/async_main — Sails serves fewer clients than gstd's runtime (it
generates its own ctor/dispatch via macros), which is why its lock model can be leaner.
Host-testable: routes through the Syscall abstraction
(with_message_id/with_block_height/with_reply_code mocks), giving the whole runtime
host-side unit tests plus Miri — gstd's runtime is largely on-chain-tested only.
Net trade-offs
Sails gains: fewer maps/lookups and a faster hasher (gas), explicit and tested
timeout/cancellation/late-reply semantics, no waker allocation, and unit/Miri testability.
Sails takes on: a subtle reentrancy invariant (future-out-of-Task during poll) that gstd
sidesteps by construction, and re-implements lock/signal/hook bookkeeping that it must keep
correct itself rather than inheriting from gstd.
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
run-benchmarksEnables running benchmarks in CIrustPull requests that update Rust code
2 participants
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.
Summary
message_loopreuse locks and enforce timeouts.WakeSignalsto manage reply lifecycle states—Pending, Ready, Timeout—so record_reply, poll, and timeout propagation share one typed registry, including optional reply hooks and automatic wake/cleanupLockto store deadline and wait typeSails
async_runtimevs the async runtime ingstdSails reimplements the on-chain async loop, collapsing gstd's four global maps into
two, moving per-message state into the structures it belongs to (wait locks into the
Task, reply hooks into theWakeSignal), swapping the hasher, dropping waker machinery, andadding a first-class timeout/expiry lifecycle with RAII cleanup. Same actor /
wait/wakemodel underneath; leaner bookkeeping and more explicit edge-case handling on top.Global state: 4 maps -> 2
FUTURES: HashMap<MessageId, Task>tasks: IdMap<Task>SIGNALS: HashMap<MessageId, WakeSignal>signals: IdMap<WakeSignal>LOCKS: HashMap<MessageId, BTreeMap<LockContext, Lock>>Task.locksREPLY_HOOKS: HashMap<MessageId, Box<dyn FnOnce()>>WakeSignal::{Pending,Expired}.reply_hookgstd does 2-3 independent keyed map operations per send and touches all four maps on teardown.
Sails registers a wait with one
signalsinsert plus one push into the owning task's heap, anda hook rides inside the signal it belongs to — fewer hash lookups and no cross-map lifetime
coordination.
Hashing
hashbrowndefault hasher — mixes all 32 bytes of everyMessageId.IdMap = HashMap<.., BuildHasherDefault<IdHasher>>.MessageIdis already acryptographic hash, so
IdHashertakes its first 8 bytes verbatim. No mixing -> cheaper gas onevery map op.
Lock storage & "shortest wait" selection
HashMap<MessageId, BTreeMap<LockContext, Lock>>.LockContextis an enum —ReplyTo,Sleep,MxLockMonitor(the last serves gstd'sMutex/RwLock). Onwait()ititerates the inner map, filters
deadline > now, andmin_by.Lock::cmpcompares deadlineonly.
Task.locks: BinaryHeap<(Reverse<Lock>, Option<MessageId>)>. Earliest deadline isthe heap root;
next_lockpeeks/pops, lazily dropping locks that expired or whose signal is nolonger waited-for.
Some(reply_to)= reply wait,None= sleep. NoMxLockMonitorvariant(Sails doesn't ship gstd's mutexes). Its
Lock::cmpbreaks deadline ties UpTo < Exactly(UpTo wins) — deterministic, where gstd leaves ties unordered.
Reply lifecycle & timeout semantics (the biggest behavioral difference)
gstd
WakeSignalis a struct:{ message_id, payload: Option<(Payload, ReplyCode)>, waker: Option<Waker> }— effectively two states (pending / has-payload). Timeout is not part of thesignal; it's computed externally in
msg::async::pollvialocks().is_timeout(...), and ontimeout the lock and signal entry are removed eagerly. Polling a missing entry panics.
Sails
WakeSignalis a 3-variant enum:Pending { message_id, deadline, reply_hook }->Ready { payload, reply_code }|Expired { expected, now, reply_hook }. Timeout is afirst-class, retained state. Expired entries are deliberately kept so that:
select!/join!where another branch finished first and theMessageFutureis polled late) observesErr(Timeout)instead of panicking on a missingentry;
reply_hookviarecord_reply;MessageFuture::drop's job (RAII), not poll's.The
expire()transition is shared byrecord_timeout(natural timeout),poll(deferredtimeout), and
forget_future(cancellation), and drop/cancel is symmetric with timeout(
Pending+hook -> Expired+hook). gstd has none of this retain/RAII machinery — it's covered inSails by a dedicated test matrix.
Wakers
Taskholds awaker_fn, each pendingpollclonescx.waker()into the signal, andrecord_replycalls bothwaker.wake_by_ref()andexec::wake.Context::from_waker(Waker::noop())— no waker stored or cloned, ever. Re-entry isdriven solely by
Syscall::wake(message_id). Valid because the loop drives exactly one futureper message and never needs intra-future notification; saves the per-poll waker clone.
Reply hooks
HooksMap;registerpanics on duplicate; executed inhandle_reply_with_hookafterrecord_reply, removed inhandle_signal. Decoupled from thesignal.
Pending/Expired), so it travels through thestate transitions — the "late reply still fires the hook" and "cancellation preserves the hook"
behaviors fall out for free. One fewer map and lookup.
send_one_wayaddsregister_hook,which stores an
Expired-with-hook entry so fire-and-forget sends can still fire a hook fromoutside
message_loop.Critical hooks
criticalmodule (take_and_execute/take_hook) as global state; the loopclears it on completion,
handle_signalruns it.critical_hook: Option<Box<dyn FnOnce(MessageId)>>is aTaskfield, set viawith_task_mut, taken from the removed task inhandle_signal. No separate global; compiledout under
ethexe.message_loop& the reentrancy/aliasing fix&mut taskand pollsPin::new(&mut task.future)while that borrow (and the&mut FUTURESborrow) is live. Safe for gstd because wait/lock/hook registration goes throughdifferent maps, never re-borrowing
FUTURESduring the poll. It also carries alock_exceededflag, checked at loop entry, to panic on lock-ownership-time overrun.Task, registering a wait during a poll(
register_signal -> task.insert_lock) would re-borrow thetasksmap while the future isbeing polled. Sails fixes this structurally:
Task.futureisOption<...>, moved out with.take()before polling (so the&mut Taskborrow ends), and every reentrant access(
register_signal,set_critical_hook,sleep_for) goes through a short-livedwith_task_mut. Result: sound under Stacked/Tree Borrows, noBox-noalias hazard, nounsafe— validated by a Miri test. There's no
lock_exceededflag; overrun relies on node-sidetimeout plus the
Expiredtransitions.Sleep
LocksMap::insert_sleep/remove_sleep, keyed by wake-up block in the nestedBTreeMapunder aSleepcontext.sleep_forpushes an unkeyed (reply_to = None) lock into the task heap and returnsa
MessageSleepFuturethat resolves whenblock_height >= deadline. No separate sleep context.Smaller but real
Sendbound on reply hooks (FnOnce() + 'static) — matches gstd's hook type; the clientGstdParams::with_reply_hookdropped its oldSendrequirement to suit.entry_ref/EntryRefAPI used inrecord_reply/forget_future/pollto avoid doublelookups.
async_init/async_main— Sails serves fewer clients than gstd's runtime (itgenerates its own ctor/dispatch via macros), which is why its lock model can be leaner.
Syscallabstraction(
with_message_id/with_block_height/with_reply_codemocks), giving the whole runtimehost-side unit tests plus Miri — gstd's runtime is largely on-chain-tested only.
Net trade-offs
Sails gains: fewer maps/lookups and a faster hasher (gas), explicit and tested
timeout/cancellation/late-reply semantics, no waker allocation, and unit/Miri testability.
Sails takes on: a subtle reentrancy invariant (future-out-of-
Taskduring poll) that gstdsidesteps by construction, and re-implements lock/signal/hook bookkeeping that it must keep
correct itself rather than inheriting from gstd.