diff --git a/.config/nextest.toml b/.config/nextest.toml index b4c46fbcc1..f20e3dbd25 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -4,6 +4,7 @@ [profile.default] fail-fast = true status-level = "pass" +default-filter = 'not test(~handler::tests::paired_block_sync::gate::)' # --- Platform-specific overrides --- @@ -36,7 +37,13 @@ threads-required = "num-test-threads" # TODO: We need a better test architecture to run all non-stateful [profile.all-tests] failure-output = "immediate" -default-filter = "not test(check_no_git_dependencies) and not test(=fully_synced_rpc_z_getsubtreesbyindex_snapshot_test) and not test(=lwd_rpc_test) and not test(=lwd_rpc_send_tx) and not test(=lwd_grpc_wallet) and not test(=lwd_integration) and not test(=lwd_sync_full) and not test(=lwd_sync_update) and not test(=lightwalletd_test_suite) and not test(=rpc_get_block_template) and not test(=rpc_submit_block) and not test(~generate_checkpoints_) and not test(=sync_one_checkpoint_mainnet) and not test(=sync_one_checkpoint_testnet) and not test(=sync_update_mainnet) and not test(=activate_mempool_mainnet) and not test(=restart_stop_at_height) and not test(=sync_large_checkpoints_mempool_mainnet) and not test(=pruned_storage_mode_prunes_during_regtest_sync) and not test(=config_tests) and not test(~zakura::testkit::cluster) and not test(~zakura::testkit::blocksync_fuzz)" +default-filter = "not test(check_no_git_dependencies) and not test(=fully_synced_rpc_z_getsubtreesbyindex_snapshot_test) and not test(=lwd_rpc_test) and not test(=lwd_rpc_send_tx) and not test(=lwd_grpc_wallet) and not test(=lwd_integration) and not test(=lwd_sync_full) and not test(=lwd_sync_update) and not test(=lightwalletd_test_suite) and not test(=rpc_get_block_template) and not test(=rpc_submit_block) and not test(~generate_checkpoints_) and not test(=sync_one_checkpoint_mainnet) and not test(=sync_one_checkpoint_testnet) and not test(=sync_update_mainnet) and not test(=activate_mempool_mainnet) and not test(=restart_stop_at_height) and not test(=sync_large_checkpoints_mempool_mainnet) and not test(=pruned_storage_mode_prunes_during_regtest_sync) and not test(=config_tests) and not test(~zakura::testkit::cluster) and not test(~zakura::testkit::blocksync_fuzz) and not test(~handler::tests::paired_block_sync::gate::)" + +# Controlled regulation tests must fail on their first divergent execution. +[[profile.all-tests.overrides]] +filter = 'test(~message_payload_limits_) or test(~inbound_frame_cap_rejects_) or test(~paused_ordered_reads_) or test(~stream_cancel_closes_ordered_worker_) or test(~block_sync::service::tests::) or test(~transport::io::) or test(~reconciliation_rejects_stale_admissions_) or test(~regulation::request::) or test(~serving_regulation::) or test(~block_sync::serving::) or test(~work_queue::request_write::tests::) or test(~handler::ordered_pair::tests::) or test(~service::block_range::tests::) or (test(~handler::tests::paired_block_sync::) and not test(~handler::tests::paired_block_sync::gate::))' +retries = 0 +slow-timeout = { period = "60s", terminate-after = 1 } # The Zakura block-sync tests drive the real reactor through a real-time (wall-clock) # scenario harness — multi-threaded tokio with real sleeps and timeouts. Under a loaded @@ -63,7 +70,13 @@ retries = 2 # no-fail-fast without hiding fast unit failures. [profile.full-tests] failure-output = "immediate" -default-filter = "not test(check_no_git_dependencies) and not test(=fully_synced_rpc_z_getsubtreesbyindex_snapshot_test) and not test(=lwd_rpc_test) and not test(=lwd_rpc_send_tx) and not test(=lwd_grpc_wallet) and not test(=lwd_integration) and not test(=lwd_sync_full) and not test(=lwd_sync_update) and not test(=lightwalletd_test_suite) and not test(=rpc_get_block_template) and not test(=rpc_submit_block) and not test(~generate_checkpoints_) and not test(=sync_one_checkpoint_mainnet) and not test(=sync_one_checkpoint_testnet) and not test(=sync_update_mainnet) and not test(=activate_mempool_mainnet) and not test(=restart_stop_at_height) and not test(=sync_large_checkpoints_mempool_mainnet) and not test(=pruned_storage_mode_prunes_during_regtest_sync) and not test(=config_tests) and not test(~zakura::testkit::cluster) and not test(~zakura::testkit::blocksync_fuzz)" +default-filter = "not test(check_no_git_dependencies) and not test(=fully_synced_rpc_z_getsubtreesbyindex_snapshot_test) and not test(=lwd_rpc_test) and not test(=lwd_rpc_send_tx) and not test(=lwd_grpc_wallet) and not test(=lwd_integration) and not test(=lwd_sync_full) and not test(=lwd_sync_update) and not test(=lightwalletd_test_suite) and not test(=rpc_get_block_template) and not test(=rpc_submit_block) and not test(~generate_checkpoints_) and not test(=sync_one_checkpoint_mainnet) and not test(=sync_one_checkpoint_testnet) and not test(=sync_update_mainnet) and not test(=activate_mempool_mainnet) and not test(=restart_stop_at_height) and not test(=sync_large_checkpoints_mempool_mainnet) and not test(=pruned_storage_mode_prunes_during_regtest_sync) and not test(=config_tests) and not test(~zakura::testkit::cluster) and not test(~zakura::testkit::blocksync_fuzz) and not test(~handler::tests::paired_block_sync::gate::)" + +# Controlled regulation tests must fail on their first divergent execution. +[[profile.full-tests.overrides]] +filter = 'test(~message_payload_limits_) or test(~inbound_frame_cap_rejects_) or test(~paused_ordered_reads_) or test(~stream_cancel_closes_ordered_worker_) or test(~block_sync::service::tests::) or test(~transport::io::) or test(~reconciliation_rejects_stale_admissions_) or test(~regulation::request::) or test(~serving_regulation::) or test(~block_sync::serving::) or test(~work_queue::request_write::tests::) or test(~handler::ordered_pair::tests::) or test(~service::block_range::tests::) or (test(~handler::tests::paired_block_sync::) and not test(~handler::tests::paired_block_sync::gate::))' +retries = 0 +slow-timeout = { period = "60s", terminate-after = 1 } # Retry the real-time Zakura block-sync scenario tests under load (see the note on the # `all-tests` override above). @@ -232,3 +245,17 @@ test-threads = 1 slow-timeout = { period = "5m", terminate-after = 2 } failure-output = "immediate" default-filter = 'package(zakura) and test(~zcashd_compat)' + +# Also selected by ordinary unit-test profiles; isolate the fixed regression checks. +[profile.blocksync-regression] +default-filter = 'test(~message_payload_limits_) or test(~inbound_frame_cap_rejects_) or test(~paused_ordered_reads_) or test(~stream_cancel_closes_ordered_worker_) or test(~block_sync::service::tests::) or test(~transport::io::) or test(~reconciliation_rejects_stale_admissions_) or test(~regulation::request::) or test(~serving_regulation::) or test(~block_sync::serving::) or test(~work_queue::request_write::tests::) or test(~handler::ordered_pair::tests::) or test(~service::block_range::tests::) or (test(~handler::tests::paired_block_sync::) and not test(~handler::tests::paired_block_sync::gate::))' +retries = 0 +slow-timeout = { period = "60s", terminate-after = 1 } + +# Run explicitly with --run-ignored=all after building; no automatic retry. +[profile.blocksync-transport-gate] +default-filter = 'test(~handler::tests::paired_block_sync::gate::)' +test-threads = 1 +retries = 0 +slow-timeout = { period = "90m", terminate-after = 1 } +failure-output = "immediate" diff --git a/Cargo.lock b/Cargo.lock index a63df40cea..64ae823e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2875,8 +2875,7 @@ dependencies = [ [[package]] name = "iroh-quinn" version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde160ebee7aabede6ae887460cd303c8b809054224815addf1469d54a6fcf7" +source = "git+https://github.com/zakura-core/iroh-quinn?rev=1dcc7a43488fecd199d343d47e93e9ed8319fcaa#1dcc7a43488fecd199d343d47e93e9ed8319fcaa" dependencies = [ "bytes", "cfg_aliases", @@ -2895,8 +2894,7 @@ dependencies = [ [[package]] name = "iroh-quinn-proto" version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535" +source = "git+https://github.com/zakura-core/iroh-quinn?rev=1dcc7a43488fecd199d343d47e93e9ed8319fcaa#1dcc7a43488fecd199d343d47e93e9ed8319fcaa" dependencies = [ "bytes", "getrandom 0.2.17", @@ -2915,8 +2913,7 @@ dependencies = [ [[package]] name = "iroh-quinn-udp" version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c53afaa1049f7c83ea1331f5ebb9e6ebc5fdd69c468b7a22dd598b02c9bcc973" +source = "git+https://github.com/zakura-core/iroh-quinn?rev=1dcc7a43488fecd199d343d47e93e9ed8319fcaa#1dcc7a43488fecd199d343d47e93e9ed8319fcaa" dependencies = [ "cfg_aliases", "libc", @@ -7961,7 +7958,7 @@ dependencies = [ [[package]] name = "zakura-network" -version = "7.1.1-rc1" +version = "8.0.0" dependencies = [ "bitflags", "blake2b_simd", diff --git a/Cargo.toml b/Cargo.toml index e2a6f62523..5fb224f178 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,14 @@ keywords = ["zakura", "zcash"] rust-version = "1.97" edition = "2021" +# Backport the loss-probe datagram limit fix to Iroh 0.92's exact QUIC versions. +# Keep the related crates on one revision. Workspace patches are not inherited +# by downstream library users; they must apply the same patch in their workspace. +[patch.crates-io] +iroh-quinn = { git = "https://github.com/zakura-core/iroh-quinn", rev = "1dcc7a43488fecd199d343d47e93e9ed8319fcaa" } +iroh-quinn-proto = { git = "https://github.com/zakura-core/iroh-quinn", rev = "1dcc7a43488fecd199d343d47e93e9ed8319fcaa" } +iroh-quinn-udp = { git = "https://github.com/zakura-core/iroh-quinn", rev = "1dcc7a43488fecd199d343d47e93e9ed8319fcaa" } + [workspace.dependencies] # Zakura's published binary assets: the reviewed Mainnet historical frontier grid, which is too # large to carry in git history at the weekly cadence the release state advances. diff --git a/crates/zakura-network/Cargo.toml b/crates/zakura-network/Cargo.toml index 8f859f22bd..f5bb463250 100644 --- a/crates/zakura-network/Cargo.toml +++ b/crates/zakura-network/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zakura-network" -version = "7.1.1-rc1" +version = "8.0.0" authors.workspace = true description = "Networking code for the Zakura node. Internal crate, published to support cargo install zakura" # # Legal diff --git a/crates/zakura-network/src/zakura.rs b/crates/zakura-network/src/zakura.rs index dc7bcf7a46..c30e180851 100644 --- a/crates/zakura-network/src/zakura.rs +++ b/crates/zakura-network/src/zakura.rs @@ -88,17 +88,21 @@ pub struct ServicePeerLimits { pub max_inbound_peers: usize, /// Maximum outbound peers this service admits. pub max_outbound_peers: usize, - /// Inbound queue depth reserved for this service. + /// Maximum inbound frames queued on each service stream. /// - /// Reserved for future transport queue wiring; not enforced in this phase. + /// Block sync applies this limit alongside the negotiated transport limit. + /// Its paired request stream always uses a single queue slot. pub inbound_queue_depth: usize, - /// Outbound queue depth reserved for this service. + /// Maximum outbound frames queued on each service stream. /// - /// Reserved for future transport queue wiring; not enforced in this phase. + /// Block sync applies this limit alongside the transport limit. Its paired + /// request stream always uses a single queue slot. pub outbound_queue_depth: usize, - /// Maximum service escalations that may be pending admission. + /// Maximum paired service sessions that may be establishing at once. /// - /// Reserved for future lazy service escalation; not enforced in this phase. + /// Paired block sync reserves this allowance until both roles arrive or + /// setup ends. Zero disables new paired sessions. Other services currently + /// use their existing admission policy. pub max_pending_escalations: usize, } diff --git a/crates/zakura-network/src/zakura/block_sync/README.md b/crates/zakura-network/src/zakura/block_sync/README.md index 68d4050bab..656d47d3ef 100644 --- a/crates/zakura-network/src/zakura/block_sync/README.md +++ b/crates/zakura-network/src/zakura/block_sync/README.md @@ -47,7 +47,13 @@ while anything anchored to the verified tip is pinned until real progress commit | Candidate selection | first pending in `[servable_low, min(servable_high, floor_high)]`, only if this peer is the preferred floor carrier | first pending in the peer's servable range, only when the floor arm produced nothing | | cwnd slots | may borrow up to `floor_bypass_slots` (default 2) beyond a saturated cwnd — bypass slots fund the floor **only** | normal cwnd slots only | | Byte funding | never refused: `reserve_request_budget`'s floor path overdrafts the in-flight budget by at most one request when `try_reserve` fails — reachable even at zero in-flight budget | non-blocking `try_reserve`; refused if the in-flight budget is spent | -| Request deadline | short fixed leash (`floor_rescue_timeout`, default 2 s); on expiry the height is rescued to a faster carrier, the peer is retry-avoided but **not** disconnected | `request_timeout` (default 8 s) + expected transfer time (`estimated_bytes / measured BtlBw`, rate floored at 256 KiB/s) — patient, since it never gates the floor | +| Request deadline | with a fresh delivery-rate sample, `floor_rescue_timeout` (default 2 s) + estimated transfer time; without a sample, use the normal deadline so the cold peer's only probe can finish | `request_timeout` (default 8 s) + estimated transfer time | + +Both lanes compute transfer time as `pending_response_bytes / measured_bytes_per_second`, +using at least 256 KiB/s to keep the deadline bounded. An unmeasured peer uses +256 KiB/s. Include this request and earlier unreceived responses in the byte +estimate because they share an ordered data stream. Floor expiry returns the missing height for retry; the separate +block-progress deadline controls session cooldown and repeated-stall disconnects. **Floor carrier preference:** the floor rides the fastest servable peer. Before taking floor work, a routine asks the shared registry @@ -167,7 +173,7 @@ the worst-case decoded contribution is approximately 1. **Commit-window heights are always fundable**, on both lanes, regardless of the look-ahead gates — a pinned checkpoint range can always assemble. -2. **Floor grants never size below one byte**, and `take_in_range_budgeted` always +2. **Floor grants never size below one byte**, and `take_for_request` always takes its first item regardless of the byte cap — so the floor block is taken even when the in-flight budget is exactly full, reaching the floor reservation path… 3. **…which overdrafts instead of waiting.** When `try_reserve` fails, @@ -207,7 +213,7 @@ borrowed a bypass slot. | `max_inflight_block_bytes` | 6 GiB | outstanding-request wire budget, released at receipt (separate from the resident gate) | | `max_blocks_per_response` | 1 | count cap per request (effective = min of both sides' advertisements, hard max 128) | | `floor_bypass_slots` | 2 | extra slots past a saturated cwnd, floor lane only | -| `request_timeout` / `floor_rescue_timeout` | 8 s / 2 s | above-floor base deadline / floor rescue leash | +| `request_timeout` / `floor_rescue_timeout` | 8 s / 2 s | normal base deadline / measured-peer floor-rescue base; both add ordered transfer time | | `max_submitted_block_applies` | 401 | sequencer submit window (floored at one checkpoint range; no ceiling — which is why the exemption span is a constant) | ## Known limitations and follow-ups diff --git a/crates/zakura-network/src/zakura/block_sync/admission.rs b/crates/zakura-network/src/zakura/block_sync/admission.rs index c31e423fc6..2ca7586c07 100644 --- a/crates/zakura-network/src/zakura/block_sync/admission.rs +++ b/crates/zakura-network/src/zakura/block_sync/admission.rs @@ -7,16 +7,10 @@ use super::{ state::next_height, }; -/// Delivery rate assumed when sizing an above-floor deadline for a peer whose -/// measured BtlBw is still near zero, so the patience window is bounded rather than -/// unbounded. A worst-case `MAX_BLOCK_BYTES` body at this rate transfers in ~8 s, so -/// with the `request_timeout` base the above-floor deadline tops out near 16 s — the -/// "a block every ~16 s is fine" tolerance the directive sets for speculative work. -const ABOVE_FLOOR_DEADLINE_MIN_BYTES_PER_SEC: u64 = 256 * 1024; -/// Delivery rate assumed for floor rescue before a peer has a fresh byte-rate -/// sample. This keeps the rescue leash short while allowing a full 2 MB body roughly -/// two seconds of transfer time. -const FLOOR_DEADLINE_MIN_BYTES_PER_SEC: u64 = 1024 * 1024; +/// Minimum rate used for deadline estimates. One maximum-size body adds about +/// eight seconds; earlier unreceived bodies add their bounded transfer estimates. +/// A session with no accepted progress still reaches its separate liveness limit. +const DEADLINE_MIN_BYTES_PER_SEC: u64 = 256 * 1024; /// Estimated resident-memory multiple of a *decoded* block body's serialized size. /// @@ -114,40 +108,34 @@ pub(super) fn request_priority( } } -/// The per-request network deadline (the one sanctioned timer), set by priority: +/// A bounded request deadline including estimated transfer time. /// -/// - **Floor**: a short rescue leash plus the expected transfer time. On expiry the -/// lowest missing height is rescued to a faster carrier (returned to the queue + the -/// peer retry-avoided), so the contiguous floor never waits on a slow peer — and the -/// peer is *not* disconnected. -/// - **Above-floor**: the base `request_timeout` plus the size-expected transfer time -/// (`estimated_bytes / BtlBw`), so a legitimately slow large-body fetch runs to -/// completion. These deadlines never gate the floor, so they can afford to be -/// patient; `btlbw_bytes_per_sec` is the peer's measured rate (`None` cold-start), -/// floored at [`ABOVE_FLOOR_DEADLINE_MIN_BYTES_PER_SEC`]. +/// A floor request uses the short rescue deadline only with a fresh delivery-rate +/// measurement. Without one, it gets the normal deadline: prematurely expiring a +/// cold peer's only probe prevents its first body from establishing progress. +/// Above-floor requests always use the normal deadline. Transfer bytes include +/// earlier unreceived responses on the ordered data stream. Expiry returns work +/// for retry; the separate block-progress deadline still bounds a silent session. pub(super) fn request_deadline( priority: RequestPriority, queued_at: Instant, request_timeout: Duration, floor_rescue_timeout: Duration, - estimated_bytes: u64, + expected_transfer_bytes: u64, btlbw_bytes_per_sec: Option, ) -> Instant { - match priority { - RequestPriority::Floor => { - let rate = btlbw_bytes_per_sec - .unwrap_or(0) - .max(FLOOR_DEADLINE_MIN_BYTES_PER_SEC); - let transfer = Duration::from_secs_f64(estimated_bytes as f64 / rate as f64); + match (priority, btlbw_bytes_per_sec) { + (RequestPriority::Floor, Some(rate)) => { + let rate = rate.max(DEADLINE_MIN_BYTES_PER_SEC); + let transfer = Duration::from_secs_f64(expected_transfer_bytes as f64 / rate as f64); queued_at + floor_rescue_timeout + transfer } - RequestPriority::AboveFloor => { + _ => { let rate = btlbw_bytes_per_sec .unwrap_or(0) - .max(ABOVE_FLOOR_DEADLINE_MIN_BYTES_PER_SEC); - // One body per request, so `estimated_bytes / rate` is at most - // `MAX_BLOCK_BYTES / rate` (~8 s): finite and non-negative. - let transfer = Duration::from_secs_f64(estimated_bytes as f64 / rate as f64); + .max(DEADLINE_MIN_BYTES_PER_SEC); + // Peer and node admission bound the outstanding byte estimates. + let transfer = Duration::from_secs_f64(expected_transfer_bytes as f64 / rate as f64); queued_at + request_timeout + transfer } } @@ -385,7 +373,7 @@ mod tests { TIMEOUT, RESCUE, 2_000_000, - None, + Some(1024 * 1024), ); assert_eq!( deadline, @@ -393,6 +381,43 @@ mod tests { ); } + #[test] + fn unmeasured_floor_probe_gets_the_normal_bounded_deadline() { + let now = Instant::now(); + for size in [1, 2_000_000] { + let deadline = + request_deadline(RequestPriority::Floor, now, TIMEOUT, RESCUE, size, None); + assert_eq!( + deadline, + request_deadline( + RequestPriority::AboveFloor, + now, + TIMEOUT, + RESCUE, + size, + None, + ) + ); + assert!(deadline > now + TIMEOUT); + assert!(deadline < now + Duration::from_secs(16)); + } + } + + #[test] + fn floor_rescue_allows_the_measured_large_body_transfer_time() { + let now = Instant::now(); + let deadline = request_deadline( + RequestPriority::Floor, + now, + TIMEOUT, + RESCUE, + 2 * 1024 * 1024, + Some(512 * 1024), + ); + assert_eq!(deadline, now + RESCUE + Duration::from_secs(4)); + assert!(deadline < now + TIMEOUT); + } + #[test] fn above_floor_deadline_grows_with_body_size() { let now = Instant::now(); diff --git a/crates/zakura-network/src/zakura/block_sync/config.rs b/crates/zakura-network/src/zakura/block_sync/config.rs index 2a0796688d..d0383aa679 100644 --- a/crates/zakura-network/src/zakura/block_sync/config.rs +++ b/crates/zakura-network/src/zakura/block_sync/config.rs @@ -75,12 +75,9 @@ pub const BS_CHECKPOINT_RANGE_BYTE_FLOOR: u64 = MIN_BS_CHECKPOINT_SUBMITTED_BLOCK_APPLIES as u64 * BS_PER_BLOCK_WORST_CASE_BYTES; /// Default block-sync request timeout. pub const DEFAULT_BS_REQUEST_TIMEOUT: Duration = Duration::from_secs(8); -/// Default short leash on a floor (lowest-missing-height) request. -/// -/// A floor request that has not been served within this window is rescued to a -/// faster carrier (returned to the queue + the peer retry-avoided), never letting -/// the contiguous download floor wait on a slow peer. Far tighter than the base -/// `request_timeout`, which governs patient above-floor speculation instead. +/// Base floor-rescue deadline after a fresh delivery-rate measurement. Estimated +/// transfer time is added for this response and earlier unreceived responses. +/// Unmeasured peers use the normal request timeout for their initial probe. pub const DEFAULT_BS_FLOOR_RESCUE_TIMEOUT: Duration = Duration::from_secs(2); /// Request-timeout windows allowed before block-progress liveness parks a session. const BLOCK_PROGRESS_TIMEOUT_REQUESTS: u32 = 4; @@ -103,6 +100,11 @@ pub const DEFAULT_BS_SIZE_DEVIATION_TOLERANCE: u32 = 200; /// only controls how many bounded body frames a server sends before `BlocksDone`. pub const MAX_BS_RESPONSE_BYTES: u32 = DEFAULT_BS_MAX_RESPONSE_BYTES; +/// Encoded payload bytes in either terminal GetBlocks response message. +pub const GET_BLOCKS_TERMINAL_PAYLOAD_BYTES: u64 = 9; + +const DEFAULT_GET_BLOCKS_NODE_ACTIVE_REQUESTS: usize = 64; + /// Default steady-state cwnd gain, percent of the bandwidth-delay product. 300% ramps a /// proven peer up as `1 → 3 → 9 …`; the reliability discount and delay-gradient ceiling /// pull it back if the extra concurrency costs drops or standing queue. @@ -233,7 +235,9 @@ pub struct ZakuraBlockSyncConfig { /// Initial per-peer BBR cwnd (cold-start point), in blocks; converges to the /// BDP-derived target once the first delivery is measured. pub initial_inflight_requests: u32, - /// Maximum total response bytes this node advertises per `GetBlocks` response. + /// Maximum serialized block bytes this node advertises per `GetBlocks` response. + /// Must be at least [`block::MAX_BLOCK_BYTES`] so any single valid block fits. + /// Message discriminators and the response terminator are reserved separately. pub max_response_bytes: u32, /// Maximum estimated bytes reserved for outstanding block-body requests: a /// DoS/pacing bound on in-flight wire data, released at receipt. Received @@ -253,8 +257,10 @@ pub struct ZakuraBlockSyncConfig { /// Timeout for an outstanding block-body range request. #[serde(with = "humantime_serde")] pub request_timeout: Duration, - /// Short leash on a floor request before its height is rescued to a faster - /// carrier. Clamped positive and never above `request_timeout`. + /// Base deadline for a floor request when the peer has a fresh delivery-rate + /// measurement. Unmeasured peers use `request_timeout` to let their initial + /// probe finish. Both deadlines add estimated transfer time. Clamped positive + /// and never above `request_timeout`. #[serde(with = "humantime_serde")] pub floor_rescue_timeout: Duration, /// How long to withhold a block-sync session after it makes no accepted block progress. @@ -316,6 +322,27 @@ pub struct ZakuraBlockSyncConfig { pub floor_bypass_slots: u32, /// Block-sync peer caps and queue limits owned by this service. pub peer_limits: ServicePeerLimits, + /// Resource policy for serving inbound `GetBlocks` requests. + pub get_blocks_regulation: GetBlocksRegulationConfig, +} + +/// Node and peer bounds applied before GetBlocks state work starts. +/// +/// Each session has one response producer, held through state work and transport +/// writes. Node capacity returns when the last query, result, or frame owner drops. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct GetBlocksRegulationConfig { + /// State queries and responses that may remain active across all peers. + pub node_active_requests: usize, +} + +impl Default for GetBlocksRegulationConfig { + fn default() -> Self { + Self { + node_active_requests: DEFAULT_GET_BLOCKS_NODE_ACTIVE_REQUESTS, + } + } } fn deserialize_ignored_replace_legacy_syncer<'de, D>(deserializer: D) -> Result @@ -359,6 +386,7 @@ impl Default for ZakuraBlockSyncConfig { bbr_cwnd_unit: CwndUnit::Bytes, floor_bypass_slots: DEFAULT_BS_FLOOR_BYPASS_SLOTS, peer_limits: ServicePeerLimits::default(), + get_blocks_regulation: GetBlocksRegulationConfig::default(), } } } @@ -465,6 +493,7 @@ impl ZakuraBlockSyncConfig { if self.bbr_probe_rtt_interval <= self.bbr_probe_rtt_duration { return Err("bbr_probe_rtt_interval must exceed bbr_probe_rtt_duration"); } + super::serving_regulation::validate_config(self)?; Ok(()) } diff --git a/crates/zakura-network/src/zakura/block_sync/events.rs b/crates/zakura-network/src/zakura/block_sync/events.rs index d0246976d3..d80f808265 100644 --- a/crates/zakura-network/src/zakura/block_sync/events.rs +++ b/crates/zakura-network/src/zakura/block_sync/events.rs @@ -21,10 +21,11 @@ pub struct BlockSyncBlockMeta { /// The routine forwards only shared concerns to the reactor through [`RoutineToReactor`]. #[derive(Clone, Debug)] pub enum BlockSyncEvent { - /// A peer became available for stream-6 block sync. + /// Direct session injection for reactor unit tests. + #[cfg(test)] PeerConnected(BlockSyncPeerSession), - /// A peer disconnected. - /// The routine drops all work owned by that peer. + /// Direct disconnection injection for reactor unit tests. + #[cfg(test)] PeerDisconnected(ZakuraPeerId), /// An authenticated local operator requested a fresh retry of one persistent alarm. RetryBodyAvailability { @@ -78,28 +79,6 @@ pub enum BlockSyncEvent { /// Typed, evidence-bearing verifier outcome. outcome: BlockApplyOutcome, }, - /// Node wiring finished or abandoned a `Block` response to an inbound `GetBlocks`. - BlockRangeResponseFinished { - /// Peer whose served-response slot can be released. - peer: ZakuraPeerId, - /// First requested height. - start_height: block::Height, - /// Requested block count. - requested_count: u32, - /// Number of blocks read from state and sent in the response. - returned_count: u32, - }, - /// State returned committed bodies requested by a peer and the reactor should send them. - BlockRangeResponseReady { - /// Peer whose inbound request is being served. - peer: ZakuraPeerId, - /// First requested height. - start_height: block::Height, - /// Requested block count. - requested_count: u32, - /// Bounded committed blocks returned by state. - blocks: Vec<(block::Height, Arc, usize)>, - }, } /// Result of applying a block-sync body through the verifier driver. @@ -269,15 +248,6 @@ pub enum BlockSyncAction { /// Atomic durable coordinates that own this state query and its result. scope: zakura_header_chain::BodyWorkAuthority, }, - /// Ask node wiring to read committed bodies for an inbound `GetBlocks`. - QueryBlocksByHeightRange { - /// Peer that requested the range. - peer: ZakuraPeerId, - /// First height. - start: block::Height, - /// Maximum count. - count: u32, - }, /// Parent-first body ready for B3's verifier/commit driver. SubmitBlock { /// Exact network request that owns this verifier submission. @@ -331,7 +301,6 @@ impl BlockSyncAction { pub(super) fn metric_label(&self) -> &'static str { match self { Self::QueryNeededBlocks { .. } => "query_needed_blocks", - Self::QueryBlocksByHeightRange { .. } => "query_blocks_by_height_range", Self::SubmitBlock { .. } => "submit_block", Self::RecordBodyUnavailable { .. } => "record_body_unavailable", Self::RecordBodyInvalid { .. } => "record_body_invalid", @@ -349,10 +318,6 @@ pub enum BlockSyncMisbehavior { MalformedMessage, /// A peer sent blocks that were not requested. UnsolicitedBlock, - /// A peer requested more blocks than this node advertised it can serve. - GetBlocksTooLong, - /// A peer exceeded this node's inbound `GetBlocks` serving budget. - GetBlocksSpam, /// A peer supplied a body whose payload does not match its requested header. BodyPayloadMismatch(zakura_header_chain::BodyPayloadMismatch), /// A commitment-matching body deterministically failed consensus. @@ -376,10 +341,9 @@ pub enum BlockSyncMisbehavior { /// Each per-peer pipe-routine ([`PeerRoutine`](super::peer_routine)) decodes its /// own frames and runs the download logic locally; it forwards only the concerns /// that need reactor-global state (serving, status advertisement, the producer, -/// misbehavior aggregation) over this channel. The sender is `try_send`/bounded -/// so a busy reactor never backpressures a routine's decode loop into stalling -/// its transport (the only blocking routine send is the Sequencer `AcceptBody`). -#[derive(Clone, Debug)] +/// misbehavior aggregation) over this channel. Serving waits for channel capacity +/// independently of stream reads; control notifications use bounded `try_send`. +#[derive(Debug)] pub(super) enum RoutineToReactor { /// A routine received a `Status` and updated its own servable/caps + the /// registry. The reactor advertises our `Status` reply and republishes the @@ -391,16 +355,6 @@ pub(super) enum RoutineToReactor { /// Whether the rate meter allows sending a `Status` reply now. send_reply: bool, }, - /// A peer requested OUR committed blocks (serving). The reactor runs the - /// state query + driver path and sends via the peer's session clone. - ServeGetBlocks { - /// Peer that requested the range. - peer: ZakuraPeerId, - /// First requested height. - start_height: block::Height, - /// Requested block count. - count: u32, - }, /// A routine drained its pending work; the producer should re-query (it /// self-gates on low-water, so the ping is idempotent/cheap). RequeryNeeded, diff --git a/crates/zakura-network/src/zakura/block_sync/mod.rs b/crates/zakura-network/src/zakura/block_sync/mod.rs index 6d500acd89..8a728dbd2a 100644 --- a/crates/zakura-network/src/zakura/block_sync/mod.rs +++ b/crates/zakura-network/src/zakura/block_sync/mod.rs @@ -5,7 +5,7 @@ //! commit pipeline, the registry, and the reactor. use std::{ - collections::{BTreeMap, HashMap, HashSet, VecDeque}, + collections::{BTreeMap, HashMap, HashSet}, io::{self, Cursor, Read, Write}, sync::{Arc, Mutex as StdMutex}, time::{Duration, Instant}, @@ -46,6 +46,8 @@ mod request; mod sequencer; mod sequencer_task; mod service; +mod serving; +mod serving_regulation; mod state; #[cfg(test)] mod tests; @@ -64,7 +66,10 @@ pub use bench::{ }; #[cfg(test)] pub(crate) use config::MIN_BS_CHECKPOINT_SUBMITTED_BLOCK_APPLIES; -pub use config::{BlockSyncStatus, CwndUnit, ZakuraBlockSyncConfig, MAX_BS_RESPONSE_BYTES}; +pub use config::{ + BlockSyncStatus, CwndUnit, GetBlocksRegulationConfig, ZakuraBlockSyncConfig, + MAX_BS_RESPONSE_BYTES, +}; pub use error::BlockSyncWireError; pub use events::{ BlockApplyOutcome, BlockApplyResult, BlockApplyToken, BlockSyncAction, BlockSyncBlockMeta, @@ -78,11 +83,13 @@ pub use service::BlockSyncPeerSession; pub(crate) use service::BlockSyncService; #[cfg(test)] pub(crate) use service::MAX_BS_FRAME_BYTES; +pub use serving::{BlockRangeRead, BlockRangeReadResult, BlockRangeSource}; pub use state::{BlockSyncFrontiers, BlockSyncHandle, BlockSyncStartup}; pub use wire::{ BlockSyncMessage, MAX_BS_BLOCKS_PER_REQUEST, MAX_BS_MESSAGE_BYTES, MSG_BS_BLOCK, MSG_BS_BLOCKS_DONE, MSG_BS_GET_BLOCKS, MSG_BS_RANGE_UNAVAILABLE, MSG_BS_STATUS, - ZAKURA_BLOCK_SYNC_STREAM_VERSION, ZAKURA_CAP_BLOCK_SYNC, ZAKURA_STREAM_BLOCK_SYNC, + ZAKURA_BLOCK_SYNC_STREAM_VERSION, ZAKURA_CAP_BLOCK_SYNC, ZAKURA_STREAM_BLOCK_REQUESTS, + ZAKURA_STREAM_BLOCK_SYNC, }; #[cfg(test)] @@ -145,3 +152,5 @@ pub(crate) fn test_block_apply_outcome(result: BlockApplyResult) -> BlockApplyOu } } } + +pub use serving_regulation::BlockRangeReadLease; diff --git a/crates/zakura-network/src/zakura/block_sync/peer_registry.rs b/crates/zakura-network/src/zakura/block_sync/peer_registry.rs index 2af95ebd50..0a1578847e 100644 --- a/crates/zakura-network/src/zakura/block_sync/peer_registry.rs +++ b/crates/zakura-network/src/zakura/block_sync/peer_registry.rs @@ -595,11 +595,37 @@ impl PeerRegistry { } } - /// Remove a peer's entry entirely (disconnect/teardown/admission-reject). + /// Remove a peer only if this session is still current. + /// + /// For example, if session 10 disconnects after the peer reconnects as + /// session 11, cleanup for session 10 must leave session 11 in the registry. + pub(super) fn remove_session(&self, peer: &ZakuraPeerId, session_id: u64) { + let mut peers = self.lock(); + if peers + .get(peer) + .is_some_and(|entry| entry.generation == session_id) + { + peers.remove(peer); + } + } + + /// Remove a unit-test peer injected without a session ID. + #[cfg(test)] pub(super) fn remove(&self, peer: &ZakuraPeerId) { self.lock().remove(peer); } + /// Check whether this is still the peer's current session. + /// + /// A generation is the session ID assigned when a peer joins the registry. + /// Serving checks it before accepting work, so a request from an old session + /// cannot be accepted through a newer connection to the same peer. + pub(super) fn owns_generation(&self, peer: &ZakuraPeerId, generation: u64) -> bool { + self.lock() + .get(peer) + .is_some_and(|entry| entry.generation == generation) + } + /// Publish a freshly-applied `Status` (routine-side, inverted inbound flow): grow /// servable range, clamp the advertised caps, and mark the peer as having sent /// a status. Generation-gated like the other routine writers so a superseded diff --git a/crates/zakura-network/src/zakura/block_sync/peer_routine.rs b/crates/zakura-network/src/zakura/block_sync/peer_routine.rs index 838479bc42..27e290ca14 100644 --- a/crates/zakura-network/src/zakura/block_sync/peer_routine.rs +++ b/crates/zakura-network/src/zakura/block_sync/peer_routine.rs @@ -4,8 +4,8 @@ //! peer's `FramedRecv`. The task decodes each stream-6 frame and runs the download //! logic directly. The reactor does not demultiplex inbound frames or create a //! per-peer `PeerInput` channel. The routine sends only shared concerns to the -//! reactor through [`RoutineToReactor`]. These concerns include `GetBlocks` -//! serving, status advertisements, producer re-query pings, and serving-side +//! reactor through [`RoutineToReactor`]. These concerns include +//! status advertisements, producer re-query pings, and //! misbehavior. The routine owns its `BlockSyncPeerSession`, outstanding requests, //! adaptive outbound window, timeout-recovery slots, servable caps, and fill loop. //! @@ -41,13 +41,11 @@ use super::{ DownloadWindow, LivenessOutcome, OutstandingBlockRange, ReceivedBlockTracker, ThroughputMeter, }, - work_queue::{WorkItem, WorkQueue, WorkReturnOutcome}, + work_queue::{RequestWrite, WorkItem, WorkQueue, WorkReturnOutcome}, BlockSyncMessage, BlockSyncMisbehavior, BlockSyncPeerSession, BlockSyncStatus, ZakuraBlockSyncConfig, ZakuraPeerId, ZakuraTrace, MSG_BS_BLOCK, }; -use crate::zakura::{ - trace::BlockBodySource, Admit, FramedRecv, OrderedSendError, SinkReject, ZakuraConnId, -}; +use crate::zakura::{trace::BlockBodySource, Admit, FramedRecv, SinkReject, ZakuraConnId}; use std::{sync::Arc, time::Duration, time::Instant}; use tokio::time; use zakura_chain::{block, serialization::ZcashSerialize}; @@ -317,9 +315,9 @@ pub(super) struct PeerRoutine { sequencer_input: mpsc::Sender, sequencer_input_bytes: Arc, sequencer_input_decoded_attributed_memory_bytes: Arc, - /// Shared routine-to-reactor channel for serving, status, re-query, and misbehavior events. - /// Bounded `try_send` prevents a busy reactor from stalling the transport decode loop. + /// Shared status, re-query, and misbehavior notifications use `try_send`. routine_to_reactor: mpsc::Sender, + /// Current download frontiers and reset authority. sequencer_view: watch::Receiver, /// Last `reset_epoch` that this routine processed. /// A `view.changed()` event uses the epoch to distinguish a reset from an advance. @@ -413,6 +411,22 @@ impl PeerRoutine { /// reject. A reject returns `Err(SinkReject::protocol(..))` so the supervised /// pipe tears the whole connection down. pub(super) async fn run(mut self) -> Result<(), SinkReject> { + let cancel = self.cancel.clone(); + let result = tokio::select! { + biased; + () = cancel.cancelled() => Ok(()), + result = self.run_inner() => result, + }; + // Transport cancels both roles when either closes. Settle the download + // policy even if cancellation interrupts a local capacity wait. + if result.is_ok() && self.recv.remotely_closed() { + self.handle_remote_stream_closed(Instant::now()) + } else { + result + } + } + + async fn run_inner(&mut self) -> Result<(), SinkReject> { // Local clones so the `Notified` futures below borrow these handles, not // `self` — `self.try_fill()` needs `&mut self` while the notifications are // pinned. The clones share the same underlying `Arc`, so the wakes still @@ -464,7 +478,7 @@ impl PeerRoutine { tokio::select! { biased; _ = self.cancel.cancelled() => return Ok(()), - frame = self.recv.recv(), if outbound_queue_has_capacity => { + frame = self.recv.recv() => { match frame { // Decode the frame and run the download/serving dispatch // in this same task. A protocol reject propagates out so @@ -555,19 +569,11 @@ impl PeerRoutine { match msg { BlockSyncMessage::Status(status) => self.handle_status(status), - BlockSyncMessage::GetBlocks { - start_height, - count, - } => { - // Serving is reactor-owned (state query + driver). Forward the - // request; the reactor serves via the session clone it holds. - let _ = self - .routine_to_reactor - .try_send(RoutineToReactor::ServeGetBlocks { - peer: self.peer.clone(), - start_height, - count, - }); + BlockSyncMessage::GetBlocks { .. } => { + return Err(SinkReject::protocol(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "GetBlocks belongs on the request stream", + ))); } BlockSyncMessage::Block(block) => { self.trace_wake("own_body"); @@ -586,6 +592,24 @@ impl PeerRoutine { Ok(()) } + /// Return only unreceived work still owned by this routine. A body already + /// handed to the sequencer keeps its ownership and cannot be requeued here. + fn return_unreceived_requests(&mut self, reason: &'static str) { + let outstanding_ranges = std::mem::take(&mut self.window.outstanding); + for outstanding in outstanding_ranges { + let unreceived: Vec<_> = unreceived_heights(&outstanding).collect(); + let outcome = self + .work + .release_reserved_and_return_items_detailed_for_owner( + outstanding.request.owner, + unreceived.iter().copied(), + ); + self.budget.release(outcome.released_bytes); + self.trace_work_returned(reason, &outstanding, unreceived.len(), outcome); + } + self.registry.clear_outstanding(&self.peer, self.generation); + } + async fn reserve_body_decode_permit( &self, ) -> Result, SinkReject> { @@ -628,6 +652,7 @@ impl PeerRoutine { // the remote's later Status retry arriving after this meter reopens. let send_reply = self.status_reply_meter.try_take(now); self.received_status = true; + self.session.mark_status_received(); self.servable_low = status.servable_low; self.servable_high = status.servable_high; self.max_blocks_per_response = @@ -674,18 +699,7 @@ impl PeerRoutine { // dropped successor heights. Return our unreceived outstanding to // `work.pending` (a no-op for heights already dropped from `in_flight` by // `reset_above`) and release their reservations exactly once. - let outstanding = std::mem::take(&mut self.window.outstanding); - for outstanding in outstanding { - let unreceived: Vec<_> = unreceived_heights(&outstanding).collect(); - let outcome = self - .work - .release_reserved_and_return_items_detailed_for_owner( - outstanding.request.owner, - unreceived.iter().copied(), - ); - self.budget.release(outcome.released_bytes); - self.trace_work_returned("view_reset", &outstanding, unreceived.len(), outcome); - } + self.return_unreceived_requests("view_reset"); self.retry_avoid.clear(); // Clear our (now-empty) registry outstanding and refresh slot diagnostics. self.publish_outstanding(); @@ -693,7 +707,7 @@ impl PeerRoutine { // no-progress probe streak must not stay charged: reset it (and clear the idle // liveness deadline) so an unproven peer whose only probe was in flight at the // reset can probe again instead of wedging at its cap. - self.window.note_view_reset(); + self.window.note_locally_returned_requests(); // Ping the producer immediately: `reset_above` emptied `pending`, and the // reactor's post-reset query may have run while our (now cleared) outstanding // still inflated the low-water gate. Without this ping a routine that then @@ -773,6 +787,7 @@ impl PeerRoutine { // `&'static str` reason via `break`; a pass that issues nothing (`fill_sent == 0`) // is a candidate bubble. let mut fill_sent = 0u32; + let request_sender = self.session.request_sender(); let fill_stop: FillStop = loop { // Floor bypass scaled by reliability: a healthy saturated carrier keeps the // full bypass so the floor keeps moving; a failing/sealed peer earns *no* @@ -799,6 +814,21 @@ impl PeerRoutine { if floor_slots == 0 { break FillStop::CwndSaturated; } + // Reserve transport capacity before taking work or charging bytes. + let slot = match request_sender.try_reserve_guarded() { + Ok(slot) => slot, + Err(crate::zakura::transport::GuardedReserveError::Full) => { + break FillStop::OutboundFull + } + Err(_) => { + self.session.cancel_token().cancel(); + break FillStop::SendError; + } + }; + let Some(request_id) = self.next_request_id else { + break FillStop::Internal; + }; + self.next_request_id = request_id.get().checked_add(1).and_then(NonZeroU64::new); let in_bypass = normal_slots == 0; let (servable_low, servable_high) = (self.servable_low, self.servable_high); @@ -854,11 +884,13 @@ impl PeerRoutine { .window .cwnd_byte_headroom_at(floor_bonus, now) .unwrap_or(u64::MAX); - items = self.work.take_in_range_budgeted( + items = self.work.take_for_request( servable_low, grant.take_high, max_count, grant.max_request_bytes.min(floor_cwnd_cap).max(1), + self.generation, + request_id, ); } AdmissionOutcome::LookaheadAtCap => break FillStop::LookaheadCap, @@ -899,11 +931,13 @@ impl PeerRoutine { .window .cwnd_byte_headroom_at(0, now) .unwrap_or(u64::MAX); - items = self.work.take_in_range_budgeted( + items = self.work.take_for_request( servable_low, grant.take_high, max_count, grant.max_request_bytes.min(above_cwnd_cap), + self.generation, + request_id, ); } // A floor-priority start while the floor arm deferred to a @@ -924,7 +958,7 @@ impl PeerRoutine { // with heights this routine recently *failed* (RangeUnavailable / // timeout / send-failure), quietly put those back so another peer can // contest them first, and only keep the suffix this routine is allowed - // to re-take. `return_items_quiet` does NOT notify (the other peers were + // to re-take. `return_unpublished` does NOT notify (the other peers were // already woken by the original failure return), so this cannot // self-wake into a take/return spin. If the whole chunk is still // avoided, break — the routine wakes to retry when the avoid window @@ -942,22 +976,20 @@ impl PeerRoutine { let Some(keep) = first_allowed_run(&items, |(height, item)| is_allowed(height, item)) else { - let avoided: Vec<_> = items.iter().map(|(h, _)| *h).collect(); - self.work.return_items_quiet(avoided); + self.work.return_unpublished(&items); retry_filter_deadline = Some(self.retry_filter_wake_deadline(now)); break FillStop::RetryAvoid; }; let keep_len = keep.len(); let mut returned_avoided = false; if keep.start > 0 { - let avoided: Vec<_> = items.drain(..keep.start).map(|(h, _)| h).collect(); - self.work.return_items_quiet(avoided); + let avoided: Vec<_> = items.drain(..keep.start).collect(); + self.work.return_unpublished(&avoided); returned_avoided = true; } if keep_len < items.len() { let avoided = items.split_off(keep_len); - self.work - .return_items_quiet(avoided.into_iter().map(|(height, _)| height)); + self.work.return_unpublished(&avoided); returned_avoided = true; } if returned_avoided { @@ -999,39 +1031,17 @@ impl PeerRoutine { self.return_taken_items(&items); break FillStop::Budget; } - let Some(request_id) = self.next_request_id else { - self.budget.release(reserved_bytes); - self.return_taken_items(&items); - break FillStop::Internal; - }; - self.next_request_id = request_id.get().checked_add(1).and_then(NonZeroU64::new); let owner = scope.bind(self.generation, request_id); - let marked = self - .work - .mark_reserved_for_owner(owner, items.iter().map(|(height, _)| *height)); - if marked != reserved_bytes { - self.budget.release(reserved_bytes); - let _ = self - .work - .release_reserved_and_return_items_detailed_for_owner( - owner, - items.iter().map(|(height, _)| *height), - ); - break FillStop::Internal; - } - + let claim = RequestWrite::new( + owner, + items.clone(), + self.work.clone(), + self.budget.clone(), + self.session.cancel_token(), + ); let count = match u32::try_from(kept_count) { Ok(count) => count, - Err(_) => { - let released = self - .work - .release_reserved_and_return_items_detailed_for_owner( - owner, - items.iter().map(|(height, _)| *height), - ); - self.budget.release(released.released_bytes); - break FillStop::Internal; - } + Err(_) => break FillStop::Internal, }; let request = BlockRangeRequest { owner, @@ -1057,62 +1067,54 @@ impl PeerRoutine { start_height: request.start_height, count: request.count, }; - if let Err(error) = self - .session - .try_send_get_blocks(request.start_height, request.count) - { - tracing::debug!( - peer = ?self.peer, - start_height = ?request.start_height, - count = request.count, - ?error, - "failed to queue Zakura block-sync GetBlocks" - ); - self.trace_queue_send_failed(&msg, &error); - // Return every still-reserved height to the queue. A competing - // peer's late body may have claimed a taken height and released its - // request reservation during the reserve await; leave that height - // in flight rather than re-queueing or releasing it twice. - let released = self - .work - .release_reserved_and_return_items_detailed_for_owner( - request.owner, - items.iter().map(|(height, _)| *height), - ); - self.budget.release(released.released_bytes); - if matches!(error, OrderedSendError::Full) { - break FillStop::OutboundFull; + let frame = match msg.encode_frame() { + Ok(frame) => frame, + Err(_) => { + self.session.cancel_token().cancel(); + break FillStop::SendError; } - self.session.cancel_token().cancel(); - break FillStop::SendError; - } + }; let deadline = request_deadline( request_priority, queued_at, self.config.request_timeout, self.config.effective_floor_rescue_timeout(), - reserved_bytes, + // Responses share an ordered stream. Include earlier unreceived + // work so this request cannot expire while those bodies arrive. + self.window + .outstanding_reserved_bytes() + .saturating_add(reserved_bytes), // Filter BtlBw by the request's send time so a stale-high rate from a // now-slow peer cannot tighten the deadline below what it can meet. self.window.bbr_btlbw_bytes_per_sec(queued_at), ); + let request_start_height = request.start_height; + let request_count = request.count; + let request_estimated_bytes = request.estimated_bytes; + let mut delivered = false; + if !claim.publish(|| { + self.window.outstanding.push(OutstandingBlockRange { + request, + queued_at, + deadline, + delivery_snapshot: self.window.delivery_snapshot(queued_at), + delivered_bytes: 0, + received: ReceivedBlockTracker::default(), + }); + delivered = slot.send_request(frame, claim.clone()); + }) { + break FillStop::Internal; + } + if !delivered { + claim.delivery_failed(); + break FillStop::SendError; + } metrics::counter!("sync.block.request.sent").increment(1); if in_bypass { // A floor request borrowed a bypass slot while the cwnd was saturated. metrics::counter!("sync.block.request.floor_bypass").increment(1); } - let request_start_height = request.start_height; - let request_count = request.count; - let request_estimated_bytes = request.estimated_bytes; - self.window.outstanding.push(OutstandingBlockRange { - request, - queued_at, - deadline, - delivery_snapshot: self.window.delivery_snapshot(queued_at), - delivered_bytes: 0, - received: ReceivedBlockTracker::default(), - }); self.window .arm_liveness(queued_at, self.config.effective_liveness_timeout()); self.publish_outstanding(); @@ -1219,8 +1221,7 @@ impl PeerRoutine { /// not re-wake its own want-work arm into a take/return spin, and any other /// peer waiting on budget capacity is woken by the matching `budget.release`. fn return_taken_items(&self, items: &[(block::Height, WorkItem)]) { - self.work - .return_items_quiet(items.iter().map(|(height, _)| *height)); + self.work.return_unpublished(items); } /// Record heights this routine just returned on a failure so it will not @@ -1294,14 +1295,9 @@ impl PeerRoutine { self.config.request_timeout, ) => { - // Outbound full but *only just* filled (< one `request_timeout` of - // continuous backpressure): plausibly transient local write congestion, not - // a dead peer. While outbound is full the select loop does not drain inbound - // frames (`if outbound_queue_has_capacity`), so a block the peer already sent - // may be waiting behind our write side. Grant one short, BOUNDED grace. This - // is the *only* liveness extension: a peer that stopped reading holds outbound - // full past `request_timeout`, falls through to the park arm, and is - // parked at the liveness deadline — it cannot dodge the timer. + // A briefly full outbound queue may mean our request has not + // reached the peer yet. Keep the existing bounded write grace; + // responses are read independently of that queue. self.window .extend_liveness_deadline(now, self.config.request_timeout); Ok(()) @@ -1353,8 +1349,8 @@ impl PeerRoutine { /// regardless of `outstanding`, so parking here only moves the already /// scheduled outcome earlier. Frames are processed in-order in this task, so /// at EOF everything the peer sent has already been counted. The liveness - /// grace does not apply: it waits for in-flight frames stuck behind our full - /// outbound queue, and a closed stream has none. + /// grace does not apply: the peer closed its stream without answering the + /// requests we still own. fn handle_remote_stream_closed(&mut self, now: Instant) -> Result<(), SinkReject> { if self.window.outstanding.is_empty() && self.window.block_liveness_deadline.is_none() { return Ok(()); @@ -2107,7 +2103,12 @@ impl PeerRoutine { BTreeMap::new(); for outstanding in &self.window.outstanding { for expected in &outstanding.request.expected_blocks { - if !outstanding.has_received(expected.height) { + // A scheduler or reset may retire a height while this routine + // waits. Publish only work that still belongs to this request. + if !outstanding.has_received(expected.height) + && self.work.owner_for_height(expected.height) + == Some(outstanding.request.owner) + { map.insert( expected.height, super::peer_registry::OutstandingMeta { @@ -2225,25 +2226,7 @@ impl Drop for PeerRoutine { /// The reactor owns entry insert (on connect) and remove (on disconnect/ /// admission-reject); see `handle_peer_disconnected`. fn drop(&mut self) { - let outstanding_ranges = std::mem::take(&mut self.window.outstanding); - for outstanding in outstanding_ranges { - let unreceived: Vec<_> = outstanding - .request - .expected_blocks - .iter() - .filter(|expected| !outstanding.has_received(expected.height)) - .map(|expected| expected.height) - .collect(); - let outcome = self - .work - .release_reserved_and_return_items_detailed_for_owner( - outstanding.request.owner, - unreceived.iter().copied(), - ); - self.budget.release(outcome.released_bytes); - self.trace_work_returned("peer_routine_drop", &outstanding, unreceived.len(), outcome); - } - self.registry.clear_outstanding(&self.peer, self.generation); + self.return_unreceived_requests("peer_routine_drop"); } } @@ -2519,8 +2502,6 @@ mod tests { ); } - /// Routine teardown must not release or requeue a height already received - /// through first-completion-wins. #[tokio::test] async fn routine_drop_leaves_a_body_won_by_another_peer_to_the_sequencer() { let config = ZakuraBlockSyncConfig::default(); diff --git a/crates/zakura-network/src/zakura/block_sync/peer_routine/trace.rs b/crates/zakura-network/src/zakura/block_sync/peer_routine/trace.rs index 117b7e98e1..78f5740edf 100644 --- a/crates/zakura-network/src/zakura/block_sync/peer_routine/trace.rs +++ b/crates/zakura-network/src/zakura/block_sync/peer_routine/trace.rs @@ -1,6 +1,6 @@ use super::super::trace::{ block_sync_message_label, elapsed_us, height as trace_height, peer as trace_peer, - saturating_usize, BlockTraceEvent, BlockTraceFields, BoolOrU64, QueueSendFailedEvent, + saturating_usize, BlockTraceEvent, BlockTraceFields, BoolOrU64, }; use super::*; use crate::zakura::trace::block_sync_trace as bs_trace; @@ -109,18 +109,6 @@ impl PeerRoutine { }); } - pub(super) fn trace_queue_send_failed(&self, msg: &BlockSyncMessage, error: &OrderedSendError) { - self.trace.emit_event(|| { - QueueSendFailedEvent::peer_routine( - &self.peer, - msg, - error, - self.session.outbound_capacity(), - self.session.outbound_max_capacity(), - ) - }); - } - pub(super) fn trace_get_blocks_sent( &self, start_height: block::Height, diff --git a/crates/zakura-network/src/zakura/block_sync/reactor.rs b/crates/zakura-network/src/zakura/block_sync/reactor.rs index b467e583f1..fa35d3dafa 100644 --- a/crates/zakura-network/src/zakura/block_sync/reactor.rs +++ b/crates/zakura-network/src/zakura/block_sync/reactor.rs @@ -62,18 +62,6 @@ struct FloorGapDiagnostics { next_deadline_ms: Option, } -#[derive(Copy, Clone, Debug)] -struct RangeResponseTrace { - start_height: block::Height, - requested_count: u32, - sent_count: u32, - sent_bytes: u64, - reason: &'static str, - prepare_elapsed: Option, - send_elapsed: Duration, - total_elapsed: Option, -} - #[derive(Copy, Clone, Debug, Eq, PartialEq)] struct PendingNeededQuery { query_id: NonZeroU64, @@ -143,6 +131,9 @@ pub fn spawn_block_sync_reactor( mpsc::channel(startup.config.peer_limits.inbound_queue_depth.max(1)); let events_keepalive = events_tx.clone(); let (lifecycle_tx, lifecycle_rx) = mpsc::unbounded_channel(); + let lifecycle_keepalive = lifecycle_tx.clone(); + let current_sessions = super::service::CurrentSessions::new(); + let sessions_changed = current_sessions.subscribe(); let (needed_query_failure_tx, needed_query_failure_rx) = mpsc::unbounded_channel(); let needed_query_failure_keepalive = needed_query_failure_tx.clone(); // Size the action channel so the Sequencer can dispatch a full checkpoint @@ -224,9 +215,11 @@ pub fn spawn_block_sync_reactor( // and written by the routines (servable/caps/outstanding) and the reactor // (admission/teardown entry insert/remove). // The shared routine→reactor channel: every per-peer pipe-routine forwards its - // serving / status-advertise / re-query / serving-misbehavior concerns here. + // status-advertise / re-query / misbehavior concerns here. let (routine_to_reactor_tx, routine_to_reactor_rx) = mpsc::channel(ROUTINE_TO_REACTOR_DEPTH); let routine_to_reactor_keepalive = routine_to_reactor_tx.clone(); + let serving_regulator = + super::serving_regulation::GetBlocksServingRegulator::new(startup.config.clone()); // The shared download primitives every pipe-routine is wired with at spawn // (`service::add_peer`), carried through the handle. @@ -243,13 +236,16 @@ pub fn spawn_block_sync_reactor( #[cfg(test)] actions: actions_tx.clone(), routine_to_reactor: routine_to_reactor_tx, + serving_regulator: serving_regulator.clone(), view: sequencer_view_rx.clone(), trace: startup.trace.clone(), }; let handle = BlockSyncHandle { + range_source: None, events: events_tx, lifecycle: lifecycle_tx, + current_sessions: current_sessions.clone(), needed_query_failures: needed_query_failure_tx, peers: peers_rx, status: status_rx, @@ -257,6 +253,7 @@ pub fn spawn_block_sync_reactor( routine_wiring: Some(routine_wiring), }; let reactor = BlockSyncReactor { + serving_regulator, verified_block_tip: startup.frontiers.verified_block_tip, request_floor: startup.frontiers.verified_block_tip, pending_needed_query: None, @@ -281,6 +278,9 @@ pub fn spawn_block_sync_reactor( events: events_rx, _events_keepalive: events_keepalive, lifecycle: lifecycle_rx, + _lifecycle_keepalive: lifecycle_keepalive, + current_sessions, + sessions_changed, needed_query_failures: needed_query_failure_rx, _needed_query_failure_keepalive: needed_query_failure_keepalive, actions: actions_tx, @@ -302,6 +302,7 @@ pub fn spawn_block_sync_reactor( #[derive(Debug)] pub(super) struct BlockSyncReactor { + serving_regulator: super::serving_regulation::GetBlocksServingRegulator, startup: BlockSyncStartup, state: BlockSyncState, /// Latest atomic header-engine view used to stamp body-work ownership. @@ -311,20 +312,22 @@ pub(super) struct BlockSyncReactor { /// only entry insert (admission) / remove (teardown). registry: Arc, events: mpsc::Receiver, - /// A keep-alive sender clone for the bounded driver-event channel so the - /// receiver never resolves to `None` while the reactor lives. The service no - /// longer stores an `events` sender (it was only used by the deleted - /// `deliver_frame` pipe path), so without this the channel would close as soon - /// as a consumer moved (not cloned) the handle. The reactor never sends on it. + /// Keep the driver-event channel open if the service takes ownership of the + /// last external handle. The reactor never sends through this clone. _events_keepalive: mpsc::Sender, lifecycle: mpsc::UnboundedReceiver, + /// Keep the public control path open after the service retains only the + /// current-session table. + _lifecycle_keepalive: mpsc::UnboundedSender, + /// Service-owned lifecycle facts with exact session identity. + current_sessions: Arc, + sessions_changed: watch::Receiver<()>, needed_query_failures: mpsc::UnboundedReceiver, /// Keep the private driver-completion channel open while the reactor lives. _needed_query_failure_keepalive: mpsc::UnboundedSender, actions: mpsc::Sender, - /// Shared routine→reactor channel: serving (`ServeGetBlocks`), status - /// advertisement (`StatusReceived`), the producer re-query ping - /// (`RequeryNeeded`), and serving-side misbehavior (`Misbehavior`). + /// Shared routine notifications for Status, needed-work refreshes, and + /// peer misbehavior. Serving requests stay in their session's task. routine_to_reactor: mpsc::Receiver, /// A keep-alive sender clone so the receiver never resolves to `None` while /// the reactor lives, even before any peer connects or after all disconnect. @@ -433,6 +436,10 @@ impl BlockSyncReactor { let Some(event) = event else { break }; self.handle_event(event).await; } + changed = self.sessions_changed.changed() => { + if changed.is_err() { break; } + self.reconcile_sessions().await; + } failure = self.needed_query_failures.recv() => { let Some(failure) = failure else { break }; self.handle_needed_blocks_query_failed(failure.query_id, failure.scope); @@ -607,8 +614,21 @@ impl BlockSyncReactor { .trace .emit_event(|| BlockEventReceived::new(&event)); match event { + #[cfg(test)] BlockSyncEvent::PeerConnected(session) => self.handle_peer_connected(session).await, - BlockSyncEvent::PeerDisconnected(peer) => self.handle_peer_disconnected(peer), + #[cfg(test)] + BlockSyncEvent::PeerDisconnected(peer) => { + if let Some(session_id) = self + .state + .peers + .get(&peer) + .map(|peer_state| peer_state.session.session_id()) + { + self.handle_peer_disconnected(peer, session_id); + } else { + self.registry.remove(&peer); + } + } BlockSyncEvent::RetryBodyAvailability { hash } => self.retry_body_availability(hash), #[cfg(any(test, feature = "proptest-impl"))] BlockSyncEvent::HeaderTipChanged { height, hash } => { @@ -662,28 +682,45 @@ impl BlockSyncReactor { ) .await } - BlockSyncEvent::BlockRangeResponseReady { - peer, - start_height, - requested_count, - blocks, - } => { - self.handle_block_range_response_ready(peer, start_height, requested_count, blocks) - .await; + } + self.publish_metrics(); + } + + /// The watch is marked observed before taking this snapshot. Admissions + /// during this pass schedule another pass; old cleanup uses exact generations. + async fn reconcile_sessions(&mut self) { + let current = self.current_sessions.snapshot(); + let removed: Vec<_> = self + .state + .peers + .iter() + .filter_map(|(peer, state)| { + let id = state.session.session_id(); + (!current + .get(peer) + .is_some_and(|session| session.session_id() == id)) + .then(|| (peer.clone(), id)) + }) + .collect(); + for (peer, id) in removed { + self.handle_peer_disconnected(peer, id); + } + for (peer, session) in current { + let id = session.session_id(); + if self + .state + .peers + .get(&peer) + .is_some_and(|state| state.session.session_id() == id) + { + continue; } - BlockSyncEvent::BlockRangeResponseFinished { - peer, - start_height, - requested_count, - returned_count, - } => { - self.handle_block_range_response_finished( - peer, - start_height, - requested_count, - returned_count, - ) - .await; + if session.cancel_token().is_cancelled() || !self.registry.owns_generation(&peer, id) { + session.cancel_token().cancel(); + session.mark_reactor_ready(); + self.registry.remove_session(&peer, id); + } else { + self.handle_peer_connected(session).await; } } self.publish_metrics(); @@ -757,7 +794,26 @@ impl BlockSyncReactor { async fn handle_peer_connected(&mut self, session: BlockSyncPeerSession) { let peer = session.peer_id().clone(); + let session_id = session.session_id(); let direction = session.direction(); + let reactor_ready = session.clone(); + if let Some(current_id) = self + .state + .peers + .get(&peer) + .map(|peer_state| peer_state.session.session_id()) + { + if current_id == session_id { + reactor_ready.mark_reactor_ready(); + return; + } + if current_id > session_id { + session.cancel_token().cancel(); + self.registry.remove_session(&peer, session_id); + reactor_ready.mark_reactor_ready(); + return; + } + } let decision = self.admission_decision_for(&peer, direction); if decision != ServiceAdmissionDecision::Admit { // Reject: cancel the session (which also cancels the already-spawned @@ -770,15 +826,14 @@ impl BlockSyncReactor { ?decision, "locally parking Zakura block-sync service session" ); - self.state.parked_peers.insert(peer.clone()); session.cancel_token().cancel(); - self.registry.remove(&peer); + self.registry.remove_session(&peer, session_id); self.publish_peer_snapshot(); self.publish_candidate_state(); + reactor_ready.mark_reactor_ready(); return; } - self.state.parked_peers.remove(&peer); // inverted inbound flow: the per-peer pipe-routine was already spawned by // `service::add_peer` (the pipe spawn point), wired with the shared // primitives and its registry generation. The reactor keeps only a thin @@ -792,25 +847,31 @@ impl BlockSyncReactor { self.publish_peer_snapshot(); self.publish_candidate_state(); self.send_status_and_mark_refresh(&peer, "peer_connected", Instant::now()); + reactor_ready.mark_reactor_ready(); // The routine fills its own slots; it begins want-work as soon as it has // a status and work. } - fn handle_peer_disconnected(&mut self, peer: ZakuraPeerId) { + fn handle_peer_disconnected(&mut self, peer: ZakuraPeerId, session_id: u64) { // The pipe-routine cancels on the session token (transport disconnect); // its `Drop` guard returns its unreceived outstanding heights to // `work.pending` and releases their budget. The reactor only drops its // thin serving handle and the registry entry. + let owns_session = self + .state + .peers + .get(&peer) + .is_some_and(|peer_state| peer_state.session.session_id() == session_id); + if !owns_session { + self.registry.remove_session(&peer, session_id); + return; + } + let received_status = self.registry_received_status(&peer); if self.state.peers.remove(&peer).is_some() { set_block_reactor_active_connection_gauge(self.state.peers.len()); - self.trace_peer_disconnected( - &peer, - self.registry_received_status(&peer), - self.state.peers.len(), - ); + self.trace_peer_disconnected(&peer, received_status, self.state.peers.len()); } - self.registry.remove(&peer); - self.state.parked_peers.remove(&peer); + self.registry.remove_session(&peer, session_id); self.publish_peer_snapshot(); self.publish_candidate_state(); } @@ -1282,16 +1343,6 @@ impl BlockSyncReactor { RoutineToReactor::StatusReceived { peer, send_reply } => { self.handle_status_received(peer, send_reply); } - RoutineToReactor::ServeGetBlocks { - peer, - start_height, - count, - } => { - if self.state.parked_peers.contains(&peer) { - return; - } - self.handle_get_blocks(peer, start_height, count).await; - } RoutineToReactor::RequeryNeeded => { self.query_needed_blocks().await; } @@ -1444,146 +1495,6 @@ impl BlockSyncReactor { Some((snapshot.state_version, header.hash, supplier_set_digest)); } - async fn handle_get_blocks( - &mut self, - peer: ZakuraPeerId, - start_height: block::Height, - count: u32, - ) { - let local_inflight_cap = self.startup.config.advertised_max_inflight_requests(); - if !self.state.peers.contains_key(&peer) { - self.report_misbehavior(peer, BlockSyncMisbehavior::GetBlocksSpam) - .await; - return; - } - - // `received_status` is now a registry fact (written reactor-side on - // `Status`); the serving slots stay on the reactor's thin peer handle. - if !self.registry_received_status(&peer) { - self.report_misbehavior(peer, BlockSyncMisbehavior::GetBlocksSpam) - .await; - return; - } - - if count == 0 { - self.report_misbehavior(peer, BlockSyncMisbehavior::GetBlocksTooLong) - .await; - return; - } - - let started_serving = self.state.peers.get_mut(&peer).is_some_and(|peer_state| { - peer_state.try_start_serving_blocks(local_inflight_cap, start_height) - }); - if !started_serving { - let unavailable_count = count.min(inbound_get_blocks_count_limit(&self.startup.config)); - self.send_range_unavailable(&peer, start_height, unavailable_count); - return; - } - - let requested_count = self.clamp_served_block_count(start_height, count); - if requested_count == 0 { - let unavailable_count = count.min(inbound_get_blocks_count_limit(&self.startup.config)); - self.send_range_unavailable(&peer, start_height, unavailable_count); - self.finish_serving_blocks(&peer, start_height); - return; - } - - if !self.dispatch_action(BlockSyncAction::QueryBlocksByHeightRange { - peer: peer.clone(), - start: start_height, - count: requested_count, - }) { - self.finish_serving_blocks(&peer, start_height); - } - } - - async fn handle_block_range_response_ready( - &mut self, - peer: ZakuraPeerId, - start_height: block::Height, - requested_count: u32, - blocks: Vec<(block::Height, Arc, usize)>, - ) { - let prepare_elapsed = self.serving_blocks_elapsed(&peer, start_height); - let send_started = Instant::now(); - let max_response_bytes = u64::from(self.startup.config.advertised_max_response_bytes()); - let mut sent_blocks = 0u32; - let mut sent_bytes = 0u64; - let mut reason = "complete"; - - for (height, block, size) in blocks { - let Ok(size) = u64::try_from(size) else { - reason = "size_overflow"; - break; - }; - let Some(next_bytes) = sent_bytes.checked_add(size) else { - reason = "byte_overflow"; - break; - }; - if next_bytes > max_response_bytes { - reason = "byte_cap"; - break; - } - if height_after_count(start_height, sent_blocks) != Some(height) { - reason = "non_contiguous"; - break; - } - - if !self.send_block(&peer, block) { - reason = "send_failed"; - break; - } - sent_blocks = sent_blocks.saturating_add(1); - sent_bytes = next_bytes; - } - - if sent_blocks == 0 { - self.send_range_unavailable(&peer, start_height, requested_count); - } else { - self.send_blocks_done(&peer, start_height, sent_blocks); - } - let total_elapsed = self.finish_serving_blocks(&peer, start_height); - self.trace_range_response_sent( - &peer, - RangeResponseTrace { - start_height, - requested_count, - sent_count: sent_blocks, - sent_bytes, - reason, - prepare_elapsed, - send_elapsed: send_started.elapsed(), - total_elapsed, - }, - ); - } - - async fn handle_block_range_response_finished( - &mut self, - peer: ZakuraPeerId, - start_height: block::Height, - requested_count: u32, - returned_count: u32, - ) { - if returned_count == 0 { - self.send_range_unavailable(&peer, start_height, requested_count); - } - let elapsed = self.finish_serving_blocks(&peer, start_height); - self.trace_range_response_sent( - &peer, - RangeResponseTrace { - start_height, - requested_count, - sent_count: returned_count, - sent_bytes: 0, - reason: "driver_finished", - prepare_elapsed: elapsed, - send_elapsed: Duration::ZERO, - total_elapsed: elapsed, - }, - ); - } - #[allow(clippy::too_many_arguments)] async fn handle_block_apply_finished( &mut self, @@ -1645,29 +1556,6 @@ impl BlockSyncReactor { ); } - fn serving_blocks_elapsed( - &self, - peer: &ZakuraPeerId, - start_height: block::Height, - ) -> Option { - self.state - .peers - .get(peer) - .and_then(|peer_state| peer_state.serving_blocks_elapsed(start_height)) - } - - fn finish_serving_blocks( - &mut self, - peer: &ZakuraPeerId, - start_height: block::Height, - ) -> Option { - if let Some(peer_state) = self.state.peers.get_mut(peer) { - peer_state.finish_serving_blocks(start_height) - } else { - None - } - } - async fn query_needed_blocks(&mut self) -> bool { self.query_needed_blocks_with_options(false).await } @@ -1950,154 +1838,6 @@ impl BlockSyncReactor { true } - fn send_block(&self, peer: &ZakuraPeerId, block: Arc) -> bool { - let Some(session) = self - .state - .peers - .get(peer) - .map(|peer_state| peer_state.session.clone()) - else { - return false; - }; - let msg = BlockSyncMessage::Block(block.clone()); - let started = Instant::now(); - match session.try_send_block(block) { - Ok(()) => { - metrics::counter!("sync.block.body.served").increment(1); - self.trace_message_sent(peer, &msg, "queued", started.elapsed()); - true - } - Err(OrderedSendError::Full) => { - metrics::counter!("sync.block.body.serve_queue_full").increment(1); - tracing::debug!(?peer, "Zakura block-sync Block queue is full"); - self.trace_message_sent(peer, &msg, "full", started.elapsed()); - self.trace_queue_send_failed( - peer, - &msg, - &OrderedSendError::Full, - session.outbound_capacity(), - session.outbound_max_capacity(), - None, - ); - false - } - Err(error) => { - tracing::debug!(?peer, ?error, "failed to queue Zakura block-sync Block"); - self.trace_message_sent(peer, &msg, "error", started.elapsed()); - self.trace_queue_send_failed( - peer, - &msg, - &error, - session.outbound_capacity(), - session.outbound_max_capacity(), - None, - ); - session.cancel_token().cancel(); - false - } - } - } - - fn send_blocks_done(&self, peer: &ZakuraPeerId, start_height: block::Height, returned: u32) { - if returned == 0 { - return; - } - let Some(session) = self - .state - .peers - .get(peer) - .map(|peer_state| peer_state.session.clone()) - else { - return; - }; - let msg = BlockSyncMessage::BlocksDone { - start_height, - returned, - }; - let started = Instant::now(); - match session.try_send_blocks_done(start_height, returned) { - Ok(()) => self.trace_message_sent(peer, &msg, "queued", started.elapsed()), - Err(OrderedSendError::Full) => { - metrics::counter!("sync.block.done.serve_queue_full").increment(1); - tracing::debug!(?peer, "Zakura block-sync BlocksDone queue is full"); - self.trace_message_sent(peer, &msg, "full", started.elapsed()); - self.trace_queue_send_failed( - peer, - &msg, - &OrderedSendError::Full, - session.outbound_capacity(), - session.outbound_max_capacity(), - None, - ); - } - Err(error) => { - tracing::debug!( - ?peer, - ?error, - "failed to queue Zakura block-sync BlocksDone" - ); - self.trace_message_sent(peer, &msg, "error", started.elapsed()); - self.trace_queue_send_failed( - peer, - &msg, - &error, - session.outbound_capacity(), - session.outbound_max_capacity(), - None, - ); - session.cancel_token().cancel(); - } - } - } - - fn send_range_unavailable(&self, peer: &ZakuraPeerId, start_height: block::Height, count: u32) { - let count = count.max(1); - let Some(peer_state) = self.state.peers.get(peer) else { - return; - }; - let msg = BlockSyncMessage::RangeUnavailable { - start_height, - count, - }; - let started = Instant::now(); - match peer_state - .session - .try_send_range_unavailable(start_height, count) - { - Ok(()) => self.trace_message_sent(peer, &msg, "queued", started.elapsed()), - Err(OrderedSendError::Full) => { - metrics::counter!("sync.block.unavailable.serve_queue_full").increment(1); - tracing::debug!(?peer, "Zakura block-sync RangeUnavailable queue is full"); - self.trace_message_sent(peer, &msg, "full", started.elapsed()); - self.trace_queue_send_failed( - peer, - &msg, - &OrderedSendError::Full, - peer_state.session.outbound_capacity(), - peer_state.session.outbound_max_capacity(), - None, - ); - } - Err(error) => { - tracing::debug!( - ?peer, - ?error, - "failed to queue Zakura block-sync RangeUnavailable" - ); - self.trace_message_sent(peer, &msg, "error", started.elapsed()); - self.trace_queue_send_failed( - peer, - &msg, - &error, - peer_state.session.outbound_capacity(), - peer_state.session.outbound_max_capacity(), - None, - ); - peer_state.session.cancel_token().cancel(); - } - } - } - async fn flush_status_refresh(&mut self) { // `received_status` is a registry fact now; snapshot which peers have not // sent us their Status so the retry filter below can read it without @@ -2224,6 +1964,7 @@ impl BlockSyncReactor { } fn publish_metrics(&mut self) { + self.serving_regulator.publish_metrics(); // These lossy casts are metrics-only gauges; consensus and scheduling // continue to use the original integer values. let view = *self.sequencer_view.borrow(); @@ -2323,24 +2064,6 @@ impl BlockSyncReactor { } } - fn clamp_served_block_count(&self, start_height: block::Height, count: u32) -> u32 { - if start_height > self.state.servable_high { - return 0; - } - - let available = self - .state - .servable_high - .0 - .checked_sub(start_height.0) - .and_then(|diff| diff.checked_add(1)) - .unwrap_or(0); - - count - .min(inbound_get_blocks_count_limit(&self.startup.config)) - .min(available) - } - fn local_status(&self) -> BlockSyncStatus { BlockSyncStatus { servable_low: block::Height::MIN, @@ -2361,10 +2084,7 @@ impl BlockSyncReactor { .actions .max_capacity() .saturating_sub(self.actions.capacity()); - let peer_action_permits = if matches!( - action, - BlockSyncAction::QueryBlocksByHeightRange { .. } | BlockSyncAction::Misbehavior { .. } - ) { + let peer_action_permits = if matches!(action, BlockSyncAction::Misbehavior { .. }) { // Acquire the peer-action slot and protected control capacity in one // semaphore operation. The Sequencer sends concurrently, so a // separate capacity check followed by `try_send` has a race. diff --git a/crates/zakura-network/src/zakura/block_sync/reactor/trace.rs b/crates/zakura-network/src/zakura/block_sync/reactor/trace.rs index 5f04272e90..a61cceac16 100644 --- a/crates/zakura-network/src/zakura/block_sync/reactor/trace.rs +++ b/crates/zakura-network/src/zakura/block_sync/reactor/trace.rs @@ -303,24 +303,6 @@ impl BlockSyncReactor { }); } - pub(super) fn trace_range_response_sent( - &self, - peer: &ZakuraPeerId, - response: RangeResponseTrace, - ) { - self.emit_block(bs_trace::BLOCK_RANGE_RESPONSE_SENT, |row| { - row.peer = Some(super::super::trace::peer(peer)); - row.range_start = Some(height(response.start_height)); - row.range_count = Some(u64::from(response.sent_count)); - row.expected_count = Some(u64::from(response.requested_count)); - row.serialized_bytes = Some(response.sent_bytes); - row.reason = Some(response.reason); - row.prepare_elapsed_ms = response.prepare_elapsed.map(elapsed_ms); - row.send_elapsed_ms = Some(elapsed_ms(response.send_elapsed)); - row.elapsed_ms = response.total_elapsed.map(elapsed_ms); - }); - } - /// Trace a WorkQueue producer extend (heights newly added to `pending`). pub(super) fn trace_work_extended(&self, inserted: usize) { if !self.startup.trace.is_enabled() { diff --git a/crates/zakura-network/src/zakura/block_sync/service.rs b/crates/zakura-network/src/zakura/block_sync/service.rs index 3f7b655c8f..952d17297a 100644 --- a/crates/zakura-network/src/zakura/block_sync/service.rs +++ b/crates/zakura-network/src/zakura/block_sync/service.rs @@ -1,14 +1,22 @@ -use super::{config::*, events::*, peer_registry::SessionAdmission, wire::*, *}; +use super::{config::*, peer_registry::SessionAdmission, wire::*, *}; use crate::zakura::{ handle_pipe_exit, spawn_supervised_pipe, FramedRecv, FramedSend, OrderedSendError, - OrderedSessionDemand, OrderedStreamOpening, OrderedStreamPolicy, Peer, PeerStreamSession, - Service, ServicePeerSnapshot, SinkReject, Stream, StreamMode, ZakuraBlockSyncCandidateState, - ZakuraConnId, ZakuraPeerId, FRAME_HEADER_BYTES, + OrderedSessionDemand, OrderedStreamOpening, OrderedStreamPair, OrderedStreamPolicy, Peer, + PeerStreamSession, Service, ServicePeerSnapshot, SinkReject, Stream, StreamMode, + ZakuraBlockSyncCandidateState, ZakuraConnId, ZakuraPeerId, FRAME_HEADER_BYTES, }; use std::{ sync::atomic::{AtomicU64, Ordering}, time::Instant, }; +use tokio::sync::Notify; + +mod sessions; +pub(super) use sessions::CurrentSessions; +use sessions::SessionCapacity; + +#[cfg(test)] +mod tests; /// Maximum frame bytes for one stream-6 body frame plus protocol framing. /// @@ -21,35 +29,60 @@ pub const MAX_BS_FRAME_BYTES: u32 = { (MAX_BS_MESSAGE_BYTES + FRAME_HEADER_BYTES) as u32 }; -const BLOCK_SYNC_SERVICE_STREAMS: [Stream; 1] = [Stream { - kind: ZAKURA_STREAM_BLOCK_SYNC, - version: ZAKURA_BLOCK_SYNC_STREAM_VERSION, - frame_cap: MAX_BS_FRAME_BYTES, - capability: ZAKURA_CAP_BLOCK_SYNC, - mode: StreamMode::Ordered, -}]; +const BLOCK_SYNC_PAIR: OrderedStreamPair = OrderedStreamPair { + data: Stream { + kind: ZAKURA_STREAM_BLOCK_SYNC, + version: ZAKURA_BLOCK_SYNC_STREAM_VERSION, + capability: ZAKURA_CAP_BLOCK_SYNC, + frame_cap: MAX_BS_FRAME_BYTES, + mode: StreamMode::Ordered, + }, + requests: Stream { + kind: ZAKURA_STREAM_BLOCK_REQUESTS, + version: 1, + // Nine payload bytes plus the fixed eight-byte frame header. + capability: ZAKURA_CAP_BLOCK_SYNC, + frame_cap: 17, + mode: StreamMode::Ordered, + }, +}; +const BLOCK_SYNC_PAIR_STREAMS: [Stream; 2] = [BLOCK_SYNC_PAIR.data, BLOCK_SYNC_PAIR.requests]; /// Service-declared streams for native block sync. pub(crate) fn block_sync_streams() -> &'static [Stream] { - &BLOCK_SYNC_SERVICE_STREAMS + &BLOCK_SYNC_PAIR_STREAMS } /// Cloneable typed stream-6 sender. #[derive(Clone, Debug)] pub struct BlockSyncPeerSession { peer_id: ZakuraPeerId, + session_id: u64, direction: ServicePeerDirection, send: FramedSend, + requests: FramedSend, + remote_status: watch::Sender, cancel_token: CancellationToken, + /// One stored wake released after the reactor installs this serving handle. + reactor_ready: Arc, } impl BlockSyncPeerSession { - pub(crate) fn new(session: &PeerStreamSession, direction: ServicePeerDirection) -> Self { + pub(crate) fn new( + session: &PeerStreamSession, + session_id: u64, + direction: ServicePeerDirection, + requests: FramedSend, + ) -> Self { Self { peer_id: session.peer_id().clone(), + session_id, direction, send: session.sender(), + requests, + remote_status: watch::channel(false).0, cancel_token: session.cancel_token(), + reactor_ready: Arc::new(Notify::new()), } } @@ -61,12 +94,27 @@ impl BlockSyncPeerSession { peer_id: ZakuraPeerId, send: FramedSend, cancel_token: CancellationToken, + ) -> Self { + Self::for_test_with_session_id(peer_id, 0, send, cancel_token) + } + + /// Build a test session with an explicit generation for ordering tests. + #[cfg(test)] + pub(super) fn for_test_with_session_id( + peer_id: ZakuraPeerId, + session_id: u64, + send: FramedSend, + cancel_token: CancellationToken, ) -> Self { Self { peer_id, + session_id, direction: ServicePeerDirection::Outbound, + requests: send.clone(), send, + remote_status: watch::channel(false).0, cancel_token, + reactor_ready: Arc::new(Notify::new()), } } @@ -75,6 +123,11 @@ impl BlockSyncPeerSession { &self.peer_id } + /// Reactor generation that owns this stream session. + pub(super) fn session_id(&self) -> u64 { + self.session_id + } + /// Direction of the underlying Zakura connection. pub fn direction(&self) -> ServicePeerDirection { self.direction @@ -85,100 +138,50 @@ impl BlockSyncPeerSession { self.cancel_token.clone() } - /// Current free slots in this peer's bounded outbound stream queue. - pub fn outbound_capacity(&self) -> usize { - self.send.capacity() - } - - /// Total slots in this peer's bounded outbound stream queue. - pub fn outbound_max_capacity(&self) -> usize { - self.send.max_capacity() - } - - /// Send a typed status advertisement. - pub fn try_send_status(&self, status: BlockSyncStatus) -> Result<(), OrderedSendError> { - self.try_send_message(BlockSyncMessage::Status(status)) + /// Wait until the reactor has installed or rejected this exact session. + pub(super) async fn wait_until_reactor_ready(&self) { + self.reactor_ready.notified().await; } - /// Send a typed status advertisement, waiting for transport queue capacity. - pub async fn send_status(&self, status: BlockSyncStatus) -> Result<(), OrderedSendError> { - self.send_message(BlockSyncMessage::Status(status)).await + /// Release the peer routine after reactor-side admission finishes. + pub(super) fn mark_reactor_ready(&self) { + self.reactor_ready.notify_one(); } - /// Send a typed block range request. - pub fn try_send_get_blocks( - &self, - start_height: block::Height, - count: u32, - ) -> Result<(), OrderedSendError> { - self.try_send_message(BlockSyncMessage::GetBlocks { - start_height, - count, - }) + /// Current free slots in this peer's bounded outbound stream queue. + pub fn outbound_capacity(&self) -> usize { + self.request_sender_ref().capacity() } - /// Send one typed block body frame. - pub fn try_send_block(&self, block: Arc) -> Result<(), OrderedSendError> { - self.try_send_message(BlockSyncMessage::Block(block)) + /// Total slots in this peer's bounded outbound stream queue. + pub fn outbound_max_capacity(&self) -> usize { + self.request_sender_ref().max_capacity() } - /// Send one typed block body frame, waiting for transport queue capacity. - pub async fn send_block(&self, block: Arc) -> Result<(), OrderedSendError> { - self.send_message(BlockSyncMessage::Block(block)).await + /// Queue used by the download routine, which reserves space before taking work. + pub(super) fn request_sender(&self) -> FramedSend { + self.request_sender_ref().clone() } - /// Send a typed response terminator. - pub fn try_send_blocks_done( - &self, - start_height: block::Height, - returned: u32, - ) -> Result<(), OrderedSendError> { - self.try_send_message(BlockSyncMessage::BlocksDone { - start_height, - returned, - }) + fn request_sender_ref(&self) -> &FramedSend { + &self.requests } - /// Send a typed response terminator, waiting for transport queue capacity. - pub async fn send_blocks_done( - &self, - start_height: block::Height, - returned: u32, - ) -> Result<(), OrderedSendError> { - self.send_message(BlockSyncMessage::BlocksDone { - start_height, - returned, - }) - .await + pub(super) fn mark_status_received(&self) { + self.remote_status.send_replace(true); } - /// Send a typed unavailable-range response. - pub fn try_send_range_unavailable( - &self, - start_height: block::Height, - count: u32, - ) -> Result<(), OrderedSendError> { - self.try_send_message(BlockSyncMessage::RangeUnavailable { - start_height, - count, - }) + pub(super) fn subscribe_remote_status(&self) -> watch::Receiver { + self.remote_status.subscribe() } - /// Send a typed unavailable-range response, waiting for transport queue capacity. - pub async fn send_range_unavailable( - &self, - start_height: block::Height, - count: u32, - ) -> Result<(), OrderedSendError> { - self.send_message(BlockSyncMessage::RangeUnavailable { - start_height, - count, - }) - .await + pub(super) fn data_sender(&self) -> FramedSend { + self.send.clone() } - fn try_send_message(&self, msg: BlockSyncMessage) -> Result<(), OrderedSendError> { - let frame = msg + /// Send a typed status advertisement. + pub fn try_send_status(&self, status: BlockSyncStatus) -> Result<(), OrderedSendError> { + let frame = BlockSyncMessage::Status(status) .encode_frame() .map_err(|error| OrderedSendError::Encode(Box::new(error)))?; match self.send.try_send(frame) { @@ -188,8 +191,9 @@ impl BlockSyncPeerSession { } } - async fn send_message(&self, msg: BlockSyncMessage) -> Result<(), OrderedSendError> { - let frame = msg + /// Send a typed status advertisement, waiting for transport queue capacity. + pub async fn send_status(&self, status: BlockSyncStatus) -> Result<(), OrderedSendError> { + let frame = BlockSyncMessage::Status(status) .encode_frame() .map_err(|error| OrderedSendError::Encode(Box::new(error)))?; self.send @@ -203,23 +207,23 @@ impl BlockSyncPeerSession { #[derive(Debug)] pub(crate) struct BlockSyncService { inner: Arc, + range_source: Option>, + local_status: Option>, service_demand: Option>, - _held_events: Option>>>, _reactor_task: Option>, } #[derive(Debug)] struct BlockSyncServiceInner { + capacity: SessionCapacity, config: ZakuraBlockSyncConfig, - lifecycle: mpsc::UnboundedSender, - /// Shared download primitives every per-peer pipe-routine is wired with at - /// `add_peer` (per-peer routines). `None` for the inert/handle-less constructors that never - /// spawn routines (they only observe `events`/`lifecycle`). + sessions: Arc, + /// Shared download primitives wired into each peer routine by `add_peer`. + /// Tests without a reactor use `None` and drain incoming frames. routine_wiring: Option, peer_snapshot: watch::Receiver, candidates: watch::Receiver, - active_peers: StdMutex>, /// Connections whose block-sync session exited while the connection stayed /// up. A claim bridges the transport's reopen backoff so a discovery /// ownership sample cannot close a healthy connection mid-gap; it is only @@ -231,6 +235,7 @@ struct BlockSyncServiceInner { #[derive(Debug)] struct BlockSyncPeerRecord { + session: BlockSyncPeerSession, conn_id: ZakuraConnId, session_id: u64, direction: ServicePeerDirection, @@ -245,7 +250,7 @@ struct SessionGapClaim { impl BlockSyncServiceInner { fn finish_session(&self, peer: &ZakuraPeerId, conn_id: ZakuraConnId, session_id: u64) -> bool { - let Ok(mut active_peers) = self.active_peers.lock() else { + let Ok(mut active_peers) = self.sessions.active.lock() else { return false; }; let owns_session = active_peers @@ -274,6 +279,7 @@ impl BlockSyncServiceInner { }, ); } + self.sessions.notify(); true } } @@ -285,18 +291,19 @@ impl BlockSyncService { pub(crate) fn new_with_handle(config: ZakuraBlockSyncConfig, handle: BlockSyncHandle) -> Self { Self { + range_source: handle.range_source.clone(), + local_status: Some(handle.subscribe_status()), inner: Arc::new(BlockSyncServiceInner { + capacity: SessionCapacity::new(config.peer_limits), config, - lifecycle: handle.lifecycle.clone(), + sessions: handle.current_sessions.clone(), routine_wiring: handle.routine_wiring.clone(), peer_snapshot: handle.subscribe_peer_snapshot(), candidates: handle.subscribe_candidate_state(), - active_peers: StdMutex::new(HashMap::new()), session_gap_claims: StdMutex::new(HashMap::new()), next_session_id: AtomicU64::new(1), }), service_demand: None, - _held_events: None, _reactor_task: None, } } @@ -323,65 +330,23 @@ impl BlockSyncService { let config = startup.config.clone(); let (handle, _actions, reactor_task) = spawn_block_sync_reactor(startup); Self { + range_source: None, + local_status: Some(handle.subscribe_status()), inner: Arc::new(BlockSyncServiceInner { + capacity: SessionCapacity::new(config.peer_limits), config, - lifecycle: handle.lifecycle.clone(), + sessions: handle.current_sessions.clone(), routine_wiring: handle.routine_wiring.clone(), peer_snapshot: handle.subscribe_peer_snapshot(), candidates: handle.subscribe_candidate_state(), - active_peers: StdMutex::new(HashMap::new()), session_gap_claims: StdMutex::new(HashMap::new()), next_session_id: AtomicU64::new(1), }), service_demand: None, - _held_events: None, _reactor_task: Some(reactor_task), } } - #[cfg(test)] - pub(crate) fn new_for_test( - config: ZakuraBlockSyncConfig, - ) -> (Self, mpsc::Receiver) { - let (events, event_rx) = mpsc::channel(config.peer_limits.inbound_queue_depth.max(1)); - let (lifecycle, mut lifecycle_rx) = mpsc::unbounded_channel(); - let (_peer_snapshot_tx, peer_snapshot) = - watch::channel(ServicePeerSnapshot::new(0, 0, config.peer_limits)); - let (_candidates_tx, candidates) = watch::channel(ZakuraBlockSyncCandidateState::default()); - let events_for_lifecycle = events.clone(); - tokio::spawn(async move { - while let Some(event) = lifecycle_rx.recv().await { - let _ = events_for_lifecycle.send(event).await; - } - }); - ( - Self { - inner: Arc::new(BlockSyncServiceInner { - config, - lifecycle, - routine_wiring: None, - peer_snapshot, - candidates, - active_peers: StdMutex::new(HashMap::new()), - session_gap_claims: StdMutex::new(HashMap::new()), - next_session_id: AtomicU64::new(1), - }), - service_demand: None, - _held_events: None, - _reactor_task: None, - }, - event_rx, - ) - } - - #[cfg(test)] - pub(crate) fn new_with_handle_for_test( - config: ZakuraBlockSyncConfig, - handle: BlockSyncHandle, - ) -> Self { - Self::new_with_handle(config, handle) - } - pub(crate) fn with_service_demand( mut self, service_demand: Option< @@ -395,7 +360,8 @@ impl BlockSyncService { #[cfg(test)] pub(crate) fn peer_count(&self) -> usize { self.inner - .active_peers + .sessions + .active .lock() .expect("block-sync peer map mutex is never poisoned") .len() @@ -404,7 +370,8 @@ impl BlockSyncService { fn peer_slots_free(&self, direction: ServicePeerDirection) -> bool { let peers = self .inner - .active_peers + .sessions + .active .lock() .expect("block-sync peer map mutex is never poisoned"); let count = peers @@ -451,6 +418,52 @@ impl Service for BlockSyncService { block_sync_streams() } + fn ordered_stream_pair(&self, stream: Stream) -> Option { + BLOCK_SYNC_PAIR_STREAMS + .contains(&stream) + .then_some(BLOCK_SYNC_PAIR) + } + + fn reserve_ordered_session( + &self, + direction: ServicePeerDirection, + ) -> Result< + Option>, + crate::zakura::OrderedSessionFull, + > { + self.inner.capacity.reserve(direction).map(Some) + } + + fn stream_queue_depths(&self, stream: Stream) -> Option<(usize, usize)> { + if stream == BLOCK_SYNC_PAIR.requests { + Some((1, 1)) + } else { + let limits = self.inner.config.peer_limits; + Some(( + limits.inbound_queue_depth.max(1), + limits.outbound_queue_depth.max(1), + )) + } + } + + fn message_payload_limits(&self, stream: Stream) -> &'static [(u16, usize)] { + if stream == BLOCK_SYNC_PAIR.requests { + serving_regulation::message_payload_limits() + } else if stream == BLOCK_SYNC_PAIR.data { + &[(1, 53), (4, 9), (5, 9)] + } else { + &[] + } + } + + fn message_types(&self, stream: Stream) -> Option<&'static [u16]> { + Some(if stream == BLOCK_SYNC_PAIR.requests { + &[2] + } else { + &[1, 3, 4, 5] + }) + } + fn ordered_stream_policy(&self, _kind: u16) -> OrderedStreamPolicy { OrderedStreamPolicy { opening: OrderedStreamOpening::EitherSide, @@ -464,11 +477,26 @@ impl Service for BlockSyncService { peer: &ZakuraPeerId, _negotiated: u64, direction: ServicePeerDirection, + ) -> OrderedSessionDemand { + let mut capacity = self.inner.capacity.subscribe(); + if !self.inner.capacity.available(direction) { + return OrderedSessionDemand::WaitForChange(Box::pin(async move { + let _ = capacity.changed().await; + })); + } + self.reserved_ordered_session_demand(conn_id, peer, _negotiated, direction) + } + + fn reserved_ordered_session_demand( + &self, + conn_id: ZakuraConnId, + peer: &ZakuraPeerId, + _negotiated: u64, + direction: ServicePeerDirection, ) -> OrderedSessionDemand { if let Some(deadline) = self.peer_park_deadline(peer) { return OrderedSessionDemand::RetryAt(deadline); } - let mut peer_snapshot = self.inner.peer_snapshot.clone(); peer_snapshot.borrow_and_update(); if !self.peer_slots_free(direction) { @@ -534,15 +562,33 @@ impl Service for BlockSyncService { return; } - let Some((recv, send)) = peer.take_stream(ZAKURA_STREAM_BLOCK_SYNC) else { + let Some((data_session_id, version, recv, send)) = + peer.take_versioned_stream_with_session_id(ZAKURA_STREAM_BLOCK_SYNC) + else { return; }; + let (incoming_requests, request_sender) = { + let Some((request_session_id, request_version, recv, send)) = + peer.take_versioned_stream_with_session_id(ZAKURA_STREAM_BLOCK_REQUESTS) + else { + peer.service_cancel_token().cancel(); + return; + }; + if version != BLOCK_SYNC_PAIR.data.version + || request_version != BLOCK_SYNC_PAIR.requests.version + || request_session_id != data_session_id + { + peer.service_cancel_token().cancel(); + return; + } + (recv, send) + }; let peer_id = peer.id.clone(); let session = PeerStreamSession::new( peer_id.clone(), ZAKURA_STREAM_BLOCK_SYNC, - ZAKURA_BLOCK_SYNC_STREAM_VERSION, + version, recv, send, peer.service_cancel_token(), @@ -550,24 +596,19 @@ impl Service for BlockSyncService { let service_cancel_token = session.cancel_token(); let connection_cancel_token = peer.cancel_token(); let close_cause = peer.close_cause(); - let block_sync_session = BlockSyncPeerSession::new(&session, peer.direction); - let session_id = self.inner.next_session_id.fetch_add(1, Ordering::Relaxed); let conn_id = peer.conn_id; - let (_session_peer, _stream_kind, _stream_version, recv, send, _session_cancel) = - session.into_parts(); - - // Production outbound block-sync frames go directly through - // `BlockSyncPeerSession` (the per-peer routine's `try_send_get_blocks` / - // the reactor's `try_send_status`/serving sends), so the raw transport - // sender taken from the stream here is redundant. The outbound stream stays - // alive through the `BlockSyncPeerSession` clone the reactor holds, so - // nothing is lost by dropping it. - drop(send); - let (old_record, re_admitted_after_no_progress, routine_generation) = { + let ( + old_record, + re_admitted_after_no_progress, + routine_generation, + session_id, + block_sync_session, + ) = { let mut active_peers = self .inner - .active_peers + .sessions + .active .lock() .expect("block-sync peer map mutex is never poisoned"); if active_peers @@ -625,9 +666,16 @@ impl Service for BlockSyncService { } else { (None, false) }; + // Production uses the registry's globally unique routine generation. + // Handle-less tests use the service-local fallback. + let session_id = routine_generation + .unwrap_or_else(|| self.inner.next_session_id.fetch_add(1, Ordering::Relaxed)); + let block_sync_session = + BlockSyncPeerSession::new(&session, session_id, peer.direction, request_sender); let old_record = active_peers.insert( peer_id.clone(), BlockSyncPeerRecord { + session: block_sync_session.clone(), conn_id, session_id, direction: peer.direction, @@ -638,8 +686,16 @@ impl Service for BlockSyncService { old_record, re_admitted_after_no_progress, routine_generation, + session_id, + block_sync_session, ) }; + let (_session_peer, _stream_kind, _stream_version, recv, send, _session_cancel) = + session.into_parts(); + + // Production outbound frames go through `BlockSyncPeerSession`; its + // sender clone keeps the stream alive after this redundant half drops. + drop(send); if let Some(old_record) = old_record { old_record.cancel_token.cancel(); } @@ -652,15 +708,10 @@ impl Service for BlockSyncService { let run_cancel = service_cancel_token.clone(); let on_teardown = { - let lifecycle = self.inner.lifecycle.clone(); let peer_id = peer_id.clone(); let inner = self.inner.clone(); move || { - let should_notify = inner.finish_session(&peer_id, conn_id, session_id); - - if should_notify { - let _ = lifecycle.send(BlockSyncEvent::PeerDisconnected(peer_id)); - } + inner.finish_session(&peer_id, conn_id, session_id); } }; let on_panic = { @@ -671,6 +722,10 @@ impl Service for BlockSyncService { connection_cancel_token.cancel(); } }; + // Publish the table before spawning the reader. The reactor marks the + // exact session ready after reconciling its snapshot. + self.inner.sessions.notify(); + // the per-peer pipe-routine is spawned HERE (the pipe spawn point), so // a protocol reject still cancels the whole connection via // `handle_pipe_exit`. The routine owns `recv` (the transport read), decodes @@ -680,40 +735,82 @@ impl Service for BlockSyncService { // the stream so frames are not silently mishandled and the lifecycle still // flows. let pipe = { + let source = self.range_source.clone(); + let local_status = self.local_status.clone(); let connection_cancel_token = connection_cancel_token.clone(); let close_cause = close_cause.clone(); let routine_wiring = self.inner.routine_wiring.clone(); let block_sync_session = block_sync_session.clone(); let peer_id = peer_id.clone(); async move { - let result = match routine_wiring { - Some(wiring) => { - let generation = routine_generation.expect( + let reactor_ready = if routine_wiring.is_some() { + tokio::select! { + () = block_sync_session.wait_until_reactor_ready() => true, + () = run_cancel.cancelled() => false, + } + } else { + true + }; + let result = if !reactor_ready || run_cancel.is_cancelled() { + Ok(()) + } else { + match routine_wiring { + Some(wiring) => { + let generation = routine_generation.expect( "production block-sync wiring allocates a routine generation before spawn", ); - let routine = super::peer_routine::PeerRoutine::new( - peer_id, - conn_id, - block_sync_session, - recv, - wiring.config, - !re_admitted_after_no_progress, - generation, - wiring.budget, - wiring.work, - wiring.registry, - wiring.received_throughput, - wiring.sequencer_input, - wiring.sequencer_input_bytes, - wiring.sequencer_input_decoded_attributed_memory_bytes, - wiring.routine_to_reactor, - wiring.view, - run_cancel, - wiring.trace, - ); - routine.run().await + let serving = wiring.serving_regulator.session(peer_id.clone()); + let routine = super::peer_routine::PeerRoutine::new( + peer_id, + conn_id, + block_sync_session.clone(), + recv, + wiring.config, + !re_admitted_after_no_progress, + generation, + wiring.budget, + wiring.work, + wiring.registry.clone(), + wiring.received_throughput, + wiring.sequencer_input, + wiring.sequencer_input_bytes, + wiring.sequencer_input_decoded_attributed_memory_bytes, + wiring.routine_to_reactor, + wiring.view, + run_cancel.clone(), + wiring.trace.clone(), + ); + let download = routine.run(); + tokio::pin!(download); + let result = tokio::select! { + result = &mut download => result, + result = super::serving::serve_requests( + block_sync_session, incoming_requests, serving, wiring.registry, + local_status.expect("paired serving has a local status watch"), + source, wiring.trace, + ) => { + // Do not drop unanswered downloads before their + // remote-close policy has been applied. + run_cancel.cancel(); + match (result, download.await) { + (Err(error @ SinkReject::Protocol(_)), _) + | (_, Err(error @ SinkReject::Protocol(_))) => Err(error), + (serving, download) => serving.and(download), + } + }, + }; + run_cancel.cancel(); + result + } + None => { + let result = tokio::select! { + result = drain_inbound(recv, run_cancel.clone()) => result, + result = drain_inbound(incoming_requests, run_cancel.clone()) => result, + }; + run_cancel.cancel(); + result + } } - None => drain_inbound(recv, run_cancel).await, }; handle_pipe_exit("block-sync", &connection_cancel_token, &close_cause, result); } @@ -727,17 +824,13 @@ impl Service for BlockSyncService { on_panic, pipe, ); - - let _ = self - .inner - .lifecycle - .send(BlockSyncEvent::PeerConnected(block_sync_session)); } fn owns_connection_for_peer(&self, peer: &ZakuraPeerId, conn_id: ZakuraConnId) -> bool { let session_is_active = self .inner - .active_peers + .sessions + .active .lock() .expect("block-sync peer map mutex is never poisoned") .get(peer) @@ -769,7 +862,8 @@ impl Service for BlockSyncService { let removed_record = { let mut active_peers = self .inner - .active_peers + .sessions + .active .lock() .expect("block-sync peer map mutex is never poisoned"); let removed = match active_peers.get(peer) { @@ -803,10 +897,7 @@ impl Service for BlockSyncService { }; record.cancel_token.cancel(); - let _ = self - .inner - .lifecycle - .send(BlockSyncEvent::PeerDisconnected(peer.clone())); + self.inner.sessions.notify(); } fn deliver_frame( diff --git a/crates/zakura-network/src/zakura/block_sync/service/sessions.rs b/crates/zakura-network/src/zakura/block_sync/service/sessions.rs new file mode 100644 index 0000000000..09e0b52fcf --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/service/sessions.rs @@ -0,0 +1,199 @@ +//! Tracks block-sync session capacity and each peer's current session. +//! +//! A session is the request stream and data stream paired on one QUIC connection. +//! The service reserves capacity during setup and publishes the assembled pair +//! for the reactor (the download coordinator). +//! +//! [`SessionCapacity`] counts pairs during setup, active use, and cleanup. +//! [`CurrentSessions`] holds only the current session for each peer. Replacing +//! its entry doesn't free the old pair's capacity while that pair still has +//! workers or send handles finishing cleanup. + +use super::*; +use crate::zakura::{OrderedSessionFull, OrderedSessionResources, ServicePeerLimits}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +/// Limits session counts by connection direction and bounds incomplete pairs. +/// +/// Inbound means the peer connected to us; outbound means we connected to the +/// peer. Each pair takes one slot from its direction and one temporary setup slot +/// from the allowance shared across both directions. A semaphore permit represents +/// one occupied slot. +#[derive(Debug)] +pub(super) struct SessionCapacity { + inbound: Arc, + outbound: Arc, + pending: Arc, + changed: watch::Sender<()>, +} + +impl SessionCapacity { + pub(super) fn new(limits: ServicePeerLimits) -> Self { + let pool = |count: usize| Arc::new(Semaphore::new(count.min(Semaphore::MAX_PERMITS))); + Self { + inbound: pool(limits.max_inbound_peers), + outbound: pool(limits.max_outbound_peers), + pending: pool(limits.max_pending_escalations), + changed: watch::channel(()).0, + } + } + + fn pool(&self, direction: ServicePeerDirection) -> &Arc { + match direction { + ServicePeerDirection::Inbound => &self.inbound, + ServicePeerDirection::Outbound => &self.outbound, + } + } + + /// Subscribe before checking [`Self::available`], so a capacity release + /// between the check and waiting for a change cannot be missed. + pub(super) fn subscribe(&self) -> watch::Receiver<()> { + self.changed.subscribe() + } + + /// Check whether setup could start now, without taking any slots. + /// Another task can claim them before [`Self::reserve`], so this is only a hint. + pub(super) fn available(&self, direction: ServicePeerDirection) -> bool { + self.pool(direction).available_permits() > 0 && self.pending.available_permits() > 0 + } + + #[cfg(test)] + pub(super) fn available_counts(&self) -> (usize, usize, usize) { + ( + self.inbound.available_permits(), + self.outbound.available_permits(), + self.pending.available_permits(), + ) + } + + /// Take one session slot for `direction` and one temporary setup slot. + /// + /// Returns [`OrderedSessionFull`] immediately if either limit is reached; + /// an error leaves no slots held by this call. Both streams share the returned + /// reservation, so opening the second stream must reuse it. + pub(super) fn reserve( + &self, + direction: ServicePeerDirection, + ) -> Result, OrderedSessionFull> { + let pending = self + .pending + .clone() + .try_acquire_owned() + .map_err(|_| OrderedSessionFull)?; + let session = match self.pool(direction).clone().try_acquire_owned() { + Ok(session) => session, + Err(_) => { + // Wake callers that may have seen the setup slot occupied. + drop(pending); + self.changed.send_replace(()); + return Err(OrderedSessionFull); + } + }; + let reserved = + metrics::gauge!("sync.block.sessions.reserved", "direction" => direction.trace_label()); + let pending_count = metrics::gauge!("sync.block.sessions.pending"); + reserved.increment(1.0); + pending_count.increment(1.0); + Ok(Arc::new(SessionResources { + reserved, + pending_count, + session: Some(session), + pending: StdMutex::new(Some(pending)), + changed: self.changed.clone(), + })) + } +} + +/// Holds a pair's session and setup slots on behalf of its workers and send handles. +/// +/// Each owner retains an `Arc` to this value. The session slot stays occupied +/// after cancellation until the last owner drops its reference. +#[derive(Debug)] +struct SessionResources { + reserved: metrics::Gauge, + pending_count: metrics::Gauge, + session: Option, + pending: StdMutex>, + changed: watch::Sender<()>, +} + +impl OrderedSessionResources for SessionResources { + /// Release the temporary setup slot once both streams are ready. + /// The session slot stays reserved; repeated calls cannot release setup twice. + fn admitted(&self) { + if self + .pending + .lock() + .expect("session setup ownership is not poisoned") + .take() + .is_some() + { + self.pending_count.decrement(1.0); + } + self.changed.send_replace(()); + } +} + +impl Drop for SessionResources { + /// Return the session slot and any setup slot left by an incomplete pair, + /// then wake callers waiting to open a session. + fn drop(&mut self) { + if self + .pending + .get_mut() + .expect("session setup ownership is not poisoned") + .take() + .is_some() + { + self.pending_count.decrement(1.0); + } + self.session.take(); + self.reserved.decrement(1.0); + self.changed.send_replace(()); + } +} + +/// The service's current session for each peer, shared with the reactor. +/// +/// The service updates the table and calls [`Self::notify`]. The reactor reads a +/// [`Self::snapshot`] and updates its download state to match those sessions. +/// Notifications merge together, so reconnects cannot build a history queue. +#[derive(Debug)] +pub(in crate::zakura::block_sync) struct CurrentSessions { + pub(super) active: StdMutex>, + changed: watch::Sender<()>, +} + +impl CurrentSessions { + pub(in crate::zakura::block_sync) fn new() -> Arc { + Arc::new(Self { + active: StdMutex::new(HashMap::new()), + changed: watch::channel(()).0, + }) + } + + /// Watch for table changes; a notification means to read the current table. + pub(in crate::zakura::block_sync) fn subscribe(&self) -> watch::Receiver<()> { + self.changed.subscribe() + } + + /// Signal a table change after publishing an updated entry or removal. + pub(super) fn notify(&self) { + self.changed.send_replace(()); + } + + /// Clone the current session handles so the reactor can work without the lock. + /// + /// Mark the watch notification as seen before taking this snapshot. A change + /// arriving while the reactor updates its state then triggers another pass. + pub(in crate::zakura::block_sync) fn snapshot( + &self, + ) -> HashMap { + self.active + .lock() + .expect("block-sync session table is not poisoned") + .iter() + .map(|(peer, record)| (peer.clone(), record.session.clone())) + .collect() + } +} diff --git a/crates/zakura-network/src/zakura/block_sync/service/tests.rs b/crates/zakura-network/src/zakura/block_sync/service/tests.rs new file mode 100644 index 0000000000..42c1dd67f6 --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/service/tests.rs @@ -0,0 +1,209 @@ +use super::*; + +#[tokio::test] +async fn pair_setup_and_retirement_keep_their_service_capacity() { + use crate::zakura::{OrderedSessionResources, ServicePeerLimits}; + let capacity = SessionCapacity::new(ServicePeerLimits { + max_inbound_peers: 1, + max_outbound_peers: 1, + max_pending_escalations: 1, + ..ServicePeerLimits::default() + }); + let mut changed = capacity.subscribe(); + let pending = capacity.reserve(ServicePeerDirection::Outbound).unwrap(); + assert!( + capacity.reserve(ServicePeerDirection::Inbound).is_err(), + "setup allowance is shared across directions" + ); + pending.admitted(); + changed.changed().await.unwrap(); + let inbound = capacity.reserve(ServicePeerDirection::Inbound).unwrap(); + inbound.admitted(); + let (send, _recv) = crate::zakura::transport::worker_framed_channel(1); + let send = send.with_session_resources(Some(pending.clone())); + let retiring_worker: Arc = pending.clone(); + drop(pending); + assert!(capacity.reserve(ServicePeerDirection::Outbound).is_err()); + drop(send); + assert!( + capacity.reserve(ServicePeerDirection::Outbound).is_err(), + "worker teardown retains its slot after the service drops its sender" + ); + drop(retiring_worker); + assert!(capacity.available(ServicePeerDirection::Outbound)); + drop(inbound); + assert!(capacity.available(ServicePeerDirection::Inbound)); +} + +#[tokio::test] +async fn abandoned_pair_setup_returns_capacity_and_wakes_demand() { + use crate::zakura::ServicePeerLimits; + let capacity = SessionCapacity::new(ServicePeerLimits { + max_pending_escalations: 1, + ..ServicePeerLimits::default() + }); + let mut changed = capacity.subscribe(); + let pending = capacity.reserve(ServicePeerDirection::Outbound).unwrap(); + assert!(!capacity.available(ServicePeerDirection::Inbound)); + drop(pending); + time::timeout(Duration::from_secs(1), changed.changed()) + .await + .unwrap() + .unwrap(); + assert!(capacity.available(ServicePeerDirection::Inbound)); + assert!(capacity.available(ServicePeerDirection::Outbound)); +} + +#[tokio::test] +async fn session_churn_coalesces_and_changes_during_reconciliation_remain_visible() { + let service = BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); + let current = service.current_sessions_for_test(); + let mut changed = current.subscribe(); + let peer = ZakuraPeerId::new(vec![211; 32]).unwrap(); + let mut old = Vec::new(); + let mut streams = Vec::new(); + for conn_id in 1..=1000 { + let (input, recv) = crate::zakura::framed_channel(1); + let (send, output) = crate::zakura::framed_channel(1); + service.add_peer( + crate::zakura::testkit::DownloadOnlyPeer::create_with_conn_id_and_direction( + conn_id, + peer.clone(), + None, + ZAKURA_CAP_BLOCK_SYNC, + ServicePeerDirection::Outbound, + HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (recv, send))]), + CancellationToken::new(), + ), + ); + old.push(current.snapshot()[&peer].cancel_token()); + streams.push((input, output)); + } + changed.changed().await.unwrap(); + let snapshot = current.snapshot(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[&peer].session_id(), 1000); + assert!(old[..999].iter().all(CancellationToken::is_cancelled)); + assert!(!old[999].is_cancelled()); + assert!( + !changed.has_changed().unwrap(), + "one observation consumes the coalesced change" + ); + service.remove_peer(&peer, 1000); + assert!( + changed.has_changed().unwrap(), + "a change after the snapshot schedules another pass" + ); + changed.changed().await.unwrap(); + assert!(current.snapshot().is_empty()); +} + +impl BlockSyncService { + pub(crate) fn available_session_slots_for_test(&self) -> (usize, usize, usize) { + self.inner.capacity.available_counts() + } + + pub(crate) fn sessions_for_transport_test( + &self, + ) -> Vec<(u64, BlockSyncPeerSession, FramedSend)> { + self.inner + .sessions + .snapshot() + .into_values() + .map(|session| { + ( + session.session_id(), + session.clone(), + session.request_sender(), + ) + }) + .collect() + } +} + +impl BlockSyncHandle { + pub(crate) fn active_serving_requests_for_test(&self) -> usize { + self.routine_wiring + .as_ref() + .unwrap() + .serving_regulator + .snapshot() + .node_active + } + + pub(crate) fn outstanding_requests_for_test(&self) -> usize { + self.routine_wiring + .as_ref() + .unwrap() + .registry + .slot_summary() + .outstanding_requests + } + + pub(crate) fn hold_serving_capacity_for_test(&self) -> Vec> { + let wiring = self.routine_wiring.as_ref().unwrap(); + (0..wiring.config.get_blocks_regulation.node_active_requests) + .map(|index| { + let mut bytes = [0xff; 32]; + bytes[..8].copy_from_slice(&u64::try_from(index).unwrap().to_le_bytes()); + let peer = ZakuraPeerId::new(bytes.to_vec()).unwrap(); + let session = wiring.serving_regulator.session(peer); + Box::new(session.admit_now(1).unwrap().commit()) as Box + }) + .collect() + } +} + +impl BlockSyncService { + pub(crate) fn new_for_test(config: ZakuraBlockSyncConfig) -> Self { + let sessions = CurrentSessions::new(); + let (_peer_snapshot_tx, peer_snapshot) = + watch::channel(ServicePeerSnapshot::new(0, 0, config.peer_limits)); + let (_candidates_tx, candidates) = watch::channel(ZakuraBlockSyncCandidateState::default()); + Self { + range_source: None, + local_status: None, + inner: Arc::new(BlockSyncServiceInner { + capacity: SessionCapacity::new(config.peer_limits), + config, + sessions, + routine_wiring: None, + peer_snapshot, + candidates, + session_gap_claims: StdMutex::new(HashMap::new()), + next_session_id: AtomicU64::new(1), + }), + service_demand: None, + _reactor_task: None, + } + } + pub(in crate::zakura::block_sync) fn current_sessions_for_test(&self) -> Arc { + self.inner.sessions.clone() + } +} + +impl CurrentSessions { + pub(in crate::zakura::block_sync) fn insert_fixture( + &self, + conn_id: ZakuraConnId, + session: BlockSyncPeerSession, + ) { + self.active.lock().unwrap().insert( + session.peer_id().clone(), + BlockSyncPeerRecord { + conn_id, + session_id: session.session_id(), + direction: session.direction(), + cancel_token: session.cancel_token(), + session, + }, + ); + self.notify(); + } +} + +impl BlockSyncService { + pub(crate) fn is_peer_parked_for_test(&self, peer: &ZakuraPeerId) -> bool { + self.peer_is_parked(peer) + } +} diff --git a/crates/zakura-network/src/zakura/block_sync/serving.rs b/crates/zakura-network/src/zakura/block_sync/serving.rs new file mode 100644 index 0000000000..1bce9c1718 --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/serving.rs @@ -0,0 +1,61 @@ +//! Storage boundary and sequential serving for the paired block-sync protocol. + +use super::*; +use futures::future::BoxFuture; + +mod task; +pub(super) use task::serve_requests; + +#[cfg(test)] +mod tests; + +/// Storage reads used by a block-sync session's serving task. +/// +/// Implementations must move the request's lease into the actual database job +/// and retain it in the returned result. Aborting the async caller must not +/// release capacity while a blocking read or its undelivered result remains. +pub trait BlockRangeSource: std::fmt::Debug + Send + Sync { + /// Dispatch at most one bounded read after claiming the request's lease. + fn read_range( + &self, + request: BlockRangeRead, + ) -> BoxFuture<'static, Result>; +} + +/// A bounded read with ownership for exactly one storage execution. +#[derive(Debug)] +pub struct BlockRangeRead { + start: block::Height, + count: u32, + max_response_bytes: u32, + lease: BlockRangeReadLease, +} + +impl BlockRangeRead { + /// Transfer the read bounds and lease to the storage adapter. The adapter + /// calls `lease.try_start()` once, then moves the lease into its blocking job. + pub fn into_parts(self) -> (block::Height, u32, u32, BlockRangeReadLease) { + (self.start, self.count, self.max_response_bytes, self.lease) + } +} + +/// Contiguous storage prefix that keeps its serving resources until disposal. +#[derive(Debug)] +pub struct BlockRangeReadResult { + // Drop block allocations before releasing their shared work resources. + blocks: Vec<(block::Height, Arc, usize)>, + _lease: BlockRangeReadLease, +} + +impl BlockRangeReadResult { + /// Transfer a completed blocking job's blocks and original lease together. + pub fn new( + blocks: Vec<(block::Height, Arc, usize)>, + lease: BlockRangeReadLease, + ) -> Self { + Self { + blocks, + _lease: lease, + } + } +} diff --git a/crates/zakura-network/src/zakura/block_sync/serving/task.rs b/crates/zakura-network/src/zakura/block_sync/serving/task.rs new file mode 100644 index 0000000000..47e8dd357d --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/serving/task.rs @@ -0,0 +1,256 @@ +use super::super::trace::{self, BlockTraceEvent}; +use super::*; +use crate::zakura::block_sync::{ + peer_registry::PeerRegistry, + serving_regulation::{GetBlocksRequest, GetBlocksServingPermit, GetBlocksServingSession}, +}; +use crate::zakura::{FramedRecv, FramedSend, SinkReject, ZakuraTrace}; + +/// The two streams may arrive in either order. Bound the initial Status wait, +/// including a session that sends no request at all. +const STATUS_SETUP_TIMEOUT: Duration = Duration::from_secs(10); + +pub(in crate::zakura::block_sync) async fn serve_requests( + session: BlockSyncPeerSession, + mut requests: FramedRecv, + admission: GetBlocksServingSession, + registry: Arc, + local_status: watch::Receiver, + source: Option>, + trace: ZakuraTrace, +) -> Result<(), SinkReject> { + let cancel = session.cancel_token(); + tokio::select! { + biased; + () = cancel.cancelled() => Ok(()), + result = async { + let mut ready = session.subscribe_remote_status(); + let deadline = time::Instant::now() + STATUS_SETUP_TIMEOUT; + let mut pending = None; + while !*ready.borrow_and_update() { + tokio::select! { + biased; + () = time::sleep_until(deadline) => return Err(local_error("block-sync Status setup timed out")), + changed = ready.changed() => changed.map_err(SinkReject::local)?, + frame = requests.recv(), if pending.is_none() => { + let Some(frame) = frame else { return Ok(()); }; + pending = Some(decode_request(&admission, frame)?); + } + } + } + + let sender = session.data_sender(); + loop { + let request = match pending.take() { + Some(request) => request, + None => { + let Some(frame) = requests.recv().await else { return Ok(()); }; + decode_request(&admission, frame)? + } + }; + let started = Instant::now(); + let mut permit = admission.admit_request(&request).await; + if cancel.is_cancelled() || !registry.owns_generation(session.peer_id(), session.session_id()) { + return Ok(()); + } + let status = *local_status.borrow(); + let count = if request.start_height < status.servable_low { + 0 + } else { + status.servable_high.0.checked_sub(request.start_height.0) + .and_then(|last| last.checked_add(1)).unwrap_or(0) + .min(request.count).min(status.max_blocks_per_response) + }; + let result = match source.as_ref().filter(|_| count > 0) { + Some(source) => Some(source.read_range(BlockRangeRead { + start: request.start_height, count, + max_response_bytes: status.max_response_bytes, + lease: permit.work_lease(), + }).await), + None => None, + }; + let prepare_elapsed = started.elapsed(); + let send_started = Instant::now(); + let mut returned = 0u32; + let mut bytes = 0u64; + match result { + Some(Ok(result)) => { + let BlockRangeReadResult { blocks, _lease } = result; + for (height, block, size) in blocks { + let next_bytes = bytes.checked_add(u64::try_from(size).unwrap_or(u64::MAX)); + if returned >= count + || request.start_height.0.checked_add(returned).map(block::Height) != Some(height) + || next_bytes.is_none_or(|bytes| bytes > u64::from(status.max_response_bytes)) { + break; + } + send_response(&sender, &mut permit, BlockSyncMessage::Block(block)).await?; + bytes = next_bytes.expect("the response size was checked"); + returned += 1; + } + } + Some(Err(error)) => tracing::debug!(peer = ?session.peer_id(), ?error, "GetBlocks storage read failed"), + None => {}, + } + let ending = if returned == 0 { + BlockSyncMessage::RangeUnavailable { start_height: request.start_height, count: request.count } + } else { + BlockSyncMessage::BlocksDone { start_height: request.start_height, returned } + }; + send_response(&sender, &mut permit, ending).await?; + trace.emit_event(|| BlockTraceEvent::build(bs_trace::BLOCK_RANGE_RESPONSE_SENT, |row| { + row.peer = Some(trace::peer(session.peer_id())); + row.range_start = Some(trace::height(request.start_height)); + row.range_count = Some(u64::from(returned)); + row.expected_count = Some(u64::from(count)); + row.serialized_bytes = Some(bytes); + row.reason = Some(if returned == 0 { "range_unavailable" } else { "blocks_done" }); + row.prepare_elapsed_ms = Some(trace::elapsed_ms(prepare_elapsed)); + row.send_elapsed_ms = Some(trace::elapsed_ms(send_started.elapsed())); + row.elapsed_ms = Some(trace::elapsed_ms(started.elapsed())); + })); + // Dropping the producer lets the next request wait for the last + // frame guard. Retaining it while waiting would deadlock this peer. + } + } => result, + } +} + +fn decode_request( + admission: &GetBlocksServingSession, + frame: Frame, +) -> Result { + admission + .decode_request(frame) + .map_err(SinkReject::protocol) +} + +pub(in crate::zakura::block_sync) async fn send_response( + sender: &FramedSend, + permit: &mut GetBlocksServingPermit, + message: BlockSyncMessage, +) -> Result<(), SinkReject> { + let slot = sender + .reserve_guarded() + .await + .map_err(|_| local_error("block-sync data queue closed"))?; + // The blocking encode owns a lease even if its async caller is aborted. + // Its returned frame and lease are dropped together if delivery disappears. + let is_block = matches!(&message, BlockSyncMessage::Block(_)); + let lease = permit.work_lease(); + let (frame, _lease) = tokio::task::spawn_blocking(move || { + if lease.is_cancelled() { + return Err(local_error("block-sync serving cancelled")); + } + let frame = message.encode_frame().map_err(SinkReject::local)?; + Ok((frame, lease)) + }) + .await + .map_err(SinkReject::local)??; + let bytes = u64::try_from(frame.payload.len()).map_err(SinkReject::local)?; + if !permit.can_queue_frame(bytes) { + return Err(local_error( + "encoded GetBlocks response exceeded its admitted byte cap", + )); + } + slot.send(frame, permit.frame_guard(bytes)); + if is_block { + metrics::counter!("sync.block.body.served").increment(1); + } + Ok(()) +} + +fn local_error(message: &'static str) -> SinkReject { + SinkReject::local(io::Error::other(message)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::zakura::{ + block_sync::serving_regulation::GetBlocksServingRegulator, + transport::{worker_framed_channel, FramedWorkerRecv}, + }; + + fn setup() -> ( + FramedSend, + FramedWorkerRecv, + GetBlocksServingRegulator, + GetBlocksServingPermit, + ) { + let regulator = GetBlocksServingRegulator::new(ZakuraBlockSyncConfig::default()); + let permit = regulator + .session(ZakuraPeerId::new(vec![1; 32]).unwrap()) + .admit_now(1) + .unwrap() + .commit(); + let (sender, receiver) = worker_framed_channel(1); + (sender, receiver, regulator, permit) + } + + fn invalid_terminal() -> BlockSyncMessage { + BlockSyncMessage::BlocksDone { + start_height: block::Height(1), + returned: u32::MAX, + } + } + + #[tokio::test] + async fn queue_pressure_waits_before_encoding_and_cancellation_returns_capacity() { + let (sender, mut receiver, regulator, mut permit) = setup(); + sender + .send( + BlockSyncMessage::RangeUnavailable { + start_height: block::Height(1), + count: 1, + } + .encode_frame() + .unwrap(), + ) + .await + .unwrap(); + assert!(time::timeout( + Duration::from_millis(20), + send_response(&sender, &mut permit, invalid_terminal()) + ) + .await + .is_err()); + drop(receiver.recv().await.unwrap()); + assert_eq!(sender.capacity(), 1); + assert_eq!(regulator.snapshot().node_active, 1); + drop(permit); + assert_eq!(regulator.snapshot().node_active, 0); + } + + #[tokio::test] + async fn encoding_failure_returns_queue_space_and_success_retains_frame_ownership() { + let (sender, mut receiver, regulator, mut permit) = setup(); + assert!(send_response(&sender, &mut permit, invalid_terminal()) + .await + .is_err()); + assert_eq!(sender.capacity(), 1); + send_response( + &sender, + &mut permit, + BlockSyncMessage::RangeUnavailable { + start_height: block::Height(1), + count: 1, + }, + ) + .await + .unwrap(); + drop(permit); + assert_eq!(regulator.snapshot().node_active, 1); + drop(receiver.recv().await.unwrap()); + assert_eq!(regulator.snapshot().node_active, 0); + } + + #[tokio::test] + async fn closed_queue_releases_the_response_producer() { + let (sender, receiver, regulator, mut permit) = setup(); + drop(receiver); + let result = send_response(&sender, &mut permit, invalid_terminal()).await; + assert!(format!("{result:?}").contains("data queue closed")); + drop(permit); + assert_eq!(regulator.snapshot().node_active, 0); + } +} diff --git a/crates/zakura-network/src/zakura/block_sync/serving/tests.rs b/crates/zakura-network/src/zakura/block_sync/serving/tests.rs new file mode 100644 index 0000000000..84f0e2905c --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/serving/tests.rs @@ -0,0 +1,501 @@ +use super::*; +use crate::zakura::{ + block_sync::{ + peer_registry::{PeerRegistry, SessionAdmission}, + serving_regulation::GetBlocksServingRegulator, + }, + framed_channel, + transport::{worker_framed_channel, FramedWorkerRecv}, + FramedSend, +}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Condvar, Mutex, +}; +use tokio::sync::Notify; +use tokio_util::task::AbortOnDropHandle; + +#[derive(Debug)] +struct Storage { + calls: AtomicUsize, + changed: Notify, + release: (Mutex, Condvar), + fail: bool, + available: usize, +} + +#[derive(Debug)] +struct Source(Arc); + +impl Source { + fn new(released: bool) -> Arc { + Self::with_outcome(released, false, 2) + } + + fn with_outcome(released: bool, fail: bool, available: usize) -> Arc { + Arc::new(Self(Arc::new(Storage { + calls: AtomicUsize::new(0), + changed: Notify::new(), + release: (Mutex::new(released), Condvar::new()), + fail, + available, + }))) + } + + async fn wait_calls(&self, count: usize) { + time::timeout(Duration::from_secs(2), async { + loop { + let changed = self.0.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if self.0.calls.load(Ordering::Acquire) >= count { + return; + } + changed.await; + } + }) + .await + .unwrap(); + } + + fn release(&self) { + *self.0.release.0.lock().unwrap() = true; + self.0.release.1.notify_all(); + } +} + +impl BlockRangeSource for Source { + fn read_range( + &self, + request: BlockRangeRead, + ) -> BoxFuture<'static, Result> { + let storage = self.0.clone(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + let (start, count, max_bytes, lease) = request.into_parts(); + assert!(lease.try_start()); + storage.calls.fetch_add(1, Ordering::AcqRel); + storage.changed.notify_waiters(); + let (released, wait) = storage + .release + .1 + .wait_timeout_while( + storage.release.0.lock().unwrap(), + Duration::from_secs(5), + |released| !*released, + ) + .unwrap(); + assert!( + *released && !wait.timed_out(), + "the test must release its database job" + ); + if storage.fail { + return Err(io::Error::other("test storage failure").into()); + } + let vectors = [ + &*zakura_test::vectors::BLOCK_MAINNET_1_BYTES, + &*zakura_test::vectors::BLOCK_MAINNET_2_BYTES, + ]; + let mut blocks = Vec::new(); + let mut bytes = 0; + for (index, encoded) in vectors.into_iter().enumerate() { + let height = block::Height(u32::try_from(index + 1).unwrap()); + if lease.is_cancelled() + || blocks.len() >= usize::try_from(count).unwrap().min(storage.available) + { + break; + } + if height < start { + continue; + } + bytes += encoded.len(); + if bytes > usize::try_from(max_bytes).unwrap() { + break; + } + blocks.push(( + height, + Arc::new(block::Block::zcash_deserialize(encoded.as_slice()).unwrap()), + encoded.len(), + )); + } + Ok(BlockRangeReadResult::new(blocks, lease)) + }) + .await? + }) + } +} + +struct Fixture { + session: BlockSyncPeerSession, + requests: FramedSend, + data: FramedWorkerRecv, + regulator: GetBlocksServingRegulator, + registry: Arc, + status: watch::Sender, + task: AbortOnDropHandle>, +} + +impl Fixture { + fn new(source: Arc) -> Self { + Self::with_queue_depth(source, 1) + } + + fn with_queue_depth(source: Arc, depth: usize) -> Self { + let config = ZakuraBlockSyncConfig { + max_blocks_per_response: 2, + ..ZakuraBlockSyncConfig::default() + }; + let registry = Arc::new(PeerRegistry::new()); + let peer = ZakuraPeerId::new(vec![111; 32]).unwrap(); + let SessionAdmission::Fresh { generation } = registry.admit_session( + &peer, + ServicePeerDirection::Inbound, + &config, + 1, + Instant::now(), + ) else { + panic!("fresh fixture") + }; + let regulator = GetBlocksServingRegulator::new(config.clone()); + let admission = regulator.session(peer.clone()); + let (send, data) = worker_framed_channel(depth); + let session = BlockSyncPeerSession::for_test_with_session_id( + peer, + generation, + send, + CancellationToken::new(), + ); + let (requests, recv) = framed_channel(1); + let (status, status_rx) = watch::channel(BlockSyncStatus { + servable_low: block::Height(1), + servable_high: block::Height(2), + ..config.initial_status() + }); + let task = AbortOnDropHandle::new(tokio::spawn(serve_requests( + session.clone(), + recv, + admission, + registry.clone(), + status_rx, + Some(source), + crate::zakura::ZakuraTrace::noop(), + ))); + Self { + session, + requests, + data, + regulator, + registry, + status, + task, + } + } + + async fn request(&self) { + self.requests + .send( + BlockSyncMessage::GetBlocks { + start_height: block::Height(1), + count: 2, + } + .encode_frame() + .unwrap(), + ) + .await + .unwrap(); + } + + async fn next(&mut self) -> BlockSyncMessage { + let queued = time::timeout(Duration::from_secs(2), self.data.recv()) + .await + .unwrap() + .unwrap(); + let (frame, _guard) = queued.into_parts(); + BlockSyncMessage::decode_frame(frame).unwrap() + } + + async fn finish(self) { + self.session.cancel_token().cancel(); + time::timeout(Duration::from_secs(2), self.task) + .await + .unwrap() + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn request_before_status_waits_and_then_receives_the_complete_range() { + let source = Source::new(true); + let mut f = Fixture::new(source.clone()); + f.request().await; + tokio::task::yield_now().await; + assert_eq!(source.0.calls.load(Ordering::Acquire), 0); + f.session.mark_status_received(); + assert!(matches!(f.next().await, BlockSyncMessage::Block(_))); + assert!(matches!(f.next().await, BlockSyncMessage::Block(_))); + assert!(matches!( + f.next().await, + BlockSyncMessage::BlocksDone { + start_height: block::Height(1), + returned: 2 + } + )); + assert_eq!(source.0.calls.load(Ordering::Acquire), 1); + f.finish().await; +} + +#[tokio::test] +async fn storage_outcomes_preserve_the_response_prefix_and_ending_under_backpressure() { + for depth in 1..=3 { + for (fail, available) in [(false, 0), (false, 1), (false, 2), (true, 0)] { + let source = Source::with_outcome(true, fail, available); + let mut f = Fixture::with_queue_depth(source.clone(), depth); + let regulator = f.regulator.clone(); + f.session.mark_status_received(); + f.request().await; + let returned = if fail { 0 } else { available }; + for height in 1..=returned { + let BlockSyncMessage::Block(block) = f.next().await else { + panic!("the response prefix must contain its available blocks"); + }; + assert_eq!( + block.coinbase_height(), + Some(block::Height(u32::try_from(height).unwrap())) + ); + } + match f.next().await { + BlockSyncMessage::RangeUnavailable { + start_height, + count, + } => { + assert_eq!(returned, 0); + assert_eq!((start_height, count), (block::Height(1), 2)); + } + BlockSyncMessage::BlocksDone { + start_height, + returned: count, + } => { + assert!(returned > 0); + assert_eq!(start_height, block::Height(1)); + assert_eq!(usize::try_from(count).unwrap(), returned); + } + other => panic!("the response must end after its available prefix: {other:?}"), + } + assert_eq!(source.0.calls.load(Ordering::Acquire), 1); + f.finish().await; + assert_eq!(regulator.snapshot().node_active, 0); + assert_eq!(regulator.snapshot().peer_active, 0); + } + } +} + +#[tokio::test] +async fn a_retained_response_frame_blocks_the_next_read_from_that_peer() { + let source = Source::new(true); + let mut f = Fixture::new(source.clone()); + f.session.mark_status_received(); + f.request().await; + source.wait_calls(1).await; + let held = time::timeout(Duration::from_secs(2), f.data.recv()) + .await + .unwrap() + .unwrap(); + f.request().await; + assert!(matches!(f.next().await, BlockSyncMessage::Block(_))); + assert!(matches!( + f.next().await, + BlockSyncMessage::BlocksDone { returned: 2, .. } + )); + tokio::task::yield_now().await; + assert_eq!(source.0.calls.load(Ordering::Acquire), 1); + assert_eq!(f.regulator.snapshot().node_active, 1); + assert_eq!(f.regulator.snapshot().peer_active, 1); + drop(held); + source.wait_calls(2).await; + f.finish().await; +} + +#[tokio::test] +async fn aborting_the_serving_task_keeps_a_running_database_job_charged() { + let source = Source::new(false); + let mut f = Fixture::new(source.clone()); + f.session.mark_status_received(); + f.request().await; + source.wait_calls(1).await; + f.task.abort(); + assert!((&mut f.task).await.unwrap_err().is_cancelled()); + assert_eq!(f.regulator.snapshot().node_active, 1); + assert_eq!(f.regulator.snapshot().peer_active, 1); + let replacement = f.regulator.session(f.session.peer_id().clone()); + let request = super::super::serving_regulation::GetBlocksRequest { + start_height: block::Height(1), + count: 2, + }; + let mut pending = Box::pin(replacement.admit_request(&request)); + assert!(futures::poll!(&mut pending).is_pending()); + assert_eq!( + f.regulator.snapshot().node_active, + 1, + "the replacement waits for its peer before acquiring node capacity" + ); + source.release(); + let permit = time::timeout(Duration::from_secs(2), pending) + .await + .unwrap(); + assert_eq!(f.regulator.snapshot().node_active, 1); + drop(permit); + assert_eq!(f.regulator.snapshot().node_active, 0); +} + +#[tokio::test(start_paused = true)] +async fn missing_status_expires_without_starting_storage() { + let source = Source::new(true); + let mut f = Fixture::new(source.clone()); + tokio::task::yield_now().await; + time::advance(Duration::from_secs(11)).await; + assert!((&mut f.task).await.unwrap().is_err()); + assert_eq!(source.0.calls.load(Ordering::Acquire), 0); +} + +#[tokio::test] +async fn paired_version_is_selected_without_a_test_override() { + use crate::zakura::Service; + let service = BlockSyncService::new(ZakuraBlockSyncConfig::default()); + assert_eq!(service.streams().len(), 2); + assert!(service.ordered_stream_pair(service.streams()[0]).is_some()); + assert_eq!(service.streams()[0].version, 3); + assert_eq!(service.streams()[0].capability, 1 << 6); +} + +#[tokio::test] +async fn a_slow_storage_read_keeps_ownership_without_a_query_timeout() { + let source = Source::new(false); + let mut f = Fixture::new(source.clone()); + f.session.mark_status_received(); + f.request().await; + source.wait_calls(1).await; + time::pause(); + time::advance(Duration::from_secs(9)).await; + assert!(!f.task.is_finished()); + assert_eq!(f.regulator.snapshot().node_active, 1); + time::resume(); + source.release(); + assert!(matches!(f.next().await, BlockSyncMessage::Block(_))); + assert!(matches!(f.next().await, BlockSyncMessage::Block(_))); + assert!(matches!( + f.next().await, + BlockSyncMessage::BlocksDone { returned: 2, .. } + )); + f.finish().await; +} + +#[tokio::test] +async fn serving_does_not_read_above_the_committed_status_range() { + let source = Source::new(true); + let mut f = Fixture::new(source.clone()); + f.status + .send_modify(|status| status.servable_high = block::Height(0)); + f.session.mark_status_received(); + f.request().await; + assert!(matches!( + f.next().await, + BlockSyncMessage::RangeUnavailable { + start_height: block::Height(1), + count: 2 + } + )); + assert_eq!(source.0.calls.load(Ordering::Acquire), 0); + f.finish().await; +} + +#[tokio::test] +async fn local_count_and_byte_limits_end_after_the_permitted_prefix() { + for byte_limit in [false, true] { + let source = Source::new(true); + let mut f = Fixture::new(source); + f.status.send_modify(|status| { + if byte_limit { + status.max_response_bytes = + u32::try_from(zakura_test::vectors::BLOCK_MAINNET_1_BYTES.len()).unwrap(); + } else { + status.max_blocks_per_response = 1; + } + }); + f.session.mark_status_received(); + f.request().await; + assert!(matches!(f.next().await, BlockSyncMessage::Block(_))); + assert!(matches!( + f.next().await, + BlockSyncMessage::BlocksDone { returned: 1, .. } + )); + f.finish().await; + } +} + +#[tokio::test] +async fn a_stale_session_cannot_read_after_waiting_for_admission() { + let source = Source::new(true); + let mut f = Fixture::new(source.clone()); + let held: Vec<_> = (0..64) + .map(|id| { + f.regulator + .session(ZakuraPeerId::new(vec![id; 32]).unwrap()) + .admit_now(1) + .unwrap() + .commit() + }) + .collect(); + f.session.mark_status_received(); + f.request().await; + time::timeout(Duration::from_secs(2), async { + while f.regulator.snapshot().peer_active < 65 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(source.0.calls.load(Ordering::Acquire), 0); + f.registry + .remove_session(f.session.peer_id(), f.session.session_id()); + drop(held); + time::timeout(Duration::from_secs(2), &mut f.task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(source.0.calls.load(Ordering::Acquire), 0); + assert_eq!(f.regulator.snapshot().node_active, 0); + assert_eq!(f.regulator.snapshot().peer_active, 0); + assert!(time::timeout(Duration::from_millis(20), f.data.recv()) + .await + .is_err()); +} + +#[tokio::test] +async fn cancellation_during_storage_suppresses_old_output_and_retains_the_job() { + let source = Source::new(false); + let mut f = Fixture::new(source.clone()); + f.session.mark_status_received(); + f.request().await; + source.wait_calls(1).await; + f.session.cancel_token().cancel(); + time::timeout(Duration::from_secs(2), &mut f.task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(f.regulator.snapshot().node_active, 1); + source.release(); + time::timeout(Duration::from_secs(2), async { + while f.regulator.snapshot().node_active > 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(f.regulator.snapshot().peer_active, 0); + assert!(time::timeout(Duration::from_millis(20), f.data.recv()) + .await + .is_err()); +} diff --git a/crates/zakura-network/src/zakura/block_sync/serving_regulation.rs b/crates/zakura-network/src/zakura/block_sync/serving_regulation.rs new file mode 100644 index 0000000000..576b61006a --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/serving_regulation.rs @@ -0,0 +1,219 @@ +//! Resource admission for serving inbound `GetBlocks` requests. +//! +//! This module turns the generic regulation primitives into one message policy. +//! Each session holds one decoded request while waiting for capacity. +//! The separate data reader continues processing downloads during this wait. +//! Admission acquires a response producer before the state query starts. The query, +//! result, and queued frames share that producer until the last owner drops. A blocked +//! transport writer therefore prevents another query for the same peer, including after reconnect. + +use std::sync::Arc; + +use super::{config::*, *}; +use crate::zakura::{ + regulation::{ + RequestAdmission, RequestSession, ResponsePermit, SlotBudget, WorkAttempt, WorkLease, + }, + transport::FrameGuard, +}; + +mod observations; +mod policy; +use policy::GetBlocksPolicy; + +/// Header-level size limits from the implemented message policies. +pub(super) fn message_payload_limits() -> &'static [(u16, usize)] { + GetBlocksPolicy::PAYLOAD_LIMITS +} + +/// One decoded request held at the admission boundary until work is available. +#[derive(Debug)] +pub(super) struct GetBlocksRequest { + pub(super) start_height: block::Height, + pub(super) count: u32, +} + +/// Validate that every legal request can eventually fit every configured bound. +pub(super) fn validate_config(config: &ZakuraBlockSyncConfig) -> Result<(), &'static str> { + if u64::from(config.max_response_bytes) < block::MAX_BLOCK_BYTES { + return Err("max_response_bytes must cover one maximum-size block"); + } + let regulation = &config.get_blocks_regulation; + if regulation.node_active_requests == 0 { + return Err("get_blocks_regulation.node_active_requests must be greater than zero"); + } + if regulation.node_active_requests > tokio::sync::Semaphore::MAX_PERMITS { + return Err("get_blocks_regulation.node_active_requests exceeds Tokio's semaphore limit"); + } + + Ok(()) +} + +/// Node-owned GetBlocks resources shared by every peer routine. +#[derive(Clone, Debug)] +pub(super) struct GetBlocksServingRegulator { + inner: Arc, +} + +#[derive(Debug)] +struct RegulatorInner { + admission: RequestAdmission, + metrics: observations::ServingMetrics, + #[cfg(test)] + node_active: SlotBudget, +} + +impl GetBlocksServingRegulator { + /// Create the GetBlocks node policy from validated block-sync configuration. + pub(super) fn new(config: ZakuraBlockSyncConfig) -> Self { + debug_assert!(validate_config(&config).is_ok()); + let regulation = &config.get_blocks_regulation; + let node_active = SlotBudget::new(regulation.node_active_requests) + .expect("GetBlocks configuration validates the active-request capacity"); + Self { + inner: Arc::new(RegulatorInner { + metrics: observations::ServingMetrics::default(), + admission: RequestAdmission::new( + GetBlocksPolicy::new(&config), + node_active.clone(), + GetBlocksPolicy::PEER_PRODUCERS, + ), + #[cfg(test)] + node_active, + }), + } + } + + /// Create one session policy within the node admission bounds. + pub(super) fn session(&self, peer: ZakuraPeerId) -> GetBlocksServingSession { + let work = self.inner.admission.session(&peer); + + GetBlocksServingSession { + work, + metrics: self.inner.metrics.clone(), + } + } + + pub(super) fn publish_metrics(&self) { + self.inner.metrics.publish(); + } + + #[cfg(test)] + pub(super) fn snapshot(&self) -> ServingRegulationSnapshot { + ServingRegulationSnapshot { + node_active: self.inner.node_active.reserved(), + peer_active: self.inner.admission.reserved_by_peers(), + } + } +} + +/// Per-session entry point for decoding and work admission. +#[derive(Clone, Debug)] +pub(super) struct GetBlocksServingSession { + metrics: observations::ServingMetrics, + work: RequestSession, +} + +impl GetBlocksServingSession { + /// Wait in peer-then-node order. The enclosing session cancels this wait. + pub(super) async fn admit_request(&self, request: &GetBlocksRequest) -> GetBlocksServingPermit { + let _waiting = self.metrics.waiting(); + AdmissionAttempt { + metrics: self.metrics.clone(), + work: self.work.admit(request).await, + } + .commit() + } + + /// Apply the declared codec before admitting an inbound request. + pub(super) fn decode_request( + &self, + frame: Frame, + ) -> Result { + self.work.decode(frame) + } +} + +/// Provisional ownership of every resource needed before state work starts. +#[derive(Debug)] +#[must_use = "dropping a GetBlocks admission attempt rolls back every reservation"] +pub(super) struct AdmissionAttempt { + metrics: observations::ServingMetrics, + work: WorkAttempt, +} + +impl AdmissionAttempt { + /// Transfer admitted resources to the sequential response producer. + pub(super) fn commit(self) -> GetBlocksServingPermit { + metrics::counter!("sync.block.serving.admitted").increment(1); + GetBlocksServingPermit { + response: self.work.commit(), + observation: self.metrics.active(), + } + } +} + +/// Committed ownership retained while producing and writing a response. +#[derive(Debug)] +#[must_use = "the response producer retains this permit until its ending is queued"] +pub(super) struct GetBlocksServingPermit { + response: ResponsePermit, + observation: Arc, +} + +impl GetBlocksServingPermit { + pub(super) fn can_queue_frame(&self, bytes: u64) -> bool { + self.response.can_queue_frame(bytes) + } + + pub(super) fn frame_guard(&mut self, bytes: u64) -> FrameGuard { + FrameGuard::new(Arc::new(( + self.response.frame_guard(bytes), + self.observation.clone(), + ))) + } + + pub(super) fn work_lease(&self) -> BlockRangeReadLease { + BlockRangeReadLease { + work: self.response.work_lease(), + _observation: self.observation.clone(), + } + } +} + +/// Capacity retained by a serving query and its completed response. +/// +/// The storage adapter claims execution once and moves this lease into the +/// actual blocking job and its returned result. Clones retain the same capacity +/// and cannot start another read. Dropping the response producer cancels delivery; +/// capacity returns after the last worker, result, and frame owner drops. +#[derive(Clone, Debug)] +pub struct BlockRangeReadLease { + work: WorkLease, + _observation: Arc, +} + +impl BlockRangeReadLease { + /// Claim the only execution, serialized against producer cancellation. + /// + /// If closure wins, no read starts. If the claim wins, the worker retains + /// capacity until the read finishes, even if delivery is then cancelled. + pub fn try_start(&self) -> bool { + self.work.try_start() + } + + /// Whether the request no longer has a live delivery owner. + pub fn is_cancelled(&self) -> bool { + self.work.is_cancelled() + } +} + +#[cfg(test)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(super) struct ServingRegulationSnapshot { + pub(super) node_active: usize, + pub(super) peer_active: usize, +} + +#[cfg(test)] +mod tests; diff --git a/crates/zakura-network/src/zakura/block_sync/serving_regulation/observations.rs b/crates/zakura-network/src/zakura/block_sync/serving_regulation/observations.rs new file mode 100644 index 0000000000..7149e51371 --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/serving_regulation/observations.rs @@ -0,0 +1,119 @@ +//! Observations retain no work: one record per live response, removed by its last owner. + +use super::*; + +#[derive(Clone, Debug, Default)] +pub(super) struct ServingMetrics(Arc>); + +#[derive(Debug, Default)] +struct Counts { + next_id: u64, + waiting: usize, + active: BTreeMap, +} + +impl ServingMetrics { + pub(super) fn waiting(&self) -> Waiting { + self.0 + .lock() + .expect("serving observations are not poisoned") + .waiting += 1; + Waiting(self.clone()) + } + + pub(super) fn active(&self) -> Arc { + let mut counts = self + .0 + .lock() + .expect("serving observations are not poisoned"); + let id = counts.next_id; + counts.next_id = id + .checked_add(1) + .expect("a node cannot serve u64::MAX responses in its lifetime"); + counts.active.insert(id, time::Instant::now()); + Arc::new(Active { + metrics: self.clone(), + id, + }) + } + + pub(super) fn publish(&self) { + let counts = self + .0 + .lock() + .expect("serving observations are not poisoned"); + let count = |value| f64::from(u32::try_from(value).unwrap_or(u32::MAX)); + metrics::gauge!("sync.block.serving.active").set(count(counts.active.len())); + metrics::gauge!("sync.block.serving.waiting").set(count(counts.waiting)); + metrics::gauge!("sync.block.serving.oldest_age_seconds").set( + counts + .active + .first_key_value() + .map_or(0.0, |(_, started)| started.elapsed().as_secs_f64()), + ); + } +} + +#[derive(Debug)] +pub(super) struct Waiting(ServingMetrics); + +impl Drop for Waiting { + fn drop(&mut self) { + self.0 + .0 + .lock() + .expect("serving observations are not poisoned") + .waiting -= 1; + } +} + +#[derive(Debug)] +pub(super) struct Active { + metrics: ServingMetrics, + id: u64, +} + +impl Drop for Active { + fn drop(&mut self) { + self.metrics + .0 + .lock() + .expect("serving observations are not poisoned") + .active + .remove(&self.id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(start_paused = true)] + async fn observation_follows_the_last_response_owner_without_retaining_it() { + let regulator = GetBlocksServingRegulator::new(ZakuraBlockSyncConfig::default()); + let session = regulator.session(ZakuraPeerId::new(vec![8; 32]).unwrap()); + let mut response = session.admit_now(1).unwrap().commit(); + let read = response.work_lease(); + let frame = response.frame_guard(9); + drop(response); + time::advance(Duration::from_secs(3)).await; + let metrics = ®ulator.inner.metrics; + { + let counts = metrics.0.lock().unwrap(); + assert_eq!(counts.active.len(), 1); + assert_eq!( + counts.active.first_key_value().unwrap().1.elapsed(), + Duration::from_secs(3) + ); + } + drop(read); + assert_eq!(metrics.0.lock().unwrap().active.len(), 1); + drop(frame); + assert!(metrics.0.lock().unwrap().active.is_empty()); + assert_eq!(regulator.snapshot().node_active, 0); + let wait = metrics.waiting(); + assert_eq!(metrics.0.lock().unwrap().waiting, 1); + drop(wait); + assert_eq!(metrics.0.lock().unwrap().waiting, 0); + } +} diff --git a/crates/zakura-network/src/zakura/block_sync/serving_regulation/policy.rs b/crates/zakura-network/src/zakura/block_sync/serving_regulation/policy.rs new file mode 100644 index 0000000000..66c0cf95a8 --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/serving_regulation/policy.rs @@ -0,0 +1,193 @@ +//! Rules for reading a GetBlocks request and limiting the response we may send. + +use super::*; +use crate::zakura::regulation::RequestPolicy; + +/// Checks request fields and sets response limits. The peer routine checks the +/// session's initial Status before allowing work to start. A peer may request a +/// completed range again because we don't know which blocks it has kept. +#[derive(Clone, Debug)] +pub(super) struct GetBlocksPolicy { + /// Maximum number of blocks we may send in one response. + max_count: u32, + /// Maximum total block bytes we may send in one response. + /// Message tags and the ending message are counted separately. + max_response_bytes: u64, +} + +impl GetBlocksPolicy { + /// Allow one response per authenticated peer, including work from old sessions. + pub(super) const PEER_PRODUCERS: usize = 1; + /// One message tag, a four-byte start height, and a four-byte block count. + const REQUEST_PAYLOAD_BYTES: usize = 9; + + /// The transport and decoder use the same GetBlocks payload limit. + pub(super) const PAYLOAD_LIMITS: &'static [(u16, usize)] = &[ + // Every u8 discriminator fits in the frame header's u16 message type. + ( + super::super::wire::MSG_BS_GET_BLOCKS as u16, + Self::REQUEST_PAYLOAD_BYTES, + ), + ]; + + /// Use the same response limits that we advertise to peers. + pub(super) fn new(config: &ZakuraBlockSyncConfig) -> Self { + Self { + max_count: inbound_get_blocks_count_limit(config), + max_response_bytes: u64::from(config.advertised_max_response_bytes()), + } + } + + /// Maximum response payload bytes, including message tags and the ending message. + pub(super) fn response_cap_for_count(&self, requested_count: u32) -> Result { + // A peer may ask for more blocks than we send in one response. + let count = requested_count.min(self.max_count); + let block_bytes = u64::from(count) + .checked_mul(block::MAX_BLOCK_BYTES) + .ok_or("GetBlocks response-cap multiplication overflowed")?; + // Cap the block bytes, then allow one tag per block and the ending message. + let response_cap = GET_BLOCKS_TERMINAL_PAYLOAD_BYTES + .checked_add(u64::from(count)) + .and_then(|bytes| bytes.checked_add(block_bytes.min(self.max_response_bytes))) + .ok_or("GetBlocks response-cap addition overflowed")?; + Ok(response_cap) + } +} + +impl RequestPolicy for GetBlocksPolicy { + type Request = GetBlocksRequest; + type Error = BlockSyncWireError; + + /// Read the requested height and count, rejecting invalid fields or extra data. + fn decode(&self, frame: Frame) -> Result { + if frame.payload.len() > Self::REQUEST_PAYLOAD_BYTES { + return Err(BlockSyncWireError::OversizedPayload { + actual: frame.payload.len(), + max: Self::REQUEST_PAYLOAD_BYTES, + }); + } + // The existing decoder checks the fields, flags, and matching message tags. + match BlockSyncMessage::decode_frame(frame)? { + BlockSyncMessage::GetBlocks { + start_height, + count, + } => Ok(GetBlocksRequest { + start_height, + count, + }), + message => Err(BlockSyncWireError::UnknownMessageType( + message.message_type(), + )), + } + } + + /// Give shared regulation the size limit it must enforce while we queue frames. + fn response_cap(&self, request: &GetBlocksRequest) -> u64 { + self.response_cap_for_count(request.count) + .expect("validated GetBlocks limits bound response arithmetic") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(start_height: block::Height, count: u32) -> Frame { + Frame { + message_type: u16::from(super::super::super::wire::MSG_BS_GET_BLOCKS), + flags: 0, + payload: BlockSyncMessage::GetBlocks { + start_height, + count, + } + .encode() + .unwrap(), + } + } + + #[test] + fn declaration_uses_codec_and_advertised_response_limits() { + let config = ZakuraBlockSyncConfig::default(); + let policy = GetBlocksPolicy::new(&config); + let request = policy.decode(frame(block::Height(42), 128)).unwrap(); + assert_eq!(request.start_height, block::Height(42)); + assert_eq!( + request.count, 128, + "decode preserves the request; serving selects its prefix" + ); + assert_eq!(policy.response_cap(&request), block::MAX_BLOCK_BYTES + 10); + } + + #[test] + fn get_blocks_range_accepts_the_last_supported_height() { + let policy = GetBlocksPolicy::new(&ZakuraBlockSyncConfig::default()); + for count in 1..=MAX_BS_BLOCKS_PER_REQUEST { + let start_height = block::Height(block::Height::MAX.0 - (count - 1)); + let request = policy.decode(frame(start_height, count)).unwrap(); + assert_eq!(request.start_height, start_height); + assert_eq!(request.count, count); + } + } + + #[test] + fn get_blocks_range_rejects_an_end_above_the_supported_height() { + let policy = GetBlocksPolicy::new(&ZakuraBlockSyncConfig::default()); + for count in 2..=MAX_BS_BLOCKS_PER_REQUEST { + let valid_start = block::Height(block::Height::MAX.0 - (count - 1)); + let invalid_start = block::Height(valid_start.0 + 1); + // Shift a legal range forward one height without using the encoder + // to build the invalid input. The start still fits; its end does not. + let mut invalid = frame(valid_start, count); + invalid.payload[1..5].copy_from_slice(&invalid_start.0.to_le_bytes()); + assert!(matches!( + policy.decode(invalid), + Err(BlockSyncWireError::HeightOutOfRange(height)) + if height == block::Height::MAX.0 + 1 + )); + assert!(matches!( + BlockSyncMessage::GetBlocks { start_height: invalid_start, count }.encode_frame(), + Err(BlockSyncWireError::HeightOutOfRange(height)) + if height == block::Height::MAX.0 + 1 + )); + } + } + + #[test] + fn get_blocks_range_encoder_rejects_arithmetic_overflow() { + assert!(matches!( + BlockSyncMessage::GetBlocks { + start_height: block::Height(u32::MAX), + count: 2 + } + .encode_frame(), + Err(BlockSyncWireError::NumericOverflow(_)) + )); + } + + #[test] + fn declaration_rejects_noncanonical_or_invalid_requests() { + let policy = GetBlocksPolicy::new(&ZakuraBlockSyncConfig::default()); + let valid = frame(block::Height(42), 1); + let mut trailing = valid.clone(); + trailing.payload.push(0); + assert!(policy.decode(trailing).is_err()); + let mut zero_count = valid.clone(); + zero_count.payload[5..9].fill(0); + assert!(matches!( + policy.decode(zero_count), + Err(BlockSyncWireError::ZeroBlockCount) + )); + let mut flags = valid.clone(); + flags.flags = 1; + assert!(matches!( + policy.decode(flags), + Err(BlockSyncWireError::UnsupportedFlags(1)) + )); + let mut mismatch = valid; + mismatch.message_type = u16::from(super::super::super::wire::MSG_BS_BLOCKS_DONE); + assert!(matches!( + policy.decode(mismatch), + Err(BlockSyncWireError::MismatchedFrameMessageType { .. }) + )); + } +} diff --git a/crates/zakura-network/src/zakura/block_sync/serving_regulation/tests.rs b/crates/zakura-network/src/zakura/block_sync/serving_regulation/tests.rs new file mode 100644 index 0000000000..9d58ee1059 --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/serving_regulation/tests.rs @@ -0,0 +1,235 @@ +use super::super::wire::MAX_BS_BLOCKS_PER_REQUEST; +use super::*; +use futures::FutureExt; + +impl GetBlocksServingSession { + // Poll the real admission future once. A pending attempt is cancelled, so + // lifecycle histories can observe immediate admission without advancing time. + pub(in crate::zakura::block_sync) fn admit_now(&self, count: u32) -> Option { + let request = GetBlocksRequest { + start_height: block::Height(0), + count, + }; + self.work + .admit(&request) + .now_or_never() + .map(|work| AdmissionAttempt { + work, + metrics: self.metrics.clone(), + }) + } +} + +fn peer(byte: u8) -> ZakuraPeerId { + ZakuraPeerId::new(vec![byte; 32]).expect("test peer id is within bounds") +} + +#[test] +fn query_and_result_keep_capacity_after_the_producer_closes() { + let regulator = GetBlocksServingRegulator::new(ZakuraBlockSyncConfig::default()); + let session = regulator.session(peer(8)); + let permit = session + .admit_now(1) + .expect("the initial request fits") + .commit(); + let query = permit.work_lease(); + assert!(query.try_start()); + assert!( + !query.clone().try_start(), + "cloning a query never authorizes another read" + ); + let second = permit.work_lease(); + assert!( + !second.try_start(), + "separately issued leases share the execution claim" + ); + let result = query.clone(); + + drop(permit); + assert!(query.is_cancelled()); + assert_eq!(regulator.snapshot().node_active, 1); + + drop(query); + drop(second); + assert_eq!(regulator.snapshot().node_active, 1); + drop(result); + assert_eq!(regulator.snapshot().node_active, 0); +} + +#[test] +fn concurrent_claims_and_cancellation_preserve_one_charged_owner() { + use std::{sync::Barrier, thread}; + + // Exercise overlapping calls; deterministic tests above require both + // ordered outcomes. This does not claim exhaustive schedule coverage. + for _ in 0..64 { + let regulator = GetBlocksServingRegulator::new(ZakuraBlockSyncConfig::default()); + let session = regulator.session(peer(9)); + let permit = session.admit_now(1).unwrap().commit(); + let lease = permit.work_lease(); + + let barrier = Barrier::new(4); + + thread::scope(|scope| { + let first = scope.spawn(|| { + barrier.wait(); + lease.try_start() + }); + let second = scope.spawn(|| { + barrier.wait(); + lease.try_start() + }); + let cancellation = scope.spawn(|| { + barrier.wait(); + drop(permit); + }); + barrier.wait(); + let claims = usize::from(first.join().unwrap()) + usize::from(second.join().unwrap()); + cancellation.join().unwrap(); + assert!(claims <= 1); + }); + + assert!(lease.is_cancelled()); + assert!( + !lease.try_start(), + "producer closure permanently prevents new claims" + ); + assert_eq!(regulator.snapshot().node_active, 1); + + drop(lease); + assert_eq!(regulator.snapshot().node_active, 0); + } +} + +#[tokio::test] +async fn admission_waits_for_and_commits_the_released_node_slot() { + let mut config = ZakuraBlockSyncConfig::default(); + config.get_blocks_regulation.node_active_requests = 1; + let regulator = GetBlocksServingRegulator::new(config); + let session = regulator.session(peer(10)); + let request = GetBlocksRequest { + start_height: block::Height(0), + count: 1, + }; + let owner = session.admit_request(&request).await; + let waiting_session = regulator.session(peer(11)); + let mut wait = Box::pin(waiting_session.admit_request(&request)); + assert!(futures::poll!(&mut wait).is_pending()); + assert_eq!(regulator.snapshot().peer_active, 2); + drop(owner); + let admitted = tokio::time::timeout(Duration::from_secs(1), wait) + .await + .expect("released capacity reaches its waiter"); + assert_eq!(regulator.snapshot().node_active, 1); + assert_eq!(regulator.snapshot().peer_active, 1); + drop(admitted); + assert_eq!(regulator.snapshot().node_active, 0); + assert_eq!(regulator.snapshot().peer_active, 0); +} + +#[test] +fn cost_includes_block_discriminators_and_terminal() { + let mut config = ZakuraBlockSyncConfig { + max_blocks_per_response: 2, + max_response_bytes: u32::try_from(block::MAX_BLOCK_BYTES * 2) + .expect("two maximum block bodies fit u32"), + ..ZakuraBlockSyncConfig::default() + }; + + let cost = GetBlocksPolicy::new(&config) + .response_cap_for_count(2) + .expect("the default bounds do not overflow"); + assert_eq!( + cost, + block::MAX_BLOCK_BYTES * 2 + 2 + GET_BLOCKS_TERMINAL_PAYLOAD_BYTES + ); + + config.max_blocks_per_response = 3; + config.max_response_bytes = u32::try_from(block::MAX_BLOCK_BYTES).unwrap(); + let byte_limited = GetBlocksPolicy::new(&config) + .response_cap_for_count(MAX_BS_BLOCKS_PER_REQUEST) + .expect("the byte-limited cost is representable"); + assert_eq!( + byte_limited, + GET_BLOCKS_TERMINAL_PAYLOAD_BYTES + 3 + block::MAX_BLOCK_BYTES, + "the body-byte cap is separate from discriminators and the terminal frame", + ); +} + +#[test] +fn config_rejects_nonprogressing_or_unbounded_admission_settings() { + let base = ZakuraBlockSyncConfig::default(); + + let mut no_active_slots = base.clone(); + no_active_slots.get_blocks_regulation.node_active_requests = 0; + assert_eq!( + validate_config(&no_active_slots), + Err("get_blocks_regulation.node_active_requests must be greater than zero"), + ); +} + +#[tokio::test(start_paused = true)] +async fn completed_requests_release_capacity_without_waiting_for_time() { + let regulator = GetBlocksServingRegulator::new(ZakuraBlockSyncConfig::default()); + let session = regulator.session(peer(2)); + let now = time::Instant::now(); + for _ in 0..4096 { + let mut permit = session + .admit_now(1) + .expect("released capacity admits work") + .commit(); + let frame = permit.frame_guard(GET_BLOCKS_TERMINAL_PAYLOAD_BYTES); + drop(permit); + assert_eq!(regulator.snapshot().node_active, 1); + drop(frame); + + assert_eq!(regulator.snapshot().node_active, 0); + } + assert_eq!( + time::Instant::now(), + now, + "admission has no bandwidth refill timer" + ); +} + +const RESPONSE_BYTES: u64 = 2_000_010; + +fn queued_response(session: &GetBlocksServingSession) -> FrameGuard { + let mut permit = session.admit_now(1).unwrap().commit(); + permit.frame_guard(RESPONSE_BYTES) +} + +#[test] +fn default_response_count_caps_large_requests_at_one_block() { + let config = ZakuraBlockSyncConfig::default(); + for count in [1, 128, u32::MAX] { + let cap = GetBlocksPolicy::new(&config) + .response_cap_for_count(count) + .unwrap(); + assert_eq!(config.initial_status().max_blocks_per_response, 1); + assert_eq!(cap, RESPONSE_BYTES); + } +} + +#[tokio::test(start_paused = true)] +async fn default_producer_limits_hold_until_writes_finish() { + let config = ZakuraBlockSyncConfig::default(); + validate_config(&config).unwrap(); + let regulator = GetBlocksServingRegulator::new(config); + let peers: Vec<_> = (0..65).map(|id| regulator.session(peer(id))).collect(); + let mut frames = Vec::new(); + for peer in &peers[..64] { + frames.push(queued_response(peer)); + } + tokio::time::advance(Duration::from_secs(60)).await; + let before = regulator.snapshot(); + assert_eq!(before.node_active, 64); + assert!(peers[0].admit_now(1).is_none()); + assert!(peers[64].admit_now(1).is_none()); + assert_eq!(regulator.snapshot(), before); + frames.pop(); + frames.push(queued_response(&peers[64])); + assert_eq!(regulator.snapshot().node_active, 64); + drop(frames); + assert_eq!(regulator.snapshot().node_active, 0); +} diff --git a/crates/zakura-network/src/zakura/block_sync/state.rs b/crates/zakura-network/src/zakura/block_sync/state.rs index ffdc12bc66..a6c2ca19bb 100644 --- a/crates/zakura-network/src/zakura/block_sync/state.rs +++ b/crates/zakura-network/src/zakura/block_sync/state.rs @@ -121,8 +121,11 @@ impl BlockSyncStartup { /// action/routine-to-reactor channels the reactor created. #[derive(Clone, Debug)] pub struct BlockSyncHandle { + pub(super) range_source: Option>, pub(super) events: mpsc::Sender, pub(super) lifecycle: mpsc::UnboundedSender, + /// Current service admissions, reconciled through a coalescing watch. + pub(super) current_sessions: Arc, pub(super) needed_query_failures: mpsc::UnboundedSender, pub(super) peers: watch::Receiver, pub(super) status: watch::Receiver, @@ -149,11 +152,18 @@ pub(super) struct RoutineWiring { #[cfg(test)] pub(super) actions: mpsc::Sender, pub(super) routine_to_reactor: mpsc::Sender, + /// Node-wide GetBlocks resources shared by every peer session. + pub(super) serving_regulator: super::serving_regulation::GetBlocksServingRegulator, pub(super) view: watch::Receiver, pub(super) trace: ZakuraTrace, } impl BlockSyncHandle { + pub(crate) fn with_range_source(mut self, source: Arc) -> Self { + self.range_source = Some(source); + self + } + /// Send a fact/event to the block-sync reactor. pub async fn send( &self, @@ -281,7 +291,6 @@ pub(super) struct BlockSyncState { /// (per-peer routines); the cross-peer facts the reactor/producer need live in the /// [`PeerRegistry`](super::peer_registry). pub(super) peers: HashMap, - pub(super) parked_peers: HashSet, /// Sorted set of needed download heights. Replaces the central /// `BlockRangeScheduler`: the per-peer issuance path pulls work in its own /// servable range, dedup/covered are `in_flight`, and the floor is GC only. @@ -319,7 +328,6 @@ impl BlockSyncState { best_header_tip: startup.best_header_tip.0, best_header_hash: startup.best_header_tip.1, peers: HashMap::new(), - parked_peers: HashSet::new(), work_queue: Arc::new(WorkQueue::new(startup.frontiers.verified_block_tip)), budget: ByteBudget::new(startup.config.max_inflight_block_bytes), needed_heights: Vec::new(), @@ -650,7 +658,7 @@ impl DownloadWindow { /// Bytes reserved across this peer's in-flight requests (the per-request size /// estimates of heights not yet received). Recomputed on demand — the byte unit is /// experimental; a hot path would maintain a running counter instead. - fn outstanding_reserved_bytes(&self) -> u64 { + pub(super) fn outstanding_reserved_bytes(&self) -> u64 { self.outstanding.iter().fold(0u64, |acc, range| { acc.saturating_add(range.reserved_bytes()) }) @@ -738,21 +746,16 @@ impl DownloadWindow { } } - /// Reset per-view no-progress accounting after a destructive view reset. The reset - /// returned this peer's outstanding to the queue on *our* initiative (a reorg/rollback, - /// not the peer's fault), so the in-flight probe streak must not stay charged against - /// it: clearing `requests_without_block_progress` lets an unproven peer probe again - /// instead of wedging at its one-probe cap forever (the reset also cleared its liveness - /// deadline, so nothing would disconnect it). Proof state (`last_block_at`) is preserved. - pub(super) fn note_view_reset(&mut self) { + /// Clear the probe streak after we return requests on our own initiative, + /// such as a view reset. Keep proof of earlier + /// progress, but let even an unproven peer receive work again when we resume. + pub(super) fn note_locally_returned_requests(&mut self) { self.requests_without_block_progress = 0; self.clear_liveness_if_idle(); } - /// Push the block-liveness deadline out by `timeout` when a would-be park is - /// attributable to *local* outbound backpressure, not the peer: while our outbound queue - /// is full the routine stops draining inbound, so a useful body may be sitting unread. - /// Avoids punishing the peer for our own write-side congestion. + /// Give a briefly congested writer time to deliver our queued request before + /// parking the peer for not answering it. The caller bounds this grace. pub(super) fn extend_liveness_deadline(&mut self, now: Instant, timeout: Duration) { self.block_liveness_deadline = Some(now + timeout); } @@ -809,8 +812,6 @@ pub(super) struct PeerBlockState { /// `status_reply_meter`; this half stays reactor-side because the reactor owns /// serving-tip advertisement. pub(super) refresh_meter: RateMeter, - pub(super) served_blocks_inflight: u32, - pub(super) served_block_requests: VecDeque<(block::Height, Instant)>, } impl PeerBlockState { @@ -819,41 +820,7 @@ impl PeerBlockState { direction: session.direction(), session, refresh_meter: RateMeter::new(config.status_refresh_interval), - served_blocks_inflight: 0, - served_block_requests: VecDeque::new(), - } - } - - pub(super) fn try_start_serving_blocks( - &mut self, - local_inflight_cap: u32, - start_height: block::Height, - ) -> bool { - if self.served_blocks_inflight >= local_inflight_cap { - return false; } - self.served_blocks_inflight = self.served_blocks_inflight.saturating_add(1); - self.served_block_requests - .push_back((start_height, Instant::now())); - true - } - - pub(super) fn serving_blocks_elapsed(&self, start_height: block::Height) -> Option { - self.served_block_requests - .iter() - .find_map(|(start, started)| (*start == start_height).then(|| started.elapsed())) - } - - pub(super) fn finish_serving_blocks( - &mut self, - start_height: block::Height, - ) -> Option { - self.served_blocks_inflight = self.served_blocks_inflight.saturating_sub(1); - self.served_block_requests - .iter() - .position(|(start, _)| *start == start_height) - .and_then(|index| self.served_block_requests.remove(index)) - .map(|(_, started)| started.elapsed()) } } @@ -975,12 +942,12 @@ const _: () = assert!(MAX_BS_BLOCKS_PER_REQUEST <= RECEIVED_TRACKER_OFFSET_CAPAC #[derive(Clone, Debug, Default)] pub(super) struct ReceivedBlockTracker { bits: u128, - count: usize, } impl ReceivedBlockTracker { pub(super) fn len(&self) -> usize { - self.count + // At most 128 set bits fit in usize on every supported target. + self.bits.count_ones() as usize } fn contains_offset(&self, offset: u32) -> bool { @@ -995,7 +962,6 @@ impl ReceivedBlockTracker { return false; } self.bits |= bit; - self.count = self.count.saturating_add(1); true } diff --git a/crates/zakura-network/src/zakura/block_sync/tests.rs b/crates/zakura-network/src/zakura/block_sync/tests.rs index 462da5ca72..7d85a1aeaa 100644 --- a/crates/zakura-network/src/zakura/block_sync/tests.rs +++ b/crates/zakura-network/src/zakura/block_sync/tests.rs @@ -30,10 +30,10 @@ use super::{ }; use crate::zakura::{ framed_channel, - testkit::{await_until, TraceCapture, TraceValue}, + testkit::{await_until, DownloadOnlyPeer as Peer, TraceCapture, TraceValue}, trace::BlockBodySource, - FramedRecv, FramedSend, OrderedSessionDemand, Peer, Service, ServicePeerSnapshot, - ServiceRegistry, StreamMode, ZakuraBlockSyncCandidateState, + FramedRecv, FramedSend, OrderedSessionDemand, Service, ServicePeerSnapshot, ServiceRegistry, + StreamMode, ZakuraBlockSyncCandidateState, }; use zakura_chain::{ fmt::HexDebug, @@ -425,13 +425,6 @@ fn round_trip(message: BlockSyncMessage) { assert_eq!(decoded, message); } -async fn next_event(events: &mut mpsc::Receiver) -> BlockSyncEvent { - tokio::time::timeout(Duration::from_secs(1), events.recv()) - .await - .expect("block-sync event should arrive") - .expect("block-sync event channel should stay open") -} - async fn next_action(actions: &mut mpsc::Receiver) -> BlockSyncAction { tokio::time::timeout(Duration::from_secs(1), actions.recv()) .await @@ -807,7 +800,7 @@ async fn newly_eligible_supplier_preserves_persistent_body_alarm_once() { startup.best_header_tip = (header.height, header.hash); startup.committed_views = Some(snapshot_rx); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle); + let service = BlockSyncService::new_with_handle(config, handle); let (_supplier, inbound, _outbound) = connect_peer_with_status( &service, &mut actions, @@ -885,7 +878,7 @@ fn persistent_body_alarm_metrics_expose_every_required_dimension() { .split_once("fn publish_body_unavailable_metrics") .expect("the body-unavailable metric publisher exists") .1 - .split_once("fn clamp_served_block_count") + .split_once("fn local_status") .expect("the metric publisher remains a focused function") .0; for required in [ @@ -948,42 +941,6 @@ async fn next_outbound_message(outbound: &mut FramedRecv) -> BlockSyncMessage { BlockSyncMessage::decode_frame(frame).expect("outbound frame decodes") } -async fn wait_for_outbound_block(outbound: &mut FramedRecv) -> Arc { - loop { - match next_outbound_message(outbound).await { - BlockSyncMessage::Block(block) => return block, - BlockSyncMessage::Status(_) | BlockSyncMessage::GetBlocks { .. } => {} - msg => panic!("unexpected outbound message before block: {msg:?}"), - } - } -} - -async fn wait_for_outbound_blocks_done(outbound: &mut FramedRecv) -> (block::Height, u32) { - loop { - match next_outbound_message(outbound).await { - BlockSyncMessage::BlocksDone { - start_height, - returned, - } => return (start_height, returned), - BlockSyncMessage::Status(_) | BlockSyncMessage::GetBlocks { .. } => {} - msg => panic!("unexpected outbound message before BlocksDone: {msg:?}"), - } - } -} - -async fn wait_for_outbound_range_unavailable(outbound: &mut FramedRecv) -> (block::Height, u32) { - loop { - match next_outbound_message(outbound).await { - BlockSyncMessage::RangeUnavailable { - start_height, - count, - } => return (start_height, count), - BlockSyncMessage::Status(_) | BlockSyncMessage::GetBlocks { .. } => {} - msg => panic!("unexpected outbound message before RangeUnavailable: {msg:?}"), - } - } -} - /// Read this peer's real outbound until the node sends it a `GetBlocks`, returning /// the requested range. The peer is implicit in `outbound` (the node→peer stream /// the routine writes its real `try_send_get_blocks` to), so unlike the old @@ -1371,7 +1328,7 @@ fn view_reset_reclears_probe_streak_so_unproven_peer_can_reprobe() { // A destructive reset returns the peer's outstanding to the queue on our // initiative, then runs the reset hook. window.outstanding.clear(); - window.note_view_reset(); + window.note_locally_returned_requests(); // The peer can probe again (streak below the cap) and is not left as a zombie // (liveness cleared, so `check_liveness` is `Ok`, and proof state is untouched). @@ -1409,7 +1366,7 @@ fn view_reset_preserves_proof_but_reclears_streak() { assert_eq!(window.no_progress_request_cap(), 8); window.outstanding.clear(); - window.note_view_reset(); + window.note_locally_returned_requests(); assert_eq!(window.requests_without_block_progress, 0); assert!( @@ -1518,7 +1475,7 @@ async fn connect_peer_with_status_message( let (inbound_tx, inbound_rx) = framed_channel(16); let (outbound_tx, mut outbound_rx) = framed_channel(16); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -1744,6 +1701,30 @@ fn config_deserialize_clamps_sub_floor_request_inflight_block_bytes() { ); } +#[test] +fn config_deserialize_requires_room_for_one_maximum_size_block() { + let minimum = u32::try_from(block::MAX_BLOCK_BYTES).unwrap(); + for cap in [0, 1, minimum - 1] { + let error = toml::from_str::(&format!( + "[zakura.block_sync]\nmax_response_bytes = {cap}" + )) + .unwrap_err(); + assert!(error + .to_string() + .contains("max_response_bytes must cover one maximum-size block")); + } + for cap in [minimum, minimum + 1, DEFAULT_BS_MAX_RESPONSE_BYTES] { + let config = toml::from_str::(&format!( + "[zakura.block_sync]\nmax_response_bytes = {cap}" + )) + .unwrap(); + assert_eq!( + config.zakura.block_sync.advertised_max_response_bytes(), + cap + ); + } +} + #[test] fn codec_round_trips_every_message_variant() { round_trip(BlockSyncMessage::Status(status())); @@ -3337,7 +3318,7 @@ async fn reactor_fill_loop_saturates_multiple_slots_in_one_pass() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Peer serves heights 1..=4 and accepts four concurrent single-block requests. let (_peer_id, _inbound, mut outbound) = connect_peer_with_status_message( @@ -3609,7 +3590,7 @@ async fn reactor_fill_loop_saturates_every_peer_window_not_just_one() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Three peers, each willing to serve heights 1..=12 and accept four // concurrent single-block requests. The budget is ample, so the fill order @@ -3732,7 +3713,7 @@ async fn reactor_budget_constrained_issuance_rotates_across_peers() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Three peers, each able to serve height 1 with a single in-flight slot. // Distinct ascending id bytes give the old sorted order a fixed lowest peer @@ -3844,7 +3825,7 @@ async fn reactor_timeout_recovery_is_local_and_healthy_peer_keeps_filling() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Two peers, both able to serve heights 1..=2 with a single in-flight slot. // One will be left holding an unanswered request (the slow peer); the other @@ -3978,14 +3959,14 @@ async fn block_liveness_parks_silent_peer_and_traces_reason() { ); startup.trace = ZakuraTrace::new(capture.tracer(), "01"); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(0x51); let (inbound_tx, inbound_rx) = framed_channel(16); let (outbound_tx, mut outbound_rx) = framed_channel(16); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); let connection_cancel = CancellationToken::new(); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -4067,8 +4048,15 @@ async fn block_liveness_parks_silent_peer_and_traces_reason() { #[tokio::test] async fn late_unowned_body_is_rejected_and_the_session_is_parked() { - // A body cannot count as progress after its request ownership expires. - // Verify both the missing submission and the local park. + check_cold_probe_deadline(true).await; +} + +#[tokio::test] +async fn cold_probe_can_finish_after_the_short_floor_rescue_deadline() { + check_cold_probe_deadline(false).await; +} + +async fn check_cold_probe_deadline(expired: bool) { let mut config = immediate_body_download_config(); // Short request/floor-rescue leash so the probe times out fast; the liveness // deadline (request_timeout * 4 = 1.2s) is what a false disconnect would trip. @@ -4089,14 +4077,14 @@ async fn late_unowned_body_is_rejected_and_the_session_is_parked() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(0x53); let (inbound_tx, inbound_rx) = framed_channel(16); let (outbound_tx, mut outbound_rx) = framed_channel(16); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); let connection_cancel = CancellationToken::new(); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -4138,12 +4126,10 @@ async fn late_unowned_body_is_rejected_and_the_session_is_parked() { assert_eq!(start_height, block::Height(1)); assert_eq!(count, 1); - // Let that probe time out on the floor-rescue leash: height 1 returns to the - // queue and, being unproven, the peer is now gated at its one-probe cap. - tokio::time::sleep(Duration::from_millis(200)).await; + // An unmeasured peer gets the normal deadline for its only probe. Deliver + // after the short rescue deadline, or after the normal deadline has expired. + tokio::time::sleep(Duration::from_millis(if expired { 500 } else { 200 })).await; - // The body arrives after retirement of its request owner. - // Do not submit it to the verifier or count it as timely progress. inbound_tx .send( BlockSyncMessage::Block(blocks[0].clone()) @@ -4153,20 +4139,27 @@ async fn late_unowned_body_is_rejected_and_the_session_is_parked() { .await .expect("late block frame queues"); - assert!( - tokio::time::timeout(Duration::from_millis(200), async { - loop { - if matches!( - next_action(&mut actions).await, - BlockSyncAction::SubmitBlock { .. } - ) { - break; - } + let submitted = tokio::time::timeout(Duration::from_millis(200), async { + loop { + if matches!( + next_action(&mut actions).await, + BlockSyncAction::SubmitBlock { .. } + ) { + break; } - }) - .await - .is_err(), - "a completion whose request owner retired must not reach the verifier", + } + }) + .await; + if !expired { + submitted.expect("the still-owned cold probe must reach the verifier"); + assert_eq!(handle.peer_snapshot().outbound_peers, 1); + assert!(!connection_cancel.is_cancelled()); + reactor_task.abort(); + return; + } + assert!( + submitted.is_err(), + "a retired owner cannot reach the verifier" ); await_until( "late unowned body does not prevent the session park", @@ -4210,13 +4203,13 @@ async fn peer_emits_periodic_bbr_heartbeat_while_idle() { ); startup.trace = ZakuraTrace::new(capture.tracer(), "01"); let (_handle, _actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, _handle.clone()); + let service = BlockSyncService::new_with_handle(config, _handle.clone()); let peer = peer(0x5b); let (inbound_tx, inbound_rx) = framed_channel(16); let (outbound_tx, mut outbound_rx) = framed_channel(16); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -6145,6 +6138,11 @@ fn received_tracker_handles_a_full_range_at_the_bitset_boundary() { whole range", height - 1, ); + + // A duplicate must not advance the count or release more reserved bytes. + outstanding.mark_received(block::Height(height)); + assert_eq!(outstanding.received.len(), usize::try_from(height).unwrap()); + assert_eq!(outstanding.reserved_bytes(), u64::from(count - height)); } assert!( @@ -6227,7 +6225,7 @@ fn block_sync_stream_declares_kind_capability_version_and_frame_cap() { let stream = block_sync_streams() .first() .copied() - .expect("block sync declares one stream"); + .expect("block sync declares its data stream first"); assert_eq!(stream.kind, ZAKURA_STREAM_BLOCK_SYNC); assert_eq!(stream.version, ZAKURA_BLOCK_SYNC_STREAM_VERSION); @@ -6259,9 +6257,18 @@ async fn service_registry_routes_block_sync_by_exact_capability_and_version() { .iter() .map(|stream| stream.kind) .collect::>(), - vec![ZAKURA_STREAM_BLOCK_SYNC] + vec![ZAKURA_STREAM_BLOCK_SYNC, ZAKURA_STREAM_BLOCK_REQUESTS] ); assert!(registry.ordered_streams_for_negotiated(0).is_empty()); + assert!(registry.ordered_streams_for_negotiated(1 << 3).is_empty()); + assert!(registry + .capability_for_stream(ZAKURA_STREAM_BLOCK_SYNC, 2) + .is_none()); + assert_eq!( + registry.ordered_streams_for_negotiated((1 << 3) | ZAKURA_CAP_BLOCK_SYNC), + registry.ordered_streams_for_negotiated(ZAKURA_CAP_BLOCK_SYNC), + "a mixed advertisement selects only the complete new layout" + ); assert!(registry.wants_ordered_stream( ZAKURA_STREAM_BLOCK_SYNC, ZAKURA_CAP_BLOCK_SYNC, @@ -6283,15 +6290,16 @@ async fn inert_reactor_parks_after_header_tip_watch_closes() { } #[tokio::test] -async fn add_peer_emits_events_and_round_trips_status_over_framed_path() { - let (service, mut events) = BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); +async fn add_peer_publishes_session_and_drains_inert_framed_path() { + let service = BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); + let current = service.current_sessions_for_test(); let peer = peer(2); let cancel_token = CancellationToken::new(); let (inbound_tx, inbound_rx) = framed_channel(4); let (outbound_tx, outbound_rx) = framed_channel(4); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new( + service.add_peer(Peer::create( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -6299,19 +6307,13 @@ async fn add_peer_emits_events_and_round_trips_status_over_framed_path() { cancel_token, )); - let session = match next_event(&mut events).await { - BlockSyncEvent::PeerConnected(session) => session, - event => panic!("expected PeerConnected, got {event:?}"), - }; + let session = current.snapshot()[&peer].clone(); assert_eq!(session.peer_id(), &peer); assert_eq!(service.peer_count(), 1); let _outbound_rx = outbound_rx; - // The inbound data flow is inverted: with no reactor wiring (`new_for_test`), - // `add_peer` drains inbound frames rather than emitting a `WireMessage` event - // (the production inbound path is the per-peer pipe-routine, exercised by the - // reactor tests with real wiring). The frame still queues onto the framed - // stream; this asserts the framed inbound path is live and consumed. + // This fixture has no reactor. The inert service drains incoming frames; + // the tests with real reactor wiring exercise decoding and downloads. inbound_tx .send( BlockSyncMessage::Status(status()) @@ -6328,14 +6330,15 @@ async fn add_peer_emits_events_and_round_trips_status_over_framed_path() { #[tokio::test] async fn stale_block_sync_teardown_keeps_replacement_session() { - let (service, mut events) = BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); + let service = BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); + let current = service.current_sessions_for_test(); let peer = peer(92); let old_conn_id = 1; let new_conn_id = 2; let (old_inbound_tx, old_inbound_rx) = framed_channel(4); let (old_outbound_tx, _old_outbound_rx) = framed_channel(4); - service.add_peer(Peer::new_with_conn_id_and_direction( + service.add_peer(Peer::create_with_conn_id_and_direction( old_conn_id, peer.clone(), None, @@ -6344,14 +6347,11 @@ async fn stale_block_sync_teardown_keeps_replacement_session() { HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (old_inbound_rx, old_outbound_tx))]), CancellationToken::new(), )); - assert!(matches!( - next_event(&mut events).await, - BlockSyncEvent::PeerConnected(session) if session.peer_id() == &peer - )); + assert!(current.snapshot().contains_key(&peer)); let (new_inbound_tx, new_inbound_rx) = framed_channel(4); let (new_outbound_tx, _new_outbound_rx) = framed_channel(4); - service.add_peer(Peer::new_with_conn_id_and_direction( + service.add_peer(Peer::create_with_conn_id_and_direction( new_conn_id, peer.clone(), None, @@ -6360,15 +6360,12 @@ async fn stale_block_sync_teardown_keeps_replacement_session() { HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (new_inbound_rx, new_outbound_tx))]), CancellationToken::new(), )); - assert!(matches!( - next_event(&mut events).await, - BlockSyncEvent::PeerConnected(session) if session.peer_id() == &peer - )); + assert!(current.snapshot().contains_key(&peer)); assert_eq!(service.peer_count(), 1); let (_stale_inbound_tx, stale_inbound_rx) = framed_channel(4); let (stale_outbound_tx, _stale_outbound_rx) = framed_channel(4); - service.add_peer(Peer::new_with_conn_id_and_direction( + service.add_peer(Peer::create_with_conn_id_and_direction( old_conn_id, peer.clone(), None, @@ -6392,43 +6389,36 @@ async fn stale_block_sync_teardown_keeps_replacement_session() { drop(old_inbound_tx); tokio::time::sleep(Duration::from_millis(50)).await; - if let Ok(Some(BlockSyncEvent::PeerDisconnected(disconnected))) = - tokio::time::timeout(Duration::from_millis(50), events.recv()).await - { - panic!("stale teardown disconnected replacement session for {disconnected:?}"); - } - // The replacement session remains installed (the stale teardown did not - // disconnect it): `peer_count` stays 1 and the live replacement record was - // never removed by the old session's teardown. (The previous check that routed a - // `send_action(SendMessage)` through the record only exercised the removed - // test-only source-pump scaffolding.) + assert!(current.snapshot().contains_key(&peer)); assert_eq!(service.peer_count(), 1); drop(new_inbound_tx); } #[tokio::test] -async fn lifecycle_events_bypass_full_bounded_wire_queue() { +async fn session_publication_bypasses_full_bounded_event_queue() { let mut config = ZakuraBlockSyncConfig::default(); config.peer_limits.inbound_queue_depth = 1; let (events, _event_rx) = mpsc::channel(config.peer_limits.inbound_queue_depth); - // Fill the bounded wire-event queue (per-peer routines deleted `WireMessage`; any event that - // rides the bounded `events` channel proves lifecycle bypass — use a header-tip - // change). + // Session publication must remain live while the driver's event queue is full. events .try_send(BlockSyncEvent::HeaderTipChanged { height: block::Height(1), hash: block::Hash([1; 32]), }) .expect("test fills bounded wire queue"); - let (lifecycle, mut lifecycle_rx) = mpsc::unbounded_channel(); + let (lifecycle, _lifecycle_rx) = mpsc::unbounded_channel(); + let current_sessions = super::service::CurrentSessions::new(); + let mut sessions_changed = current_sessions.subscribe(); let (needed_query_failures, _needed_query_failure_rx) = mpsc::unbounded_channel(); let (_peers_tx, peers) = watch::channel(ServicePeerSnapshot::new(0, 0, config.peer_limits)); let (_status_tx, status) = watch::channel(config.initial_status()); let (_candidates_tx, candidates) = watch::channel(ZakuraBlockSyncCandidateState::default()); let handle = BlockSyncHandle { + range_source: None, events, lifecycle, + current_sessions: current_sessions.clone(), needed_query_failures, peers, status, @@ -6437,13 +6427,13 @@ async fn lifecycle_events_bypass_full_bounded_wire_queue() { // only checks the lifecycle-bypass plumbing. routine_wiring: None, }; - let service = BlockSyncService::new_with_handle_for_test(config, handle); + let service = BlockSyncService::new_with_handle(config, handle); let peer = peer(91); let (inbound_tx, inbound_rx) = framed_channel(4); let (outbound_tx, _outbound_rx) = framed_channel(4); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -6453,22 +6443,141 @@ async fn lifecycle_events_bypass_full_bounded_wire_queue() { )); let _inbound_tx = inbound_tx; - assert!(matches!( - tokio::time::timeout(Duration::from_secs(1), lifecycle_rx.recv()) - .await - .expect("lifecycle event arrives") - .expect("lifecycle channel stays open"), - BlockSyncEvent::PeerConnected(session) if session.peer_id() == &peer - )); - + tokio::time::timeout(Duration::from_secs(1), sessions_changed.changed()) + .await + .unwrap() + .unwrap(); + assert!(current_sessions.snapshot().contains_key(&peer)); service.remove_peer(&peer, 0); - assert!(matches!( - tokio::time::timeout(Duration::from_secs(1), lifecycle_rx.recv()) + tokio::time::timeout(Duration::from_secs(1), sessions_changed.changed()) + .await + .unwrap() + .unwrap(); + assert!(current_sessions.snapshot().is_empty()); +} + +#[tokio::test] +async fn reconciliation_rejects_stale_admissions_and_service_ignores_stale_disconnects() { + let mut config = ZakuraBlockSyncConfig::default(); + config.peer_limits.max_outbound_peers = 1; + let (_tip_tx, tip_rx) = watch::channel((block::Height(0), block::Hash([0; 32]))); + let startup = BlockSyncStartup::new( + BlockSyncFrontiers { + finalized_height: block::Height(0), + verified_block_tip: block::Height(0), + verified_block_hash: block::Hash([0; 32]), + }, + (block::Height(0), block::Hash([0; 32])), + tip_rx, + config.clone(), + ); + let (handle, _actions, reactor_task) = spawn_block_sync_reactor(startup); + let registry = &handle + .routine_wiring + .as_ref() + .expect("a spawned reactor exposes test wiring") + .registry; + let peer_id = peer(93); + let older_id = registry + .admit_session( + &peer_id, + ServicePeerDirection::Outbound, + &config, + 1, + Instant::now(), + ) + .generation(); + let newer_id = registry + .admit_session( + &peer_id, + ServicePeerDirection::Outbound, + &config, + 2, + Instant::now(), + ) + .generation(); + assert!(newer_id > older_id); + + let (newer_send, mut newer_recv) = framed_channel(4); + let newer_cancel = CancellationToken::new(); + let newer = BlockSyncPeerSession::for_test_with_session_id( + peer_id.clone(), + newer_id, + newer_send, + newer_cancel.clone(), + ); + let (older_send, _older_recv) = framed_channel(4); + let older_cancel = CancellationToken::new(); + let older = BlockSyncPeerSession::for_test_with_session_id( + peer_id.clone(), + older_id, + older_send, + older_cancel.clone(), + ); + + let service = BlockSyncService::new_with_handle(config.clone(), handle.clone()); + handle.current_sessions.insert_fixture(1, older); + tokio::time::timeout(Duration::from_secs(1), older_cancel.cancelled()) + .await + .expect("the reactor rejects an admission whose generation is stale"); + assert!(registry.owns_generation(&peer_id, newer_id)); + handle.current_sessions.insert_fixture(2, newer); + wait_for_outbound_status(&mut newer_recv).await; + assert!(!newer_cancel.is_cancelled()); + assert_eq!(handle.peer_snapshot().outbound_peers, 1); + + // The service can admit a session before the reactor observes the previous + // disconnect. Exercise that full-capacity rejection with several identities. + for byte in 100..116 { + let rejected_peer = peer(byte); + let rejected_id = registry + .admit_session( + &rejected_peer, + ServicePeerDirection::Outbound, + &config, + 3, + Instant::now(), + ) + .generation(); + let (send, _recv) = framed_channel(4); + let cancelled = CancellationToken::new(); + handle.current_sessions.insert_fixture( + 3, + BlockSyncPeerSession::for_test_with_session_id( + rejected_peer.clone(), + rejected_id, + send, + cancelled.clone(), + ), + ); + tokio::time::timeout(Duration::from_secs(1), cancelled.cancelled()) .await - .expect("lifecycle event arrives") - .expect("lifecycle channel stays open"), - BlockSyncEvent::PeerDisconnected(disconnected) if disconnected == peer - )); + .unwrap(); + assert!(!registry.owns_generation(&rejected_peer, rejected_id)); + + service.remove_peer(&rejected_peer, 3); + assert!(!newer_cancel.is_cancelled()); + assert_eq!(handle.peer_snapshot().outbound_peers, 1); + } + + service.remove_peer(&peer_id, 1); + tokio::task::yield_now().await; + assert_eq!( + handle.peer_snapshot().outbound_peers, + 1, + "an older disconnect must not remove the replacement session" + ); + + service.remove_peer(&peer_id, 2); + await_until( + "the current session disconnects", + Duration::from_secs(1), + || handle.peer_snapshot().outbound_peers == 0, + ) + .await + .expect("the exact owner may remove its session"); + + reactor_task.abort(); } #[tokio::test] @@ -6492,7 +6601,7 @@ async fn add_peer_decode_failure_reports_malformed_and_cancels_connection() { config.clone(), ); let (handle, mut actions, _reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(3); let (inbound_tx, inbound_rx) = framed_channel(4); @@ -6500,7 +6609,7 @@ async fn add_peer_decode_failure_reports_malformed_and_cancels_connection() { let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); let connection_cancel = CancellationToken::new(); - service.add_peer(Peer::new( + service.add_peer(Peer::create( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -6538,7 +6647,8 @@ async fn add_peer_decode_failure_reports_malformed_and_cancels_connection() { #[tokio::test] async fn registry_add_peer_requires_negotiated_block_sync_capability() { - let (service, mut events) = BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); + let service = BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); + let current = service.current_sessions_for_test(); let registry = ServiceRegistry::new(vec![Arc::new(service)]) .expect("block-sync service declares unique kind"); let peer = peer(4); @@ -6546,13 +6656,17 @@ async fn registry_add_peer_requires_negotiated_block_sync_capability() { let (outbound_tx, _outbound_rx) = framed_channel(4); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - registry.add_peer(Peer::new(peer, None, 0, streams, CancellationToken::new())); + registry.add_peer(Peer::create( + peer, + None, + 0, + streams, + CancellationToken::new(), + )); drop(inbound_tx); assert!( - tokio::time::timeout(Duration::from_millis(100), events.recv()) - .await - .is_err(), + current.snapshot().is_empty(), "without cap 1<<3 the registry must not deliver kind-6 streams" ); } @@ -6567,7 +6681,8 @@ async fn wants_peer_rejects_when_configured_slot_cap_is_reached() { }, ..ZakuraBlockSyncConfig::default() }; - let (service, mut events) = BlockSyncService::new_for_test(config); + let service = BlockSyncService::new_for_test(config); + let current = service.current_sessions_for_test(); let inbound_peer = peer(5); assert!(!service.wants_peer( @@ -6588,7 +6703,7 @@ async fn wants_peer_rejects_when_configured_slot_cap_is_reached() { let (outbound_tx, _outbound_rx) = framed_channel(4); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -6597,10 +6712,7 @@ async fn wants_peer_rejects_when_configured_slot_cap_is_reached() { CancellationToken::new(), )); - assert!(matches!( - next_event(&mut events).await, - BlockSyncEvent::PeerConnected(session) if session.peer_id() == &peer_id - )); + assert!(current.snapshot().contains_key(&peer_id)); inbound_senders.push(inbound_tx); } @@ -6614,7 +6726,7 @@ async fn wants_peer_rejects_when_configured_slot_cap_is_reached() { let (_inbound_tx, inbound_rx) = framed_channel(4); let (outbound_tx, _outbound_rx) = framed_channel(4); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer(8), None, ZAKURA_CAP_BLOCK_SYNC, @@ -6643,13 +6755,13 @@ async fn reactor_drives_tip_to_getblocks_to_submit_over_framed_path() { let trace = ZakuraTrace::noop(); startup.trace = trace.clone(); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(40); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -6780,13 +6892,13 @@ async fn reactor_releases_request_budget_at_receipt_not_apply() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(41); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -6923,12 +7035,12 @@ async fn reactor_does_not_requeue_held_height_reported_still_needed() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(73); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -7086,7 +7198,7 @@ async fn reactor_buffers_body_larger_than_its_size_hint() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -7185,7 +7297,7 @@ async fn reactor_downloads_run_ahead_of_stalled_commit() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status_message( &service, @@ -7270,7 +7382,7 @@ fn add_outbound_block_sync_peer( let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -7291,37 +7403,31 @@ async fn block_sync_add_peer_replaces_same_peer_even_at_full_cap() { let mut config = immediate_body_download_config(); config.peer_limits.max_outbound_peers = 1; config.peer_limits.max_inbound_peers = 0; - let (service, mut events) = BlockSyncService::new_for_test(config); + let service = BlockSyncService::new_for_test(config); + let current = service.current_sessions_for_test(); // Keep every stream handle alive so the per-peer pipes are not torn down. let mut held = Vec::new(); // Peer A fills the only outbound slot. let peer_a = add_outbound_block_sync_peer(&service, 41, &mut held); - match next_event(&mut events).await { - BlockSyncEvent::PeerConnected(session) => assert_eq!(session.peer_id(), &peer_a), - event => panic!("expected PeerConnected for peer A, got {event:?}"), - } + let first = current.snapshot()[&peer_a].clone(); assert_eq!(service.peer_count(), 1); // A distinct, new peer at the full cap is rejected: no session is created. let _peer_b = add_outbound_block_sync_peer(&service, 42, &mut held); - let quiet = tokio::time::timeout(Duration::from_millis(100), events.recv()).await; assert!( - quiet.is_err(), - "a new peer at a full per-direction cap must be rejected", + !current.snapshot().contains_key(&_peer_b), + "a new peer at a full per-direction cap must be rejected" ); assert_eq!(service.peer_count(), 1); // Re-registering peer A (the collision adoption) replaces its session even - // though the cap is full, because A is already counted. The stale-session - // teardown keys on the session id, so only a fresh PeerConnected is emitted. + // though the cap is full, because A is already counted. let peer_a_again = add_outbound_block_sync_peer(&service, 41, &mut held); assert_eq!(peer_a_again, peer_a); - match next_event(&mut events).await { - BlockSyncEvent::PeerConnected(session) => assert_eq!(session.peer_id(), &peer_a), - event => panic!("expected PeerConnected for replaced peer A, got {event:?}"), - } + assert!(current.snapshot()[&peer_a].session_id() > first.session_id()); + assert!(first.cancel_token().is_cancelled()); assert_eq!( service.peer_count(), 1, @@ -7350,7 +7456,7 @@ async fn reactor_keeps_applying_body_after_non_advancing_duplicate_result() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -7468,13 +7574,13 @@ async fn reactor_keeps_active_response_when_needed_snapshot_omits_inflight_heigh config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(42); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -7591,13 +7697,13 @@ async fn reactor_ignores_unmatched_body_for_currently_needed_height() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(142); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -7699,7 +7805,7 @@ async fn reactor_rejects_unmatched_body_for_ownerless_queued_height() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // One request slot and a response byte cap of one block-1 hint, so the single // GetBlocks below covers only height 1 and height 2 stays queued without an // outstanding request — the gap the unmatched-queued acceptance path fills. @@ -7812,13 +7918,13 @@ async fn reactor_queries_needed_blocks_above_submitted_floor() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(43); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -7999,13 +8105,13 @@ async fn reactor_retries_unavailable_body_without_scoring_its_supplier() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(42); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -8234,7 +8340,7 @@ async fn reactor_keeps_issuing_far_above_floor_with_no_near_tip_pause() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Peer serves heights 1..=4 with four concurrent single-block slots. let (_peer_id, _inbound, mut outbound) = connect_peer_with_status_message( @@ -8316,7 +8422,7 @@ async fn routine_refills_after_budget_release_no_missed_wake() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status_message( &service, &mut actions, @@ -8429,7 +8535,7 @@ async fn routine_disconnect_returns_outstanding_and_releases_budget() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (peer_a, _a_in, mut a_out) = connect_peer_with_status( &service, &mut actions, @@ -8530,7 +8636,7 @@ async fn reactor_reserves_size_hint_per_block_not_worst_case() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // One slot, a generous response-byte and per-request block count, so neither // the slot count, the peer's response-byte cap, nor the block-count cap is the @@ -8596,7 +8702,7 @@ async fn reactor_packs_small_estimates_under_peer_response_byte_cap() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let one_worst_case_response = u32::try_from(BS_PER_BLOCK_WORST_CASE_BYTES).expect("worst-case block size fits u32"); @@ -8662,7 +8768,7 @@ async fn reactor_tiny_estimates_pack_into_one_worst_case_budget_block() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, _inbound, mut outbound) = connect_peer_with_status_message( &service, @@ -8762,7 +8868,7 @@ async fn reactor_keeps_block_sync_peer_after_catch_up_and_reuses_later() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -8917,13 +9023,13 @@ async fn reactor_accepts_multi_block_range_and_submits_parent_first() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(43); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -9055,13 +9161,13 @@ async fn reactor_backpressures_inbound_body_flood_without_dropping_bodies() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(64); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -9189,7 +9295,7 @@ async fn reactor_restarted_at_genesis_queries_and_schedules_without_tip_change() config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); match next_action(&mut actions).await { BlockSyncAction::QueryNeededBlocks { @@ -9264,7 +9370,7 @@ async fn reactor_accepts_blocks_done_after_completed_range() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (peer, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -9358,7 +9464,7 @@ async fn reactor_retries_missing_heights_after_partial_blocks_done() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -9484,7 +9590,7 @@ async fn checkpoint_hole_disconnect_retries_first_missing_height_with_fresh_peer config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (old_peer, old_inbound, mut old_outbound) = connect_peer_with_status_message( &service, @@ -9710,7 +9816,7 @@ async fn reactor_reset_mid_download_drops_stale_anchors_and_releases_budget() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -9844,7 +9950,7 @@ async fn reactor_forward_reset_preserves_submitted_successor_body() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -9979,7 +10085,7 @@ async fn reactor_forward_reset_preserves_future_outstanding_body() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -10047,7 +10153,6 @@ async fn reactor_forward_reset_preserves_future_outstanding_body() { ); } BlockSyncAction::QueryNeededBlocks { .. } => {} - BlockSyncAction::QueryBlocksByHeightRange { .. } => {} BlockSyncAction::RecordBodyUnavailable { .. } | BlockSyncAction::RecordBodyInvalid { .. } | BlockSyncAction::RestartBodyAvailability { .. } @@ -10078,7 +10183,7 @@ async fn reactor_forward_reset_preserves_buffered_successor_body() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -10161,7 +10266,6 @@ async fn reactor_forward_reset_preserves_buffered_successor_body() { ); } BlockSyncAction::QueryNeededBlocks { .. } - | BlockSyncAction::QueryBlocksByHeightRange { .. } | BlockSyncAction::RecordBodyUnavailable { .. } | BlockSyncAction::RecordBodyInvalid { .. } | BlockSyncAction::RestartBodyAvailability { .. } @@ -10192,7 +10296,7 @@ async fn reactor_destructive_forward_reset_does_not_rerequest_same_hash_in_fligh config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -10374,12 +10478,12 @@ async fn reactor_ignores_stale_apply_completion_after_resubmit() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(61); let (inbound_tx, inbound_rx) = framed_channel(16); let (outbound_tx, mut outbound_rx) = framed_channel(16); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -10617,7 +10721,7 @@ async fn reactor_fast_forward_reset_clears_buffered_bodies_and_releases_budget() config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -10807,7 +10911,7 @@ async fn reactor_fuzzes_arrival_order_across_fork_parent_first() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -11037,10 +11141,8 @@ async fn reactor_competing_fork_download_switches_to_current_header_hashes() { immediate_body_download_config(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test( - immediate_body_download_config(), - handle.clone(), - ); + let service = + BlockSyncService::new_with_handle(immediate_body_download_config(), handle.clone()); let (peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -11175,7 +11277,7 @@ async fn reactor_legacy_commit_dedups_inflight_request_and_reuses_budget() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, _inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -11264,12 +11366,12 @@ async fn reactor_treats_duplicate_buffered_blocks_as_benign() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(44); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -11376,12 +11478,12 @@ async fn reactor_accepts_rapid_status_growth_without_spam_score() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle); + let service = BlockSyncService::new_with_handle(config, handle); let peer_id = peer(46); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -11439,12 +11541,12 @@ async fn reactor_ignores_redundant_status_burst_without_spam_score() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle); + let service = BlockSyncService::new_with_handle(config, handle); let peer_id = peer(47); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -11498,15 +11600,13 @@ async fn reactor_rejects_block_hash_mismatch_without_hard_drop_for_size_mismatch immediate_body_download_config(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test( - immediate_body_download_config(), - handle.clone(), - ); + let service = + BlockSyncService::new_with_handle(immediate_body_download_config(), handle.clone()); let peer = peer(41); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -11619,7 +11719,7 @@ async fn scheduled_get_blocks_is_sent_once_via_session() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Admit a peer through the production `add_peer` path with an observable // outbound transport channel. @@ -11627,7 +11727,7 @@ async fn scheduled_get_blocks_is_sent_once_via_session() { let (inbound_tx, inbound_rx) = framed_channel(16); let (outbound_tx, mut outbound_rx) = framed_channel(16); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -11721,7 +11821,7 @@ async fn reactor_scores_exact_supplier_for_commitment_matching_consensus_invalid config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (bad_peer, bad_inbound, mut bad_outbound) = connect_peer_with_status( &service, &mut actions, @@ -11822,117 +11922,17 @@ async fn reactor_scores_exact_supplier_for_commitment_matching_consensus_invalid } #[tokio::test] -async fn reactor_serves_committed_blocks_with_count_and_byte_clamps() { - let blocks = mainnet_blocks_1_to_3(); - let block1_size = block_size(&blocks[0]); - let mut config = ZakuraBlockSyncConfig { - max_blocks_per_response: 2, - max_response_bytes: block1_size, - ..ZakuraBlockSyncConfig::default() - }; - config.peer_limits.outbound_queue_depth = 16; - let (_tip_tx, tip_rx) = watch::channel((block::Height(4), block::Hash([4; 32]))); - let startup = BlockSyncStartup::new( - BlockSyncFrontiers { - finalized_height: block::Height(0), - verified_block_tip: block::Height(3), - verified_block_hash: blocks[2].hash(), - }, - (block::Height(4), block::Hash([4; 32])), - tip_rx, - config.clone(), - ); - let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); - let (peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( - &service, - &mut actions, - 60, - block::Height(3), - blocks[2].hash(), - 1, - MAX_BS_RESPONSE_BYTES, - ) - .await; - - inbound_tx - .send( - BlockSyncMessage::GetBlocks { - start_height: block::Height(1), - count: 10, - } - .encode_frame() - .expect("GetBlocks frame encodes"), - ) - .await - .expect("GetBlocks frame queues"); - - loop { - match next_action(&mut actions).await { - BlockSyncAction::QueryBlocksByHeightRange { peer, start, count } => { - assert_eq!(peer, peer_id); - assert_eq!(start, block::Height(1)); - assert_eq!(count, 2); - break; - } - BlockSyncAction::QueryNeededBlocks { .. } => {} - action => panic!("unexpected action before block range query: {action:?}"), - } - } - - handle - .send(BlockSyncEvent::BlockRangeResponseReady { - peer: peer_id.clone(), - start_height: block::Height(1), - requested_count: 2, - blocks: vec![ - ( - block::Height(1), - blocks[0].clone(), - usize::try_from(block1_size).expect("block size fits usize"), - ), - ( - block::Height(2), - blocks[1].clone(), - usize::try_from(block_size(&blocks[1])).expect("block size fits usize"), - ), - ], - }) - .await - .expect("served block response queues"); - - assert_eq!( - wait_for_outbound_block(&mut outbound_rx).await.hash(), - blocks[0].hash() - ); - assert_eq!( - wait_for_outbound_blocks_done(&mut outbound_rx).await, - (block::Height(1), 1), - "max_response_bytes clamps the served response to one body" - ); - - inbound_tx - .send( - BlockSyncMessage::GetBlocks { - start_height: block::Height(4), - count: 1, - } - .encode_frame() - .expect("GetBlocks frame encodes"), - ) - .await - .expect("above-tip GetBlocks frame queues"); - - assert_eq!( - wait_for_outbound_range_unavailable(&mut outbound_rx).await, - (block::Height(4), 1) - ); - - reactor_task.abort(); -} - -#[tokio::test] -async fn reactor_never_serves_reorder_buffer_bodies() { +async fn reactor_schedules_gap_below_buffered_reorder_run() { + // Regression for the mainnet stuck-at-0 deadlock: a body run received above + // an open gap must not starve the gap below it. The state reports every + // header-known, body-missing height (it cannot see our in-memory reorder + // buffer), so a re-query returns already-buffered heights too. With + // multi-peer fanout the held range lingers in the scheduler queue. Because + // `refresh_needed` builds one maximal contiguous range and `ensure` rejects + // any range overlapping a queued one, the gap below the held run would never + // be scheduled and `body_download_floor` would freeze forever while we + // re-requested the already-held blocks. The reactor must drop already-held + // heights from the needed set so the gap gets scheduled. let blocks = mainnet_blocks_1_to_3(); let mut config = immediate_body_download_config(); config.peer_limits.outbound_queue_depth = 16; @@ -11948,11 +11948,11 @@ async fn reactor_never_serves_reorder_buffer_bodies() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, - 61, + 63, block::Height(3), blocks[2].hash(), 1, @@ -11960,95 +11960,8 @@ async fn reactor_never_serves_reorder_buffer_bodies() { ) .await; - handle - .send(BlockSyncEvent::NeededBlocks(vec![block_meta(&blocks[2])])) - .await - .expect("needed metadata queues"); - assert_eq!( - wait_for_outbound_getblocks(&mut outbound_rx).await, - (block::Height(3), 1) - ); - inbound_tx - .send( - BlockSyncMessage::Block(blocks[2].clone()) - .encode_frame() - .expect("block frame encodes"), - ) - .await - .expect("block frame queues"); - - let quiet = tokio::time::timeout(Duration::from_millis(50), async { - while let Some(action) = actions.recv().await { - if matches!(action, BlockSyncAction::SubmitBlock { .. }) { - panic!("height 3 must stay buffered behind the height 2 gap"); - } - } - }) - .await; - assert!(quiet.is_err()); - - inbound_tx - .send( - BlockSyncMessage::GetBlocks { - start_height: block::Height(3), - count: 1, - } - .encode_frame() - .expect("GetBlocks frame encodes"), - ) - .await - .expect("GetBlocks frame queues"); - - assert_eq!( - wait_for_outbound_range_unavailable(&mut outbound_rx).await, - (block::Height(3), 1), - "uncommitted reorder-buffer body must not be served" - ); - - reactor_task.abort(); -} - -#[tokio::test] -async fn reactor_schedules_gap_below_buffered_reorder_run() { - // Regression for the mainnet stuck-at-0 deadlock: a body run received above - // an open gap must not starve the gap below it. The state reports every - // header-known, body-missing height (it cannot see our in-memory reorder - // buffer), so a re-query returns already-buffered heights too. With - // multi-peer fanout the held range lingers in the scheduler queue. Because - // `refresh_needed` builds one maximal contiguous range and `ensure` rejects - // any range overlapping a queued one, the gap below the held run would never - // be scheduled and `body_download_floor` would freeze forever while we - // re-requested the already-held blocks. The reactor must drop already-held - // heights from the needed set so the gap gets scheduled. - let blocks = mainnet_blocks_1_to_3(); - let mut config = immediate_body_download_config(); - config.peer_limits.outbound_queue_depth = 16; - let (_tip_tx, tip_rx) = watch::channel((block::Height(3), blocks[2].hash())); - let startup = BlockSyncStartup::new( - BlockSyncFrontiers { - finalized_height: block::Height(0), - verified_block_tip: block::Height(1), - verified_block_hash: blocks[0].hash(), - }, - (block::Height(3), blocks[2].hash()), - tip_rx, - config.clone(), - ); - let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); - let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( - &service, - &mut actions, - 63, - block::Height(3), - blocks[2].hash(), - 1, - MAX_BS_RESPONSE_BYTES, - ) - .await; - - // Height 2 is momentarily not offered, so we fetch and buffer height 3 in - // the reorder buffer above the open height-2 gap. + // Height 2 is momentarily not offered, so we fetch and buffer height 3 in + // the reorder buffer above the open height-2 gap. handle .send(BlockSyncEvent::NeededBlocks(vec![block_meta(&blocks[2])])) .await @@ -12136,7 +12049,7 @@ async fn reactor_debounces_status_advertisements_on_serving_tip_change() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, _inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -12230,13 +12143,13 @@ async fn reactor_retries_status_to_peer_without_status_when_local_status_unchang config.clone(), ); let (handle, _actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle); + let service = BlockSyncService::new_with_handle(config, handle); let peer = peer(63); let (_inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer, None, ZAKURA_CAP_BLOCK_SYNC, @@ -12282,7 +12195,7 @@ async fn reactor_replies_to_first_status_when_connect_status_queue_was_full() { config.clone(), ); let (handle, _actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(64); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(1); @@ -12295,7 +12208,7 @@ async fn reactor_replies_to_first_status_when_connect_status_queue_was_full() { .expect("outbound queue starts full"); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer, None, ZAKURA_CAP_BLOCK_SYNC, @@ -12345,13 +12258,13 @@ async fn reactor_does_not_ping_pong_rapid_repeated_status() { config.clone(), ); let (handle, _actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle); + let service = BlockSyncService::new_with_handle(config, handle); let peer = peer(65); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(16); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer, None, ZAKURA_CAP_BLOCK_SYNC, @@ -12394,7 +12307,7 @@ async fn reactor_preserves_successor_work_across_stale_finalized_reset() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -12518,7 +12431,7 @@ async fn committed_reanchor_requeries_while_downloads_in_flight() { ); let (snapshots, startup) = committed_block_sync_startup(initial, config.clone()); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, _inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -12593,7 +12506,7 @@ async fn epoch_change_supersedes_stale_floor_query() { ); let (snapshots, startup) = committed_block_sync_startup(initial, config.clone()); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let BlockSyncAction::QueryNeededBlocks { query_id, @@ -12758,7 +12671,7 @@ async fn header_extension_preserves_checkpoint_pipeline() { ); let (snapshots, startup) = committed_block_sync_startup(initial, config.clone()); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let BlockSyncAction::QueryNeededBlocks { query_id, scope, .. } = next_action(&mut actions).await @@ -12937,7 +12850,7 @@ async fn committed_body_progress_preserves_same_target_native_response() { ); let (snapshots, startup) = committed_block_sync_startup(initial, config.clone()); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -13039,7 +12952,7 @@ async fn committed_reanchor_releases_stale_submitted_bodies() { ); let (snapshots, startup) = committed_block_sync_startup(initial, config.clone()); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -13156,7 +13069,7 @@ async fn reactor_clamps_tiny_submitted_apply_config_above_checkpoint_range() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); wait_for_query_needed_blocks(&mut actions, block::Height(0), block::Height(4)).await; let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( @@ -13666,7 +13579,7 @@ async fn reactor_retries_matched_range_unavailable_without_scoring_peer() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -13786,7 +13699,7 @@ async fn reactor_does_not_wedge_honest_peer_under_range_unavailable_spam() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Two misbehaving peers (ids 0x01, 0x02 sort first) and one honest peer (0x03). let (m1, m1_in, mut m1_out) = connect_peer_with_status( @@ -13889,7 +13802,7 @@ async fn reactor_range_unavailable_retries_only_unverified_suffix() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (_peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -13943,191 +13856,6 @@ async fn reactor_range_unavailable_retries_only_unverified_suffix() { reactor_task.abort(); } -#[tokio::test] -async fn reactor_backpressures_serving_slots_without_scoring_peer() { - let mut config = ZakuraBlockSyncConfig { - max_inflight_requests: 1, - ..ZakuraBlockSyncConfig::default() - }; - config.peer_limits.outbound_queue_depth = 16; - let blocks = mainnet_blocks_1_to_3(); - let (_tip_tx, tip_rx) = watch::channel((block::Height(2), blocks[1].hash())); - let startup = BlockSyncStartup::new( - BlockSyncFrontiers { - finalized_height: block::Height(0), - verified_block_tip: block::Height(1), - verified_block_hash: blocks[0].hash(), - }, - (block::Height(2), blocks[1].hash()), - tip_rx, - config.clone(), - ); - let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); - let (peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( - &service, - &mut actions, - 63, - block::Height(1), - blocks[0].hash(), - 1, - MAX_BS_RESPONSE_BYTES, - ) - .await; - tokio::time::sleep(Duration::from_millis(10)).await; - - for _ in 0..2 { - inbound_tx - .send( - BlockSyncMessage::GetBlocks { - start_height: block::Height(1), - count: 1, - } - .encode_frame() - .expect("GetBlocks frame encodes"), - ) - .await - .expect("GetBlocks frame queues"); - } - while !matches!( - next_action(&mut actions).await, - BlockSyncAction::QueryBlocksByHeightRange { .. } - ) {} - - assert_eq!( - wait_for_outbound_range_unavailable(&mut outbound_rx).await, - (block::Height(1), 1), - "serving-slot saturation should backpressure the requester, not score it as spam", - ); - assert_eq!(handle.peer_snapshot().outbound_peers, 1); - - handle - .send(BlockSyncEvent::BlockRangeResponseFinished { - peer: peer_id.clone(), - start_height: block::Height(1), - requested_count: 1, - returned_count: 1, - }) - .await - .expect("serving slot release queues"); - - reactor_task.abort(); -} - -#[tokio::test] -async fn serving_flood_cannot_consume_needed_query_retry() { - let blocks = mainnet_blocks_1_to_3(); - let mut config = ZakuraBlockSyncConfig { - request_timeout: Duration::from_secs(2), - ..ZakuraBlockSyncConfig::default() - }; - config.peer_limits.outbound_queue_depth = 16; - let (_tip_tx, tip_rx) = watch::channel((block::Height(1), blocks[0].hash())); - let startup = BlockSyncStartup::new( - BlockSyncFrontiers { - finalized_height: block::Height(0), - verified_block_tip: block::Height(1), - verified_block_hash: blocks[0].hash(), - }, - (block::Height(1), blocks[0].hash()), - tip_rx, - config.clone(), - ); - let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); - let (peer_id, inbound_tx, _outbound_rx) = connect_peer_with_status( - &service, - &mut actions, - 62, - block::Height(1), - blocks[0].hash(), - 1, - MAX_BS_RESPONSE_BYTES, - ) - .await; - - let wiring = handle - .routine_wiring - .as_ref() - .expect("the spawned reactor exposes its shared test wiring"); - await_until( - "the peer status reaches the registry", - Duration::from_secs(1), - || wiring.registry.has_received_status(&peer_id), - ) - .await - .expect("the valid serving peer becomes ready"); - tokio::time::sleep(Duration::from_millis(50)).await; - while wiring.actions.capacity() > 0 { - wiring - .actions - .try_send(BlockSyncAction::Misbehavior { - peer: peer(0xfe), - reason: BlockSyncMisbehavior::InvalidBlock, - }) - .expect("the test fills one available action slot"); - } - - handle - .send(BlockSyncEvent::HeaderTipChanged { - height: block::Height(2), - hash: blocks[1].hash(), - }) - .await - .expect("the higher header tip queues"); - let (barrier_send, _barrier_recv) = framed_channel(1); - handle - .send(BlockSyncEvent::PeerConnected( - BlockSyncPeerSession::for_test(peer(0xfd), barrier_send, CancellationToken::new()), - )) - .await - .expect("the event-order barrier queues"); - await_until( - "the reactor handles the full-queue refill attempt", - Duration::from_secs(1), - || handle.peer_snapshot().outbound_peers == 2, - ) - .await - .expect("the peer event follows the header-tip event"); - assert_eq!( - wiring.actions.capacity(), - 0, - "the first needed-body query loses to the full action queue", - ); - - let _ = actions.recv().await.expect("one flooded action drains"); - send_inbound( - &inbound_tx, - BlockSyncMessage::GetBlocks { - start_height: block::Height(1), - count: 1, - }, - ) - .await; - tokio::time::sleep(Duration::from_millis(10)).await; - - tokio::time::timeout(Duration::from_secs(3), async { - loop { - match actions.recv().await { - Some(BlockSyncAction::QueryNeededBlocks { - from: block::Height(2), - best_header_tip: block::Height(2), - .. - }) => break, - Some(BlockSyncAction::QueryBlocksByHeightRange { .. }) => { - panic!("peer serving consumed the refill control reservation") - } - Some(_) => {} - None => panic!("the action stream closed before the refill retry"), - } - } - }) - .await - .expect("the local tick retries the needed-body query"); - - reactor_task.abort(); -} - #[tokio::test] async fn misbehavior_flood_cannot_consume_needed_query_capacity() { let blocks = mainnet_blocks_1_to_3(); @@ -14148,7 +13876,7 @@ async fn misbehavior_flood_cannot_consume_needed_query_capacity() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -14242,189 +13970,6 @@ async fn misbehavior_flood_cannot_consume_needed_query_capacity() { reactor_task.abort(); } -/// A full per-peer serving queue must drop the serving send, never disconnect -/// the peer. -/// -/// The reactor serves `Status`/`Block`/`BlocksDone`/`RangeUnavailable` with -/// non-blocking `try_send_*`. On `Full` it drops the frame and bumps a -/// `*.serve_queue_full` metric; only a genuine send *error* cancels the peer. -/// A regression that collapsed those two arms (cancelling on `Full`) would -/// disconnect honest-but-slow peers under serving load — a self-inflicted DoS. -/// This guards the distinction: a saturated serving queue leaves the peer -/// connected, and serving resumes once the queue drains. -#[tokio::test] -async fn reactor_full_serving_queue_drops_without_disconnecting_peer() { - let blocks = mainnet_blocks_1_to_3(); - let config = ZakuraBlockSyncConfig { - max_blocks_per_response: 16, - max_response_bytes: MAX_BS_RESPONSE_BYTES, - ..ZakuraBlockSyncConfig::default() - }; - // Tip == verified tip: the reactor is caught up, so it only *serves* and - // never issues its own downloads into the tiny outbound queue below. - let (_tip_tx, tip_rx) = watch::channel((block::Height(3), blocks[2].hash())); - let startup = BlockSyncStartup::new( - BlockSyncFrontiers { - finalized_height: block::Height(0), - verified_block_tip: block::Height(3), - verified_block_hash: blocks[2].hash(), - }, - (block::Height(3), blocks[2].hash()), - tip_rx, - config.clone(), - ); - let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); - - // A two-slot outbound queue, wired by hand so we keep the peer's - // cancellation token: serving three bodies plus a terminator is four sends - // through two slots, so `try_send` is guaranteed to hit `Full`. - let peer_id = peer(66); - let cancel = CancellationToken::new(); - let (inbound_tx, inbound_rx) = framed_channel(16); - let (outbound_tx, mut outbound_rx) = framed_channel(2); - let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( - peer_id.clone(), - None, - ZAKURA_CAP_BLOCK_SYNC, - ServicePeerDirection::Outbound, - streams, - cancel.clone(), - )); - wait_for_outbound_status(&mut outbound_rx).await; - inbound_tx - .send( - BlockSyncMessage::Status(BlockSyncStatus { - servable_low: block::Height(1), - servable_high: block::Height(3), - tip_hash: blocks[2].hash(), - max_blocks_per_response: 16, - max_inflight_requests: 8, - max_response_bytes: MAX_BS_RESPONSE_BYTES, - }) - .encode_frame() - .expect("status encodes"), - ) - .await - .expect("status frame queues"); - - // The peer asks for the whole committed range; answer it so the reactor - // serves all three bodies into the saturated queue. - inbound_tx - .send( - BlockSyncMessage::GetBlocks { - start_height: block::Height(1), - count: 3, - } - .encode_frame() - .expect("GetBlocks frame encodes"), - ) - .await - .expect("GetBlocks frame queues"); - loop { - match next_action(&mut actions).await { - BlockSyncAction::QueryBlocksByHeightRange { peer, start, count } => { - assert_eq!(peer, peer_id); - assert_eq!(start, block::Height(1)); - assert_eq!(count, 3); - break; - } - BlockSyncAction::QueryNeededBlocks { .. } => {} - action => panic!("unexpected action before block range query: {action:?}"), - } - } - let served: Vec<_> = blocks - .iter() - .map(|block| { - ( - block.coinbase_height().expect("test block has height"), - block.clone(), - usize::try_from(block_size(block)).expect("block size fits usize"), - ) - }) - .collect(); - handle - .send(BlockSyncEvent::BlockRangeResponseReady { - peer: peer_id.clone(), - start_height: block::Height(1), - requested_count: 3, - blocks: served, - }) - .await - .expect("served block response queues"); - - // Let the reactor finish the serve, including the sends that hit `Full`. - tokio::time::sleep(Duration::from_millis(50)).await; - assert!( - !cancel.is_cancelled(), - "a full serving queue must drop sends, not cancel the peer", - ); - assert_eq!(handle.peer_snapshot().outbound_peers, 1); - - // Self-heal: drain the queue, release the serving slot, and re-request. The - // earlier drop neither wedged nor scored the peer, so a fresh serve lands. - while tokio::time::timeout( - Duration::from_millis(100), - next_outbound_message(&mut outbound_rx), - ) - .await - .is_ok() - {} - handle - .send(BlockSyncEvent::BlockRangeResponseFinished { - peer: peer_id.clone(), - start_height: block::Height(1), - requested_count: 3, - returned_count: 3, - }) - .await - .expect("serving slot release queues"); - inbound_tx - .send( - BlockSyncMessage::GetBlocks { - start_height: block::Height(1), - count: 1, - } - .encode_frame() - .expect("GetBlocks frame encodes"), - ) - .await - .expect("re-request GetBlocks frame queues"); - loop { - match next_action(&mut actions).await { - BlockSyncAction::QueryBlocksByHeightRange { start, count, .. } => { - assert_eq!(start, block::Height(1)); - assert_eq!(count, 1); - break; - } - BlockSyncAction::QueryNeededBlocks { .. } => {} - action => panic!("unexpected action before re-request query: {action:?}"), - } - } - handle - .send(BlockSyncEvent::BlockRangeResponseReady { - peer: peer_id.clone(), - start_height: block::Height(1), - requested_count: 1, - blocks: vec![( - block::Height(1), - blocks[0].clone(), - usize::try_from(block_size(&blocks[0])).expect("block size fits usize"), - )], - }) - .await - .expect("re-served block response queues"); - assert_eq!( - wait_for_outbound_block(&mut outbound_rx).await.hash(), - blocks[0].hash(), - "serving resumes once the queue drains", - ); - assert!(!cancel.is_cancelled()); - - reactor_task.abort(); -} - #[tokio::test] async fn reactor_publishes_block_sync_candidate_gap() { let config = immediate_body_download_config(); @@ -14440,12 +13985,12 @@ async fn reactor_publishes_block_sync_candidate_gap() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer_id = peer(77); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -14575,12 +14120,12 @@ async fn oversize_body_policy_reports_size_mismatch_and_retries_without_bufferin config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(42); let (inbound_tx, inbound_rx) = framed_channel(8); let (outbound_tx, mut outbound_rx) = framed_channel(8); let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( peer, None, ZAKURA_CAP_BLOCK_SYNC, @@ -14703,7 +14248,7 @@ async fn reactor_known_peer_unsolicited_blocks_done_is_reported_as_misbehavior() config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Connect a peer that advertises no downloadable work (servable_high == our // verified tip), so the reactor never schedules a GetBlocks and the peer has @@ -14775,7 +14320,7 @@ async fn reactor_accepts_unmatched_body_for_height_active_on_another_request() { let trace = ZakuraTrace::noop(); startup.trace = trace.clone(); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (peer1, inbound1, mut outbound1) = connect_peer_with_status( &service, @@ -14896,7 +14441,7 @@ async fn reactor_ignores_duplicate_response_at_body_download_floor() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (peer_id, inbound_tx, mut outbound_rx) = connect_peer_with_status( &service, &mut actions, @@ -15009,7 +14554,7 @@ async fn reactor_ignores_matched_duplicate_response_at_body_download_floor() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let (peer_a, inbound_a, mut outbound_a) = connect_peer_with_status( &service, &mut actions, @@ -15165,7 +14710,7 @@ async fn reactor_scores_unsolicited_terminator_from_connected_peer() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config.clone(), handle.clone()); + let service = BlockSyncService::new_with_handle(config.clone(), handle.clone()); let (peer_id, inbound_tx, _outbound_rx) = connect_peer_with_status( &service, @@ -15223,14 +14768,14 @@ async fn repeated_misbehavior_is_recorded_without_disconnecting_the_peer() { config.clone(), ); let (handle, mut actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); // Connect the probe peer with a real pipe-routine (per-peer routines) so its inbound frames // are decoded and dispatched. let probe = peer(7); - let (probe_inbound_tx, probe_inbound_rx) = framed_channel(8); + let (_probe_inbound_tx, probe_inbound_rx) = framed_channel(8); let (probe_outbound_tx, _probe_outbound_rx) = framed_channel(8); - service.add_peer(Peer::new_with_direction( + service.add_peer(Peer::create_with_direction( probe.clone(), None, ZAKURA_CAP_BLOCK_SYNC, @@ -15249,18 +14794,20 @@ async fn repeated_misbehavior_is_recorded_without_disconnecting_the_peer() { .await .expect("probe peer connects"); - // Each `GetBlocks` from a peer that has not sent a Status is `GetBlocksSpam` - // (formerly a "soft" offense that disconnected at a threshold of 3). Send well - // past the old threshold. + // Repeated soft reports remain record-only. GetBlocks before Status is now + // legal across the two streams and is tested at the serving boundary. for _ in 0..8 { - send_inbound( - &probe_inbound_tx, - BlockSyncMessage::GetBlocks { - start_height: block::Height(1), - count: 1, - }, - ) - .await; + handle + .routine_wiring + .as_ref() + .unwrap() + .routine_to_reactor + .send(super::events::RoutineToReactor::Misbehavior { + peer: probe.clone(), + reason: BlockSyncMisbehavior::UnsolicitedDone, + }) + .await + .unwrap(); } // The violation is still recorded: the reactor emits a best-effort @@ -15268,7 +14815,7 @@ async fn repeated_misbehavior_is_recorded_without_disconnecting_the_peer() { tokio::time::timeout(Duration::from_secs(2), async { loop { if let BlockSyncAction::Misbehavior { peer, reason } = next_action(&mut actions).await { - if peer == probe && reason == BlockSyncMisbehavior::GetBlocksSpam { + if peer == probe && reason == BlockSyncMisbehavior::UnsolicitedDone { break; } } @@ -15304,7 +14851,7 @@ async fn parked_connection_cleanup_allows_a_fresh_connection_after_cooldown() { config.clone(), ); let (handle, _actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(93); let old_conn_id = 7; let new_conn_id = 8; @@ -15348,7 +14895,7 @@ async fn same_connection_block_sync_session_waits_at_tip_then_reopens_for_new_wo config.clone(), ); let (handle, _actions, reactor_task) = spawn_block_sync_reactor(startup); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()); + let service = BlockSyncService::new_with_handle(config, handle.clone()); let peer = peer(9); let conn_id = 17; handle.park_session_for_test(&peer, conn_id, Duration::ZERO); @@ -15414,7 +14961,7 @@ async fn serving_only_coordinator_demand_keeps_block_session_available_during_fa }, }; let (demand_tx, demand_rx) = watch::channel(applying); - let service = BlockSyncService::new_with_handle_for_test(config, handle.clone()) + let service = BlockSyncService::new_with_handle(config, handle.clone()) .with_service_demand(Some(demand_rx)); let peer = peer(10); let conn_id = 18; diff --git a/crates/zakura-network/src/zakura/block_sync/trace.rs b/crates/zakura-network/src/zakura/block_sync/trace.rs index 15ea861268..4196379bf3 100644 --- a/crates/zakura-network/src/zakura/block_sync/trace.rs +++ b/crates/zakura-network/src/zakura/block_sync/trace.rs @@ -357,36 +357,6 @@ impl QueueSendFailedEvent { ) } - /// Peer routines historically projected range fields only for GetBlocks. - pub(super) fn peer_routine( - peer: &ZakuraPeerId, - message: &BlockSyncMessage, - error: &OrderedSendError, - queue_capacity: usize, - queue_max_capacity: usize, - ) -> Self { - let message_fields = match message { - BlockSyncMessage::GetBlocks { - start_height, - count, - } => MessageFields { - range_start: Some(height(*start_height)), - range_count: Some(u64::from(*count)), - ..MessageFields::default() - }, - _ => MessageFields::default(), - }; - Self::build( - peer, - message, - error, - None, - queue_capacity, - queue_max_capacity, - message_fields, - ) - } - fn build( peer: &ZakuraPeerId, message: &BlockSyncMessage, @@ -481,9 +451,11 @@ pub(super) struct BlockEventReceived { #[derive(Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum BlockEventDetail { + #[cfg(test)] PeerConnected { peer: String, }, + #[cfg(test)] PeerDisconnected { peer: String, }, @@ -523,26 +495,16 @@ enum BlockEventDetail { #[serde(skip_serializing_if = "Option::is_none")] verified_block_tip: Option, }, - BlockRangeResponseFinished { - peer: String, - range_start: u64, - range_count: u64, - expected_count: u64, - }, - BlockRangeResponseReady { - peer: String, - range_start: u64, - range_count: u64, - expected_count: u64, - }, } impl BlockEventReceived { pub(super) fn new(event: &BlockSyncEvent) -> Self { let detail = match event { + #[cfg(test)] BlockSyncEvent::PeerConnected(session) => BlockEventDetail::PeerConnected { peer: peer_label(session.peer_id()), }, + #[cfg(test)] BlockSyncEvent::PeerDisconnected(peer) => BlockEventDetail::PeerDisconnected { peer: peer_label(peer), }, @@ -597,28 +559,6 @@ impl BlockEventReceived { result: block_apply_result_label(outcome.result()), verified_block_tip: None, }, - BlockSyncEvent::BlockRangeResponseFinished { - peer, - start_height, - requested_count, - returned_count, - } => BlockEventDetail::BlockRangeResponseFinished { - peer: peer_label(peer), - range_start: height(*start_height), - range_count: u64::from(*returned_count), - expected_count: u64::from(*requested_count), - }, - BlockSyncEvent::BlockRangeResponseReady { - peer, - start_height, - requested_count, - blocks, - } => BlockEventDetail::BlockRangeResponseReady { - peer: peer_label(peer), - range_start: height(*start_height), - range_count: saturating_usize(blocks.len()), - expected_count: u64::from(*requested_count), - }, }; Self { event: "block_event_received", @@ -646,11 +586,6 @@ enum BlockActionDetail { range_count: u64, best_header_tip: u64, }, - QueryBlocksByHeightRange { - peer: String, - range_start: u64, - range_count: u64, - }, SubmitBlock { apply_token: u64, hash: String, @@ -680,13 +615,6 @@ impl BlockActionDispatched { range_count: u64::from(*limit), best_header_tip: height(*best_header_tip), }, - BlockSyncAction::QueryBlocksByHeightRange { peer, start, count } => { - BlockActionDetail::QueryBlocksByHeightRange { - peer: peer_label(peer), - range_start: height(*start), - range_count: u64::from(*count), - } - } BlockSyncAction::SubmitBlock { token, block, .. } => BlockActionDetail::SubmitBlock { apply_token: *token, hash: hash(block.hash()), @@ -742,8 +670,6 @@ fn block_misbehavior_label(reason: &BlockSyncMisbehavior) -> &'static str { match reason { BlockSyncMisbehavior::MalformedMessage => "malformed_message", BlockSyncMisbehavior::UnsolicitedBlock => "unsolicited_block", - BlockSyncMisbehavior::GetBlocksTooLong => "get_blocks_too_long", - BlockSyncMisbehavior::GetBlocksSpam => "get_blocks_spam", BlockSyncMisbehavior::BodyPayloadMismatch(_) => "body_payload_mismatch", BlockSyncMisbehavior::ConsensusBodyInvalid(_) => "consensus_body_invalid", BlockSyncMisbehavior::InvalidBlock => "invalid_block", @@ -945,24 +871,6 @@ mod tests { }, json!({"kind": "block_apply_finished", "apply_token": 3, "height": 1, "hash": "h", "result": "committed", "verified_block_tip": 1}), ), - ( - BlockEventDetail::BlockRangeResponseFinished { - peer: "p".into(), - range_start: 1, - range_count: 2, - expected_count: 3, - }, - json!({"kind": "block_range_response_finished", "peer": "p", "range_start": 1, "range_count": 2, "expected_count": 3}), - ), - ( - BlockEventDetail::BlockRangeResponseReady { - peer: "p".into(), - range_start: 1, - range_count: 2, - expected_count: 3, - }, - json!({"kind": "block_range_response_ready", "peer": "p", "range_start": 1, "range_count": 2, "expected_count": 3}), - ), ]; for (detail, expected) in cases { @@ -986,14 +894,6 @@ mod tests { }, json!({"kind": "query_needed_blocks", "range_start": 1, "range_count": 2, "best_header_tip": 3}), ), - ( - BlockActionDetail::QueryBlocksByHeightRange { - peer: "p".into(), - range_start: 1, - range_count: 2, - }, - json!({"kind": "query_blocks_by_height_range", "peer": "p", "range_start": 1, "range_count": 2}), - ), ( BlockActionDetail::SubmitBlock { apply_token: 3, diff --git a/crates/zakura-network/src/zakura/block_sync/wire.rs b/crates/zakura-network/src/zakura/block_sync/wire.rs index f88f40872c..0a2bbedbc9 100644 --- a/crates/zakura-network/src/zakura/block_sync/wire.rs +++ b/crates/zakura-network/src/zakura/block_sync/wire.rs @@ -2,10 +2,13 @@ use super::{config::*, error::*, *}; /// Zakura stream kind reserved for native block sync. pub const ZAKURA_STREAM_BLOCK_SYNC: u16 = 6; -/// Capability bit for the native block-sync service. -pub const ZAKURA_CAP_BLOCK_SYNC: u64 = 1 << 3; -/// Version of the native block-sync stream. -pub const ZAKURA_BLOCK_SYNC_STREAM_VERSION: u16 = 2; +/// Request-only stream paired with the block-sync data stream. +pub const ZAKURA_STREAM_BLOCK_REQUESTS: u16 = 7; +/// Capability for both roles of the paired native block-sync service. +/// Bit 3 remains reserved for the incompatible single-stream layout. +pub const ZAKURA_CAP_BLOCK_SYNC: u64 = 1 << 6; +/// Version of the native block-sync data stream; request streams use version 1. +pub const ZAKURA_BLOCK_SYNC_STREAM_VERSION: u16 = 3; /// Peer status advertisement. pub const MSG_BS_STATUS: u8 = 1; @@ -18,7 +21,7 @@ pub const MSG_BS_BLOCKS_DONE: u8 = 4; /// Report that a requested range is not servable. pub const MSG_BS_RANGE_UNAVAILABLE: u8 = 5; -/// Maximum block bodies ever requested or reported by stream 6. +/// Maximum block bodies in a native block-sync request or response. pub const MAX_BS_BLOCKS_PER_REQUEST: u32 = 128; /// Maximum encoded stream-6 message bytes. /// @@ -33,7 +36,7 @@ pub(super) const BLOCK_SYNC_MESSAGE_TYPE_BYTES: usize = 1; const _: () = assert!(MAX_BS_MESSAGE_BYTES < 4 * 1024 * 1024); const _: () = assert!(MAX_BS_MESSAGE_BYTES > block::MAX_BLOCK_BYTES as usize); -/// Native stream-6 block-sync message. +/// Native block-sync message, sent on its declared stream role. #[derive(Clone, Debug, Eq, PartialEq)] pub enum BlockSyncMessage { /// Servable range and serving capacity advertisement. @@ -64,7 +67,7 @@ pub enum BlockSyncMessage { } impl BlockSyncMessage { - /// Returns this message's stream-6 discriminator. + /// Returns this message's block-sync discriminator. pub fn message_type(&self) -> u8 { match self { Self::Status(_) => MSG_BS_STATUS, @@ -84,8 +87,12 @@ impl BlockSyncMessage { Self::GetBlocks { start_height, count, + } => { + validate_get_blocks_range(*start_height, *count)?; + write_height(&mut bytes, *start_height)?; + bytes.write_u32::(*count)?; } - | Self::RangeUnavailable { + Self::RangeUnavailable { start_height, count, } => { @@ -122,7 +129,7 @@ impl BlockSyncMessage { MSG_BS_GET_BLOCKS => { let start_height = read_height(&mut reader)?; let count = reader.read_u32::()?; - validate_block_count(count)?; + validate_get_blocks_range(start_height, count)?; Self::GetBlocks { start_height, count, @@ -227,6 +234,23 @@ impl BlockSyncMessage { } } +/// Every requested height must fit, even if serving later selects a shorter prefix. +fn validate_get_blocks_range( + start_height: block::Height, + count: u32, +) -> Result<(), BlockSyncWireError> { + validate_block_count(count)?; + // Count is nonzero after validation; the range includes its starting height. + let end_height = start_height + .0 + .checked_add(count - 1) + .ok_or(BlockSyncWireError::NumericOverflow("GetBlocks range end"))?; + if end_height > block::Height::MAX.0 { + return Err(BlockSyncWireError::HeightOutOfRange(end_height)); + } + Ok(()) +} + pub(super) fn validate_block_count(count: u32) -> Result<(), BlockSyncWireError> { if count == 0 { return Err(BlockSyncWireError::ZeroBlockCount); diff --git a/crates/zakura-network/src/zakura/block_sync/work_queue.rs b/crates/zakura-network/src/zakura/block_sync/work_queue.rs index 181172c258..76614f5b92 100644 --- a/crates/zakura-network/src/zakura/block_sync/work_queue.rs +++ b/crates/zakura-network/src/zakura/block_sync/work_queue.rs @@ -21,13 +21,19 @@ //! reservation); it exists only to carry the `SizeMismatch` tolerance check //! through to the reactor's receive path and request budget. -use std::sync::Mutex as StdMutex; +use std::{ + num::NonZeroU64, + sync::{Mutex as StdMutex, Weak}, +}; use tokio::sync::Notify; use zakura_chain::block; use super::{request::BlockSizeEstimate, state::BlockBudgetLedger}; +mod request_write; +pub(super) use request_write::RequestWrite; + /// Lower clamp on a body-size estimate. pub(super) const DEFAULT_BS_SIZE_FLOOR_BYTES: u64 = 1024; @@ -36,8 +42,10 @@ pub(super) const DEFAULT_BS_SIZE_FLOOR_BYTES: u64 = 1024; pub(super) struct WorkItem { /// Exact durable coordinates that authorized this body download. pub(super) scope: zakura_header_chain::BodyWorkAuthority, - /// Exact active range request, set only while reserved in flight. + /// Exact attempt, retained from provisional take through response receipt. pub(super) owner: Option, + /// Taken by an attempt that has not yet published its reservation and frame. + provisional: bool, /// Expected hash of the block at this height (drives the response match). pub(super) hash: block::Hash, /// The block's size estimate. Used for request budget reservation and the @@ -78,6 +86,10 @@ struct WorkQueueInner { /// item, maintained incrementally at each ledger transition so /// [`WorkQueue::reserved_bytes`] reserved_bytes: u64, + /// Only queued or writing requests need reset notification. Weak entries + /// cannot retain expired requests or their session resources. + request_writes: + std::collections::HashMap>, } impl WorkQueueInner { @@ -115,6 +127,7 @@ impl WorkQueue { current_authority: None, floor_estimate_bytes: DEFAULT_BS_SIZE_FLOOR_BYTES, reserved_bytes: 0, + request_writes: std::collections::HashMap::new(), }), available: Notify::new(), } @@ -158,6 +171,7 @@ impl WorkQueue { WorkItem { scope, owner: None, + provisional: false, hash, estimated_bytes, budget: BlockBudgetLedger::Released, @@ -241,12 +255,45 @@ impl WorkQueue { /// The estimate cap bounds the request's summed byte reservation. To /// guarantee progress, the first eligible item is always taken when /// `max_count > 0`, even if its estimate alone exceeds the cap. + #[cfg(test)] pub(super) fn take_in_range_budgeted( &self, low: block::Height, high: block::Height, max_count: usize, max_estimated_bytes: u64, + ) -> Vec<(block::Height, WorkItem)> { + self.take_budgeted(low, high, max_count, max_estimated_bytes, None) + } + + /// Give the provisional take an exact owner before releasing the queue lock. + /// A reset followed by a new take cannot be undone by this attempt's cleanup. + #[allow(clippy::too_many_arguments)] + pub(super) fn take_for_request( + &self, + low: block::Height, + high: block::Height, + max_count: usize, + max_estimated_bytes: u64, + session_id: u64, + request_id: NonZeroU64, + ) -> Vec<(block::Height, WorkItem)> { + self.take_budgeted( + low, + high, + max_count, + max_estimated_bytes, + Some((session_id, request_id)), + ) + } + + fn take_budgeted( + &self, + low: block::Height, + high: block::Height, + max_count: usize, + max_estimated_bytes: u64, + attempt: Option<(u64, NonZeroU64)>, ) -> Vec<(block::Height, WorkItem)> { // An empty count or inverted range is a caller bug, not a real "nothing to // take": every caller computes `low <= high` and a positive count before @@ -297,6 +344,10 @@ impl WorkQueue { item.scope = authority; } } + for (_, item) in &mut taken { + item.owner = attempt.map(|(session, request)| item.scope.bind(session, request)); + item.provisional = attempt.is_some(); + } for (height, item) in &taken { inner.pending.remove(height); inner.in_flight.insert(*height, *item); @@ -333,12 +384,18 @@ impl WorkQueue { /// freshly-registered `available` future and busy-loop the want-work arm /// (a self-wake spin); other peers were already woken by the original failure /// `return_items`, so suppressing the notify only affects the caller. - pub(super) fn return_items_quiet(&self, heights: impl IntoIterator) { + pub(super) fn return_unpublished(&self, items: &[(block::Height, WorkItem)]) { let mut inner = self.lock(); - for height in heights { - if let Some(mut item) = inner.in_flight.remove(&height) { + for (height, taken) in items { + if !inner.in_flight.get(height).is_some_and(|item| { + item.owner == taken.owner && item.owner.is_some() && item.provisional + }) { + continue; + } + if let Some(mut item) = inner.in_flight.remove(height) { item.owner = None; - inner.pending.insert(height, item); + item.provisional = false; + inner.pending.insert(*height, item); } } } @@ -352,6 +409,7 @@ impl WorkQueue { self.mark_reserved_matching(None, heights) } + #[cfg(test)] pub(super) fn mark_reserved_for_owner( &self, owner: zakura_header_chain::BodyWorkOwner, @@ -360,6 +418,7 @@ impl WorkQueue { self.mark_reserved_matching(Some(owner), heights) } + #[cfg(test)] fn mark_reserved_matching( &self, owner: Option, @@ -564,8 +623,16 @@ impl WorkQueue { ) -> WorkReturnOutcome { let mut moved = false; let mut outcome = WorkReturnOutcome::default(); + let claim; { let mut inner = self.lock(); + claim = + owner.and_then(|owner| inner.request_writes.get(&owner).and_then(Weak::upgrade)); + if let Some(claim) = &claim { + // The writer claims under this same lock. Expiry skips an + // unwritten frame; an already-started frame must finish. + claim.expire_unwritten(); + } for height in heights { outcome.min_height = Some( outcome @@ -611,6 +678,7 @@ impl WorkQueue { } inner.reserved_bytes = inner.reserved_bytes.saturating_sub(outcome.released_bytes); } + drop(claim); if moved { self.available.notify_waiters(); } @@ -664,6 +732,18 @@ impl WorkQueue { /// in [`advance_floor`](Self::advance_floor). pub(super) fn reset_above(&self, floor: block::Height) -> u64 { let mut inner = self.lock(); + // Retain upgraded owners until after unlocking: a concurrently dropped + // queue entry can make this the last reference, whose Drop settles work. + let claims: Vec<_> = inner + .request_writes + .values() + .filter_map(Weak::upgrade) + .filter(|claim| claim.has_height_above(floor)) + .collect(); + for claim in &claims { + claim.reset(); + inner.request_writes.remove(&claim.owner()); + } inner.floor = floor; // Pop only the `> floor` suffix from each map (O(removed · log n)); see the // note in `advance_floor` on why a full-map `retain` is too expensive here. @@ -689,6 +769,8 @@ impl WorkQueue { released = released.saturating_add(item.budget.release_reserved()); } inner.reserved_bytes = inner.reserved_bytes.saturating_sub(released); + drop(inner); + drop(claims); released } @@ -879,6 +961,7 @@ impl WorkQueue { .pending .get(&height) .or_else(|| inner.in_flight.get(&height)) + .filter(|item| !item.provisional) .and_then(|item| item.owner) } diff --git a/crates/zakura-network/src/zakura/block_sync/work_queue/request_write.rs b/crates/zakura-network/src/zakura/block_sync/work_queue/request_write.rs new file mode 100644 index 0000000000..f48ef194f4 --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/work_queue/request_write.rs @@ -0,0 +1,181 @@ +//! Ownership from provisional download reservation through the request write. + +use std::sync::{ + atomic::{AtomicU8, Ordering}, + Arc, +}; + +use tokio_util::sync::CancellationToken; +use zakura_header_chain::BodyWorkOwner; + +use super::{block, BlockBudgetLedger, WorkItem, WorkQueue}; +use crate::zakura::transport::{ByteBudget, FrameWriteClaim}; + +const UNPUBLISHED: u8 = 0; +const QUEUED: u8 = 1; +const STARTED: u8 = 2; +const WRITTEN: u8 = 3; +const EXPIRED: u8 = 4; + +#[cfg(test)] +mod tests; + +/// One exact attempt owns its provisional budget until publication. Afterwards +/// the work ledger arbitrates every release, including reset and response arrival. +#[derive(Debug)] +pub(crate) struct RequestWrite { + owner: BodyWorkOwner, + items: Vec<(block::Height, WorkItem)>, + estimated_bytes: u64, + work: Arc, + budget: ByteBudget, + cancel: CancellationToken, + state: AtomicU8, +} + +impl RequestWrite { + pub(in crate::zakura::block_sync) fn new( + owner: BodyWorkOwner, + items: Vec<(block::Height, WorkItem)>, + work: Arc, + budget: ByteBudget, + cancel: CancellationToken, + ) -> Arc { + let estimated_bytes = items.iter().map(|(_, item)| item.estimated_bytes).sum(); + Arc::new(Self { + owner, + items, + estimated_bytes, + work, + budget, + cancel, + state: AtomicU8::new(UNPUBLISHED), + }) + } + + /// Record outstanding state and enqueue into already-reserved capacity under + /// the same lock used by reset and writer claim. The caller retains this Arc + /// until return, even if a closed queue immediately discards its copy. + pub(in crate::zakura::block_sync) fn publish(self: &Arc, publish: impl FnOnce()) -> bool { + let mut inner = self.work.lock(); + if self.cancel.is_cancelled() + || self.items.is_empty() + || !self.items.iter().all(|(height, taken)| { + inner.in_flight.get(height).is_some_and(|item| { + item.owner == Some(self.owner) && item.hash == taken.hash && item.provisional + }) + }) + { + return false; + } + assert_eq!( + self.state.load(Ordering::Acquire), + UNPUBLISHED, + "a request is published once" + ); + for (height, _) in &self.items { + let item = inner + .in_flight + .get_mut(height) + .expect("every provisional item was checked under this lock"); + item.budget = BlockBudgetLedger::reserved(item.estimated_bytes); + item.provisional = false; + } + inner.reserved_bytes = inner.reserved_bytes.saturating_add(self.estimated_bytes); + inner + .request_writes + .retain(|_, claim| claim.strong_count() > 0); + inner + .request_writes + .insert(self.owner, Arc::downgrade(self)); + self.state.store(QUEUED, Ordering::Release); + publish(); + true + } + + pub(super) fn owner(&self) -> BodyWorkOwner { + self.owner + } + + /// A reserved queue slot lost its receiver during publication. Settle the + /// ledger now even if that closed channel retains a copy of this claim. + pub(in crate::zakura::block_sync) fn delivery_failed(&self) { + self.cancel.cancel(); + self.expire_unwritten(); + let released = self + .work + .release_reserved_and_return_items_detailed_for_owner( + self.owner, + self.items.iter().map(|(height, _)| *height), + ); + self.budget.clone().release(released.released_bytes); + } + + pub(super) fn has_height_above(&self, floor: block::Height) -> bool { + self.items.last().is_some_and(|(height, _)| *height > floor) + } + + pub(super) fn expire_unwritten(&self) { + let _ = self + .state + .compare_exchange(QUEUED, EXPIRED, Ordering::AcqRel, Ordering::Acquire); + } + + pub(super) fn reset(&self) { + self.expire_unwritten(); + if self.state.load(Ordering::Acquire) == STARTED { + // The prefix already belongs to this ordered stream. Cancelling the + // session resets the pair; no later request may follow that prefix. + self.cancel.cancel(); + } + } +} + +impl FrameWriteClaim for RequestWrite { + fn try_start(&self) -> bool { + let inner = self.work.lock(); + let current = !self.cancel.is_cancelled() + && self.items.iter().all(|(height, _)| { + inner + .in_flight + .get(height) + .is_some_and(|item| item.owner == Some(self.owner) && item.budget.is_reserved()) + }); + if !current { + self.expire_unwritten(); + return false; + } + self.state + .compare_exchange(QUEUED, STARTED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + fn written(&self) { + self.state.store(WRITTEN, Ordering::Release); + self.work.lock().request_writes.remove(&self.owner); + } +} + +impl Drop for RequestWrite { + fn drop(&mut self) { + match self.state.load(Ordering::Acquire) { + UNPUBLISHED => { + self.work.return_unpublished(&self.items); + self.budget.release(self.estimated_bytes); + } + WRITTEN => {} + state => { + if state == STARTED { + self.cancel.cancel(); + } + let released = self + .work + .release_reserved_and_return_items_detailed_for_owner( + self.owner, + self.items.iter().map(|(height, _)| *height), + ); + self.budget.release(released.released_bytes); + } + } + } +} diff --git a/crates/zakura-network/src/zakura/block_sync/work_queue/request_write/tests.rs b/crates/zakura-network/src/zakura/block_sync/work_queue/request_write/tests.rs new file mode 100644 index 0000000000..d66db7251a --- /dev/null +++ b/crates/zakura-network/src/zakura/block_sync/work_queue/request_write/tests.rs @@ -0,0 +1,317 @@ +use super::*; +use crate::zakura::{ + block_sync::{test_work_scope, BlockSizeEstimate}, + transport::worker_framed_channel, + Frame, FramedSend, +}; +use std::{num::NonZeroU64, time::Duration}; + +struct Fixture { + work: Arc, + budget: ByteBudget, + cancel: CancellationToken, +} + +impl Fixture { + fn new() -> Self { + let fixture = Self { + work: Arc::new(WorkQueue::new(block::Height(0))), + budget: ByteBudget::new(1000), + cancel: CancellationToken::new(), + }; + fixture.work.set_estimate_floor_for_tests(1); + fixture.refill(); + fixture + } + + fn refill(&self) { + self.work.extend( + test_work_scope(), + [ + ( + block::Height(1), + block::Hash([1; 32]), + BlockSizeEstimate::Confirmed(100), + ), + ( + block::Height(2), + block::Hash([2; 32]), + BlockSizeEstimate::Confirmed(100), + ), + ], + ); + } + + fn take(&mut self, id: u64) -> Arc { + let items = self.work.take_for_request( + block::Height(1), + block::Height(2), + 2, + 200, + 7, + NonZeroU64::new(id).unwrap(), + ); + assert_eq!(items.len(), 2); + assert!(self.budget.try_reserve(200)); + RequestWrite::new( + items[0].1.owner.unwrap(), + items, + self.work.clone(), + self.budget.clone(), + self.cancel.clone(), + ) + } + + fn expire(&mut self, owner: BodyWorkOwner) { + let outcome = self + .work + .release_reserved_and_return_items_detailed_for_owner( + owner, + [block::Height(1), block::Height(2)], + ); + self.budget.release(outcome.released_bytes); + } + + fn reset(&mut self) { + self.budget.release(self.work.reset_above(block::Height(0))); + } +} + +fn publish(claim: &Arc, sender: &FramedSend) { + let slot = sender.try_reserve_guarded().unwrap(); + assert!(claim.publish(|| { + assert!(slot.send_request( + Frame { + message_type: 2, + flags: 0, + payload: vec![0; 9] + }, + claim.clone() + )); + })); +} + +#[tokio::test] +async fn expiry_before_writer_claim_skips_the_entire_frame() { + let mut f = Fixture::new(); + let (sender, mut receiver) = worker_framed_channel(1); + let claim = f.take(1); + publish(&claim, &sender); + f.expire(claim.owner()); + drop(claim); + receiver + .recv() + .await + .unwrap() + .write_with(|_| async { + panic!("an expired unwritten request must never reach QUIC"); + #[allow(unreachable_code)] + Ok::<_, ()>(()) + }) + .await + .unwrap(); + assert_eq!(f.budget.reserved(), 0); + assert_eq!(f.work.pending_len(), 2); + assert!(!f.cancel.is_cancelled()); +} + +#[tokio::test] +async fn started_request_finishes_after_expiry_without_releasing_its_replacement() { + let mut f = Fixture::new(); + let (sender, mut receiver) = worker_framed_channel(1); + let claim = f.take(1); + let owner = claim.owner(); + publish(&claim, &sender); + drop(claim); + let queued = receiver.recv().await.unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (finish_tx, finish_rx) = tokio::sync::oneshot::channel(); + let writer = tokio::spawn(queued.write_with(|_| async { + started_tx.send(()).unwrap(); + finish_rx.await.unwrap(); + Ok::<_, ()>(()) + })); + tokio::time::timeout(Duration::from_secs(1), started_rx) + .await + .unwrap() + .unwrap(); + f.expire(owner); + assert_eq!(f.budget.reserved(), 0); + let replacement = f.take(2); + publish(&replacement, &sender); + assert!( + !f.cancel.is_cancelled(), + "expiry alone lets the started frame finish" + ); + finish_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(1), writer) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(f.budget.reserved(), 200); + assert_eq!( + f.work.owner_for_height(block::Height(1)), + Some(replacement.owner()) + ); + drop(receiver); + drop(replacement); + assert_eq!(f.budget.reserved(), 0); +} + +#[tokio::test] +async fn aborting_a_partial_write_cancels_the_session_and_returns_work() { + let mut f = Fixture::new(); + let (sender, mut receiver) = worker_framed_channel(1); + let claim = f.take(1); + publish(&claim, &sender); + drop(claim); + let queued = receiver.recv().await.unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let writer = tokio::spawn(queued.write_with(|_| async { + started_tx.send(()).unwrap(); + std::future::pending::>().await + })); + tokio::time::timeout(Duration::from_secs(1), started_rx) + .await + .unwrap() + .unwrap(); + writer.abort(); + assert!(writer.await.unwrap_err().is_cancelled()); + assert!(f.cancel.is_cancelled()); + assert_eq!(f.budget.reserved(), 0); + assert_eq!(f.work.pending_len(), 2); +} + +#[test] +fn reset_before_publication_cannot_return_a_replacement_take() { + let mut f = Fixture::new(); + let old = f.take(1); + f.reset(); + f.refill(); + let replacement = f.take(2); + assert!(!old.publish(|| panic!("reset invalidated this take"))); + drop(old); + assert_eq!(f.budget.reserved(), 200); + assert_eq!(f.work.in_flight_len(), 2); + let (sender, receiver) = worker_framed_channel(1); + publish(&replacement, &sender); + assert_eq!( + f.work.owner_for_height(block::Height(1)), + Some(replacement.owner()) + ); + drop(receiver); + drop(replacement); + assert_eq!(f.budget.reserved(), 0); +} + +#[test] +fn reset_cancels_a_started_request_but_only_skips_a_queued_request() { + for started in [false, true] { + let mut f = Fixture::new(); + let (sender, receiver) = worker_framed_channel(1); + let claim = f.take(1); + publish(&claim, &sender); + if started { + assert!(claim.try_start()); + } + f.reset(); + assert_eq!(f.cancel.is_cancelled(), started); + assert!(!claim.try_start()); + drop(receiver); + drop(claim); + assert_eq!(f.budget.reserved(), 0); + } +} + +#[test] +fn queue_failure_after_receipt_preserves_the_received_height() { + let mut f = Fixture::new(); + let (sender, receiver) = worker_framed_channel(1); + let claim = f.take(1); + publish(&claim, &sender); + f.budget.release( + f.work + .release_active_reserved_height_for_owner(claim.owner(), block::Height(1)) + .unwrap(), + ); + drop(receiver); + drop(claim); + assert_eq!(f.budget.reserved(), 0); + assert_eq!(f.work.in_flight_len(), 1); + assert!(!f.work.pending_contains(block::Height(1))); + assert!(f.work.pending_contains(block::Height(2))); +} + +#[test] +fn receiver_closing_after_slot_reservation_settles_publication_once() { + let mut f = Fixture::new(); + let (sender, receiver) = worker_framed_channel(1); + let slot = sender.try_reserve_guarded().unwrap(); + let claim = f.take(1); + drop(receiver); + let mut delivered = true; + assert!(claim.publish(|| { + delivered = slot.send_request( + Frame { + message_type: 2, + flags: 0, + payload: vec![], + }, + claim.clone(), + ); + })); + assert!(!delivered); + claim.delivery_failed(); + drop(claim); + assert_eq!(f.budget.reserved(), 0); + assert_eq!(f.work.reserved_bytes(), 0); + assert_eq!(f.work.pending_len(), 2); +} + +#[test] +fn reset_cannot_interleave_outstanding_publication_and_enqueue() { + let mut f = Fixture::new(); + let claim = f.take(1); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel(); + let (reset_tx, reset_rx) = std::sync::mpsc::channel(); + let (attempt_tx, attempt_rx) = std::sync::mpsc::channel(); + let work = f.work.clone(); + std::thread::scope(|scope| { + let claim = claim.clone(); + scope.spawn(move || { + let (sender, receiver) = worker_framed_channel(1); + let slot = sender.try_reserve_guarded().unwrap(); + assert!(claim.publish(|| { + entered_tx.send(()).unwrap(); + finish_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(slot.send_request( + Frame { + message_type: 2, + flags: 0, + payload: vec![] + }, + claim.clone() + )); + })); + drop(receiver); + }); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + scope.spawn(move || { + attempt_tx.send(()).unwrap(); + reset_tx.send(work.reset_above(block::Height(0))).unwrap(); + }); + attempt_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(matches!( + reset_rx.recv_timeout(Duration::from_millis(20)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + finish_tx.send(()).unwrap(); + f.budget + .release(reset_rx.recv_timeout(Duration::from_secs(2)).unwrap()); + }); + drop(claim); + assert_eq!(f.budget.reserved(), 0); + assert_eq!(f.work.reserved_bytes(), 0); +} diff --git a/crates/zakura-network/src/zakura/discovery/service.rs b/crates/zakura-network/src/zakura/discovery/service.rs index 5c4c3cf1e8..52b381c655 100644 --- a/crates/zakura-network/src/zakura/discovery/service.rs +++ b/crates/zakura-network/src/zakura/discovery/service.rs @@ -2414,8 +2414,7 @@ mod tests { connected_rx, )?; let service = DiscoveryService::new(handle.clone()); - let (block_sync, _block_events) = - BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); + let block_sync = BlockSyncService::new_for_test(ZakuraBlockSyncConfig::default()); let block_sync = Arc::new(block_sync); service.set_connection_owners(vec![block_sync.clone()]); @@ -2427,7 +2426,7 @@ mod tests { let (_peer_block_send, service_block_recv) = framed_channel(4); let (service_block_send, _peer_block_recv) = framed_channel(4); - block_sync.add_peer(Peer::new( + block_sync.add_peer(crate::zakura::testkit::DownloadOnlyPeer::create( peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, diff --git a/crates/zakura-network/src/zakura/handler.rs b/crates/zakura-network/src/zakura/handler.rs index fefcf45e87..33ad5f74aa 100644 --- a/crates/zakura-network/src/zakura/handler.rs +++ b/crates/zakura-network/src/zakura/handler.rs @@ -1,6 +1,8 @@ //! Zakura P2P v2 endpoint, protocol handler, and bounded connection serving. +mod ordered_pair; mod trace; +use ordered_pair::{spawn_ordered_pair, PendingOrderedPairs, PreparedOrderedStream}; use std::{ collections::{HashMap, HashSet}, @@ -148,8 +150,9 @@ pub const ZAKURA_DUPLICATE_EVICT_MIN_AGE: Duration = Duration::from_secs(300); /// resolution (milliseconds) so a genuine race keeps the transcript-tiebreak /// winner instead of flapping. pub const ZAKURA_SAME_IP_DUPLICATE_EVICT_MIN_AGE: Duration = Duration::from_secs(5); -/// QUIC stream receive window used by Zakura endpoints. -pub const DEFAULT_ZAKURA_STREAM_RECEIVE_WINDOW: u32 = 32 * 1024 * 1024; +/// A paused stream may consume at most half the connection receive window, +/// leaving credit for another service stream. +pub const DEFAULT_ZAKURA_STREAM_RECEIVE_WINDOW: u32 = 16 * 1024 * 1024; /// QUIC connection receive window used by Zakura endpoints. pub const DEFAULT_ZAKURA_RECEIVE_WINDOW: u32 = 32 * 1024 * 1024; /// QUIC send window used by Zakura endpoints. @@ -167,6 +170,9 @@ const STREAM_WORKER_DRAIN_TIMEOUT: Duration = Duration::from_secs(1); const ORDERED_STREAM_REOPEN_BACKOFF: Duration = Duration::from_millis(250); const ORDERED_STREAM_REOPEN_BACKOFF_CAP: Duration = Duration::from_secs(8); const OUTBOUND_STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(10); +// A paused sibling can make shared-credit updates take longer than ten seconds +// on a slow link even while complete blocks keep arriving. +const PAIRED_DATA_WRITE_TIMEOUT: Duration = Duration::from_secs(32); const OUTBOUND_REQUEST_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); // Mirrors the legacy gossip compatibility protocol. Compile-time assertions // below keep this transport-side budget validator pinned to the codec constants. @@ -571,6 +577,8 @@ struct HeaderSyncBackgroundTasks { /// Durable state facts required before attaching the production header-sync driver. #[derive(Clone, Debug)] pub struct ZakuraHeaderSyncDriverStartup { + /// Owned block range reads supplied by the node for sequential block serving. + pub block_range_source: Arc, /// Durable state frontiers loaded at node startup. pub frontiers: FullStateFrontiers, /// Durable best header tip loaded from state. @@ -1537,6 +1545,7 @@ impl Drop for RegisteredPeerCleanupGuard { } struct StreamAdmission<'a> { + direction: ServicePeerDirection, conn: ZakuraConnTrace, peer_id: &'a ZakuraPeerId, stream_sem: &'a Arc, @@ -1587,6 +1596,10 @@ struct StreamWorkerContext { _permit: OwnedSemaphorePermit, limits: ZakuraConnectionLimits, inbound_frame_cap: u32, + message_payload_limits: &'static [(u16, usize)], + message_types: Option<&'static [u16]>, + queue_depths: Option<(usize, usize)>, + session_resources: Option>, outbound_frame_cap: u32, message_bucket: SharedMessageBucket, connection_token: CancellationToken, @@ -1602,6 +1615,43 @@ struct AdmittedOrderedSession { recv: FramedRecv, send: FramedSend, cancel_token: CancellationToken, + companion: Option, +} + +struct ServiceStreamRole { + kind: u16, + version: u16, + recv: FramedRecv, + send: FramedSend, +} + +impl AdmittedOrderedSession { + fn into_service_streams(self) -> HashMap { + let mut streams = HashMap::new(); + if let Some(role) = self.companion { + streams.insert( + role.kind, + ServiceStream::new( + self.session_id, + role.version, + role.recv, + role.send, + self.cancel_token.clone(), + ), + ); + } + streams.insert( + self.kind, + ServiceStream::new( + self.session_id, + self.version, + self.recv, + self.send, + self.cancel_token, + ), + ); + streams + } } #[derive(Copy, Clone, Debug)] @@ -2323,6 +2373,7 @@ impl ZakuraProtocolHandler { let mut workers = JoinSet::new(); let (ordered_session_exit_tx, mut ordered_session_exit_rx) = mpsc::unbounded_channel(); let mut ordered_session_waits = OrderedSessionWaits::new(); + let mut pending_pairs = PendingOrderedPairs::default(); let mut open_limiter = TokenBucket::new(limits.stream_open_rate_per_second); let mut message_buckets = MessageRateBuckets::new(); let (freshness_tx, freshness_rx) = watch::channel(Instant::now()); @@ -2342,6 +2393,13 @@ impl ZakuraProtocolHandler { let mut ordered_streams = Vec::new(); let mut deferred_ordered_streams = Vec::new(); for stream in negotiated_ordered_streams.iter().copied() { + if self + .registry + .ordered_stream_pair(stream) + .is_some_and(|pair| stream != pair.data) + { + continue; + } let policy = self.registry.ordered_stream_policy(stream.kind); if !opens_ordered_stream_locally( policy, @@ -2366,7 +2424,7 @@ impl ZakuraProtocolHandler { .registry .request_response_streams_for_negotiated(accepted_capabilities) .len(); - if ordered_streams.len() > usize::from(limits.max_open_streams) { + if negotiated_ordered_streams.len() > usize::from(limits.max_open_streams) { debug!( max_open_streams = limits.max_open_streams, ordered_stream_count = ordered_streams.len(), @@ -2412,7 +2470,6 @@ impl ZakuraProtocolHandler { } else if !connection_token.is_cancelled() { let mut opened_capabilities = 0; for stream in ordered_streams { - opened_capabilities |= stream.capability; let admitted = match self .open_ordered_service_stream( &connection, @@ -2427,11 +2484,21 @@ impl ZakuraProtocolHandler { freshness_tx.clone(), conn.clone(), peer_id.clone(), + context.direction, ordered_session_exit_tx.clone(), ) .await { Ok(admitted) => admitted, + Err(ZakuraHandlerError::OrderedSessionFull) => { + // Demand is advisory: another connection can reserve the + // last slot before this open. Retry only this service. + ordered_sessions + .get_mut(&stream.kind) + .expect("selected stream has negotiated session state") + .schedule_transport_backoff(&mut ordered_session_waits); + continue; + } Err(error) => { debug!( ?error, @@ -2447,20 +2514,12 @@ impl ZakuraProtocolHandler { break; } }; + opened_capabilities |= stream.capability; ordered_sessions .get_mut(&admitted.kind) .expect("opened ordered stream was selected from negotiated session state") .local_session_id = Some(admitted.session_id); - service_streams.insert( - admitted.kind, - ServiceStream::new( - admitted.session_id, - admitted.version, - admitted.recv, - admitted.send, - admitted.cancel_token, - ), - ); + service_streams.extend(admitted.into_service_streams()); } if !connection_token.is_cancelled() { // Escalation is already narrowed to opened ordered services. @@ -2498,9 +2557,16 @@ impl ZakuraProtocolHandler { } loop { + let pair_deadline = pending_pairs.deadline(); tokio::select! { biased; _ = connection_token.cancelled() => break, + _ = async { + match pair_deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => future::pending().await, + } + } => pending_pairs.expire(Instant::now()), _ = freshness_reaper(freshness_rx.clone(), limits.idle_timeout), if run_freshness_reaper => { connection.close(VarInt::from_u32(ZAKURA_CLOSE_NEUTRAL), b"idle"); close_cause.record("idle_timeout"); @@ -2586,6 +2652,7 @@ impl ZakuraProtocolHandler { freshness_tx.clone(), conn.clone(), peer_id.clone(), + context.direction, ordered_session_exit_tx.clone(), ) .await @@ -2596,16 +2663,7 @@ impl ZakuraProtocolHandler { .expect("opened ordered stream has negotiated session state"); session.cancel_wait(&mut ordered_session_waits); session.local_session_id = Some(admitted.session_id); - let service_streams = HashMap::from([( - admitted.kind, - ServiceStream::new( - admitted.session_id, - admitted.version, - admitted.recv, - admitted.send, - admitted.cancel_token, - ), - )]); + let service_streams = admitted.into_service_streams(); let admitted_capabilities = self.registry.add_escalated_peer( Peer::new_with_service_streams( conn_id, @@ -2643,6 +2701,7 @@ impl ZakuraProtocolHandler { match accepted { Ok((send, recv)) => { let mut admission = StreamAdmission { + direction: context.direction, conn: conn.clone(), peer_id: &peer_id, stream_sem: &stream_sem, @@ -2662,6 +2721,7 @@ impl ZakuraProtocolHandler { &mut admission, per_stream_queue_depth, ordered_session_exit_tx.clone(), + &mut pending_pairs, ) .await { @@ -2728,13 +2788,23 @@ impl ZakuraProtocolHandler { // demand. For a collision we lost, demand is // implied because we already opened the same kind. let demand = (!is_collision).then(|| { - self.registry.ordered_session_demand( - kind, - conn_id, - accepted_capabilities, - &peer_id, - context.direction, - ) + if admitted.companion.is_some() { + self.registry.reserved_ordered_session_demand( + kind, + conn_id, + accepted_capabilities, + &peer_id, + context.direction, + ) + } else { + self.registry.ordered_session_demand( + kind, + conn_id, + accepted_capabilities, + &peer_id, + context.direction, + ) + } }); if demand .as_ref() @@ -2782,21 +2852,7 @@ impl ZakuraProtocolHandler { .get_mut(&kind) .expect("adopted ordered stream has negotiated session state") .cancel_wait(&mut ordered_session_waits); - let service_streams = HashMap::from([( - kind, - ServiceStream::new( - admitted.session_id, - admitted.version, - admitted.recv, - admitted.send, - admitted.cancel_token.clone(), - ), - )]); - // Current ordered services own one ordered stream - // each, so we fan out accepted streams one at a - // time. Batch here if a service gains multiple - // ordered streams. - // + let service_streams = admitted.into_service_streams(); // We keep the full accepted capability context so // discovery can make cross-service ownership // decisions; disconnect fanout still uses the @@ -2858,6 +2914,7 @@ impl ZakuraProtocolHandler { &connection, limits, stream, + self.registry.message_payload_limits(stream), request_id, message_type, flags, @@ -2924,71 +2981,72 @@ impl ZakuraProtocolHandler { freshness_tx: watch::Sender, conn: ZakuraConnTrace, peer_id: ZakuraPeerId, + direction: ServicePeerDirection, ordered_session_exit_tx: mpsc::UnboundedSender, ) -> Result { - let stream_id = self.next_stream_id.fetch_add(1, Ordering::Relaxed); - let permit = stream_sem - .clone() - .try_acquire_owned() - .map_err(|_| ZakuraHandlerError::ResourceLimit("ordered stream permit"))?; - let (mut send, recv) = timeout(OUTBOUND_STREAM_WRITE_TIMEOUT, connection.open_bi()) - .await - .map_err(|_| ZakuraHandlerError::Timeout("open ordered service stream"))??; - let prelude = StreamPrelude { - magic: STREAM_PRELUDE_MAGIC, - stream_kind: stream.kind, - stream_version: stream.version, - request_id: None, - max_frame_bytes: inbound_frame_cap_for_stream(&limits, stream), - }; - let prelude_bytes = prelude.encode()?; - timeout( - OUTBOUND_STREAM_WRITE_TIMEOUT, - send.write_all(&prelude_bytes), - ) - .await - .map_err(|_| ZakuraHandlerError::Timeout("ordered stream prelude write"))??; - - let message_bucket = message_bucket_for( - message_buckets, - stream.kind, - limits.message_rate_per_second, - RealClock, - ); - let stream_token = connection_token.child_token(); - let context = StreamWorkerContext { - conn: conn.clone(), - peer_id, - stream_id, - _permit: permit, - limits, - inbound_frame_cap: prelude.max_frame_bytes, - outbound_frame_cap: application_frame_cap(&limits, stream), - message_bucket, - connection_token, - stream_token, - close_cause, - freshness_tx, + let pair = self.registry.ordered_stream_pair(stream); + let resources = if pair.is_some() { + self.registry + .service_for_kind(stream.kind) + .expect("a selected stream has an owning service") + .reserve_ordered_session(direction) + .map_err(|_| ZakuraHandlerError::OrderedSessionFull)? + } else { + None }; - - metrics::counter!( - "zakura.p2p.stream.accepted", - "stream_kind" => stream_kind_label(stream.kind), - ) - .increment(1); - conn.trace_stream("accepted", stream_id, Some(stream_kind_label(stream.kind))); - - Ok(spawn_persistent_stream_worker( - workers, - send, - recv, - stream, - prelude, - context, - per_stream_queue_depth, - true, - ordered_session_exit_tx, - )) + let pair_id = pair.map(|_| random_stream_session_seed()); + let mut primary = self + .prepare_ordered_stream( + connection, + stream, + pair_id, + stream_sem, + message_buckets, + limits, + connection_token.clone(), + close_cause.clone(), + freshness_tx.clone(), + conn.clone(), + peer_id.clone(), + ) + .await?; + primary.set_session_resources(resources.clone()); + if let Some(pair) = pair { + if pair.data != stream { + return Err(ZakuraHandlerError::InvalidOrderedPair); + } + let mut requests = self + .prepare_ordered_stream( + connection, + pair.requests, + pair_id, + stream_sem, + message_buckets, + limits, + connection_token, + close_cause, + freshness_tx, + conn, + peer_id, + ) + .await?; + requests.set_session_resources(resources); + Ok(spawn_ordered_pair( + workers, + primary, + requests, + per_stream_queue_depth, + true, + ordered_session_exit_tx, + )) + } else { + Ok(primary.spawn_single( + workers, + per_stream_queue_depth, + true, + ordered_session_exit_tx, + )) + } } async fn admit_bi_stream( @@ -2998,6 +3056,7 @@ impl ZakuraProtocolHandler { admission: &mut StreamAdmission<'_>, per_stream_queue_depth: usize, ordered_session_exit_tx: mpsc::UnboundedSender, + pending_pairs: &mut PendingOrderedPairs, ) -> Option { let stream_id = self.next_stream_id.fetch_add(1, Ordering::Relaxed); let Ok(permit) = admission.stream_sem.clone().try_acquire_owned() else { @@ -3085,9 +3144,8 @@ impl ZakuraProtocolHandler { && self .registry .ordered_streams_for_negotiated(admission.accepted_capabilities) - .into_iter() - .find(|selected| selected.kind == stream.kind) - .is_some_and(|selected| selected.version != stream.version) + .iter() + .all(|selected| *selected != stream) { debug!( stream_kind = prelude.stream_kind, @@ -3145,9 +3203,45 @@ impl ZakuraProtocolHandler { .conn .trace_stream("accepted", stream_id, Some(stream_kind)); + let pair = self.registry.ordered_stream_pair(stream); + let resources = if let Some(pair) = pair { + match pending_pairs.reserve_or_share(pair, &self.registry, admission.direction) { + Ok(resources) => resources, + Err(_) => { + let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_RESOURCE)); + let _ = recv.stop(VarInt::from_u32(ZAKURA_CLOSE_RESOURCE)); + return None; + } + } + } else { + None + }; + let pair_id = if pair.is_some() { + if prelude.request_id.is_some() { + let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_BAD_PRELUDE)); + let _ = recv.stop(VarInt::from_u32(ZAKURA_CLOSE_BAD_PRELUDE)); + return None; + } + let mut bytes = [0u8; 8]; + if !matches!( + timeout( + admission.limits.prelude_timeout, + recv.read_exact(&mut bytes) + ) + .await, + Ok(Ok(())) + ) { + let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_BAD_PRELUDE)); + let _ = recv.stop(VarInt::from_u32(ZAKURA_CLOSE_BAD_PRELUDE)); + return None; + } + Some(u64::from_le_bytes(bytes)) + } else { + None + }; let message_bucket = message_bucket_for( admission.message_buckets, - prelude.stream_kind, + pair.map_or(prelude.stream_kind, |pair| pair.data.kind), admission.limits.message_rate_per_second, RealClock, ); @@ -3160,6 +3254,10 @@ impl ZakuraProtocolHandler { _permit: permit, limits: admission.limits, inbound_frame_cap: inbound_frame_cap_for_stream(&admission.limits, stream), + message_payload_limits: self.registry.message_payload_limits(stream), + message_types: self.registry.message_types(stream), + queue_depths: self.registry.stream_queue_depths(stream), + session_resources: resources, outbound_frame_cap: peer_accepted_frame_cap( &admission.limits, stream, @@ -3172,6 +3270,30 @@ impl ZakuraProtocolHandler { freshness_tx: admission.freshness_tx.clone(), }; + if let Some(pair) = pair { + let prepared = PreparedOrderedStream::new(send, recv, stream, prelude, context); + return match pending_pairs.insert( + pair, + pair_id.expect("pair setup was decoded"), + prepared, + ) { + Ok(Some((data, requests))) => Some(spawn_ordered_pair( + admission.workers, + data, + requests, + per_stream_queue_depth, + false, + ordered_session_exit_tx, + )), + Ok(None) => None, + Err(error) => { + debug!(?error, "rejecting mismatched ordered stream pair"); + admission.close_cause.record("invalid_ordered_pair"); + admission.connection_token.cancel(); + None + } + }; + } if stream.mode == StreamMode::RequestResponse { admission.workers.spawn(request_stream_worker( send, @@ -3636,6 +3758,7 @@ async fn spawn_zakura_endpoint_inner( startup.shutdown = header_sync_shutdown.clone(); startup.trace = trace.clone(); let (handle, actions, task) = spawn_block_sync_reactor(startup); + let handle = handle.with_range_source(driver_startup.block_range_source.clone()); (Some(handle), Some(actions), Some(task)) } else { (None, None, None) @@ -3980,8 +4103,10 @@ fn spawn_persistent_stream_worker( opened_locally: bool, ordered_session_exit_tx: mpsc::UnboundedSender, ) -> AdmittedOrderedSession { - let (to_service_tx, to_service_rx) = mpsc::channel(queue_depth); - let (from_service_tx, from_service_rx) = worker_framed_channel(queue_depth); + let (inbound_depth, outbound_depth) = + bounded_stream_queue_depths(queue_depth, context.queue_depths); + let (to_service_tx, to_service_rx) = mpsc::channel(inbound_depth); + let (from_service_tx, from_service_rx) = worker_framed_channel(outbound_depth); let admitted = AdmittedOrderedSession { kind: prelude.stream_kind, version: prelude.stream_version, @@ -3989,6 +4114,7 @@ fn spawn_persistent_stream_worker( recv: FramedRecv::new(to_service_rx), send: from_service_tx, cancel_token: context.stream_token.clone(), + companion: None, }; let exit = OrderedSessionExit { @@ -4004,7 +4130,7 @@ fn spawn_persistent_stream_worker( context, to_service_tx, from_service_rx, - queue_depth, + inbound_depth, ) .await; let _ = ordered_session_exit_tx.send(exit); @@ -4013,7 +4139,54 @@ fn spawn_persistent_stream_worker( admitted } +fn bounded_stream_queue_depths( + transport_depth: usize, + service: Option<(usize, usize)>, +) -> (usize, usize) { + service.map_or((transport_depth, transport_depth), |(inbound, outbound)| { + ( + transport_depth.min(inbound.max(1)), + transport_depth.min(outbound.max(1)), + ) + }) +} + +#[derive(Copy, Clone, Eq, PartialEq)] +enum OrderedWritePolicy { + Standalone, + PairData, + PairRequests, +} + +#[derive(Debug, Error)] +#[error("Zakura outbound frame write timed out")] +struct OrderedFrameWriteTimeout; + async fn persistent_stream_worker( + send: SendStream, + recv: RecvStream, + prelude: StreamPrelude, + context: StreamWorkerContext, + inbound_tx: mpsc::Sender, + outbound_rx: FramedWorkerRecv, + queue_depth_limit: usize, +) { + persistent_stream_worker_with_policy( + send, + recv, + prelude, + context, + inbound_tx, + outbound_rx, + queue_depth_limit, + OrderedWritePolicy::Standalone, + None, + ) + .await; +} + +#[allow(clippy::too_many_arguments)] +async fn persistent_stream_worker_with_policy( mut send: SendStream, recv: RecvStream, prelude: StreamPrelude, @@ -4021,33 +4194,30 @@ async fn persistent_stream_worker( inbound_tx: mpsc::Sender, outbound_rx: FramedWorkerRecv, queue_depth_limit: usize, + write_policy: OrderedWritePolicy, + remote_close: Option, ) { let context = Arc::new(context); let stream_kind = prelude.stream_kind; - // The inbound reader runs in its own task, not as a `select!` branch racing - // the outbound writer below. `read_frame` is NOT cancellation-safe: it - // consumes the fixed frame header, then awaits the (multi-packet) payload. If - // it shared this `select!` with the outbound arm, an outbound frame becoming - // ready mid-read would drop the `read_frame` future and discard the header - // bytes it already consumed, desyncing the stream forever -- the next read - // decodes body bytes as a header, yielding a garbage multi-GiB `payload_len`, - // an `OversizeFrame` error, and a stream reset. Heavy concurrent body-sync - // (inbound bodies + outbound `GetBlocks`) made this fire constantly. Reading - // in a dedicated task removes the write/read race; the main loop only ever - // *receives* fully-read frames over a channel, which is cancellation-safe. - let (frame_tx, mut frame_rx) = mpsc::channel::>(1); + // Reading and forwarding may both block. Keep them independent of writes + // so waiting for inbound channel space cannot stall an outgoing response. + // A dedicated reader also preserves partial frame reads across outbound writes. + let (error_tx, mut error_rx) = mpsc::channel::(1); let reader_context = Arc::clone(&context); - let reader = tokio::spawn(async move { + let reader_remote_close = remote_close.clone(); + let reader = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move { let mut recv = recv; loop { let frame = tokio::select! { biased; _ = reader_context.connection_token.cancelled() => break, _ = reader_context.stream_token.cancelled() => break, - frame = read_frame( + frame = read_frame_with_types( &mut recv, reader_context.inbound_frame_cap, + reader_context.message_payload_limits, + reader_context.message_types, reader_context.limits.idle_timeout, // A persistent ordered stream is legitimately quiet between // frames; do not let an inter-frame gap time out and cancel @@ -4057,20 +4227,31 @@ async fn persistent_stream_worker( None, ) => frame, }; - // Admit (rate/oversize) at ingress, the instant a frame is read, so - // throttling never trails behind the main loop draining queued - // outbound writes or forwarding an earlier frame to a service that - // might disconnect first. The main loop only ever receives frames - // that already cleared admission, plus terminal errors it maps to a - // reset code (it owns the send half). Admission is charged exactly - // once, here. - let message = match frame { + // Charge ingress admission once, before forwarding to the service. + // A full service channel stops this reader from granting QUIC credit. + let error = match frame { Ok(frame) => { let _ = reader_context.freshness_tx.send(Instant::now()); match admit_inbound_message(frame.payload.len(), &reader_context, stream_kind) { - InboundMessageAdmission::Admit => Ok(frame), - InboundMessageAdmission::Oversize => Err(ZakuraHandlerError::Oversize), - InboundMessageAdmission::Throttled => Err(ZakuraHandlerError::RateLimited), + InboundMessageAdmission::Admit => { + let forwarded = tokio::select! { + biased; + _ = reader_context.connection_token.cancelled() => break, + _ = reader_context.stream_token.cancelled() => break, + result = inbound_tx.send(frame) => result, + }; + if forwarded.is_err() { + break; + } + metrics::gauge!( + "zakura.p2p.queue.depth", + "stream_kind" => stream_kind_label(stream_kind), + ) + .set(queue_depth_limit.saturating_sub(inbound_tx.capacity()) as f64); + continue; + } + InboundMessageAdmission::Oversize => ZakuraHandlerError::Oversize, + InboundMessageAdmission::Throttled => ZakuraHandlerError::RateLimited, } } Err(error) => { @@ -4089,7 +4270,7 @@ async fn persistent_stream_worker( Some(max_frame_bytes), ); } - Err(error) + error } }; // Any error is terminal. `Closed` is a clean peer-initiated close; @@ -4098,19 +4279,22 @@ async fn persistent_stream_worker( // map it to a reset code, then cancel the connection ourselves so // the disconnect is guaranteed even if the main loop tore the worker // down for a stopped outbound write before processing it. - let is_terminal = message.is_err(); - let must_disconnect = - matches!(&message, Err(error) if !matches!(error, ZakuraHandlerError::Closed)); - let forward_failed = frame_tx.send(message).await.is_err(); + let must_disconnect = !matches!(error, ZakuraHandlerError::Closed); + if !must_disconnect && !reader_context.stream_token.is_cancelled() { + if let Some(closed) = &reader_remote_close { + // Publish the cause before waking either the service EOF or + // the sibling worker's cancellation path. + closed.cancel(); + } + } + let _ = error_tx.send(error).await; if must_disconnect { reader_context.close_cause.record("ordered_read_error"); reader_context.connection_token.cancel(); } - if forward_failed || is_terminal { - break; - } + break; } - }); + })); let mut outbound_rx = Some(outbound_rx); loop { @@ -4126,17 +4310,44 @@ async fn persistent_stream_worker( } => { match outbound { Some(queued_frame) => { - if let Err(error) = write_queued_ordered_frame( - &mut send, - queued_frame, - context.limits, - context.outbound_frame_cap, - ).await { + // Standalone streams finish the current frame on local + // cancellation. Paired streams can interrupt the write: + // teardown resets both roles before either can be reused. + let result = tokio::select! { + biased; + _ = context.connection_token.cancelled() => break, + _ = context.stream_token.cancelled(), + if write_policy != OrderedWritePolicy::Standalone => break, + result = async { + if write_policy == OrderedWritePolicy::Standalone { + write_queued_ordered_frame(&mut send, queued_frame, + context.limits, context.outbound_frame_cap).await + } else { + queued_frame.write_with(|frame| write_ordered_frame_with_policy( + &mut send, frame, context.limits, + context.outbound_frame_cap, write_policy, + )).await + } + } => result, + }; + if let Err(error) = result { + if write_policy == OrderedWritePolicy::PairData + && error.is::() + { + debug!(stream_kind, stream_id = context.stream_id, + "retiring Zakura stream pair after data write timeout"); + break; + } if ordered_stream_write_was_stopped(&error) { + if !context.stream_token.is_cancelled() { + if let Some(closed) = &remote_close { + closed.cancel(); + } + } debug!(?error, "closing Zakura ordered stream after peer stopped receiving"); break; } - debug!(?error, "closing Zakura ordered stream writer"); + debug!(stream_kind, stream_id = context.stream_id, ?error, "closing Zakura ordered stream writer"); let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_BAD_PRELUDE)); context.close_cause.record("ordered_write_error"); context.connection_token.cancel(); @@ -4148,41 +4359,25 @@ async fn persistent_stream_worker( } } } - inbound = frame_rx.recv() => { - match inbound { - // Frames here already cleared ingress admission in the reader. - Some(Ok(frame)) => { - if inbound_tx.send(frame).await.is_err() { - debug!( - stream_kind, - "closing Zakura ordered stream after local service receiver dropped" - ); - break; - } - metrics::gauge!( - "zakura.p2p.queue.depth", - "stream_kind" => stream_kind_label(stream_kind), - ) - .set(queue_depth_limit.saturating_sub(inbound_tx.capacity()) as f64); - } - // The reader signalled an oversize message: disconnect it. - Some(Err(ZakuraHandlerError::Oversize)) => { + error = error_rx.recv() => { + match error { + Some(ZakuraHandlerError::Oversize) => { let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_OVERSIZE)); context.close_cause.record("ordered_oversize"); context.connection_token.cancel(); break; } - Some(Err(ZakuraHandlerError::RateLimited)) => { + Some(ZakuraHandlerError::RateLimited) => { let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_RATE_LIMIT)); context.close_cause.record("ordered_rate_limited"); context.connection_token.cancel(); break; } - Some(Err(ZakuraHandlerError::Closed)) | None => { + Some(ZakuraHandlerError::Closed) | None => { break; } // The reader already emitted any oversize-desync diagnostic. - Some(Err(error)) => { + Some(error) => { debug!(?error, "closing Zakura stream worker"); let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_BAD_PRELUDE)); context.close_cause.record("ordered_read_error"); @@ -4194,10 +4389,15 @@ async fn persistent_stream_worker( } } - // Stop the reader: it also observes the cancellation tokens, but abort - // guarantees a prompt exit on the paths that break without cancelling one - // (e.g. a peer that stopped receiving, or the local service receiver closing). + if write_policy != OrderedWritePolicy::Standalone { + // A cancelled pair cannot leave a partial frame followed by a graceful + // FIN. Reset it before any replacement session may write. + let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_NEUTRAL)); + context.stream_token.cancel(); + } reader.abort(); + // Keep the stream permit until the reader has actually dropped its buffers. + let _ = reader.await; } fn ordered_stream_write_was_stopped(error: &BoxError) -> bool { @@ -4227,6 +4427,7 @@ async fn request_stream_worker( frame = read_frame( &mut recv, context.inbound_frame_cap, + context.message_payload_limits, context.limits.idle_timeout, // A request stream carries its request frame immediately after the // prelude, so a peer that opens one and then goes silent is treated @@ -4398,6 +4599,26 @@ async fn read_stream_prelude( async fn read_frame( recv: &mut RecvStream, max_frame_bytes: u32, + message_payload_limits: &[(u16, usize)], + read_timeout: Duration, + first_byte_timeout: Option, +) -> Result { + read_frame_with_types( + recv, + max_frame_bytes, + message_payload_limits, + None, + read_timeout, + first_byte_timeout, + ) + .await +} + +async fn read_frame_with_types( + recv: &mut RecvStream, + max_frame_bytes: u32, + message_payload_limits: &[(u16, usize)], + message_types: Option<&[u16]>, read_timeout: Duration, first_byte_timeout: Option, ) -> Result { @@ -4427,11 +4648,22 @@ async fn read_frame( } let mut reader = &header[..]; let message_type = reader.read_u16::()?; + if message_types.is_some_and(|types| !types.contains(&message_type)) { + return Err(ZakuraHandlerError::InvalidMessageType(message_type)); + } let flags = reader.read_u16::()?; let payload_len = usize::try_from(reader.read_u32::()?) .expect("u32 payload lengths fit usize on supported targets"); let max_frame_bytes = usize::try_from(max_frame_bytes).expect("u32 frame cap fits usize on supported targets"); + // A service may declare a tighter limit for this message. Apply it before + // allocating the payload; it can never enlarge the negotiated stream cap. + let max_frame_bytes = message_payload_limits + .iter() + .find(|(kind, _)| *kind == message_type) + .map_or(max_frame_bytes, |(_, max_payload_bytes)| { + max_frame_bytes.min(max_payload_bytes.saturating_add(FRAME_HEADER_BYTES)) + }); let frame_len = FRAME_HEADER_BYTES.saturating_add(payload_len); if frame_len > max_frame_bytes { metrics::counter!("zakura.p2p.ratelimit.frame.oversize").increment(1); @@ -4442,9 +4674,7 @@ async fn read_frame( }); } let mut payload = vec![0; payload_len]; - timeout(read_timeout, recv.read_exact(&mut payload)) - .await - .map_err(|_| ZakuraHandlerError::Timeout("frame payload"))??; + read_frame_payload(recv, &mut payload, read_timeout).await?; Ok(Frame { message_type, flags, @@ -4452,6 +4682,25 @@ async fn read_frame( }) } +async fn read_frame_payload( + recv: &mut RecvStream, + payload: &mut [u8], + read_timeout: Duration, +) -> Result<(), ZakuraHandlerError> { + match timeout(read_timeout, recv.read_exact(payload)) + .await + .map_err(|_| ZakuraHandlerError::Timeout("frame payload"))? + { + Ok(()) => Ok(()), + // A peer can reset a pair during a partial frame. Reset ends that stream; + // a normal FIN with a truncated payload still reports a protocol error. + Err(iroh::endpoint::ReadExactError::ReadError(iroh::endpoint::ReadError::Reset(_))) => { + Err(ZakuraHandlerError::Closed) + } + Err(error) => Err(error.into()), + } +} + async fn read_control_payload( recv: &mut RecvStream, max_bytes: u32, @@ -4503,6 +4752,23 @@ async fn write_ordered_frame( frame: Frame, limits: ZakuraConnectionLimits, max_frame_bytes: u32, +) -> Result<(), BoxError> { + write_ordered_frame_with_policy( + send, + frame, + limits, + max_frame_bytes, + OrderedWritePolicy::Standalone, + ) + .await +} + +async fn write_ordered_frame_with_policy( + send: &mut SendStream, + frame: Frame, + limits: ZakuraConnectionLimits, + max_frame_bytes: u32, + write_policy: OrderedWritePolicy, ) -> Result<(), BoxError> { // Mirror `write_response_frame`: a persistent ordered-stream frame whose // payload exceeds the peer's negotiated `max_message_bytes` would be @@ -4519,16 +4785,22 @@ async fn write_ordered_frame( .into()); } let frame = frame.encode(max_frame_bytes)?; - timeout(OUTBOUND_STREAM_WRITE_TIMEOUT, send.write_all(&frame)) - .await - .map_err(|_| -> BoxError { "Zakura outbound frame write timed out".into() })??; + if write_policy == OrderedWritePolicy::PairRequests { + send.write_all(&frame).await?; + } else { + let write_timeout = if write_policy == OrderedWritePolicy::PairData { + PAIRED_DATA_WRITE_TIMEOUT + } else { + OUTBOUND_STREAM_WRITE_TIMEOUT + }; + timeout(write_timeout, send.write_all(&frame)) + .await + .map_err(|_| -> BoxError { Box::new(OrderedFrameWriteTimeout) })??; + } Ok(()) } -/// Write one queued frame while retaining its byte-accounting lease. -/// -/// The lease remains owned until the QUIC write succeeds, fails, times out, or -/// this future is dropped with the worker. +/// Retain the queued frame's ownership through its QUIC write or cancellation. async fn write_queued_ordered_frame( send: &mut SendStream, queued_frame: QueuedFrame, @@ -4540,10 +4812,12 @@ async fn write_queued_ordered_frame( .await } +#[allow(clippy::too_many_arguments)] async fn write_outbound_request_frame( connection: &Connection, limits: ZakuraConnectionLimits, stream: Stream, + message_payload_limits: &'static [(u16, usize)], request_id: u64, message_type: u16, flags: u16, @@ -4555,6 +4829,7 @@ async fn write_outbound_request_frame( connection, limits, stream, + message_payload_limits, request_id, message_type, flags, @@ -4565,10 +4840,12 @@ async fn write_outbound_request_frame( .map_err(|_| OutboundRequestError::Local("Zakura outbound request/response timed out".into()))? } +#[allow(clippy::too_many_arguments)] async fn write_outbound_request_frame_inner( connection: &Connection, limits: ZakuraConnectionLimits, stream: Stream, + message_payload_limits: &'static [(u16, usize)], request_id: u64, message_type: u16, flags: u16, @@ -4622,6 +4899,7 @@ async fn write_outbound_request_frame_inner( match read_frame( &mut recv, inbound_frame_cap, + message_payload_limits, limits.idle_timeout, // This is the requester side of a one-shot legacy request/response: // the responder streams its frames promptly, so a silent gap before @@ -5220,6 +5498,7 @@ fn stream_kind_label(stream_kind: u16) -> &'static str { DISCOVERY_STREAM_KIND => "discovery", HEADER_SYNC_STREAM_KIND => "header_sync", ZAKURA_STREAM_BLOCK_SYNC => "block_sync", + crate::zakura::ZAKURA_STREAM_BLOCK_REQUESTS => "block_requests", _ => "unknown", } } @@ -5378,6 +5657,12 @@ impl TokenBucket { /// Errors produced by the Zakura protocol handler. #[derive(Debug, Error)] pub enum ZakuraHandlerError { + /// The frame header names a message that is invalid on this stream role. + #[error("invalid message type {0} for this stream role")] + InvalidMessageType(u16), + /// Two ordered stream roles failed to name one complete session. + #[error("invalid Zakura ordered stream pair")] + InvalidOrderedPair, /// A bounded read/write timed out. #[error("Zakura {0} timed out")] Timeout(&'static str), @@ -5412,6 +5697,9 @@ pub enum ZakuraHandlerError { /// A local resource cap rejected the operation. #[error("Zakura resource limit exceeded: {0}")] ResourceLimit(&'static str), + /// Another connection reserved the last service session slot. + #[error("ordered service session capacity is full")] + OrderedSessionFull, /// The peer exceeded its per-kind inbound message rate. #[error("Zakura message rate exceeded")] RateLimited, @@ -5465,6 +5753,11 @@ impl ZakuraHandlerError { #[cfg(test)] mod tests { + pub(super) mod connection; + mod paired_block_sync; + mod quic_progress; + mod serving_progress; + use super::*; use crate::{ protocol::internal::{InventoryResponse, Response}, @@ -5511,7 +5804,8 @@ mod tests { #[tokio::test] async fn parked_block_sync_peer_gets_a_stream_when_its_cooldown_lapses() -> Result<(), BoxError> { - const COOLDOWN: Duration = Duration::from_secs(3); + const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + const COOLDOWN: Duration = Duration::from_secs(15); let _guard = zakura_test::init(); @@ -5530,21 +5824,28 @@ mod tests { let dialer = node(140).await?; let listener = node(141).await?; - let listener_peer = - ZakuraPeerId::new(listener.node_addr().await.node_id.as_bytes().to_vec())?; + let listener_addr = listener.node_addr().await; + let listener_peer = ZakuraPeerId::new(listener_addr.node_id.as_bytes().to_vec())?; let block_sync = dialer .block_sync() .expect("the header-sync driver spawns the block-sync reactor"); // Block sync evicts and parks the peer after its no-progress deadline. - // The transport redials during the cooldown. + // Keep the cooldown longer than connection setup so the first assertion + // tests a live park even when the dial is slow. + let parked_at = std::time::Instant::now(); block_sync.park_peer_for_test(&listener_peer, COOLDOWN); dialer - .connect_native(&listener, Duration::from_secs(10)) + .connect_native_to_addr(listener_addr, CONNECT_TIMEOUT) .await?; // The park remains active. // Withhold block sync from this connection. + assert!( + parked_at.elapsed() < COOLDOWN, + "connection setup outlasted the test cooldown: {:?}", + parked_at.elapsed(), + ); assert_eq!( block_sync.peer_snapshot().outbound_peers, 0, @@ -7814,13 +8115,201 @@ mod tests { ); } + /// Exercise the production worker with a full application receive channel. + /// Sending must stay live, QUIC must eventually stop the bounded sender, and + /// another stream must retain connection credit until the application resumes. + #[tokio::test] + async fn paused_ordered_reads_preserve_writes_and_sibling_credit() -> Result<(), BoxError> { + const ALPN: &[u8] = b"/zakura/testkit/paused-reader/0"; + const FRAME_COUNT: usize = 24; + let local = ZakuraLocalLimits::from_config(&Config::default()); + let server = LocalEndpointFactory::with_transport_config(local.transport_config()) + .endpoint(52) + .await?; + let (conn_tx, _conn_rx) = mpsc::channel(1); + let (stream_tx, mut stream_rx) = mpsc::channel(2); + let router = Router::builder(server) + .accept( + ALPN, + CaptureConnection { + connection_tx: conn_tx, + stream_tx, + }, + ) + .spawn(); + // A small sender buffer makes write completion reflect receiver credit. + let mut client_transport = local.transport_config(); + client_transport.send_window(64 * 1024); + let client = LocalEndpointFactory::with_transport_config(client_transport) + .endpoint(53) + .await?; + let address = router.endpoint().node_addr().initialized().await; + client.add_node_addr(address.clone())?; + let connection = timeout(Duration::from_secs(10), client.connect(address, ALPN)).await??; + let (mut sender, mut receiver) = connection.open_bi().await?; + let frame = Frame { + message_type: 3, + flags: 0, + payload: vec![0; 1024 * 1024], + }; + let limits = test_connection_limits(); + let stream = Stream { + kind: ZAKURA_STREAM_BLOCK_SYNC, + version: ZAKURA_BLOCK_SYNC_STREAM_VERSION, + frame_cap: 2_000_009, + capability: ZAKURA_CAP_BLOCK_SYNC, + mode: StreamMode::Ordered, + }; + let encoded = frame.encode(stream.frame_cap)?; + // Opening a QUIC stream becomes visible to the receiver after the first bytes. + sender.write_all(&encoded[..1]).await?; + let (send, recv) = timeout(Duration::from_secs(5), stream_rx.recv()) + .await? + .unwrap(); + let cancel = CancellationToken::new(); + let stream_cancel = cancel.child_token(); + let (freshness_tx, _freshness_rx) = watch::channel(Instant::now()); + let context = StreamWorkerContext { + conn: ZakuraConnTrace::without_peer(1), + peer_id: test_peer(52), + stream_id: 1, + _permit: Arc::new(Semaphore::new(1)).try_acquire_owned().unwrap(), + limits, + inbound_frame_cap: stream.frame_cap, + message_payload_limits: &[], + message_types: None, + queue_depths: None, + session_resources: None, + outbound_frame_cap: stream.frame_cap, + message_bucket: Arc::new(std::sync::Mutex::new(TokenBucket::new(128))), + connection_token: cancel.clone(), + stream_token: stream_cancel.clone(), + close_cause: CloseCause::new(), + freshness_tx, + }; + let prelude = StreamPrelude { + magic: STREAM_PRELUDE_MAGIC, + stream_kind: stream.kind, + stream_version: stream.version, + request_id: None, + max_frame_bytes: stream.frame_cap, + }; + let (inbound_tx, mut inbound_rx) = mpsc::channel(1); + let (outbound_tx, outbound_rx) = worker_framed_channel(1); + let mut worker = tokio_util::task::AbortOnDropHandle::new(tokio::spawn( + persistent_stream_worker(send, recv, prelude, context, inbound_tx, outbound_rx, 1), + )); + let (progress_tx, mut progress_rx) = watch::channel(0); + let mut sending = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move { + sender.write_all(&encoded[1..]).await.unwrap(); + progress_tx.send_replace(1); + for sent in 2..=FRAME_COUNT { + sender.write_all(&encoded).await.unwrap(); + progress_tx.send_replace(sent); + } + sender + })); + timeout(Duration::from_secs(10), async { + while *progress_rx.borrow_and_update() < 16 { + progress_rx.changed().await.unwrap(); + } + }) + .await + .expect("the sender fills the default stream receive window"); + assert!( + timeout(Duration::from_millis(100), &mut sending) + .await + .is_err(), + "QUIC must stop the sender while application reads are paused" + ); + assert_eq!(inbound_rx.len(), 1); + + let response = Frame { + message_type: 4, + flags: 0, + payload: vec![7; 9], + }; + let producer = crate::zakura::regulation::SlotBudget::new(1).unwrap(); + let ownership = Arc::new(producer.try_reserve().unwrap()); + outbound_tx.try_reserve_guarded().unwrap().send( + response.clone(), + crate::zakura::transport::FrameGuard::new(ownership), + ); + let written = timeout( + Duration::from_secs(2), + read_frame( + &mut receiver, + stream.frame_cap, + &[], + Duration::from_secs(2), + None, + ), + ) + .await??; + assert_eq!(written, response); + assert_eq!( + producer.reserved(), + 0, + "the response write releases serving capacity" + ); + + let (mut sibling_send, _sibling_recv) = connection.open_bi().await?; + timeout(Duration::from_secs(2), sibling_send.write_all(b"sibling")).await??; + let (_, mut sibling_read) = timeout(Duration::from_secs(2), stream_rx.recv()) + .await? + .unwrap(); + let mut bytes = [0; 7]; + timeout(Duration::from_secs(2), sibling_read.read_exact(&mut bytes)).await??; + assert_eq!(&bytes, b"sibling"); + + timeout(Duration::from_secs(10), async { + for _ in 0..FRAME_COUNT { + assert_eq!(inbound_rx.recv().await.unwrap(), frame); + } + }) + .await + .expect("resuming application reads drains all frames in order"); + let mut sender = timeout(Duration::from_secs(2), &mut sending).await??; + assert_eq!(*progress_rx.borrow(), FRAME_COUNT); + // Cancellation must also work while the reader waits on a full channel. + let empty = Frame { + message_type: 2, + flags: 0, + payload: vec![0; 9], + } + .encode(stream.frame_cap)?; + sender.write_all(&empty).await?; + sender.write_all(&empty).await?; + timeout(Duration::from_secs(2), async { + while inbound_rx.len() != 1 { + tokio::task::yield_now().await; + } + }) + .await?; + stream_cancel.cancel(); + timeout(Duration::from_secs(2), &mut worker).await??; + assert!( + !cancel.is_cancelled(), + "local stream cancellation preserves the connection" + ); + connection.close(0u32.into(), b"done"); + client.close().await; + router.shutdown().await?; + Ok(()) + } + #[tokio::test] async fn stream_cancel_closes_ordered_worker_without_connection_cancel() -> Result<(), BoxError> { const ALPN: &[u8] = b"/zakura/testkit/stream-cancel/0"; let _guard = zakura_test::init(); - let server = LocalEndpointFactory::new().endpoint(50).await?; + let mut server_transport = + ZakuraLocalLimits::from_config(&Config::default()).transport_config(); + server_transport.send_window(64 * 1024); + let server = LocalEndpointFactory::with_transport_config(server_transport) + .endpoint(50) + .await?; let (conn_tx, mut conn_rx) = mpsc::channel(1); let (stream_tx, mut stream_rx) = mpsc::channel(2); let router = Router::builder(server) @@ -7832,7 +8321,12 @@ mod tests { }, ) .spawn(); - let client = LocalEndpointFactory::new().endpoint(51).await?; + let mut client_transport = + ZakuraLocalLimits::from_config(&Config::default()).transport_config(); + client_transport.stream_receive_window(VarInt::from_u32(64 * 1024)); + let client = LocalEndpointFactory::with_transport_config(client_transport) + .endpoint(51) + .await?; let server_addr = router.endpoint().node_addr().initialized().await; client.add_node_addr(server_addr.clone())?; @@ -7844,7 +8338,7 @@ mod tests { .expect("server connection is captured") .expect("capture handler sends the accepted connection"); drop(server_conn); - let (mut client_send, _client_recv) = + let (mut client_send, mut client_recv) = timeout(Duration::from_secs(1), client_conn.open_bi()) .await .expect("client opens the worker stream")?; @@ -7864,7 +8358,7 @@ mod tests { let mut limits = test_connection_limits(); limits.idle_timeout = Duration::from_millis(50); - let stream_kind = DISCOVERY_STREAM_KIND; + let stream_kind = ZAKURA_STREAM_BLOCK_SYNC; let connection_token = CancellationToken::new(); let stream_token = connection_token.child_token(); let (freshness_tx, _freshness_rx) = watch::channel(Instant::now()); @@ -7874,8 +8368,8 @@ mod tests { let stream = Stream { kind: stream_kind, version: ZAKURA_STREAM_VERSION_1, - frame_cap: LOCAL_MAX_CONTROL_FRAME_BYTES, - capability: ZAKURA_CAP_DISCOVERY, + frame_cap: 2_000_009, + capability: ZAKURA_CAP_BLOCK_SYNC, mode: StreamMode::Ordered, }; let context = StreamWorkerContext { @@ -7885,6 +8379,10 @@ mod tests { _permit: permit, limits, inbound_frame_cap: inbound_frame_cap_for_stream(&limits, stream), + message_payload_limits: &[], + message_types: None, + queue_depths: None, + session_resources: None, outbound_frame_cap: application_frame_cap(&limits, stream), message_bucket: Arc::new(std::sync::Mutex::new(TokenBucket::new(128))), connection_token: connection_token.clone(), @@ -7913,7 +8411,33 @@ mod tests { ordered_session_exit_tx, ); + let response = Frame { + message_type: 3, + flags: 0, + payload: vec![7; 1024 * 1024], + }; + admitted.send.try_send(response.clone()).unwrap(); + // Reading the header proves the write has started. The much smaller + // QUIC windows keep the remaining payload blocked until we drain it. + let mut header = [0; FRAME_HEADER_BYTES]; + timeout(Duration::from_secs(2), client_recv.read_exact(&mut header)).await??; + assert_eq!( + &header[..], + &response.encode(stream.frame_cap)?[..FRAME_HEADER_BYTES] + ); admitted.cancel_token.cancel(); + assert!( + timeout(Duration::from_millis(50), ordered_session_exit_rx.recv()) + .await + .is_err(), + "stream cancellation must finish the current frame before reporting exit" + ); + let mut payload = vec![0; response.payload.len()]; + timeout(Duration::from_secs(2), client_recv.read_exact(&mut payload)).await??; + assert_eq!( + payload, response.payload, + "the peer must receive a complete frame" + ); // The exit must be reported, or the connection loop never prunes the dead // generation and never reopens the stream. let exited = timeout(Duration::from_secs(1), ordered_session_exit_rx.recv()) @@ -8066,6 +8590,10 @@ mod tests { _permit: permit, limits, inbound_frame_cap: inbound_frame_cap_for_stream(&limits, stream), + message_payload_limits: &[], + message_types: None, + queue_depths: None, + session_resources: None, outbound_frame_cap: application_frame_cap(&limits, stream), message_bucket: Arc::new(std::sync::Mutex::new(TokenBucket::new( limits.message_rate_per_second, @@ -8345,6 +8873,142 @@ mod tests { Ok(()) } + #[tokio::test] + async fn message_payload_limits_apply_before_payload_reads() -> Result<(), BoxError> { + use crate::zakura::{BlockSyncMessage, BlockSyncService, ZakuraBlockSyncConfig}; + use zakura_chain::{block, serialization::ZcashDeserializeInto}; + + let service = Arc::new(BlockSyncService::new(ZakuraBlockSyncConfig::default())); + let stream = service.streams()[0]; + let requests = service.streams()[1]; + let registry = ServiceRegistry::new(vec![service])?; + let payload_limits = registry.message_payload_limits(requests); + assert_eq!(payload_limits, &[(2, 9)]); + assert!(registry + .message_payload_limits(Stream { + version: stream.version + 1, + ..stream + }) + .is_empty()); + assert!(registry + .message_payload_limits(Stream { + kind: u16::MAX, + ..stream + }) + .is_empty()); + assert!(NoopService.message_payload_limits(stream).is_empty()); + + const ALPN: &[u8] = b"/zakura/testkit/message-payload-limits/0"; + let _guard = zakura_test::init(); + let server = LocalEndpointFactory::new().endpoint(79).await?; + let (conn_tx, _conn_rx) = mpsc::channel(8); + let (stream_tx, mut stream_rx) = mpsc::channel(8); + let router = Router::builder(server) + .accept( + ALPN, + CaptureConnection { + connection_tx: conn_tx, + stream_tx, + }, + ) + .spawn(); + let client = LocalEndpointFactory::new().endpoint(80).await?; + let server_addr = router.endpoint().node_addr().initialized().await; + client.add_node_addr(server_addr.clone())?; + + // Only send headers and keep the send sides open. The reader must reject + // before waiting for a payload that the peer has not supplied. + for (message_type, payload_len, frame_cap, expected_cap) in [ + // Eight frame-header bytes plus the nine-byte GetBlocks payload cap. + (2u16, 10u32, stream.frame_cap, 17usize), + (2, u32::MAX, stream.frame_cap, 17), + // A tighter stream cap still applies to an otherwise legal request. + (2, 9, 16, 16), + // Block has no message-specific cap yet; its stream cap still applies. + (3, 100, 107, 107), + ] { + let connection = timeout( + Duration::from_secs(5), + client.connect(server_addr.clone(), ALPN), + ) + .await??; + let (mut send, _recv) = timeout(Duration::from_secs(2), connection.open_bi()).await??; + let mut header = Vec::with_capacity(FRAME_HEADER_BYTES); + header.extend_from_slice(&message_type.to_le_bytes()); + header.extend_from_slice(&0u16.to_le_bytes()); + header.extend_from_slice(&payload_len.to_le_bytes()); + timeout(Duration::from_secs(2), send.write_all(&header)).await??; + let (_, mut recv) = timeout(Duration::from_secs(2), stream_rx.recv()) + .await? + .unwrap(); + let result = timeout( + Duration::from_secs(1), + read_frame( + &mut recv, + frame_cap, + payload_limits, + Duration::from_secs(5), + Some(Duration::from_secs(5)), + ), + ) + .await + .expect("an oversized header is rejected without waiting for payload bytes"); + assert!( + matches!(result, Err(ZakuraHandlerError::OversizeFrame { max_frame_bytes, .. }) if max_frame_bytes == expected_cap) + ); + } + + // The generic payload gate preserves the independent Block allowance. + // Role enforcement is covered by the paired-stream tests. + let connection = + timeout(Duration::from_secs(5), client.connect(server_addr, ALPN)).await??; + let (mut send, _recv) = timeout(Duration::from_secs(2), connection.open_bi()).await??; + let messages = [ + BlockSyncMessage::GetBlocks { + start_height: block::Height(1), + count: 1, + }, + BlockSyncMessage::Block( + zakura_test::vectors::BLOCK_MAINNET_1_BYTES.zcash_deserialize_into()?, + ), + ]; + let first = messages[0].encode_frame()?; + timeout( + Duration::from_secs(2), + send.write_all(&first.encode(stream.frame_cap)?), + ) + .await??; + let (_, mut recv) = timeout(Duration::from_secs(2), stream_rx.recv()) + .await? + .unwrap(); + for (index, message) in messages.into_iter().enumerate() { + if index > 0 { + let frame = message.encode_frame()?; + assert!(frame.payload.len() > 9); + timeout( + Duration::from_secs(2), + send.write_all(&frame.encode(stream.frame_cap)?), + ) + .await??; + } + let frame = timeout( + Duration::from_secs(2), + read_frame( + &mut recv, + stream.frame_cap, + payload_limits, + Duration::from_secs(2), + Some(Duration::from_secs(2)), + ), + ) + .await??; + assert_eq!(BlockSyncMessage::decode_frame(frame)?, message); + } + client.close().await; + router.shutdown().await?; + Ok(()) + } + // claude-late-message-cap-allocation: read_frame checks only // frame_len > max_frame_bytes before `vec![0; payload_len]`, while the smaller // max_message_bytes is enforced later in admit_inbound_message. A peer can @@ -8444,6 +9108,7 @@ mod tests { let rejected = read_frame( &mut s1_recv, inbound_cap, + &[], Duration::from_secs(2), Some(Duration::from_secs(2)), ) @@ -8477,6 +9142,7 @@ mod tests { let allocated = read_frame( &mut s2_recv, raw_cap, + &[], Duration::from_secs(2), Some(Duration::from_secs(2)), ) @@ -8515,6 +9181,7 @@ mod tests { let frame = read_frame( &mut s3_recv, inbound_cap, + &[], Duration::from_secs(2), Some(Duration::from_secs(2)), ) @@ -8610,6 +9277,7 @@ mod tests { let (freshness_tx, _freshness_rx) = watch::channel(Instant::now()); let mut admission = StreamAdmission { + direction: ServicePeerDirection::Inbound, conn: ZakuraConnTrace::placeholder(), peer_id: &peer_id, stream_sem: &stream_sem, @@ -8630,6 +9298,7 @@ mod tests { &mut admission, 16, ordered_session_exit_tx, + &mut PendingOrderedPairs::default(), ) .await; @@ -8845,7 +9514,8 @@ mod tests { ); } - for kind in [7u16, 255, u16::MAX] { + assert_eq!(stream_kind_label(7), "block_requests"); + for kind in [255u16, u16::MAX] { assert_eq!(stream_kind_label(kind), "unknown"); } } diff --git a/crates/zakura-network/src/zakura/handler/ordered_pair.rs b/crates/zakura-network/src/zakura/handler/ordered_pair.rs new file mode 100644 index 0000000000..5fef5da0a2 --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/ordered_pair.rs @@ -0,0 +1,323 @@ +//! Connection-scoped setup and teardown for persistent stream pairs. + +use super::*; +use crate::zakura::OrderedStreamPair; + +/// Own raw stream halves until setup succeeds. Failed or cancelled setup must +/// stop both directions rather than leave a partially written prelude alive. +struct SetupIo(Option<(SendStream, RecvStream)>); + +impl SetupIo { + fn take(mut self) -> (SendStream, RecvStream) { + self.0 + .take() + .expect("setup owns both stream halves until handoff") + } +} + +impl Drop for SetupIo { + fn drop(&mut self) { + if let Some((send, recv)) = &mut self.0 { + let _ = send.reset(VarInt::from_u32(ZAKURA_CLOSE_RESOURCE)); + let _ = recv.stop(VarInt::from_u32(ZAKURA_CLOSE_RESOURCE)); + } + } +} + +pub(super) struct PreparedOrderedStream { + io: SetupIo, + stream: Stream, + prelude: StreamPrelude, + context: StreamWorkerContext, +} + +impl PreparedOrderedStream { + pub(super) fn set_session_resources( + &mut self, + resources: Option>, + ) { + self.context.session_resources = resources; + } + + pub(super) fn new( + send: SendStream, + recv: RecvStream, + stream: Stream, + prelude: StreamPrelude, + context: StreamWorkerContext, + ) -> Self { + Self { + io: SetupIo(Some((send, recv))), + stream, + prelude, + context, + } + } + + pub(super) fn spawn_single( + self, + workers: &mut JoinSet<()>, + queue_depth: usize, + opened_locally: bool, + exits: mpsc::UnboundedSender, + ) -> AdmittedOrderedSession { + let (send, recv) = self.io.take(); + spawn_persistent_stream_worker( + workers, + send, + recv, + self.stream, + self.prelude, + self.context, + queue_depth, + opened_locally, + exits, + ) + } +} + +struct PendingPair { + id: u64, + first: PreparedOrderedStream, + deadline: Instant, +} + +/// At most one incomplete offer per negotiated pair on this connection. +/// Each retained half also owns a transport stream permit until it is stopped. +#[derive(Default)] +pub(super) struct PendingOrderedPairs { + pairs: HashMap, +} + +impl PendingOrderedPairs { + pub(super) fn reserve_or_share( + &self, + pair: OrderedStreamPair, + registry: &ServiceRegistry, + direction: ServicePeerDirection, + ) -> Result< + Option>, + crate::zakura::OrderedSessionFull, + > { + match self.pairs.get(&pair.data.kind) { + Some(pending) => Ok(pending.first.context.session_resources.clone()), + None => registry + .service_for_kind(pair.data.kind) + .expect("a selected pair has an owning service") + .reserve_ordered_session(direction), + } + } + + pub(super) fn deadline(&self) -> Option { + self.pairs.values().map(|pair| pair.deadline).min() + } + + pub(super) fn expire(&mut self, now: Instant) { + self.pairs.retain(|_, pair| pair.deadline > now); + } + + pub(super) fn insert( + &mut self, + pair: OrderedStreamPair, + id: u64, + incoming: PreparedOrderedStream, + ) -> Result, ZakuraHandlerError> { + if id == 0 || (incoming.stream != pair.data && incoming.stream != pair.requests) { + return Err(ZakuraHandlerError::InvalidOrderedPair); + } + let Some(pending) = self.pairs.remove(&pair.data.kind) else { + let deadline = Instant::now() + incoming.context.limits.prelude_timeout; + self.pairs.insert( + pair.data.kind, + PendingPair { + id, + first: incoming, + deadline, + }, + ); + return Ok(None); + }; + if pending.id != id + || pending.first.stream == incoming.stream + || Instant::now() >= pending.deadline + { + return Err(ZakuraHandlerError::InvalidOrderedPair); + } + if incoming.stream == pair.data { + Ok(Some((incoming, pending.first))) + } else { + Ok(Some((pending.first, incoming))) + } + } +} + +/// Workers share cancellation and publish one exit only after both have ended. +pub(super) fn spawn_ordered_pair( + workers: &mut JoinSet<()>, + mut data: PreparedOrderedStream, + mut requests: PreparedOrderedStream, + queue_depth: usize, + opened_locally: bool, + exits: mpsc::UnboundedSender, +) -> AdmittedOrderedSession { + if let Some(resources) = &data.context.session_resources { + resources.admitted(); + } + let cancel = data.context.connection_token.child_token(); + let remote_close = CancellationToken::new(); + data.context.stream_token = cancel.clone(); + requests.context.stream_token = cancel.clone(); + let (inbound_depth, outbound_depth) = + bounded_stream_queue_depths(queue_depth, data.context.queue_depths); + let (data_tx, data_rx) = mpsc::channel(inbound_depth); + let (data_send, data_out) = worker_framed_channel(outbound_depth); + // One raw queued request plus one reader-held frame. The service owns its + // decoded waiting request separately from these transport bounds. + let (request_tx, request_rx) = mpsc::channel(1); + let (request_send, request_out) = worker_framed_channel(1); + let admitted = AdmittedOrderedSession { + kind: data.stream.kind, + version: data.stream.version, + session_id: data.context.stream_id, + recv: FramedRecv::new(data_rx).with_remote_close(remote_close.clone()), + send: data_send.with_session_resources(data.context.session_resources.clone()), + cancel_token: cancel.clone(), + companion: Some(ServiceStreamRole { + kind: requests.stream.kind, + version: requests.stream.version, + recv: FramedRecv::new(request_rx).with_remote_close(remote_close.clone()), + send: request_send.with_session_resources(requests.context.session_resources.clone()), + }), + }; + let exit = OrderedSessionExit { + stream: data.stream, + session_id: admitted.session_id, + opened_locally, + }; + workers.spawn(async move { + let _cancel_on_exit = cancel.clone().drop_guard(); + let (data_send, data_recv) = data.io.take(); + let (request_send, request_recv) = requests.io.take(); + tokio::join!( + async { + persistent_stream_worker_with_policy( + data_send, + data_recv, + data.prelude, + data.context, + data_tx, + data_out, + inbound_depth, + OrderedWritePolicy::PairData, + Some(remote_close.clone()), + ) + .await; + cancel.cancel(); + }, + async { + persistent_stream_worker_with_policy( + request_send, + request_recv, + requests.prelude, + requests.context, + request_tx, + request_out, + 1, + OrderedWritePolicy::PairRequests, + Some(remote_close.clone()), + ) + .await; + cancel.cancel(); + }, + ); + let _ = exits.send(exit); + }); + admitted +} + +impl ZakuraProtocolHandler { + #[allow(clippy::too_many_arguments)] + pub(super) async fn prepare_ordered_stream( + &self, + connection: &Connection, + stream: Stream, + pair_id: Option, + stream_sem: &Arc, + message_buckets: &mut MessageRateBuckets, + limits: ZakuraConnectionLimits, + connection_token: CancellationToken, + close_cause: CloseCause, + freshness_tx: watch::Sender, + conn: ZakuraConnTrace, + peer_id: ZakuraPeerId, + ) -> Result { + let stream_id = self.next_stream_id.fetch_add(1, Ordering::Relaxed); + let permit = stream_sem + .clone() + .try_acquire_owned() + .map_err(|_| ZakuraHandlerError::ResourceLimit("ordered stream permit"))?; + let io = timeout(OUTBOUND_STREAM_WRITE_TIMEOUT, connection.open_bi()) + .await + .map_err(|_| ZakuraHandlerError::Timeout("open ordered service stream"))??; + let mut io = SetupIo(Some(io)); + let prelude = StreamPrelude { + magic: STREAM_PRELUDE_MAGIC, + stream_kind: stream.kind, + stream_version: stream.version, + request_id: None, + max_frame_bytes: inbound_frame_cap_for_stream(&limits, stream), + }; + let mut bytes = prelude.encode()?; + if let Some(id) = pair_id { + bytes.extend_from_slice(&id.to_le_bytes()); + } + timeout( + OUTBOUND_STREAM_WRITE_TIMEOUT, + io.0.as_mut() + .expect("setup retains its stream halves") + .0 + .write_all(&bytes), + ) + .await + .map_err(|_| ZakuraHandlerError::Timeout("ordered stream prelude write"))??; + let bucket_kind = self + .registry + .ordered_stream_pair(stream) + .map_or(stream.kind, |pair| pair.data.kind); + let message_bucket = message_bucket_for( + message_buckets, + bucket_kind, + limits.message_rate_per_second, + RealClock, + ); + let context = StreamWorkerContext { + conn: conn.clone(), + peer_id, + stream_id, + _permit: permit, + limits, + inbound_frame_cap: prelude.max_frame_bytes, + message_payload_limits: self.registry.message_payload_limits(stream), + message_types: self.registry.message_types(stream), + queue_depths: self.registry.stream_queue_depths(stream), + session_resources: None, + outbound_frame_cap: application_frame_cap(&limits, stream), + message_bucket, + stream_token: connection_token.child_token(), + connection_token, + close_cause, + freshness_tx, + }; + metrics::counter!("zakura.p2p.stream.accepted", "stream_kind" => stream_kind_label(stream.kind)).increment(1); + conn.trace_stream("accepted", stream_id, Some(stream_kind_label(stream.kind))); + Ok(PreparedOrderedStream { + io, + stream, + prelude, + context, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/zakura-network/src/zakura/handler/ordered_pair/tests.rs b/crates/zakura-network/src/zakura/handler/ordered_pair/tests.rs new file mode 100644 index 0000000000..b7215c1348 --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/ordered_pair/tests.rs @@ -0,0 +1,531 @@ +use super::*; +use crate::zakura::testkit::LocalEndpointFactory; +use tokio_util::task::AbortOnDropHandle; + +const DATA: Stream = Stream { + kind: 64, + version: 1, + frame_cap: 2 * 1024 * 1024, + capability: 1 << 16, + mode: StreamMode::Ordered, +}; +const REQUESTS: Stream = Stream { kind: 65, ..DATA }; +const SIBLING: Stream = Stream { + kind: 66, + capability: 1 << 17, + ..DATA +}; +const PAIR: OrderedStreamPair = OrderedStreamPair { + data: DATA, + requests: REQUESTS, +}; +const ALPN: &[u8] = b"/zakura/test/ordered-pair/1"; +const TEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug)] +struct PairService { + fail_first_reservation: std::sync::atomic::AtomicBool, + sessions: mpsc::Sender, +} + +#[derive(Debug)] +struct SiblingService(mpsc::Sender); + +impl Service for SiblingService { + fn name(&self) -> &'static str { + "test-sibling" + } + fn streams(&self) -> &[Stream] { + &[SIBLING] + } + fn add_peer(&self, peer: Peer) { + let cancel = peer.service_cancel_token(); + if self.0.try_send(peer).is_err() { + cancel.cancel(); + } + } + fn remove_peer(&self, _: &ZakuraPeerId, _: ZakuraConnId) {} +} + +impl Service for PairService { + fn reserve_ordered_session( + &self, + _: ServicePeerDirection, + ) -> Result< + Option>, + crate::zakura::OrderedSessionFull, + > { + // Inject the outcome of another connection taking the final slot after + // this connection's advisory OpenNow check. A retry can succeed. + if self.fail_first_reservation.swap(false, Ordering::SeqCst) { + Err(crate::zakura::OrderedSessionFull) + } else { + Ok(None) + } + } + fn name(&self) -> &'static str { + "test-pair" + } + fn streams(&self) -> &[Stream] { + &[DATA, REQUESTS] + } + fn ordered_stream_pair(&self, stream: Stream) -> Option { + [DATA, REQUESTS].contains(&stream).then_some(PAIR) + } + fn stream_queue_depths(&self, _: Stream) -> Option<(usize, usize)> { + Some((1, 1)) + } + fn ordered_stream_policy(&self, _: u16) -> OrderedStreamPolicy { + OrderedStreamPolicy { + opening: OrderedStreamOpening::EitherSide, + reopen: true, + } + } + fn add_peer(&self, peer: Peer) { + let cancel = peer.service_cancel_token(); + if self.sessions.try_send(peer).is_err() { + cancel.cancel(); + } + } + fn remove_peer(&self, _: &ZakuraPeerId, _: ZakuraConnId) {} +} + +struct Session { + id: u64, + conn_id: u64, + data_recv: FramedRecv, + data_send: FramedSend, + request_recv: FramedRecv, + request_send: FramedSend, + cancel: CancellationToken, + connection_cancel: CancellationToken, +} + +impl Session { + async fn receive(receiver: &mut mpsc::Receiver) -> Result { + let mut peer = timeout(TEST_TIMEOUT, receiver.recv()) + .await? + .ok_or("pair admission channel closed")?; + let (data_id, data_version, data_recv, data_send) = peer + .take_versioned_stream_with_session_id(DATA.kind) + .expect("complete data role"); + let (request_id, request_version, request_recv, request_send) = peer + .take_versioned_stream_with_session_id(REQUESTS.kind) + .expect("complete request role"); + assert_eq!(data_id, request_id); + assert_ne!(data_id, 0); + assert_eq!(data_version, DATA.version); + assert_eq!(request_version, REQUESTS.version); + Ok(Self { + id: data_id, + conn_id: peer.conn_id, + data_recv, + data_send, + request_recv, + request_send, + cancel: peer.service_cancel_token(), + connection_cancel: peer.cancel_token(), + }) + } +} + +struct Fixture { + router: Router, + client: Endpoint, + connection: Connection, + serving: AbortOnDropHandle>, + server_sessions: mpsc::Receiver, + client_sessions: mpsc::Receiver, + server_siblings: mpsc::Receiver, + client_siblings: mpsc::Receiver, +} + +impl Fixture { + async fn start() -> Result { + Self::start_with_reservation_race(false).await + } + + async fn start_with_reservation_race(fail_first_reservation: bool) -> Result { + let local = ZakuraLocalLimits::from_config(&Config::default()); + let server = LocalEndpointFactory::with_transport_config(local.transport_config()) + .endpoint(93101) + .await?; + let client = LocalEndpointFactory::with_transport_config(local.transport_config()) + .endpoint(93102) + .await?; + let (server_tx, server_sessions) = mpsc::channel(2); + let (client_tx, client_sessions) = mpsc::channel(2); + let (server_sibling_tx, server_siblings) = mpsc::channel(1); + let (client_sibling_tx, client_siblings) = mpsc::channel(1); + let handler = |sessions, siblings, endpoint: Endpoint, fail_reservation| { + ZakuraProtocolHandler::new_with_registry( + ZakuraSupervisorHandle::new(16), + Network::Mainnet, + ZakuraHandshakeConfig::for_network(&Network::Mainnet), + local.clone(), + Arc::new( + ServiceRegistry::new(vec![ + Arc::new(PairService { + sessions, + fail_first_reservation: std::sync::atomic::AtomicBool::new( + fail_reservation, + ), + }), + Arc::new(SiblingService(siblings)), + ]) + .unwrap(), + ), + ) + .with_endpoint(endpoint) + }; + let server_opens = i_open_collision_winner(&server.node_id(), &client.node_id()); + let server_handler = handler( + server_tx, + server_sibling_tx, + server.clone(), + fail_first_reservation && server_opens, + ); + let client_handler = handler( + client_tx, + client_sibling_tx, + client.clone(), + fail_first_reservation && !server_opens, + ); + let router = Router::builder(server).accept(ALPN, server_handler).spawn(); + let address = LocalEndpointFactory::node_addr(router.endpoint()).await; + let (connection, serving) = super::super::tests::connection::connect_and_serve( + &client, + address, + client_handler, + local, + ALPN, + TEST_TIMEOUT, + ) + .await?; + Ok(Self { + router, + client, + connection, + serving, + server_sessions, + client_sessions, + server_siblings, + client_siblings, + }) + } + + async fn sessions(&mut self) -> Result<(Session, Session), BoxError> { + tokio::try_join!( + Session::receive(&mut self.client_sessions), + Session::receive(&mut self.server_sessions) + ) + } + + async fn close(self) -> Result<(), BoxError> { + self.connection.close(0u32.into(), b"test complete"); + timeout(TEST_TIMEOUT, self.serving).await???; + timeout(TEST_TIMEOUT, self.client.close()).await?; + timeout(TEST_TIMEOUT, self.router.shutdown()).await??; + Ok(()) + } +} + +fn frame(kind: u16, byte: u8, bytes: usize) -> Frame { + Frame { + message_type: kind, + flags: 0, + payload: vec![byte; bytes], + } +} + +async fn exchange(client: &mut Session, server: &mut Session) -> Result<(), BoxError> { + let request = frame(1, 17, 9); + timeout(TEST_TIMEOUT, client.request_send.send(request.clone())).await??; + assert_eq!( + timeout(TEST_TIMEOUT, server.request_recv.recv()).await?, + Some(request) + ); + let response = frame(2, 43, 1024 * 1024); + timeout(TEST_TIMEOUT, server.data_send.send(response.clone())).await??; + assert_eq!( + timeout(TEST_TIMEOUT, client.data_recv.recv()).await?, + Some(response) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn paired_data_timeout_preserves_sibling_and_reopens_pair() -> Result<(), BoxError> { + let _guard = zakura_test::init(); + let mut fixture = Fixture::start().await?; + let (client, server) = fixture.sessions().await?; + let mut client_sibling = timeout(TEST_TIMEOUT, fixture.client_siblings.recv()) + .await + .expect("the client admits the sibling service") + .ok_or("missing client sibling")?; + let mut server_sibling = timeout(TEST_TIMEOUT, fixture.server_siblings.recv()) + .await + .expect("the server admits the sibling service") + .ok_or("missing server sibling")?; + let (mut sibling_recv, _send) = client_sibling.take_stream(SIBLING.kind).unwrap(); + let (_recv, sibling_send) = server_sibling.take_stream(SIBLING.kind).unwrap(); + + // Keep the peer's data consumer paused past the actual production deadline. + // More than both transport windows ensures a data write must wait. + let started = Instant::now(); + let sender = client.data_send.clone(); + let writes = AbortOnDropHandle::new(tokio::spawn(async move { + for _ in 0..80 { + sender.send(frame(2, 43, 1024 * 1024)).await?; + } + Ok::<_, BoxError>(()) + })); + timeout(PAIRED_DATA_WRITE_TIMEOUT + Duration::from_secs(10), async { + loop { + let ping = frame(1, 17, 64); + sibling_send.send(ping.clone()).await?; + assert_eq!(sibling_recv.recv().await, Some(ping)); + tokio::select! { + () = client.cancel.cancelled() => break, + () = tokio::time::sleep(Duration::from_millis(100)) => {}, + } + } + Ok::<_, BoxError>(()) + }) + .await + .expect("the paired data writer retires its session at the write deadline")?; + assert!(started.elapsed() >= PAIRED_DATA_WRITE_TIMEOUT); + timeout(TEST_TIMEOUT, server.cancel.cancelled()).await?; + assert!(!client.connection_cancel.is_cancelled()); + assert!(!server.connection_cancel.is_cancelled()); + assert!(!client_sibling.service_cancel_token().is_cancelled()); + assert!(!server_sibling.service_cancel_token().is_cancelled()); + assert!(fixture.connection.close_reason().is_none()); + assert!(timeout(TEST_TIMEOUT, writes).await??.is_err()); + + let (mut replacement_client, mut replacement_server) = fixture.sessions().await?; + assert_eq!(replacement_client.conn_id, client.conn_id); + assert_ne!(replacement_client.id, client.id); + exchange(&mut replacement_client, &mut replacement_server).await?; + let ping = frame(1, 19, 64); + timeout(TEST_TIMEOUT, sibling_send.send(ping.clone())).await??; + assert_eq!( + timeout(TEST_TIMEOUT, sibling_recv.recv()).await?, + Some(ping) + ); + fixture.close().await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn negotiated_pair_reopens_as_one_session_on_the_same_connection() -> Result<(), BoxError> { + let _guard = zakura_test::init(); + let mut fixture = Fixture::start().await?; + let (mut client, mut server) = fixture.sessions().await?; + let conn_id = client.conn_id; + for _ in 0..3 { + exchange(&mut client, &mut server).await?; + let old_id = client.id; + client.cancel.cancel(); + timeout(TEST_TIMEOUT, server.cancel.cancelled()).await?; + assert!(!client.connection_cancel.is_cancelled()); + assert!(!server.connection_cancel.is_cancelled()); + (client, server) = fixture.sessions().await?; + assert_ne!(client.id, old_id); + assert_eq!(client.conn_id, conn_id); + } + exchange(&mut client, &mut server).await?; + fixture.close().await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_backpressure_survives_write_timeout_and_pair_cancellation() -> Result<(), BoxError> +{ + let _guard = zakura_test::init(); + let mut fixture = Fixture::start().await?; + let (mut client, server) = fixture.sessions().await?; + let sender = client.request_send.clone(); + // Exceed both receive and send windows while the request consumer is paused. + // This custom service uses larger frames to reach the same transport state + // without encoding a million tiny GetBlocks requests. + let mut writes = AbortOnDropHandle::new(tokio::spawn(async move { + for _ in 0..80 { + sender.send(frame(1, 17, 1024 * 1024)).await?; + } + Ok::<_, mpsc::error::SendError>(()) + })); + let started = Instant::now(); + while started.elapsed() < OUTBOUND_STREAM_WRITE_TIMEOUT + Duration::from_secs(1) { + let response = frame(2, 43, 64); + timeout(TEST_TIMEOUT, server.data_send.send(response.clone())).await??; + assert_eq!( + timeout(TEST_TIMEOUT, client.data_recv.recv()).await?, + Some(response) + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + !writes.is_finished(), + "request writes are still held by flow control" + ); + assert!( + !client.cancel.is_cancelled(), + "useful responses keep the pair valid" + ); + client.cancel.cancel(); + assert!(timeout(TEST_TIMEOUT, &mut writes).await??.is_err()); + timeout(TEST_TIMEOUT, server.cancel.cancelled()).await?; + assert!(!client.connection_cancel.is_cancelled()); + let (mut replacement_client, mut replacement_server) = fixture.sessions().await?; + exchange(&mut replacement_client, &mut replacement_server).await?; + fixture.close().await +} + +/// Capture only the connection so tests can send exact setup bytes. +#[derive(Debug)] +struct RawConnection(mpsc::Sender); + +impl ProtocolHandler for RawConnection { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + self.0 + .send(connection.clone()) + .await + .map_err(AcceptError::from_err)?; + connection.closed().await; + Ok(()) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn incomplete_pairs_expire_and_mismatched_roles_release_stream_permits( +) -> Result<(), BoxError> { + let _guard = zakura_test::init(); + let local = ZakuraLocalLimits::from_config(&Config::default()); + let server = LocalEndpointFactory::with_transport_config(local.transport_config()) + .endpoint(93201) + .await?; + let client = LocalEndpointFactory::with_transport_config(local.transport_config()) + .endpoint(93202) + .await?; + let (accepted, mut connections) = mpsc::channel(1); + let router = Router::builder(server) + .accept(ALPN, RawConnection(accepted)) + .spawn(); + let connection = timeout( + TEST_TIMEOUT, + client.connect( + LocalEndpointFactory::node_addr(router.endpoint()).await, + ALPN, + ), + ) + .await??; + let remote = timeout(TEST_TIMEOUT, connections.recv()) + .await? + .ok_or("missing connection")?; + let permits = Arc::new(Semaphore::new(2)); + let (sessions, _sessions_rx) = mpsc::channel(1); + let handler = ZakuraProtocolHandler::new_with_registry( + ZakuraSupervisorHandle::new(16), + Network::Mainnet, + ZakuraHandshakeConfig::for_network(&Network::Mainnet), + local.clone(), + Arc::new(ServiceRegistry::new(vec![Arc::new(PairService { + fail_first_reservation: std::sync::atomic::AtomicBool::new(false), + sessions, + })])?), + ); + let mut limits = local.clamp(&local.initial_limits()); + limits.prelude_timeout = Duration::from_millis(100); + let peer = ZakuraPeerId::new(client.node_id().as_bytes().to_vec())?; + let (freshness, _freshness_rx) = watch::channel(Instant::now()); + let cancel = CancellationToken::new(); + let mut pending = PendingOrderedPairs::default(); + let mut workers = JoinSet::new(); + let mut buckets = MessageRateBuckets::new(); + let mut open_limiter = TokenBucket::new(100); + let (exits, _exit_rx) = mpsc::unbounded_channel(); + let mut admission = StreamAdmission { + direction: ServicePeerDirection::Inbound, + conn: ZakuraConnTrace::without_peer(1), + peer_id: &peer, + stream_sem: &permits, + open_limiter: &mut open_limiter, + message_buckets: &mut buckets, + workers: &mut workers, + limits, + accepted_capabilities: DATA.capability, + connection_token: cancel.clone(), + close_cause: CloseCause::new(), + freshness_tx: freshness, + }; + let mut offers = Vec::new(); + // The request role may arrive first. It must not reach the service alone. + for (kind, id, should_expire) in [ + (REQUESTS.kind, 1u64, true), + (DATA.kind, 2, false), + (REQUESTS.kind, 3, false), + ] { + let (mut send, recv) = connection.open_bi().await?; + let mut bytes = StreamPrelude { + magic: STREAM_PRELUDE_MAGIC, + stream_kind: kind, + stream_version: 1, + request_id: None, + max_frame_bytes: DATA.frame_cap, + } + .encode()?; + bytes.extend_from_slice(&id.to_le_bytes()); + timeout(TEST_TIMEOUT, send.write_all(&bytes)).await??; + offers.push((send, recv)); + let (send, recv) = timeout(TEST_TIMEOUT, remote.accept_bi()).await??; + assert!(handler + .admit_bi_stream(send, recv, &mut admission, 2, exits.clone(), &mut pending) + .await + .is_none()); + if should_expire { + assert_eq!(permits.available_permits(), 1); + let deadline = pending.deadline().expect("incomplete pair owns a deadline"); + tokio::time::sleep_until(deadline).await; + pending.expire(Instant::now()); + assert_eq!(permits.available_permits(), 2); + assert!( + !cancel.is_cancelled(), + "incomplete setup is retired locally" + ); + } + } + assert!( + cancel.is_cancelled(), + "different pair identifiers cannot be combined" + ); + assert_eq!(permits.available_permits(), 2); + assert!(pending.deadline().is_none()); + assert!( + admission.workers.is_empty(), + "incomplete and mismatched pairs never activate workers" + ); + drop(offers); + connection.close(0u32.into(), b"done"); + timeout(TEST_TIMEOUT, client.close()).await?; + timeout(TEST_TIMEOUT, router.shutdown()).await??; + Ok(()) +} + +#[tokio::test] +async fn initial_pair_capacity_race_preserves_the_connection() -> Result<(), BoxError> { + let mut fixture = Fixture::start_with_reservation_race(true).await?; + let mut client_sibling = timeout(TEST_TIMEOUT, fixture.client_siblings.recv()) + .await? + .ok_or("missing client sibling")?; + let mut server_sibling = timeout(TEST_TIMEOUT, fixture.server_siblings.recv()) + .await? + .ok_or("missing server sibling")?; + let (mut recv, _) = client_sibling.take_stream(SIBLING.kind).unwrap(); + let (_, send) = server_sibling.take_stream(SIBLING.kind).unwrap(); + let ping = frame(1, 19, 64); + timeout(TEST_TIMEOUT, send.send(ping.clone())).await??; + assert_eq!(timeout(TEST_TIMEOUT, recv.recv()).await?, Some(ping)); + let (mut client, mut server) = fixture.sessions().await?; + exchange(&mut client, &mut server).await?; + assert!(fixture.connection.close_reason().is_none()); + fixture.close().await +} diff --git a/crates/zakura-network/src/zakura/handler/tests/connection.rs b/crates/zakura-network/src/zakura/handler/tests/connection.rs new file mode 100644 index 0000000000..c70ef0a2d8 --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/tests/connection.rs @@ -0,0 +1,62 @@ +//! Native connection setup shared by paired transport fixtures. + +use super::*; +use tokio_util::task::AbortOnDropHandle; + +pub(in crate::zakura::handler) async fn connect_and_serve( + client: &Endpoint, + address: NodeAddr, + handler: ZakuraProtocolHandler, + limits: ZakuraLocalLimits, + alpn: &[u8], + deadline: Duration, +) -> Result< + ( + Connection, + AbortOnDropHandle>, + ), + BoxError, +> { + let remote_id = address.node_id; + let local_id = client.node_id(); + let connection = timeout(deadline, client.connect(address, alpn)).await??; + let local_peer = ZakuraPeerId::new(local_id.as_bytes().to_vec())?; + let remote_peer = ZakuraPeerId::new(remote_id.as_bytes().to_vec())?; + let conn = ZakuraConnTrace::without_peer(1); + let negotiated = timeout( + deadline, + run_native_initiator_handshake( + &connection, + &limits, + &handler.current_handshake_config(), + &local_peer, + &ZakuraTrace::noop(), + &conn, + ), + ) + .await??; + let serving_connection = connection.clone(); + let transport = AbortOnDropHandle::new(tokio::spawn(async move { + handler + .register_and_serve( + serving_connection, + remote_peer, + None, + ConnectionServeContext { + limits: limits.clamp(&negotiated.limits), + accepted_capabilities: negotiated.accepted_capabilities, + role: "initiator", + direction: ServicePeerDirection::Outbound, + transcript_hash: native_connection_transcript_hash( + ServicePeerDirection::Outbound, + &local_id, + &remote_id, + ), + i_open_collision_winner: i_open_collision_winner(&local_id, &remote_id), + conn, + }, + ) + .await + })); + Ok((connection, transport)) +} diff --git a/crates/zakura-network/src/zakura/handler/tests/paired_block_sync.rs b/crates/zakura-network/src/zakura/handler/tests/paired_block_sync.rs new file mode 100644 index 0000000000..abc45a4f08 --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/tests/paired_block_sync.rs @@ -0,0 +1,952 @@ +//! Matched one-way downloads through negotiation, paired workers, both peer +//! routines, sequential serving, and the download sequencer. Storage and final +//! consensus verification are deterministic fixtures; transport is real QUIC. + +#![allow( + clippy::print_stderr, + reason = "record measured completion times for the transport gate" +)] + +use super::*; +use crate::zakura::block_sync::{ + spawn_block_sync_reactor, BlockApplyOutcome, BlockRangeRead, BlockRangeReadResult, + BlockRangeSource, BlockSizeEstimate, BlockSyncAction, BlockSyncBlockMeta, BlockSyncEvent, + BlockSyncFrontiers, BlockSyncHandle, ZakuraBlockSyncConfig, +}; +use futures::future::BoxFuture; +use tokio_util::task::AbortOnDropHandle; +use zakura_chain::serialization::ZcashSerialize; + +const ALPN: &[u8] = b"/zakura/test/paired-block-download/1"; +const COUNT: u32 = 32; +const DEADLINE: Duration = Duration::from_secs(30); +const LOSS_DEADLINE: Duration = Duration::from_secs(240); + +mod gate; +mod link; +mod paused; + +struct Workload { + peer_limit: Option, + pressure: bool, + impaired: bool, + rounds: u32, + paused_siblings: u16, + resume_after: Option, + recover_on_fresh_peer: bool, +} + +impl Default for Workload { + fn default() -> Self { + Self { + peer_limit: None, + pressure: false, + impaired: false, + rounds: 1, + paused_siblings: 0, + resume_after: None, + recover_on_fresh_peer: false, + } + } +} + +#[derive(Debug)] +struct MemorySource(Arc>>); + +impl BlockRangeSource for MemorySource { + fn read_range( + &self, + request: BlockRangeRead, + ) -> BoxFuture<'static, Result> { + let blocks = self.0.clone(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + let (start, count, cap, lease) = request.into_parts(); + assert!(lease.try_start()); + let mut bytes = 0usize; + let mut result = Vec::new(); + for block in blocks + .iter() + .skip(usize::try_from(start.0.saturating_sub(1)).unwrap()) + .take(usize::try_from(count).unwrap()) + { + let size = block.zcash_serialized_size(); + if lease.is_cancelled() || bytes + size > usize::try_from(cap).unwrap() { + break; + } + bytes += size; + result.push((block.coinbase_height().unwrap(), block.clone(), size)); + } + BlockRangeReadResult::new(result, lease) + }) + .await + .map_err(Into::into) + }) + } +} + +struct ConnectedPeer { + connection: Connection, + _task: AbortOnDropHandle>, +} + +impl Drop for ConnectedPeer { + fn drop(&mut self) { + self.connection.close(0u32.into(), b"test finished"); + } +} + +async fn connect_download_peer( + client: &Endpoint, + address: NodeAddr, + handler: ZakuraProtocolHandler, + limits: ZakuraLocalLimits, +) -> Result { + let (connection, task) = + connection::connect_and_serve(client, address, handler, limits, ALPN, DEADLINE).await?; + Ok(ConnectedPeer { + connection, + _task: task, + }) +} + +struct Node { + handle: BlockSyncHandle, + service: Arc, + cancel: CancellationToken, + _tasks: Vec>, + _tip: watch::Sender<(block::Height, block::Hash)>, + received: watch::Receiver, + _capture: Option, +} + +impl Node { + fn new(blocks: Arc>>, serving: bool) -> Self { + Self::with_peer_limit(blocks, serving, None) + } + + fn with_peer_limit( + blocks: Arc>>, + serving: bool, + peer_limit: Option, + ) -> Self { + Self::with_range_source(blocks, serving, peer_limit, None, None) + } + + fn with_range_source( + blocks: Arc>>, + serving: bool, + peer_limit: Option, + source: Option>, + cooldown: Option, + ) -> Self { + let genesis = Block::zcash_deserialize(&BLOCK_MAINNET_GENESIS_BYTES[..]) + .unwrap() + .hash(); + let tip = blocks.last().unwrap(); + let verified = if serving { + (block::Height(COUNT), tip.hash()) + } else { + (block::Height(0), genesis) + }; + let (tip_tx, tip_rx) = watch::channel(verified); + let mut config = ZakuraBlockSyncConfig::default(); + if let Some(cooldown) = cooldown { + config.no_progress_peer_cooldown = cooldown; + } + config.peer_limits.inbound_queue_depth = 8; + config.peer_limits.outbound_queue_depth = 8; + if let Some(limit) = peer_limit { + config.peer_limits.max_inbound_peers = limit; + config.peer_limits.max_outbound_peers = limit; + } + let cancel = CancellationToken::new(); + let mut startup = BlockSyncStartup::new( + BlockSyncFrontiers { + finalized_height: verified.0, + verified_block_tip: verified.0, + verified_block_hash: verified.1, + }, + verified, + tip_rx, + config.clone(), + ); + startup.shutdown = cancel.clone(); + let capture = std::env::var_os("ZAKURA_DOWNLOAD_TRACE").map(|_| { + let mut capture = crate::zakura::testkit::TraceCapture::for_test(if serving { + "paired-gate-server" + } else { + "paired-gate-downloader" + }) + .unwrap(); + startup.trace = ZakuraTrace::new( + capture.tracer(), + if serving { "server" } else { "downloader" }, + ); + eprintln!("download trace: {}", capture.path().display()); + capture + }); + let (handle, mut actions, reactor) = spawn_block_sync_reactor(startup); + let handle = handle + .with_range_source(source.unwrap_or_else(|| Arc::new(MemorySource(blocks.clone())))); + let service = BlockSyncService::new_with_handle(config, handle.clone()); + let (progress_tx, received) = watch::channel(0); + let driver_handle = handle.clone(); + let driver = tokio::spawn(async move { + let mut completed = 0; + while let Some(action) = actions.recv().await { + match action { + BlockSyncAction::SubmitBlock { + owner, + source, + token, + block, + } => { + assert!(!serving, "only the downloader has matched body work"); + completed += 1; + let height = block.coinbase_height().unwrap(); + assert_eq!(height.0, (completed - 1) % COUNT + 1); + assert_eq!( + block.hash(), + blocks[usize::try_from(height.0 - 1).unwrap()].hash() + ); + let hash = block.hash(); + driver_handle + .send(BlockSyncEvent::BlockApplyFinished { + owner, + source, + token, + height, + hash, + outcome: BlockApplyOutcome::committed( + zakura_header_chain::VerifiedBodyEvidence { + hash, + evidence: zakura_header_chain::EvidenceId::from_digest( + [0xa5; 32], + ), + }, + ), + }) + .await + .unwrap(); + progress_tx.send_replace(completed); + } + BlockSyncAction::QueryNeededBlocks { .. } => {} + action => panic!("unexpected download action: {action:?}"), + } + } + }); + Self { + handle, + service: Arc::new(service), + cancel, + _tasks: vec![ + AbortOnDropHandle::new(reactor), + AbortOnDropHandle::new(driver), + ], + _tip: tip_tx, + received, + _capture: capture, + } + } +} + +impl Drop for Node { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + +fn blocks() -> Arc>> { + let template = super::serving_progress::large_block_template(); + let mut previous = Block::zcash_deserialize(&BLOCK_MAINNET_GENESIS_BYTES[..]) + .unwrap() + .hash(); + Arc::new( + (1..=COUNT) + .map(|height| { + let mut block = + (*super::serving_progress::block_at_height(&template, block::Height(height))) + .clone(); + let mut header = *block.header; + header.previous_block_hash = previous; + header.merkle_root = block.transactions.iter().collect(); + block.header = Arc::new(header); + previous = block.hash(); + Arc::new(block) + }) + .collect(), + ) +} + +async fn download(pressure: bool) -> Result { + download_over_link(pressure, false).await +} + +async fn download_over_link(pressure: bool, impaired: bool) -> Result { + download_rounds(pressure, impaired, 1).await +} + +async fn download_rounds( + pressure: bool, + impaired: bool, + rounds: u32, +) -> Result { + run_download(Workload { + pressure, + impaired, + rounds, + ..Workload::default() + }) + .await +} + +async fn run_download(workload: Workload) -> Result { + let Workload { + peer_limit, + pressure, + impaired, + rounds, + paused_siblings, + resume_after, + recover_on_fresh_peer, + } = workload; + let completion_deadline = if impaired { LOSS_DEADLINE } else { DEADLINE }; + let blocks = blocks(); + let mut downloader = Node::with_peer_limit(blocks.clone(), false, peer_limit); + let server_node = Node::with_peer_limit(blocks.clone(), true, peer_limit); + let initial_client_slots = downloader.service.available_session_slots_for_test(); + let initial_server_slots = server_node.service.available_session_slots_for_test(); + let mut serving_service = server_node.service.clone(); + let mut limits = ZakuraLocalLimits::from_config(&Config::default()); + // Let the declared request burst test serving pressure, not rate rejection. + limits.message_rate_per_second = 40_000; + let server = LocalEndpointFactory::with_transport_config(limits.transport_config()) + .endpoint(94101) + .await?; + let client = LocalEndpointFactory::with_transport_config(limits.transport_config()) + .endpoint(94102) + .await?; + let handler = + |service: Arc, sibling: Arc, endpoint| { + let mut services: Vec> = vec![service]; + if paused_siblings > 0 { + services.push(sibling); + } + ZakuraProtocolHandler::new_with_registry( + ZakuraSupervisorHandle::new(16), + Network::Mainnet, + ZakuraHandshakeConfig::for_network(&Network::Mainnet), + limits.clone(), + Arc::new(ServiceRegistry::new(services).unwrap()), + ) + .with_endpoint(endpoint) + }; + let (server_sibling, mut server_siblings) = paused::PausedService::new(paused_siblings); + let (client_sibling, mut client_siblings) = paused::PausedService::new(paused_siblings); + let server_handler = handler(server_node.service.clone(), server_sibling, server.clone()); + let client_handler = handler(downloader.service.clone(), client_sibling, client.clone()); + let router = Router::builder(server).accept(ALPN, server_handler).spawn(); + let mut address = LocalEndpointFactory::node_addr(router.endpoint()).await; + let link = if impaired { + let server_address = *address + .direct_addresses() + .find(|address| address.is_ipv4()) + .unwrap(); + let link = link::ImpairedLink::new(server_address).await?; + address = NodeAddr::new(address.node_id).with_direct_addresses([link.address]); + Some(link) + } else { + None + }; + let remote_id = address.node_id; + let transport = + connect_download_peer(&client, address, client_handler.clone(), limits.clone()).await?; + let mut connection = transport.connection.clone(); + let mut recovery = None; + await_until("both block-sync sessions admitted", DEADLINE, || { + downloader.service.peer_count() == 1 && server_node.service.peer_count() == 1 + }) + .await?; + let mut paused_receivers = Vec::new(); + let mut paused_senders = Vec::new(); + let before_siblings = connection.stats().udp_rx.bytes; + for _ in 0..paused_siblings { + let sender = paused::PausedSession::receive(&mut server_siblings).await?; + let receiver = paused::PausedSession::receive(&mut client_siblings).await?; + sender.fill_window().await?; + paused_senders.push(sender); + paused_receivers.push(receiver); + } + if paused_siblings > 0 { + await_until( + "sibling window traffic reaches the receiver", + completion_deadline, + || { + connection + .stats() + .udp_rx + .bytes + .saturating_sub(before_siblings) + >= u64::from(paused_siblings) * u64::from(DEFAULT_ZAKURA_STREAM_RECEIVE_WINDOW) + }, + ) + .await?; + } + let mut resume = None; + let capacity = pressure.then(|| downloader.handle.hold_serving_capacity_for_test()); + let mut total = Duration::ZERO; + for round in 0..rounds { + let mut client_session = downloader + .service + .sessions_for_transport_test() + .pop() + .unwrap(); + let mut server_session = server_node + .service + .sessions_for_transport_test() + .pop() + .unwrap(); + if pressure { + // All requests precede the download. Their receiver waits for capacity; + // only A's download below must complete. + timeout(DEADLINE, async { + for _ in 0..32_000 { + server_session + .2 + .send( + crate::zakura::block_sync::BlockSyncMessage::GetBlocks { + start_height: block::Height(1), + count: 1, + } + .encode_frame()?, + ) + .await?; + } + Ok::<_, BoxError>(()) + }) + .await + .map_err(|_| std::io::Error::other("request pressure setup timed out"))??; + } + let mut start = Instant::now(); + downloader + ._tip + .send_replace((block::Height(COUNT), blocks.last().unwrap().hash())); + downloader + .handle + .send(BlockSyncEvent::NeededBlocks( + blocks + .iter() + .map(|block| BlockSyncBlockMeta { + height: block.coinbase_height().unwrap(), + hash: block.hash(), + size: BlockSizeEstimate::Advertised( + u32::try_from(block.zcash_serialized_size()).unwrap(), + ), + }) + .collect(), + )) + .await?; + if let Some(delay) = resume_after { + assert_eq!(rounds, 1, "transient saturation resumes once"); + await_until("a request is outstanding before resuming", DEADLINE, || { + downloader.handle.outstanding_requests_for_test() > 0 + }) + .await?; + let receivers = std::mem::take(&mut paused_receivers); + let progress = downloader.received.clone(); + resume = Some(AbortOnDropHandle::new(tokio::spawn(async move { + tokio::time::sleep(delay).await; + assert_eq!( + *progress.borrow(), + 0, + "saturation must prevent a complete body before resuming" + ); + let _readers: Vec<_> = receivers + .into_iter() + .map(paused::PausedSession::resume) + .collect(); + std::future::pending::<()>().await; + }))); + } + if recover_on_fresh_peer { + assert!(!impaired && !pressure && rounds == 1 && paused_siblings == 2); + await_until( + "the original request arms block-progress liveness", + DEADLINE, + || downloader.handle.outstanding_requests_for_test() > 0, + ) + .await?; + await_until( + "an existing write or block-progress deadline retires the saturated pair", + Duration::from_secs(42), + || client_session.1.cancel_token().is_cancelled(), + ) + .await?; + assert_eq!(*downloader.received.borrow(), 0); + drop(client_session); + drop(server_session); + await_until( + "retiring workers and serving frames release their slots", + Duration::from_secs(10), + || { + downloader.service.peer_count() == 0 + && server_node.service.peer_count() == 0 + && downloader.handle.outstanding_requests_for_test() == 0 + && downloader.handle.active_serving_requests_for_test() == 0 + && server_node.handle.active_serving_requests_for_test() == 0 + && downloader.service.available_session_slots_for_test() + == initial_client_slots + && server_node.service.available_session_slots_for_test() + == initial_server_slots + }, + ) + .await?; + eprintln!( + "saturated pair cleaned up after {:?}; connection {:?}", + start.elapsed(), + connection.close_reason() + ); + + let fresh_node = Node::new(blocks.clone(), true); + let fresh_endpoint = + LocalEndpointFactory::with_transport_config(limits.transport_config()) + .endpoint(94103) + .await?; + let (fresh_sibling, fresh_siblings) = paused::PausedService::new(paused_siblings); + let fresh_handler = handler( + fresh_node.service.clone(), + fresh_sibling, + fresh_endpoint.clone(), + ); + let fresh_router = Router::builder(fresh_endpoint) + .accept(ALPN, fresh_handler) + .spawn(); + start = Instant::now(); + let fresh_transport = connect_download_peer( + &client, + LocalEndpointFactory::node_addr(fresh_router.endpoint()).await, + client_handler.clone(), + limits.clone(), + ) + .await?; + connection = fresh_transport.connection.clone(); + await_until( + "a fresh peer admits the returned download work", + DEADLINE, + || downloader.service.peer_count() == 1 && fresh_node.service.peer_count() == 1, + ) + .await?; + client_session = downloader + .service + .sessions_for_transport_test() + .pop() + .unwrap(); + server_session = fresh_node + .service + .sessions_for_transport_test() + .pop() + .unwrap(); + serving_service = fresh_node.service.clone(); + recovery = Some((fresh_node, fresh_router, fresh_transport, fresh_siblings)); + } + timeout(completion_deadline, async { + while *downloader.received.borrow_and_update() != (round + 1) * COUNT { + downloader.received.changed().await?; + if impaired { + eprintln!( + "matched {}/{} blocks after {:?}", + *downloader.received.borrow(), + (round + 1) * COUNT, + start.elapsed() + ); + } + } + Ok::<_, BoxError>(()) + }) + .await + .map_err(|_| { + std::io::Error::other(format!( + "download deadline: {}/{} blocks, {} outstanding; link {:?}; transport {:?}", + *downloader.received.borrow(), + COUNT, + downloader.handle.outstanding_requests_for_test(), + link.as_ref().map(link::ImpairedLink::counters), + connection.stats(), + )) + })??; + await_until( + "all matched requests consume their ending", + DEADLINE, + || downloader.handle.outstanding_requests_for_test() == 0, + ) + .await?; + let elapsed = start.elapsed(); + total += elapsed; + if rounds > 1 { + eprintln!("matched round {}/{}: {:?}", round + 1, rounds, elapsed); + } + assert!(!client_session.1.cancel_token().is_cancelled()); + assert!(!server_session.1.cancel_token().is_cancelled()); + assert_eq!( + downloader.service.sessions_for_transport_test()[0].0, + client_session.0 + ); + assert_eq!( + serving_service.sessions_for_transport_test()[0].0, + server_session.0 + ); + assert!(connection.close_reason().is_none()); + if let Some(link) = &link { + let useful = blocks + .iter() + .map(|block| u64::try_from(block.zcash_serialized_size()).unwrap()) + .sum(); + link.verify_path(&client, remote_id, useful); + } + if round + 1 < rounds { + let previous_client = client_session.0; + let previous_server = server_session.0; + client_session.1.cancel_token().cancel(); + drop(client_session); + drop(server_session); + await_until( + "replacement pair admitted on the same connection", + DEADLINE, + || { + downloader + .service + .sessions_for_transport_test() + .first() + .is_some_and(|session| session.0 != previous_client) + && server_node + .service + .sessions_for_transport_test() + .first() + .is_some_and(|session| session.0 != previous_server) + }, + ) + .await?; + let genesis = Block::zcash_deserialize(&BLOCK_MAINNET_GENESIS_BYTES[..]) + .unwrap() + .hash(); + downloader + .handle + .send(BlockSyncEvent::ChainTipReset(BlockSyncFrontiers { + finalized_height: block::Height(0), + verified_block_tip: block::Height(0), + verified_block_hash: genesis, + })) + .await?; + } + } + drop(capacity); + drop(resume); + drop(paused_receivers); + drop(paused_senders); + downloader.cancel.cancel(); + server_node.cancel.cancel(); + connection.close(0u32.into(), b"done"); + drop(transport); + if let Some((fresh_node, fresh_router, fresh_transport, _siblings)) = recovery { + fresh_node.cancel.cancel(); + drop(fresh_transport); + fresh_router.shutdown().await?; + } + client.close().await; + router.shutdown().await?; + Ok(total) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn incoming_pair_uses_the_last_reserved_session_slot() -> Result<(), BoxError> { + run_download(Workload { + peer_limit: Some(1), + ..Workload::default() + }) + .await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn paired_download_matches_every_block_and_ending() -> Result<(), BoxError> { + eprintln!("paired matched download: {:?}", download(false).await?); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn paired_download_completes_while_serving_capacity_is_full() -> Result<(), BoxError> { + eprintln!( + "paired matched download under 32000-request pressure: {:?}", + download(true).await? + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn paired_download_completes_with_request_pressure_and_a_paused_service( +) -> Result<(), BoxError> { + eprintln!( + "paired matched download with pressure and paused service: {:?}", + run_download(Workload { + pressure: true, + paused_siblings: 1, + ..Workload::default() + }) + .await? + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn transient_full_connection_credit_resumes_the_original_matched_download( +) -> Result<(), BoxError> { + eprintln!( + "paired matched download after transient connection saturation: {:?}", + run_download(Workload { + paused_siblings: 2, + resume_after: Some(Duration::from_secs(1)), + ..Workload::default() + }) + .await? + ); + Ok(()) +} + +#[tokio::test] +async fn reset_during_a_frame_is_stream_local_but_truncated_fin_is_invalid() -> Result<(), BoxError> +{ + let server = LocalEndpointFactory::new().endpoint(94105).await?; + let (connection_tx, _connections) = mpsc::channel(2); + let (stream_tx, mut streams) = mpsc::channel(2); + let router = Router::builder(server) + .accept( + ALPN, + CaptureConnection { + connection_tx, + stream_tx, + }, + ) + .spawn(); + let client = LocalEndpointFactory::new().endpoint(94106).await?; + let address = LocalEndpointFactory::node_addr(router.endpoint()).await; + let connection = timeout(DEADLINE, client.connect(address, ALPN)).await??; + for reset in [true, false] { + let (mut send, _recv) = connection.open_bi().await?; + send.write_all(&[42]).await?; + let (_, mut recv) = timeout(DEADLINE, streams.recv()).await?.unwrap(); + let mut first = [0]; + timeout(DEADLINE, recv.read_exact(&mut first)).await??; + assert_eq!(first, [42], "the peer consumed the beginning of this frame"); + if reset { + send.reset(0u32.into())?; + } else { + send.finish()?; + } + let result = read_frame_payload(&mut recv, &mut [0; 16], DEADLINE).await; + if reset { + assert!(matches!(result, Err(ZakuraHandlerError::Closed))); + } else { + assert!(result.is_err() && !matches!(result, Err(ZakuraHandlerError::Closed))); + } + assert!(connection.close_reason().is_none()); + } + connection.close(0u32.into(), b"done"); + client.close().await; + router.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn paired_roles_reject_wrong_messages_before_reading_payloads() -> Result<(), BoxError> { + let service = BlockSyncService::new(ZakuraBlockSyncConfig::default()); + let pair = service.ordered_stream_pair(service.streams()[0]).unwrap(); + let server = LocalEndpointFactory::new().endpoint(94103).await?; + let (connection_tx, _connections) = mpsc::channel(4); + let (stream_tx, mut streams) = mpsc::channel(4); + let router = Router::builder(server) + .accept( + ALPN, + CaptureConnection { + connection_tx, + stream_tx, + }, + ) + .spawn(); + let client = LocalEndpointFactory::new().endpoint(94104).await?; + let address = LocalEndpointFactory::node_addr(router.endpoint()).await; + for (role, message) in [ + (pair.requests, 1u16), + (pair.requests, 3), + (pair.data, 2), + (pair.data, 99), + ] { + let connection = timeout(DEADLINE, client.connect(address.clone(), ALPN)).await??; + let (mut send, _recv) = connection.open_bi().await?; + let mut header = Vec::new(); + header.extend_from_slice(&message.to_le_bytes()); + header.extend_from_slice(&0u16.to_le_bytes()); + header.extend_from_slice(&100u32.to_le_bytes()); + send.write_all(&header).await?; + let (_, mut recv) = timeout(DEADLINE, streams.recv()).await?.unwrap(); + assert!( + matches!(timeout(Duration::from_secs(1), read_frame_with_types( + &mut recv, role.frame_cap, service.message_payload_limits(role), service.message_types(role), + Duration::from_secs(5), None, + )).await?, Err(ZakuraHandlerError::InvalidMessageType(kind)) if kind == message) + ); + connection.close(0u32.into(), b"checked"); + } + client.close().await; + router.shutdown().await?; + Ok(()) +} + +#[derive(Debug)] +struct StalledSource(Arc); +impl BlockRangeSource for StalledSource { + fn read_range( + &self, + request: BlockRangeRead, + ) -> BoxFuture<'static, Result> { + let seen = self.0.clone(); + Box::pin(async move { + let (_, _, _, lease) = request.into_parts(); + assert!(lease.try_start()); + seen.notify_one(); + std::future::pending::<()>().await; + drop(lease); + unreachable!() + }) + } +} + +#[tokio::test] +async fn remote_pair_reset_with_unanswered_work_preserves_no_progress_policy( +) -> Result<(), BoxError> { + let blocks = blocks(); + let downloader = Node::with_range_source( + blocks.clone(), + false, + None, + None, + Some(Duration::from_secs(2)), + ); + let seen = Arc::new(tokio::sync::Notify::new()); + let server_node = Node::with_range_source( + blocks.clone(), + true, + None, + Some(Arc::new(StalledSource(seen.clone()))), + None, + ); + let limits = ZakuraLocalLimits::from_config(&Config::default()); + let server = LocalEndpointFactory::with_transport_config(limits.transport_config()) + .endpoint(94301) + .await?; + let client = LocalEndpointFactory::with_transport_config(limits.transport_config()) + .endpoint(94302) + .await?; + let remote_peer = ZakuraPeerId::new(server.node_id().as_bytes().to_vec())?; + let handler = |service: Arc, endpoint| { + ZakuraProtocolHandler::new_with_registry( + ZakuraSupervisorHandle::new(16), + Network::Mainnet, + ZakuraHandshakeConfig::for_network(&Network::Mainnet), + limits.clone(), + Arc::new(ServiceRegistry::new(vec![service]).unwrap()), + ) + .with_endpoint(endpoint) + }; + let server_handler = handler(server_node.service.clone(), server.clone()); + let client_handler = handler(downloader.service.clone(), client.clone()); + let router = Router::builder(server).accept(ALPN, server_handler).spawn(); + let mut transport = connect_download_peer( + &client, + LocalEndpointFactory::node_addr(router.endpoint()).await, + client_handler, + limits.clone(), + ) + .await?; + await_until("sessions admitted", DEADLINE, || { + downloader.service.peer_count() == 1 && server_node.service.peer_count() == 1 + }) + .await?; + downloader + ._tip + .send_replace((block::Height(COUNT), blocks.last().unwrap().hash())); + downloader + .handle + .send(BlockSyncEvent::NeededBlocks( + blocks + .iter() + .map(|block| BlockSyncBlockMeta { + height: block.coinbase_height().unwrap(), + hash: block.hash(), + size: BlockSizeEstimate::Advertised( + u32::try_from(block.zcash_serialized_size()).unwrap(), + ), + }) + .collect(), + )) + .await?; + timeout(DEADLINE, seen.notified()) + .await + .expect("server sees initial request"); + assert!(downloader.handle.outstanding_requests_for_test() > 0); + // Local retirement returns work without charging the remote peer a stall. + downloader + .service + .sessions_for_transport_test() + .pop() + .unwrap() + .1 + .cancel_token() + .cancel(); + timeout(DEADLINE, seen.notified()) + .await + .expect("local cancellation permits another request"); + assert!(!downloader.service.is_peer_parked_for_test(&remote_peer)); + assert!(downloader.handle.outstanding_requests_for_test() > 0); + server_node + .service + .sessions_for_transport_test() + .pop() + .unwrap() + .1 + .cancel_token() + .cancel(); + await_until("remote reset parks unanswered download", DEADLINE, || { + downloader.service.is_peer_parked_for_test(&remote_peer) + }) + .await?; + assert!(transport.connection.close_reason().is_none()); + assert!( + timeout(Duration::from_secs(1), seen.notified()) + .await + .is_err(), + "cooldown must prevent new requests" + ); + timeout(DEADLINE, seen.notified()) + .await + .expect("cooldown permits one readmission"); + server_node + .service + .sessions_for_transport_test() + .pop() + .unwrap() + .1 + .cancel_token() + .cancel(); + // This fixture keeps a QUIC clone after register_and_serve returns. The + // production caller closes it when the cancelled connection handler exits. + timeout(DEADLINE, &mut transport._task) + .await + .expect("repeated remote reset ends the connection handler")??; + assert_eq!(downloader.service.peer_count(), 0); + assert_eq!(*downloader.received.borrow(), 0); + drop(transport); + client.close().await; + router.shutdown().await?; + Ok(()) +} diff --git a/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/gate.rs b/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/gate.rs new file mode 100644 index 0000000000..00a5126dfc --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/gate.rs @@ -0,0 +1,87 @@ +//! Standalone transport acceptance measurements. These are excluded from ordinary CI +//! even when that lane runs ignored tests. + +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "standalone 240-second impaired-link activation gate"] +async fn paired_download_completes_with_request_pressure_and_packet_loss() -> Result<(), BoxError> { + eprintln!( + "paired matched download, 32000 requests, 50ms RTT, 1% loss: {:?}", + download_over_link(true, true).await? + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "standalone combined impairment and paused-service activation gate"] +async fn paired_download_completes_with_request_pressure_paused_service_and_loss( +) -> Result<(), BoxError> { + let _guard = zakura_test::init(); + eprintln!( + "paired matched download with pressure, paused service, and loss: {:?}", + run_download(Workload { + pressure: true, + impaired: true, + paused_siblings: 1, + ..Workload::default() + }) + .await? + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "standalone saturation recovery gate using the default 32-second liveness deadline"] +async fn sustained_saturation_cleans_up_and_retries_on_a_fresh_peer() -> Result<(), BoxError> { + let _guard = zakura_test::init(); + eprintln!( + "matched retry on a fresh peer: {:?}", + run_download(Workload { + paused_siblings: 2, + recover_on_fresh_peer: true, + ..Workload::default() + }) + .await? + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "standalone repeated saturation, cleanup, and matched retry measurement"] +async fn twenty_saturated_connections_release_capacity_and_complete_retries() -> Result<(), BoxError> +{ + let _guard = zakura_test::init(); + for round in 1..=20 { + let elapsed = run_download(Workload { + paused_siblings: 2, + recover_on_fresh_peer: true, + ..Workload::default() + }) + .await?; + eprintln!("matched saturation retry {round}/20: {elapsed:?}"); + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "transport acceptance gate: twenty matched downloads and real reopen backoff"] +async fn twenty_pair_reopens_complete_matched_downloads_under_request_pressure( +) -> Result<(), BoxError> { + eprintln!( + "twenty matched downloads under request pressure: {:?}", + download_rounds(true, false, 20).await? + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "standalone twenty-round impaired-link activation gate"] +async fn twenty_pair_reopens_complete_matched_downloads_under_request_pressure_and_loss( +) -> Result<(), BoxError> { + eprintln!( + "twenty matched downloads under request pressure and loss: {:?}", + download_rounds(true, true, 20).await? + ); + Ok(()) +} diff --git a/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/link.rs b/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/link.rs new file mode 100644 index 0000000000..6158b51919 --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/link.rs @@ -0,0 +1,108 @@ +//! Bounded UDP proxy for the real-QUIC acceptance tests. + +use super::*; +use std::{ + collections::VecDeque, + net::SocketAddr, + sync::atomic::{AtomicU64, Ordering}, +}; +use tokio::net::UdpSocket; + +const ONE_WAY_DELAY: Duration = Duration::from_millis(25); +const MAX_PENDING_PACKETS: usize = 4096; + +pub(super) struct ImpairedLink { + pub(super) address: SocketAddr, + response_bytes: Arc, + dropped: Arc, + _task: AbortOnDropHandle<()>, +} + +impl ImpairedLink { + pub(super) async fn new(server: SocketAddr) -> Result { + let front = UdpSocket::bind("127.0.0.1:0").await?; + let back = UdpSocket::bind("127.0.0.1:0").await?; + let address = front.local_addr()?; + let response_bytes = Arc::new(AtomicU64::new(0)); + let dropped = Arc::new(AtomicU64::new(0)); + let forwarded = response_bytes.clone(); + let lost = dropped.clone(); + let task = tokio::spawn(async move { + let mut client = None; + let mut from_client = [0; 65_536]; + let mut from_server = [0; 65_536]; + let mut queue: VecDeque<(Instant, bool, Vec)> = VecDeque::new(); + let mut client_packets = 0u64; + let mut server_packets = 0u64; + loop { + let deadline = queue + .front() + .map_or_else(|| Instant::now() + DEADLINE, |packet| packet.0); + tokio::select! { + biased; + () = tokio::time::sleep_until(deadline), if !queue.is_empty() => { + let (_, response, bytes) = queue.pop_front().unwrap(); + if response { + if let Some(client) = client { + timeout(DEADLINE, front.send_to(&bytes, client)).await.unwrap().unwrap(); + forwarded.fetch_add(u64::try_from(bytes.len()).unwrap(), Ordering::Relaxed); + } + } else { + timeout(DEADLINE, back.send_to(&bytes, server)).await.unwrap().unwrap(); + } + } + received = front.recv_from(&mut from_client), if queue.len() < MAX_PENDING_PACKETS => { + let (len, source) = received.unwrap(); + client = Some(source); + client_packets += 1; + // Deterministic 1% loss separately in each direction. + if client_packets.is_multiple_of(100) { + lost.fetch_add(1, Ordering::Relaxed); + } else { + queue.push_back((Instant::now() + ONE_WAY_DELAY, false, from_client[..len].to_vec())); + } + } + received = back.recv_from(&mut from_server), if queue.len() < MAX_PENDING_PACKETS => { + let (len, source) = received.unwrap(); + assert_eq!(source, server); + server_packets += 1; + if server_packets.is_multiple_of(100) { + lost.fetch_add(1, Ordering::Relaxed); + } else { + queue.push_back((Instant::now() + ONE_WAY_DELAY, true, from_server[..len].to_vec())); + } + } + } + } + }); + Ok(Self { + address, + response_bytes, + dropped, + _task: AbortOnDropHandle::new(task), + }) + } + + pub(super) fn verify_path(&self, client: &Endpoint, server: NodeId, useful_bytes: u64) { + assert_eq!( + client.remote_info(server).unwrap().conn_type, + ConnectionType::Direct(self.address), + "iroh must not migrate around the impaired link" + ); + assert!( + self.response_bytes.load(Ordering::Relaxed) >= useful_bytes, + "all useful response bytes crossed the proxy" + ); + assert!( + self.dropped.load(Ordering::Relaxed) > 0, + "the test exercised packet loss" + ); + } + + pub(super) fn counters(&self) -> (u64, u64) { + ( + self.response_bytes.load(Ordering::Relaxed), + self.dropped.load(Ordering::Relaxed), + ) + } +} diff --git a/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/paused.rs b/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/paused.rs new file mode 100644 index 0000000000..31b2fe8f68 --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/tests/paired_block_sync/paused.rs @@ -0,0 +1,118 @@ +//! A negotiated sibling service whose bounded consumer deliberately pauses. + +use super::*; + +const FRAME_BYTES: u32 = 16 * 1024; +const FIRST_KIND: u16 = 64; + +#[derive(Debug)] +pub(super) struct PausedService { + streams: Vec, + sessions: mpsc::Sender, +} + +impl PausedService { + pub(super) fn new(count: u16) -> (Arc, mpsc::Receiver) { + assert!(count <= 2); + let (sessions, receiver) = mpsc::channel(2); + let streams = (0..count) + .map(|index| Stream { + kind: FIRST_KIND + index, + version: 1, + frame_cap: FRAME_BYTES, + capability: 1 << 17, + mode: StreamMode::Ordered, + }) + .collect(); + (Arc::new(Self { streams, sessions }), receiver) + } +} + +impl Service for PausedService { + fn name(&self) -> &'static str { + "paused-test-service" + } + + fn streams(&self) -> &[Stream] { + &self.streams + } + + fn stream_queue_depths(&self, _: Stream) -> Option<(usize, usize)> { + Some((1, 1)) + } + + fn add_peer(&self, mut peer: Peer) { + let cancel = peer.service_cancel_token(); + for stream in &self.streams { + if let Some((recv, send)) = peer.take_stream(stream.kind) { + if self + .sessions + .try_send(PausedSession { + recv, + send, + cancel: cancel.clone(), + }) + .is_err() + { + cancel.cancel(); + } + } + } + } + + fn remove_peer(&self, _: &ZakuraPeerId, _: ZakuraConnId) {} +} + +#[derive(Debug)] +pub(super) struct PausedSession { + recv: FramedRecv, + send: FramedSend, + cancel: CancellationToken, +} + +impl PausedSession { + pub(super) async fn receive(receiver: &mut mpsc::Receiver) -> Result { + let session = timeout(DEADLINE, receiver.recv()) + .await? + .ok_or("sibling service closed")?; + Ok(session) + } + + pub(super) async fn fill_window(&self) -> Result<(), BoxError> { + let payload = vec![42; usize::try_from(FRAME_BYTES)? - FRAME_HEADER_BYTES]; + timeout(DEADLINE, async { + for _ in 0..DEFAULT_ZAKURA_STREAM_RECEIVE_WINDOW / FRAME_BYTES { + self.send + .send(Frame { + message_type: 1, + flags: 0, + payload: payload.clone(), + }) + .await?; + } + Ok::<_, BoxError>(()) + }) + .await??; + Ok(()) + } + + pub(super) fn resume(mut self) -> AbortOnDropHandle<()> { + AbortOnDropHandle::new(tokio::spawn(async move { + loop { + tokio::select! { + _ = self.cancel.cancelled() => break, + frame = self.recv.recv() => { + let Some(frame) = frame else { break; }; + assert!(frame.payload.iter().all(|byte| *byte == 42)); + } + } + } + })) + } +} + +impl Drop for PausedSession { + fn drop(&mut self) { + self.cancel.cancel(); + } +} diff --git a/crates/zakura-network/src/zakura/handler/tests/quic_progress.rs b/crates/zakura-network/src/zakura/handler/tests/quic_progress.rs new file mode 100644 index 0000000000..cd5b0508ba --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/tests/quic_progress.rs @@ -0,0 +1,83 @@ +//! Regression coverage for the pinned QUIC loss-recovery backport. + +use super::*; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bidirectional_transfers_exceed_flow_control_windows() -> Result<(), BoxError> { + let _guard = zakura_test::init(); + const ALPN: &[u8] = b"/zakura/testkit/quic-progress/0"; + // Larger than the default send, stream, and connection windows. Both sides + // must receive acknowledgements and new flow-control credit to finish. + const BYTES: usize = 64 * 1024 * 1024; + let limits = ZakuraLocalLimits::from_config(&Config::default()); + let server = LocalEndpointFactory::with_transport_config(limits.transport_config()) + .endpoint(92341) + .await?; + let client = LocalEndpointFactory::with_transport_config(limits.transport_config()) + .endpoint(92342) + .await?; + let (connection_tx, mut connection_rx) = mpsc::channel(1); + let (stream_tx, mut stream_rx) = mpsc::channel(1); + let router = Router::builder(server) + .accept( + ALPN, + CaptureConnection { + connection_tx, + stream_tx, + }, + ) + .spawn(); + let address = LocalEndpointFactory::node_addr(router.endpoint()).await; + let connection = timeout(Duration::from_secs(10), client.connect(address, ALPN)).await??; + let remote = timeout(Duration::from_secs(5), connection_rx.recv()) + .await? + .unwrap(); + let (mut send_a, recv_a) = connection.open_bi().await?; + // Make the stream visible to accept_bi before starting the bulk transfer. + send_a.write_all(&[42]).await?; + let (mut send_b, recv_b) = timeout(Duration::from_secs(5), stream_rx.recv()) + .await? + .unwrap(); + + let transfer = async { + tokio::try_join!( + async { + send_a.write_all(&vec![42; BYTES]).await?; + send_a.finish()?; + Ok::<_, BoxError>(()) + }, + async { + send_b.write_all(&vec![43; BYTES]).await?; + send_b.finish()?; + Ok::<_, BoxError>(()) + }, + drain_stream(recv_a, BYTES, 43), + drain_stream(recv_b, BYTES + 1, 42), + )?; + Ok::<_, BoxError>(()) + }; + timeout(Duration::from_secs(30), transfer) + .await + .unwrap_or_else(|_| { + panic!( + "QUIC transfer stalled: local={:?}; remote={:?}", + connection.stats(), + remote.stats() + ) + })?; + connection.close(0u32.into(), b"done"); + client.close().await; + router.shutdown().await?; + Ok(()) +} + +async fn drain_stream(mut recv: RecvStream, expected: usize, byte: u8) -> Result<(), BoxError> { + let mut buffer = vec![0; 64 * 1024]; + let mut received = 0; + while let Some(count) = recv.read(&mut buffer).await? { + assert!(buffer[..count].iter().all(|value| *value == byte)); + received += count; + assert!(received <= expected); + } + assert_eq!(received, expected); + Ok(()) +} diff --git a/crates/zakura-network/src/zakura/handler/tests/serving_progress.rs b/crates/zakura-network/src/zakura/handler/tests/serving_progress.rs new file mode 100644 index 0000000000..e7c0edb730 --- /dev/null +++ b/crates/zakura-network/src/zakura/handler/tests/serving_progress.rs @@ -0,0 +1,36 @@ +//! Large block fixtures shared by the matched QUIC download tests. + +use super::*; +use zakura_chain::{serialization::ZcashSerialize, transparent}; + +pub(super) fn large_block_template() -> Arc { + let mut block = + Block::zcash_deserialize(&zakura_test::vectors::BLOCK_MAINNET_1_BYTES[..]).unwrap(); + let transaction = block.transactions[0].clone(); + let tx_bytes = transaction.zcash_serialized_size(); + block.transactions = vec![transaction; 1_901_000 / tx_bytes]; + Arc::new(block) +} + +pub(super) fn block_at_height(template: &Arc, height: block::Height) -> Arc { + let mut block = template.as_ref().clone(); + let mut coinbase = block.transactions[0].as_ref().clone(); + let inputs = match &mut coinbase { + transaction::Transaction::V1 { inputs, .. } + | transaction::Transaction::V2 { inputs, .. } + | transaction::Transaction::V3 { inputs, .. } + | transaction::Transaction::V4 { inputs, .. } + | transaction::Transaction::V5 { inputs, .. } + | transaction::Transaction::V6 { inputs, .. } => inputs, + }; + let transparent::Input::Coinbase { + height: coinbase_height, + .. + } = &mut inputs[0] + else { + panic!("fixture has a coinbase input"); + }; + *coinbase_height = height; + block.transactions[0] = Arc::new(coinbase); + Arc::new(block) +} diff --git a/crates/zakura-network/src/zakura/regulation/mod.rs b/crates/zakura-network/src/zakura/regulation/mod.rs index bdfbd3b3bc..afd34019c3 100644 --- a/crates/zakura-network/src/zakura/regulation/mod.rs +++ b/crates/zakura-network/src/zakura/regulation/mod.rs @@ -1,27 +1,16 @@ -//! Reusable resource accounting for native Zakura services. +//! Shared admission and ownership for native Zakura message policies. //! -//! This facade provides the ownership mechanics shared by message-specific -//! policies. It does not decide what a message costs or what should happen -//! when capacity is unavailable. Those decisions stay with each service. +//! Finite request policies supply their codec and response bound. The shared +//! admission path owns concurrency, rollback, execution, and response lifetimes. +//! Peer routines retain protocol dispatch and scheduling decisions. -#[allow(dead_code)] // used by the first message policy in the stacked PR -mod outstanding_bytes; -#[allow(dead_code)] // used by the first message policy in the stacked PR -mod rate; -#[allow(dead_code)] // used by the first message policy in the stacked PR -mod slots; - -#[allow(unused_imports)] // used by the first message policy in the stacked PR -pub(crate) use outstanding_bytes::{ - FrameLease, OutstandingByteBudget, OutstandingByteReservation, OutstandingCapacityError, -}; -#[allow(unused_imports)] // used by the first message policy in the stacked PR -pub(crate) use rate::{ - CommittedRateReservation, RateBudget, RateBudgetConfigError, RateReservation, - RateReservationError, RateReservationSpendError, +mod request; +pub(crate) use request::{ + RequestAdmission, RequestPolicy, RequestSession, ResponsePermit, WorkAttempt, WorkLease, }; -#[allow(unused_imports)] // used by the first message policy in the stacked PR -pub(crate) use slots::{SlotBudget, SlotBudgetCapacityError, SlotPermit}; + +mod slots; +pub(crate) use slots::{SlotBudget, SlotPermit}; #[cfg(test)] mod tests; diff --git a/crates/zakura-network/src/zakura/regulation/outstanding_bytes.rs b/crates/zakura-network/src/zakura/regulation/outstanding_bytes.rs deleted file mode 100644 index 8f50868591..0000000000 --- a/crates/zakura-network/src/zakura/regulation/outstanding_bytes.rs +++ /dev/null @@ -1,255 +0,0 @@ -//! Linear accounting for response bytes until their transport owner drops them. - -use std::sync::{ - atomic::{AtomicU64, Ordering}, - Arc, -}; - -use thiserror::Error; -use tokio::sync::{futures::Notified, Notify}; - -/// A reservation exceeded an outstanding-byte budget's total capacity. -#[derive(Copy, Clone, Debug, Eq, Error, PartialEq)] -#[error("requested {requested} outstanding bytes exceeds capacity {capacity}")] -pub(crate) struct OutstandingCapacityError { - /// Requested byte count. - pub(crate) requested: u64, - /// Configured byte capacity. - pub(crate) capacity: u64, -} - -/// Shared capacity returned only when outstanding byte ownership ends. -/// -/// Unlike [`RateBudget`](super::RateBudget), this balance does not refill with -/// time. Clones reserve and release against the same atomic counter, allowing a -/// node budget and a peer budget to be held by different tasks. -#[derive(Clone, Debug)] -pub(crate) struct OutstandingByteBudget { - inner: Arc, -} - -#[derive(Debug)] -struct OutstandingByteBudgetInner { - capacity: u64, - reserved: AtomicU64, - capacity_released: Notify, -} - -impl OutstandingByteBudget { - /// Create an empty budget with `capacity` bytes. - pub(crate) fn new(capacity: u64) -> Self { - Self { - inner: Arc::new(OutstandingByteBudgetInner { - capacity, - reserved: AtomicU64::new(0), - capacity_released: Notify::new(), - }), - } - } - - /// Return the configured byte capacity. - pub(crate) fn capacity(&self) -> u64 { - self.inner.capacity - } - - /// Return the bytes currently available for reservation. - pub(crate) fn available(&self) -> u64 { - self.capacity().saturating_sub(self.reserved()) - } - - /// Return the bytes currently owned by reservations and frame leases. - pub(crate) fn reserved(&self) -> u64 { - self.inner.reserved.load(Ordering::Acquire) - } - - /// Reserve bytes and return their linear owner. - /// - /// `Err` means the request can never fit. `Ok(None)` means it can fit after - /// another owner releases capacity. - pub(crate) fn try_reserve( - &self, - bytes: u64, - ) -> Result, OutstandingCapacityError> { - self.ensure_request_fits(bytes)?; - - if !self.reserve_bytes(bytes) { - return Ok(None); - } - - Ok(Some(OutstandingByteReservation { - budget: self.clone(), - remaining: bytes, - })) - } - - /// Wait until `bytes` could fit, without reserving them. - /// - /// The notification is registered before capacity is rechecked, so a - /// concurrent release cannot be missed. - pub(crate) async fn wait_for(&self, bytes: u64) -> Result<(), OutstandingCapacityError> { - self.ensure_request_fits(bytes)?; - - loop { - let released = self.inner.capacity_released.notified(); - tokio::pin!(released); - Notified::enable(released.as_mut()); - - if self.available() >= bytes { - return Ok(()); - } - - released.await; - } - } - - fn ensure_request_fits(&self, bytes: u64) -> Result<(), OutstandingCapacityError> { - if bytes > self.capacity() { - return Err(OutstandingCapacityError { - requested: bytes, - capacity: self.capacity(), - }); - } - - Ok(()) - } - - fn reserve_bytes(&self, bytes: u64) -> bool { - if bytes == 0 { - return true; - } - - let mut reserved = self.reserved(); - loop { - if bytes > self.capacity().saturating_sub(reserved) { - return false; - } - - let next = reserved.saturating_add(bytes); - match self.inner.reserved.compare_exchange_weak( - reserved, - next, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => return true, - Err(observed) => reserved = observed, - } - } - } - - fn release_bytes(&self, bytes: u64) { - if bytes == 0 { - return; - } - - let mut reserved = self.reserved(); - loop { - let next = reserved.saturating_sub(bytes); - match self.inner.reserved.compare_exchange_weak( - reserved, - next, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => break, - Err(observed) => reserved = observed, - } - } - self.inner.capacity_released.notify_waiters(); - } -} - -/// Linear ownership of bytes reserved but not yet handed to a frame. -#[derive(Debug)] -#[must_use = "dropping an outstanding-byte reservation releases it"] -pub(crate) struct OutstandingByteReservation { - budget: OutstandingByteBudget, - remaining: u64, -} - -impl OutstandingByteReservation { - /// Return the bytes this reservation still owns directly. - pub(crate) fn remaining(&self) -> u64 { - self.remaining - } - - /// Transfer equal bytes from every reservation into one frame lease. - /// - /// This operation validates every reservation before changing any of them. - /// It is intended for the peer and node response-byte reservations attached - /// to the same outbound frame. - pub(crate) fn transfer_to_frame( - reservations: [&mut OutstandingByteReservation; N], - bytes: u64, - ) -> Option { - if reservations - .iter() - .any(|reservation| reservation.remaining < bytes) - { - return None; - } - - let mut releases = Vec::with_capacity(N); - for reservation in reservations { - reservation.remaining -= bytes; - releases.push(FrameLeaseRelease { - budget: reservation.budget.clone(), - bytes, - }); - } - - Some(FrameLease { - accounted_bytes: bytes, - releases, - }) - } - - /// Release all remaining bytes now. - pub(crate) fn release(self) { - drop(self); - } -} - -impl Drop for OutstandingByteReservation { - fn drop(&mut self) { - self.budget.release_bytes(self.remaining); - self.remaining = 0; - } -} - -/// Linear ownership of one queued frame's outstanding-byte charges. -#[derive(Debug)] -#[must_use = "the transport must retain a frame lease until write completion or drop"] -pub(crate) struct FrameLease { - accounted_bytes: u64, - releases: Vec, -} - -#[derive(Debug)] -struct FrameLeaseRelease { - budget: OutstandingByteBudget, - bytes: u64, -} - -impl FrameLease { - /// Return the response bytes represented by this lease. - pub(crate) fn accounted_bytes(&self) -> u64 { - self.accounted_bytes - } - - #[cfg(test)] - pub(crate) fn empty_for_test() -> Self { - Self { - accounted_bytes: 0, - releases: Vec::new(), - } - } -} - -impl Drop for FrameLease { - fn drop(&mut self) { - for release in self.releases.drain(..) { - release.budget.release_bytes(release.bytes); - } - } -} diff --git a/crates/zakura-network/src/zakura/regulation/rate.rs b/crates/zakura-network/src/zakura/regulation/rate.rs deleted file mode 100644 index 103dcb7ab7..0000000000 --- a/crates/zakura-network/src/zakura/regulation/rate.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! Refundable reservations from a monotonic rate budget. - -use std::{ - sync::{Arc, Mutex, MutexGuard}, - time::Duration, -}; - -use thiserror::Error; -use tokio::{ - sync::{futures::Notified, Notify}, - time::sleep, -}; - -use crate::zakura::transport::{Clock, RealClock}; - -const NANOS_PER_SECOND: u128 = 1_000_000_000; - -/// Invalid local configuration for a rate budget. -#[derive(Copy, Clone, Debug, Eq, Error, PartialEq)] -pub(crate) enum RateBudgetConfigError { - /// A zero capacity cannot admit any positive work. - #[error("rate budget capacity must be greater than zero")] - ZeroCapacity, - /// A zero refill would make spent capacity unavailable forever. - #[error("rate budget refill must be greater than zero")] - ZeroRefill, -} - -/// A rate reservation could not be admitted. -#[derive(Copy, Clone, Debug, Eq, Error, PartialEq)] -pub(crate) enum RateReservationError { - /// The requested amount can never fit in this budget. - #[error("requested rate reservation {requested} exceeds capacity {capacity}")] - ExceedsCapacity { - /// Requested units. - requested: u64, - /// Configured burst capacity. - capacity: u64, - }, - /// The request can fit after refill or an earlier reservation is returned. - #[error("rate reservation is temporarily unavailable for {retry_after:?}")] - TemporarilyUnavailable { - /// Refill time assuming no earlier return. - retry_after: Duration, - }, -} - -impl RateReservationError { - /// Return the refill delay for a temporary rejection. - pub(crate) fn retry_after(self) -> Option { - match self { - Self::TemporarilyUnavailable { retry_after } => Some(retry_after), - Self::ExceedsCapacity { .. } => None, - } - } -} - -/// A caller tried to spend more than its refundable reservation. -#[derive(Copy, Clone, Debug, Eq, Error, PartialEq)] -#[error("rate spend {spent} exceeds refundable reservation {refundable}")] -pub(crate) struct RateReservationSpendError { - /// Units the caller tried to spend. - pub(crate) spent: u64, - /// Units that were still refundable. - pub(crate) refundable: u64, -} - -/// Shared tokens that bound a burst and sustained work rate. -/// -/// Each instance has one caller-defined unit, such as response bytes or a -/// measured work unit. Incomparable resources use separate budgets. Time -/// replenishes spent tokens; dropping an uncommitted reservation returns its -/// tokens immediately. -#[derive(Clone, Debug)] -pub(crate) struct RateBudget { - inner: Arc>, -} - -#[derive(Debug)] -struct RateBudgetInner { - capacity: u64, - refill_per_second: u64, - clock: C, - state: Mutex, - tokens_returned: Notify, -} - -#[derive(Debug)] -struct RateState { - available: u64, - /// Fractional unit numerator in unit-nanoseconds. - refill_remainder: u128, - last_refill: tokio::time::Instant, -} - -impl RateBudget { - /// Create a production budget initialized at full capacity. - pub(crate) fn new( - capacity: u64, - refill_per_second: u64, - ) -> Result { - Self::with_clock(capacity, refill_per_second, RealClock) - } - - /// Wait until `units` could be reserved, without consuming them. - /// - /// The notification is registered before the balance is rechecked, so a - /// concurrent return cannot be missed. - pub(crate) async fn wait_for(&self, units: u64) -> Result<(), RateReservationError> { - self.ensure_reservation_fits(units)?; - - loop { - let returned = self.inner.tokens_returned.notified(); - tokio::pin!(returned); - Notified::enable(returned.as_mut()); - - let Some(retry_after) = self.time_until_available(units) else { - return Ok(()); - }; - - tokio::select! { - _ = &mut returned => {} - _ = sleep(retry_after) => {} - } - } - } -} - -impl RateBudget { - /// Create a budget with an injected monotonic clock. - pub(crate) fn with_clock( - capacity: u64, - refill_per_second: u64, - clock: C, - ) -> Result { - if capacity == 0 { - return Err(RateBudgetConfigError::ZeroCapacity); - } - if refill_per_second == 0 { - return Err(RateBudgetConfigError::ZeroRefill); - } - - let now = clock.now(); - Ok(Self { - inner: Arc::new(RateBudgetInner { - capacity, - refill_per_second, - clock, - state: Mutex::new(RateState { - available: capacity, - refill_remainder: 0, - last_refill: now, - }), - tokens_returned: Notify::new(), - }), - }) - } - - /// Return the configured burst capacity. - pub(crate) fn capacity(&self) -> u64 { - self.inner.capacity - } - - /// Return the configured refill rate in units per second. - pub(crate) fn refill_per_second(&self) -> u64 { - self.inner.refill_per_second - } - - /// Return the currently available units after applying elapsed refill. - pub(crate) fn available(&self) -> u64 { - let mut state = self.lock_state(); - self.refill(&mut state); - state.available - } - - /// Reserve units now or return why they are unavailable. - pub(crate) fn try_reserve( - &self, - units: u64, - ) -> Result, RateReservationError> { - self.ensure_reservation_fits(units)?; - - let mut state = self.lock_state(); - self.refill(&mut state); - if state.available < units { - return Err(RateReservationError::TemporarilyUnavailable { - retry_after: self.retry_after(&state, units), - }); - } - - state.available -= units; - Ok(RateReservation { - budget: self.clone(), - refundable: units, - }) - } - - fn ensure_reservation_fits(&self, units: u64) -> Result<(), RateReservationError> { - if units > self.capacity() { - return Err(RateReservationError::ExceedsCapacity { - requested: units, - capacity: self.capacity(), - }); - } - - Ok(()) - } - - fn time_until_available(&self, units: u64) -> Option { - let mut state = self.lock_state(); - self.refill(&mut state); - (state.available < units).then(|| self.retry_after(&state, units)) - } - - fn retry_after(&self, state: &RateState, units: u64) -> Duration { - let deficit = units.saturating_sub(state.available); - let scaled_deficit = u128::from(deficit) - .saturating_mul(NANOS_PER_SECOND) - .saturating_sub(state.refill_remainder); - let refill = u128::from(self.refill_per_second().max(1)); - let nanos = scaled_deficit.saturating_add(refill - 1) / refill; - Duration::from_nanos(u64::try_from(nanos).unwrap_or(u64::MAX)) - } - - fn refill(&self, state: &mut RateState) { - let now = self.inner.clock.now(); - let elapsed = now.saturating_duration_since(state.last_refill); - if elapsed.is_zero() || state.available == self.capacity() { - state.last_refill = now; - if state.available == self.capacity() { - state.refill_remainder = 0; - } - return; - } - - let produced = elapsed - .as_nanos() - .saturating_mul(u128::from(self.refill_per_second())) - .saturating_add(state.refill_remainder); - let whole_units = u64::try_from(produced / NANOS_PER_SECOND).unwrap_or(u64::MAX); - state.available = self - .capacity() - .min(state.available.saturating_add(whole_units)); - state.refill_remainder = if state.available == self.capacity() { - 0 - } else { - produced % NANOS_PER_SECOND - }; - state.last_refill = now; - } - - fn return_units(&self, units: u64) { - if units == 0 { - return; - } - - let mut state = self.lock_state(); - self.refill(&mut state); - state.available = self.capacity().min(state.available.saturating_add(units)); - if state.available == self.capacity() { - state.refill_remainder = 0; - } - drop(state); - self.inner.tokens_returned.notify_waiters(); - } - - fn lock_state(&self) -> MutexGuard<'_, RateState> { - self.inner - .state - .lock() - .expect("rate budget mutex should not be poisoned") - } -} - -/// Provisional ownership of a fully refundable rate reservation. -#[derive(Debug)] -#[must_use = "dropping a provisional rate reservation returns it"] -pub(crate) struct RateReservation { - budget: RateBudget, - refundable: u64, -} - -impl RateReservation { - /// Commit the reservation and permanently spend `initial_spend` units. - pub(crate) fn commit( - mut self, - initial_spend: u64, - ) -> Result, RateReservationSpendError> { - if initial_spend > self.refundable { - return Err(RateReservationSpendError { - spent: initial_spend, - refundable: self.refundable, - }); - } - - self.refundable -= initial_spend; - let refundable = self.refundable; - self.refundable = 0; - Ok(CommittedRateReservation { - budget: self.budget.clone(), - refundable, - }) - } - - /// Return the units held by this provisional reservation. - pub(crate) fn reserved(&self) -> u64 { - self.refundable - } -} - -impl Drop for RateReservation { - fn drop(&mut self) { - self.budget.return_units(self.refundable); - self.refundable = 0; - } -} - -/// A committed rate reservation whose unused units remain refundable. -#[derive(Debug)] -#[must_use = "dropping a committed rate reservation returns its unused units"] -pub(crate) struct CommittedRateReservation { - budget: RateBudget, - refundable: u64, -} - -impl CommittedRateReservation { - /// Permanently spend units consumed by completed work. - pub(crate) fn spend(&mut self, units: u64) -> Result<(), RateReservationSpendError> { - if units > self.refundable { - return Err(RateReservationSpendError { - spent: units, - refundable: self.refundable, - }); - } - - self.refundable -= units; - Ok(()) - } - - /// Return the units that will be returned when this owner is dropped. - pub(crate) fn refundable(&self) -> u64 { - self.refundable - } - - /// Finish this reservation and return its unused units now. - pub(crate) fn finish(self) { - drop(self); - } -} - -impl Drop for CommittedRateReservation { - fn drop(&mut self) { - self.budget.return_units(self.refundable); - self.refundable = 0; - } -} diff --git a/crates/zakura-network/src/zakura/regulation/request.rs b/crates/zakura-network/src/zakura/regulation/request.rs new file mode 100644 index 0000000000..cc2c66c377 --- /dev/null +++ b/crates/zakura-network/src/zakura/regulation/request.rs @@ -0,0 +1,267 @@ +//! Admission and ownership for requests that produce a finite response. +//! +//! Policies supply the codec and response bound. Sequential serving tasks wait +//! for admission before dispatching work; this layer owns capacity and lifetimes. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use tokio_util::sync::CancellationToken; + +use super::{slots::WeakSlotBudget, SlotBudget, SlotPermit}; +use crate::zakura::transport::{Frame, FrameGuard}; +use crate::zakura::ZakuraPeerId; + +/// Message-specific rules used by request admission. +/// +/// Decode must use the production codec and validate the request before returning +/// it. The response bound must cover every frame the handler may produce. This +/// interface is for finite requests, not announcements or subscription lifetimes. +pub(crate) trait RequestPolicy { + type Request; + type Error; + + fn decode(&self, frame: Frame) -> Result; + fn response_cap(&self, request: &Self::Request) -> u64; +} + +/// Node work capacity shared by the sessions of one configured request policy. +#[derive(Clone, Debug)] +pub(crate) struct RequestAdmission

{ + policy: P, + node: SlotBudget, + peer_capacity: usize, + peers: Arc>>, +} + +impl RequestAdmission

{ + pub(crate) fn new(policy: P, node: SlotBudget, peer_capacity: usize) -> Self { + // Validate before a session is created rather than panicking on ingress. + SlotBudget::new(peer_capacity).expect("request peer capacity is validated"); + Self { + policy, + node, + peer_capacity, + peers: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Reconnects share the authenticated peer's capacity with its old work. + pub(crate) fn session(&self, peer: &ZakuraPeerId) -> RequestSession

{ + let mut peers = self + .peers + .lock() + .expect("peer budget registry is not poisoned"); + // Permits keep their semaphore alive even after the old session closes. + // Remove expired identities on each connection so churn cannot grow the map. + peers.retain(|_, budget| budget.is_alive()); + let budget = peers + .get(peer) + .and_then(WeakSlotBudget::upgrade) + .unwrap_or_else(|| { + let budget = SlotBudget::new(self.peer_capacity) + .expect("request peer capacity was validated at construction"); + peers.insert(peer.clone(), budget.downgrade()); + budget + }); + RequestSession { + policy: self.policy.clone(), + node: self.node.clone(), + peer: budget, + } + } + + #[cfg(test)] + pub(crate) fn reserved_by_peers(&self) -> usize { + self.peers + .lock() + .unwrap() + .values() + .filter_map(WeakSlotBudget::upgrade) + .map(|budget| budget.reserved()) + .sum() + } +} + +/// One session's request policy, sharing capacity with the peer's other sessions. +#[derive(Clone, Debug)] +pub(crate) struct RequestSession

{ + policy: P, + node: SlotBudget, + peer: SlotBudget, +} + +impl RequestSession

{ + pub(crate) fn decode(&self, frame: Frame) -> Result { + self.policy.decode(frame) + } + + /// One sequential serving task waits for its peer before entering the node + /// queue. A previous response cannot make this peer hold extra node slots. + /// Dropping this future removes its FIFO waiter and releases a partial claim. + pub(crate) async fn admit(&self, request: &P::Request) -> WorkAttempt { + let peer = reserve_response_slot(&self.peer, WorkBound::Peer).await; + let node = reserve_response_slot(&self.node, WorkBound::Node).await; + WorkAttempt { + resources: Arc::new(WorkResources { + _peer: peer, + _node: node, + }), + response_cap: self.policy.response_cap(request), + } + } + + #[cfg(test)] + pub(crate) fn peer_budget(&self) -> &SlotBudget { + &self.peer + } +} + +/// Scope of the work capacity that delayed a request. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(crate) enum WorkBound { + Peer, + Node, +} + +impl WorkBound { + /// Stable resource names used by delay metrics and traces. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Peer => "peer_active", + Self::Node => "node_active", + } + } +} + +async fn reserve_response_slot(budget: &SlotBudget, bound: WorkBound) -> SlotPermit { + if let Some(permit) = budget.try_reserve() { + return permit; + } + metrics::counter!("sync.block.serving.delayed", "bound" => bound.label()).increment(1); + budget.reserve().await +} + +/// Provisional work ownership. Dropping it rolls back admission. +#[derive(Debug)] +#[must_use = "dropping an admission attempt returns its capacity"] +pub(crate) struct WorkAttempt { + resources: Arc, + response_cap: u64, +} + +impl WorkAttempt { + pub(crate) fn commit(self) -> ResponsePermit { + ResponsePermit { + resources: self.resources, + execution: Arc::new(Execution::default()), + response_cap: self.response_cap, + queued_bytes: 0, + } + } +} + +#[derive(Debug)] +pub(crate) struct WorkResources { + _peer: SlotPermit, + _node: SlotPermit, +} + +/// The handler's response ownership; closure cancels execution not yet claimed. +#[derive(Debug)] +#[must_use = "retain the response permit until settlement or cancellation"] +pub(crate) struct ResponsePermit { + resources: Arc, + execution: Arc, + response_cap: u64, + queued_bytes: u64, +} + +impl ResponsePermit { + pub(crate) fn can_queue_frame(&self, bytes: u64) -> bool { + bytes <= self.response_cap.saturating_sub(self.queued_bytes) + } + + /// Call only once queue capacity is reserved. The guard lives through writing. + pub(crate) fn frame_guard(&mut self, bytes: u64) -> FrameGuard { + assert!( + self.can_queue_frame(bytes), + "encoded response fits its declared cap" + ); + self.queued_bytes += bytes; + FrameGuard::new(self.resources.clone()) + } + + pub(crate) fn work_lease(&self) -> WorkLease { + WorkLease { + _resources: self.resources.clone(), + execution: self.execution.clone(), + } + } +} + +impl Drop for ResponsePermit { + fn drop(&mut self) { + self.execution.cancel(); + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +enum ExecutionState { + #[default] + Queued, + Started, + Cancelled, +} + +#[derive(Debug, Default)] +struct Execution { + state: Mutex, + cancelled: CancellationToken, +} + +impl Execution { + fn try_start(&self) -> bool { + let mut state = self + .state + .lock() + .expect("request execution state is not poisoned"); + if *state != ExecutionState::Queued { + return false; + } + *state = ExecutionState::Started; + true + } + + fn cancel(&self) { + *self + .state + .lock() + .expect("request execution state is not poisoned") = ExecutionState::Cancelled; + self.cancelled.cancel(); + } +} + +/// Capacity shared by an execution and its returned result. +/// +/// Clones share one execution claim. Cancellation prevents an unclaimed start; +/// an already running operation must retain this lease until it actually ends. +#[derive(Clone, Debug)] +pub(crate) struct WorkLease { + _resources: Arc, + execution: Arc, +} + +impl WorkLease { + pub(crate) fn try_start(&self) -> bool { + self.execution.try_start() + } + pub(crate) fn is_cancelled(&self) -> bool { + self.execution.cancelled.is_cancelled() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/zakura-network/src/zakura/regulation/request/tests.rs b/crates/zakura-network/src/zakura/regulation/request/tests.rs new file mode 100644 index 0000000000..30fa806db4 --- /dev/null +++ b/crates/zakura-network/src/zakura/regulation/request/tests.rs @@ -0,0 +1,302 @@ +//! A second finite-request adapter, using the production discovery codec. +//! This exercises reuse without enabling discovery regulation in production. + +use std::time::Duration; + +use futures::FutureExt; + +use super::*; +use crate::zakura::discovery::{DiscoveryMessage, DiscoveryWireError, MAX_DISCOVERY_MESSAGE_BYTES}; + +impl ResponsePermit { + pub(crate) fn weak_resources(&self) -> std::sync::Weak { + Arc::downgrade(&self.resources) + } +} + +#[derive(Clone, Debug)] +pub(super) struct GetPeersPolicy; + +#[derive(Debug)] +pub(super) struct GetPeersRequest { + limit: u16, +} + +impl RequestPolicy for GetPeersPolicy { + type Request = GetPeersRequest; + type Error = String; + + fn decode(&self, frame: Frame) -> Result { + if frame.message_type != 1 || frame.flags != 0 { + return Err("invalid discovery envelope".into()); + } + match DiscoveryMessage::decode(&frame.payload) + .map_err(|e: DiscoveryWireError| e.to_string())? + { + DiscoveryMessage::GetPeers { limit, .. } => Ok(GetPeersRequest { limit }), + _ => Err("expected GetPeers".into()), + } + } + + fn response_cap(&self, request: &GetPeersRequest) -> u64 { + assert!(request.limit > 0); + // Today's codec caps the complete discovery response, independently of + // the smaller per-record limits proposed in the regulation draft. + u64::try_from(MAX_DISCOVERY_MESSAGE_BYTES).unwrap() + } +} + +pub(super) fn frame(limit: u16) -> Frame { + Frame { + message_type: 1, + flags: 0, + payload: DiscoveryMessage::GetPeers { + limit, + wanted_services: vec![], + exclude_node_ids: vec![], + } + .encode() + .unwrap(), + } +} + +fn peer(byte: u8) -> ZakuraPeerId { + ZakuraPeerId::new(vec![byte; 32]).unwrap() +} + +fn admission(node: &SlotBudget) -> RequestAdmission { + RequestAdmission::new(GetPeersPolicy, node.clone(), 1) +} + +#[test] +fn discovery_codec_runs_before_work_admission() { + let node = SlotBudget::new(1).unwrap(); + let session = admission(&node).session(&peer(1)); + let mut malformed = frame(1); + malformed.payload.push(0); + assert!(session.decode(malformed).is_err()); + assert_eq!(node.reserved(), 0); + let request = session.decode(frame(1)).unwrap(); + assert_eq!(request.limit, 1); + let attempt = session.admit(&request).now_or_never().unwrap(); + assert_eq!(node.reserved(), 1); + drop(attempt); + assert_eq!(node.reserved(), 0); +} + +#[tokio::test] +async fn finite_discovery_response_retains_work_through_its_write() { + use crate::zakura::transport::worker_framed_channel; + let node = SlotBudget::new(1).unwrap(); + let session = admission(&node).session(&peer(1)); + let request = session.decode(frame(1)).unwrap(); + let mut response = session.admit(&request).now_or_never().unwrap().commit(); + let lifetime = response.weak_resources(); + let work = response.work_lease(); + assert!(work.try_start()); + assert!(!work.clone().try_start()); + let (send, mut recv) = worker_framed_channel(1); + let payload = DiscoveryMessage::Peers { records: vec![] } + .encode() + .unwrap(); + let payload_bytes = u64::try_from(payload.len()).unwrap(); + send.try_reserve_guarded().unwrap().send( + Frame { + message_type: 1, + flags: 0, + payload, + }, + response.frame_guard(payload_bytes), + ); + let queued = recv.recv().await.unwrap(); + drop(response); + drop(work); + assert_eq!(node.reserved(), 1); + assert!(lifetime.upgrade().is_some()); + assert!(session.admit(&request).now_or_never().is_none()); + queued + .write_with(|_| async { Ok::<_, std::io::Error>(()) }) + .await + .unwrap(); + assert_eq!(node.reserved(), 0); + assert!(lifetime.upgrade().is_none()); +} + +#[tokio::test] +async fn admission_waiters_retain_peer_capacity_and_receive_node_slots_in_order() { + let node = SlotBudget::new(1).unwrap(); + let admission = admission(&node); + let first = admission.session(&peer(1)); + let second = admission.session(&peer(2)); + let third = admission.session(&peer(3)); + let request = first.decode(frame(1)).unwrap(); + let held = first.admit(&request).await; + let mut second_wait = Box::pin(second.admit(&request)); + let mut third_wait = Box::pin(third.admit(&request)); + assert!(futures::poll!(&mut second_wait).is_pending()); + assert!(futures::poll!(&mut third_wait).is_pending()); + assert_eq!(second.peer_budget().reserved(), 1); + assert_eq!(third.peer_budget().reserved(), 1); + assert_eq!(node.reserved(), 1); + drop(held); + assert!( + node.try_reserve().is_none(), + "the first waiter owns the released slot" + ); + let second_owner = tokio::time::timeout(Duration::from_secs(1), second_wait) + .await + .unwrap(); + assert!(futures::poll!(&mut third_wait).is_pending()); + drop(second_owner); + let third_owner = tokio::time::timeout(Duration::from_secs(1), third_wait) + .await + .unwrap(); + drop(third_owner); + assert_eq!(node.reserved(), 0); + assert_eq!(admission.reserved_by_peers(), 0); +} + +#[test] +fn closing_response_prevents_an_unclaimed_execution() { + let node = SlotBudget::new(1).unwrap(); + let session = admission(&node).session(&peer(1)); + let request = session.decode(frame(1)).unwrap(); + let response = session.admit(&request).now_or_never().unwrap().commit(); + let work = response.work_lease(); + drop(response); + assert!(work.is_cancelled()); + assert!(!work.try_start()); + assert_eq!(node.reserved(), 1); + drop(work); + assert_eq!(node.reserved(), 0); +} + +#[tokio::test] +async fn cancelling_a_node_waiter_releases_its_partial_peer_claim() { + let node = SlotBudget::new(1).unwrap(); + let admission = admission(&node); + let first = admission.session(&peer(1)); + let second = admission.session(&peer(2)); + let request = first.decode(frame(1)).unwrap(); + let held = first.admit(&request).await; + let mut wait = Box::pin(second.admit(&request)); + assert!(futures::poll!(&mut wait).is_pending()); + assert_eq!(second.peer_budget().reserved(), 1); + drop(wait); + assert_eq!(second.peer_budget().reserved(), 0); + assert_eq!(node.reserved(), 1); + drop(held); + assert!(second.admit(&request).now_or_never().is_some()); + assert_eq!(node.reserved(), 0); +} + +#[test] +fn reconnect_waits_for_all_old_read_and_frame_owners() { + for read_finishes_first in [false, true] { + let node = SlotBudget::new(2).unwrap(); + let admission = admission(&node); + let original = admission.session(&peer(1)); + let request = original.decode(frame(1)).unwrap(); + let mut response = original.admit(&request).now_or_never().unwrap().commit(); + let mut work = Some(response.work_lease()); + assert!(work.as_ref().unwrap().try_start()); + let writing = response.frame_guard(1); + let ending = response.frame_guard(1); + drop(response); + drop(original); + + for _ in 0..64 { + let replacement = admission.session(&peer(1)); + assert!(replacement.admit(&request).now_or_never().is_none()); + assert_eq!(node.reserved(), 1); + } + let replacement = admission.session(&peer(1)); + assert!(admission + .session(&peer(2)) + .admit(&request) + .now_or_never() + .is_some()); + if read_finishes_first { + drop(work.take()); + } + drop(writing); + assert!(replacement.admit(&request).now_or_never().is_none()); + drop(ending); + if !read_finishes_first { + assert!(replacement.admit(&request).now_or_never().is_none()); + drop(work); + } + assert!(replacement.admit(&request).now_or_never().is_some()); + assert_eq!(node.reserved(), 0); + } +} + +#[test] +fn peer_registry_prunes_churn_but_retains_outstanding_work() { + let node = SlotBudget::new(2).unwrap(); + let admission = admission(&node); + let original = admission.session(&peer(0)); + let request = original.decode(frame(1)).unwrap(); + let held = original.admit(&request).now_or_never().unwrap(); + drop(original); + for id in 1..=255 { + drop(admission.session(&peer(id))); + assert_eq!(admission.peers.lock().unwrap().len(), 2); + } + assert!(admission + .session(&peer(0)) + .admit(&request) + .now_or_never() + .is_none()); + drop(held); + let replacement = admission.session(&peer(0)); + assert_eq!(admission.peers.lock().unwrap().len(), 1); + assert!(replacement.admit(&request).now_or_never().is_some()); +} + +#[test] +fn concurrent_sessions_for_one_identity_share_capacity() { + let node = SlotBudget::new(8).unwrap(); + let admission = admission(&node); + let barrier = std::sync::Barrier::new(8); + let sessions = std::thread::scope(|scope| { + let tasks: Vec<_> = (0..8) + .map(|_| { + scope.spawn(|| { + barrier.wait(); + admission.session(&peer(1)) + }) + }) + .collect(); + tasks + .into_iter() + .map(|task| task.join().unwrap()) + .collect::>() + }); + let request = sessions[0].decode(frame(1)).unwrap(); + let held = sessions[0].admit(&request).now_or_never().unwrap(); + for session in &sessions[1..] { + assert!(session.admit(&request).now_or_never().is_none()); + } + drop(held); + assert_eq!(node.reserved(), 0); +} + +#[tokio::test] +async fn a_waiters_peer_slot_survives_session_replacement_and_cancellation() { + let node = SlotBudget::new(1).unwrap(); + let admission = admission(&node); + let original = admission.session(&peer(1)); + let request = original.decode(frame(1)).unwrap(); + let held = original.admit(&request).await; + let mut wait = Box::pin(async move { original.admit(&request).await }); + assert!(futures::poll!(&mut wait).is_pending()); + drop(held); + let replacement = admission.session(&peer(1)); + let request = replacement.decode(frame(1)).unwrap(); + assert!(replacement.admit(&request).now_or_never().is_none()); + // Cancel after capacity is assigned but before the waiting future resumes. + drop(wait); + assert!(replacement.admit(&request).now_or_never().is_some()); + assert_eq!(node.reserved(), 0); +} diff --git a/crates/zakura-network/src/zakura/regulation/slots.rs b/crates/zakura-network/src/zakura/regulation/slots.rs index 19349a5a32..d898f8e1d3 100644 --- a/crates/zakura-network/src/zakura/regulation/slots.rs +++ b/crates/zakura-network/src/zakura/regulation/slots.rs @@ -1,6 +1,6 @@ //! Owned permits for bounded collections of retained or active work. -use std::sync::Arc; +use std::sync::{Arc, Weak}; use thiserror::Error; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; @@ -21,6 +21,7 @@ pub(crate) struct SlotBudgetCapacityError { /// admitted item makes ordinary drop and cancellation release capacity. #[derive(Clone, Debug)] pub(crate) struct SlotBudget { + #[cfg(test)] capacity: usize, permits: Arc, } @@ -36,22 +37,34 @@ impl SlotBudget { } Ok(Self { + #[cfg(test)] capacity, permits: Arc::new(Semaphore::new(capacity)), }) } /// Return the maximum number of owned slots. + #[cfg(test)] pub(crate) fn capacity(&self) -> usize { self.capacity } /// Return the number of currently owned slots. + #[cfg(test)] pub(crate) fn reserved(&self) -> usize { self.capacity .saturating_sub(self.permits.available_permits()) } + /// Track this pool without retaining it after sessions and permits are gone. + pub(super) fn downgrade(&self) -> WeakSlotBudget { + WeakSlotBudget { + #[cfg(test)] + capacity: self.capacity, + permits: Arc::downgrade(&self.permits), + } + } + /// Reserve one slot without waiting. pub(crate) fn try_reserve(&self) -> Option { self.permits @@ -63,8 +76,7 @@ impl SlotBudget { /// Wait for a slot and return its ownership in semaphore queue order. /// - /// Keep the returned permit through admission. If another resource cannot - /// be reserved, drop it before waiting for that resource. Cancelling this + /// Keep the returned permit while owning the resource. Cancelling this /// future removes its waiter without consuming a slot. pub(crate) async fn reserve(&self) -> SlotPermit { let permit = self @@ -83,3 +95,25 @@ impl SlotBudget { pub(crate) struct SlotPermit { _permit: OwnedSemaphorePermit, } + +/// An identity entry that doesn't keep a departed peer's capacity alive. +#[derive(Debug)] +pub(super) struct WeakSlotBudget { + #[cfg(test)] + capacity: usize, + permits: Weak, +} + +impl WeakSlotBudget { + pub(super) fn is_alive(&self) -> bool { + self.permits.strong_count() > 0 + } + + pub(super) fn upgrade(&self) -> Option { + Some(SlotBudget { + #[cfg(test)] + capacity: self.capacity, + permits: self.permits.upgrade()?, + }) + } +} diff --git a/crates/zakura-network/src/zakura/regulation/tests.rs b/crates/zakura-network/src/zakura/regulation/tests.rs index fc163edbaf..1dc9301787 100644 --- a/crates/zakura-network/src/zakura/regulation/tests.rs +++ b/crates/zakura-network/src/zakura/regulation/tests.rs @@ -1,225 +1,6 @@ use std::time::Duration; use super::*; -use crate::zakura::testkit::TestClock; - -#[test] -fn rate_budget_rejects_configuration_that_cannot_refill() { - assert_eq!( - RateBudget::with_clock(0, 1, TestClock::new()).unwrap_err(), - RateBudgetConfigError::ZeroCapacity, - ); - assert_eq!( - RateBudget::with_clock(1, 0, TestClock::new()).unwrap_err(), - RateBudgetConfigError::ZeroRefill, - ); -} - -#[test] -fn provisional_rate_reservation_returns_all_units_on_drop() { - let budget = RateBudget::with_clock(100, 10, TestClock::new()) - .expect("the rate budget configuration is valid"); - let reservation = budget - .try_reserve(80) - .expect("the full budget covers eighty units"); - - assert_eq!(budget.available(), 20); - drop(reservation); - assert_eq!(budget.available(), 100); -} - -#[test] -fn committed_rate_reservation_keeps_consumed_units_spent() { - let budget = RateBudget::with_clock(100, 10, TestClock::new()) - .expect("the rate budget configuration is valid"); - let reservation = budget - .try_reserve(80) - .expect("the full budget covers eighty units"); - let mut committed = reservation - .commit(20) - .expect("the initial spend fits the reservation"); - committed - .spend(30) - .expect("completed work fits the refundable remainder"); - - committed.finish(); - - assert_eq!(budget.available(), 50); -} - -#[test] -fn rate_refill_preserves_fractional_time() { - let clock = TestClock::new(); - let budget = RateBudget::with_clock(10, 3, clock.clone()) - .expect("the rate budget configuration is valid"); - budget - .try_reserve(10) - .expect("the full budget covers its capacity") - .commit(10) - .expect("the full reservation can be spent") - .finish(); - - clock.advance(Duration::from_millis(333)); - assert_eq!(budget.available(), 0); - clock.advance(Duration::from_millis(1)); - assert_eq!(budget.available(), 1); -} - -#[tokio::test(start_paused = true)] -async fn rate_wait_wakes_when_a_reservation_is_returned() { - let budget = RateBudget::new(100, 1).expect("the rate budget configuration is valid"); - let reservation = budget - .try_reserve(100) - .expect("the full budget covers its capacity"); - let waiting_budget = budget.clone(); - let waiter = tokio::spawn(async move { waiting_budget.wait_for(50).await }); - tokio::task::yield_now().await; - assert!(!waiter.is_finished()); - - drop(reservation); - - tokio::task::yield_now().await; - waiter - .await - .expect("the waiter task should not panic") - .expect("the returned reservation fits the budget"); -} - -#[tokio::test(start_paused = true)] -async fn rate_wait_wakes_at_the_refill_deadline() { - let budget = RateBudget::new(100, 10).expect("the rate budget configuration is valid"); - budget - .try_reserve(100) - .expect("the full budget covers its capacity") - .commit(100) - .expect("the full reservation can be spent") - .finish(); - let waiting_budget = budget.clone(); - let waiter = tokio::spawn(async move { waiting_budget.wait_for(50).await }); - tokio::task::yield_now().await; - assert!(!waiter.is_finished()); - - tokio::time::advance(Duration::from_secs(4)).await; - tokio::task::yield_now().await; - assert!(!waiter.is_finished()); - - tokio::time::advance(Duration::from_secs(1)).await; - tokio::task::yield_now().await; - waiter - .await - .expect("the waiter task should not panic") - .expect("five seconds refill fifty units"); -} - -#[tokio::test] -async fn impossible_waits_return_capacity_errors() { - let outstanding = OutstandingByteBudget::new(10); - assert_eq!( - outstanding.wait_for(11).await, - Err(OutstandingCapacityError { - requested: 11, - capacity: 10, - }) - ); - - let rate = RateBudget::new(10, 1).expect("the rate budget configuration is valid"); - assert_eq!( - rate.wait_for(11).await, - Err(RateReservationError::ExceedsCapacity { - requested: 11, - capacity: 10, - }) - ); -} - -#[tokio::test] -async fn outstanding_wait_does_not_miss_a_concurrent_release() { - let budget = OutstandingByteBudget::new(10); - let reservation = budget - .try_reserve(10) - .expect("the request fits the budget") - .expect("the empty budget admits the reservation"); - let waiting_budget = budget.clone(); - let waiter = tokio::spawn(async move { waiting_budget.wait_for(1).await }); - tokio::task::yield_now().await; - - drop(reservation); - - tokio::time::timeout(Duration::from_secs(1), waiter) - .await - .expect("the release must wake the waiter") - .expect("the waiter task should not panic") - .expect("one byte fits after release"); -} - -#[tokio::test(start_paused = true)] -async fn outstanding_budget_does_not_refill_with_time() { - let budget = OutstandingByteBudget::new(10); - let reservation = budget - .try_reserve(10) - .expect("the request fits the budget") - .expect("the empty budget admits the reservation"); - - tokio::time::advance(Duration::from_secs(24 * 60 * 60)).await; - - assert_eq!(budget.available(), 0); - reservation.release(); - assert_eq!(budget.available(), 10); -} - -#[test] -fn frame_lease_holds_peer_and_node_bytes() { - let node = OutstandingByteBudget::new(100); - let peer = OutstandingByteBudget::new(100); - let mut node_reservation = node - .try_reserve(100) - .expect("the request fits the node budget") - .expect("the node budget is empty"); - let mut peer_reservation = peer - .try_reserve(100) - .expect("the request fits the peer budget") - .expect("the peer budget is empty"); - - let lease = OutstandingByteReservation::transfer_to_frame( - [&mut node_reservation, &mut peer_reservation], - 60, - ) - .expect("both reservations cover the frame"); - assert_eq!(lease.accounted_bytes(), 60); - - drop(node_reservation); - drop(peer_reservation); - assert_eq!(node.reserved(), 60); - assert_eq!(peer.reserved(), 60); - - drop(lease); - assert_eq!(node.reserved(), 0); - assert_eq!(peer.reserved(), 0); -} - -#[test] -fn failed_multi_budget_transfer_changes_nothing() { - let first = OutstandingByteBudget::new(100); - let second = OutstandingByteBudget::new(50); - let mut first_reservation = first - .try_reserve(100) - .expect("the request fits the first budget") - .expect("the first budget is empty"); - let mut second_reservation = second - .try_reserve(50) - .expect("the request fits the second budget") - .expect("the second budget is empty"); - - assert!(OutstandingByteReservation::transfer_to_frame( - [&mut first_reservation, &mut second_reservation], - 60, - ) - .is_none()); - assert_eq!(first_reservation.remaining(), 100); - assert_eq!(second_reservation.remaining(), 50); - assert_eq!(first.reserved(), 100); - assert_eq!(second.reserved(), 50); -} #[test] fn slot_permits_bound_and_release_owned_items() { diff --git a/crates/zakura-network/src/zakura/testkit/block_sync_peer.rs b/crates/zakura-network/src/zakura/testkit/block_sync_peer.rs index 2873dbd27f..c08064e1b0 100644 --- a/crates/zakura-network/src/zakura/testkit/block_sync_peer.rs +++ b/crates/zakura-network/src/zakura/testkit/block_sync_peer.rs @@ -9,9 +9,10 @@ use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; use crate::zakura::{ - framed_channel, BlockSyncHandle, BlockSyncMessage, BlockSyncService, BlockSyncStatus, - FramedRecv, FramedSend, Peer, Service, ServicePeerDirection, ZakuraBlockSyncConfig, - ZakuraPeerId, ZAKURA_CAP_BLOCK_SYNC, ZAKURA_STREAM_BLOCK_SYNC, + framed_channel, transport::ServiceStream, BlockSyncHandle, BlockSyncMessage, BlockSyncService, + BlockSyncStatus, CloseCause, FramedRecv, FramedSend, Peer, Service, ServicePeerDirection, + ZakuraBlockSyncConfig, ZakuraPeerId, ZAKURA_BLOCK_SYNC_STREAM_VERSION, ZAKURA_CAP_BLOCK_SYNC, + ZAKURA_STREAM_BLOCK_REQUESTS, ZAKURA_STREAM_BLOCK_SYNC, }; /// A connected synthetic block-sync peer backed by in-memory stream channels. @@ -19,7 +20,9 @@ use crate::zakura::{ pub struct SyntheticBlockSyncPeer { peer_id: ZakuraPeerId, inbound: FramedSend, + requests: FramedSend, outbound: FramedRecv, + outgoing_requests: FramedRecv, cancel: CancellationToken, } @@ -32,13 +35,22 @@ impl SyntheticBlockSyncPeer { /// Queue a real stream-6 message as inbound peer traffic to the node. pub async fn send(&self, msg: BlockSyncMessage) -> Result<(), crate::BoxError> { let frame = msg.encode_frame()?; - self.inbound.send(frame).await?; + let sender = if matches!(msg, BlockSyncMessage::GetBlocks { .. }) { + &self.requests + } else { + &self.inbound + }; + sender.send(frame).await?; Ok(()) } /// Receive the next real stream-6 message sent by the node to this peer. pub async fn recv(&mut self) -> Result, crate::BoxError> { - let Some(frame) = self.outbound.recv().await else { + let frame = tokio::select! { + frame = self.outbound.recv() => frame, + frame = self.outgoing_requests.recv() => frame, + }; + let Some(frame) = frame else { return Ok(None); }; Ok(Some(BlockSyncMessage::decode_frame(frame)?)) @@ -85,22 +97,46 @@ impl SyntheticBlockSyncPeers { ) -> Result { let (inbound_tx, inbound_rx) = framed_channel(self.queue_depth); let (outbound_tx, outbound_rx) = framed_channel(self.queue_depth); + // These channels also stand in for QUIC's receive buffering. Real QUIC + // gates separately check the one-frame application request queues. + let (requests, request_recv) = framed_channel(self.queue_depth); + let (request_send, outgoing_requests) = framed_channel(self.queue_depth); let cancel = CancellationToken::new(); - let streams = HashMap::from([(ZAKURA_STREAM_BLOCK_SYNC, (inbound_rx, outbound_tx))]); + let session_cancel = cancel.child_token(); + let streams = HashMap::from([ + ( + ZAKURA_STREAM_BLOCK_SYNC, + ServiceStream::new( + 0, + ZAKURA_BLOCK_SYNC_STREAM_VERSION, + inbound_rx, + outbound_tx, + session_cancel.clone(), + ), + ), + ( + ZAKURA_STREAM_BLOCK_REQUESTS, + ServiceStream::new(0, 1, request_recv, request_send, session_cancel), + ), + ]); - self.service.add_peer(Peer::new_with_direction( + self.service.add_peer(Peer::new_with_service_streams( + 0, peer_id.clone(), None, ZAKURA_CAP_BLOCK_SYNC, ServicePeerDirection::Outbound, streams, cancel.clone(), + CloseCause::new(), )); let peer = SyntheticBlockSyncPeer { peer_id, inbound: inbound_tx, + requests, outbound: outbound_rx, + outgoing_requests, cancel, }; peer.send(BlockSyncMessage::Status(status)).await?; diff --git a/crates/zakura-network/src/zakura/testkit/blocksync_fuzz/mod.rs b/crates/zakura-network/src/zakura/testkit/blocksync_fuzz/mod.rs index 608d9fce23..4347c722ee 100644 --- a/crates/zakura-network/src/zakura/testkit/blocksync_fuzz/mod.rs +++ b/crates/zakura-network/src/zakura/testkit/blocksync_fuzz/mod.rs @@ -22,7 +22,8 @@ use tokio_util::sync::CancellationToken; use zakura_chain::block; use super::mock_blocksync::{ - mainnet_genesis_hash, MockApplyFrontier, SyntheticBlockCorpus, SyntheticBlockShape, + mainnet_genesis_hash, CorpusRangeSource, MockApplyFrontier, SyntheticBlockCorpus, + SyntheticBlockShape, }; use super::{SyntheticBlockSyncPeers, TraceCapture}; use crate::zakura::{ @@ -110,6 +111,7 @@ pub(crate) async fn run_scenario( startup.shutdown = shutdown.clone(); let (handle, actions, reactor_task) = crate::zakura::spawn_block_sync_reactor(startup); + let handle = handle.with_range_source(Arc::new(CorpusRangeSource::new(corpus.clone()))); let (committed_tx, mut committed_rx) = watch::channel(initial_verified); @@ -284,21 +286,6 @@ fn spawn_action_driver( break; } } - BlockSyncAction::QueryBlocksByHeightRange { peer, start, count } => { - let blocks = corpus.blocks_in_range(start, count, target); - if handle - .send(BlockSyncEvent::BlockRangeResponseReady { - peer, - start_height: start, - requested_count: count, - blocks, - }) - .await - .is_err() - { - break; - } - } BlockSyncAction::SubmitBlock { owner, source, diff --git a/crates/zakura-network/src/zakura/testkit/blocksync_fuzz/tests.rs b/crates/zakura-network/src/zakura/testkit/blocksync_fuzz/tests.rs index d10961f481..6e72049c3e 100644 --- a/crates/zakura-network/src/zakura/testkit/blocksync_fuzz/tests.rs +++ b/crates/zakura-network/src/zakura/testkit/blocksync_fuzz/tests.rs @@ -441,12 +441,8 @@ async fn fuzz_peer_wedges_after_progress_is_parked() { "the parked peer should have been proven (streak past the initial probe), got {}", report.max_requests_without_block_progress, ); - // The reliability seal engaged (the discount folded the drops in on the way down). - assert!( - report.min_reliability_permille < 1000, - "the wedged peer's reliability must fall as its requests stop delivering, got {}/1000", - report.min_reliability_permille, - ); + // The block-progress deadline can park this peer before its queued-response + // request deadlines expire, so a reliability dip is not required here. } /// Requirement — a peer that WEDGES by *no longer reading our stream* (not merely going @@ -588,10 +584,8 @@ async fn fuzz_peer_slows_radically_is_kept() { (0, 0), "a peer that only slowed down (still delivering) must not be rejected or parked", ); - // It kept delivering, so it was never sealed off like a dropper: its reliability - // recovers to a healthy settled band (late bodies credit back transition timeouts), - // well clear of the sealed (~0) range even though a lone slow peer serving its own - // contiguous floor carries some steady re-request churn. + // Transfer-aware deadlines may avoid every timeout. Otherwise reliability + // must recover as the slow peer continues delivering. assert!( report.final_reliability_permille >= 300, "a slow-but-delivering peer's reliability must stay well clear of the sealed range \ @@ -600,7 +594,8 @@ async fn fuzz_peer_slows_radically_is_kept() { report.min_reliability_permille, ); assert!( - report.final_reliability_permille > report.min_reliability_permille, + report.final_reliability_permille == 1000 + || report.final_reliability_permille > report.min_reliability_permille, "reliability must recover from its transition trough (settled {} vs trough {})", report.final_reliability_permille, report.min_reliability_permille, diff --git a/crates/zakura-network/src/zakura/testkit/cluster.rs b/crates/zakura-network/src/zakura/testkit/cluster.rs index 4c4a02b63b..4aab246198 100644 --- a/crates/zakura-network/src/zakura/testkit/cluster.rs +++ b/crates/zakura-network/src/zakura/testkit/cluster.rs @@ -864,16 +864,6 @@ mod tests { })) .await; } - BlockSyncAction::QueryBlocksByHeightRange { peer, start, count } => { - let _ = handle - .send(BlockSyncEvent::BlockRangeResponseFinished { - peer, - start_height: start, - requested_count: count, - returned_count: 0, - }) - .await; - } BlockSyncAction::RecordBodyUnavailable { .. } | BlockSyncAction::RecordBodyInvalid { .. } | BlockSyncAction::RestartBodyAvailability { .. } @@ -1199,7 +1189,9 @@ mod tests { let (start_height, count) = tokio::time::timeout(Duration::from_secs(5), async { loop { - let frame = hostile.recv_ordered_frame(ZAKURA_STREAM_BLOCK_SYNC).await?; + let frame = hostile + .recv_ordered_frame(crate::zakura::ZAKURA_STREAM_BLOCK_REQUESTS) + .await?; match BlockSyncMessage::decode_frame(frame) .map_err(|error| -> BoxError { Box::new(error) })? { @@ -1216,7 +1208,7 @@ mod tests { }) .await .map_err(|_| -> BoxError { - "timed out waiting for physical stream-6 GetBlocks frame".into() + "timed out waiting for physical request-stream GetBlocks frame".into() })??; assert_eq!(start_height, block::Height(1)); @@ -1227,7 +1219,7 @@ mod tests { .expect("submitted list mutex is not poisoned") .is_empty(), "the test-side responder must not send bodies or trigger submissions before it has \ - physically read GetBlocks from stream 6" + physically read GetBlocks from the request stream" ); let end_height = start_height @@ -1519,6 +1511,39 @@ mod tests { } } + #[tokio::test] + async fn retired_block_sync_layout_keeps_other_negotiated_services_usable( + ) -> Result<(), BoxError> { + let _guard = zakura_test::init(); + let mut cluster = ZakuraTestCluster::new(); + let victim_idx = cluster.spawn_node(70).await?; + let victim = cluster.node(victim_idx); + for (seed, capabilities) in [(71, 1 << 3), (72, (1 << 3) | ZAKURA_CAP_BLOCK_SYNC)] { + let hostile = HostilePeer::connect_native_with_capabilities( + victim, + seed, + capabilities | ZAKURA_CAP_LEGACY_GOSSIP, + ) + .await?; + hostile + .send_frame_with_version( + ZAKURA_STREAM_BLOCK_SYNC, + 2, + b"retired single-stream layout".to_vec(), + ) + .await?; + let good = format!("gossip-after-old-block-stream-{seed}").into_bytes(); + hostile.send_frame(2, good.clone()).await?; + await_until("gossip remains usable", Duration::from_secs(5), || { + victim.recorder().contains_payload(2, &good) + }) + .await?; + hostile.shutdown().await; + } + cluster.shutdown().await; + Ok(()) + } + #[tokio::test] async fn recorder_transport_survives_malformed_header_sync_frame() -> Result<(), BoxError> { let _guard = zakura_test::init(); diff --git a/crates/zakura-network/src/zakura/testkit/download_peer.rs b/crates/zakura-network/src/zakura/testkit/download_peer.rs new file mode 100644 index 0000000000..04cb8131c4 --- /dev/null +++ b/crates/zakura-network/src/zakura/testkit/download_peer.rs @@ -0,0 +1,91 @@ +//! Download-only unit fixtures. Both outgoing roles share an observation queue; +//! the QUIC integration tests exercise independent transport queues and writes. + +use std::{collections::HashMap, net::IpAddr}; + +use tokio_util::sync::CancellationToken; + +use crate::zakura::{ + framed_channel, transport::ServiceStream, CloseCause, FramedRecv, FramedSend, Peer, + ServicePeerDirection, ZakuraConnId, ZakuraPeerId, ZAKURA_BLOCK_SYNC_STREAM_VERSION, + ZAKURA_STREAM_BLOCK_REQUESTS, ZAKURA_STREAM_BLOCK_SYNC, +}; + +pub(crate) struct DownloadOnlyPeer; + +impl DownloadOnlyPeer { + pub(crate) fn create( + id: ZakuraPeerId, + remote_ip: Option, + negotiated: u64, + streams: HashMap, + cancel: CancellationToken, + ) -> Peer { + Self::create_with_direction( + id, + remote_ip, + negotiated, + ServicePeerDirection::Inbound, + streams, + cancel, + ) + } + + pub(crate) fn create_with_direction( + id: ZakuraPeerId, + remote_ip: Option, + negotiated: u64, + direction: ServicePeerDirection, + streams: HashMap, + cancel: CancellationToken, + ) -> Peer { + Self::create_with_conn_id_and_direction( + 0, id, remote_ip, negotiated, direction, streams, cancel, + ) + } + + pub(crate) fn create_with_conn_id_and_direction( + conn_id: ZakuraConnId, + id: ZakuraPeerId, + remote_ip: Option, + negotiated: u64, + direction: ServicePeerDirection, + mut streams: HashMap, + cancel: CancellationToken, + ) -> Peer { + let (data, send) = streams.remove(&ZAKURA_STREAM_BLOCK_SYNC).unwrap(); + assert!(streams.is_empty()); + let (requests_keepalive, requests) = framed_channel(1); + let session_cancel = cancel.child_token(); + let keepalive_cancel = session_cancel.clone(); + tokio::spawn(async move { + keepalive_cancel.cancelled().await; + drop(requests_keepalive); + }); + Peer::new_with_service_streams( + conn_id, + id, + remote_ip, + negotiated, + direction, + HashMap::from([ + ( + ZAKURA_STREAM_BLOCK_SYNC, + ServiceStream::new( + 0, + ZAKURA_BLOCK_SYNC_STREAM_VERSION, + data, + send.clone(), + session_cancel.clone(), + ), + ), + ( + ZAKURA_STREAM_BLOCK_REQUESTS, + ServiceStream::new(0, 1, requests, send, session_cancel), + ), + ]), + cancel, + CloseCause::new(), + ) + } +} diff --git a/crates/zakura-network/src/zakura/testkit/hostile.rs b/crates/zakura-network/src/zakura/testkit/hostile.rs index 058065d63d..c55c0f597c 100644 --- a/crates/zakura-network/src/zakura/testkit/hostile.rs +++ b/crates/zakura-network/src/zakura/testkit/hostile.rs @@ -1,6 +1,9 @@ //! Raw peer harness for adversarial Zakura tests. -use std::collections::HashMap; +use std::{ + collections::HashMap, + sync::atomic::{AtomicU64, Ordering}, +}; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use iroh::endpoint::{Connection, Endpoint, RecvStream, SendStream, VarInt}; @@ -14,8 +17,8 @@ use crate::{ ZakuraLocalLimits, ZakuraPeerId, FRAME_HEADER_BYTES, LEGACY_GOSSIP_VERSION, P2P_V2_ALPN, STREAM_PRELUDE_MAGIC, ZAKURA_BLOCK_SYNC_STREAM_VERSION, ZAKURA_CAP_HEADER_SYNC, ZAKURA_CAP_LEGACY_GOSSIP, ZAKURA_DISCOVERY_STREAM_VERSION, - ZAKURA_HEADER_SYNC_STREAM_VERSION, ZAKURA_STREAM_BLOCK_SYNC, ZAKURA_STREAM_DISCOVERY, - ZAKURA_STREAM_HEADER_SYNC, + ZAKURA_HEADER_SYNC_STREAM_VERSION, ZAKURA_STREAM_BLOCK_REQUESTS, ZAKURA_STREAM_BLOCK_SYNC, + ZAKURA_STREAM_DISCOVERY, ZAKURA_STREAM_HEADER_SYNC, }, BoxError, Config, }; @@ -26,6 +29,8 @@ pub struct HostilePeer { endpoint: Endpoint, connection: Connection, limits: ZakuraLocalLimits, + opens_block_pair: bool, + next_pair_id: AtomicU64, held_streams: Vec, ordered_streams: Mutex>, } @@ -52,6 +57,7 @@ impl HostilePeer { .endpoint(seed) .await?; let victim_addr = victim.node_addr().await; + let opens_block_pair = endpoint.node_id() < victim_addr.node_id; endpoint.add_node_addr(victim_addr.clone())?; let connection = endpoint.connect(victim_addr, P2P_V2_ALPN).await?; let mut config = ZakuraHandshakeConfig::for_network(&Config::default().network); @@ -63,6 +69,8 @@ impl HostilePeer { endpoint, connection, limits, + opens_block_pair, + next_pair_id: AtomicU64::new(1), held_streams: Vec::new(), ordered_streams: Mutex::new(HashMap::new()), }) @@ -128,6 +136,7 @@ impl HostilePeer { | ZAKURA_STREAM_DISCOVERY | ZAKURA_STREAM_HEADER_SYNC | ZAKURA_STREAM_BLOCK_SYNC + | ZAKURA_STREAM_BLOCK_REQUESTS ) { return self.send_ordered_raw_frame(stream_kind, frame).await; } @@ -157,6 +166,9 @@ impl HostilePeer { frame: Frame, ) -> Result<(), BoxError> { let mut streams = self.ordered_streams.lock().await; + if Self::is_block_pair_role(stream_kind, stream_version) { + self.ensure_block_pair(&mut streams).await?; + } let (send, _recv) = match streams.entry((stream_kind, stream_version)) { std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), std::collections::hash_map::Entry::Vacant(entry) => { @@ -184,6 +196,9 @@ impl HostilePeer { stream_version: u16, ) -> Result { let mut streams = self.ordered_streams.lock().await; + if Self::is_block_pair_role(stream_kind, stream_version) { + self.ensure_block_pair(&mut streams).await?; + } let (_send, recv) = match streams.entry((stream_kind, stream_version)) { std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), std::collections::hash_map::Entry::Vacant(entry) => { @@ -196,57 +211,113 @@ impl HostilePeer { Self::read_frame(recv, self.limits.max_frame_bytes).await } - /// Gracefully finish and forget one persistent ordered stream generation. - pub async fn finish_ordered_stream( + fn is_block_pair_role(kind: u16, version: u16) -> bool { + (kind == ZAKURA_STREAM_BLOCK_SYNC && version == ZAKURA_BLOCK_SYNC_STREAM_VERSION) + || (kind == ZAKURA_STREAM_BLOCK_REQUESTS && version == 1) + } + + // Use the same node-id opener as production. Raw fixtures do not run the + // handler's collision-selection loop, so only one endpoint offers a pair. + async fn ensure_block_pair( &self, - stream_kind: u16, - stream_version: u16, + streams: &mut HashMap<(u16, u16), (SendStream, RecvStream)>, ) -> Result<(), BoxError> { - let Some((mut send, _recv)) = self - .ordered_streams - .lock() - .await - .remove(&(stream_kind, stream_version)) - else { + let data = (ZAKURA_STREAM_BLOCK_SYNC, ZAKURA_BLOCK_SYNC_STREAM_VERSION); + let requests = (ZAKURA_STREAM_BLOCK_REQUESTS, 1); + if streams.contains_key(&data) && streams.contains_key(&requests) { return Ok(()); - }; - send.finish()?; - Ok(()) + } + tokio::time::timeout(std::time::Duration::from_secs(3), async { + if self.opens_block_pair { + let pair_id = self.next_pair_id.fetch_add(1, Ordering::Relaxed); + for role in [data, requests] { + let (mut send, recv) = self.connection.open_bi().await?; + self.write_prelude_with_version(&mut send, role.0, role.1) + .await?; + send.write_all(&pair_id.to_le_bytes()).await?; + streams.insert(role, (send, recv)); + } + return Ok(()); + } + let mut pair_id = None; + for _ in 0..16 { + let (mut send, mut recv) = self.connection.accept_bi().await?; + let prelude = Self::read_prelude(&mut recv).await?; + let role = (prelude.stream_kind, prelude.stream_version); + if role != data && role != requests { + send.reset(0u32.into())?; + recv.stop(0u32.into())?; + continue; + } + let mut id = [0; 8]; + recv.read_exact(&mut id).await?; + let id = u64::from_le_bytes(id); + if id == 0 + || pair_id.is_some_and(|previous| previous != id) + || streams.contains_key(&role) + { + return Err("victim opened an invalid block-sync pair".into()); + } + pair_id = Some(id); + streams.insert(role, (send, recv)); + if streams.contains_key(&data) && streams.contains_key(&requests) { + return Ok(()); + } + } + Err("victim did not open both block-sync roles".into()) + }) + .await? + } + + /// Finish a persistent stream, or both roles of its block-sync pair. + pub async fn finish_ordered_stream(&self, kind: u16, version: u16) -> Result<(), BoxError> { + self.retire_ordered_stream(kind, version, false).await } - /// Reset and forget one persistent ordered stream generation. - pub async fn reset_ordered_stream( + /// Reset a persistent stream, or both roles of its block-sync pair. + pub async fn reset_ordered_stream(&self, kind: u16, version: u16) -> Result<(), BoxError> { + self.retire_ordered_stream(kind, version, true).await + } + + async fn retire_ordered_stream( &self, - stream_kind: u16, - stream_version: u16, + kind: u16, + version: u16, + reset: bool, ) -> Result<(), BoxError> { - let Some((mut send, mut recv)) = self - .ordered_streams - .lock() - .await - .remove(&(stream_kind, stream_version)) - else { - return Ok(()); + let mut streams = self.ordered_streams.lock().await; + let roles = if Self::is_block_pair_role(kind, version) { + vec![ + (ZAKURA_STREAM_BLOCK_SYNC, ZAKURA_BLOCK_SYNC_STREAM_VERSION), + (ZAKURA_STREAM_BLOCK_REQUESTS, 1), + ] + } else { + vec![(kind, version)] }; - let code = VarInt::from_u32(0); - send.reset(code)?; - recv.stop(code)?; + for role in roles { + if let Some((mut send, mut recv)) = streams.remove(&role) { + if reset { + send.reset(0u32.into())?; + recv.stop(0u32.into())?; + } else { + send.finish()?; + } + } + } Ok(()) } - /// Replace one persistent ordered stream with a fresh physical generation. - pub async fn reopen_ordered_stream( - &self, - stream_kind: u16, - stream_version: u16, - ) -> Result<(), BoxError> { - self.reset_ordered_stream(stream_kind, stream_version) - .await?; + /// Replace a persistent stream or pair with a fresh physical generation. + pub async fn reopen_ordered_stream(&self, kind: u16, version: u16) -> Result<(), BoxError> { + self.reset_ordered_stream(kind, version).await?; let mut streams = self.ordered_streams.lock().await; + if Self::is_block_pair_role(kind, version) { + return self.ensure_block_pair(&mut streams).await; + } let (mut send, recv) = self.connection.open_bi().await?; - self.write_prelude_with_version(&mut send, stream_kind, stream_version) + self.write_prelude_with_version(&mut send, kind, version) .await?; - streams.insert((stream_kind, stream_version), (send, recv)); + streams.insert((kind, version), (send, recv)); Ok(()) } @@ -422,6 +493,20 @@ impl HostilePeer { stream_kind: u16, declared_payload_len: u32, ) -> Result<(), BoxError> { + if stream_kind == ZAKURA_STREAM_BLOCK_SYNC { + let mut streams = self.ordered_streams.lock().await; + self.ensure_block_pair(&mut streams).await?; + let (send, _) = streams + .get_mut(&(stream_kind, Self::stream_version(stream_kind))) + .unwrap(); + let mut header = Vec::with_capacity(FRAME_HEADER_BYTES); + WriteBytesExt::write_u16::(&mut header, 3)?; + WriteBytesExt::write_u16::(&mut header, 0)?; + WriteBytesExt::write_u32::(&mut header, declared_payload_len)?; + send.write_all(&header).await?; + send.finish()?; + return Ok(()); + } let (mut send, _recv) = self.connection.open_bi().await?; self.write_prelude(&mut send, stream_kind).await?; let mut header = Vec::with_capacity(FRAME_HEADER_BYTES); @@ -486,6 +571,7 @@ impl HostilePeer { ZAKURA_STREAM_DISCOVERY => ZAKURA_DISCOVERY_STREAM_VERSION, ZAKURA_STREAM_HEADER_SYNC => ZAKURA_HEADER_SYNC_STREAM_VERSION, ZAKURA_STREAM_BLOCK_SYNC => ZAKURA_BLOCK_SYNC_STREAM_VERSION, + ZAKURA_STREAM_BLOCK_REQUESTS => 1, _ => 1, } } diff --git a/crates/zakura-network/src/zakura/testkit/mock_blocksync.rs b/crates/zakura-network/src/zakura/testkit/mock_blocksync.rs index 1adc319c8f..472ef7901a 100644 --- a/crates/zakura-network/src/zakura/testkit/mock_blocksync.rs +++ b/crates/zakura-network/src/zakura/testkit/mock_blocksync.rs @@ -56,13 +56,65 @@ impl SyntheticBlockShape { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub(crate) struct SyntheticBlockCorpus { blocks: Arc>>, sizes: Arc>, by_hash: Arc>, } +#[derive(Debug)] +pub(crate) struct CorpusRangeSource { + pub(crate) corpus: SyntheticBlockCorpus, + stats: Option, +} + +impl CorpusRangeSource { + pub(crate) fn new(corpus: SyntheticBlockCorpus) -> Self { + Self { + corpus, + stats: None, + } + } +} + +impl crate::zakura::BlockRangeSource for CorpusRangeSource { + fn read_range( + &self, + request: crate::zakura::BlockRangeRead, + ) -> futures::future::BoxFuture<'static, Result> + { + let corpus = self.corpus.clone(); + let stats = self.stats.clone(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + let (start, count, cap, lease) = request.into_parts(); + let mut blocks = Vec::new(); + let mut bytes = 0usize; + if lease.try_start() { + for (height, block, size) in + corpus.blocks_in_range(start, count, corpus.target_height()) + { + if lease.is_cancelled() + || bytes.saturating_add(size) > usize::try_from(cap).unwrap() + { + break; + } + bytes += size; + blocks.push((height, block, size)); + } + } + if let Some(stats) = stats { + stats.record_request(blocks.len(), bytes); + } + crate::zakura::BlockRangeReadResult::new(blocks, lease) + }) + .await + .map_err(Into::into) + }) + } +} + impl SyntheticBlockCorpus { pub(crate) fn generate(count: u32, seed: u64, shape: SyntheticBlockShape) -> Self { let template = mainnet_block(&BLOCK_MAINNET_1_BYTES); @@ -295,11 +347,12 @@ impl MockApplyFrontier { } } -#[derive(Clone, Default)] +#[derive(Clone, Default, Debug)] struct ThroughputStats { inner: Arc>, } +#[derive(Debug)] struct ThroughputStatsState { started: Instant, committed_blocks: u64, @@ -501,6 +554,7 @@ async fn spawn_mock_node( corpus: &SyntheticBlockCorpus, config: &HarnessConfig, trace: &mut HarnessTrace, + stats: ThroughputStats, ) -> Result { let anchor = (block::Height(0), mainnet_genesis_hash()); let builder = ZakuraTestNode::builder(seed) @@ -517,7 +571,11 @@ async fn spawn_mock_node( }, Some((corpus.target_height(), corpus.tip_hash())), ) - .block_sync_config(config.block_sync_config()); + .block_sync_config(config.block_sync_config()) + .block_range_source(Arc::new(CorpusRangeSource { + corpus: corpus.clone(), + stats: Some(stats), + })); cluster.spawn_node_with_builder(builder).await } @@ -535,7 +593,6 @@ async fn drive_mock_block_sync_actions( node: &ZakuraTestNode, corpus: SyntheticBlockCorpus, apply: Option, - servable_high: block::Height, stats: ThroughputStats, mut needed_blocks_gate: Option>, ) -> JoinHandle<()> { @@ -602,21 +659,6 @@ async fn drive_mock_block_sync_actions( }) .await; } - BlockSyncAction::QueryBlocksByHeightRange { peer, start, count } => { - let blocks = corpus.blocks_in_range(start, count, servable_high); - let response_bytes = blocks - .iter() - .fold(0usize, |sum, (_, _, size)| sum.saturating_add(*size)); - stats.record_request(blocks.len(), response_bytes); - let _ = handle - .send(BlockSyncEvent::BlockRangeResponseReady { - peer, - start_height: start, - requested_count: count, - blocks, - }) - .await; - } BlockSyncAction::SubmitBlock { owner, source, @@ -1069,6 +1111,7 @@ async fn zakura_mock_blocksync_throughput() -> Result<(), BoxError> { &corpus, &config, &mut trace, + stats.clone(), ) .await?; tasks.push(drain_header_sync_actions(cluster.node(index)).await); @@ -1077,7 +1120,6 @@ async fn zakura_mock_blocksync_throughput() -> Result<(), BoxError> { cluster.node(index), corpus.clone(), None, - corpus.target_height(), stats.clone(), None, ) @@ -1101,6 +1143,7 @@ async fn zakura_mock_blocksync_throughput() -> Result<(), BoxError> { &corpus, &config, &mut trace, + stats.clone(), ) .await?; tasks.push(drain_header_sync_actions(cluster.node(leecher_index)).await); @@ -1109,7 +1152,6 @@ async fn zakura_mock_blocksync_throughput() -> Result<(), BoxError> { cluster.node(leecher_index), corpus.clone(), Some(apply), - block::Height(0), stats.clone(), Some(needed_blocks_gate_rx), ) diff --git a/crates/zakura-network/src/zakura/testkit/mod.rs b/crates/zakura-network/src/zakura/testkit/mod.rs index 6f90a56b83..5810e3ea32 100644 --- a/crates/zakura-network/src/zakura/testkit/mod.rs +++ b/crates/zakura-network/src/zakura/testkit/mod.rs @@ -5,6 +5,8 @@ mod block_sync_peer; mod blocksync_fuzz; mod clock; mod cluster; +#[cfg(test)] +mod download_peer; mod endpoint; mod gossip; mod hostile; @@ -21,6 +23,8 @@ mod wait; pub use block_sync_peer::{SyntheticBlockSyncPeer, SyntheticBlockSyncPeers}; pub use clock::{Clock, RealClock, TestClock}; pub use cluster::{ClusterTopology, ZakuraTestCluster}; +#[cfg(test)] +pub(crate) use download_peer::DownloadOnlyPeer; pub use endpoint::LocalEndpointFactory; pub use gossip::GossipNode; pub use hostile::HostilePeer; diff --git a/crates/zakura-network/src/zakura/testkit/node.rs b/crates/zakura-network/src/zakura/testkit/node.rs index 747e0d8537..7bae37b13e 100644 --- a/crates/zakura-network/src/zakura/testkit/node.rs +++ b/crates/zakura-network/src/zakura/testkit/node.rs @@ -228,6 +228,7 @@ pub struct ZakuraTestNodeBuilder { header_sync_request_timeout: Option, supported_capabilities: Option, block_sync_config: ZakuraBlockSyncConfig, + block_range_source: Option>, } #[derive(Clone, Debug)] @@ -283,6 +284,7 @@ impl ZakuraTestNodeBuilder { header_sync_request_timeout: None, supported_capabilities: None, block_sync_config: ZakuraBlockSyncConfig::default(), + block_range_source: None, } } @@ -292,6 +294,12 @@ impl ZakuraTestNodeBuilder { self } + /// Supply owned storage reads for the node's native block-serving task. + pub fn block_range_source(mut self, source: Arc) -> Self { + self.block_range_source = Some(source); + self + } + /// Advertise an additional service id in this node's discovery self-record. pub fn add_advertised_service(mut self, service: ZakuraServiceId) -> Self { self.extra_advertised_services.push(service); @@ -537,6 +545,10 @@ impl ZakuraTestNodeBuilder { startup.shutdown = shutdown; startup.trace = ZakuraTrace::new(self.tracer.clone(), seed_label(self.seed)); let (block_handle, actions, task) = spawn_block_sync_reactor(startup); + let block_handle = match self.block_range_source { + Some(source) => block_handle.with_range_source(source), + None => block_handle, + }; header_sync_tasks.push(task); block_sync_actions = Some(actions); block_sync_handle = Some(block_handle.clone()); @@ -573,7 +585,8 @@ impl ZakuraTestNodeBuilder { self.limits.clone(), registry, ZakuraTrace::new(self.tracer.clone(), seed_label(self.seed)), - ); + ) + .with_endpoint(endpoint.clone()); if let Some(supported_capabilities) = self.supported_capabilities { handler = handler.with_supported_capabilities(supported_capabilities); } diff --git a/crates/zakura-network/src/zakura/transport/io.rs b/crates/zakura-network/src/zakura/transport/io.rs index fba2f75966..3aa114c742 100644 --- a/crates/zakura-network/src/zakura/transport/io.rs +++ b/crates/zakura-network/src/zakura/transport/io.rs @@ -8,12 +8,13 @@ use tokio::sync::mpsc; use super::Frame; -use crate::zakura::regulation::FrameLease; +use std::sync::Arc; /// Receive half for bounded, rate-admitted Zakura frames. #[derive(Debug)] pub struct FramedRecv { receiver: FramedReceiver, + remote_close: Option, } #[derive(Debug)] @@ -27,21 +28,47 @@ impl FramedRecv { pub fn new(receiver: mpsc::Receiver) -> Self { Self { receiver: FramedReceiver::Plain(receiver), + remote_close: None, } } fn queued(receiver: mpsc::Receiver) -> Self { Self { receiver: FramedReceiver::Queued(receiver), + remote_close: None, } } + pub(crate) fn with_remote_close( + mut self, + remote_close: tokio_util::sync::CancellationToken, + ) -> Self { + self.remote_close = Some(remote_close); + self + } + + /// Whether the peer closed either role of this pair before local cancellation. + pub(crate) fn remotely_closed(&self) -> bool { + self.remote_close + .as_ref() + .is_some_and(|closed| closed.is_cancelled()) + } + /// Receive the next admitted frame, or `None` after the transport closes the stream. pub async fn recv(&mut self) -> Option { match &mut self.receiver { FramedReceiver::Plain(receiver) => receiver.recv().await, FramedReceiver::Queued(receiver) => { - receiver.recv().await.map(|queued| queued.into_parts().0) + while let Some(queued) = receiver.recv().await { + if let Some(claim) = &queued.claim { + if !claim.try_start() { + continue; + } + claim.written(); + } + return Some(queued.frame); + } + None } } } @@ -51,6 +78,7 @@ impl FramedRecv { #[derive(Clone, Debug)] pub struct FramedSend { sender: FramedSender, + session_resources: Option>, } #[derive(Clone, Debug)] @@ -64,15 +92,26 @@ impl FramedSend { pub fn new(sender: mpsc::Sender) -> Self { Self { sender: FramedSender::Plain(sender), + session_resources: None, } } fn queued(sender: mpsc::Sender) -> Self { Self { sender: FramedSender::Queued(sender), + session_resources: None, } } + /// Keep service admission charged while application senders still own the session. + pub(crate) fn with_session_resources( + mut self, + resources: Option>, + ) -> Self { + self.session_resources = resources; + self + } + /// Queue a frame for transport-owned encoding and writing. pub async fn send(&self, frame: Frame) -> Result<(), mpsc::error::SendError> { match &self.sender { @@ -94,29 +133,32 @@ impl FramedSend { } } - /// Reserve a queue slot before attaching an outstanding-byte lease. - /// - /// `make_lease` is called only after the transport owns a queue slot. This - /// prevents accounting from moving to the transport when the queue is full - /// or closed. - #[allow(dead_code)] // consumed by the GetBlocks policy in the stacked PR - pub(crate) fn try_send_leased( - &self, - frame: Frame, - make_lease: impl FnOnce() -> FrameLease, - ) -> Result<(), LeasedSendError> { + /// Reserve queue space before encoding a response or sharing its ownership. + pub(crate) fn try_reserve_guarded(&self) -> Result, GuardedReserveError> { let FramedSender::Queued(sender) = &self.sender else { - return Err(LeasedSendError::Unsupported(frame)); + return Err(GuardedReserveError::Unsupported); }; + sender + .try_reserve() + .map(|permit| GuardedFrameSlot { permit, sender }) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(()) => GuardedReserveError::Full, + mpsc::error::TrySendError::Closed(()) => GuardedReserveError::Closed, + }) + } - match sender.try_reserve() { - Ok(slot) => { - slot.send(QueuedFrame::leased(frame, make_lease())); - Ok(()) - } - Err(mpsc::error::TrySendError::Full(())) => Err(LeasedSendError::Full(frame)), - Err(mpsc::error::TrySendError::Closed(())) => Err(LeasedSendError::Closed(frame)), - } + /// Wait for queue space. Cancellation leaves response ownership with the caller. + pub(crate) async fn reserve_guarded( + &self, + ) -> Result, GuardedReserveError> { + let FramedSender::Queued(sender) = &self.sender else { + return Err(GuardedReserveError::Unsupported); + }; + sender + .reserve() + .await + .map(|permit| GuardedFrameSlot { permit, sender }) + .map_err(|_| GuardedReserveError::Closed) } /// Current free slots in the bounded transport queue. @@ -136,71 +178,120 @@ impl FramedSend { } } -/// Failure to queue a leased frame. +/// One reserved queue slot. Dropping it returns capacity without sending a frame. #[derive(Debug)] -#[allow(dead_code)] // consumed by the GetBlocks policy in the stacked PR -pub(crate) enum LeasedSendError { - /// The bounded transport queue has no free slot. - Full(Frame), - /// The transport worker has closed its receive half. - Closed(Frame), - /// This handle wraps a compatibility channel without lease support. - Unsupported(Frame), +pub(crate) struct GuardedFrameSlot<'a> { + permit: mpsc::Permit<'a, QueuedFrame>, + sender: &'a mpsc::Sender, } -#[allow(dead_code)] // consumed by the GetBlocks policy in the stacked PR -impl LeasedSendError { - /// Recover the frame that was not queued. - pub(crate) fn into_frame(self) -> Frame { - match self { - Self::Full(frame) | Self::Closed(frame) | Self::Unsupported(frame) => frame, - } +impl GuardedFrameSlot<'_> { + /// Transfer a validated frame and its ownership to the reserved queue slot. + pub(crate) fn send(self, frame: Frame, guard: FrameGuard) { + self.permit.send(QueuedFrame::guarded(frame, guard)); } - /// Return whether the queue was temporarily full. - pub(crate) fn is_full(&self) -> bool { - matches!(self, Self::Full(_)) + /// Publish a request whose ownership must be claimed before its first byte. + /// A false result requires explicit settlement after publication unlocks: + /// Tokio can retain a send made through a permit after its receiver drops. + pub(crate) fn send_request(self, frame: Frame, claim: Arc) -> bool { + if self.sender.is_closed() { + return false; + } + self.permit.send(QueuedFrame { + frame, + guard: None, + claim: Some(claim), + }); + !self.sender.is_closed() } +} - /// Return whether the worker permanently closed the queue. - pub(crate) fn is_closed(&self) -> bool { - matches!(self, Self::Closed(_)) +/// Arbitrates an unwritten request against expiry and reset. Dropping a started +/// but unfinished claim must retire the stream session before another write. +pub(crate) trait FrameWriteClaim: std::fmt::Debug + Send + Sync { + /// Atomically claim current ownership, or skip this obsolete frame. + fn try_start(&self) -> bool; + /// Mark the complete frame accepted by the transport write. + fn written(&self); +} + +/// Failure to reserve space for a guarded response. +#[derive(Debug)] +pub(crate) enum GuardedReserveError { + /// The bounded transport queue has no free slot. + Full, + /// The transport worker has closed its receive half. + Closed, + /// This handle wraps a compatibility channel without guard support. + Unsupported, +} + +/// Shared service ownership held until a frame finishes its application write. +/// +/// This is a completion guard, not a byte budget or acknowledgement of delivery. +/// QUIC owns its own bounded send buffers after the write accepts the frame. +#[derive(Clone, Debug)] +pub(crate) struct FrameGuard { + _owner: Arc, +} + +impl FrameGuard { + /// Share an existing work owner without acquiring more capacity. + pub(crate) fn new(owner: Arc) -> Self { + Self { _owner: owner } } } -/// Frame plus optional byte ownership retained through its transport write. +/// Frame plus optional response ownership retained through its transport write. #[derive(Debug)] pub(crate) struct QueuedFrame { frame: Frame, - lease: Option, + guard: Option, + claim: Option>, } impl QueuedFrame { fn plain(frame: Frame) -> Self { - Self { frame, lease: None } + Self { + frame, + guard: None, + claim: None, + } } - #[allow(dead_code)] // consumed through `try_send_leased` in the stacked PR - fn leased(frame: Frame, lease: FrameLease) -> Self { + fn guarded(frame: Frame, guard: FrameGuard) -> Self { Self { frame, - lease: Some(lease), + guard: Some(guard), + claim: None, } } - /// Split the frame from its lease while retaining both in the caller. - pub(crate) fn into_parts(self) -> (Frame, Option) { - (self.frame, self.lease) + /// Split the frame from its guard while retaining both in the caller. + pub(crate) fn into_parts(self) -> (Frame, Option) { + (self.frame, self.guard) } - /// Run the transport write while retaining this frame's lease. - pub(crate) async fn write_with(self, write: F) -> T + /// Run the transport write while retaining this frame's guard. + pub(crate) async fn write_with(self, write: F) -> Result<(), E> where F: FnOnce(Frame) -> Fut, - Fut: std::future::Future, + Fut: std::future::Future>, { - let (frame, _lease) = self.into_parts(); - write(frame).await + let Self { + frame, + guard: _guard, + claim, + } = self; + if claim.as_ref().is_some_and(|claim| !claim.try_start()) { + return Ok(()); + } + write(frame).await?; + if let Some(claim) = &claim { + claim.written(); + } + Ok(()) } } @@ -244,22 +335,30 @@ fn map_queued_try_send_error( #[cfg(test)] mod tests { - use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }; + use std::sync::Arc; use super::*; - use crate::zakura::regulation::{OutstandingByteBudget, OutstandingByteReservation}; + use crate::zakura::regulation::SlotBudget; fn frame(message_type: u16) -> Frame { Frame { message_type, flags: 0, - payload: vec![u8::try_from(message_type).unwrap_or(u8::MAX)], + payload: vec![u8::try_from(message_type).unwrap_or(u8::MAX); 10], } } + fn guarded_queue() -> (FramedSend, FramedWorkerRecv, SlotBudget) { + let (sender, receiver) = worker_framed_channel(1); + let budget = SlotBudget::new(1).unwrap(); + let reservation = Arc::new(budget.try_reserve().expect("the producer is free")); + sender + .try_reserve_guarded() + .expect("the worker queue has a slot") + .send(frame(1), FrameGuard::new(reservation)); + (sender, receiver, budget) + } + #[tokio::test] async fn public_channel_preserves_order_capacity_and_errors() { let (sender, mut receiver) = framed_channel(2); @@ -305,47 +404,22 @@ mod tests { } #[tokio::test] - async fn queued_frame_holds_lease_until_transport_consumes_it() { - let (sender, mut receiver) = worker_framed_channel(1); - let budget = OutstandingByteBudget::new(10); - let mut reservation = budget - .try_reserve(10) - .expect("the frame fits the budget") - .expect("the budget has capacity"); - - sender - .try_send_leased(frame(1), || { - OutstandingByteReservation::transfer_to_frame([&mut reservation], 10) - .expect("the reservation covers the frame") - }) - .expect("the worker queue has a slot"); - drop(reservation); - assert_eq!(budget.reserved(), 10); + async fn queued_frame_holds_guard_until_transport_consumes_it() { + let (_sender, mut receiver, budget) = guarded_queue(); + assert_eq!(budget.reserved(), 1); let queued = receiver.recv().await.expect("worker receives the frame"); - let (received, lease) = queued.into_parts(); + let (received, guard) = queued.into_parts(); assert_eq!(received, frame(1)); - assert_eq!(budget.reserved(), 10); + assert_eq!(budget.reserved(), 1); - drop(lease); + drop(guard); assert_eq!(budget.reserved(), 0); } #[tokio::test] - async fn queued_frame_holds_lease_while_write_is_pending() { - let (sender, mut receiver) = worker_framed_channel(1); - let budget = OutstandingByteBudget::new(10); - let mut reservation = budget - .try_reserve(10) - .expect("the frame fits the budget") - .expect("the budget has capacity"); - sender - .try_send_leased(frame(1), || { - OutstandingByteReservation::transfer_to_frame([&mut reservation], 10) - .expect("the reservation covers the frame") - }) - .expect("the worker queue has a slot"); - drop(reservation); + async fn queued_frame_holds_guard_while_write_is_pending() { + let (_sender, mut receiver, budget) = guarded_queue(); let queued = receiver.recv().await.expect("worker receives the frame"); let (started_tx, started_rx) = tokio::sync::oneshot::channel(); let (finish_tx, finish_rx) = tokio::sync::oneshot::channel(); @@ -353,39 +427,31 @@ mod tests { let write = tokio::spawn(queued.write_with(move |_frame| async move { let _ = started_tx.send(()); let _ = finish_rx.await; + Ok::<_, std::convert::Infallible>(()) })); started_rx.await.expect("the write reaches its wait point"); - assert_eq!(budget.reserved(), 10); + assert_eq!(budget.reserved(), 1); let _ = finish_tx.send(()); - write.await.expect("the write task should not panic"); + write + .await + .expect("the write task should not panic") + .unwrap(); assert_eq!(budget.reserved(), 0); } #[tokio::test] - async fn cancelling_pending_write_releases_lease() { - let (sender, mut receiver) = worker_framed_channel(1); - let budget = OutstandingByteBudget::new(10); - let mut reservation = budget - .try_reserve(10) - .expect("the frame fits the budget") - .expect("the budget has capacity"); - sender - .try_send_leased(frame(1), || { - OutstandingByteReservation::transfer_to_frame([&mut reservation], 10) - .expect("the reservation covers the frame") - }) - .expect("the worker queue has a slot"); - drop(reservation); + async fn cancelling_pending_write_releases_guard() { + let (_sender, mut receiver, budget) = guarded_queue(); let queued = receiver.recv().await.expect("worker receives the frame"); let (started_tx, started_rx) = tokio::sync::oneshot::channel(); let write = tokio::spawn(queued.write_with(move |_frame| async move { let _ = started_tx.send(()); - std::future::pending::<()>().await; + std::future::pending::>().await })); started_rx.await.expect("the write reaches its wait point"); - assert_eq!(budget.reserved(), 10); + assert_eq!(budget.reserved(), 1); write.abort(); assert!(write @@ -395,63 +461,197 @@ mod tests { assert_eq!(budget.reserved(), 0); } - #[test] - fn failed_leased_send_does_not_create_a_lease() { - let (sender, _receiver) = worker_framed_channel(1); - sender.try_send(frame(1)).expect("the queue slot is free"); - let full_called = Arc::new(AtomicBool::new(false)); - let called_by_factory = full_called.clone(); - - let result = sender.try_send_leased(frame(2), move || { - called_by_factory.store(true, Ordering::SeqCst); - FrameLease::empty_for_test() - }); + #[tokio::test] + async fn waiting_guarded_send_transfers_only_after_capacity_and_holds_through_write() { + let (sender, mut receiver) = worker_framed_channel(1); + sender.try_send(frame(1)).expect("filler fits"); + let budget = SlotBudget::new(1).unwrap(); + let reservation = Arc::new(budget.try_reserve().expect("the producer is free")); + let mut pending = Box::pin(sender.reserve_guarded()); + assert!(futures::poll!(&mut pending).is_pending()); + assert_eq!(Arc::strong_count(&reservation), 1); + assert_eq!(budget.reserved(), 1); + drop(receiver.recv().await.expect("filler queued")); + pending + .await + .expect("the freed slot admits the frame") + .send(frame(2), FrameGuard::new(reservation.clone())); + drop(reservation); + assert_eq!(budget.reserved(), 1); + let queued = receiver.recv().await.expect("guarded frame queued"); + let mut write = Box::pin(queued.write_with(|received| async move { + assert_eq!(received, frame(2)); + std::future::pending::>().await + })); + assert!(futures::poll!(&mut write).is_pending()); + assert_eq!(budget.reserved(), 1); + drop(write); + assert_eq!(budget.reserved(), 0); + } - assert!(matches!(result, Err(LeasedSendError::Full(_)))); - assert!(!full_called.load(Ordering::SeqCst)); + #[tokio::test] + async fn cancelling_or_closing_a_guarded_queue_wait_keeps_the_callers_reservation() { + let (sender, mut receiver) = worker_framed_channel(1); + sender.try_send(frame(1)).expect("filler fits"); + let budget = SlotBudget::new(1).unwrap(); + let reservation = Arc::new(budget.try_reserve().expect("the producer is free")); + let mut pending = Box::pin(sender.reserve_guarded()); + assert!(futures::poll!(&mut pending).is_pending()); + drop(pending); + drop(receiver.recv().await.expect("filler queued")); + assert_eq!(Arc::strong_count(&reservation), 1); + assert_eq!(budget.reserved(), 1); + drop(receiver); + assert!(matches!( + sender.reserve_guarded().await, + Err(GuardedReserveError::Closed) + )); + assert_eq!(Arc::strong_count(&reservation), 1); + assert_eq!(budget.reserved(), 1); + drop(reservation); + assert_eq!(budget.reserved(), 0); + } + #[test] + fn guarded_reservation_reports_full_closed_and_unsupported_queues() { let (sender, receiver) = worker_framed_channel(1); + sender.try_send(frame(1)).unwrap(); + assert!(matches!( + sender.try_reserve_guarded(), + Err(GuardedReserveError::Full) + )); drop(receiver); - let closed_called = Arc::new(AtomicBool::new(false)); - let called_by_factory = closed_called.clone(); - let result = sender.try_send_leased(frame(3), move || { - called_by_factory.store(true, Ordering::SeqCst); - FrameLease::empty_for_test() - }); - assert!(matches!(result, Err(LeasedSendError::Closed(_)))); - assert!(!closed_called.load(Ordering::SeqCst)); + assert!(matches!( + sender.try_reserve_guarded(), + Err(GuardedReserveError::Closed) + )); let (raw_sender, _raw_receiver) = mpsc::channel(1); let sender = FramedSend::new(raw_sender); - let unsupported_called = Arc::new(AtomicBool::new(false)); - let called_by_factory = unsupported_called.clone(); - let result = sender.try_send_leased(frame(4), move || { - called_by_factory.store(true, Ordering::SeqCst); - FrameLease::empty_for_test() - }); - assert!(matches!(result, Err(LeasedSendError::Unsupported(_)))); - assert!(!unsupported_called.load(Ordering::SeqCst)); + assert!(matches!( + sender.try_reserve_guarded(), + Err(GuardedReserveError::Unsupported) + )); } #[test] - fn dropping_worker_queue_releases_queued_lease() { - let (sender, receiver) = worker_framed_channel(1); - let budget = OutstandingByteBudget::new(10); - let mut reservation = budget - .try_reserve(10) - .expect("the frame fits the budget") - .expect("the budget has capacity"); - sender - .try_send_leased(frame(1), || { - OutstandingByteReservation::transfer_to_frame([&mut reservation], 10) - .expect("the reservation covers the frame") - }) - .expect("the worker queue has a slot"); - drop(reservation); - assert_eq!(budget.reserved(), 10); + fn dropping_reserved_slot_returns_queue_capacity() { + let (sender, _receiver) = worker_framed_channel(1); + let slot = sender.try_reserve_guarded().unwrap(); + assert_eq!(sender.capacity(), 0); + drop(slot); + assert_eq!(sender.capacity(), 1); + } + + #[test] + fn dropping_worker_queue_releases_queued_guard() { + let (_sender, receiver, budget) = guarded_queue(); + assert_eq!(budget.reserved(), 1); drop(receiver); assert_eq!(budget.reserved(), 0); } + #[tokio::test] + async fn quic_backpressure_holds_producer_and_preserves_another_stream() { + use crate::zakura::testkit::LocalEndpointFactory; + use iroh::{ + endpoint::{Connection, TransportConfig, VarInt}, + protocol::{AcceptError, ProtocolHandler, Router}, + }; + use std::time::Duration; + + #[derive(Debug)] + struct AcceptConnection(mpsc::Sender); + impl ProtocolHandler for AcceptConnection { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let _ = self.0.send(connection).await; + Ok(()) + } + } + + const ALPN: &[u8] = b"/zakura/test/producer-backpressure"; + // Scale down the windows so a single bounded frame reaches flow control. + let transport_config = || { + let mut config = TransportConfig::default(); + config + .stream_receive_window(VarInt::from_u32(16 * 1024)) + .receive_window(VarInt::from_u32(128 * 1024)) + .send_window(128 * 1024); + config + }; + let server = LocalEndpointFactory::with_transport_config(transport_config()) + .endpoint(92_001) + .await + .unwrap(); + let client = LocalEndpointFactory::with_transport_config(transport_config()) + .endpoint(92_002) + .await + .unwrap(); + let (accepted, mut incoming) = mpsc::channel(1); + let router = Router::builder(server) + .accept(ALPN, AcceptConnection(accepted)) + .spawn(); + let address = LocalEndpointFactory::node_addr(router.endpoint()).await; + let connection = client.connect(address, ALPN).await.unwrap(); + let remote = tokio::time::timeout(Duration::from_secs(5), incoming.recv()) + .await + .unwrap() + .unwrap(); + let (mut send, _recv) = connection.open_bi().await.unwrap(); + let producer = SlotBudget::new(1).unwrap(); + let owner = Arc::new(producer.try_reserve().unwrap()); + let (queue, mut writer) = worker_framed_channel(1); + queue.try_reserve_guarded().unwrap().send( + Frame { + message_type: 1, + flags: 0, + payload: vec![0; 2_000_001], + }, + FrameGuard::new(owner.clone()), + ); + drop(owner); + let queued = writer.recv().await.unwrap(); + let mut write = tokio::spawn(queued.write_with(move |frame| async move { + send.write_all(&frame.payload).await.unwrap(); + send.finish().unwrap(); + Ok::<_, std::convert::Infallible>(()) + })); + let (_remote_send, mut slow_read) = + tokio::time::timeout(Duration::from_secs(5), remote.accept_bi()) + .await + .unwrap() + .unwrap(); + assert!(tokio::time::timeout(Duration::from_millis(100), &mut write) + .await + .is_err()); + assert!( + producer.try_reserve().is_none(), + "a pending QUIC write retains the producer" + ); + + let (mut other_send, _other_recv) = connection.open_bi().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + other_send.write_all(b"progress").await.unwrap(); + other_send.finish().unwrap(); + let (_send, mut recv) = remote.accept_bi().await.unwrap(); + assert_eq!(recv.read_to_end(8).await.unwrap(), b"progress"); + }) + .await + .expect("the blocked stream does not consume all connection credit"); + assert!(producer.try_reserve().is_none()); + tokio::time::timeout(Duration::from_secs(5), async { + assert_eq!( + slow_read.read_to_end(2_000_001).await.unwrap().len(), + 2_000_001 + ); + write.await.unwrap().unwrap(); + }) + .await + .expect("draining the peer resumes the write"); + assert!(producer.try_reserve().is_some()); + connection.close(0u32.into(), b"done"); + client.close().await; + router.shutdown().await.unwrap(); + } } diff --git a/crates/zakura-network/src/zakura/transport/mod.rs b/crates/zakura-network/src/zakura/transport/mod.rs index bd787c4ea7..9de40036eb 100644 --- a/crates/zakura-network/src/zakura/transport/mod.rs +++ b/crates/zakura-network/src/zakura/transport/mod.rs @@ -20,8 +20,10 @@ pub use frame::{Frame, StreamPrelude, ZakuraTrace}; #[allow(unused_imports)] pub(crate) use guard::{Admit, ByteBudget, PeerMeters, SessionGuard}; pub use io::{framed_channel, FramedRecv, FramedSend}; -#[allow(unused_imports)] // used by the first message policy in the stacked PR -pub(crate) use io::{worker_framed_channel, FramedWorkerRecv, LeasedSendError, QueuedFrame}; +pub(crate) use io::{ + worker_framed_channel, FrameGuard, FrameWriteClaim, FramedWorkerRecv, GuardedReserveError, + QueuedFrame, +}; pub(crate) use pipe::{ handle_pipe_exit, spawn_supervised_peer_task, spawn_supervised_pipe, CloseCause, Edge, Flow, Node, NodeKind, Pipe, PipeCx, PipeShape, @@ -29,7 +31,8 @@ pub(crate) use pipe::{ pub use registry::{RegistryError, ServiceRegistry}; pub(crate) use service::ServiceStream; pub use service::{ - BoxRunFuture, OrderedSessionDemand, OrderedStreamOpening, OrderedStreamPolicy, Peer, - RequestResponseService, Service, Sink, SinkReject, Source, Stream, StreamMode, + BoxRunFuture, OrderedSessionDemand, OrderedSessionFull, OrderedSessionResources, + OrderedStreamOpening, OrderedStreamPair, OrderedStreamPolicy, Peer, RequestResponseService, + Service, Sink, SinkReject, Source, Stream, StreamMode, }; pub use session::{OrderedSendError, PeerStreamSession}; diff --git a/crates/zakura-network/src/zakura/transport/registry.rs b/crates/zakura-network/src/zakura/transport/registry.rs index 48cb07ff63..1173d4f7e1 100644 --- a/crates/zakura-network/src/zakura/transport/registry.rs +++ b/crates/zakura-network/src/zakura/transport/registry.rs @@ -8,13 +8,22 @@ use std::{ use thiserror::Error; use super::{ - Frame, OrderedSessionDemand, OrderedStreamPolicy, Peer, Service, SinkReject, Stream, StreamMode, + Frame, OrderedSessionDemand, OrderedStreamPair, OrderedStreamPolicy, Peer, Service, SinkReject, + Stream, StreamMode, }; use crate::zakura::{ServicePeerDirection, ZakuraConnId, ZakuraPeerId}; /// Errors returned while building a [`ServiceRegistry`]. #[derive(Debug, Error)] pub enum RegistryError { + /// A paired session has missing, mismatched, or inconsistently declared roles. + #[error("service {service} declared an invalid ordered stream pair for kind {kind}")] + InvalidOrderedPair { + /// Service declaring the pair. + service: &'static str, + /// Stream with an inconsistent pair declaration. + kind: u16, + }, /// Two services declared the same stream kind. #[error( "duplicate Zakura stream kind {kind} declared by {first_service} and {second_service}" @@ -81,6 +90,26 @@ impl ServiceRegistry { let mut service_streams = HashSet::new(); for stream in service.streams() { + if let Some(pair) = service.ordered_stream_pair(*stream) { + let valid = pair.data != pair.requests + && pair.data.kind != pair.requests.kind + && pair.data.mode == StreamMode::Ordered + && pair.requests.mode == StreamMode::Ordered + && pair.data.capability == pair.requests.capability + && (*stream == pair.data || *stream == pair.requests) + && service.streams().contains(&pair.data) + && service.streams().contains(&pair.requests) + && service.ordered_stream_pair(pair.data) == Some(pair) + && service.ordered_stream_pair(pair.requests) == Some(pair) + && service.ordered_stream_policy(pair.data.kind) + == service.ordered_stream_policy(pair.requests.kind); + if !valid { + return Err(RegistryError::InvalidOrderedPair { + service: service.name(), + kind: stream.kind, + }); + } + } // Each stream must map to exactly one capability bit, otherwise // `supported_capabilities` (the OR below) and per-bit // `services_for_capability` lookups disagree. @@ -141,6 +170,22 @@ impl ServiceRegistry { .map(|index| Arc::clone(&self.services[*index])) } + /// Local message limits supplied by the service that owns this stream. + pub(crate) fn message_payload_limits(&self, stream: Stream) -> &'static [(u16, usize)] { + self.service_for_kind(stream.kind) + .map(|service| service.message_payload_limits(stream)) + .unwrap_or(&[]) + } + + pub(crate) fn stream_queue_depths(&self, stream: Stream) -> Option<(usize, usize)> { + self.service_for_kind(stream.kind)? + .stream_queue_depths(stream) + } + + pub(crate) fn message_types(&self, stream: Stream) -> Option<&'static [u16]> { + self.service_for_kind(stream.kind)?.message_types(stream) + } + /// Lookup the declared stream for `kind`. pub fn stream_for_kind(&self, kind: u16) -> Option { let service = self.service_for_kind(kind)?; @@ -215,13 +260,19 @@ impl ServiceRegistry { self.supported_capabilities } + /// Return this exact stream version's validated pair declaration. + pub fn ordered_stream_pair(&self, stream: Stream) -> Option { + self.service_for_kind(stream.kind)? + .ordered_stream_pair(stream) + } + /// Ordered streams negotiated with a peer, in registry service order. pub fn ordered_streams_for_negotiated(&self, negotiated: u64) -> Vec { let mut streams = Vec::new(); for service in self.services_for_negotiated(negotiated) { streams.extend(selected_streams( - service.streams(), + service.as_ref(), negotiated, StreamMode::Ordered, )); @@ -250,7 +301,7 @@ impl ServiceRegistry { } streams.extend(selected_streams( - service.streams(), + service.as_ref(), negotiated, StreamMode::Ordered, )); @@ -297,13 +348,28 @@ impl ServiceRegistry { service.ordered_session_demand(conn_id, peer_id, negotiated, direction) } + /// Recheck demand after a complete pair has reserved its service capacity. + pub(crate) fn reserved_ordered_session_demand( + &self, + kind: u16, + conn_id: ZakuraConnId, + negotiated: u64, + peer_id: &ZakuraPeerId, + direction: ServicePeerDirection, + ) -> OrderedSessionDemand { + let Some(service) = self.service_for_kind(kind) else { + return OrderedSessionDemand::Retire; + }; + service.reserved_ordered_session_demand(conn_id, peer_id, negotiated, direction) + } + /// Request/response streams negotiated with a peer, in registry service order. pub fn request_response_streams_for_negotiated(&self, negotiated: u64) -> Vec { let mut streams = Vec::new(); for service in self.services_for_negotiated(negotiated) { streams.extend(selected_streams( - service.streams(), + service.as_ref(), negotiated, StreamMode::RequestResponse, )); @@ -470,9 +536,10 @@ impl ServiceRegistry { /// Each capability bit declares one version alternative. /// Select the highest matching version before opening the prelude. /// The stream selection scopes decoding and preserves existing streams for older peers. -fn selected_streams(streams: &[Stream], negotiated: u64, mode: StreamMode) -> Vec { +fn selected_streams(service: &dyn Service, negotiated: u64, mode: StreamMode) -> Vec { let mut selected = Vec::::new(); - for stream in streams + for stream in service + .streams() .iter() .copied() .filter(|stream| stream.mode == mode && negotiated & stream.capability != 0) @@ -489,6 +556,14 @@ fn selected_streams(streams: &[Stream], negotiated: u64, mode: StreamMode) -> Ve } } selected + .iter() + .copied() + .filter(|stream| { + service.ordered_stream_pair(*stream).is_none_or(|pair| { + selected.contains(&pair.data) && selected.contains(&pair.requests) + }) + }) + .collect() } #[cfg(test)] @@ -508,6 +583,7 @@ mod tests { added: Mutex>, added_streams: Mutex>>, removed: Mutex>, + pairs: Vec, } impl TestService { @@ -519,6 +595,7 @@ mod tests { added: Mutex::new(Vec::new()), added_streams: Mutex::new(Vec::new()), removed: Mutex::new(Vec::new()), + pairs: Vec::new(), }) } @@ -539,6 +616,13 @@ mod tests { &self.streams } + fn ordered_stream_pair(&self, stream: Stream) -> Option { + self.pairs + .iter() + .copied() + .find(|pair| pair.data == stream || pair.requests == stream) + } + fn wants_peer( &self, _peer: &ZakuraPeerId, @@ -636,6 +720,62 @@ mod tests { ); } + #[test] + fn paired_versions_are_selected_together_and_cannot_leave_an_orphan_role() { + let legacy = versioned_stream(6, 2, 1); + let pair = OrderedStreamPair { + data: versioned_stream(6, 3, 2), + requests: versioned_stream(7, 1, 2), + }; + let later = versioned_stream(6, 4, 4); + let mut service = TestService::new("pair", vec![legacy, pair.data, pair.requests, later]); + Arc::get_mut(&mut service).unwrap().pairs.push(pair); + let registry = ServiceRegistry::new(vec![service]).unwrap(); + assert_eq!(registry.ordered_streams_for_negotiated(1), vec![legacy]); + assert_eq!( + registry.ordered_streams_for_negotiated(3), + vec![pair.data, pair.requests] + ); + assert_eq!(registry.ordered_streams_for_negotiated(7), vec![later]); + } + + #[test] + fn invalid_pair_declarations_are_rejected_before_handshake() { + let data = versioned_stream(6, 3, 2); + let requests = versioned_stream(7, 1, 2); + for pair in [ + OrderedStreamPair { + data, + requests: data, + }, + OrderedStreamPair { + data, + requests: Stream { + capability: 4, + ..requests + }, + }, + OrderedStreamPair { + data, + requests: Stream { + mode: StreamMode::RequestResponse, + ..requests + }, + }, + OrderedStreamPair { + data, + requests: versioned_stream(8, 1, 2), + }, + ] { + let mut service = TestService::new("pair", vec![data, requests]); + Arc::get_mut(&mut service).unwrap().pairs.push(pair); + assert!(matches!( + ServiceRegistry::new(vec![service]), + Err(RegistryError::InvalidOrderedPair { .. }) + )); + } + } + #[test] fn duplicate_kind_and_version_in_one_service_is_rejected() { let header = TestService::new( diff --git a/crates/zakura-network/src/zakura/transport/service.rs b/crates/zakura-network/src/zakura/transport/service.rs index db7dfd7935..b44cc1ae71 100644 --- a/crates/zakura-network/src/zakura/transport/service.rs +++ b/crates/zakura-network/src/zakura/transport/service.rs @@ -90,6 +90,33 @@ pub struct Stream { pub mode: StreamMode, } +/// Two persistent ordered streams selected and retired as one service session. +/// +/// Both roles belong to the same service and capability. The data stream is the +/// session's transport identity; the request stream shares its cancellation and +/// message budget. Each role carries the same nonzero eight-byte pair identifier +/// immediately after its ordinary prelude, scoped to its connection and opener. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct OrderedStreamPair { + /// Stream carrying responses and control messages, with bounded writes. + pub data: Stream, + /// Stream whose writes may wait while the session remains valid. + pub requests: Stream, +} + +/// A service slot held from paired-stream setup through the last transport and +/// application sender owner. The service can release its setup allowance once +/// both roles are ready, while retaining its session allowance through teardown. +pub trait OrderedSessionResources: fmt::Debug + Send + Sync { + /// Both roles have completed setup. Called once for a successfully built pair. + fn admitted(&self); +} + +/// The service has no capacity for another establishing or retiring session. +#[derive(Debug, Error)] +#[error("ordered service session capacity is full")] +pub struct OrderedSessionFull; + /// Transport state for one ordered service stream. #[derive(Debug)] pub(crate) struct ServiceStream { @@ -367,6 +394,44 @@ pub trait Service: fmt::Debug + Send + Sync + 'static { /// Streams this service owns. fn streams(&self) -> &[Stream]; + /// Payload size limits for this stream, as `(message_type, maximum_bytes)` pairs. + /// + /// The reader checks these limits before allocating a payload. Limits exclude + /// the frame header and may only tighten the stream's existing cap. Unlisted + /// message types keep that cap; message validity is checked by the codec. + fn message_payload_limits(&self, _stream: Stream) -> &'static [(u16, usize)] { + &[] + } + + /// Optional message types accepted on this role. The transport rejects an + /// unlisted type from its header, before allocating or reading its payload. + fn message_types(&self, _stream: Stream) -> Option<&'static [u16]> { + None + } + + /// Optional per-stream inbound and outbound application queue limits. + /// The transport also applies its connection-wide inbound queue allowance. + fn stream_queue_depths(&self, _stream: Stream) -> Option<(usize, usize)> { + None + } + + /// Reserve service capacity before starting a stream pair. The returned + /// owner lives through incomplete setup and both workers' eventual teardown. + fn reserve_ordered_session( + &self, + _direction: ServicePeerDirection, + ) -> Result>, OrderedSessionFull> { + Ok(None) + } + + /// Return the complete pair containing `stream`, if this version uses one. + /// + /// Both declarations must be present in [`Service::streams`] with the same + /// capability and opening policy. Ordinary ordered streams return `None`. + fn ordered_stream_pair(&self, _stream: Stream) -> Option { + None + } + /// Return the transport-owned opening and re-admission policy for `kind`. /// /// The default preserves the legacy one-shot initiator-opens behavior. @@ -394,6 +459,21 @@ pub trait Service: fmt::Debug + Send + Sync + 'static { } } + /// Recheck demand for a complete pair that already owns its setup reservation. + /// + /// Services with session reservations must retain cooldown and usefulness + /// checks here without requiring capacity for a second reservation. The + /// default preserves ordinary demand checks for services without reservations. + fn reserved_ordered_session_demand( + &self, + conn_id: ZakuraConnId, + peer: &ZakuraPeerId, + negotiated: u64, + direction: ServicePeerDirection, + ) -> OrderedSessionDemand { + self.ordered_session_demand(conn_id, peer, negotiated, direction) + } + /// Return whether this service currently wants a new session for `peer`. /// /// This is a cheap, advisory demand check used by the transport before diff --git a/crates/zakura-rpc/Cargo.toml b/crates/zakura-rpc/Cargo.toml index f49bc742f9..0a988aaf96 100644 --- a/crates/zakura-rpc/Cargo.toml +++ b/crates/zakura-rpc/Cargo.toml @@ -112,7 +112,7 @@ zakura-chain = { path = "../zakura-chain", version = "7.0.0-rc0", features = [ "json-conversion", ] } zakura-consensus = { path = "../zakura-consensus", version = "8.0.0-rc1" } -zakura-network = { path = "../zakura-network", version = "7.1.1-rc0" } +zakura-network = { path = "../zakura-network", version = "8.0.0" } zakura-node-services = { path = "../zakura-node-services", version = "3.2.4-rc0", features = [ "rpc-client", ] } @@ -142,7 +142,7 @@ zakura-chain = { path = "../zakura-chain", version = "7.0.0-rc0", features = [ zakura-consensus = { path = "../zakura-consensus", version = "8.0.0-rc1", features = [ "proptest-impl", ] } -zakura-network = { path = "../zakura-network", version = "7.1.1-rc0", features = [ +zakura-network = { path = "../zakura-network", version = "8.0.0", features = [ "proptest-impl", ] } zakura-state = { path = "../zakura-state", version = "8.0.0-rc1", features = [ diff --git a/crates/zakura-state/src/lib.rs b/crates/zakura-state/src/lib.rs index e9029f9a35..9b5ab119b1 100644 --- a/crates/zakura-state/src/lib.rs +++ b/crates/zakura-state/src/lib.rs @@ -124,7 +124,7 @@ pub use service::read::{ }; pub use service::{ finalized_state::{DiskWriteBatch, FallibleDiskValue, FromDisk, IntoDisk, WriteDisk, ZakuraDb}, - ReadStateService, VctRootRepairState, VctRootRepairStatus, + OwnedBlockRange, ReadStateService, VctRootRepairState, VctRootRepairStatus, }; // Allow use in external tests diff --git a/crates/zakura-state/src/request.rs b/crates/zakura-state/src/request.rs index fe98bb1393..e80139b7c4 100644 --- a/crates/zakura-state/src/request.rs +++ b/crates/zakura-state/src/request.rs @@ -1900,6 +1900,9 @@ pub enum ReadRequest { /// Returns contiguous committed blocks by height, in ascending order. /// /// The response stops before the first height without a committed body. + /// Callers that charge resources to the database job should instead use + /// [`crate::ReadStateService::read_owned_block_range`] so cancellation of + /// the caller cannot release those resources during a running read. BlocksByHeightRange { /// First height to read. start: block::Height, diff --git a/crates/zakura-state/src/service.rs b/crates/zakura-state/src/service.rs index 675f6673a4..869aee5b77 100644 --- a/crates/zakura-state/src/service.rs +++ b/crates/zakura-state/src/service.rs @@ -72,6 +72,7 @@ use crate::{ }; pub mod block_iter; +mod block_range; pub mod chain_tip; pub mod watch_receiver; @@ -91,6 +92,7 @@ pub mod arbitrary; #[cfg(test)] mod tests; +pub use block_range::OwnedBlockRange; pub use finalized_state::{OutputLocation, TransactionIndex, TransactionLocation}; use write::NonFinalizedWriteMessage; pub use write::{VctRootRepairState, VctRootRepairStatus}; diff --git a/crates/zakura-state/src/service/block_range.rs b/crates/zakura-state/src/service/block_range.rs new file mode 100644 index 0000000000..b99154683e --- /dev/null +++ b/crates/zakura-state/src/service/block_range.rs @@ -0,0 +1,123 @@ +//! Height-range reads that retain caller-owned resources inside the database job. + +use std::sync::Arc; + +use futures::future::BoxFuture; +use tower::ServiceExt; +use tracing::Span; +use zakura_chain::{block, diagnostic::CodeTimer}; + +use super::{read, ReadStateService}; +use crate::{request::TimedSpan, BoxError, ReadRequest}; + +/// A bounded block prefix together with the resources charged for reading it. +/// +/// The blocking read transfers its resources into this result. Dropping the +/// async caller cannot release them while that read is running. Dropping an +/// undelivered result releases its blocks before its resources. +#[derive(Debug)] +pub struct OwnedBlockRange { + // Field order keeps resources alive while the retained blocks are dropped. + blocks: Vec<(block::Height, Arc, usize)>, + resources: R, +} + +impl OwnedBlockRange { + /// Transfer the blocks and their resources to the next owner. + /// + /// The caller must retain the resources for as long as its resource policy + /// requires, including while it holds or processes the returned blocks. + pub fn into_parts(self) -> (Vec<(block::Height, Arc, usize)>, R) { + (self.blocks, self.resources) + } +} + +impl ReadStateService { + /// Read a bounded contiguous prefix while the database job owns `resources`. + /// + /// This uses the same readiness checks, chain snapshot, and missing-block + /// behavior as [`ReadRequest::BlocksByHeightRange`], with an additional byte + /// cap. One blocking job transfers its resources into the returned result. + /// + /// `is_cancelled` is checked before the first lookup and between lookups. + /// Cancellation stops further lookups and returns the prefix already read. + /// It cannot interrupt a database lookup already in progress. Dropping or + /// aborting the caller discards delivery, but the job retains its resources + /// until it finishes and drops its undelivered result. + pub async fn read_owned_block_range( + &mut self, + start: block::Height, + count: u32, + max_response_bytes: u32, + resources: R, + is_cancelled: impl FnMut(&R) -> bool + Send + 'static, + ) -> Result, BoxError> { + self.ready().await?; + ReadRequest::BlocksByHeightRange { start, count }.count_metric(); + let state = self.clone(); + let best_chain = state.latest_best_chain(); + spawn_owned_block_range( + start, + count, + max_response_bytes, + resources, + is_cancelled, + move |height| read::block_and_size(best_chain.clone(), &state.db, height.into()), + ) + .await + } +} + +fn spawn_owned_block_range( + start: block::Height, + count: u32, + max_response_bytes: u32, + resources: R, + mut is_cancelled: impl FnMut(&R) -> bool + Send + 'static, + mut get_block: impl FnMut(block::Height) -> Option<(Arc, usize)> + Send + 'static, +) -> BoxFuture<'static, Result, BoxError>> { + let timed_span = TimedSpan::new( + CodeTimer::start_desc("blocks_by_height_range"), + Span::current(), + ); + timed_span.spawn_blocking(move || { + let blocks = collect_bounded_height_range(start, count, max_response_bytes, |height| { + if is_cancelled(&resources) { + None + } else { + get_block(height) + } + }); + Ok(OwnedBlockRange { blocks, resources }) + }) +} + +/// Read a contiguous prefix without retaining more encoded block bytes than +/// the caller permits. +/// +/// The first block that does not fit can be materialized by the lookup, but is +/// dropped immediately and never enters the returned response. +fn collect_bounded_height_range( + start: block::Height, + count: u32, + max_response_bytes: u32, + mut get_block: impl FnMut(block::Height) -> Option<(T, usize)>, +) -> Vec<(block::Height, T, usize)> { + let mut response_bytes = 0u64; + (0..count) + .map_while(|offset| { + let height = start.0.checked_add(offset).map(block::Height)?; + let (block, size) = get_block(height)?; + let size_u64 = u64::try_from(size).ok()?; + let next_response_bytes = response_bytes.checked_add(size_u64)?; + if next_response_bytes > u64::from(max_response_bytes) { + return None; + } + response_bytes = next_response_bytes; + Some((height, block, size)) + }) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/zakura-state/src/service/block_range/tests.rs b/crates/zakura-state/src/service/block_range/tests.rs new file mode 100644 index 0000000000..81e51aac38 --- /dev/null +++ b/crates/zakura-state/src/service/block_range/tests.rs @@ -0,0 +1,375 @@ +use std::{ + panic::AssertUnwindSafe, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, Arc, + }, + time::Duration, +}; + +use futures::FutureExt; +use tokio::{sync::oneshot, time::timeout}; +use zakura_chain::{ + block, + parameters::Network, + serialization::{ZcashDeserializeInto, ZcashSerialize}, +}; + +use super::spawn_owned_block_range; + +const DEADLINE: Duration = Duration::from_secs(10); + +#[derive(Debug)] +struct DropSignal { + drops: Arc, + finished: Option>, +} + +impl Drop for DropSignal { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + if let Some(finished) = self.finished.take() { + let _ = finished.send(()); + } + } +} + +fn resources() -> (DropSignal, Arc, oneshot::Receiver<()>) { + let drops = Arc::new(AtomicUsize::new(0)); + let (finished, receiver) = oneshot::channel(); + ( + DropSignal { + drops: drops.clone(), + finished: Some(finished), + }, + drops, + receiver, + ) +} + +fn genesis() -> Arc { + Arc::new( + zakura_test::vectors::BLOCK_MAINNET_GENESIS_BYTES + .zcash_deserialize_into() + .expect("the genesis fixture is a serialized block"), + ) +} + +#[tokio::test] +async fn returned_blocks_retain_resources_and_respect_the_byte_cap() { + let _guard = zakura_test::init(); + let (resources, drops, _finished) = resources(); + let block = genesis(); + let reads = Arc::new(AtomicUsize::new(0)); + let worker_reads = reads.clone(); + let result = timeout( + DEADLINE, + spawn_owned_block_range( + block::Height(1), + 3, + 5, + resources, + |_| false, + move |_| { + worker_reads.fetch_add(1, Ordering::SeqCst); + // Synthetic sizes isolate the response cap from fixture size. + Some((block.clone(), 2)) + }, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(reads.load(Ordering::SeqCst), 3); + assert_eq!( + result + .blocks + .iter() + .map(|(height, _, _)| *height) + .collect::>(), + [block::Height(1), block::Height(2)] + ); + assert_eq!(result.resources.drops.load(Ordering::SeqCst), 0); + let (blocks, resources) = result.into_parts(); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(blocks); + drop(resources); + assert_eq!(drops.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn cancellation_before_the_first_lookup_skips_the_range() { + let _guard = zakura_test::init(); + let (resources, drops, _finished) = resources(); + let result = timeout( + DEADLINE, + spawn_owned_block_range( + block::Height(1), + 2, + 10, + resources, + |_| true, + |_| panic!("cancelled work must not reach its first lookup"), + ), + ) + .await + .unwrap() + .unwrap(); + assert!(result.blocks.is_empty()); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(result); + assert_eq!(drops.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn cancellation_between_lookups_retains_the_completed_prefix() { + let _guard = zakura_test::init(); + let (resources, drops, _finished) = resources(); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = cancelled.clone(); + let block = genesis(); + let result = timeout( + DEADLINE, + spawn_owned_block_range( + block::Height(1), + 2, + 10, + resources, + move |_| cancelled.load(Ordering::SeqCst), + move |_| { + assert!(!worker_cancelled.swap(true, Ordering::SeqCst)); + Some((block.clone(), 2)) + }, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result.blocks.len(), 1); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(result); + assert_eq!(drops.load(Ordering::SeqCst), 1); +} + +struct BlockedRead { + started: oneshot::Receiver<()>, + resume: mpsc::SyncSender<()>, + drops: Arc, + finished: oneshot::Receiver<()>, +} + +impl BlockedRead { + async fn wait_started(&mut self) { + timeout(DEADLINE, &mut self.started).await.unwrap().unwrap(); + } + + async fn finish(self) { + assert_eq!(self.drops.load(Ordering::SeqCst), 0); + self.resume.send(()).unwrap(); + timeout(DEADLINE, self.finished).await.unwrap().unwrap(); + assert_eq!(self.drops.load(Ordering::SeqCst), 1); + } +} + +fn blocked_read() -> ( + futures::future::BoxFuture< + 'static, + Result, crate::BoxError>, + >, + BlockedRead, +) { + let (resources, drops, finished) = resources(); + let (started_tx, started) = oneshot::channel(); + let mut started_tx = Some(started_tx); + let (resume, blocked) = mpsc::sync_channel(1); + let block = genesis(); + let job = spawn_owned_block_range( + block::Height(1), + 1, + 10, + resources, + |_| false, + move |_| { + started_tx.take().unwrap().send(()).unwrap(); + blocked.recv_timeout(DEADLINE).unwrap(); + Some((block.clone(), 2)) + }, + ); + ( + job, + BlockedRead { + started, + resume, + drops, + finished, + }, + ) +} + +#[tokio::test] +async fn dropping_the_waiter_keeps_a_running_read_charged() { + let _guard = zakura_test::init(); + let (job, mut read) = blocked_read(); + read.wait_started().await; + drop(job); + read.finish().await; +} + +#[tokio::test] +async fn aborting_the_caller_keeps_a_running_read_charged() { + let _guard = zakura_test::init(); + let (job, mut read) = blocked_read(); + let caller = tokio::spawn(job); + read.wait_started().await; + caller.abort(); + assert!(timeout(DEADLINE, caller) + .await + .unwrap() + .unwrap_err() + .is_cancelled()); + read.finish().await; +} + +#[tokio::test] +async fn a_panicking_read_releases_resources_once() { + let _guard = zakura_test::init(); + let (resources, drops, finished) = resources(); + let job = spawn_owned_block_range( + block::Height(1), + 1, + 10, + resources, + |_| false, + |_| panic!("injected database panic"), + ); + let outcome = timeout(DEADLINE, AssertUnwindSafe(job).catch_unwind()) + .await + .unwrap(); + assert!(outcome.is_err()); + timeout(DEADLINE, finished).await.unwrap().unwrap(); + assert_eq!(drops.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn the_state_api_returns_an_owned_empty_range() { + let _guard = zakura_test::init(); + let (resources, drops, _finished) = resources(); + let (_state, mut read_state, _tip, _change) = + timeout(DEADLINE, crate::init_test_services(&Network::Mainnet)) + .await + .unwrap(); + let result = timeout( + DEADLINE, + read_state.read_owned_block_range(block::Height(1), 1, 10, resources, |_| false), + ) + .await + .unwrap() + .unwrap(); + assert!(result.blocks.is_empty()); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(result); + assert_eq!(drops.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn the_state_api_returns_committed_blocks_with_their_resources() { + let _guard = zakura_test::init(); + let (resources, drops, _finished) = resources(); + let block = genesis(); + let size = block.zcash_serialized_size(); + let (_state, mut read_state, _tip, _change) = timeout( + DEADLINE, + crate::populated_state([block.clone()], &Network::Mainnet), + ) + .await + .unwrap(); + let result = timeout( + DEADLINE, + read_state.read_owned_block_range( + block::Height(0), + 2, + u32::try_from(size).unwrap(), + resources, + |_| false, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result.blocks, [(block::Height(0), block, size)]); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(result); + assert_eq!(drops.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn readiness_failure_releases_resources_without_dispatching_a_read() { + use crate::service::write::{BlockWriteTaskFailure, HeaderChainAttachmentError}; + + let _guard = zakura_test::init(); + let (resources, drops, finished) = resources(); + let (_state, mut read_state, _tip, _change) = + timeout(DEADLINE, crate::init_test_services(&Network::Mainnet)) + .await + .unwrap(); + let failure = BlockWriteTaskFailure::from(&HeaderChainAttachmentError::MissingGenesis); + read_state.block_write_failure.set(failure.clone()).unwrap(); + let error = timeout( + DEADLINE, + read_state.read_owned_block_range(block::Height(1), 1, 10, resources, |_| { + panic!("a readiness failure must prevent dispatch of the blocking job") + }), + ) + .await + .unwrap() + .unwrap_err(); + assert_eq!(error.to_string(), failure.to_string()); + timeout(DEADLINE, finished).await.unwrap().unwrap(); + assert_eq!(drops.load(Ordering::SeqCst), 1); +} + +#[test] +fn block_range_response_stops_before_crossing_its_byte_limit() { + let sizes = [3usize, 2, 4]; + let exact = super::collect_bounded_height_range(block::Height(10), 3, 5, |height| { + let index = usize::try_from(height.0.checked_sub(10)?).ok()?; + sizes.get(index).copied().map(|size| (index, size)) + }); + assert_eq!( + exact, + vec![(block::Height(10), 0, 3), (block::Height(11), 1, 2)], + "the exact-fit prefix is returned and the first over-limit block is excluded", + ); + + let one_byte_short = super::collect_bounded_height_range(block::Height(10), 3, 4, |height| { + let index = usize::try_from(height.0.checked_sub(10)?).ok()?; + sizes.get(index).copied().map(|size| (index, size)) + }); + assert_eq!( + one_byte_short, + vec![(block::Height(10), 0, 3)], + "a prefix that would cross the cap by one byte stops before that block", + ); + + let first_too_large = super::collect_bounded_height_range(block::Height(10), 3, 2, |height| { + let index = usize::try_from(height.0.checked_sub(10)?).ok()?; + sizes.get(index).copied().map(|size| (index, size)) + }); + assert!( + first_too_large.is_empty(), + "a first block larger than the response budget is not retained", + ); +} + +#[test] +fn block_range_response_includes_a_maximum_size_first_block() { + let maximum = usize::try_from(block::MAX_BLOCK_BYTES).unwrap(); + let cap = u32::try_from(block::MAX_BLOCK_BYTES).unwrap(); + let result = super::collect_bounded_height_range(block::Height(10), 2, cap, |height| { + Some((height, maximum)) + }); + assert_eq!( + result, + vec![(block::Height(10), block::Height(10), maximum)] + ); +} diff --git a/crates/zakurad/Cargo.toml b/crates/zakurad/Cargo.toml index 67f41ec35f..845a6e6d92 100644 --- a/crates/zakurad/Cargo.toml +++ b/crates/zakurad/Cargo.toml @@ -172,7 +172,7 @@ zakura-chain = { path = "../zakura-chain", version = "7.0.0-rc0" } zakura-consensus = { path = "../zakura-consensus", version = "8.0.0-rc1" } zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.0-rc1" } zakura-jsonl-trace = { path = "../zakura-jsonl-trace", version = "1.2.0" } -zakura-network = { path = "../zakura-network", version = "7.1.1-rc0" } +zakura-network = { path = "../zakura-network", version = "8.0.0" } zakura-node-services = { path = "../zakura-node-services", version = "3.2.4-rc0", features = ["rpc-client"] } zakura-rpc = { path = "../zakura-rpc", version = "10.0.0-rc1" } zakura-state = { path = "../zakura-state", version = "8.0.0-rc1" } @@ -308,7 +308,7 @@ proptest-derive = { workspace = true } zakura-chain = { path = "../zakura-chain", version = "7.0.0-rc0", features = ["proptest-impl"] } zakura-consensus = { path = "../zakura-consensus", version = "8.0.0-rc1", features = ["proptest-impl"] } zakura-header-chain = { path = "../zakura-header-chain", version = "2.1.0-rc1", features = ["test-support"] } -zakura-network = { path = "../zakura-network", version = "7.1.1-rc0", features = ["proptest-impl", "zakura-testkit"] } +zakura-network = { path = "../zakura-network", version = "8.0.0", features = ["proptest-impl", "zakura-testkit"] } zakura-state = { path = "../zakura-state", version = "8.0.0-rc1", features = ["proptest-impl"] } zakura-rpc = { path = "../zakura-rpc", version = "10.0.0-rc1", features = ["proptest-impl"] } diff --git a/crates/zakurad/src/commands/start.rs b/crates/zakurad/src/commands/start.rs index 523dfbfb26..340689822c 100644 --- a/crates/zakurad/src/commands/start.rs +++ b/crates/zakurad/src/commands/start.rs @@ -2298,7 +2298,7 @@ mod zakura_header_sync_driver_tests { zakura_network::zakura::ZakuraPeerId::new(vec![byte; 32]).expect("test peer id is valid") } - fn read_state_serving_blocks( + fn read_state_for_driver_barrier( blocks: Vec>, query_seen: Option>>>>, ) -> BoxCloneService< @@ -2311,7 +2311,7 @@ mod zakura_header_sync_driver_tests { let query_seen = query_seen.clone(); async move { match request { - zakura_state::ReadRequest::BlocksByHeightRange { start, count } => { + zakura_state::ReadRequest::MissingBlockBodyMetadata { .. } => { if let Some(query_seen) = query_seen { if let Some(query_seen) = query_seen .lock() @@ -2322,17 +2322,15 @@ mod zakura_header_sync_driver_tests { } } - let end = (start + i64::from(count.saturating_sub(1))) - .unwrap_or(block::Height::MAX); - let blocks = blocks - .into_iter() - .filter_map(|block| { - let height = block.coinbase_height()?; - (height >= start && height <= end).then_some((height, block, 0)) - }) - .collect(); - - Ok(zakura_state::ReadResponse::Blocks(blocks)) + Ok(zakura_state::ReadResponse::MissingBlockBodyMetadata( + zakura_state::BlockSyncBodyMetadata { + anchor: zakura_header_chain::Frontier::new( + block::Height(0), + block::Hash([0; 32]), + ), + blocks: Vec::new(), + }, + )) } zakura_state::ReadRequest::FinalizedTip => { let tip = blocks @@ -2597,7 +2595,7 @@ mod zakura_header_sync_driver_tests { async fn wait_for_query_seen(query_seen_rx: oneshot::Receiver<()>) { tokio::time::timeout(Duration::from_secs(1), query_seen_rx) .await - .expect("driver handles the serving query") + .expect("driver reaches the metadata-query barrier") .expect("query signal sender remains live"); } @@ -4120,7 +4118,7 @@ mod zakura_header_sync_driver_tests { .expect("idle native applies yield to fallback"); let (query_seen_tx, query_seen_rx) = oneshot::channel(); let query_seen = Arc::new(Mutex::new(Some(query_seen_tx))); - let read_state = read_state_serving_blocks(vec![block.clone()], Some(query_seen)); + let read_state = read_state_for_driver_barrier(vec![block.clone()], Some(query_seen)); let (driver, shutdown_tx) = DriverParams { max_checkpoint_height: block::Height(0), full_apply_limit: 1, @@ -4137,9 +4135,8 @@ mod zakura_header_sync_driver_tests { verifier, ); - // Send 2 actions to the driver - // Submit block should be acked as abandoned. - // QueryBlocksByHeightRange should still be served, proving Zakura is still alive as a serving bridge. + // A subsequent metadata query is a barrier proving the driver processed + // the submission while native application was yielded to fallback. action_tx .send(BlockSyncAction::SubmitBlock { owner: test_block_work_owner(), @@ -4150,11 +4147,12 @@ mod zakura_header_sync_driver_tests { .await .expect("driver action channel stays open"); action_tx - .send(BlockSyncAction::QueryBlocksByHeightRange { - peer: test_zakura_peer(77), - start: block::Height(1), - count: 1, - }) + .send(needed_blocks_query( + 99, + block::Height(1), + 1, + block::Height(2), + )) .await .expect("driver action channel stays open"); wait_for_query_seen(query_seen_rx).await; @@ -4175,17 +4173,6 @@ mod zakura_header_sync_driver_tests { // The driver did not drop this submit block. // Instead, it marked it as abandoned. assert_abandoned_apply_trace_rows(&rows, [77]); - commit_state.assert_row( - cs_trace::REACTOR_EVENT_SENT, - &[ - ( - cs_trace::ACTION, - TraceValue::Str("block_range_response_ready"), - ), - (cs_trace::RANGE_START, TraceValue::U64(1)), - (cs_trace::RANGE_COUNT, TraceValue::U64(1)), - ], - ); let _ = shutdown_tx.send(()); driver.await.expect("driver task exits cleanly"); @@ -4210,7 +4197,7 @@ mod zakura_header_sync_driver_tests { let (query_seen_tx, query_seen_rx) = oneshot::channel(); let query_seen = Arc::new(Mutex::new(Some(query_seen_tx))); let read_state = - read_state_serving_blocks(vec![block1.clone(), block2.clone()], Some(query_seen)); + read_state_for_driver_barrier(vec![block1.clone(), block2.clone()], Some(query_seen)); let handoff = super::zakura::SyncCoordinator::new(); let drain_handoff = handoff.clone(); let (driver, shutdown_tx) = DriverParams { @@ -4273,11 +4260,12 @@ mod zakura_header_sync_driver_tests { .expect("fallback drain task exits") .expect("fallback acquires the lease after the apply drains"); action_tx - .send(BlockSyncAction::QueryBlocksByHeightRange { - peer: test_zakura_peer(78), - start: block::Height(1), - count: 1, - }) + .send(needed_blocks_query( + 99, + block::Height(1), + 1, + block::Height(2), + )) .await .expect("driver action channel stays open"); wait_for_query_seen(query_seen_rx).await; @@ -4308,74 +4296,6 @@ mod zakura_header_sync_driver_tests { reactor_task.abort(); } - #[tokio::test] - async fn fallback_yield_still_serves_block_range_queries() { - let block = mainnet_block(&BLOCK_MAINNET_1_BYTES); - let (action_tx, action_rx) = mpsc::channel(8); - let mut capture = - TraceCapture::for_test("fallback_yield_still_serves_block_range_queries").unwrap(); - let trace = zakura_network::zakura::ZakuraTrace::new(capture.tracer(), "01"); - let mut startup = block_sync_startup_for_test(); - startup.trace = trace.clone(); - let (block_sync, _reactor_actions, reactor_task) = - zakura_network::zakura::spawn_block_sync_reactor(startup); - let commit_count = Arc::new(AtomicUsize::new(0)); - let verifier = counting_verifier(commit_count.clone(), None); - let (query_seen_tx, query_seen_rx) = oneshot::channel(); - let query_seen = Arc::new(Mutex::new(Some(query_seen_tx))); - let read_state = read_state_serving_blocks(vec![block.clone()], Some(query_seen)); - let handoff = super::zakura::SyncCoordinator::new(); - let _fallback_lease = handoff - .acquire_legacy_fallback(Duration::from_secs(1)) - .await - .expect("idle native applies yield to fallback"); - let (driver, shutdown_tx) = DriverParams { - max_checkpoint_height: block::Height(0), - full_apply_limit: 1, - combined_apply_limit: 1, - trace: trace.clone(), - handoff, - ..DriverParams::default() - } - .spawn( - action_rx, - block_sync, - zakura_chain::chain_tip::NoChainTip, - read_state, - verifier, - ); - - // Send a serving query to Zakura. - action_tx - .send(BlockSyncAction::QueryBlocksByHeightRange { - peer: test_zakura_peer(79), - start: block::Height(1), - count: 1, - }) - .await - .expect("driver action channel stays open"); - wait_for_query_seen(query_seen_rx).await; - assert_eq!(commit_count.load(Ordering::SeqCst), 0); - - capture.flush().await; - let reader = capture.reader().unwrap(); - reader.table(COMMIT_STATE_TABLE.table()).assert_row( - cs_trace::REACTOR_EVENT_SENT, - &[ - ( - cs_trace::ACTION, - TraceValue::Str("block_range_response_ready"), - ), - (cs_trace::RANGE_START, TraceValue::U64(1)), - (cs_trace::RANGE_COUNT, TraceValue::U64(1)), - ], - ); - - let _ = shutdown_tx.send(()); - driver.await.expect("driver task exits cleanly"); - reactor_task.abort(); - } - #[tokio::test] async fn fallback_yield_handles_submit_storm_without_restarting_applies() { const SUBMIT_COUNT: u64 = 128; @@ -4397,7 +4317,7 @@ mod zakura_header_sync_driver_tests { let (query_seen_tx, query_seen_rx) = oneshot::channel(); let query_seen = Arc::new(Mutex::new(Some(query_seen_tx))); let read_state = - read_state_serving_blocks(vec![block1.clone(), block2.clone()], Some(query_seen)); + read_state_for_driver_barrier(vec![block1.clone(), block2.clone()], Some(query_seen)); let handoff = super::zakura::SyncCoordinator::new(); let _fallback_lease = handoff .acquire_legacy_fallback(Duration::from_secs(1)) @@ -4436,13 +4356,14 @@ mod zakura_header_sync_driver_tests { .expect("driver action channel stays open during submit storm"); } action_tx - .send(BlockSyncAction::QueryBlocksByHeightRange { - peer: test_zakura_peer(80), - start: block::Height(1), - count: 2, - }) + .send(needed_blocks_query( + 99, + block::Height(1), + 1, + block::Height(2), + )) .await - .expect("driver action channel handles serving work after submit storm"); + .expect("driver reaches the metadata query after the submit storm"); wait_for_query_seen(query_seen_rx).await; assert_eq!( @@ -4456,17 +4377,6 @@ mod zakura_header_sync_driver_tests { let commit_state = reader.table(COMMIT_STATE_TABLE.table()); let rows = commit_state.rows(); assert_abandoned_apply_trace_rows(&rows, 1..=SUBMIT_COUNT); - commit_state.assert_row( - cs_trace::REACTOR_EVENT_SENT, - &[ - ( - cs_trace::ACTION, - TraceValue::Str("block_range_response_ready"), - ), - (cs_trace::RANGE_START, TraceValue::U64(1)), - (cs_trace::RANGE_COUNT, TraceValue::U64(2)), - ], - ); let _ = shutdown_tx.send(()); tokio::time::timeout(Duration::from_secs(1), driver) diff --git a/crates/zakurad/src/commands/start/zakura/block_range_source.rs b/crates/zakurad/src/commands/start/zakura/block_range_source.rs new file mode 100644 index 0000000000..2c04301a29 --- /dev/null +++ b/crates/zakurad/src/commands/start/zakura/block_range_source.rs @@ -0,0 +1,37 @@ +//! Move GetBlocks ownership into the real state job at the node boundary. + +use futures::future::BoxFuture; +use zakura_network::zakura::{BlockRangeRead, BlockRangeReadResult, BlockRangeSource}; + +#[derive(Debug)] +pub(super) struct StateBlockRangeSource { + state: zakura_state::ReadStateService, +} + +impl StateBlockRangeSource { + pub(super) fn new(state: zakura_state::ReadStateService) -> Self { + Self { state } + } +} + +impl BlockRangeSource for StateBlockRangeSource { + fn read_range( + &self, + request: BlockRangeRead, + ) -> BoxFuture<'static, Result> { + let mut state = self.state.clone(); + Box::pin(async move { + let (start, count, max_response_bytes, lease) = request.into_parts(); + if !lease.try_start() { + return Ok(BlockRangeReadResult::new(Vec::new(), lease)); + } + let result = state + .read_owned_block_range(start, count, max_response_bytes, lease, |lease| { + lease.is_cancelled() + }) + .await?; + let (blocks, lease) = result.into_parts(); + Ok(BlockRangeReadResult::new(blocks, lease)) + }) + } +} diff --git a/crates/zakurad/src/commands/start/zakura/block_sync_driver.rs b/crates/zakurad/src/commands/start/zakura/block_sync_driver.rs index 44384a1b30..eac3111586 100644 --- a/crates/zakurad/src/commands/start/zakura/block_sync_driver.rs +++ b/crates/zakurad/src/commands/start/zakura/block_sync_driver.rs @@ -489,91 +489,7 @@ pub(crate) async fn drive_block_sync_actions( } } } - BlockSyncAction::QueryBlocksByHeightRange { peer, start, count } => { - trace.trace_block_range_query_started(&peer, start, count); - let started = Instant::now(); - match tokio::time::timeout( - ZAKURA_BLOCK_SYNC_DRIVER_TIMEOUT, - read_state - .clone() - .oneshot(zakura_state::ReadRequest::BlocksByHeightRange { start, count }), - ) - .await - { - Ok(Ok(zakura_state::ReadResponse::Blocks(blocks))) => { - trace.trace_block_range_query_succeeded( - &peer, - start, - blocks.len(), - started, - ); - trace.trace_block_range_event( - "block_range_response_ready", - &peer, - start, - count, - ); - let _ = block_sync.send_control(BlockSyncEvent::BlockRangeResponseReady { - peer, - start_height: start, - requested_count: count, - blocks, - }); - } - Ok(Ok(response)) => { - trace.trace_block_range_query_failed( - &peer, - start, - count, - "unexpected_response", - started, - ); - warn!(?peer, ?response, "unexpected BlocksByHeightRange response"); - trace.trace_block_range_finished(&peer, start, count, 0); - let _ = - block_sync.send_control(BlockSyncEvent::BlockRangeResponseFinished { - peer, - start_height: start, - requested_count: count, - returned_count: 0, - }); - } - Ok(Err(error)) => { - trace.trace_block_range_query_failed( - &peer, - start, - count, - &format!("{error}"), - started, - ); - warn!( - ?peer, - ?error, - "failed to read Zakura Blocks response from state" - ); - trace.trace_block_range_finished(&peer, start, count, 0); - let _ = - block_sync.send_control(BlockSyncEvent::BlockRangeResponseFinished { - peer, - start_height: start, - requested_count: count, - returned_count: 0, - }); - } - Err(_elapsed) => { - trace.trace_block_range_query_timed_out(&peer, start, count, started); - warn!(?peer, "timed out reading Zakura block-sync serving range"); - trace.trace_block_range_finished(&peer, start, count, 0); - let _ = - block_sync.send_control(BlockSyncEvent::BlockRangeResponseFinished { - peer, - start_height: start, - requested_count: count, - returned_count: 0, - }); - } - } - } + BlockSyncAction::SubmitBlock { owner, source, diff --git a/crates/zakurad/src/commands/start/zakura/header_sync_driver.rs b/crates/zakurad/src/commands/start/zakura/header_sync_driver.rs index cc6f8c8b81..4d1b17f362 100644 --- a/crates/zakurad/src/commands/start/zakura/header_sync_driver.rs +++ b/crates/zakurad/src/commands/start/zakura/header_sync_driver.rs @@ -101,6 +101,9 @@ where .unwrap_or(empty_state_tip); Ok(ZakuraHeaderSyncDriverStartup { + block_range_source: Arc::new(super::block_range_source::StateBlockRangeSource::new( + read_state.clone(), + )), frontiers: FullStateFrontiers { finalized_height, verified_block_tip: verified_block_tip.0, diff --git a/crates/zakurad/src/commands/start/zakura/mod.rs b/crates/zakurad/src/commands/start/zakura/mod.rs index 3cf7a33c32..04c1a99052 100644 --- a/crates/zakurad/src/commands/start/zakura/mod.rs +++ b/crates/zakurad/src/commands/start/zakura/mod.rs @@ -1,5 +1,6 @@ use std::time::Duration; +mod block_range_source; pub(crate) mod block_sync_driver; mod coordinator; pub(crate) mod frontier; diff --git a/crates/zakurad/src/commands/start/zakura/trace/block_driver.rs b/crates/zakurad/src/commands/start/zakura/trace/block_driver.rs index df01d4947f..f478019f61 100644 --- a/crates/zakurad/src/commands/start/zakura/trace/block_driver.rs +++ b/crates/zakurad/src/commands/start/zakura/trace/block_driver.rs @@ -7,7 +7,7 @@ use zakura_chain::block; use zakura_jsonl_trace::{saturating_count, saturating_millis, JsonlTraceEvent}; use zakura_network::zakura::{ commit_state_trace as event, zakura_trace_peer_label, BlockApplyResult, BlockApplyToken, - BlockSyncAction, BlockSyncMisbehavior, ZakuraPeerId, ZakuraTrace, COMMIT_STATE_TABLE, + BlockSyncAction, BlockSyncMisbehavior, ZakuraTrace, COMMIT_STATE_TABLE, }; use super::super::block_sync_driver::BlockApplyClass; @@ -40,12 +40,6 @@ enum ReceivedAction { reason: &'static str, }, NeededBlocks(NeededRange), - BlockRange { - action: &'static str, - peer: String, - range_start: u64, - range_count: u64, - }, SubmitBlock { action: &'static str, #[serde(skip_serializing_if = "Option::is_none")] @@ -79,22 +73,6 @@ struct NeededFailure<'a> { elapsed_ms: u64, } -#[derive(Serialize)] -struct Range<'a> { - action: &'static str, - peer: String, - range_start: u64, - range_count: u64, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option<&'static str>, - #[serde(skip_serializing_if = "Option::is_none")] - reason: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - elapsed_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - requested_count: Option, -} - #[derive(Serialize)] struct ApplyIdentity { height: u64, @@ -154,48 +132,6 @@ pub(crate) trait BlockDriverTraceExt { fn trace_needed_blocks_query_succeeded(&self, count: usize, started: Instant); fn trace_needed_blocks_query_failed(&self, reason: &str, started: Instant); fn trace_block_reactor_event(&self, action: &'static str); - fn trace_block_range_query_started( - &self, - peer: &ZakuraPeerId, - start: block::Height, - count: u32, - ); - fn trace_block_range_query_succeeded( - &self, - peer: &ZakuraPeerId, - start: block::Height, - count: usize, - started: Instant, - ); - fn trace_block_range_event( - &self, - action: &'static str, - peer: &ZakuraPeerId, - start: block::Height, - count: u32, - ); - fn trace_block_range_query_failed( - &self, - peer: &ZakuraPeerId, - start: block::Height, - count: u32, - reason: &str, - started: Instant, - ); - fn trace_block_range_query_timed_out( - &self, - peer: &ZakuraPeerId, - start: block::Height, - count: u32, - started: Instant, - ); - fn trace_block_range_finished( - &self, - peer: &ZakuraPeerId, - start: block::Height, - requested: u32, - returned: u32, - ); fn trace_block_submit_queued( &self, token: BlockApplyToken, @@ -260,14 +196,6 @@ impl BlockDriverTraceExt for ZakuraTrace { range_count: (*limit).into(), best_header_tip: best_header_tip.0.into(), }), - BlockSyncAction::QueryBlocksByHeightRange { peer, start, count } => { - ReceivedAction::BlockRange { - action: "query_blocks_by_height_range", - peer: zakura_trace_peer_label(peer), - range_start: start.0.into(), - range_count: (*count).into(), - } - } BlockSyncAction::SubmitBlock { token, block, .. } => ReceivedAction::SubmitBlock { action: "submit_block", apply_token: *token, @@ -325,132 +253,6 @@ impl BlockDriverTraceExt for ZakuraTrace { emit(self, event::REACTOR_EVENT_SENT, || Action { action }); } - fn trace_block_range_query_started( - &self, - peer: &ZakuraPeerId, - start: block::Height, - count: u32, - ) { - range( - self, - event::STATE_READ_START, - "query_blocks_by_height_range", - peer, - start, - count.into(), - None, - None, - None, - None, - ); - } - - fn trace_block_range_query_succeeded( - &self, - peer: &ZakuraPeerId, - start: block::Height, - count: usize, - started: Instant, - ) { - range( - self, - event::STATE_READ_SUCCESS, - "query_blocks_by_height_range", - peer, - start, - saturating_count(count), - None, - None, - Some(saturating_millis(started.elapsed())), - None, - ); - } - - fn trace_block_range_event( - &self, - action: &'static str, - peer: &ZakuraPeerId, - start: block::Height, - count: u32, - ) { - range( - self, - event::REACTOR_EVENT_SENT, - action, - peer, - start, - count.into(), - None, - None, - None, - None, - ); - } - - fn trace_block_range_query_failed( - &self, - peer: &ZakuraPeerId, - start: block::Height, - count: u32, - reason: &str, - started: Instant, - ) { - range( - self, - event::STATE_READ_ERROR, - "query_blocks_by_height_range", - peer, - start, - count.into(), - Some("error"), - Some(reason), - Some(saturating_millis(started.elapsed())), - None, - ); - } - - fn trace_block_range_query_timed_out( - &self, - peer: &ZakuraPeerId, - start: block::Height, - count: u32, - started: Instant, - ) { - range( - self, - event::STATE_READ_TIMEOUT, - "query_blocks_by_height_range", - peer, - start, - count.into(), - None, - None, - Some(saturating_millis(started.elapsed())), - None, - ); - } - - fn trace_block_range_finished( - &self, - peer: &ZakuraPeerId, - start: block::Height, - requested: u32, - returned: u32, - ) { - range( - self, - event::REACTOR_EVENT_SENT, - "block_range_response_finished", - peer, - start, - returned.into(), - None, - None, - None, - Some(requested.into()), - ); - } - fn trace_block_submit_queued( &self, token: BlockApplyToken, @@ -551,35 +353,6 @@ fn emit(trace: &ZakuraTrace, name: &'static str, fields: impl FnOn }); } -#[allow(clippy::too_many_arguments)] -fn range( - trace: &ZakuraTrace, - name: &'static str, - action: &'static str, - peer: &ZakuraPeerId, - start: block::Height, - count: u64, - result: Option<&'static str>, - reason: Option<&str>, - elapsed_ms: Option, - requested_count: Option, -) { - trace.emit_event(|| DriverEvent { - event: name, - source: SOURCE, - fields: Range { - action, - peer: zakura_trace_peer_label(peer), - range_start: start.0.into(), - range_count: count, - result, - reason, - elapsed_ms, - requested_count, - }, - }); -} - fn class_label(class: BlockApplyClass) -> &'static str { match class { BlockApplyClass::Checkpoint => "checkpoint", @@ -601,8 +374,6 @@ fn misbehavior_label(reason: &BlockSyncMisbehavior) -> &'static str { match reason { BlockSyncMisbehavior::MalformedMessage => "malformed_message", BlockSyncMisbehavior::UnsolicitedBlock => "unsolicited_block", - BlockSyncMisbehavior::GetBlocksTooLong => "get_blocks_too_long", - BlockSyncMisbehavior::GetBlocksSpam => "get_blocks_spam", BlockSyncMisbehavior::BodyPayloadMismatch(_) => "body_payload_mismatch", BlockSyncMisbehavior::ConsensusBodyInvalid(_) => "consensus_body_invalid", BlockSyncMisbehavior::InvalidBlock => "invalid_block", diff --git a/deny.toml b/deny.toml index b98c213547..d28975ad68 100644 --- a/deny.toml +++ b/deny.toml @@ -210,7 +210,7 @@ unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] # List of URLs for allowed Git repositories -allow-git = [] +allow-git = ["https://github.com/zakura-core/iroh-quinn"] [sources.allow-org] github = [ diff --git a/docs/changelog/params.md b/docs/changelog/params.md index 835164d39f..41488dfec4 100644 --- a/docs/changelog/params.md +++ b/docs/changelog/params.md @@ -32,6 +32,9 @@ Keep entries **newest-first**. Each row records: | Parameter | Location | Old → New | PR | Why | | --- | --- | --- | --- | --- | +| `DEFAULT_ZAKURA_STREAM_RECEIVE_WINDOW` | `crates/zakura-network/src/zakura/handler.rs` | `32 MiB` → `16 MiB` | [#892](https://github.com/zakura-core/zakura/pull/892) | Leave connection receive credit for another service while one stream's application reads are paused. | +| `get_blocks_regulation.*` defaults | `crates/zakura-network/src/zakura/block_sync/config.rs` | new → one response per authenticated identity; `64` per node | [#892](https://github.com/zakura-core/zakura/pull/892) | Hold capacity through storage jobs, results, and application writes, including across reconnects. | +| `PAIRED_DATA_WRITE_TIMEOUT` | `crates/zakura-network/src/zakura/handler.rs` | `10 s` → `32 s` | [#892](https://github.com/zakura-core/zakura/pull/892) | Allow healthy writes to wait for shared connection credit on lossy links with a paused service. | | `MAX_CONCURRENT_UTXO_LOOKUPS` | `crates/zakura-consensus/src/transaction.rs` | serial (`1`) → `64` per block transaction | [#918](https://github.com/zakura-core/zakura/pull/918) | Overlap external UTXO waits while bounding pending lookups per transaction. Concurrent lookups start their six-minute timeout clocks together. | | `MAX_MINED_SUBMISSIONS` | `crates/zakura-rpc/src/methods/types/submit_block.rs` | unbounded → `16` submissions | [#748](https://github.com/zakura-core/zakura/pull/748) | Bound detached verification work across RPC cancellation. | | `non_finalized_write_slots` | `crates/zakura-state/src/service.rs` | unbounded → `1,000` contextual writes | [#748](https://github.com/zakura-core/zakura/pull/748) | Bound writer block bodies using the existing orphan queue capacity. | diff --git a/docs/changelog/unreleased/892.md b/docs/changelog/unreleased/892.md new file mode 100644 index 0000000000..cb44594733 --- /dev/null +++ b/docs/changelog/unreleased/892.md @@ -0,0 +1,22 @@ +## Security + +- Bound native GetBlocks serving to one response per authenticated peer and 64 + per node, retaining capacity through storage, queued results, and writes even + across reconnects. Use a new two-stream block-sync version so serving waits + pause request intake while block downloads continue. Preserve complete bounded + response prefixes under output congestion, reject malformed request headers + before allocation, and settle outgoing request ownership exactly once across + expiry, cancellation, and reset. Allow slow peers to complete their initial + probe and queued responses; give block-sync data writes 32 seconds and reset + only the block-sync pair on expiry. Defer concurrent session-capacity races + without closing healthy connections, and retain the no-progress cooldown and + repeated-stall disconnect when a peer resets unanswered paired downloads. The previous + native block-sync version is not + selected; other negotiated services and the existing legacy fallback remain + available ([#892](https://github.com/zakura-core/zakura/pull/892)). + +## Fixed + +- Fix QUIC connections stalling during loss recovery when the socket cannot + send a batch of datagrams + ([#892](https://github.com/zakura-core/zakura/pull/892)). diff --git a/docs/design/getblocks-refactor-plan.md b/docs/design/getblocks-refactor-plan.md new file mode 100644 index 0000000000..196c72cf65 --- /dev/null +++ b/docs/design/getblocks-refactor-plan.md @@ -0,0 +1,326 @@ +# GetBlocks refactor plan + +Simplify #892's serving path while preserving response reads when requests to serve other peers wait for capacity. Use two streams per block-sync session and one sequential serving task. Keep the current limits: one active response per authenticated peer, including across reconnects, and 64 per node by default. + +A **session** is one active block-sync relationship with a peer. A **permit** reserves capacity for one response. Storage work and outgoing data retain that permit until they finish or are discarded. + +This plan replaces the shared-stream implementation before #892 ships. The +[transport gate](getblocks-refactor-results.md) has passed with the selected +windows and the approved 32-second data-write deadline. The new version is +activated in the implementation, and the old serving driver is removed. The +focused integration checks pass; the results document records the broader test +results and environment limitations. + +## Before and after + +**Overload example:** A is downloading block 200 from B, and A's 64 serving slots are occupied. The test peer B also sends a request for block 100 to put pressure on A's serving path. Only A has a download that must complete; B's extra request is test traffic. + +**Before:** Requests and responses share one stream. A queues B's request so it can keep reading block 200. The current #892 implements this progress fix. + +**After:** A holds one decoded request for block 100 and pauses request intake. It continues reading block 200 from the separate data stream. A dedicated serving task handles the request when capacity becomes available. + +| Stream | Messages | When reading can pause | +| --- | --- | --- | +| Requests | `GetBlocks` | While waiting for serving capacity or the previous response to finish | +| Data and control | `Status`, blocks, ending messages | During ordinary bounded download processing | + +The benefit is simpler request handling and ownership. Serving waits become local to the serving task, rather than work coordinated through the shared block-sync event loop, called the **reactor**. + +## Completed work + +These changes are complete in the inspected #892 source. Preserve their behavior and adapt their tests during the refactor. + +- [x] Kept response reads progressing during serving admission and outbound congestion. +- [x] Removed admission-grace timers and compensating download-deadline extensions. +- [x] Made each active serving response explicit. The sequential task now owns it directly. +- [x] Derived the received-block count from the exact bitmap with `count_ones()`. +- [x] Reserved output-queue capacity before encoding responses. +- [x] Added production-QUIC serving-progress regressions and separate matched-download coverage. + +## Proposal adjustments + +Keep the [proposal's](https://github.com/zakura-core/zakura/pull/892#issuecomment-5590691911) core design, with these adjustments: + +1. **Allow request writes to wait under serving pressure.** Today's generic write timeout closes the connection after ten seconds. In the example above, that could disconnect B while useful block data is arriving. Once a write starts, finish the frame while the session remains valid; cancellation of that write resets the pair. Retain deadlines for data writes, session establishment, and ordinary downloads. +2. **Allow a slow peer to establish and maintain delivery progress.** The loss test also fails on the original PR: its only cold probe expires under the short floor deadline. Give an unmeasured peer the normal bounded request deadline. Include transfer time for the requested body and earlier unreceived responses on the ordered data stream, using the measured rate with the existing 256 KiB/s lower bound. Keep the probe cap, ownership checks, and block-progress timeout. This policy fix is included by the user's explicit decision. +3. **Allow data writes to wait for shared connection credit.** With a paused sibling and packet loss, a healthy block-data write can take about 14 seconds while earlier blocks are still arriving. The existing ten-second deadline closes that connection. Use a bounded 32-second deadline for the paired data stream, including its control and ending messages. Cancellation still interrupts the write, and request expiry and block-progress liveness still apply. The user approved this amendment after a prototype completed the combined workload. +4. **Move download-index optimization into a separate effort.** Preserve the current matching, retry, and ownership bookkeeping here. Optimizing it alongside the stream migration adds correctness risk and is not needed to complete this refactor. + +## Implementation order + +### 1. Prove transport and storage behavior + +Start from a refreshed #892 head, preserving its transport prerequisite. Record baseline test results, memory use, and useful block throughput for comparison. + +#### Transport limits + +QUIC already gives each stream independent flow control. A can stop reading requests while continuing to read blocks, provided the connection has capacity. The two-stream design does not inherently require a QUIC fork change. + +A **receive window** limits how far a sender can get ahead of the application reading its data. Reading replenishes the allowance. + +**Selected design:** Keep two streams with the existing window sizes and QUIC dependency. Any upstream work on individual stream-window controls is a separate future effort. + +| Allowance | Selected setting | +| --- | --- | +| Request stream | 16 MiB | +| Data stream | 16 MiB | +| All streams combined | 32 MiB shared receive ceiling | +| Shared send window | 32 MiB | + +If A stops reading B's requests, unread requests may occupy up to 16 MiB in QUIC. The connection's 32 MiB receive ceiling is shared; it is not memory allocated in advance. The two block-sync streams fit within it, but other services and incomplete setup also consume capacity. Enforce stream-count limits and account for every stream that can pause. A small application queue alone does not bound QUIC buffers. + +#### Transport acceptance gate + +Before measuring the prototype, record each workload, connection/stream counts, permitted peak memory, completion deadline, and useful-throughput floor in the execution results. Compare against the refreshed #892 baseline on the same hardware and link conditions. Thresholds must not be chosen after seeing the new implementation's results. + +Use an ordinary download from B to A: A must receive its requested blocks and the ending message, with responses matched to its actual outstanding request. Exercise other paused services, delayed acknowledgments/loss, and reset/reopen cycles, including combined conditions. Starting queries or accepting bytes into a send queue is insufficient. + +Separately, inject extra `GetBlocks` requests from test peer B while A's serving capacity is occupied. Within the declared supported workload, A's download must still complete and the extra requests must remain bounded. This checks resilience to incoming request pressure. + +Full buffers are ordinary backpressure, not a reason to disconnect. Let paused consumers resume and release transport allowance naturally. If expected block progress remains absent until the existing block-progress deadline, retire the affected stream pair and apply the existing cooldown and repeated-stall policy. Request expiry can return missing work earlier. Reuse these timers rather than adding a separate fullness timer. + +For sustained saturation, require bounded cleanup and recovery instead of completion of the original attempt. Return unreceived work for retry, preserve received blocks, and keep running reads and encodes charged until they end. If other streams prevent recovery, close the peer connection. Repeated saturation must not create an endless immediate reopen loop or prevent use of another available peer. Test natural recovery before the deadline, recovery on a fresh session, and escape from a repeatedly saturating peer. Stream replacement alone is not evidence that syncing recovered. + +Passing the completion, memory, and throughput criteria is a prerequisite for enabling the new service version. An unmeasured case is not a pass. If these default settings fail the gate, keep the new version disabled and record the failed condition before revisiting the design. + +The user approved a specific revision after raw QUIC measurements showed that +the default transport cannot meet the original loss threshold. Lossy workloads +must complete within 240 seconds and retain at least 90% of the original serving +path's median throughput with the same download-policy fix. Loss-free workloads +retain the 30-second deadline. See the execution results for the failed baseline, +the revision, and subsequent measurements. + +#### Storage ownership + +**Before:** The async node driver holds a `BlockRangeQueryLease`, which retains response capacity. After normal cancellation or a query timeout, it keeps waiting for the state read to finish. That protection depends on the driver task staying alive. + +**After:** Put the ownership inside the blocking database job itself. Aborting the async caller must leave capacity charged until the actual read and any retained result finish. + +**Example:** A is reading block 100 for B when B disconnects. The active read must keep its serving slot even if the task waiting for its result is aborted. Otherwise A could admit replacement work while the old read still runs. + +Add a narrow state API that accepts caller-owned resources and checks cancellation before the first lookup and between lookups. It must retain those resources through a running read and any returned blocks. Use at most one result per job; failed delivery drops both the result and its ownership. Test caller cancellation, caller abort, and panic unwinding. + +Define the serving interface in the network crate, perform reads in the state crate, and connect them through an adapter in `zakurad`. Reuse the existing range-read logic and state readiness/failure checks. Keep this single-execution job out of the cloneable `ReadRequest` enum. Proceed once both prototypes demonstrate the required behavior. + +### 2. Treat both streams as one session + +**Before:** A and B use one persistent block-sync stream inside their QUIC connection. Requests, blocks, ending messages, and `Status` share that stream. There is one stream to select, track, and replace. + +**After:** A and B still use one QUIC connection. Block sync uses two persistent ordered streams: requests on one, blocks and control messages on the other. Treat them as one pair with one session identity. Poll each stream's reader and writer independently so waiting on requests cannot stop response processing. + +#### Stream setup + +Splitting the stream creates setup and ordering cases that need explicit rules: + +| Situation | Before | After | +| --- | --- | --- | +| Both peers open block sync | The existing selection rule chooses one stream. | Apply that rule to the whole pair; reject duplicate roles and mismatched pairs. | +| Setup is incomplete | One stream must be accepted. | Activate only after both roles are accepted; abandon incomplete pairs after a bounded deadline. | +| A stream ends normally | Retire that stream's session. | Retire both streams and reopen with a new pair identity; leave unrelated services usable. | +| B sends `Status`, then a request | The ordered stream delivers `Status` first. | The request can arrive first on its separate stream. Hold at most one decoded request until valid `Status` arrives, under the setup deadline. Arrival order alone must not penalize B. | + +Negotiate a new service version before using this layout. Choose unused identifiers and document the bounded setup encoding. Scope the pair identity to its connection and opener, separately from the generic prelude's `request_id`. The existing `RequestResponse` mode opens a stream per request; use persistent ordered streams here. + +#### Current session tracking + +**Before:** The service maintains an `active_peers` admission map and queues individual connection/disconnection events for the reactor. + +**After:** Make that map the authoritative table of current sessions. Replace the event history with a watch notification meaning "the table changed; check its current state." + +**Example:** B's session 7 is replaced by session 8 while the reactor is busy. Both designs cancel session 7 directly. Today the reactor processes queued lifecycle events; after the change, it reads session 8 from the current table and reconciles its state. Repeated replacements must not accumulate a queue of obsolete events. + +Preserve readiness signals, download cleanup, and peer counts, including changes arriving during reconciliation. Keep the existing session-identity checks: delayed requests, results, or cleanup from session 7 must never affect session 8. + +#### Resource limits + +Adding a stream must not double the peer's allowance or let reconnects accumulate unfinished work: + +- Enforce small bounded request queues and bounded data queues. Wire `ServicePeerLimits` queue-depth and pending-escalation fields to the actual queues and admission checks. Count a reader-held frame separately from the raw queue and the serving task's decoded request. +- Count sessions being established and tasks being retired against admission limits until they finish. Replacing a table entry does not mean its old work has ended. +- Keep storage workers and outgoing frames charged to response permits across session replacement. Retain the weak per-identity permit registry and prune expired entries so reconnects cannot bypass the peer limit. +- Share the existing block-sync message budget across both streams. Check message roles and payload limits before allocation, preserving malformed-message checks and the nine-byte `GetBlocks` payload limit: 17 bytes with framing. Account for pair setup separately. + +Bound paired data writes to 32 seconds. Request writes follow the cancellation-aware policy under Proposal adjustments. Session setup and unrelated services retain their existing deadlines. + +### 3. Move serving into a sequential task + +**Before:** A's peer routine admits B's request for block 100 and forwards it to the reactor. The reactor asks the node driver to read storage, then handles the result and queues the response. Serving state is coordinated across these components. + +**After:** A's serving task waits for capacity, calls the storage adapter, and queues the response on B's data stream. The reactor no longer coordinates those serving steps. In the overload example, waiting to serve B's extra request does not stop A's data reader from receiving block 200. + +#### Request flow + +For each request, the session's serving task: + +1. Validates the request and session readiness. +2. Acquires the peer permit, then waits for the node permit. This prevents an old response from causing its peer to hold extra node slots. Keep FIFO waits and make both waits cancellable. +3. Rechecks the session, captures its exact sender and cancellation handle, and snapshots the serving range and advertised count/byte limits. +4. Dispatches the bounded storage operation exactly once through the injected adapter. +5. Reserves an output slot before encoding each block. Run at most one encode at a time per response, outside the reactor on the bounded blocking path. An encode retains its permit even if its async caller disappears. +6. Sends the available bounded prefix and its ending message in order on the captured session. Wait for queue capacity instead of truncating the response because the queue is full. Preserve existing storage-error and unavailable-range behavior. +7. Drops the task's ownership after queuing the ending message. Each queued or partly written frame keeps a `FrameGuard`, which retains the permit through the actual application write or discard. The next request waits for that peer's permit to become available. + +Do not wait for every ownership reference to disappear while the task still holds one itself. Do not find a replacement sender by peer ID after an asynchronous wait. `Status` may interleave only where the existing protocol allows it. + +#### Cancellation and deadlines + +**Before:** `query_timeout` ends the response when the storage deadline expires, even if the database is still reading. The driver retains its lease while waiting for that read to finish. + +**After:** Remove this serving-query timeout and its generated completion. A slow read can finish normally while the session remains active. Session cancellation stops delivery and can prevent later lookups, but a running database call keeps its permit until it exits. Ordinary download deadlines, including floor rescue, still apply. + +Remove the obsolete timeout configuration and tests. If the field has shipped, provide an explicit configuration migration. + +#### Production wiring + +Wire the adapter through real node startup: `zakurad` constructs it, network initialization carries it, and `handler.rs::spawn_zakura_endpoint_inner` passes it into block sync. Updating test constructors alone would miss production. + +### 4. Connect the outgoing request queue + +**Before:** When A requests block 200 from B, it reserves the download work, tries to enqueue `GetBlocks` on the shared stream, and records the queued request as outstanding. A failed enqueue returns the reservation still owned by that attempt. + +**After:** Use the dedicated request queue and reserve its capacity first. If it is full, A creates no new outstanding request and continues reading responses on the data stream. `Status` and responses use the data sender. + +#### Queue admission + +1. Reserve a request-queue slot without waiting. +2. Validate the current work authority and session, then publish the outstanding request and its exact ownership and byte reservation. +3. Transfer the request into the reserved slot under the same ownership protocol. + +Avoiding an asynchronous wait is not atomicity: another task can reset the work concurrently. Reuse the existing synchronization where possible and document where publication takes effect relative to reset. The ownership argument must cover reset before/during publication, enqueue failure, writer startup, and response arrival. Each reservation settles exactly once, and a stale attempt cannot release work now owned by another request. Do not hold a lock while waiting on network I/O. + +Before writing the first byte, the writer must atomically claim the queued request against expiry, reset, and session replacement. If invalidation wins, discard it unwritten. If the writer wins, it owns completion of that frame under the rule below. A separate check followed by an unprotected write is insufficient. + +On enqueue failure, return only work and bytes still owned by that attempt. Preserve the existing exact bitmap, expected hashes, late-response handling, reset accounting, and duplicate-issuance protection. The change is where and when requests enter the transport; their response-matching rules stay intact. + +#### Request expiry + +Keep download deadlines measured from queue admission. Because request writes can wait under serving pressure, the writer must distinguish these cases: + +| Request state when it expires | Required action | +| --- | --- | +| Still queued; writer has not claimed it | Atomically invalidate it against writer startup, then discard it using its exact owner and session. | +| Writer has claimed it, including a partial frame | Finish the frame while its session remains valid. Deadline expiry alone does not abandon a started frame. If the write is cancelled, reset both streams before another request can be sent. | +| Fully written | Apply existing timeout and late-response settlement rules. | + +Session cancellation or a reset that invalidates an active write cancels that write and resets the pair. Never drop an unfinished frame and append another request: the peer would interpret the new bytes as part of the old request. + +### 5. Remove the old path and finish integration + +The sequential task now replaces the following serving machinery. Its old consumers and temporary migration adapters have been removed: + +| Old component | Replacement | +| --- | --- | +| Routine serving queue, pending admission, and `ServeGetBlocks` forwarding | One decoded request waiting in the session's serving task | +| `QueryBlocksByHeightRange`, `serve_block_range`, and serving result/completion events | Storage adapter returns an owned result directly to the serving task | +| Reactor serving record and `pending_serving_terminals` | Response permit owned by the task, plus the ordered data queue | +| Driver-held `BlockRangeQueryLease` | `BlockRangeReadLease` retained inside the database job and result; the shared execution claim still prevents duplicate dispatch | +| Queued session lifecycle events and their test bridge | Current-session table and watch notification; tests exercise real publication and reconciliation | +| Unused raw request/response send methods | Guarded request publication and the serving task's guarded data queue | +| Duplicate synchronous admission algorithm used by tests | Tests poll the actual asynchronous admission future; the independent expected model remains separate | + +Keep download IDs, response guards, shared regulation policy, and unrelated apply/verification work. + +The existing unowned state-read API keeps its original signature. Serving uses +the separate owned API, which enforces the byte cap and retains the permit inside +the real database job. Completed matched-download and recovery tests replace the +temporary raw-stream probes; retain the QUIC backport regression and version-4 +ownership histories. + +#### Activation and compatibility + +Activate the new capability only when the requester, responder, and pair teardown work together and the transport acceptance gate has recorded passing results. Keep it disabled while any required result is missing or failing. Remove temporary migration adapters afterward. + +Test old-only, new-only, and mixed peers through negotiation and existing fallback. The new layout must never be interpreted as the old version. If older native block sync must remain supported, that requires a separate version and overload policy. + +#### Documentation and tests + +Update the stream specification, GetBlocks design, parameter ledger, configuration examples, public API docs, and existing `docs/changelog/unreleased/892.md` entry. Adapt #896's model, production tests, trace/replay format, regression seeds, and test filters together. Preserve the meaning of old failing histories through an explicit replay version or conversion. The local design describes pausing request intake while response processing continues; changing the external draft is a separate editorial action. + +Use the one-way download and request-pressure cases below when adapting the existing serving regressions. Preserve coverage of admission bounds, output congestion, ownership, and cleanup. + +## Resource limits + +Every resource below must remain bounded during overload, cancellation, and reconnects. + +| Resource | Required bound | +| --- | --- | +| Session records and session tasks | Admission covers current, establishing, and retiring sessions | +| Waiting serving requests | One decoded request per admitted session; separately bounded raw queue and transport-reader-held frame | +| Active response ownership | One per authenticated identity and 64 per node by default, including obsolete-session work | +| Storage and encoding | One storage dispatch, one retained result, and at most one active encode per response | +| Outgoing frames | Bounded queues plus writer-held frames; response count/byte limits and guards remain in force | +| Identity budgets | Current sessions plus identities retaining response ownership; prune expired weak entries | +| Transport buffers | Explicit stream, connection, and connection-count limits, including setup and other services | + +One decoded waiter does not mean only one request's bytes are retained: the raw queue and QUIC buffers also count. Likewise, 64 response permits are not a process-memory limit. + +**Memory example:** At an illustrative 32 MiB per response, 64 responses represent 2 GiB before decoded objects, encoding temporaries, transport buffers, and other node work. This response budget is separate from QUIC's connection window. Measure the supported memory envelope. + +The limits contain load; they cannot guarantee service under unlimited identities or make a hung database return. Track active/waiting work, queue depths, retiring sessions, and the oldest response age. Keep metrics limited to live bounded state and avoid peer-ID labels. + +## Validation + +Run these checks through the production transport and serving path, with controlled storage where necessary: + +| Scenario | Required result | +| --- | --- | +| A downloads blocks from B | Requested blocks and the ending message complete and settle A's actual outstanding request | +| A's serving capacity is occupied; test peer B sends extra requests while supplying A's download | A's download completes and B's extra requests remain bounded | +| Request writes remain blocked beyond ten seconds | Useful responses continue without a timeout disconnect breaking the wait; data-write stalls still follow their deadline | +| Other services pause; connections experience loss or reopening | Ready response readers and writers make progress within declared transport bounds | +| All 64 response permits are held | Extra requests stay bounded and dispatch no extra storage or encoding work | +| A read lasts beyond eight seconds or its caller is aborted | No query-timeout completion; the real worker retains its permit until it exits | +| The output queue is full | Encoding waits for a reserved slot; the bounded response and its ending message stay ordered | +| Cancellation at admission, read, encode, queue, or partial-write boundaries | Resources settle once; reconnects cannot bypass the peer limit or receive old-session output | +| Pair setup is reordered, duplicated, incomplete, or replaced | One complete pair is selected; setup and teardown remain bounded | +| Session replacement while reconciliation is paused | Records and tasks plateau at their limits, then converge to current state | +| Request expiry, send failure, reset, rejection, or late response | No unqueued outstanding request, duplicate issuance, or double release | +| Expiry/reset races the writer's initial claim | Invalidation wins and no bytes are written, or the writer wins and finishes the frame unless cancellation resets the pair | +| Reset races publication, enqueue failure, or response arrival on another task | Exactly one settlement per reservation; old owners cannot release replacement work | +| Invalid roles, oversized headers, malformed ranges, response-cap edges, and tighter transport caps | Reject before expensive work; preserve valid empty/partial response behavior | +| Old/new peers and configuration migration | Explicit version selection and working fallback | + +Keep tests that deliberately release a guard early or accept stale ownership and verify that the suite catches the mistake. Start stream workers before sending large bursts in the new harness. Require completed downloads; the existing serving reproduction consumes blocks as stale and counts queries, so it is not evidence of matched-download completion. + +### Performance comparison + +Compare baseline and final behavior under the same workloads: normal sync, downloading from B while serving other peers, slow readers/storage, large blocks, reconnects, and delayed/lossy links. Measure the synthetic request-pressure case separately from ordinary sync. Record completed requests, useful bytes, latency, CPU, and peak memory against the transport gate's thresholds, fixed before measuring the prototype. + +### Required checks + +During implementation, run focused network regulation, block-sync, transport, state, and node-driver suites. At the combined head, run: + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +cargo nextest list --profile blocksync-regression --locked +cargo nextest run --profile blocksync-regression --locked +cargo nextest run --profile zakura-integration --locked +``` + +The `blocksync-regression` profile covers the fixed regressions in this refactor. Generated ownership histories and version-4 JSON replay belong to the follow-on property-testing PR, #896. Confirm the intended tests actually run. Preserve the included QUIC loss-recovery backport and the current crate-family pin and lockfile. Run the relevant documentation/configuration checks too. + +## Code map and baseline + +Network paths below are relative to `crates/zakura-network/src/zakura/`. + +| Area | Main targets | +| --- | --- | +| Stream pair, queues, and write policy | `handler.rs`, `handshake.rs`, `transport/` | +| Serving task and permits | `block_sync/serving.rs`, `block_sync/serving_regulation.rs`, `regulation/request.rs`, `regulation/slots.rs` | +| Sessions, downloads, and old serving removal | `block_sync/service.rs`, `block_sync/peer_routine.rs`, `block_sync/reactor.rs`, and their state/event modules | +| State API | `crates/zakura-state/src/service/block_range.rs` | +| Production adapter and wiring | `crates/zakurad/src/commands/start.rs`, `crates/zakurad/src/commands/start/zakura/`, and `crates/zakura-network/src/peer_set/initialize.rs` | +| Tests and wire contract | `handler/tests/paired_block_sync.rs`, block-sync/transport tests and testkit, the ported #896 model, and `docs/specs/blocksync/stream-pair.md` | + +The source baseline is [#892 at `5d5c8abc8f5b`](https://github.com/zakura-core/zakura/tree/5d5c8abc8f5b6c00f3a2e7adc03256b55cb18c3c), inspected September 8, 2026. Its base is [the transport fixes at `53ccc72b5dcb`](https://github.com/zakura-core/zakura/tree/53ccc72b5dcb65713ee4af17216432d1fcfdb134), with the QUIC family pinned to `1dcc7a43488fecd199d343d47e93e9ed8319fcaa`. These refs were refreshed before implementation. + +Useful source anchors: + +- [Current GetBlocks design](https://github.com/zakura-core/zakura/blob/5d5c8abc8f5b6c00f3a2e7adc03256b55cb18c3c/docs/design/getblocks-regulation.md). +- [QUIC flow control](https://www.rfc-editor.org/rfc/rfc9000.html#section-4.1), [Zakura's window defaults](https://github.com/zakura-core/zakura/blob/5d5c8abc8f5b6c00f3a2e7adc03256b55cb18c3c/crates/zakura-network/src/zakura/handler.rs#L151-L157), and [the pinned window configuration](https://github.com/zakura-core/iroh-quinn/blob/1dcc7a43488fecd199d343d47e93e9ed8319fcaa/quinn-proto/src/config/transport.rs#L97-L118). +- [Actual blocking state dispatch](https://github.com/zakura-core/zakura/blob/5d5c8abc8f5b6c00f3a2e7adc03256b55cb18c3c/crates/zakura-state/src/service.rs#L3523-L3529). +- [Ordered-write timeout](https://github.com/zakura-core/zakura/blob/5d5c8abc8f5b6c00f3a2e7adc03256b55cb18c3c/crates/zakura-network/src/zakura/handler.rs#L4508-L4537) and [unenforced queue fields](https://github.com/zakura-core/zakura/blob/5d5c8abc8f5b6c00f3a2e7adc03256b55cb18c3c/crates/zakura-network/src/zakura.rs#L74-L115). + +Download-index optimization, stricter overlap/response validation, regulation of other services, and broader fairness or process isolation remain separate work. This refactor preserves existing download semantics unless a concrete migration dependency requires an explicit design decision. diff --git a/docs/design/getblocks-refactor-results.md b/docs/design/getblocks-refactor-results.md new file mode 100644 index 0000000000..330e7c3fcb --- /dev/null +++ b/docs/design/getblocks-refactor-results.md @@ -0,0 +1,251 @@ +# GetBlocks refactor results + +The transport acceptance gate passed with the existing QUIC dependency and +windows. The paired service is now selected normally, and the old serving driver +has been removed. The implementation and final validation are recorded below. + +## Baseline and criteria + +The baseline is #892 at `5d5c8abc8f5b6c00f3a2e7adc03256b55cb18c3c`, including +base `53ccc72b5dcb65713ee4af17216432d1fcfdb134`. Both refs were refreshed on +September 8, 2026. The QUIC family stays pinned to +`1dcc7a43488fecd199d343d47e93e9ed8319fcaa`. + +Measurements use Rust 1.97.0 debug test executables on the same Mac Studio. +Each matched download uses two real QUIC endpoints in one process. A receives +32 blocks of about 1.9 MB each from B, matching every block and ending to its +actual outstanding request. Storage and consensus verification are fixtures; +the blocks have consistent commitments but are not consensus-valid chain blocks. + +These local engineering thresholds were fixed before measuring each comparison. +They are not a production memory guarantee. + +| Workload | Required result | +| --- | --- | +| Ordinary matched download | All blocks and endings within 30 seconds | +| Incoming request pressure | Same completion while A's serving slots are occupied and B sends 32,000 synthetic requests | +| Paused sibling service | Same completion with one sibling retaining a full 16 MiB window | +| 50 ms RTT and 1% loss, including pressure and paused sibling | All blocks and endings within 240 seconds | +| Throughput comparison | Five-run median useful throughput at least 90% of the original serving path with the same policy fixes | +| Process memory | Peak RSS at most 512 MiB, including both endpoints | +| Reopening | Twenty replacements on the same connection, including loss; each new session completes within its applicable deadline | +| Temporary saturation | Resuming a paused consumer before liveness expires lets the original pair complete without a reset | +| Sustained saturation | Bounded cleanup returns unreceived work; a usable peer completes the retry within 30 seconds; twenty cycles stay within memory and session bounds | + +Only A must complete a matched download. B's extra requests are synthetic serving +pressure, not a second matched download requirement. + +## Completed comparisons + +Each row contains five runs of each implementation. Every run completed all +blocks and endings without replacing its session. The baseline includes the same +download-policy fix. Paused fixture streams use a one-frame application queue on +both paths; the original block-sync queues are otherwise unchanged. + +| Workload | Original median | Paired median | Paired useful throughput versus original | Highest RSS across both paths | +| --- | --- | --- | --- | --- | +| Ordinary download | 1.802 s | 1.781 s | 101.2% | 182.4 MiB | +| 32,000 reverse requests | 1.830 s | 1.827 s | 100.1% | 186.3 MiB | +| Reverse requests and a paused 16 MiB sibling | 1.781 s | 1.803 s | 98.8% | 210.2 MiB | +| Reverse requests, 50 ms RTT, and 1% loss | 203.164 s | 205.934 s | 98.7% | 150.0 MiB | +| Reverse requests, paused sibling, 50 ms RTT, and 1% loss | 210.156 s | 210.811 s | 99.7% | 179.7 MiB | + +All 50 runs pass. Both implementations use the approved 32-second block-sync +data-write deadline in the combined-condition row. The first four rows used the +previous ten-second deadline; it did not fire. QUIC and its windows are identical +throughout. + +After removing the activation override, the combined-condition test completed +again through normal service selection: **213.178 seconds**, every block and +ending, and **183.5 MiB peak RSS**. With the final ownership observations and +session metrics, it passed again in **211.588 seconds** at **170.1 MiB peak RSS**. + +| Recovery test | Result | +| --- | --- | +| Twenty loss-free replacements under request pressure | Every round completes; 35.236 seconds of useful transfer in total; real reopen backoff retained | +| Twenty replacements with loss | 640 matched blocks and endings; every round within 240 seconds; 4,129.461 seconds of useful transfer in total; 171.3 MiB peak RSS | +| Two full paused siblings resume after one second | Original pair completes in 2.782 seconds without a reset | +| Twenty sustained-saturation and fresh-peer retries with the approved data deadline | Every retry completes from returned work without a new work submission; serving and session slots return to initial counts; 257.6 MiB peak RSS | + +In the sustained-saturation fixture, an unrelated sibling's unchanged ten-second +write deadline closes the blocked connection first. That test proves recovery +and resource ownership; it does not measure the exact 32-second block-sync timer. + +## Decisions supported by failed probes + +### Shared connection credit + +Two paused streams can consume all 32 MiB of connection receive allowance. +Reading an empty data stream cannot release bytes held by those siblings. +Reducing the application queues to one frame does not reduce QUIC's windows. + +| Raw transport probe | Result before releasing paused traffic | Peak RSS | +| --- | --- | --- | +| No paused stream | 64 MiB in 0.750 s | 150.9 MiB | +| One full 16 MiB paused stream | 64 MiB in 0.719 s | 187.6 MiB | +| Two full 16 MiB paused streams | Zero data bytes in 30 s | 187.8 MiB | +| Two full windows behind bounded production frame workers | Zero data bytes in 30 s | 189.5 MiB | +| Advertised 32,000 requests plus one full paused sibling | 64 MiB in 0.767 s | 196.5 MiB | + +The full-window request burst is about 986,895 frames. The advertised 32,000 +requests occupy only 544,000 framed bytes. Releasing one paused sibling restored +the original stalled transfer, which completed about 0.77 seconds later. + +The user selected bounded cleanup and a completed retry for sustained excessive +traffic, rather than requiring the original saturated attempt to complete. +Fullness alone does not trigger a new timer. Temporary pressure can clear; +existing write, request, and block-progress deadlines handle sustained stalls. +The recovery tests above establish this behavior through real session management. + +### Download deadlines + +The original PR and paired prototype both failed the original 30-second loss +gate. The original completed zero matched blocks because its only cold probe +expired. A raw 4 MiB transfer without block-sync policy took 12.706 seconds under +the same 50 ms RTT and 1% loss. The default transport could not meet the original +absolute throughput requirement. + +The user approved including the download-policy fix and, before measuring the +complete comparisons, revising lossy completion to 240 seconds plus the 90% +relative throughput floor. Loss-free deadlines and the memory envelope stayed +unchanged. The failed original measurements remain failures. + +The policy fix gives an unmeasured peer the normal bounded request deadline. +Measured peers include transfer time for the requested body and earlier +unreceived responses on the ordered data stream, with the existing 256 KiB/s +minimum estimated rate. Cold-probe limits, exact ownership, and block-progress +liveness remain in force. + +### Data writes + +Combining loss with a paused sibling exposed a separate limit. The original +serving path completed 16 of 32 blocks before a write timeout; paired diagnostic +runs reached 16 and 17 blocks. Healthy writes took 13.8–14.1 seconds while earlier +blocks were still arriving, exceeding the generic ten-second deadline. + +A 32-second prototype completed the combined workload in 208.527 seconds at +171.9 MiB peak RSS. The user approved that deadline for paired data writes, +including Status and ending messages. The completed comparison gives both paths +the same deadline. Request writes remain cancellation-aware; setup and unrelated +services retain their existing deadlines. No QUIC change was needed. + +## Correctness coverage + +The sequential serving tests cover requests arriving before Status, missing +Status, bounded and unavailable ranges, storage failures, and output congestion. +Eighteen storage/queue combinations preserve the exact available prefix and +ending. A read lasting beyond the former eight-second query timeout can finish +normally. Cancellation and stale-session tests keep the actual job charged and +prevent old output from reaching a replacement. + +Eleven owned-state-read tests exercise the byte cap, retained results, cancellation +before and between lookups, a dropped waiter, an aborted caller, panic unwinding, +and the public API against empty and populated databases. The concurrency tests +wait for a real blocking job, terminate its caller, verify retained ownership, +then release the job and observe exactly one resource release. + +The request ownership tests cover expiry versus the writer's first claim and +reset on another thread during publication. Publication, ledger transfer, +enqueue, and reset share an explicit lock. Only the exact owner can return +unreceived reservations; received blocks and replacement work survive cleanup. +An unfinished write cancels its pair. + +Pair tests cover incomplete and mismatched setup, duplicate roles, request +backpressure beyond ten seconds while data continues, and repeated reopening. +The independent ownership model retains negative controls and uses replay format +version 4. Long transport gates have a separate profile and are excluded from +routine CI, including profiles that run ignored tests. + +## Reproduction and final checks + +Build the network tests with +`cargo +1.97.0 test -p zakura-network --lib --locked --no-run`. +Run the emitted executable with `/usr/bin/time -l`, the fully qualified test +name, and `--exact --nocapture` to measure RSS without compilation. + +Matched downloads are in `handler/tests/paired_block_sync.rs`; long comparisons +and reopen repetitions are under its `gate` module. The earlier raw probes were +removed after the paired tests covered completion, pauses, saturation, and retry. +Their [source remains in history](https://github.com/zakura-core/zakura/blob/7cf0d46d0c0b0a3680f032935384a397d0c324d9/crates/zakura-network/src/zakura/handler/tests/quic_progress.rs). +`quic_progress.rs` retains the QUIC backport regression test. The `blocksync-transport-gate` nextest profile +selects those explicit long-running measurements. Local measurement logs and +median JSON files are retained under `target/getblocks-gate/`. + +A passing diagnostic stall test confirms the expected stall and recovery; +it is not a successful completion measurement. The matched results above are +the activation evidence. + +Checks before the cleanup, at `c7481f0cd`: + +- Workspace Clippy with all targets and warnings denied passes. +- The regulation profile lists and runs 102 tests; all pass without retries, + including the final observation guards and nine owned-state-read tests. +- All 22 Zakura integration tests pass, including old and mixed capability + advertisements retaining other negotiated services after rejecting the old + block-sync layout. +- The final network run passes 1,236 tests. Ten remain ignored, and the three + unavailable loopback tests listed below are excluded. +- The workspace run passes all 402 node library tests, 572 state library tests, + and 1,234 network library tests. Other workspace library and doc-test targets + pass. Six tests fail for the conditions listed below. This broad run precedes + the final metrics additions; the focused checks above include them. +- Formatting, Markdown lint, and changelog validation pass. + +The three network failures are +`listener_bans_zcashd_compat_peer_before_reserved_slot`, +`listener_reserves_one_zcashd_compat_inbound_slot`, and +`listener_zcashd_compat_reconnect_bypasses_recent_ip_limit`. Their additional +loopback source addresses are unavailable on this Mac (OS error 49). + +The three node acceptance failures are `activate_mempool_mainnet`, +`restart_stop_at_height`, and `sync_one_checkpoint_mainnet`. They run the legacy +P2P stack, fail to establish usable public peer connections, and expire waiting +for sync progress. These are not paired-transport runs. Host networking and +legacy peer policy were left unchanged. The macOS compact-unwind linker warning +also remains; it does not prevent linking or execution. + +### Cleanup validation against main + +The file-by-file cleanup removes unused send APIs, duplicate test admission and +lifecycle adapters, and superseded raw transport probes. Tests now use the real +admission future and session table. The owned state-read API retains its byte +cap; the existing unowned API keeps its original signature. + +With Rust 1.97.0 on September 9, 2026: + +- All 1,230 selected network tests and 11 owned-state-read tests pass. +- The regulation profile lists and passes 103 tests without retries. +- All 22 integration tests pass without retries. +- Workspace Clippy with all targets and warnings denied, formatting, Markdown + lint, and changelog validation pass. + +The first network run exposed a test timing assumption: connection setup took +3.27 seconds after starting a three-second cooldown. The test now keeps its +cooldown longer than its connection deadline and asserts that the park is still +live before checking admission. No production cooldown changed. + +The three unavailable loopback-address tests above remain excluded. The long +loss, reopening, and repeated-saturation gates retain their earlier measurements; +they were not rerun for this cleanup. Their transport settings and serving +algorithms are unchanged, and the routine matched-download and recovery tests +pass again. The full workspace run was not repeated. + +### Test consolidation + +At `2fd9cac34`, duplicate admission checks are consolidated in the shared request +tests. Separate regressions still cover dropping a read waiter, aborting its +caller, and each transport-write outcome; they now share their setup. Paired QUIC +fixtures also share native connection negotiation. The storage outcome matrix +keeps all four distinct outcomes at three queue depths, removing six repeated +failure cases. + +The independent ownership models, generated histories, and JSON replay move +to #896. Fixed regressions for one block per response, 64 active responses, execution +claims, reconnects, and cancellation remain in #892. This changes test placement +and fixtures, with no change to production behavior or transport settings. + +The `blocksync-regression` profile passes all 84 tests without retries, and the +integration profile passes all 22 tests. Workspace all-target Clippy passes. +The earlier long transport measurements above remain the validation evidence for +loss, repeated reopening, and sustained saturation; those gates were not rerun +for this test consolidation. diff --git a/docs/design/getblocks-regulation.md b/docs/design/getblocks-regulation.md new file mode 100644 index 0000000000..4faa070ed7 --- /dev/null +++ b/docs/design/getblocks-regulation.md @@ -0,0 +1,133 @@ +# GetBlocks serving + +Native block sync uses two persistent QUIC streams on one connection. Requests +travel on one stream; Status, blocks, and response endings travel on the other. +One sequential task serves each session. Waiting for serving capacity pauses +request intake while the data reader continues processing downloads. + +The [stream specification](../specs/blocksync/stream-pair.md) defines negotiation, +framing, and retirement. The [execution results](getblocks-refactor-results.md) +record the transport gate, including its memory and throughput limits. + +## Request flow + +**Example:** A is downloading block 200 from B while A's 64 serving slots are +occupied. B also asks A for block 100. A holds that request and waits for a +serving slot. Block 200 can still arrive on A's separate data stream. + +The serving task: + +1. Decodes one GetBlocks request. If the initial Status has not arrived on the + data stream, waits for it under a ten-second setup deadline. +2. Acquires the authenticated peer's response slot, then the node's slot. Both + waits are cancellable and use FIFO admission. Waiting for an earlier response + from this identity does not consume another node slot. +3. Rechecks the session and snapshots the committed serving range and response + limits. An unavailable range gets RangeUnavailable without a storage read. +4. Dispatches one bounded storage read through the node's state adapter. +5. Reserves an output-queue slot before encoding each response frame. Queue + pressure waits for space; it does not truncate the available response. +6. Queues the available contiguous prefix followed by BlocksDone. An empty read + or storage failure produces RangeUnavailable. All output uses the original + session's data sender. + +A request may arrive before Status because the streams are independent. That +ordering alone is not a peer fault. Wrong-role messages, malformed ranges, and +oversized headers are rejected before expensive work. + +## Ownership + +A response slot remains held by every resource that can outlive its caller: +its producer, database job, retained result, encode, and queued or writing frames. +The slot returns only after all of those owners finish or are discarded. + +**Example:** B disconnects while A is reading block 100 for it. A cancels delivery, +but the running database job still owns its slot. B's replacement session waits +for that same identity's slot instead of starting another read alongside it. + +The state adapter claims execution once, then moves the lease into the blocking +job and its returned result. Cancellation is checked before and between lookups. +Aborting the async caller does not release resources still owned by a real read. +There is no serving-query timeout. A database call that never ends keeps its slot. + +Each queued frame has a guard that retains the response's slots through its +application write or discard. Taking a frame out of the queue does not release +those slots. QUIC can retain bytes after accepting the write; the application +permit does not wait for the remote peer to read them. + +The producer drops its ownership after queuing the ending message. The next +request can wait for the final frame guard to release the peer's slot. It must +not hold the old producer while waiting for all old owners to disappear. + +## Bounds and cancellation + +| Resource or wait | Limit or behavior | +| --- | --- | +| Decoded waiting requests | One per admitted session | +| Raw request queues | One inbound and one outbound frame; a reader or writer can also hold a frame | +| Data queues | Configured block-sync inbound and outbound queue depths | +| Active responses | One per authenticated identity, including reconnects; 64 per node by default | +| Storage and encoding | One storage dispatch and at most one active encode per response | +| Request writes | Finish once claimed while the session is valid; cancellation resets the pair | +| Data writes | 32 seconds, including Status and ending messages | +| Initial Status | Ten seconds | +| Incomplete stream pair | Prelude deadline, three seconds by default | + +Outgoing requests reserve queue space before publishing outstanding work. The +same ownership lock orders publication, reset, enqueue failure, and the writer's +initial claim. If expiry wins before that claim, no bytes are written. If the +writer wins, it finishes the frame while its session remains valid. Cancellation +of a partial write resets both streams before another frame can be sent. +Received blocks and replacement requests keep their exact ownership during +cleanup; an obsolete request cannot return their reservations. + +Full buffers alone do not cause a disconnect. Existing request expiry, +block-progress liveness, and bounded data writes handle sustained stalls. +Cancellation returns unreceived work for retry and keeps running jobs charged. +Pair reopening uses the existing cooldown and backoff. Other services remain +usable unless a connection-wide failure or repeated-stall policy closes the +connection. + +Active responses, admission waiters, and the oldest response age are reported +under `sync.block.serving.*`. Observations follow the last database/result/frame +owner and do not retain that work themselves. `sync.block.sessions.reserved` +counts establishing and retiring sessions as well as current ones; +`sync.block.sessions.pending` counts incomplete setup. Labels contain no peer IDs. + +## Transport and memory + +The transport uses a 16 MiB receive allowance per stream, a 32 MiB shared receive +allowance per connection, and a 32 MiB connection send window. It needs no +per-stream window extension. +Opening two streams does not allocate or reserve that memory in advance. Paused +services can consume the shared allowance and delay otherwise-ready streams. + +The default advertised burst of 32,000 requests is 544,000 framed bytes. It fits +alongside one paused 16 MiB sibling in the measured workload. Excessive traffic +can fill two stream windows and consume all shared receive credit. Tests require +natural recovery from a temporary pause and bounded cleanup followed by a +completed retry for sustained saturation. + +For each admitted response, the payload bound is +`min(count * MAX_BLOCK_BYTES, advertised_max_response_bytes) + count + 9`. +The extra count allows a tag per block; nine bytes cover the ending. Output queue +capacity is reserved before serialization, and all frames share the original +response slot. + +This bound does not measure decoded blocks, encoding temporaries, total RSS, or +QUIC buffers. Connection limits and existing decode bounds still matter. The +512 MiB gate envelope applies to the documented local fixture, not every possible +configuration or node workload. + +## Shared policy and scope + +`GetBlocksPolicy` supplies decoding and response-size rules to the shared finite +request admission code. GetBlocks uses its own node slot pool. A GetPeers test +checks reuse of the generic mechanism; production GetPeers regulation is outside +this change. + +This implements serving ownership and progress under the declared workloads, +not all requirements of the broader regulation draft. Overlapping live ranges +are still handled by the requester's existing reassignment and late-response +rules; serial serving does not reject them. Message prioritization, download-index +optimization, and regulation of other services remain separate work. diff --git a/docs/specs/blocksync/congestion_control.md b/docs/specs/blocksync/congestion_control.md index dfb7aa766e..adc8320998 100644 --- a/docs/specs/blocksync/congestion_control.md +++ b/docs/specs/blocksync/congestion_control.md @@ -99,15 +99,24 @@ the previous base round-trip, so one tick's burst can't inflate the BDR max. **A slow peer holds the contiguous floor.** The lowest missing height gates commit; one slow carrier must not pin it. -- A floor request MUST carry a short leash (`floor_rescue_timeout`, 2 s); on expiry the +- A floor request with a fresh delivery-rate sample MUST use `floor_rescue_timeout` + (2 s) plus estimated transfer time. Without a sample, it MUST use `request_timeout` + (8 s) plus transfer time so the cold peer's only probe can complete. On expiry the height MUST return to the queue and the peer be retry-avoided — rescued, not disconnected (record-only). - The floor MAY borrow up to `floor_bypass_slots` (2) bodies beyond a saturated window (within the request-count cap, reserving real budget). The borrow MUST scale by the peer's reliability, so a sealed peer earns **no** bypass; if every servable carrier is sealed, the floor waits for a fresh one. -- Above-floor speculation SHOULD use a size-aware deadline (`request_timeout + bytes ÷ - BDR`) and MUST NOT gate the floor. +- Above-floor requests use `request_timeout` plus estimated transfer time. +- Both lanes MUST include this response and earlier unreceived responses in the byte + estimate: responses share an ordered stream. Use the measured byte rate with a + 256 KiB/s lower bound; use that lower bound when the rate is unmeasured. + +For example, suppose B takes four seconds to send each 2 MiB block. If A queues +block 101 behind block 100, block 101 needs eight seconds of transfer allowance, +plus its base timeout. Allowing time only for block 101 can expire it while B is +still delivering the earlier response. **Unbounded memory under attacker-controlled bodies or stalls.** @@ -128,9 +137,10 @@ slow carrier must not pin it. single always-taken item that guarantees floor progress. - The reorder look-ahead and the serving-request heap MUST be bounded. -**An unbounded wait wedges a peer.** Every outbound request MUST have a network deadline — -the only sanctioned timer. When BDR is near zero the above-floor deadline assumes a -minimum delivery rate, so it stays finite (~16 s worst case). +**An unbounded wait wedges a peer.** Every outbound request MUST have a finite network +deadline. Admission bounds the outstanding byte estimate, and the minimum rate bounds +its transfer allowance. The separate block-progress deadline still retires a silent +session even when queued responses have later individual deadlines. **A peer accepts requests but never delivers bodies (probe-first).** Admission in front of the window MUST enforce a no-progress policy: diff --git a/docs/specs/blocksync/stream-pair.md b/docs/specs/blocksync/stream-pair.md new file mode 100644 index 0000000000..351d2703d4 --- /dev/null +++ b/docs/specs/blocksync/stream-pair.md @@ -0,0 +1,100 @@ +# Block-sync stream pair + +Native block sync uses two persistent bidirectional QUIC streams on one +authenticated connection. The [acceptance gate](../../design/getblocks-refactor-results.md) +passed before this layout was activated. + +## Negotiation + +Both peers must select capability bit 6 (`1 << 6`) and both stream roles: + +| Role | Kind | Version | Allowed frame message types | Maximum frame size | +| --- | --- | --- | --- | --- | +| Data and control | 6 | 3 | Status (1), Block (3), BlocksDone (4), RangeUnavailable (5) | 3 MiB + 8 bytes | +| Requests | 7 | 1 | GetBlocks (2) | 17 bytes | + +The previous single-stream layout used kind 6, version 2, and capability bit 3. +Its encoding does not contain a pair identifier. Never interpret that stream as +one role of this layout. If negotiation cannot select both roles of a pair, it +must select neither. + +## Setup + +The opener writes the ordinary 13-byte ordered-stream prelude on each role, +followed immediately by an eight-byte pair identifier. Multi-byte integers use +little-endian encoding. + +| Field | Bytes | Value | +| --- | --- | --- | +| Magic | 4 | `ZKST` | +| Stream kind | 2 | Role's kind above | +| Stream version | 2 | Role's version above | +| Request-ID presence | 1 | 0; these are persistent streams | +| Receive frame cap | 4 | Opener's accepted frame limit | +| Pair identifier | 8 | Same nonzero value on both roles | + +Scope the pair identifier to the connection and opener. It is separate from +the generic prelude's optional request ID. Apply the existing simultaneous-open +selection rule to the whole pair. Reject duplicate roles, mismatched identifiers, +and unsupported declarations. + +Neither role reaches block sync until both are ready. At most one incomplete +pair per block-sync service and connection is retained. The second role must +arrive within the configured prelude deadline, three seconds by default. +Setup holds the service's pending and directional session permits. Admission +releases the pending permit; transport tasks and application senders retain the +session permit through teardown. +The demand check for a complete incoming pair reuses that reservation, so the +last available slot can admit a session. It still honors parks and useful-work +policy; opening another pair requires a new reservation. + +## Messages + +Message encodings are unchanged. Every frame has the ordinary eight-byte header +and a payload beginning with the block-sync discriminator. Reject a frame's +message type for the wrong role before allocating its payload. Status is bounded +to 53 payload bytes; GetBlocks and both ending messages to nine. Block decoding +retains its existing maximum block-size check. + +Status travels on the data stream. A request can arrive first on the other +stream: retain at most one decoded request while awaiting valid Status, for at +most ten seconds. Arrival order alone is not a protocol error. + +One serving task handles requests sequentially. It acquires the authenticated +peer's response permit before node capacity, reads the bounded range, and sends +the available prefix followed by its ending message on the captured data sender. +Storage jobs, encodes, and queued or writing frames retain their permits until +completion or discard. Status can interleave between complete response frames. + +## Writes and retirement + +Both roles share one session identity, cancellation token, and message-rate +budget. Ending either role retires both; a replacement uses a new identity. +Teardown waits for both workers and their readers before reporting session exit. + +Claim each outgoing request atomically against expiry and reset before its first +byte is written. Skip invalidated unwritten requests. A started request finishes +while its session remains valid, even if its request deadline expires. Cancelling +an unfinished write resets the pair; never append another frame after an +abandoned partial frame. + +Request writes have no independent write timeout. The data stream has a bounded +32-second write deadline, including control and ending messages. This allows +shared-credit waits on slow links while other services are paused. Cancellation +can still interrupt a data write. Stream resets during a payload remain local to +the stream pair. A data-write timeout also resets only the pair, preserving +unrelated services on the connection while block sync reopens. A graceful end +with a truncated frame payload remains invalid. + +## Flow control + +Keep the existing 16 MiB receive window per stream, 32 MiB shared connection +receive ceiling, and 32 MiB connection send window. Opening a stream does not +reserve dedicated connection credit. Other services can consume the shared +allowance; bounded application queues do not prevent that. + +Temporary pauses can clear naturally. Sustained stalls use the existing write, +setup, and download deadlines. Cleanup returns unreceived work for retry and +retains resources still owned by running jobs. Reopening remains subject to +cooldown and backoff. A usable peer must still be able to complete the download +when another connection is saturated. diff --git a/qa/supply-chain/audits.toml b/qa/supply-chain/audits.toml index 7415a56665..672460e02f 100644 --- a/qa/supply-chain/audits.toml +++ b/qa/supply-chain/audits.toml @@ -4381,3 +4381,24 @@ criteria = "safe-to-deploy" user-id = 169181 # Kris Nuttycombe (nuttycom) start = "2024-12-17" end = "2027-07-10" + +[[audits.iroh-quinn]] +who = "OpenAI Codex " +criteria = "safe-to-deploy" +delta = "0.14.0 -> 0.14.0@git:1dcc7a43488fecd199d343d47e93e9ed8319fcaa" +importable = false +notes = "Compared the 25 packaged files, including the original manifest, with the pinned Git source. They are byte-identical to the already audited registry release. The related protocol crate carries the separately audited loss-recovery fix." + +[[audits.iroh-quinn-proto]] +who = "OpenAI Codex " +criteria = "safe-to-deploy" +delta = "0.13.0 -> 0.13.0@git:1dcc7a43488fecd199d343d47e93e9ed8319fcaa" +importable = false +notes = "Compared all 53 packaged files with the pinned source. Only the transmit-bound condition and its regression test differ. The condition counts datagrams directly instead of inferring their count from bytes, so a short loss probe cannot admit a second datagram when the socket supports only one. The integer conversion is bounded by the existing ten-segment clamp. The regression failed on the original condition; all 254 default-feature protocol tests pass with the fix. No parsing, cryptography, dependency, or unsafe-code changes." + +[[audits.iroh-quinn-udp]] +who = "OpenAI Codex " +criteria = "safe-to-deploy" +delta = "0.5.7 -> 0.5.7@git:1dcc7a43488fecd199d343d47e93e9ed8319fcaa" +importable = false +notes = "Compared the 13 packaged files, including the original manifest and build script, with the pinned Git source. They are byte-identical to the already audited registry release. Socket and platform-specific code is unchanged." diff --git a/qa/supply-chain/config.toml b/qa/supply-chain/config.toml index 62b0022789..c192e21d4c 100644 --- a/qa/supply-chain/config.toml +++ b/qa/supply-chain/config.toml @@ -28,6 +28,16 @@ url = "https://raw.githubusercontent.com/zcash/rust-ecosystem/main/supply-chain/ [imports.zcashd] url = "https://raw.githubusercontent.com/zcash/zcash/master/qa/supply-chain/audits.toml" +# Keep the existing registry audits and require a reviewed delta for the backport. +[policy.iroh-quinn] +audit-as-crates-io = true + +[policy.iroh-quinn-proto] +audit-as-crates-io = true + +[policy.iroh-quinn-udp] +audit-as-crates-io = true + [policy.zakura] audit-as-crates-io = false