peashape 0.3.0

A traffic-shaping middleware for pea2pea nodes: constant-rate or Poisson outbound traffic, automatic frame padding, and priority lanes for cover traffic.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
//! The shared, clone-able state that backs every [`Node`].
//!
//! [`Node`]: crate::Node

use std::collections::{HashMap, HashSet, VecDeque};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;

use bytes::BytesMut;
use parking_lot::Mutex;
use tokio::sync::{Notify, broadcast};

use crate::config::{CoverGenerator, Lane, ShapeConfig, ShapingScope};
use crate::error::Error;
use crate::frame::{ID_SIZE, random_cover};

/// Capacity of the broadcast channel used to deliver received
/// messages to application subscribers. When the channel is full,
/// the oldest message is dropped and receivers observe
/// `RecvError::Lagged`.
///
/// Exposed as a `pub` constant so that layered protocols (e.g.
/// `peasub`) can size their *own* broadcast channels
/// consistently.
pub const SUBSCRIBER_CAPACITY: usize = 1024;

/// The on-the-wire delivery mode of a queued frame.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum Target {
    /// Send to a specific peer. If the peer is not connected at
    /// tick time, the frame is silently dropped (with a debug
    /// log line).
    Unicast(SocketAddr),
    /// Send to `fanout` randomly-chosen connected peers (in
    /// [`ShapingScope::Global`]) or to a single round-robin
    /// peer (in [`ShapingScope::PerConnection`]).
    ///
    /// [`ShapingScope::Global`]: crate::ShapingScope::Global
    /// [`ShapingScope::PerConnection`]: crate::ShapingScope::PerConnection
    Broadcast,
}

/// A frame that has been queued for transmission, together with
/// its intended delivery target.
#[derive(Clone, Debug)]
pub struct PendingFrame {
    /// The frame bytes (already padded to `frame_size`).
    pub bytes: BytesMut,
    /// Where the frame should be sent.
    pub target: Target,
}

/// The pair of priority lanes maintained for a single peer in
/// [`ShapingScope::PerConnection`] mode. Same disciplines as the
/// global lanes: `high` is a bounded FIFO, `low` is a LIFO with
/// drop-oldest eviction.
#[derive(Default)]
struct PeerLanes {
    high: VecDeque<PendingFrame>,
    low: VecDeque<PendingFrame>,
}

/// The shared, node-wide state that backs every clone of a
/// [`Node`].
///
/// Two priority lanes are maintained:
///
/// - `high_lane` is a FIFO that the scheduler always drains
///   *first*. The natural choice for application-submitted
///   traffic: a fresh enqueue is sent on the very next
///   shaping tick regardless of how much other traffic has
///   piled up. Bounded; [`Error::LaneFull`] is returned once
///   it saturates.
/// - `low_lane` is a LIFO with drop-oldest eviction. The
///   natural choice for relay traffic: a freshly-enqueued
///   frame is pushed to the front and the next tick pops it
///   without waiting in line behind older relays. Under
///   sustained inflow, the oldest queued frame is discarded
///   first to make room.
///
/// In addition, a `tokio::sync::broadcast` channel is used to
/// deliver every frame that arrives from a peer to all
/// application subscribers — both real and cover traffic pass
/// through the same channel, because on the wire they are
/// indistinguishable.
///
/// # Per-connection lanes
///
/// In [`ShapingScope::PerConnection`] mode the global `high_lane`
/// and `low_lane` are *not* used. Instead each peer gets its own
/// pair of lanes (`peer_lanes`), drained on that peer's scheduled
/// slot, so a real frame for a peer simply occupies the cover slot
/// that peer's link was going to emit anyway. `pc_peers` caches the
/// most recent connected-peer set (refreshed by the scheduler each
/// tick) so that a `Broadcast` enqueue can fan a copy into every
/// peer's lane without the shaper needing direct access to the
/// `pea2pea` node.
///
/// [`Node`]: crate::Node
/// [`Error::LaneFull`]: crate::Error::LaneFull
pub struct Shaper {
    config: ShapeConfig,
    incoming: broadcast::Sender<BytesMut>,
    high_lane: Mutex<VecDeque<PendingFrame>>,
    low_lane: Mutex<VecDeque<PendingFrame>>,
    /// Per-peer lanes, used only in [`ShapingScope::PerConnection`].
    peer_lanes: Mutex<HashMap<SocketAddr, PeerLanes>>,
    /// Most-recent connected-peer snapshot, used only in
    /// [`ShapingScope::PerConnection`] to fan out `Broadcast`
    /// enqueues. Refreshed by the scheduler on every tick.
    pc_peers: Mutex<Vec<SocketAddr>>,
    shutting_down: AtomicBool,
    /// The generator consulted to build a cover frame when a tick
    /// fires with empty lanes. Resolved from
    /// [`ShapeConfig::cover_generator`], defaulting to
    /// [`random_cover`].
    cover_generator: Arc<dyn CoverGenerator>,
    /// Wakes the scheduler whenever a real frame is enqueued. Only
    /// consulted by the `None` shaping strategy (passthrough mode)
    /// — the timed strategies tick on their own schedule and ignore
    /// this signal. One notification per enqueue, coalesced across
    /// many enqueues that arrive between scheduler wake-ups.
    enqueue_notify: Arc<Notify>,
}

