diff --git a/crates/before/README.md b/crates/before/README.md index 85c1f9969..5efc42a73 100644 --- a/crates/before/README.md +++ b/crates/before/README.md @@ -168,7 +168,7 @@ To use `Clock` in this manner: Unlike `absorb` (and its operator forms `|` and `|=`), `recv` increments the clock's inner `Version` after absorbing the received one. -Consistently using `send` and [`recv`](Clock::recv`) in this +Consistently using `send` and `recv` in this manner ensures that the `Clock` tracks *process causality*, by following the ordering of sends and receives. diff --git a/src/tree/mirror/streaming/materialized.rs b/src/tree/mirror/streaming/materialized.rs index 12574a129..931491c37 100644 --- a/src/tree/mirror/streaming/materialized.rs +++ b/src/tree/mirror/streaming/materialized.rs @@ -199,33 +199,51 @@ fn violation(violation: Violation) -> Result> { /// An honest replica supplies each leaf at most once (disputed scopes /// partition the tree) and only leaves its own set holds, so the running /// total of absorbed live leaves is bounded by the `set_len` its greeting -/// declared — a premise the session's window solve priced. Every -/// ingestion site charges the ledger exactly where it checks version -/// containment: the two declared premises are enforced side by side. +/// declared — a premise the session's window solve priced. Two +/// instruments enforce it, each holding its own ledger over the same +/// declaration: the walk charges each supply exactly where it checks +/// version containment, so the two declared premises are enforced side by +/// side; the wire decoder charges each supplied record at ingress, before +/// its payload takes backend custody, so the bound holds at every instant +/// of a still-open reply. #[derive(Clone, Debug)] pub(crate) struct SupplyLedger { /// The sender's greeting-declared set length. declared: u64, - /// Live leaves absorbed so far, across every ingestion site. + /// Live leaves absorbed so far, across every ingestion site sharing + /// this ledger. absorbed: Arc, } impl SupplyLedger { - fn new(declared: u64) -> Self { + pub(crate) fn new(declared: u64) -> Self { SupplyLedger { declared, absorbed: Arc::default(), } } - /// Charge `leaves` absorbed supplies, failing the session at the first - /// leaf past the declaration ([`Violation::OverdrawnSupply`]). - pub(crate) fn absorb(&self, leaves: u64) -> Result<(), Error> { + /// Charge `leaves` absorbed supplies against the declaration. + /// + /// Errors with the declared length at the first leaf past it; each + /// enforcement point renders the overdraw in its own typed vocabulary + /// (the walk's [`Violation::OverdrawnSupply`], the wire decoder's + /// ingress rejection). + pub(crate) fn charge(&self, leaves: u64) -> Result<(), u64> { let prior = self.absorbed.fetch_add(leaves, Ordering::Relaxed); match prior.checked_add(leaves) { Some(total) if total <= self.declared => Ok(()), // A wrapped counter is past any declarable length too. - _ => violation(Violation::OverdrawnSupply), + _ => Err(self.declared), + } + } + + /// Charge `leaves` absorbed supplies, failing the session at the first + /// leaf past the declaration ([`Violation::OverdrawnSupply`]). + pub(crate) fn absorb(&self, leaves: u64) -> Result<(), Error> { + match self.charge(leaves) { + Ok(()) => Ok(()), + Err(_) => violation(Violation::OverdrawnSupply), } } } diff --git a/src/tree/mirror/streaming/remote/adapter/decode.rs b/src/tree/mirror/streaming/remote/adapter/decode.rs index 824a2d844..bae4e7192 100644 --- a/src/tree/mirror/streaming/remote/adapter/decode.rs +++ b/src/tree/mirror/streaming/remote/adapter/decode.rs @@ -11,6 +11,7 @@ use crate::tree::{ Backend, Leaf, backend::BoxNodeStream, convert::Convert, + materialized::SupplyLedger, message::{Reaction as ProtocolReaction, Reply}, window::FAN, }, @@ -72,10 +73,13 @@ where /// /// `version_bytes` is the peer's greeting-declared `max_version_bytes`: /// a supplied version encoding over it is a -/// [`DecodeError::OversizedVersion`] session violation. +/// [`DecodeError::OversizedVersion`] session violation. `ledger` is the +/// session's declared-`set_len` allowance, charged per record before the +/// payload takes custody ([`DecodeError::OverdrawnSupply`]). pub fn early_supplies( backend: B, version_bytes: u64, + ledger: SupplyLedger, parent: Prefix>, frames: F, ) -> impl Stream), DecodeError>> + Send @@ -95,7 +99,13 @@ where let leaves = leaves.inspect(|_| fan_probe::on_recv()); let leaves: BoxNodeStream<'static, B, T, Z> = Box::pin(leaves); let mut assembled = pin!(backend.clone().assemble::(leaves)); - let mut read = pin!(read_early::(version_bytes, parent, frames, tx)); + let mut read = pin!(read_early::( + version_bytes, + &ledger, + parent, + frames, + tx + )); let mut read_result: Option>> = None; loop { let step = futures::future::poll_fn(|cx| { @@ -133,6 +143,7 @@ where /// nothing after it — streaming its leaves to assembly. async fn read_early( version_bytes: u64, + ledger: &SupplyLedger, parent: Prefix>, mut frames: F, leaves: mpsc::Sender, B::Node), B::Error>>, @@ -157,6 +168,14 @@ where let (version, message) = record.map_err(DecodeError::Record)?; let (leaf_prefix, _) = supplies.observe::(parent, &version, &message)?; + // The set-length half of the greeting's priced + // premises, charged per record before the payload + // takes backend custody: a peer supplying past its + // declaration fails at the offending record, while + // the reply is still open. + ledger + .charge(1) + .map_err(|declared| DecodeError::OverdrawnSupply { declared })?; let leaf = as Leaf>::leaf(version, message) .await .map_err(DecodeError::Backend)?; @@ -195,6 +214,7 @@ where pub async fn decode_reply( backend: B, version_bytes: u64, + ledger: SupplyLedger, scope: Scope>, frames: &mut F, ) -> Result, Vec>>, DecodeError> @@ -206,10 +226,17 @@ where S>: Height, F: Stream> + Unpin, { - decode(backend, version_bytes, scope, frames, |scope, listing| { - let (_, prefix) = scope.next().ok_or(ScopeError::UnpositionedQuery)?; - Ok(Scope::new(prefix, listing)) - }) + decode( + backend, + version_bytes, + ledger, + scope, + frames, + |scope, listing| { + let (_, prefix) = scope.next().ok_or(ScopeError::UnpositionedQuery)?; + Ok(Scope::new(prefix, listing)) + }, + ) .await } @@ -217,6 +244,7 @@ where pub async fn decode_leaf_reply( backend: B, version_bytes: u64, + ledger: SupplyLedger, scope: Scope, frames: &mut F, ) -> Result>>, DecodeError> @@ -225,19 +253,27 @@ where T: borsh::BorshDeserialize + Send + Sync + 'static, F: Stream> + Unpin, { - decode(backend, version_bytes, scope, frames, |scope, listing| { - if !listing.is_empty() { - return Err(ScopeError::NonemptyLeafQuery); - } - let (_, prefix) = scope.next().ok_or(ScopeError::UnpositionedQuery)?; - Ok(Scope::leaf(prefix)) - }) + decode( + backend, + version_bytes, + ledger, + scope, + frames, + |scope, listing| { + if !listing.is_empty() { + return Err(ScopeError::NonemptyLeafQuery); + } + let (_, prefix) = scope.next().ok_or(ScopeError::UnpositionedQuery)?; + Ok(Scope::leaf(prefix)) + }, + ) .await } async fn decode( backend: B, version_bytes: u64, + ledger: SupplyLedger, scope: Scope, frames: &mut F, question: Q, @@ -261,7 +297,7 @@ where // reader's hand per reply stream, at `node_bytes(0, version_bound)` // plus the slot itself (the window's supply-decode envelope). let (tx, rx) = mpsc::channel::, B::Node), B::Error>>(FAN); - let read = read_reply::(version_bytes, scope, frames, question, tx); + let read = read_reply::(version_bytes, &ledger, scope, frames, question, tx); let assemble = assemble_supplies::(backend, rx); let (read, assembled) = futures::future::join(read, assemble).await; let Some(ReadReply { @@ -280,6 +316,7 @@ where /// Read and validate exactly one reply while streaming its leaves to assembly. async fn read_reply( version_bytes: u64, + ledger: &SupplyLedger, mut scope: Scope, frames: &mut F, mut question: Q, @@ -340,6 +377,14 @@ where if let Some((radix, prefix)) = run { read.skeleton.push(Skeleton::Supply { radix, prefix }); } + // The set-length half of the greeting's priced + // premises, charged per record before the payload + // takes backend custody: a peer supplying past its + // declaration fails at the offending record, while + // the reply is still open. + ledger + .charge(1) + .map_err(|declared| DecodeError::OverdrawnSupply { declared })?; let leaf = as Leaf>::leaf(version, message) .await .map_err(DecodeError::Backend)?; diff --git a/src/tree/mirror/streaming/remote/adapter/error.rs b/src/tree/mirror/streaming/remote/adapter/error.rs index fc8368035..b24c5d2e8 100644 --- a/src/tree/mirror/streaming/remote/adapter/error.rs +++ b/src/tree/mirror/streaming/remote/adapter/error.rs @@ -95,6 +95,20 @@ pub enum DecodeError { "supplied version encodes {actual} bytes, over the peer's declared {declared}-byte bound" )] OversizedVersion { declared: u64, actual: usize }, + /// A supplied leaf record past the peer's declared `set_len`. + /// + /// An honest peer supplies each leaf at most once and only leaves its + /// own set holds, so its greeting-declared set length bounds the + /// session's total supplied records; the local window solve priced + /// absorbed-supply volume from that declaration. The charge lands at + /// ingress, before the record's payload takes backend custody, so a + /// peer supplying past its declaration fails the session at the + /// offending record while its reply is still open, never after the + /// reply materializes. The in-process walk enforces the same premise + /// at absorption as + /// [`Violation::OverdrawnSupply`](crate::error::MaterializedViolation::OverdrawnSupply). + #[error("supplied leaf overruns the peer's declared set length of {declared}")] + OverdrawnSupply { declared: u64 }, /// A positional wire reaction cannot be scoped without another child. #[error(transparent)] Scope(#[from] ScopeError), diff --git a/src/tree/mirror/streaming/remote/adapter/tests.rs b/src/tree/mirror/streaming/remote/adapter/tests.rs index ab010fa51..2dc9aceab 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests.rs @@ -15,7 +15,7 @@ use before::Version; use crate::{ message::Message, tree::{ - mirror::streaming::remote::codec::LeafRun, + mirror::streaming::{materialized::SupplyLedger, remote::codec::LeafRun}, typed::{Hash, Path, hash::MERKLE_HASH_LEN}, }, }; @@ -32,6 +32,12 @@ fn hash(byte: u8) -> Hash { Hash([byte; MERKLE_HASH_LEN]) } +/// A set-length allowance no fixture here can exhaust, for tests whose +/// subject is not the ingress supply charge. +fn unbounded() -> SupplyLedger { + SupplyLedger::new(u64::MAX) +} + /// Build a supply run from borrowed leaf records, in the given order. fn leaf_run(records: &[(&Version, &Message)]) -> LeafRun { let mut run = LeafRun::new(); diff --git a/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs b/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs index 43aabfa0a..541a44a71 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs @@ -17,7 +17,7 @@ use crate::tree::{ use super::{ super::{DecodeError, EncodeError, Scope, decode_reply, encode_reply}, - LeafCase, hash, leaf_run, runtime, + LeafCase, hash, leaf_run, runtime, unbounded, }; use crate::tree::mirror::streaming::{ convert::Convert, @@ -132,6 +132,7 @@ where .block_on(decode_reply::, u64, H, _>( backend.clone(), u64::MAX, + unbounded(), Scope::new(parent, &[]), &mut frames, )) diff --git a/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs b/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs index d0e06b3cc..92d05cd7d 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs @@ -35,6 +35,7 @@ use crate::{ }; use super::super::{Scope, decode::fan_probe, decode_reply, early_supplies}; +use super::unbounded; /// Leaf records per supply frame. const PER_FRAME: usize = 16; @@ -85,6 +86,7 @@ fn peak_occupancy(mut input: impl Stream> + Unpin) -> usize { decode_reply::( Local, u64::MAX, + unbounded(), Scope::::opening(&[]), &mut input, ) @@ -136,6 +138,7 @@ fn eager_early_supplies_ride_the_same_ceiling() { let assembled: Vec<_> = early_supplies::( Local, u64::MAX, + unbounded(), Prefix::new(), stream::iter(frames(&leaves)), ) diff --git a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs index f8e3b15de..53cf00e5c 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs @@ -21,7 +21,7 @@ use super::{ DecodeError, EncodeError, Scope, ScopeError, decode_leaf_reply, decode_reply, encode_leaf_reply, encode_reply, }, - LeafCase, hash, leaf_run, runtime, + LeafCase, hash, leaf_run, runtime, unbounded, }; use crate::tree::mirror::streaming::message::{Reaction, Reply}; use crate::tree::mirror::streaming::remote::codec::{ @@ -43,6 +43,7 @@ fn bare_end_cannot_follow_reactions() { decode_leaf_reply( Local, u64::MAX, + unbounded(), Scope::new(parent, &[(0, hash(0))]), &mut frames, ) @@ -64,6 +65,7 @@ fn stream_exhaustion_before_a_boundary_is_truncation() { decode_leaf_reply( Local, u64::MAX, + unbounded(), Scope::new(parent, &[(0, hash(0))]), &mut frames, ) @@ -96,6 +98,7 @@ fn an_unpositioned_match_is_rejected_in_both_directions() { decode_reply::( Local, u64::MAX, + unbounded(), Scope::new(parent, &[(1, hash(1))]), &mut frames, ) @@ -142,10 +145,16 @@ fn an_unpositioned_query_is_rejected_in_both_directions() { let decode_error = runtime().block_on(async { let mut frames = stream::iter(frames); - decode_reply::(Local, u64::MAX, Scope::new(parent, &[]), &mut frames) - .await - .err() - .expect("a query without a child has no derivable scope") + decode_reply::( + Local, + u64::MAX, + unbounded(), + Scope::new(parent, &[]), + &mut frames, + ) + .await + .err() + .expect("a query without a child has no derivable scope") }); assert!(matches!( decode_error, @@ -233,6 +242,7 @@ fn leaf_query_matrix_is_exhaustive() { decode_leaf_reply( Local, u64::MAX, + unbounded(), Scope::new(parent, &scope_listing), &mut frames, ) @@ -269,6 +279,7 @@ fn stream_end_is_not_a_protocol_reply() { .block_on(decode_leaf_reply( Local, u64::MAX, + unbounded(), Scope::new(parent, &[]), &mut frames, )) @@ -329,6 +340,7 @@ fn a_multi_leaf_run_is_one_supplied_subtree() { let decoded = decode_reply::( Local, u64::MAX, + unbounded(), scope.clone(), &mut input, ) @@ -386,6 +398,7 @@ fn leaf_order_is_enforced_within_one_run() { decode_reply::( Local, u64::MAX, + unbounded(), Scope::opening(&[]), &mut input, ) @@ -423,10 +436,16 @@ fn leaf_scope_is_enforced_within_one_run() { let error = runtime().block_on(async { let mut input = stream::iter(frames); - decode_leaf_reply(Local, u64::MAX, Scope::new(parent, &[]), &mut input) - .await - .err() - .expect("a record escaping the reply scope must fail") + decode_leaf_reply( + Local, + u64::MAX, + unbounded(), + Scope::new(parent, &[]), + &mut input, + ) + .await + .err() + .expect("a record escaping the reply scope must fail") }); let DecodeError::LeafOutsideScope { expected, actual } = error else { panic!("expected LeafOutsideScope, got {error:?}"); @@ -456,6 +475,7 @@ fn a_zero_length_record_fails_as_a_version_decode_error() { decode_reply::( Local, u64::MAX, + unbounded(), Scope::opening(&[]), &mut input, ) @@ -489,17 +509,29 @@ fn a_version_over_the_declared_bound_is_rejected() { runtime().block_on(async { let mut input = stream::iter(frames()); - decode_leaf_reply(Local, declared, Scope::new(parent, &[]), &mut input) - .await - .expect("a version exactly at the declared bound is admitted"); + decode_leaf_reply( + Local, + declared, + unbounded(), + Scope::new(parent, &[]), + &mut input, + ) + .await + .expect("a version exactly at the declared bound is admitted"); }); let error = runtime().block_on(async { let mut input = stream::iter(frames()); - decode_leaf_reply(Local, declared - 1, Scope::new(parent, &[]), &mut input) - .await - .err() - .expect("a version over the declared bound must be rejected") + decode_leaf_reply( + Local, + declared - 1, + unbounded(), + Scope::new(parent, &[]), + &mut input, + ) + .await + .err() + .expect("a version over the declared bound must be rejected") }); let DecodeError::OversizedVersion { declared: bound, @@ -512,6 +544,117 @@ fn a_version_over_the_declared_bound_is_rejected() { assert_eq!(actual as u64, declared); } +/// `count` distinct leaves in ascending content-path order, all inside +/// the whole-root opening scope: the shape of one reply streaming an +/// arbitrary volume of supplies. +fn ascending_leaves(count: u64) -> Vec { + let mut cases: Vec = (0..count) + .map(|value| LeafCase::new(value, value as u8 % 4)) + .collect(); + cases.sort_by_key(LeafCase::path); + cases +} + +/// One whole-root reply supplying every leaf in `cases`, as a single +/// ascending run. +fn whole_root_supply_reply(cases: &[LeafCase]) -> Vec> { + let records: Vec<_> = cases + .iter() + .map(|case| (&case.version, &case.message)) + .collect(); + vec![Frame::Reaction( + WireReaction::Supply(leaf_run(&records)), + Flow::End, + )] +} + +/// A reply streaming past the declared `set_len` fails typed at its first +/// over-declaration record, under node residency independent of the +/// overrun; a declaration exactly covering the stream admits it whole. +/// +/// The peer's greeting-declared `set_len` is a premise the session's +/// window solve prices, and the decoder charges it per record before the +/// payload takes backend custody. Metered by the node census (the +/// crate's exact residency shadow): the boundary case pins the meter +/// alive (an admitted stream's every leaf is resident at completion), +/// and the rejection case pins residency equal across a doubled +/// overrun, so custody provably stops at the charge rather than at the +/// reply boundary. +#[test] +fn a_reply_past_the_declared_set_len_fails_at_its_first_over_record() { + use crate::tree::mirror::streaming::materialized::SupplyLedger; + use crate::tree::typed::untyped::census; + + const SMALL: u64 = 128; + const LARGE: u64 = 256; + + /// Decode one whole-root reply of `count` leaves under a declared + /// allowance of `declared`, returning the outcome and the peak + /// node-handle residency beyond the pre-decode baseline. + #[allow(clippy::type_complexity)] + fn decode_metered( + count: u64, + declared: u64, + ) -> (Result>, usize) { + let frames = whole_root_supply_reply(&ascending_leaves(count)); + census::reset_peak(); + let (live, _) = census::read(); + let decoded = runtime().block_on(async { + let mut input = stream::iter(frames); + decode_reply::( + Local, + u64::MAX, + SupplyLedger::new(declared), + Scope::opening(&[]), + &mut input, + ) + .await + }); + let (_, peak) = census::read(); + ( + decoded.map(|decoded| decoded.reply.replies.len()), + peak - live, + ) + } + + // The no-false-positive boundary, doubling as the meter's liveness + // floor: a declaration exactly covering the stream admits every + // record, and every admitted leaf is resident at completion. + let (admitted, residency) = decode_metered(SMALL, SMALL); + admitted.expect("a declaration exactly covering the stream admits it"); + assert!( + residency >= SMALL as usize, + "the census meter is alive: an admitted {SMALL}-leaf reply holds \ + {residency} resident handles", + ); + + // The rejection: an allowance of one fails at the second record, + // while the reply is still open. + let overdrawn = |count: u64| { + let (result, residency) = decode_metered(count, 1); + let error = result.expect_err( + "undetected over-supply: a reply past the declared set length \ + must fail at ingress, at its first over-declaration record", + ); + assert!( + matches!(error, DecodeError::OverdrawnSupply { declared: 1 }), + "mistyped over-supply rejection: {error:?}", + ); + residency + }; + let small = overdrawn(SMALL); + let large = overdrawn(LARGE); + assert_eq!( + small, large, + "residency at rejection is independent of the streamed overrun", + ); + assert!( + small < SMALL as usize, + "custody stops at the charge: {small} resident handles against a \ + {SMALL}-leaf stream", + ); +} + /// Interrupting a supply run finalizes its radix, so later resumption is rejected as reordering. #[test] fn a_supply_run_cannot_resume_after_another_reaction() { @@ -536,6 +679,7 @@ fn a_supply_run_cannot_resume_after_another_reaction() { decode_reply::( Local, u64::MAX, + unbounded(), Scope::opening(&[(1, hash(1))]), &mut input, ) diff --git a/src/tree/mirror/streaming/remote/adapter/tests/opening.rs b/src/tree/mirror/streaming/remote/adapter/tests/opening.rs index b5c38e25e..c7858b68d 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/opening.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/opening.rs @@ -27,7 +27,7 @@ use crate::tree::{ use super::{ super::{DecodeError, OpeningError, Scope, early_supplies, opening_parts, opening_reply}, - LeafCase, hash, leaf_run, runtime, + LeafCase, hash, leaf_run, runtime, unbounded, }; trait OpeningNode: Height { @@ -149,6 +149,7 @@ fn opening_supplies_decode_by_radix_group() { early_supplies::( Local, u64::MAX, + unbounded(), Prefix::new(), stream::iter(frames), ) @@ -167,6 +168,51 @@ fn opening_supplies_decode_by_radix_group() { } } +/// The opening-supply reply is held to the declared set length record by +/// record: the first record past the allowance fails the decode typed, +/// while the one opening reply is still open. +/// +/// The same fixture as the radix-group decode above, under an allowance +/// of one: the eager early path charges at ingress exactly as the +/// per-reply decoder does, so an over-declaring initiator cannot ride +/// the opening stream past its greeting. +#[test] +fn opening_supplies_past_the_declared_set_len_are_rejected() { + use crate::tree::mirror::streaming::materialized::SupplyLedger; + + let mut cases: Vec = (0..6).map(|i| LeafCase::new(1_000 + i, 1)).collect(); + cases.sort_by_key(LeafCase::path); + let records: Vec<_> = cases + .iter() + .map(|case| (&case.version, &case.message)) + .collect(); + let frames: Vec> = vec![Frame::Reaction( + WireReaction::Supply(leaf_run(&records)), + Flow::End, + )]; + + let error = runtime() + .block_on(async { + early_supplies::( + Local, + u64::MAX, + SupplyLedger::new(1), + Prefix::new(), + stream::iter(frames), + ) + .try_collect::>() + .await + }) + .expect_err( + "undetected over-supply: opening supplies past the declared set \ + length must fail at ingress", + ); + assert!( + matches!(error, DecodeError::OverdrawnSupply { declared: 1 }), + "mistyped over-supply rejection: {error:?}", + ); +} + /// An empty opening-supply reply — the whole early set pruned away — /// decodes to no supplies at all. #[test] @@ -177,6 +223,7 @@ fn empty_opening_supply_reply_decodes_to_nothing() { early_supplies::( Local, u64::MAX, + unbounded(), Prefix::new(), stream::iter(frames), ) @@ -196,6 +243,7 @@ fn second_opening_supply_reply_is_rejected() { early_supplies::( Local, u64::MAX, + unbounded(), Prefix::new(), stream::iter(frames), ) @@ -216,6 +264,7 @@ fn positional_reaction_in_opening_supplies_is_rejected() { early_supplies::( Local, u64::MAX, + unbounded(), Prefix::new(), stream::iter(frames), ) diff --git a/src/tree/mirror/streaming/remote/adapter/tests/parking.rs b/src/tree/mirror/streaming/remote/adapter/tests/parking.rs index e626884a0..0339a482c 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/parking.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/parking.rs @@ -42,7 +42,7 @@ use crate::{ use super::{ super::{Scope, decode_reply, encode_reply}, - hash, runtime, + hash, runtime, unbounded, }; /// Leaves committed under the supplied root fan: enough that the fan's @@ -127,6 +127,7 @@ fn parked_supply_reply_holds_handles_not_subtrees() { .block_on(decode_reply::( Local, u64::MAX, + unbounded(), scope, &mut frames, )) @@ -213,6 +214,7 @@ fn maximally_disputed_reply_parks_bounded_skeleton() { .block_on(decode_reply::( Local, u64::MAX, + unbounded(), Scope::::opening(&listing), &mut frames, )) diff --git a/src/tree/mirror/streaming/remote/adapter/tests/properties.rs b/src/tree/mirror/streaming/remote/adapter/tests/properties.rs index af57d73fd..9231d7b48 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/properties.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/properties.rs @@ -19,7 +19,7 @@ use crate::tree::{ use super::{ super::{DecodeError, Scope, decode_leaf_reply, decode_reply, encode_leaf_reply, encode_reply}, - LeafCase, hash, leaf_run, runtime, + LeafCase, hash, leaf_run, runtime, unbounded, }; use crate::tree::mirror::streaming::remote::codec::{ End, Flow, Frame, Reaction as WireReaction, RunBudget, @@ -107,6 +107,7 @@ impl AdapterHeight for Z { .block_on(decode_leaf_reply( Local, u64::MAX, + unbounded(), scope.clone(), &mut frames, )) @@ -152,7 +153,13 @@ impl AdapterHeight for Z { .chain([sentinel.clone()]), ); let decoded = runtime - .block_on(decode_leaf_reply(Local, u64::MAX, scope, &mut frames)) + .block_on(decode_leaf_reply( + Local, + u64::MAX, + unbounded(), + scope, + &mut frames, + )) .expect("canonical matches decode"); prop_assert!(decoded.questions.is_empty(), "height 0"); assert_matches(&decoded.reply, radixes.len(), 0)?; @@ -223,7 +230,13 @@ impl AdapterHeight for Z { let mut frames = stream::iter(actual_frames); let decoded = runtime - .block_on(decode_leaf_reply(Local, u64::MAX, scope, &mut frames)) + .block_on(decode_leaf_reply( + Local, + u64::MAX, + unbounded(), + scope, + &mut frames, + )) .expect("canonical leaf reactions decode"); prop_assert_eq!(&decoded.questions, &expected_questions, "height 0"); assert_positional_reply(&decoded.reply, &leaf_case, 0) @@ -267,7 +280,13 @@ impl AdapterHeight for Z { let sentinel = Frame::End(End::Reply); let mut frames = stream::iter(actual_frames.into_iter().chain([sentinel.clone()])); let decoded = runtime - .block_on(decode_leaf_reply(Local, u64::MAX, scope, &mut frames)) + .block_on(decode_leaf_reply( + Local, + u64::MAX, + unbounded(), + scope, + &mut frames, + )) .expect("canonical mixed leaf reactions decode"); prop_assert_eq!(&decoded.questions, &expected_questions, "height 0"); assert_mixed_reply( @@ -293,6 +312,7 @@ impl AdapterHeight for Z { .block_on(decode_leaf_reply( Local, u64::MAX, + unbounded(), Scope::new(parent, &[]), &mut frames, )) @@ -312,6 +332,7 @@ impl AdapterHeight for Z { .block_on(decode_leaf_reply( Local, u64::MAX, + unbounded(), Scope::new(foreign, &[]), &mut frames, )) @@ -344,6 +365,7 @@ where .block_on(decode_reply::( Local, u64::MAX, + unbounded(), scope.clone(), &mut frames, )) @@ -392,6 +414,7 @@ where .block_on(decode_reply::( Local, u64::MAX, + unbounded(), scope, &mut frames, )) @@ -474,6 +497,7 @@ where .block_on(decode_reply::( Local, u64::MAX, + unbounded(), scope, &mut frames, )) @@ -540,6 +564,7 @@ where .block_on(decode_reply::( Local, u64::MAX, + unbounded(), scope, &mut frames, )) @@ -578,6 +603,7 @@ where .block_on(decode_reply::( Local, u64::MAX, + unbounded(), Scope::new(parent, &[]), &mut frames, )) @@ -603,6 +629,7 @@ where .block_on(decode_reply::( Local, u64::MAX, + unbounded(), Scope::new(foreign, &[]), &mut frames, )) diff --git a/src/tree/mirror/streaming/remote/adapter/tests/runs.rs b/src/tree/mirror/streaming/remote/adapter/tests/runs.rs index 8734c8581..e86d49233 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/runs.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/runs.rs @@ -35,7 +35,7 @@ use crate::{ use super::{ super::{Scope, decode_reply, encode_reply}, - LeafCase, leaf_run, runtime, + LeafCase, leaf_run, runtime, unbounded, }; /// Inclusive bound on leaves per generated run scenario. @@ -114,6 +114,7 @@ fn recode(frames: Vec>, budget: RunBudget) -> Vec> { let decoded = decode_reply::( Local, u64::MAX, + unbounded(), Scope::::opening(&[]), &mut input, ) @@ -269,6 +270,7 @@ fn a_batched_run_round_trips_the_reply() { decode_reply::( Local, u64::MAX, + unbounded(), Scope::::opening(&[]), &mut input, ) diff --git a/src/tree/mirror/streaming/remote/proxy/start.rs b/src/tree/mirror/streaming/remote/proxy/start.rs index f57e0d553..3080aba6f 100644 --- a/src/tree/mirror/streaming/remote/proxy/start.rs +++ b/src/tree/mirror/streaming/remote/proxy/start.rs @@ -324,6 +324,7 @@ where budget, local, remote.max_version_bytes, + remote.set_len, remote.listing, link, stats, @@ -337,7 +338,8 @@ where /// election, and merged against the local opening's listing to gate the /// early-supply stream when it loses. `peer_version_bytes` is the remote /// greeting's `max_version_bytes`, which the session enforces on every -/// version the remote supplies. +/// version the remote supplies; `peer_set_len` is its declared set +/// length, which the session charges per supplied record at ingress. #[allow(clippy::too_many_arguments)] fn open( backend: B, @@ -345,6 +347,7 @@ fn open( budget: RunBudget, local: Speaker, peer_version_bytes: u64, + peer_set_len: u64, peer_listing: Vec<(u8, Hash)>, link: Link, stats: Recorder, @@ -372,6 +375,7 @@ where window, budget, peer_version_bytes, + peer_set_len, peer_listing, Physical { control_read, diff --git a/src/tree/mirror/streaming/remote/proxy/tests/declarations.rs b/src/tree/mirror/streaming/remote/proxy/tests/declarations.rs index 63c8907ba..f37297bfb 100644 --- a/src/tree/mirror/streaming/remote/proxy/tests/declarations.rs +++ b/src/tree/mirror/streaming/remote/proxy/tests/declarations.rs @@ -18,7 +18,6 @@ use crate::tree::{ mirror::{ Error as MirrorError, streaming::{ - materialized::{Error as MaterializedError, Violation}, remote::{ CodecDecodeError, CodecDecodeErrorKind, DecodeError, Error as RemoteError, StreamError, @@ -211,19 +210,22 @@ fn understated_version_bytes_fail_the_session() { /// The dual of the oversized-version guard, completing the declaration /// matrix: the declared set length is a premise of the window solve's /// occupancy envelopes and per-slot pricing, so honest supplies overrunning -/// it void what the window priced. The receiving side reports -/// `OverdrawnSupply` at the first leaf past the declaration; the peer's -/// endpoint is left to whatever its schedule surfaces — here it may even -/// complete, having already reconciled before the deceived side's late -/// ingestion tripped — which is not this tripwire's concern (the -/// containment wire test draws the same line). +/// it void what the window priced. The receiving side's wire decoder +/// reports `OverdrawnSupply` at the first record past the declaration, +/// before the payload takes backend custody — the walk's own ledger still +/// stands behind it for the in-process stack, but on the wire the ingress +/// charge fires first. The peer's endpoint is left to whatever its +/// schedule surfaces — here it may even complete, having already +/// reconciled before the deceived side's late ingestion tripped — which +/// is not this tripwire's concern (the containment wire test draws the +/// same line). /// /// The rewrite shrinks the heard length of the honestly-smaller side, so /// the role election stays complementary — a real under-declaring peer /// elects from its own declared value, so only election-preserving /// rewrites model one. The smaller side therefore still initiates; its -/// early supplies ride the opening stream, land at the deceived side's -/// first descending level, and trip the resolver's ledger there. +/// early supplies ride the opening stream and trip the deceived side's +/// ingress charge at their first record. #[test] fn understated_set_len_fails_the_session() { for receiver_left in [false, true] { @@ -240,31 +242,102 @@ fn understated_set_len_fails_the_session() { left, right, hears.0, hears.1, )) .expect("an overdrawn supply stream must terminate both sessions"); - // The violation rises from the receiver's materialized walk, which - // sits in the opposite mirror seat from its proxy — so the side - // labels mirror the oversized-version test's, where the proxy's - // decoder reports instead. - if receiver_left { - assert!( - matches!( - &left, - Err(MirrorError::Client(MaterializedError::Violation( - Violation::OverdrawnSupply - ))), + let receiver_error = if receiver_left { + match &left { + Err(MirrorError::Server(error)) => error, + other => panic!( + "undetected set_len lie: the left proxy did not report \ + the violation: {other:?}" + ), + } + } else { + match &right { + Err(MirrorError::Client(error)) => error, + other => panic!( + "undetected set_len lie: the right proxy did not report \ + the violation: {other:?}" + ), + } + }; + assert!( + matches!( + receiver_error, + RemoteError::Decode(DecodeError::OverdrawnSupply { declared: 0 }) + ), + "mistyped set_len violation: {receiver_error:?}", + ); + } +} + +/// A divergent pair of four messages against eight, on distinct parties: +/// the four-message side wins the initiator election, and its whole +/// exclusive content rides the opening-supply stream as one reply. +fn opening_bulk_pair() -> (crate::tree::Root<()>, crate::tree::Root<()>) { + let mut small = Tree::new(); + small.act( + &nth_party(1), + (0..4).map(|_| Action::Insert(Message::new(()))), + ); + let mut large = Tree::new(); + large.act( + &nth_party(0), + (0..8).map(|_| Action::Insert(Message::new(()))), + ); + (small.root, large.root) +} + +/// A `set_len` lie surfacing *within* one still-open reply fails at the +/// offending record, at ingress. +/// +/// The zero-declaration case above trips at a reply's first record; here +/// the heard declaration admits one leaf while the initiator's +/// opening-supply reply carries four, so the overrun surfaces +/// mid-reply. Only the wire decoder can detect it there — the walk's +/// ledger charges at absorption, after a decoded subtree materializes — +/// so the receiving side must report the ingress rejection carrying the +/// declaration it enforced, never absorb the reply whole first. The +/// rewrite shrinks the heard length of the honestly-smaller side to a +/// nonzero value below its traffic, preserving the role election. +#[test] +fn set_len_overrun_within_one_reply_fails_at_ingress() { + for receiver_left in [false, true] { + let (small, large) = opening_bulk_pair(); + // The receiver holds the large tree and hears the small + // (initiating) side's declared length as one. + let rewrite = GreetingRewrite::set_len(1); + let ((left, right), hears) = if receiver_left { + ((large, small), (Some(rewrite), None)) + } else { + ((small, large), (None, Some(rewrite))) + }; + let (left, right) = run_to_quiescence(harness::reconcile_rewritten_greetings( + left, right, hears.0, hears.1, + )) + .expect("a mid-reply overdrawn supply must terminate both sessions, not stall them"); + let receiver_error = if receiver_left { + match &left { + Err(MirrorError::Server(error)) => error, + other => panic!( + "undetected within-one-reply set_len lie: the left proxy \ + did not report the violation: {other:?}" ), - "the left walk did not report the violation: {left:?}", - ); + } } else { - assert!( - matches!( - &right, - Err(MirrorError::Server(MaterializedError::Violation( - Violation::OverdrawnSupply - ))), + match &right { + Err(MirrorError::Client(error)) => error, + other => panic!( + "undetected within-one-reply set_len lie: the right proxy \ + did not report the violation: {other:?}" ), - "the right walk did not report the violation: {right:?}", - ); - } + } + }; + assert!( + matches!( + receiver_error, + RemoteError::Decode(DecodeError::OverdrawnSupply { declared: 1 }) + ), + "mistyped within-one-reply set_len violation: {receiver_error:?}", + ); } } diff --git a/src/tree/mirror/streaming/remote/proxy/work.rs b/src/tree/mirror/streaming/remote/proxy/work.rs index 06c031eb2..31b45a182 100644 --- a/src/tree/mirror/streaming/remote/proxy/work.rs +++ b/src/tree/mirror/streaming/remote/proxy/work.rs @@ -13,6 +13,7 @@ use crate::link::Acceptor; use crate::tree::{ mirror::streaming::{ Backend, Leaf, + materialized::SupplyLedger, protocol::{BoxResponses, Responses}, remote::{ adapter::{DecodeError, EncodeError}, @@ -51,6 +52,10 @@ where /// The remote greeting's `max_version_bytes` declaration, enforced /// against every supplied version this session decodes. peer_version_bytes: u64, + /// The remote greeting's `set_len` declaration as a session-total + /// supply allowance: every leaf record this session decodes charges + /// it before the payload takes backend custody. + peer_supplies: SupplyLedger, /// The remote greeting's root-fan listing, consumed by whichever role /// the election assigns. /// @@ -92,6 +97,7 @@ where window: Window, budget: RunBudget, peer_version_bytes: u64, + peer_set_len: u64, peer_listing: Vec<(u8, Hash)>, physical: Physical, ) -> Self { @@ -100,6 +106,7 @@ where window, budget, peer_version_bytes, + peer_supplies: SupplyLedger::new(peer_set_len), peer_listing, physical, tasks: Vec::new(), diff --git a/src/tree/mirror/streaming/remote/proxy/work/pump.rs b/src/tree/mirror/streaming/remote/proxy/work/pump.rs index 4e1ca8971..b352cebcc 100644 --- a/src/tree/mirror/streaming/remote/proxy/work/pump.rs +++ b/src/tree/mirror/streaming/remote/proxy/work/pump.rs @@ -31,7 +31,7 @@ use crate::tree::{ Backend, Leaf, channel::Receiver, convert::Convert, - materialized::children_of, + materialized::{SupplyLedger, children_of}, message::{Reaction, Reply}, protocol::{BoxResponses, Requests}, remote::{ @@ -116,11 +116,12 @@ where queues::next_scopes::<_, UnderUnderRoot>(self.window.capacity(UnderUnderRoot::HEIGHT)); let backend = self.backend(); let version_bytes = self.peer_version_bytes; + let ledger = self.peer_supplies.clone(); let responses = try_stream! { while let Some(scope) = questions.recv().await { let Decoded { reply, questions } = decode_reply::( - backend.clone(), version_bytes, scope, &mut incoming, + backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming, ).await?; yield_reply_scopes!( progress, UnderUnderRoot, questions.len(); @@ -173,8 +174,9 @@ where let (next_scopes, scopes) = queues::next_scopes::<_, H>(self.window.capacity(H::HEIGHT)); let backend = self.backend(); let version_bytes = self.peer_version_bytes; + let ledger = self.peer_supplies.clone(); let responses = try_stream! { - let mut early = Early::>, A::Rx>::new(version_bytes, early); + let mut early = Early::>, A::Rx>::new(version_bytes, ledger.clone(), early); while let Some(scope) = questions.recv().await { if early.armed() && scope.is_request() { // A root-level request: its content crossed at the @@ -183,7 +185,7 @@ where // when pruning removed the whole subtree. let parent = scope.parent(); let Decoded { reply, questions: asked } = decode_reply::( - backend.clone(), version_bytes, scope, &mut incoming, + backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming, ).await?; debug_assert!(asked.is_empty(), "an empty request opens no lower scope"); let (root, radix) = parent.pop(); @@ -206,7 +208,7 @@ where continue; } let Decoded { reply, questions } = decode_reply::( - backend.clone(), version_bytes, scope, &mut incoming, + backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming, ).await?; yield_reply_scopes!( progress, H, questions.len(); @@ -243,10 +245,11 @@ where let (next_scopes, scopes) = queues::next_scopes::<_, Z>(self.window.capacity(Z::HEIGHT)); let backend = self.backend(); let version_bytes = self.peer_version_bytes; + let ledger = self.peer_supplies.clone(); let responses = try_stream! { while let Some(scope) = questions.recv().await { let Decoded { reply, questions } = decode_leaf_reply( - backend.clone(), version_bytes, scope, &mut incoming, + backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming, ).await?; yield_reply_scopes!( progress, Z, questions.len(); @@ -310,10 +313,11 @@ where )); let backend = self.backend(); let version_bytes = self.peer_version_bytes; + let ledger = self.peer_supplies.clone(); let responses = try_stream! { while let Some(scope) = questions.recv().await { let Decoded { reply, questions } = decode_leaf_reply( - backend.clone(), version_bytes, scope, &mut incoming, + backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming, ).await?; if !questions.is_empty() { Err(Error::TerminalQuery)?; @@ -350,6 +354,9 @@ where /// The peer's greeting-declared `max_version_bytes`, enforced on /// every supplied version the opening stream decodes. version_bytes: u64, + /// The session's declared-`set_len` allowance, charged per record + /// the opening stream decodes. + ledger: SupplyLedger, receiver: Option>, supplies: Option), DecodeError>> + Send>>>, @@ -367,9 +374,14 @@ where { /// Arm the cursor with the opening-supply stream's receiver, if this /// stage is the one that owns it. - fn new(version_bytes: u64, receiver: Option>) -> Self { + fn new( + version_bytes: u64, + ledger: SupplyLedger, + receiver: Option>, + ) -> Self { Self { version_bytes, + ledger, receiver, supplies: None, lookahead: None, @@ -417,6 +429,7 @@ where .get_or_insert(Box::pin(early_supplies::( backend.clone(), self.version_bytes, + self.ledger.clone(), root, receiver, ))) diff --git a/src/tree/mirror/streaming/remote/proxy/work/tests.rs b/src/tree/mirror/streaming/remote/proxy/work/tests.rs index a73116d34..e30182b32 100644 --- a/src/tree/mirror/streaming/remote/proxy/work/tests.rs +++ b/src/tree/mirror/streaming/remote/proxy/work/tests.rs @@ -53,6 +53,7 @@ fn parked_session() -> ParkedSession { Window::FLOOR, RunBudget::default(), u64::MAX, + u64::MAX, Vec::new(), Physical { control_read: parts.control_read, diff --git a/src/tree/mirror/streaming/testing/faulting.rs b/src/tree/mirror/streaming/testing/faulting.rs index 6562976f6..1533930c2 100644 --- a/src/tree/mirror/streaming/testing/faulting.rs +++ b/src/tree/mirror/streaming/testing/faulting.rs @@ -53,6 +53,14 @@ pub enum GreetingLie { /// Declare an empty set: the first absorbed honest supply overruns /// the declared length ([`Violation::OverdrawnSupply`]). ShrunkenSetLen, + /// Declare a single leaf while holding more: honest supply overruns + /// the nonzero allowance mid-session ([`Violation::OverdrawnSupply`]). + /// + /// The walk-side face of the lie the wire decoder catches within one + /// still-open reply: here the allowance admits supply before the + /// ledger's accumulation trips, unlike the zero declaration's + /// first-charge rejection. + UnderdeclaredSetLen, /// Declare more leaves than the tree holds; the session must /// complete cleanly. InflatedSetLen, @@ -70,6 +78,7 @@ pub enum GreetingLie { fn tell(greeting: &mut message::Greeting, lie: GreetingLie) { match lie { GreetingLie::ShrunkenSetLen => greeting.set_len = 0, + GreetingLie::UnderdeclaredSetLen => greeting.set_len = 1, GreetingLie::InflatedSetLen => greeting.set_len = greeting.set_len * 2 + 1, GreetingLie::ShrunkenVersion => greeting.version = Version::new(), GreetingLie::InflatedVersion => { diff --git a/src/tree/mirror/streaming/tests/faults.rs b/src/tree/mirror/streaming/tests/faults.rs index 90bcb0757..6ff8c33c5 100644 --- a/src/tree/mirror/streaming/tests/faults.rs +++ b/src/tree/mirror/streaming/tests/faults.rs @@ -50,6 +50,7 @@ fn arb_connected_violation() -> impl Strategy { fn arb_greeting_lie() -> impl Strategy { prop_oneof![ Just(GreetingLie::ShrunkenSetLen), + Just(GreetingLie::UnderdeclaredSetLen), Just(GreetingLie::InflatedSetLen), Just(GreetingLie::ShrunkenVersion), Just(GreetingLie::InflatedVersion), @@ -120,7 +121,13 @@ proptest! { full_depth_comb_pair(2, LeafOrder::Interleaved); let before = (client_root.clone(), server_root.clone()); let expected = match lie { - GreetingLie::ShrunkenSetLen => Some(Violation::OverdrawnSupply), + // The zero declaration trips at the first absorbed supply; + // the one-leaf declaration admits supply first and trips on + // the ledger's accumulation — both land as the same + // violation, from opposite ends of the allowance. + GreetingLie::ShrunkenSetLen | GreetingLie::UnderdeclaredSetLen => { + Some(Violation::OverdrawnSupply) + } GreetingLie::ShrunkenVersion => Some(Violation::UncontainedSupply), GreetingLie::InflatedSetLen | GreetingLie::InflatedVersion => None, };