Skip to content

feat: host-side scheduled wakeup primitive for delegates (#3972)#4666

Draft
sanity wants to merge 1 commit into
mainfrom
feat/3972-delegate-wakeup
Draft

feat: host-side scheduled wakeup primitive for delegates (#3972)#4666
sanity wants to merge 1 commit into
mainfrom
feat/3972-delegate-wakeup

Conversation

@sanity

@sanity sanity commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Blocked on freenet-stdlib 0.8.3 (adds ScheduleWakeup / WakeupFired).
This PR depends on that release being published to crates.io first
(stdlib-first policy). CI will be red until then. Draft until 0.8.3 is out and
Cargo.lock is refreshed (cargo update -p freenet-stdlib --precise 0.8.3).

Problem

Delegates cannot schedule future execution, so always-on background work (River
private-room key rotation #228, TTL pruning, scheduled publish) has to live in a
UI sync loop and stops when the UI is closed. This implements the host side of
the scheduled-wakeup primitive whose wire variants land in freenet-stdlib 0.8.3.

Approach

WakeupScheduler (crates/core/src/contract/wakeup.rs) — a per-node
schedule with:

  • a time-ordered forward index (BTreeMap<(SystemTime, seq)>) so "what fires
    next" is O(1),
  • a (delegate, tag) reverse index for cancel-by-tag (re-scheduling the
    same tag replaces the prior pending wakeup), and
  • ReDb persistence (new WAKEUPS_TABLE) so pending wakeups survive restart;
    the table is created on first open of an upgraded DB.

Params capture. A fired wakeup must invoke the delegate with the same params
it was registered with, but the DelegateKey is a one-way hash of code+params so
the host can't recover them from the key. Each wakeup therefore stores the
delegate's params at schedule time and replays them on fire — correcting the
empty-params shortcut the contract-notification path takes.

Inline firing (no separate task). OutboundDelegateMsg::ScheduleWakeup is
intercepted in handle_delegate_with_contract_requests and persisted
(fire-and-forget, not re-driven). Wakeups fire inline as a new arm in the
contract_handling select! loop — the same single task that owns the executor
and runs delegates. That means no cross-task channel, no lossy dispatch, and no
Notify: because scheduling and firing are on the same task, a newly-scheduled
earlier wakeup is observed on the loop's next pass. Firing delivers
InboundDelegateMsg::WakeupFired; a delegate that is no longer registered yields
a missing-delegate error that is logged and dropped (the entry was already
removed by take_due), never reinstalled — the issue's "drop on uninstalled
delegate" rule.

This deviates from the issue's suggested standalone-tokio-task sketch on purpose:
firing on the delegate-owning task avoids the whole class of cross-task
channel/back-pressure bugs the project's channel-safety rules exist to prevent.

Testing

  • WakeupScheduler unit tests: cancel-by-tag replace, earliest-first fire
    ordering, bounded batch, ReDb persistence across reload (simulated restart),
    cancel-by-tag persisted, and volume/no-loss for many independent schedules.
  • New test-delegate-wakeup WASM delegate + runtime tests: a delegate emits
    ScheduleWakeup (host sees it), a host-delivered WakeupFired reaches the
    delegate's process() and it reacts — exercising both new variants across the
    WASM bincode boundary — plus the drop-on-uninstalled path.

Closes #3972

[AI-assisted - Claude]

🤖 Generated with Claude Code


Review

Full-tier multi-model review run pre-PR (wire format + delegate runtime +
concurrency + persistence): Codex (external) + 4 diverse Claude reviewers
(concurrency, persistence/wire-format, adversarial bug-hunt, big-picture). The
data-race and lost-wakeup questions were traced and confirmed safe (single-task
&mut, no cross-task mutation). Findings addressed in this PR:

  • Sender-attestation bypass (Codex/bug-hunt): the ScheduleWakeup arm in
    process_outbound no longer drain-and-breaks; it falls through so each
    trailing SendDelegateMessage reaches its own attesting arm (also preserves
    ContextUpdated folding).
  • decode_value panic on a corrupt row (persistence/bug-hunt): now validates
    nanos and uses checked_add, returning None so a torn row is skipped, not a
    startup crash. New overflow test.
  • Unbounded pending wakeups (Codex/persistence/big-picture): per-delegate,
    global, and tag-length caps enforced at schedule(); new cap tests.
  • LOW: wall-clock/TimeSource deviation now carries a load-bearing justification
    comment; at-most-once delivery documented on the stdlib variant; the shared
    "migration probes" warn generalised for the uninstalled-wakeup case.

Known follow-ups (filed separately): a loop-level integration test for the
firing glue (a deterministic one is blocked by the intentional wall-clock design;
tracked as an issue), and the pre-existing drain-bypass in the sibling terminal
arms (ApplicationMessage/RequestUserInput/… — not introduced here).

Implement the host side of the delegate scheduled-wakeup primitive whose wire
variants land in freenet-stdlib 0.8.3.

- WakeupScheduler (contract/wakeup.rs): a per-node schedule with a time-ordered
  forward index, a (delegate, tag) reverse index for cancel-by-tag, and ReDb
  persistence so pending wakeups survive restart. Each wakeup stores the
  delegate's params so a fired wakeup replays in the delegate's own param
  context (the DelegateKey is a one-way hash of code+params, so params can't be
  recovered from the key -- correcting the empty-params shortcut the contract-
  notification path takes).
- OutboundDelegateMsg::ScheduleWakeup is intercepted in
  handle_delegate_with_contract_requests and persisted; it is fire-and-forget
  and does not re-drive the delegate.
- Wakeups fire INLINE as a new arm in the contract_handling select! loop -- the
  same task that owns the executor and runs delegates -- so there is no cross-
  task channel, no lossy dispatch, and a newly-scheduled earlier wakeup is
  observed on the loop's next pass with no explicit notification. Firing
  delivers InboundDelegateMsg::WakeupFired; an uninstalled delegate yields a
  missing-delegate error that is logged and dropped (the entry was already
  removed by take_due), never reinstalled.

Requires freenet-stdlib 0.8.3 (published separately; stdlib-first).

Testing: WakeupScheduler unit tests (cancel-by-tag replace, fire ordering,
ReDb persistence across reload, bounded batch, volume/no-loss); a new
test-delegate-wakeup WASM delegate + runtime tests exercising ScheduleWakeup
emission and WakeupFired delivery across the WASM boundary, and the
drop-on-uninstalled path.

Closes #3972
[AI-assisted - Claude]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8mqiskQracG7CDVLSxDJC
@sanity

sanity commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Depends on freenet/freenet-stdlib#82 (adds ScheduleWakeup/WakeupFired, bumps 0.8.2→0.8.3). Per the stdlib-first policy this stays draft until #82 merges and 0.8.3 publishes to crates.io; then Cargo.lock is refreshed and CI can go green.

[AI-assisted - Claude]

@sanity

sanity commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up issues from review (deferred, not blocking this PR): #4667 (loop-level integration test for the firing glue) and #4668 (pre-existing sender-attestation drain-bypass in the sibling terminal arms).

[AI-assisted - Claude]

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.

feat: scheduled wakeup primitive for delegates (ScheduleWakeup / WakeupFired)

1 participant