impl Shaper {
    /// Builds a fresh `Shaper` from the given configuration.
    ///
    /// # Panics
    ///
    /// Panics if `config.high_lane_capacity` or
    /// `config.low_lane_capacity` is `0` (no buffering is
    /// possible), or if `config.frame_size < ID_SIZE` (a frame
    /// could not hold its identifier prefix; the framing helpers
    /// would underflow).
    pub fn new(config: &ShapeConfig) -> Self {
        assert!(
            config.high_lane_capacity > 0,
            "high_lane_capacity must be non-zero",
        );
        assert!(
            config.low_lane_capacity > 0,
            "low_lane_capacity must be non-zero",
        );
        assert!(
            config.frame_size >= ID_SIZE,
            "frame_size ({} bytes) must be at least ID_SIZE ({ID_SIZE} bytes)",
            config.frame_size,
        );
        let (incoming, _) = broadcast::channel(SUBSCRIBER_CAPACITY);
        // Resolve the cover generator now: a caller-supplied one, or
        // the built-in uniform-random default.
        let cover_generator: Arc<dyn CoverGenerator> = match &config.cover_generator {
            Some(g) => g.clone(),
            None => Arc::new(random_cover as fn(&ShapeConfig) -> BytesMut),
        };
        Self {
            config: config.clone(),
            incoming,
            high_lane: Mutex::new(VecDeque::with_capacity(config.high_lane_capacity)),
            low_lane: Mutex::new(VecDeque::with_capacity(config.low_lane_capacity)),
            peer_lanes: Mutex::new(HashMap::new()),
            pc_peers: Mutex::new(Vec::new()),
            shutting_down: AtomicBool::new(false),
            cover_generator,
            enqueue_notify: Arc::new(Notify::new()),
        }
    }

    /// Returns a clone of the enqueue-notification handle. The
    /// scheduler uses this to wake when a real frame has been
    /// enqueued; this is only relevant in
    /// [`ShapingStrategy::None`](crate::ShapingStrategy::None) (passthrough) mode.
    pub fn enqueue_notify(&self) -> Arc<Notify> {
        self.enqueue_notify.clone()
    }

    /// Returns a reference to the configuration this shaper was
    /// built from.
    pub fn config(&self) -> &ShapeConfig {
        &self.config
    }

    /// Returns a clone of the broadcast sender used to deliver
    /// received messages to application subscribers.
    pub fn incoming(&self) -> broadcast::Sender<BytesMut> {
        self.incoming.clone()
    }

    /// Returns a shared reference to the shutdown flag. The
    /// scheduler polls this on every iteration of its loop.
    pub fn shutting_down(&self) -> &AtomicBool {
        &self.shutting_down
    }

    /// Enqueue a real (application-originated) message into the
    /// named lane.
    ///
    /// The payload is padded to `frame_size` with random bytes;
    /// on the wire it is therefore indistinguishable from a
    /// cover message. The returned identifier is the random
    /// 32-byte ID that the message has been assigned; the
    /// application can use it (e.g. correlated with its own
    /// bookkeeping) but it has no on-the-wire significance
    /// beyond dedup at intermediate nodes.
    ///
    /// The high-priority lane is FIFO; once it saturates, further
    /// enqueues return [`Error::LaneFull`]. The low-priority
    /// lane is LIFO with drop-oldest: a fresh frame is always
    /// accepted, but if the lane is at capacity, the oldest
    /// frame is silently evicted from the back to make room.
    ///
    /// # Errors
    ///
    /// - [`Error::PayloadTooLarge`] if `payload.len()` exceeds
    ///   `frame_size - ID_SIZE`.
    /// - [`Error::LaneFull`] if the high-priority lane is at
    ///   capacity (only possible with `lane == Lane::High`).
    pub fn enqueue(
        &self,
        lane: Lane,
        target: Target,
        payload: &[u8],
    ) -> Result<[u8; ID_SIZE], Error> {
        let max_payload = self.config.frame_size - ID_SIZE;
        if payload.len() > max_payload {
            return Err(Error::PayloadTooLarge {
                size: payload.len(),
                max: max_payload,
            });
        }

        let (id, msg) = crate::frame::build_frame(&self.config, payload);
        self.enqueue_raw(lane, target, msg)?;
        Ok(id)
    }

