use std::collections::{HashMap, VecDeque};
use std::time::Duration;
use either::Either;
use freenet_stdlib::client_api::DelegateRequest;
use freenet_stdlib::prelude::{
ContractContainer, ContractInstanceId, ContractKey, DelegateContext, DelegateKey,
InboundDelegateMsg, OutboundDelegateMsg, Parameters, RelatedContracts, StateDelta,
WrappedState,
};
use super::executor::ExecutorError;
use super::handler::{EventId, StashedResponder};
use crate::client_events::ConnectionScope;
use crate::wasm_runtime::UserSecretContext;
pub(super) const MAX_PARKED_DELEGATES: usize = 64;
pub(super) const MAX_PENDING_PER_DELEGATE: usize = 8;
pub(super) const MAX_PENDING_NOTIFICATION_CONTRACTS: usize = 16;
pub(super) const MAX_DEFERRED_UPSERTS_PER_PARK: usize = 4;
pub(super) const MAX_NETWORK_CONTRACT_OPS_PER_PARK: usize = 4;
pub(super) const MAX_PARKED_BYTES: usize = 64 * 1024 * 1024;
pub(super) fn continuation_bytes(continuation: &Continuation) -> usize {
continuation
.inbound_so_far
.iter()
.map(inbound_bytes)
.sum::<usize>()
+ continuation
.accumulated
.iter()
.map(outbound_bytes)
.sum::<usize>()
+ continuation.params.as_ref().len()
}
pub(super) fn task_bytes(
prompts: &[freenet_stdlib::prelude::UserInputRequest<'static>],
upserts: &[PendingUpsert],
contract_ops: &[PendingContractOp],
) -> usize {
let prompt_bytes: usize = prompts
.iter()
.map(|r| r.message.bytes().len() + r.responses.iter().map(|resp| resp.len()).sum::<usize>())
.sum();
let upsert_bytes: usize = upserts
.iter()
.map(|u| {
let update = match &u.update {
Either::Left(state) => state.as_ref().len(),
Either::Right(delta) => delta.as_ref().len(),
};
let code = u
.code
.as_ref()
.map_or(0, |c| c.data().len() + c.params().as_ref().len());
let related: usize = u
.related_contracts
.states()
.map(|(_, st)| st.as_ref().map_or(0, |s| s.as_ref().len()))
.sum();
update + code + related
})
.sum();
let contract_op_bytes: usize = contract_ops
.iter()
.map(|op| ctx_len(&op.context).saturating_mul(2))
.sum();
prompt_bytes + upsert_bytes + contract_op_bytes
}
pub(super) fn request_bytes(req: &DelegateRequest<'static>) -> usize {
match req {
DelegateRequest::ApplicationMessages {
inbound, params, ..
} => inbound.iter().map(inbound_bytes).sum::<usize>() + params.as_ref().len(),
DelegateRequest::RegisterDelegate { delegate, .. } => delegate_container_bytes(delegate),
DelegateRequest::UnregisterDelegate(_) | _ => 0,
}
}
fn delegate_container_bytes(delegate: &freenet_stdlib::prelude::DelegateContainer) -> usize {
delegate.code().as_ref().len()
}
fn inbound_bytes(msg: &InboundDelegateMsg<'static>) -> usize {
match msg {
InboundDelegateMsg::ApplicationMessage(m) => m.payload.len() + ctx_len(&m.context),
InboundDelegateMsg::GetContractResponse(r) => {
r.state.as_ref().map_or(0, |s| s.as_ref().len()) + ctx_len(&r.context)
}
InboundDelegateMsg::ContractNotification(n) => {
n.new_state.as_ref().len() + ctx_len(&n.context)
}
InboundDelegateMsg::UserResponse(r) => r.response.len() + ctx_len(&r.context),
InboundDelegateMsg::DelegateMessage(m) => m.payload.len() + ctx_len(&m.context),
InboundDelegateMsg::PutContractResponse(r) => ctx_len(&r.context),
InboundDelegateMsg::UpdateContractResponse(r) => ctx_len(&r.context),
InboundDelegateMsg::SubscribeContractResponse(r) => ctx_len(&r.context),
InboundDelegateMsg::UnsubscribeContractResponse(r) => ctx_len(&r.context),
InboundDelegateMsg::WakeupFired { tag } => tag.len(),
_ => 0,
}
}
fn outbound_bytes(msg: &OutboundDelegateMsg) -> usize {
match msg {
OutboundDelegateMsg::ApplicationMessage(m) => m.payload.len() + ctx_len(&m.context),
OutboundDelegateMsg::SendDelegateMessage(m) => m.payload.len() + ctx_len(&m.context),
OutboundDelegateMsg::ContextUpdated(c) => ctx_len(c),
OutboundDelegateMsg::RequestUserInput(r) => {
r.message.bytes().len() + r.responses.iter().map(|resp| resp.len()).sum::<usize>()
}
OutboundDelegateMsg::GetContractRequest(r) => ctx_len(&r.context),
OutboundDelegateMsg::PutContractRequest(r) => r.state.as_ref().len() + ctx_len(&r.context),
OutboundDelegateMsg::UpdateContractRequest(r) => ctx_len(&r.context),
OutboundDelegateMsg::SubscribeContractRequest(r) => ctx_len(&r.context),
OutboundDelegateMsg::UnsubscribeContractRequest(r) => ctx_len(&r.context),
}
}
fn ctx_len(ctx: &DelegateContext) -> usize {
ctx.as_ref().len()
}
pub(super) const PARK_TTL: Duration = Duration::from_secs(90);
pub(super) const PARK_WORK_BUDGET: Duration = Duration::from_secs(75);
const _: () = assert!(
PARK_WORK_BUDGET.as_secs() < PARK_TTL.as_secs(),
"PARK_WORK_BUDGET must stay below PARK_TTL: the off-loop task has to \
finish and deliver its resume before the loop's backstop sweep would \
force-resume the park, or the task's result is discarded"
);
pub(super) enum PendingRun {
Client {
id: EventId,
req: DelegateRequest<'static>,
origin_contract: Option<ContractInstanceId>,
connection_scope: ConnectionScope,
user_context: Option<UserSecretContext>,
},
Notification {
contract_id: ContractInstanceId,
req: DelegateRequest<'static>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Delivery {
Client,
Apps,
}
pub(super) struct Continuation {
pub iterations: usize,
pub self_heal_fetches_started: usize,
pub params: Parameters<'static>,
pub origin_contract: Option<ContractInstanceId>,
pub connection_scope: ConnectionScope,
pub user_context: Option<UserSecretContext>,
pub inter_delegate: super::InterDelegateDispatch,
pub accumulated: Vec<OutboundDelegateMsg>,
pub inbound_so_far: Vec<InboundDelegateMsg<'static>>,
pub responder: Option<StashedResponder>,
pub delivery: Delivery,
}
struct ParkEntry {
continuation: Continuation,
epoch: u64,
parked_at: tokio::time::Instant,
task_bytes: usize,
pending_clients: VecDeque<PendingRun>,
pending_notifications: HashMap<ContractInstanceId, DelegateRequest<'static>>,
notification_order: VecDeque<ContractInstanceId>,
pending_bytes: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ResumeCause {
Completed,
TimedOut,
}
pub(super) struct PendingUpsert {
pub key: ContractKey,
pub update: Either<WrappedState, StateDelta<'static>>,
pub related_contracts: RelatedContracts<'static>,
pub code: Option<ContractContainer>,
pub is_put: bool,
pub context: DelegateContext,
pub missing: Vec<ContractInstanceId>,
}
pub(super) struct ResolvedUpsert {
pub pending: PendingUpsert,
pub fetched: Result<Vec<(ContractInstanceId, WrappedState)>, ExecutorError>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(super) enum ContractOpKind {
Get,
Subscribe,
}
pub(super) struct PendingContractOp {
pub id: u64,
pub contract_id: ContractInstanceId,
pub kind: ContractOpKind,
pub context: DelegateContext,
}
impl PendingContractOp {
pub(super) fn next_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
}
pub(super) enum ContractOpOutcome {
Fetched(Option<WrappedState>),
Subscribed,
Failed(String),
}
pub(super) struct ResolvedContractOp {
pub pending: PendingContractOp,
pub outcome: ContractOpOutcome,
}
pub(super) struct DelegateResume {
pub delegate_key: DelegateKey,
pub epoch: u64,
pub cause: ResumeCause,
pub inbound: Vec<InboundDelegateMsg<'static>>,
pub upserts: Vec<ResolvedUpsert>,
pub unresolved_upserts: Vec<(ContractInstanceId, bool)>,
pub contract_ops: Vec<ResolvedContractOp>,
pub unresolved_contract_ops: Vec<(ContractInstanceId, ContractOpKind, DelegateContext)>,
}
pub(super) struct ParkGuard {
payload: Option<ParkGuardPayload>,
}
struct ParkGuardPayload {
resume_tx: tokio::sync::mpsc::UnboundedSender<DelegateResume>,
delegate_key: DelegateKey,
epoch: u64,
owed_prompts: Vec<u32>,
owed_upserts: Vec<(ContractInstanceId, bool)>,
answers: std::sync::Arc<std::sync::Mutex<Vec<InboundDelegateMsg<'static>>>>,
fetches: std::sync::Arc<std::sync::Mutex<Vec<ResolvedUpsert>>>,
owed_contract_ops: Vec<(u64, ContractInstanceId, ContractOpKind, DelegateContext)>,
contract_ops: std::sync::Arc<std::sync::Mutex<Vec<ResolvedContractOp>>>,
}
impl ParkGuard {
#[allow(clippy::too_many_arguments)]
pub(super) fn new(
resume_tx: tokio::sync::mpsc::UnboundedSender<DelegateResume>,
delegate_key: DelegateKey,
epoch: u64,
owed_prompts: Vec<u32>,
owed_upserts: Vec<(ContractInstanceId, bool)>,
answers: std::sync::Arc<std::sync::Mutex<Vec<InboundDelegateMsg<'static>>>>,
fetches: std::sync::Arc<std::sync::Mutex<Vec<ResolvedUpsert>>>,
owed_contract_ops: Vec<(u64, ContractInstanceId, ContractOpKind, DelegateContext)>,
contract_ops: std::sync::Arc<std::sync::Mutex<Vec<ResolvedContractOp>>>,
) -> Self {
Self {
payload: Some(ParkGuardPayload {
resume_tx,
delegate_key,
epoch,
owed_prompts,
owed_upserts,
answers,
fetches,
owed_contract_ops,
contract_ops,
}),
}
}
pub(super) fn send(mut self) {
if let Some(p) = self.payload.take() {
Self::deliver(p, ResumeCause::Completed);
}
}
fn deliver(p: ParkGuardPayload, cause: ResumeCause) {
let ParkGuardPayload {
resume_tx,
delegate_key,
epoch,
owed_prompts,
owed_upserts,
answers,
fetches,
owed_contract_ops,
contract_ops,
} = p;
let mut inbound = std::mem::take(&mut *answers.lock().unwrap_or_else(|e| e.into_inner()));
let upserts = std::mem::take(&mut *fetches.lock().unwrap_or_else(|e| e.into_inner()));
let contract_ops =
std::mem::take(&mut *contract_ops.lock().unwrap_or_else(|e| e.into_inner()));
let mut answered: HashMap<u32, usize> = HashMap::new();
for msg in &inbound {
if let InboundDelegateMsg::UserResponse(r) = msg {
*answered.entry(r.request_id).or_default() += 1;
}
}
for request_id in owed_prompts {
match answered.get_mut(&request_id) {
Some(n) if *n > 0 => *n -= 1,
_ => inbound.push(InboundDelegateMsg::UserResponse(
freenet_stdlib::prelude::UserInputResponse {
request_id,
response: freenet_stdlib::prelude::ClientResponse::new(Vec::new()),
context: DelegateContext::default(),
},
)),
}
}
let mut resolved: HashMap<(ContractInstanceId, bool), usize> = HashMap::new();
for r in &upserts {
*resolved
.entry((*r.pending.key.id(), r.pending.is_put))
.or_default() += 1;
}
let mut unresolved_upserts = Vec::new();
for (id, is_put) in owed_upserts {
match resolved.get_mut(&(id, is_put)) {
Some(n) if *n > 0 => *n -= 1,
_ => unresolved_upserts.push((id, is_put)),
}
}
if !unresolved_upserts.is_empty() {
tracing::warn!(
delegate = %delegate_key,
count = unresolved_upserts.len(),
"Off-loop delegate work ended without resolving every upsert; \
synthesizing failures so the delegate is told rather than left \
waiting (#5544)"
);
}
let resolved_ids: std::collections::HashSet<u64> =
contract_ops.iter().map(|r| r.pending.id).collect();
let mut unresolved_contract_ops = Vec::new();
for (op_id, id, kind, context) in owed_contract_ops {
if !resolved_ids.contains(&op_id) {
unresolved_contract_ops.push((id, kind, context));
}
}
if !unresolved_contract_ops.is_empty() {
tracing::warn!(
delegate = %delegate_key,
count = unresolved_contract_ops.len(),
"Off-loop delegate work ended without resolving every network \
contract operation; synthesizing failures so the delegate is \
told rather than left waiting (#5542)"
);
}
if resume_tx
.send(DelegateResume {
delegate_key: delegate_key.clone(),
epoch,
cause,
inbound,
upserts,
unresolved_upserts,
contract_ops,
unresolved_contract_ops,
})
.is_err()
{
tracing::debug!(
delegate = %delegate_key,
"Delegate resume channel closed; contract-handling loop gone"
);
}
}
}
impl Drop for ParkGuard {
fn drop(&mut self) {
if let Some(p) = self.payload.take() {
tracing::warn!(
delegate = %p.delegate_key,
"Off-loop delegate task dropped before sending — delivering an \
empty resume so the park terminates, its pending queue drains \
and the parked client is answered exactly once (#5544)"
);
Self::deliver(p, ResumeCause::TimedOut);
}
}
}
pub(super) enum ParkAdmission {
Admitted { epoch: u64 },
Refused(Box<Continuation>),
}
pub(super) enum QueueOutcome {
Queued,
Rejected(Box<PendingRun>),
}
pub(super) struct DelegateParkCtx {
parked: HashMap<DelegateKey, ParkEntry>,
parked_bytes: usize,
next_epoch: u64,
refused: RefusalCounts,
resume_tx: tokio::sync::mpsc::UnboundedSender<DelegateResume>,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct RefusalCounts {
pub parks: u64,
pub client_requests: u64,
pub notifications: u64,
}
impl DelegateParkCtx {
pub(super) fn new(resume_tx: tokio::sync::mpsc::UnboundedSender<DelegateResume>) -> Self {
Self {
parked: HashMap::new(),
parked_bytes: 0,
next_epoch: 0,
refused: RefusalCounts::default(),
resume_tx,
}
}
pub(super) fn resume_tx(&self) -> &tokio::sync::mpsc::UnboundedSender<DelegateResume> {
&self.resume_tx
}
pub(super) fn is_parked(&self, key: &DelegateKey) -> bool {
self.parked.contains_key(key)
}
#[cfg(test)]
pub(super) fn refusals(&self) -> RefusalCounts {
self.refused
}
#[cfg(test)]
pub(super) fn epoch_of(&self, key: &DelegateKey) -> Option<u64> {
self.parked.get(key).map(|e| e.epoch)
}
#[cfg(test)]
pub(super) fn parked_count(&self) -> usize {
self.parked.len()
}
pub(super) fn park(
&mut self,
key: DelegateKey,
continuation: Continuation,
task_bytes: usize,
) -> ParkAdmission {
let bytes = continuation_bytes(&continuation).saturating_add(task_bytes);
let over_bytes = self.parked_bytes.saturating_add(bytes) > MAX_PARKED_BYTES;
if self.parked.len() >= MAX_PARKED_DELEGATES || self.parked.contains_key(&key) || over_bytes
{
tracing::warn!(
delegate = %key,
parked = self.parked.len(),
limit = MAX_PARKED_DELEGATES,
parked_bytes = self.parked_bytes,
adding_bytes = bytes,
byte_limit = MAX_PARKED_BYTES,
over_bytes,
already_parked = self.parked.contains_key(&key),
total_refused_parks = self.refused.parks.saturating_add(1),
"Refusing to park delegate; falling back to the inline path"
);
self.refused.parks = self.refused.parks.saturating_add(1);
return ParkAdmission::Refused(Box::new(continuation));
}
self.parked_bytes = self.parked_bytes.saturating_add(bytes);
let epoch = self.next_epoch;
self.next_epoch = self.next_epoch.wrapping_add(1);
self.parked.insert(
key,
ParkEntry {
continuation,
epoch,
task_bytes,
parked_at: tokio::time::Instant::now(),
pending_clients: VecDeque::new(),
pending_notifications: HashMap::new(),
notification_order: VecDeque::new(),
pending_bytes: 0,
},
);
ParkAdmission::Admitted { epoch }
}
pub(super) fn queue_pending(&mut self, key: &DelegateKey, req: PendingRun) -> QueueOutcome {
let parked_bytes = self.parked_bytes;
let Some(entry) = self.parked.get_mut(key) else {
return QueueOutcome::Rejected(Box::new(req));
};
match req {
PendingRun::Notification { contract_id, req } => {
let bytes = request_bytes(&req);
let superseded = entry
.pending_notifications
.get(&contract_id)
.map_or(0, request_bytes);
let is_new_contract = !entry.pending_notifications.contains_key(&contract_id);
if is_new_contract
&& entry.pending_notifications.len() >= MAX_PENDING_NOTIFICATION_CONTRACTS
{
tracing::info!(
delegate = %key,
contract = %contract_id,
limit = MAX_PENDING_NOTIFICATION_CONTRACTS,
total_dropped = self.refused.notifications.saturating_add(1),
"Dropped a notification: too many distinct contracts already \
queued behind this park"
);
self.refused.notifications = self.refused.notifications.saturating_add(1);
return QueueOutcome::Rejected(Box::new(PendingRun::Notification {
contract_id,
req,
}));
}
let projected = parked_bytes
.saturating_add(bytes)
.saturating_sub(superseded);
if projected > MAX_PARKED_BYTES {
tracing::info!(
delegate = %key,
contract = %contract_id,
parked_bytes,
adding_bytes = bytes,
byte_limit = MAX_PARKED_BYTES,
total_dropped = self.refused.notifications.saturating_add(1),
"Dropped a notification: queueing it would exceed the parked \
byte budget"
);
self.refused.notifications = self.refused.notifications.saturating_add(1);
return QueueOutcome::Rejected(Box::new(PendingRun::Notification {
contract_id,
req,
}));
}
entry.pending_bytes = entry.pending_bytes.saturating_add(bytes);
if is_new_contract {
entry.notification_order.push_back(contract_id);
}
if let Some(old) = entry.pending_notifications.insert(contract_id, req) {
let freed = request_bytes(&old);
entry.pending_bytes = entry.pending_bytes.saturating_sub(freed);
self.parked_bytes = self.parked_bytes.saturating_sub(freed);
tracing::debug!(
delegate = %key,
contract = %contract_id,
"Coalesced a superseded notification behind a park"
);
}
self.parked_bytes = self.parked_bytes.saturating_add(bytes);
QueueOutcome::Queued
}
client => {
if entry.pending_clients.len() >= MAX_PENDING_PER_DELEGATE {
tracing::warn!(
delegate = %key,
queued = entry.pending_clients.len(),
limit = MAX_PENDING_PER_DELEGATE,
total_refused = self.refused.client_requests.saturating_add(1),
"Delegate pending queue full while parked — rejecting request"
);
self.refused.client_requests = self.refused.client_requests.saturating_add(1);
return QueueOutcome::Rejected(Box::new(client));
}
let bytes = match &client {
PendingRun::Client { req, .. } => request_bytes(req),
PendingRun::Notification { .. } => 0,
};
if parked_bytes.saturating_add(bytes) > MAX_PARKED_BYTES {
tracing::warn!(
delegate = %key,
parked_bytes,
adding_bytes = bytes,
byte_limit = MAX_PARKED_BYTES,
total_refused = self.refused.client_requests.saturating_add(1),
"Refusing to queue a delegate request: it would exceed the \
parked byte budget"
);
self.refused.client_requests = self.refused.client_requests.saturating_add(1);
return QueueOutcome::Rejected(Box::new(client));
}
entry.pending_bytes = entry.pending_bytes.saturating_add(bytes);
self.parked_bytes = self.parked_bytes.saturating_add(bytes);
entry.pending_clients.push_back(client);
QueueOutcome::Queued
}
}
}
pub(super) fn attach_responder(
&mut self,
key: &DelegateKey,
responder: Option<StashedResponder>,
) {
match self.parked.get_mut(key) {
Some(entry) => entry.continuation.responder = responder,
None => {
tracing::error!(
delegate = %key,
"attach_responder for a delegate that is not parked; the \
client for this run will not be answered"
);
}
}
}
pub(super) fn take_matching(
&mut self,
key: &DelegateKey,
epoch: u64,
) -> Option<(Continuation, VecDeque<PendingRun>)> {
match self.parked.get(key) {
Some(entry) if entry.epoch == epoch => {}
Some(entry) => {
tracing::warn!(
delegate = %key,
stale_epoch = epoch,
live_epoch = entry.epoch,
"Dropping a STALE park resume: this delegate re-parked after \
its previous park was force-resumed by the TTL backstop. \
Absorbing it would feed the old continuation's messages to \
the new park (#5544 H1)"
);
return None;
}
None => return None,
}
self.parked.remove(key).map(|entry| {
self.parked_bytes = self
.parked_bytes
.saturating_sub(continuation_bytes(&entry.continuation))
.saturating_sub(entry.task_bytes)
.saturating_sub(entry.pending_bytes);
let mut pending: VecDeque<PendingRun> = entry.pending_clients;
let mut notifications = entry.pending_notifications;
pending.extend(
entry
.notification_order
.into_iter()
.filter_map(|contract_id| {
notifications
.remove(&contract_id)
.map(|req| PendingRun::Notification { contract_id, req })
}),
);
debug_assert!(
notifications.is_empty(),
"every coalesced notification must have an arrival-order slot"
);
(entry.continuation, pending)
})
}
pub(super) fn next_sweep_deadline(&self) -> Option<tokio::time::Instant> {
self.parked
.values()
.map(|entry| entry.parked_at + PARK_TTL)
.min()
}
pub(super) fn expired(
&self,
now: tokio::time::Instant,
resume_rx: &mut tokio::sync::mpsc::UnboundedReceiver<DelegateResume>,
already_delivered: &mut VecDeque<DelegateResume>,
) -> Vec<(DelegateKey, u64)> {
absorb_delivered(resume_rx, already_delivered);
let mut out: Vec<(DelegateKey, u64)> = self
.parked
.iter()
.filter(|(_, entry)| now.duration_since(entry.parked_at) >= PARK_TTL)
.filter(|(key, entry)| !resume_in_hand(already_delivered, key, entry.epoch))
.map(|(key, entry)| (key.clone(), entry.epoch))
.collect();
out.sort_by_key(|(_, epoch)| *epoch);
out
}
pub(super) fn should_force_resume(
&self,
key: &DelegateKey,
epoch: u64,
resume_rx: &mut tokio::sync::mpsc::UnboundedReceiver<DelegateResume>,
already_delivered: &mut VecDeque<DelegateResume>,
) -> bool {
absorb_delivered(resume_rx, already_delivered);
!resume_in_hand(already_delivered, key, epoch)
}
}
fn absorb_delivered(
resume_rx: &mut tokio::sync::mpsc::UnboundedReceiver<DelegateResume>,
already_delivered: &mut VecDeque<DelegateResume>,
) {
while let Ok(resume) = resume_rx.try_recv() {
already_delivered.push_back(resume);
}
}
fn resume_in_hand(
already_delivered: &VecDeque<DelegateResume>,
key: &DelegateKey,
epoch: u64,
) -> bool {
already_delivered
.iter()
.any(|resume| resume.epoch == epoch && &resume.delegate_key == key)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(byte: u8) -> DelegateKey {
DelegateKey::new(
[byte; 32],
freenet_stdlib::prelude::CodeHash::new([byte; 32]),
)
}
fn continuation() -> Continuation {
Continuation {
self_heal_fetches_started: 0,
params: Parameters::from(Vec::new()),
origin_contract: None,
connection_scope: ConnectionScope::Local,
user_context: None,
inter_delegate: super::super::InterDelegateDispatch::Allowed,
accumulated: Vec::new(),
inbound_so_far: Vec::new(),
responder: None,
delivery: Delivery::Client,
iterations: 0,
}
}
fn pending(byte: u8) -> PendingRun {
PendingRun::Client {
id: EventId { id: byte as u64 },
req: DelegateRequest::ApplicationMessages {
key: key(byte),
params: Parameters::from(Vec::new()),
inbound: Vec::new(),
},
origin_contract: None,
connection_scope: ConnectionScope::Local,
user_context: None,
}
}
fn ctx() -> (
DelegateParkCtx,
tokio::sync::mpsc::UnboundedReceiver<DelegateResume>,
) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(DelegateParkCtx::new(tx), rx)
}
#[tokio::test]
async fn park_then_take_round_trips() {
let (mut ctx, _rx) = ctx();
let k = key(1);
assert!(!ctx.is_parked(&k));
assert!(matches!(
ctx.park(k.clone(), continuation(), 0),
ParkAdmission::Admitted { .. }
));
assert!(ctx.is_parked(&k));
let (_cont, pend) = ctx
.take_matching(&k, ctx.epoch_of(&k).expect("parked"))
.expect("park must be takeable");
assert!(pend.is_empty());
assert!(!ctx.is_parked(&k), "take must end the park");
}
#[tokio::test]
async fn node_wide_cap_refuses_and_hands_the_continuation_back() {
let (mut ctx, _rx) = ctx();
for i in 0..MAX_PARKED_DELEGATES {
assert!(matches!(
ctx.park(key(i as u8), continuation(), 0),
ParkAdmission::Admitted { .. }
));
}
assert_eq!(ctx.parked_count(), MAX_PARKED_DELEGATES);
assert!(matches!(
ctx.park(key(200), continuation(), 0),
ParkAdmission::Refused(_)
));
assert!(!ctx.is_parked(&key(200)));
}
#[tokio::test]
async fn double_park_is_refused_not_clobbered() {
let (mut ctx, _rx) = ctx();
let k = key(1);
assert!(matches!(
ctx.park(k.clone(), continuation(), 0),
ParkAdmission::Admitted { .. }
));
assert!(matches!(
ctx.park(k.clone(), continuation(), 0),
ParkAdmission::Refused(_)
));
assert!(
ctx.take_matching(&k, ctx.epoch_of(&k).expect("parked"))
.is_some()
);
}
#[tokio::test]
async fn pending_queue_is_capped_and_overflow_is_returned_not_dropped() {
let (mut ctx, _rx) = ctx();
let k = key(1);
ctx.park(k.clone(), continuation(), 0);
for i in 0..MAX_PENDING_PER_DELEGATE {
assert!(matches!(
ctx.queue_pending(&k, pending(i as u8)),
QueueOutcome::Queued
));
}
assert!(matches!(
ctx.queue_pending(&k, pending(99)),
QueueOutcome::Rejected(_)
));
let (_cont, pend) = ctx
.take_matching(&k, ctx.epoch_of(&k).expect("parked"))
.expect("park present");
assert_eq!(pend.len(), MAX_PENDING_PER_DELEGATE);
}
#[tokio::test]
async fn queueing_for_an_unparked_delegate_returns_the_request() {
let (mut ctx, _rx) = ctx();
assert!(matches!(
ctx.queue_pending(&key(1), pending(1)),
QueueOutcome::Rejected(_)
));
}
fn notification(contract: u8, state: &[u8]) -> PendingRun {
let contract_id = ContractInstanceId::new([contract; 32]);
PendingRun::Notification {
contract_id,
req: DelegateRequest::ApplicationMessages {
key: key(1),
params: Parameters::from(Vec::new()),
inbound: vec![InboundDelegateMsg::ContractNotification(
freenet_stdlib::prelude::ContractNotification {
contract_id,
new_state: WrappedState::new(state.to_vec()),
context: DelegateContext::default(),
},
)],
},
}
}
fn queued_state(run: &PendingRun) -> Option<Vec<u8>> {
let PendingRun::Notification { req, .. } = run else {
return None;
};
let DelegateRequest::ApplicationMessages { inbound, .. } = req else {
return None;
};
#[allow(clippy::wildcard_enum_match_arm)]
inbound.iter().find_map(|m| match m {
InboundDelegateMsg::ContractNotification(n) => Some(n.new_state.as_ref().to_vec()),
_ => None,
})
}
#[tokio::test]
async fn notifications_coalesce_per_contract_rather_than_being_rejected() {
let (mut ctx, _rx) = ctx();
let k = key(1);
ctx.park(k.clone(), continuation(), 0);
for i in 0..(MAX_PENDING_PER_DELEGATE as u8 + 12) {
assert!(
matches!(
ctx.queue_pending(&k, notification(7, &[i])),
QueueOutcome::Queued
),
"a notification must never be rejected for queue depth; \
superseded ones coalesce"
);
}
let (_cont, pending) = ctx
.take_matching(&k, ctx.epoch_of(&k).expect("parked"))
.expect("park present");
assert_eq!(
pending.len(),
1,
"notifications for one contract must collapse to a single pending run"
);
assert_eq!(
queued_state(&pending[0]),
Some(vec![MAX_PENDING_PER_DELEGATE as u8 + 11]),
"the NEWEST notification must win. Lossless only while the contract's \
state is ACCUMULATING, so the newest subsumes the superseded — see \
the precondition on `PendingRun::Notification`"
);
}
#[tokio::test]
async fn a_full_client_queue_does_not_reject_notifications() {
let (mut ctx, _rx) = ctx();
let k = key(1);
ctx.park(k.clone(), continuation(), 0);
for i in 0..MAX_PENDING_PER_DELEGATE {
assert!(matches!(
ctx.queue_pending(&k, pending(i as u8)),
QueueOutcome::Queued
));
}
assert!(
matches!(
ctx.queue_pending(&k, pending(99)),
QueueOutcome::Rejected(_)
),
"client requests still hit the cap — the caller can be told"
);
assert!(
matches!(
ctx.queue_pending(&k, notification(3, b"x")),
QueueOutcome::Queued
),
"a notification must still be accepted with the client queue full"
);
let (_cont, pending_runs) = ctx
.take_matching(&k, ctx.epoch_of(&k).expect("parked"))
.expect("park present");
assert_eq!(
pending_runs.len(),
MAX_PENDING_PER_DELEGATE + 1,
"clients plus the coalesced notification"
);
}
#[tokio::test]
async fn refusals_are_counted_per_cause() {
let (mut ctx, _rx) = ctx();
let k = key(1);
ctx.park(k.clone(), continuation(), 0);
for i in 0..MAX_PENDING_PER_DELEGATE {
ctx.queue_pending(&k, pending(i as u8));
}
ctx.queue_pending(&k, pending(99));
for i in 0..MAX_PENDING_NOTIFICATION_CONTRACTS {
ctx.queue_pending(&k, notification(i as u8, b"s"));
}
ctx.queue_pending(&k, notification(250, b"s"));
let counts = ctx.refusals();
assert_eq!(counts.client_requests, 1, "the over-cap client request");
assert_eq!(
counts.notifications, 1,
"the over-cap notification contract"
);
assert_eq!(counts.parks, 0, "no park was refused here");
}
#[tokio::test]
async fn distinct_notification_contracts_are_capped() {
let (mut ctx, _rx) = ctx();
let k = key(1);
ctx.park(k.clone(), continuation(), 0);
for i in 0..MAX_PENDING_NOTIFICATION_CONTRACTS {
assert!(matches!(
ctx.queue_pending(&k, notification(i as u8, b"s")),
QueueOutcome::Queued
));
}
assert!(
matches!(
ctx.queue_pending(&k, notification(250, b"s")),
QueueOutcome::Rejected(_)
),
"a NEW contract past the cap is refused; the delegate will see that \
contract's next state change"
);
assert!(matches!(
ctx.queue_pending(&k, notification(0, b"newer")),
QueueOutcome::Queued
));
}
#[tokio::test(start_paused = true)]
async fn park_expires_only_after_the_ttl() {
let (mut ctx, mut rx) = ctx();
let mut buffered = VecDeque::new();
let k = key(1);
ctx.park(k.clone(), continuation(), 0);
tokio::time::advance(PARK_TTL - Duration::from_secs(1)).await;
assert!(
ctx.expired(tokio::time::Instant::now(), &mut rx, &mut buffered)
.is_empty(),
"must not expire early — a park cut short would report a spurious \
failure for work that was about to succeed"
);
tokio::time::advance(Duration::from_secs(2)).await;
assert_eq!(
ctx.expired(tokio::time::Instant::now(), &mut rx, &mut buffered),
vec![(k.clone(), ctx.epoch_of(&k).expect("parked"))]
);
}
#[tokio::test(start_paused = true)]
async fn the_backstop_leaves_a_park_whose_answer_is_already_in_hand() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut ctx = DelegateParkCtx::new(tx.clone());
let k = key(1);
let ParkAdmission::Admitted { epoch } = ctx.park(k.clone(), continuation(), 0) else {
panic!("park must be admitted");
};
let (answers, fetches) = sinks();
answers.lock().unwrap().push(answer(1));
drop(ParkGuard::new(
tx,
k.clone(),
epoch,
vec![1],
Vec::new(),
answers,
fetches,
Vec::new(),
Default::default(),
));
let mut buffered: VecDeque<DelegateResume> = VecDeque::new();
while let Ok(resume) = rx.try_recv() {
buffered.push_back(resume);
}
assert_eq!(buffered.len(), 1, "the guard must have delivered a resume");
tokio::time::advance(PARK_TTL + Duration::from_secs(1)).await;
assert!(
ctx.expired(tokio::time::Instant::now(), &mut rx, &mut buffered)
.is_empty(),
"a park whose resume is already buffered is QUEUED, not wedged; \
force-resuming it discards the answer that resume is carrying \
(#5554)"
);
let InboundDelegateMsg::UserResponse(response) = buffered[0]
.inbound
.iter()
.find(|m| matches!(m, InboundDelegateMsg::UserResponse(r) if r.request_id == 1))
.expect("the buffered resume must carry the answer for request 1")
else {
unreachable!()
};
assert_eq!(
&response.response[..],
b"allow".as_slice(),
"this is the answer the sweep would have thrown away"
);
let mut nothing_in_hand = VecDeque::new();
let (_unused_tx, mut empty_rx) = tokio::sync::mpsc::unbounded_channel();
assert_eq!(
ctx.expired(
tokio::time::Instant::now(),
&mut empty_rx,
&mut nothing_in_hand
),
vec![(k.clone(), epoch)],
"the park really is past PARK_TTL"
);
}
#[tokio::test(start_paused = true)]
async fn a_resume_arriving_after_the_snapshot_still_stops_the_sweep() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut ctx = DelegateParkCtx::new(tx.clone());
let k = key(1);
let ParkAdmission::Admitted { epoch } = ctx.park(k.clone(), continuation(), 0) else {
panic!("park must be admitted");
};
let mut buffered: VecDeque<DelegateResume> = VecDeque::new();
while let Ok(resume) = rx.try_recv() {
buffered.push_back(resume);
}
assert!(
buffered.is_empty(),
"the snapshot must be taken BEFORE the guard fires, or this test \
is the buffered case again rather than the racing one"
);
let (answers, fetches) = sinks();
answers.lock().unwrap().push(answer(1));
drop(ParkGuard::new(
tx,
k.clone(),
epoch,
vec![1],
Vec::new(),
answers,
fetches,
Vec::new(),
Default::default(),
));
tokio::time::advance(PARK_TTL + Duration::from_secs(1)).await;
assert!(
ctx.expired(tokio::time::Instant::now(), &mut rx, &mut buffered)
.is_empty(),
"the answer arrived after the snapshot but BEFORE the sweep; \
force-resuming now discards the human's response, which is the \
whole defect (#5554)"
);
assert_eq!(
answered_ids(&buffered[0]),
vec![1],
"and the resume it declined to sweep is the one carrying the answer"
);
}
#[tokio::test(start_paused = true)]
async fn a_resume_arriving_during_an_earlier_force_resume_cancels_the_next() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut ctx = DelegateParkCtx::new(tx.clone());
let (first, second) = (key(1), key(2));
let ParkAdmission::Admitted { epoch: e1 } = ctx.park(first.clone(), continuation(), 0)
else {
panic!("park must be admitted");
};
let ParkAdmission::Admitted { epoch: e2 } = ctx.park(second.clone(), continuation(), 0)
else {
panic!("park must be admitted");
};
let mut buffered: VecDeque<DelegateResume> = VecDeque::new();
tokio::time::advance(PARK_TTL + Duration::from_secs(1)).await;
let victims = ctx.expired(tokio::time::Instant::now(), &mut rx, &mut buffered);
assert_eq!(
victims,
vec![(first.clone(), e1), (second.clone(), e2)],
"both parks are past the TTL with nothing in hand"
);
let (answers, fetches) = sinks();
answers.lock().unwrap().push(answer(9));
drop(ParkGuard::new(
tx,
second.clone(),
e2,
vec![9],
Vec::new(),
answers,
fetches,
Vec::new(),
Default::default(),
));
assert!(
!ctx.should_force_resume(&second, e2, &mut rx, &mut buffered),
"the second victim's answer landed while the first was being \
force-resumed; sweeping it now throws that answer away (#5554)"
);
assert!(
ctx.should_force_resume(&first, e1, &mut rx, &mut buffered),
"the first victim produced nothing, so it is still genuinely \
wedged and the backstop must still fire for it — otherwise this \
check would disarm the backstop rather than target it"
);
}
#[tokio::test(start_paused = true)]
async fn a_stale_buffered_resume_does_not_shield_the_current_park() {
let (tx, mut _rx) = tokio::sync::mpsc::unbounded_channel();
let mut ctx = DelegateParkCtx::new(tx);
let k = key(1);
let ParkAdmission::Admitted { epoch: first } = ctx.park(k.clone(), continuation(), 0)
else {
panic!("first park must be admitted");
};
assert!(ctx.take_matching(&k, first).is_some());
let ParkAdmission::Admitted { epoch: second } = ctx.park(k.clone(), continuation(), 0)
else {
panic!("second park must be admitted");
};
let mut buffered: VecDeque<DelegateResume> = VecDeque::new();
buffered.push_back(DelegateResume {
delegate_key: k.clone(),
epoch: first,
cause: ResumeCause::Completed,
inbound: vec![answer(1)],
upserts: Vec::new(),
unresolved_upserts: Vec::new(),
contract_ops: Vec::new(),
unresolved_contract_ops: Vec::new(),
});
tokio::time::advance(PARK_TTL + Duration::from_secs(1)).await;
assert_eq!(
ctx.expired(tokio::time::Instant::now(), &mut _rx, &mut buffered),
vec![(k.clone(), second)],
"a resume for the PREVIOUS park says nothing about this one; the \
backstop must still fire"
);
}
#[tokio::test]
async fn a_stale_resume_from_a_force_resumed_park_is_rejected() {
let (mut ctx, _rx) = ctx();
let k = key(1);
let ParkAdmission::Admitted { epoch: first } = ctx.park(k.clone(), continuation(), 0)
else {
panic!("first park must be admitted");
};
assert!(
ctx.take_matching(&k, first).is_some(),
"the sweep ends the park it observed"
);
let ParkAdmission::Admitted { epoch: second } = ctx.park(k.clone(), continuation(), 0)
else {
panic!("second park must be admitted");
};
assert_ne!(first, second, "each park must have its own identity");
assert!(
ctx.take_matching(&k, first).is_none(),
"a stale resume must be rejected; absorbing it would feed park #1's \
messages into park #2 (#5544 H1)"
);
assert_eq!(
ctx.epoch_of(&k),
Some(second),
"the live park must survive the stale resume untouched"
);
}
#[tokio::test]
async fn message_contexts_are_charged_not_just_payloads() {
let big_ctx = DelegateContext::new(vec![0u8; 200 * 1024]);
let tiny_payload = InboundDelegateMsg::ApplicationMessage(
freenet_stdlib::prelude::ApplicationMessage::new(vec![1u8; 8])
.with_context(big_ctx.clone()),
);
let mut cont = continuation();
cont.inbound_so_far = vec![tiny_payload];
assert!(
continuation_bytes(&cont) >= 200 * 1024,
"a message's context must be charged; payload was 8 bytes and the \
context 200 KiB, and only the context makes this a real cost"
);
}
#[tokio::test]
async fn coalesced_notifications_drain_in_arrival_order() {
let (mut ctx, _rx) = ctx();
let k = key(1);
ctx.park(k.clone(), continuation(), 0);
let arrival: Vec<u8> = (0..8).collect();
for c in &arrival {
ctx.queue_pending(&k, notification(*c, b"first"));
}
ctx.queue_pending(&k, notification(3, b"second"));
let epoch = ctx.epoch_of(&k).expect("parked");
let (_cont, pending) = ctx.take_matching(&k, epoch).expect("parked");
let drained: Vec<u8> = pending
.iter()
.filter_map(|run| match run {
PendingRun::Notification { contract_id, .. } => Some(contract_id.as_bytes()[0]),
PendingRun::Client { .. } => None,
})
.collect();
assert_eq!(
drained, arrival,
"notifications must drain in arrival order, and a superseded one \
must keep its original position"
);
}
#[tokio::test]
async fn every_context_carrying_variant_is_charged() {
const N: usize = 64 * 1024;
let ctx = DelegateContext::new(vec![0u8; N]);
let cid = ContractInstanceId::new([1; 32]);
let inbound: Vec<(&str, InboundDelegateMsg<'static>)> = vec![
(
"ApplicationMessage",
InboundDelegateMsg::ApplicationMessage(
freenet_stdlib::prelude::ApplicationMessage::new(Vec::new())
.with_context(ctx.clone()),
),
),
(
"UserResponse",
InboundDelegateMsg::UserResponse(freenet_stdlib::prelude::UserInputResponse {
request_id: 1,
response: freenet_stdlib::prelude::ClientResponse::new(Vec::new()),
context: ctx.clone(),
}),
),
(
"GetContractResponse",
InboundDelegateMsg::GetContractResponse(
freenet_stdlib::prelude::GetContractResponse {
contract_id: cid,
state: None,
context: ctx.clone(),
},
),
),
(
"ContractNotification",
InboundDelegateMsg::ContractNotification(
freenet_stdlib::prelude::ContractNotification {
contract_id: cid,
new_state: WrappedState::new(Vec::new()),
context: ctx.clone(),
},
),
),
];
for (name, msg) in inbound {
let mut cont = continuation();
cont.inbound_so_far = vec![msg];
assert!(
continuation_bytes(&cont) >= N,
"{name}: its context must be charged; payload was empty, so only \
the context makes this a real cost"
);
}
let mut cont = continuation();
cont.accumulated = vec![OutboundDelegateMsg::ContextUpdated(ctx.clone())];
assert!(
continuation_bytes(&cont) >= N,
"ContextUpdated's payload IS a context and must be charged"
);
let mut cont = continuation();
cont.accumulated = vec![OutboundDelegateMsg::ApplicationMessage(
freenet_stdlib::prelude::ApplicationMessage::new(Vec::new()).with_context(ctx),
)];
assert!(
continuation_bytes(&cont) >= N,
"an outbound ApplicationMessage's context must be charged"
);
}
#[tokio::test]
async fn the_byte_cap_charges_retained_payloads_not_just_the_continuation() {
let (mut ctx, _rx) = ctx();
let big = vec![0u8; 8 * 1024 * 1024];
let mut cont = continuation();
cont.inbound_so_far = vec![InboundDelegateMsg::ContractNotification(
freenet_stdlib::prelude::ContractNotification {
contract_id: ContractInstanceId::new([1; 32]),
new_state: WrappedState::new(big.clone()),
context: DelegateContext::default(),
},
)];
assert!(
continuation_bytes(&cont) >= big.len(),
"the continuation's inbound state must be charged"
);
let mut with_params = continuation();
with_params.params = Parameters::from(vec![7u8; 4096]);
assert!(
continuation_bytes(&with_params) >= 4096,
"`params` must be charged: it is retained for the life of the park"
);
let per_park = MAX_PARKED_BYTES / 4;
let mut admitted = 0usize;
for i in 0..MAX_PARKED_DELEGATES {
match ctx.park(key(i as u8), continuation(), per_park) {
ParkAdmission::Admitted { .. } => admitted += 1,
ParkAdmission::Refused(_) => break,
}
}
assert!(
admitted <= 4,
"the byte cap must refuse once the RETAINED total is reached; \
admitted {admitted} parks of {per_park} bytes each against a \
{MAX_PARKED_BYTES} byte budget"
);
}
type Sinks = (
std::sync::Arc<std::sync::Mutex<Vec<InboundDelegateMsg<'static>>>>,
std::sync::Arc<std::sync::Mutex<Vec<ResolvedUpsert>>>,
);
fn sinks() -> Sinks {
(Default::default(), Default::default())
}
fn answer(request_id: u32) -> InboundDelegateMsg<'static> {
InboundDelegateMsg::UserResponse(freenet_stdlib::prelude::UserInputResponse {
request_id,
response: freenet_stdlib::prelude::ClientResponse::new(b"allow".to_vec()),
context: DelegateContext::default(),
})
}
#[allow(clippy::wildcard_enum_match_arm)]
fn answered_ids(resume: &DelegateResume) -> Vec<u32> {
resume
.inbound
.iter()
.filter_map(|m| match m {
InboundDelegateMsg::UserResponse(r) => Some(r.request_id),
_ => None,
})
.collect()
}
fn net_op(contract: u8, kind: ContractOpKind) -> PendingContractOp {
net_op_with(PendingContractOp::next_id(), contract, kind, Vec::new())
}
fn net_op_with(
id: u64,
contract: u8,
kind: ContractOpKind,
context: Vec<u8>,
) -> PendingContractOp {
PendingContractOp {
id,
contract_id: ContractInstanceId::new([contract; 32]),
kind,
context: DelegateContext::new(context),
}
}
#[tokio::test]
async fn drop_reports_every_owed_network_op_as_unresolved() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (answers, fetches) = sinks();
drop(ParkGuard::new(
tx,
key(1),
0,
Vec::new(),
Vec::new(),
answers,
fetches,
vec![
(
1,
ContractInstanceId::new([9; 32]),
ContractOpKind::Get,
DelegateContext::new(b"ctx-get".to_vec()),
),
(
2,
ContractInstanceId::new([8; 32]),
ContractOpKind::Subscribe,
DelegateContext::new(b"ctx-sub".to_vec()),
),
],
Default::default(),
));
let resume = rx.recv().await.expect("drop must still resume the park");
assert_eq!(resume.cause, ResumeCause::TimedOut);
assert_eq!(
resume.unresolved_contract_ops,
vec![
(
ContractInstanceId::new([9; 32]),
ContractOpKind::Get,
DelegateContext::new(b"ctx-get".to_vec()),
),
(
ContractInstanceId::new([8; 32]),
ContractOpKind::Subscribe,
DelegateContext::new(b"ctx-sub".to_vec()),
),
],
"every owed network operation must be reported unresolved, WITH the \
delegate's own context: a synthesized failure carrying \
`DelegateContext::default()` reads to a delegate state machine as \
\"start over\" rather than \"this operation failed\" (#5542 F7)"
);
}
#[tokio::test]
async fn network_ops_are_reconciled_by_count_not_by_set() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (answers, fetches) = sinks();
let net_ops: std::sync::Arc<std::sync::Mutex<Vec<ResolvedContractOp>>> = Default::default();
net_ops.lock().unwrap().push(ResolvedContractOp {
pending: net_op_with(12, 9, ContractOpKind::Get, b"second".to_vec()),
outcome: ContractOpOutcome::Fetched(None),
});
drop(ParkGuard::new(
tx,
key(1),
0,
Vec::new(),
Vec::new(),
answers,
fetches,
vec![
(
11,
ContractInstanceId::new([9; 32]),
ContractOpKind::Get,
DelegateContext::new(b"first".to_vec()),
),
(
12,
ContractInstanceId::new([9; 32]),
ContractOpKind::Get,
DelegateContext::new(b"second".to_vec()),
),
],
net_ops,
));
let resume = rx.recv().await.expect("resume");
assert_eq!(
resume.contract_ops.len(),
1,
"the completed operation must survive the drop path"
);
assert_eq!(
resume.unresolved_contract_ops,
vec![(
ContractInstanceId::new([9; 32]),
ContractOpKind::Get,
DelegateContext::new(b"first".to_vec())
)],
"one completion must discharge exactly ONE of two obligations that \
look identical under `(contract, kind)` — and it must discharge \
THE ONE THAT COMPLETED. Reconciling by count consumed the FIRST \
owed entry while the real response and the synthesized failure \
both carried the SECOND's context: one request answered twice, one \
never (#5542, Codex P2)"
);
}
#[tokio::test]
async fn a_get_and_a_subscribe_for_one_contract_are_two_obligations() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (answers, fetches) = sinks();
let net_ops: std::sync::Arc<std::sync::Mutex<Vec<ResolvedContractOp>>> = Default::default();
net_ops.lock().unwrap().push(ResolvedContractOp {
pending: net_op_with(21, 9, ContractOpKind::Get, Vec::new()),
outcome: ContractOpOutcome::Fetched(None),
});
drop(ParkGuard::new(
tx,
key(1),
0,
Vec::new(),
Vec::new(),
answers,
fetches,
vec![
(
21,
ContractInstanceId::new([9; 32]),
ContractOpKind::Get,
DelegateContext::default(),
),
(
22,
ContractInstanceId::new([9; 32]),
ContractOpKind::Subscribe,
DelegateContext::default(),
),
],
net_ops,
));
let resume = rx.recv().await.expect("resume");
assert_eq!(
resume.unresolved_contract_ops,
vec![(
ContractInstanceId::new([9; 32]),
ContractOpKind::Subscribe,
DelegateContext::default()
)],
"the SUBSCRIBE must still be owed after only the GET completed"
);
}
#[tokio::test]
async fn pending_network_ops_are_charged_for_their_context() {
const N: usize = 32 * 1024;
let mut op = net_op(9, ContractOpKind::Get);
op.context = DelegateContext::new(vec![0u8; N]);
let charged = task_bytes(&[], &[], std::slice::from_ref(&op));
assert!(
charged >= 2 * N,
"a pending network op retains its context TWICE — once in the \
off-loop task's `PendingContractOp` and once in the `ParkGuard`'s \
`owed_contract_ops`, which is what lets a synthesized failure hand \
the delegate back its own continuation state (#5542 F7) — so both \
copies must be charged; got {charged} for a {N}-byte context"
);
}
#[tokio::test]
async fn guard_delivers_exactly_one_resume_on_success() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (answers, fetches) = sinks();
let guard = ParkGuard::new(
tx,
key(1),
0,
Vec::new(),
Vec::new(),
answers,
fetches,
Vec::new(),
Default::default(),
);
guard.send();
let resume = rx.recv().await.expect("one resume");
assert_eq!(resume.cause, ResumeCause::Completed);
assert!(rx.try_recv().is_err(), "must not deliver twice");
}
#[tokio::test]
async fn drop_synthesizes_denials_for_everything_it_owes() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (answers, fetches) = sinks();
drop(ParkGuard::new(
tx,
key(1),
0,
vec![1, 2],
vec![(ContractInstanceId::new([3; 32]), true)],
answers,
fetches,
Vec::new(),
Default::default(),
));
let resume = rx.recv().await.expect("drop must still resume the park");
assert_eq!(resume.cause, ResumeCause::TimedOut);
assert_eq!(
answered_ids(&resume),
vec![1, 2],
"every owed prompt must get a synthesized response, or the delegate \
waits forever for one nothing remains to produce"
);
assert_eq!(
resume.unresolved_upserts,
vec![(ContractInstanceId::new([3; 32]), true)],
"every owed upsert must be reported unresolved"
);
}
#[tokio::test]
async fn drop_keeps_answers_already_given_rather_than_denying_them() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (answers, fetches) = sinks();
answers.lock().unwrap().push(answer(1)); drop(ParkGuard::new(
tx,
key(1),
0,
vec![1, 2],
Vec::new(),
answers,
fetches,
Vec::new(),
Default::default(),
));
let resume = rx.recv().await.expect("resume");
let kept: Vec<&InboundDelegateMsg<'static>> = resume
.inbound
.iter()
.filter(|m| matches!(m, InboundDelegateMsg::UserResponse(r) if r.request_id == 1))
.collect();
assert_eq!(kept.len(), 1, "exactly one response for request 1");
let InboundDelegateMsg::UserResponse(r) = kept[0] else {
unreachable!()
};
assert_eq!(
&r.response[..],
b"allow".as_slice(),
"the answer the human gave must survive, not be replaced by a denial"
);
assert_eq!(
answered_ids(&resume),
vec![1, 2],
"the unanswered one is still synthesized"
);
}
#[tokio::test]
async fn reconciliation_counts_duplicates_rather_than_matching_by_membership() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (answers, fetches) = sinks();
answers.lock().unwrap().push(answer(7)); let contract = ContractInstanceId::new([9; 32]);
drop(ParkGuard::new(
tx,
key(1),
0,
vec![7, 7],
vec![(contract, true), (contract, true)],
answers,
fetches,
Vec::new(),
Default::default(),
));
let resume = rx.recv().await.expect("resume");
assert_eq!(
answered_ids(&resume),
vec![7, 7],
"two owed prompts with the SAME id need two responses; matching by \
membership would have cancelled both obligations with one answer"
);
assert_eq!(
resume.unresolved_upserts.len(),
2,
"two owed upserts on one contract need two outcomes"
);
}
}