From a5a87061aa6ebf8fc6ea3a72ca7be8695ef85c03 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Thu, 2 Jul 2026 12:26:19 -0500 Subject: [PATCH] feat: scheduled wakeup primitive for delegates (ScheduleWakeup / WakeupFired) Add two variants to the host<->delegate protocol enums so a delegate can schedule future execution without a connected UI (key rotation, TTL pruning, scheduled publication): - OutboundDelegateMsg::ScheduleWakeup { at: SystemTime, tag: Vec } - InboundDelegateMsg::WakeupFired { tag: Vec } Both are appended at the end of their enums (bincode variant tag 8), so the change is wire-compatible for every existing variant: delegate WASM compiled against an older stdlib keeps deserializing everything it already understood. New wire-format pin tests freeze the tags. OutboundDelegateMsg stays exhaustive (deliberately not #[non_exhaustive]) so the host must handle every outbound variant; the inaccurate doc claiming it was already non_exhaustive is corrected. Bumps 0.8.2 -> 0.8.3. Host-side implementation lands in freenet-core#3972. Refs: freenet/freenet-core#3972 [AI-assisted - Claude] Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K8mqiskQracG7CDVLSxDJC --- CHANGELOG.md | 27 ++++++++ rust/Cargo.toml | 2 +- rust/src/client_api/client_events.rs | 5 ++ rust/src/delegate_interface.rs | 98 +++++++++++++++++++++++++++- 4 files changed, 129 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d3b9c8..450cd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [0.8.3] + +### Added +- Scheduled-wakeup primitive for delegates (freenet/freenet-core#3972): + - `OutboundDelegateMsg::ScheduleWakeup { at: SystemTime, tag: Vec }` — + a delegate asks the host to deliver a wakeup at an absolute time. Opaque + `tag` is echoed back on fire. Re-scheduling with the same `tag` replaces + the prior pending wakeup for that `(delegate, tag)` pair (cancel-by-tag). + - `InboundDelegateMsg::WakeupFired { tag: Vec }` — delivered by the host + when a scheduled wakeup fires. + - Lets always-on delegates run periodic background work (key rotation, TTL + pruning, scheduled publication) without a connected UI, instead of pushing + that work into a client sync loop. + +### Compatibility +- **Wire-compatible, source-additive.** Both variants are appended at the end + of their enums (bincode variant tag 8), so no existing variant's tag shifts — + delegate WASM compiled against an older stdlib keeps deserializing every + message it already understood. New wire-format pin tests + (`inbound_wakeup_fired_wire_format_is_stable`, + `outbound_schedule_wakeup_wire_format_is_stable`) freeze the tags. +- Adding `ScheduleWakeup` is a **source-level break for exhaustive `match` + sites on `OutboundDelegateMsg`** (which is intentionally *not* + `#[non_exhaustive]`, so the host is forced to handle every outbound variant). + `InboundDelegateMsg` remains `#[non_exhaustive]`; unknown inbound variants are + forwarded to the delegate WASM unchanged. + ## [0.8.0] ### Fixed diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 73b9c61..ea00224 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "freenet-stdlib" -version = "0.8.2" +version = "0.8.3" edition = "2021" rust-version = "1.80" publish = true diff --git a/rust/src/client_api/client_events.rs b/rust/src/client_api/client_events.rs index 01a41d9..e4ebb29 100644 --- a/rust/src/client_api/client_events.rs +++ b/rust/src/client_api/client_events.rs @@ -1579,6 +1579,11 @@ impl HostResponse { "SendDelegateMessage reached client serialization - this is a bug" ); } + OutboundDelegateMsg::ScheduleWakeup { .. } => { + tracing::error!( + "ScheduleWakeup reached client serialization - this is a bug" + ); + } }); let messages_offset = builder.create_vector(&messages); let delegate_response_offset = FbsDelegateResponse::create( diff --git a/rust/src/delegate_interface.rs b/rust/src/delegate_interface.rs index 71a98bf..8cad567 100644 --- a/rust/src/delegate_interface.rs +++ b/rust/src/delegate_interface.rs @@ -5,6 +5,7 @@ use std::{ io::Read, ops::Deref, path::Path, + time::SystemTime, }; use blake3::{traits::digest::Digest, Hasher as Blake3}; @@ -498,8 +499,11 @@ impl AsRef<[u8]> for DelegateContext { /// This is the inbound counterpart of [`OutboundDelegateMsg`] and sits on the /// host↔delegate wire boundary. Marked `#[non_exhaustive]` so future variants /// can be added without a source-level break; downstream `match` sites must -/// include a wildcard arm. This matches the pre-existing `#[non_exhaustive]` -/// on `OutboundDelegateMsg`. +/// include a wildcard arm. Inbound variants the host does not recognize are +/// forwarded through to the delegate WASM unchanged, so a wildcard is safe here. +/// (`OutboundDelegateMsg` is deliberately *not* `#[non_exhaustive]`: the host +/// must consciously handle every outbound variant, so the compiler should force +/// an arm for each new one.) /// /// Wire format: bincode with variant index 0..=N in declaration order. The /// `inbound_delegate_msg_wire_format_is_stable` test pins the bytes for @@ -515,6 +519,13 @@ pub enum InboundDelegateMsg<'a> { SubscribeContractResponse(SubscribeContractResponse), ContractNotification(ContractNotification), DelegateMessage(DelegateMessage), + /// Delivered by the host when a wakeup previously requested via + /// [`OutboundDelegateMsg::ScheduleWakeup`] fires. `tag` is the opaque + /// value the delegate supplied when scheduling, echoed back verbatim so + /// the delegate can identify which wakeup fired. Owned (`'static`). + WakeupFired { + tag: Vec, + }, } impl InboundDelegateMsg<'_> { @@ -538,6 +549,7 @@ impl InboundDelegateMsg<'_> { InboundDelegateMsg::ContractNotification(r) } InboundDelegateMsg::DelegateMessage(r) => InboundDelegateMsg::DelegateMessage(r), + InboundDelegateMsg::WakeupFired { tag } => InboundDelegateMsg::WakeupFired { tag }, } } @@ -689,6 +701,25 @@ pub enum OutboundDelegateMsg { UpdateContractRequest(UpdateContractRequest), SubscribeContractRequest(SubscribeContractRequest), SendDelegateMessage(DelegateMessage), + /// Ask the host to deliver an [`InboundDelegateMsg::WakeupFired`] to this + /// delegate at (or after) the absolute time `at`. `tag` is opaque to the + /// host and echoed back on fire. Re-scheduling with the same `tag` replaces + /// any prior pending wakeup for this `(delegate, tag)` pair (cancel-by-tag). + /// The host persists the schedule so it survives node restart, and drops it + /// silently if the delegate is no longer registered when it would fire. + /// + /// Delivery is **at-most-once**: a wakeup is removed from the durable + /// schedule when it fires, so a node crash in the narrow window between + /// firing and the delegate finishing `process()` can lose that fire. A + /// recurring delegate that re-arms only inside its `WakeupFired` handler can + /// therefore have its chain broken by such a crash; a delegate that needs a + /// hard guarantee should also re-assert its next wakeup from its startup + /// logic. The host additionally bounds how many wakeups a single delegate + /// may hold pending, so keep the set of live tags small. + ScheduleWakeup { + at: SystemTime, + tag: Vec, + }, } impl From for OutboundDelegateMsg { @@ -746,6 +777,8 @@ impl OutboundDelegateMsg { OutboundDelegateMsg::SendDelegateMessage(msg) => msg.processed, OutboundDelegateMsg::RequestUserInput(_) => true, OutboundDelegateMsg::ContextUpdated(_) => true, + // Fire-and-forget scheduling request; it carries no reprocessing loop. + OutboundDelegateMsg::ScheduleWakeup { .. } => true, } } @@ -1225,4 +1258,65 @@ mod message_origin_tests { let decoded: InboundDelegateMsg<'_> = bincode::deserialize(&encoded).unwrap(); assert!(matches!(decoded, InboundDelegateMsg::ApplicationMessage(_))); } + + /// Wire-format pin for [`InboundDelegateMsg::WakeupFired`]. It is the 9th + /// variant (declaration index 8), so its bincode tag must be `8` (4-byte + /// LE). Once shipped this tag is frozen: reordering or inserting a variant + /// ahead of it would silently redirect a host's wakeup delivery to the + /// wrong variant on a delegate compiled against this stdlib. + #[test] + fn inbound_wakeup_fired_wire_format_is_stable() { + let msg = InboundDelegateMsg::WakeupFired { + tag: vec![0xAA, 0xBB], + }; + let encoded = bincode::serialize(&msg).unwrap(); + + // tag 8 (u32 LE) + Vec len (u64 LE = 2) + the two tag bytes. + let mut expected = vec![8u8, 0, 0, 0]; + expected.extend_from_slice(&[2, 0, 0, 0, 0, 0, 0, 0]); + expected.extend_from_slice(&[0xAA, 0xBB]); + assert_eq!( + encoded, expected, + "WakeupFired must stay at variant tag 8 with a stable payload layout" + ); + + let decoded: InboundDelegateMsg<'_> = bincode::deserialize(&encoded).unwrap(); + assert!(matches!( + decoded, + InboundDelegateMsg::WakeupFired { tag } if tag == vec![0xAA, 0xBB] + )); + } + + /// Wire-format pin for [`OutboundDelegateMsg::ScheduleWakeup`]. It is the + /// 9th variant (declaration index 8), so its bincode tag must be `8`. + /// Pins the tag and round-trip (the full `SystemTime` byte layout is left + /// to serde's stable impl and asserted via round-trip rather than a brittle + /// hardcoded prefix). + #[test] + fn outbound_schedule_wakeup_wire_format_is_stable() { + let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000); + let msg = OutboundDelegateMsg::ScheduleWakeup { + at, + tag: vec![0x01, 0x02, 0x03], + }; + let encoded = bincode::serialize(&msg).unwrap(); + assert_eq!( + encoded[..4], + [8, 0, 0, 0], + "ScheduleWakeup must stay at variant tag 8 on the wire; \ + reordering OutboundDelegateMsg variants is a wire-format break" + ); + + let decoded: OutboundDelegateMsg = bincode::deserialize(&encoded).unwrap(); + match decoded { + OutboundDelegateMsg::ScheduleWakeup { + at: decoded_at, + tag, + } => { + assert_eq!(decoded_at, at); + assert_eq!(tag, vec![0x01, 0x02, 0x03]); + } + other => panic!("expected ScheduleWakeup, got {other:?}"), + } + } }