    /// Enqueue a *pre-built* frame into the named lane.
    ///
    /// Unlike [`Shaper::enqueue`], this method does not pad or
    /// generate an ID; the caller is responsible for providing
    /// a fully-shaped frame of exactly `frame_size` bytes.
    ///
    /// This is the right method for protocols that need to
    /// re-broadcast a frame received from a peer *byte-for-byte
    /// unchanged* (so that an intermediate node's dedup cache
    /// can recognize the same ID), or for protocols that want
    /// to use a different frame-construction convention than
    /// peashape's defaults.
    ///
    /// # Errors
    ///
    /// - [`Error::FrameSizeMismatch`] if `frame.len() !=
    ///   frame_size`.
    /// - [`Error::LaneFull`] if the high-priority lane is at
    ///   capacity (only possible with `lane == Lane::High` and a
    ///   `Unicast`/global enqueue; `Broadcast` fan-out in
    ///   `PerConnection` mode is best-effort and never returns
    ///   `LaneFull`).
    pub fn enqueue_raw(&self, lane: Lane, target: Target, frame: BytesMut) -> Result<(), Error> {
        if frame.len() != self.config.frame_size {
            return Err(Error::FrameSizeMismatch {
                size: frame.len(),
                expected: self.config.frame_size,
            });
        }
        let result = match self.config.scope {
            // Global scope: one shared pair of lanes; the scheduler
            // decides fanout/recipients at dispatch time.
            ShapingScope::Global => self.push_global(
                lane,
                PendingFrame {
                    bytes: frame,
                    target,
                },
            ),
            // Per-connection scope: route the frame to the lane(s) of
            // the peer(s) it is destined for. The scheduler drains
            // each peer's lane on that peer's own scheduled slot.
            ShapingScope::PerConnection { .. } => match target {
                Target::Unicast(peer) => self.push_peer(peer, lane, frame),
                Target::Broadcast => {
                    self.fan_out_per_connection(lane, frame);
                    Ok(())
                }
            },
        };
        // Wake the scheduler so passthrough mode can drain the new
        // frame immediately. The timed strategies tick on their own
        // schedule and ignore this signal; the notification is
        // harmless for them.
        self.enqueue_notify.notify_one();
        result
    }

    /// Push a frame onto the shared (global-scope) lanes.
    fn push_global(&self, lane: Lane, pframe: PendingFrame) -> Result<(), Error> {
        match lane {
            Lane::High => {
                let mut l = self.high_lane.lock();
                if l.len() >= self.config.high_lane_capacity {
                    return Err(Error::LaneFull);
                }
                l.push_back(pframe);
            }
            Lane::Low => {
                let mut l = self.low_lane.lock();
                while l.len() >= self.config.low_lane_capacity {
                    l.pop_back();
                }
                l.push_front(pframe);
            }
        }
        Ok(())
    }

    /// Push a unicast frame onto a single peer's per-connection
    /// lane (PerConnection scope). High is bounded FIFO
    /// ([`Error::LaneFull`] when full); Low is LIFO with
    /// drop-oldest.
    fn push_peer(&self, peer: SocketAddr, lane: Lane, frame: BytesMut) -> Result<(), Error> {
        let pframe = PendingFrame {
            bytes: frame,
            target: Target::Unicast(peer),
        };
        let mut map = self.peer_lanes.lock();
        let lanes = map.entry(peer).or_default();
        match lane {
            Lane::High => {
                if lanes.high.len() >= self.config.high_lane_capacity {
                    return Err(Error::LaneFull);
                }
                lanes.high.push_back(pframe);
            }
            Lane::Low => {
                while lanes.low.len() >= self.config.low_lane_capacity {
                    lanes.low.pop_back();
                }
                lanes.low.push_front(pframe);
            }
        }
        Ok(())
    }

    /// Fan a broadcast frame into every cached peer's per-connection
    /// lane (PerConnection scope). Best-effort: a peer whose lane is
    /// full has its oldest queued frame evicted to make room rather
    /// than failing the whole broadcast, so no recipient can stall
    /// delivery to the others. Peers that connect *after* this call
    /// will not receive this particular frame.
    fn fan_out_per_connection(&self, lane: Lane, frame: BytesMut) {
        let peers = self.pc_peers.lock().clone();
        if peers.is_empty() {
            return;
        }
        let mut map = self.peer_lanes.lock();
        for peer in peers {
            let pframe = PendingFrame {
                bytes: frame.clone(),
                target: Target::Unicast(peer),
            };
            let lanes = map.entry(peer).or_default();
            match lane {
                Lane::High => {
                    while lanes.high.len() >= self.config.high_lane_capacity {
                        lanes.high.pop_front();
                    }
                    lanes.high.push_back(pframe);
                }
                Lane::Low => {
                    while lanes.low.len() >= self.config.low_lane_capacity {
                        lanes.low.pop_back();
                    }
                    lanes.low.push_front(pframe);
                }
            }
        }
    }

    /// Returns the next frame to put on the wire, *or* `None` if
    /// both lanes are empty and the caller should generate
    /// cover.
    ///
    /// The high-priority lane is drained first; only when it is
    /// empty is the low-priority lane consulted.
    pub fn next_frame(&self) -> Option<PendingFrame> {
        if let Some(f) = self.high_lane.lock().pop_front() {
            return Some(f);
        }
        self.low_lane.lock().pop_front()
    }

    /// Returns the next frame to put on the wire for `peer`
    /// (PerConnection scope), draining that peer's own high lane
    /// first and then its low lane, or `None` if the peer has no
    /// queued frames and the caller should generate cover.
    pub fn next_frame_for(&self, peer: SocketAddr) -> Option<PendingFrame> {
        let mut map = self.peer_lanes.lock();
        let lanes = map.get_mut(&peer)?;
        if let Some(f) = lanes.high.pop_front() {
            return Some(f);
        }
        lanes.low.pop_front()
    }

    /// Refreshes the cached connected-peer set consulted by
    /// `Broadcast` fan-out in [`ShapingScope::PerConnection`] mode.
    /// Called by the scheduler on every tick.
    pub fn refresh_pc_peers(&self, peers: &[SocketAddr]) {
        let mut cache = self.pc_peers.lock();
        cache.clear();
        cache.extend_from_slice(peers);
    }

    /// Returns a clone of the cached connected-peer set consulted
    /// by `Broadcast` fan-out in [`ShapingScope::PerConnection`]
    /// mode. Used by the passthrough scheduler to walk the
    /// round-robin order.
    pub fn pc_peers_snapshot(&self) -> Vec<SocketAddr> {
        self.pc_peers.lock().clone()
    }

    /// Drops the per-connection lanes of any peer not in
    /// `connected`, so queued frames for a departed peer are
    /// discarded (mirroring the global-scope behavior, where a
    /// unicast to a disconnected peer is dropped) and the map does
    /// not grow without bound. Called by the scheduler on every
    /// tick in [`ShapingScope::PerConnection`] mode.
    pub fn prune_peer_lanes(&self, connected: &[SocketAddr]) {
        let mut map = self.peer_lanes.lock();
        if map.is_empty() {
            return;
        }
        let keep: HashSet<SocketAddr> = connected.iter().copied().collect();
        map.retain(|addr, _| keep.contains(addr));
    }

    /// Returns the total number of frames currently queued, across
    /// both the global lanes (Global scope) and all per-connection
    /// lanes (PerConnection scope). Only one of the two sets is
    /// populated for a given node, so this is simply their sum.
    pub fn queued(&self) -> usize {
        let global = self.high_lane.lock().len() + self.low_lane.lock().len();
        let per_peer: usize = self
            .peer_lanes
            .lock()
            .values()
            .map(|l| l.high.len() + l.low.len())
            .sum();
        global + per_peer
    }

    /// Returns a freshly-generated cover frame, identical in shape to
    /// a real one. Delegates to the configured [`CoverGenerator`]
    /// (the uniform-random [`random_cover`] unless a custom one was
    /// supplied via [`ShapeConfig::cover_generator`]).
    pub fn cover(&self) -> PendingFrame {
        let bytes = self.cover_generator.cover(&self.config);
        debug_assert_eq!(
            bytes.len(),
            self.config.frame_size,
            "cover generator returned a frame of the wrong size",
        );
        PendingFrame {
            bytes,
            target: Target::Broadcast,
        }
    }

    /// Runs a real (lane-drained) frame through the configured
    /// [`CoverGenerator`]'s
    /// [`finalize_real`](CoverGenerator::finalize_real) hook, in send
    /// order, preserving its delivery target. With the default
    /// generator this is a no-op; a custom generator can use it to
    /// sequence real frames into the same stream as cover.
    pub fn finalize_real(&self, mut frame: PendingFrame) -> PendingFrame {
        frame.bytes = self
            .cover_generator
            .finalize_real(&self.config, frame.bytes);
        debug_assert_eq!(
            frame.bytes.len(),
            self.config.frame_size,
            "cover generator finalize_real returned a frame of the wrong size",
        );
        frame
    }

    /// Dispatches a frame received from a peer to all
    /// application subscribers via the broadcast channel.
    ///
    /// Silently drops frames of the wrong size (a misbehaving or
    /// non-`peashape` peer); well-formed frames are fanned out
    /// to every active subscriber. Late subscribers see the
    /// frame only if the broadcast channel hasn't already
    /// recycled its slot.
    pub fn handle_incoming(&self, message: BytesMut) {
        if message.len() != self.config.frame_size {
            return;
        }
        let _ = self.incoming.send(message);
    }
}