@peerbit/native-backbone 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,727 @@
1
+ //! Receive fusion for shared-log raw exchange-heads sync.
2
+ //!
3
+ //! The `peerbit_wire` envelope codec is compiled into this wasm module (rather
4
+ //! than the standalone `@peerbit/network-rust` module) so the single boundary
5
+ //! write that `decode_and_verify_batch` already performs — copying the inbound
6
+ //! frame into wasm linear memory — can be shared with the native-backbone
7
+ //! prepared-raw-receive pipeline. Frames whose DataMessage payload is a
8
+ //! shared-log RPC `RawExchangeHeadsMessage([0,7])` on a registered topic are
9
+ //! kept ("stashed") in that memory; the entry block bytes are later fed into
10
+ //! `prepare_raw_receive_*` as in-memory slices instead of a second
11
+ //! JS→wasm copy, and JS never materializes the borsh `RawEntryWithRefs`.
12
+ //!
13
+ //! One [`NativeWireSyncSession`] belongs to one node: its DirectStream feeds
14
+ //! `decode_and_verify_batch` and its shared-log programs register their topics
15
+ //! and consume stashed payloads by message id.
16
+ //!
17
+ //! The stash core is `JsValue`-free so host `cargo test` covers the decision
18
+ //! and eviction logic.
19
+
20
+ use js_sys::{Array, Uint32Array, Uint8Array};
21
+ use peerbit_wire::sync_payload::{
22
+ parse_pubsub_data, parse_raw_exchange_rpc_request, SyncPayloadHead,
23
+ };
24
+ use peerbit_wire::wire::{
25
+ decode_and_verify_frames, decode_frame_delivery_meta, DeliveryMode, FrameRecord, VerifyStatus,
26
+ ID_LENGTH, VARIANT_DATA,
27
+ };
28
+ use peerbit_wire::{record_to_words, RECORD_FLAG_SYNC_STASHED, RECORD_WORDS};
29
+ use std::collections::{HashMap, VecDeque};
30
+ use wasm_bindgen::prelude::*;
31
+
32
+ use crate::js_interop::strings_slice_to_array;
33
+ use crate::NativePeerbitBackbone;
34
+
35
+ /// Bounds for never-consumed stash entries (a message can be stashed at the
36
+ /// wire level and then dropped before program dispatch, e.g. by the seen
37
+ /// cache). FIFO-evicted; eviction of a never-resolved entry only costs the
38
+ /// fused fast path — the TS fallback still processes the message. Entries
39
+ /// resolved for processing (`stashed_meta`) are pinned and excluded from
40
+ /// eviction until `release`: after resolve there is no TS fallback (the RPC
41
+ /// layer skipped the borsh decode), so evicting a pinned entry would drop the
42
+ /// message's heads. Pinned entries are bounded by the message-processing
43
+ /// concurrency of their consumers, not by these caps.
44
+ pub(crate) const WIRE_SYNC_MAX_STASHED_MESSAGES: usize = 512;
45
+ pub(crate) const WIRE_SYNC_MAX_STASHED_BYTES: usize = 64 * 1024 * 1024;
46
+
47
+ pub(crate) struct StashedSyncMessage {
48
+ frame: Vec<u8>,
49
+ /// Head byte offsets are absolute within `frame`.
50
+ heads: Vec<SyncPayloadHead>,
51
+ reserved: [u8; 4],
52
+ payload_length: usize,
53
+ /// Pinned entries are mid-processing (resolved via `stashed_meta`, not
54
+ /// yet released); they are excluded from FIFO eviction.
55
+ pinned: bool,
56
+ }
57
+
58
+ #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
59
+ pub(crate) struct WireSyncCounters {
60
+ pub stashed: u32,
61
+ pub evicted: u32,
62
+ pub meta_reads: u32,
63
+ pub block_copy_outs: u32,
64
+ pub released: u32,
65
+ }
66
+
67
+ #[derive(Default)]
68
+ pub(crate) struct WireSyncCore {
69
+ self_hash: String,
70
+ topic_refs: HashMap<String, usize>,
71
+ stash: HashMap<[u8; ID_LENGTH], StashedSyncMessage>,
72
+ order: VecDeque<[u8; ID_LENGTH]>,
73
+ stashed_bytes: usize,
74
+ pub(crate) counters: WireSyncCounters,
75
+ }
76
+
77
+ impl WireSyncCore {
78
+ pub(crate) fn new(self_hash: String) -> Self {
79
+ WireSyncCore {
80
+ self_hash,
81
+ ..WireSyncCore::default()
82
+ }
83
+ }
84
+
85
+ pub(crate) fn register_topic(&mut self, topic: String) {
86
+ *self.topic_refs.entry(topic).or_insert(0) += 1;
87
+ }
88
+
89
+ pub(crate) fn unregister_topic(&mut self, topic: &str) -> bool {
90
+ match self.topic_refs.get_mut(topic) {
91
+ Some(count) if *count > 1 => {
92
+ *count -= 1;
93
+ true
94
+ }
95
+ Some(_) => {
96
+ self.topic_refs.remove(topic);
97
+ true
98
+ }
99
+ None => false,
100
+ }
101
+ }
102
+
103
+ pub(crate) fn topic_count(&self) -> usize {
104
+ self.topic_refs.len()
105
+ }
106
+
107
+ fn delivered_locally(&self, mode: Option<&DeliveryMode>) -> bool {
108
+ // Mirrors TopicControlPlane.onDataMessage: explicit receivers must
109
+ // include this node; AnyWhere modes are always delivered locally.
110
+ match mode {
111
+ Some(DeliveryMode::Silent { to, .. }) | Some(DeliveryMode::Acknowledge { to, .. }) => {
112
+ to.iter().any(|hash| hash == &self.self_hash)
113
+ }
114
+ Some(DeliveryMode::AnyWhere) | Some(DeliveryMode::AcknowledgeAnyWhere { .. }) => true,
115
+ Some(DeliveryMode::Traced { .. }) | None => false,
116
+ }
117
+ }
118
+
119
+ /// Try to stash a decoded-and-verified DataMessage frame. Returns `true`
120
+ /// when the frame carried a raw exchange sync payload for a registered
121
+ /// topic addressed to this node.
122
+ pub(crate) fn try_stash(
123
+ &mut self,
124
+ frame: &mut Option<Vec<u8>>,
125
+ data_offset: usize,
126
+ data_length: usize,
127
+ ) -> bool {
128
+ if self.topic_refs.is_empty() {
129
+ return false;
130
+ }
131
+ let Some(frame_bytes) = frame.as_deref() else {
132
+ return false;
133
+ };
134
+ let Some(payload) = frame_bytes.get(data_offset..data_offset + data_length) else {
135
+ return false;
136
+ };
137
+ let Ok(pubsub) = parse_pubsub_data(payload) else {
138
+ return false;
139
+ };
140
+ if !pubsub
141
+ .topics
142
+ .iter()
143
+ .any(|topic| self.topic_refs.contains_key(topic))
144
+ {
145
+ return false;
146
+ }
147
+ let Some(data) = payload.get(pubsub.data_offset..pubsub.data_offset + pubsub.data_length)
148
+ else {
149
+ return false;
150
+ };
151
+ let Ok(parsed) = parse_raw_exchange_rpc_request(data) else {
152
+ return false;
153
+ };
154
+ let Ok(meta) = decode_frame_delivery_meta(frame_bytes) else {
155
+ return false;
156
+ };
157
+ if meta.variant != VARIANT_DATA || !self.delivered_locally(meta.mode.as_ref()) {
158
+ return false;
159
+ }
160
+
161
+ if self
162
+ .stash
163
+ .get(&meta.id)
164
+ .is_some_and(|existing| existing.pinned)
165
+ {
166
+ // Duplicate delivery of a message that is mid-processing: the id
167
+ // is part of the signed header, so the stashed frame is
168
+ // byte-identical. Keep the pinned entry (replacing it would reset
169
+ // the pin) and report the frame as stashed.
170
+ self.counters.stashed += 1;
171
+ return true;
172
+ }
173
+
174
+ let heads = parsed
175
+ .heads
176
+ .into_iter()
177
+ .map(|head| SyncPayloadHead {
178
+ // Translate payload-relative offsets to frame-absolute ones.
179
+ bytes_offset: data_offset + pubsub.data_offset + head.bytes_offset,
180
+ ..head
181
+ })
182
+ .collect();
183
+ let frame = frame.take().expect("frame checked above");
184
+ let frame_length = frame.len();
185
+ if let Some(previous) = self.stash.insert(
186
+ meta.id,
187
+ StashedSyncMessage {
188
+ frame,
189
+ heads,
190
+ reserved: parsed.reserved,
191
+ payload_length: data_length,
192
+ pinned: false,
193
+ },
194
+ ) {
195
+ self.stashed_bytes -= previous.frame.len();
196
+ self.order.retain(|id| id != &meta.id);
197
+ }
198
+ self.stashed_bytes += frame_length;
199
+ self.order.push_back(meta.id);
200
+ self.counters.stashed += 1;
201
+ while self.stash.len() > WIRE_SYNC_MAX_STASHED_MESSAGES
202
+ || self.stashed_bytes > WIRE_SYNC_MAX_STASHED_BYTES
203
+ {
204
+ let Some(oldest) = self.order.pop_front() else {
205
+ break;
206
+ };
207
+ if let Some(evicted) = self.stash.remove(&oldest) {
208
+ self.stashed_bytes -= evicted.frame.len();
209
+ self.counters.evicted += 1;
210
+ }
211
+ }
212
+ true
213
+ }
214
+
215
+ pub(crate) fn get(&self, id: &[u8]) -> Option<&StashedSyncMessage> {
216
+ let id: &[u8; ID_LENGTH] = id.try_into().ok()?;
217
+ self.stash.get(id)
218
+ }
219
+
220
+ /// Pin a stashed entry while it is being processed: pinned entries are
221
+ /// taken out of the eviction order until `release` removes them, so the
222
+ /// block bytes an in-flight `StashBackedRawExchangeHeadsMessage` refers
223
+ /// to cannot be dropped by later `try_stash` calls. Returns `false` when
224
+ /// the id is not stashed.
225
+ pub(crate) fn pin(&mut self, id: &[u8]) -> bool {
226
+ let Ok(id) = <&[u8; ID_LENGTH]>::try_from(id) else {
227
+ return false;
228
+ };
229
+ match self.stash.get_mut(id) {
230
+ Some(entry) if entry.pinned => true,
231
+ Some(entry) => {
232
+ entry.pinned = true;
233
+ self.order.retain(|entry_id| entry_id != id);
234
+ true
235
+ }
236
+ None => false,
237
+ }
238
+ }
239
+
240
+ pub(crate) fn release(&mut self, id: &[u8]) -> bool {
241
+ let Ok(id) = <&[u8; ID_LENGTH]>::try_from(id) else {
242
+ return false;
243
+ };
244
+ if let Some(removed) = self.stash.remove(id) {
245
+ self.stashed_bytes -= removed.frame.len();
246
+ self.order.retain(|entry| entry != id);
247
+ self.counters.released += 1;
248
+ true
249
+ } else {
250
+ false
251
+ }
252
+ }
253
+
254
+ pub(crate) fn stash_len(&self) -> usize {
255
+ self.stash.len()
256
+ }
257
+
258
+ /// Copy the selected head block bytes out of a stashed message. `indexes`
259
+ /// of `None` selects every head. This is the in-wasm handoff used by the
260
+ /// stashed prepare entry points and — as `Uint8Array`s — the JS fallback.
261
+ pub(crate) fn blocks(&self, id: &[u8], indexes: Option<&[u32]>) -> Option<Vec<Vec<u8>>> {
262
+ let stashed = self.get(id)?;
263
+ let select = |head: &SyncPayloadHead| {
264
+ stashed
265
+ .frame
266
+ .get(head.bytes_offset..head.bytes_offset + head.bytes_length)
267
+ .map(<[u8]>::to_vec)
268
+ };
269
+ match indexes {
270
+ Some(indexes) => indexes
271
+ .iter()
272
+ .map(|index| stashed.heads.get(*index as usize).and_then(select))
273
+ .collect(),
274
+ None => stashed.heads.iter().map(select).collect(),
275
+ }
276
+ }
277
+ }
278
+
279
+ #[cfg(test)]
280
+ impl StashedSyncMessage {
281
+ pub(crate) fn head_count(&self) -> usize {
282
+ self.heads.len()
283
+ }
284
+
285
+ pub(crate) fn head(&self, index: usize) -> Option<&SyncPayloadHead> {
286
+ self.heads.get(index)
287
+ }
288
+
289
+ pub(crate) fn reserved(&self) -> [u8; 4] {
290
+ self.reserved
291
+ }
292
+ }
293
+
294
+ /// Per-node receive-fusion state: the fused wire decoder for DirectStream and
295
+ /// the stash consumed by shared-log programs. See the module docs.
296
+ #[wasm_bindgen]
297
+ pub struct NativeWireSyncSession {
298
+ pub(crate) core: WireSyncCore,
299
+ }
300
+
301
+ #[wasm_bindgen]
302
+ impl NativeWireSyncSession {
303
+ #[wasm_bindgen(constructor)]
304
+ pub fn new(self_hash: String) -> Self {
305
+ NativeWireSyncSession {
306
+ core: WireSyncCore::new(self_hash),
307
+ }
308
+ }
309
+
310
+ pub fn register_topic(&mut self, topic: String) {
311
+ self.core.register_topic(topic);
312
+ }
313
+
314
+ pub fn unregister_topic(&mut self, topic: &str) -> bool {
315
+ self.core.unregister_topic(topic)
316
+ }
317
+
318
+ pub fn topic_count(&self) -> usize {
319
+ self.core.topic_count()
320
+ }
321
+
322
+ /// Drop-in replacement for `peerbit_wire`'s `decode_and_verify_batch`
323
+ /// (same flat u32 record layout) that additionally stashes raw exchange
324
+ /// sync payloads for registered topics, flagging their records with
325
+ /// `RECORD_FLAG_SYNC_STASHED`.
326
+ pub fn decode_and_verify_batch(&mut self, frames: Array, now_ms: f64) -> Vec<u32> {
327
+ let mut buffers: Vec<Option<Vec<u8>>> = frames
328
+ .iter()
329
+ .map(|value| {
330
+ value
331
+ .dyn_into::<Uint8Array>()
332
+ .ok()
333
+ .map(|array| array.to_vec())
334
+ })
335
+ .collect();
336
+ let slices: Vec<&[u8]> = buffers
337
+ .iter()
338
+ .map(|buffer| buffer.as_deref().unwrap_or(&[]))
339
+ .collect();
340
+ let records = decode_and_verify_frames(&slices, now_ms as u64);
341
+ let mut words = Vec::with_capacity(records.len() * RECORD_WORDS);
342
+ for (record, buffer) in records.iter().zip(buffers.iter_mut()) {
343
+ let stashed = record_is_stash_candidate(record)
344
+ && self.core.try_stash(
345
+ buffer,
346
+ record.data_offset as usize,
347
+ record.data_length as usize,
348
+ );
349
+ record_to_words(record, &mut words);
350
+ if stashed {
351
+ let flag_word = words.len() - RECORD_WORDS;
352
+ words[flag_word] |= RECORD_FLAG_SYNC_STASHED;
353
+ }
354
+ }
355
+ words
356
+ }
357
+
358
+ /// Stash facts for a message id: `[hashes, gidRefrences, byteLengths,
359
+ /// reserved, payloadLength]`, or `undefined` when not stashed. Does not
360
+ /// consume the entry, but pins it: a resolved message has no TS decode
361
+ /// fallback anymore, so the entry must survive FIFO eviction until
362
+ /// `release` is called when processing finishes.
363
+ pub fn stashed_meta(&mut self, id: &[u8]) -> JsValue {
364
+ if !self.core.pin(id) {
365
+ return JsValue::UNDEFINED;
366
+ }
367
+ self.core.counters.meta_reads += 1;
368
+ let stashed = self.core.get(id).expect("pinned above");
369
+ let hashes = Array::new();
370
+ let gid_refrences = Array::new();
371
+ let mut byte_lengths: Vec<u32> = Vec::with_capacity(stashed.heads.len());
372
+ for head in &stashed.heads {
373
+ hashes.push(&JsValue::from_str(&head.hash));
374
+ gid_refrences.push(&strings_slice_to_array(&head.gid_refrences));
375
+ byte_lengths.push(head.bytes_length as u32);
376
+ }
377
+ let out = Array::new();
378
+ out.push(&hashes);
379
+ out.push(&gid_refrences);
380
+ out.push(&Uint32Array::from(byte_lengths.as_slice()));
381
+ out.push(&Uint8Array::from(stashed.reserved.as_slice()));
382
+ out.push(&JsValue::from_f64(stashed.payload_length as f64));
383
+ out.into()
384
+ }
385
+
386
+ /// Copy head block bytes out to JS (fallback paths only — the fused path
387
+ /// hands blocks to `prepare_stashed_raw_receive_*` inside wasm memory).
388
+ pub fn stashed_blocks(&mut self, id: &[u8], indexes: Option<Uint32Array>) -> JsValue {
389
+ let indexes = indexes.map(|indexes| indexes.to_vec());
390
+ let Some(blocks) = self.core.blocks(id, indexes.as_deref()) else {
391
+ return JsValue::UNDEFINED;
392
+ };
393
+ self.core.counters.block_copy_outs += blocks.len() as u32;
394
+ let out = Array::new();
395
+ for block in blocks {
396
+ out.push(&Uint8Array::from(block.as_slice()));
397
+ }
398
+ out.into()
399
+ }
400
+
401
+ pub fn release(&mut self, id: &[u8]) -> bool {
402
+ self.core.release(id)
403
+ }
404
+
405
+ pub fn stash_len(&self) -> usize {
406
+ self.core.stash_len()
407
+ }
408
+
409
+ /// `[stashed, evicted, metaReads, blockCopyOuts, released]`.
410
+ pub fn counters(&self) -> Vec<u32> {
411
+ let counters = &self.core.counters;
412
+ vec![
413
+ counters.stashed,
414
+ counters.evicted,
415
+ counters.meta_reads,
416
+ counters.block_copy_outs,
417
+ counters.released,
418
+ ]
419
+ }
420
+ }
421
+
422
+ fn record_is_stash_candidate(record: &FrameRecord) -> bool {
423
+ record.decode_ok
424
+ && record.variant == VARIANT_DATA
425
+ && record.has_data
426
+ && record.verify == VerifyStatus::Verified
427
+ }
428
+
429
+ #[wasm_bindgen]
430
+ impl NativePeerbitBackbone {
431
+ /// Stashed-input twin of
432
+ /// `prepare_raw_receive_unverified_expected_compact_columns_batch`: the
433
+ /// blocks come from the wire stash (wasm memory) instead of a JS array.
434
+ pub fn prepare_stashed_raw_receive_expected_compact_columns_batch(
435
+ &mut self,
436
+ session: &NativeWireSyncSession,
437
+ id: &[u8],
438
+ indexes: Uint32Array,
439
+ hashes: Array,
440
+ verify_signatures: bool,
441
+ ) -> Result<JsValue, JsValue> {
442
+ let indexes = indexes.to_vec();
443
+ let Some(blocks) = session.core.blocks(id, Some(&indexes)) else {
444
+ return Ok(JsValue::UNDEFINED);
445
+ };
446
+ let hashes = crate::js_interop::strings_from_array(hashes)?;
447
+ crate::js_interop::ensure_same_len(blocks.len(), hashes.len(), "stashed raw receive")?;
448
+ let prepared = self.prepare_raw_receive_entries(blocks, Some(hashes), verify_signatures)?;
449
+ Ok(self
450
+ .prepare_raw_receive_columns_from_entries(prepared, false, false)?
451
+ .into())
452
+ }
453
+
454
+ /// Stashed-input twin of
455
+ /// `prepare_raw_receive_unverified_expected_compact_columns_and_selection_batch`.
456
+ #[allow(clippy::too_many_arguments)]
457
+ pub fn prepare_stashed_raw_receive_expected_compact_columns_and_selection_batch(
458
+ &mut self,
459
+ session: &NativeWireSyncSession,
460
+ id: &[u8],
461
+ indexes: Uint32Array,
462
+ hashes: Array,
463
+ min_replicas: u32,
464
+ max_replicas: JsValue,
465
+ role_age_ms: f64,
466
+ now: String,
467
+ peer_filter: JsValue,
468
+ expand_peer_filter: bool,
469
+ self_hash: String,
470
+ include_self: bool,
471
+ full_replica_fallback: bool,
472
+ include_strict_full_replica: bool,
473
+ _from_hash: String,
474
+ ) -> Result<JsValue, JsValue> {
475
+ let indexes = indexes.to_vec();
476
+ let Some(blocks) = session.core.blocks(id, Some(&indexes)) else {
477
+ return Ok(JsValue::UNDEFINED);
478
+ };
479
+ let hashes = crate::js_interop::strings_from_array(hashes)?;
480
+ crate::js_interop::ensure_same_len(blocks.len(), hashes.len(), "stashed raw receive")?;
481
+ let prepared = self.prepare_raw_receive_entries(blocks, Some(hashes.clone()), false)?;
482
+ let columns = self.prepare_raw_receive_columns_from_entries(prepared, false, false)?;
483
+ let selection = self.plan_prepared_raw_receive_selection_core(
484
+ hashes,
485
+ min_replicas,
486
+ max_replicas,
487
+ role_age_ms,
488
+ now,
489
+ peer_filter,
490
+ expand_peer_filter,
491
+ self_hash,
492
+ include_self,
493
+ full_replica_fallback,
494
+ include_strict_full_replica,
495
+ )?;
496
+ let out = Array::new();
497
+ out.push(&columns);
498
+ out.push(&selection);
499
+ Ok(out.into())
500
+ }
501
+
502
+ /// Sync fallback for lazily materialized stash-backed heads whose stash
503
+ /// entry was already released: serve the raw block bytes from the pending
504
+ /// prepared entries or the committed block store.
505
+ pub fn raw_receive_block_bytes(&self, hash: &str) -> JsValue {
506
+ if let Some(pending) = self.pending_raw_receive_entries.get(hash) {
507
+ return Uint8Array::from(pending.storage_bytes()).into();
508
+ }
509
+ match self.blocks.get_ref(hash) {
510
+ Some(bytes) => Uint8Array::from(bytes).into(),
511
+ None => JsValue::UNDEFINED,
512
+ }
513
+ }
514
+ }
515
+
516
+ #[cfg(test)]
517
+ mod tests {
518
+ use super::*;
519
+ use peerbit_wire::sync_payload::encode_raw_exchange_sync_payload;
520
+ use peerbit_wire::wire::{encode_frame, MessageHeader, WireMessage};
521
+
522
+ fn sync_frame(
523
+ id_byte: u8,
524
+ topic: &str,
525
+ mode: Option<DeliveryMode>,
526
+ heads: &[(String, Vec<u8>, Vec<String>)],
527
+ ) -> (Vec<u8>, usize, usize) {
528
+ let payload =
529
+ encode_raw_exchange_sync_payload(&[topic.to_string()], true, heads, [0, 0, 0, 0]);
530
+ let message = WireMessage::Data {
531
+ header: MessageHeader {
532
+ id: [id_byte; ID_LENGTH],
533
+ timestamp: 1,
534
+ session: 2,
535
+ expires: u64::MAX,
536
+ priority: Some(0),
537
+ response_priority: None,
538
+ origin: None,
539
+ mode,
540
+ signatures: Some(Vec::new()),
541
+ },
542
+ data: Some(payload.clone()),
543
+ };
544
+ let frame = encode_frame(&message);
545
+ let data_offset = frame.len() - payload.len();
546
+ (frame, data_offset, payload.len())
547
+ }
548
+
549
+ fn heads() -> Vec<(String, Vec<u8>, Vec<String>)> {
550
+ vec![
551
+ ("h0".to_string(), vec![1, 2, 3], Vec::new()),
552
+ ("h1".to_string(), vec![4, 5], vec!["g".to_string()]),
553
+ ]
554
+ }
555
+
556
+ fn silent_to_self() -> Option<DeliveryMode> {
557
+ Some(DeliveryMode::Silent {
558
+ to: vec!["self-hash".to_string()],
559
+ redundancy: 1,
560
+ })
561
+ }
562
+
563
+ #[test]
564
+ fn stashes_registered_topic_frames_addressed_to_self() {
565
+ let mut core = WireSyncCore::new("self-hash".to_string());
566
+ core.register_topic("topic".to_string());
567
+ let (frame, data_offset, data_length) = sync_frame(7, "topic", silent_to_self(), &heads());
568
+ let mut buffer = Some(frame.clone());
569
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
570
+ assert!(buffer.is_none(), "stash takes frame ownership");
571
+ assert_eq!(core.stash_len(), 1);
572
+
573
+ let stashed = core.get(&[7u8; ID_LENGTH]).unwrap();
574
+ assert_eq!(stashed.head_count(), 2);
575
+ assert_eq!(stashed.head(0).unwrap().hash, "h0");
576
+ assert_eq!(stashed.reserved(), [0, 0, 0, 0]);
577
+ let blocks = core.blocks(&[7u8; ID_LENGTH], None).unwrap();
578
+ assert_eq!(blocks, vec![vec![1, 2, 3], vec![4, 5]]);
579
+ let selected = core.blocks(&[7u8; ID_LENGTH], Some(&[1])).unwrap();
580
+ assert_eq!(selected, vec![vec![4, 5]]);
581
+
582
+ assert!(core.release(&[7u8; ID_LENGTH]));
583
+ assert_eq!(core.stash_len(), 0);
584
+ assert!(!core.release(&[7u8; ID_LENGTH]));
585
+ }
586
+
587
+ #[test]
588
+ fn skips_unregistered_topics_and_foreign_recipients() {
589
+ let mut core = WireSyncCore::new("self-hash".to_string());
590
+ core.register_topic("topic".to_string());
591
+
592
+ let (frame, data_offset, data_length) =
593
+ sync_frame(1, "other-topic", silent_to_self(), &heads());
594
+ let mut buffer = Some(frame);
595
+ assert!(!core.try_stash(&mut buffer, data_offset, data_length));
596
+ assert!(buffer.is_some(), "rejected frames keep their buffer");
597
+
598
+ let relay_mode = Some(DeliveryMode::Silent {
599
+ to: vec!["someone-else".to_string()],
600
+ redundancy: 1,
601
+ });
602
+ let (frame, data_offset, data_length) = sync_frame(2, "topic", relay_mode, &heads());
603
+ let mut buffer = Some(frame);
604
+ assert!(!core.try_stash(&mut buffer, data_offset, data_length));
605
+
606
+ let (frame, data_offset, data_length) =
607
+ sync_frame(3, "topic", Some(DeliveryMode::AnyWhere), &heads());
608
+ let mut buffer = Some(frame);
609
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
610
+
611
+ core.unregister_topic("topic");
612
+ let (frame, data_offset, data_length) = sync_frame(4, "topic", silent_to_self(), &heads());
613
+ let mut buffer = Some(frame);
614
+ assert!(!core.try_stash(&mut buffer, data_offset, data_length));
615
+ }
616
+
617
+ #[test]
618
+ fn topic_registration_is_refcounted() {
619
+ let mut core = WireSyncCore::new("self-hash".to_string());
620
+ core.register_topic("topic".to_string());
621
+ core.register_topic("topic".to_string());
622
+ assert!(core.unregister_topic("topic"));
623
+ assert_eq!(core.topic_count(), 1);
624
+ assert!(core.unregister_topic("topic"));
625
+ assert_eq!(core.topic_count(), 0);
626
+ assert!(!core.unregister_topic("topic"));
627
+ }
628
+
629
+ #[test]
630
+ fn evicts_oldest_when_over_message_cap() {
631
+ let mut core = WireSyncCore::new("self-hash".to_string());
632
+ core.register_topic("topic".to_string());
633
+ for index in 0..(WIRE_SYNC_MAX_STASHED_MESSAGES + 3) {
634
+ let (frame, data_offset, data_length) =
635
+ sync_frame(index as u8, "topic", silent_to_self(), &heads());
636
+ // ids repeat every 256 messages; use distinct high bytes instead
637
+ let mut frame = frame;
638
+ frame[2] = (index >> 8) as u8; // second byte of the 32-byte id
639
+ let mut buffer = Some(frame);
640
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
641
+ }
642
+ assert_eq!(core.stash_len(), WIRE_SYNC_MAX_STASHED_MESSAGES);
643
+ assert_eq!(core.counters.evicted, 3);
644
+ }
645
+
646
+ #[test]
647
+ fn pinned_entries_survive_fifo_eviction_until_release() {
648
+ // Regression: a message that was resolved for processing
649
+ // (stashed_meta pins it) must keep its block bytes available across
650
+ // the awaits of SharedLog.onMessage even when a burst of later
651
+ // frames overflows the FIFO caps — there is no TS fallback for a
652
+ // resolved message.
653
+ let mut core = WireSyncCore::new("self-hash".to_string());
654
+ core.register_topic("topic".to_string());
655
+ let (frame, data_offset, data_length) = sync_frame(0, "topic", silent_to_self(), &heads());
656
+ let mut buffer = Some(frame);
657
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
658
+ assert!(core.pin(&[0u8; ID_LENGTH]));
659
+ assert!(!core.pin(&[9u8; ID_LENGTH]), "missing ids cannot be pinned");
660
+
661
+ // Flood far past the message cap; the pinned entry is oldest.
662
+ for index in 1..(WIRE_SYNC_MAX_STASHED_MESSAGES + 8) {
663
+ let (frame, data_offset, data_length) =
664
+ sync_frame(index as u8, "topic", silent_to_self(), &heads());
665
+ let mut frame = frame;
666
+ frame[2] = (index >> 8) as u8; // second byte of the 32-byte id
667
+ let mut buffer = Some(frame);
668
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
669
+ }
670
+ assert!(core.counters.evicted > 0);
671
+ // Pinned entry survives (the cap counts it, so the stash holds the
672
+ // cap's worth of unpinned entries plus the pinned one).
673
+ let blocks = core.blocks(&[0u8; ID_LENGTH], None).unwrap();
674
+ assert_eq!(blocks, vec![vec![1, 2, 3], vec![4, 5]]);
675
+
676
+ assert!(core.release(&[0u8; ID_LENGTH]));
677
+ assert!(core.get(&[0u8; ID_LENGTH]).is_none());
678
+ assert!(!core.release(&[0u8; ID_LENGTH]));
679
+ }
680
+
681
+ #[test]
682
+ fn duplicate_of_pinned_entry_keeps_the_pinned_entry() {
683
+ // A duplicate delivery (redundancy >= 2) of a message that is
684
+ // mid-processing must not replace the pinned entry — replacing would
685
+ // reset the pin and re-expose the in-flight message to eviction.
686
+ let mut core = WireSyncCore::new("self-hash".to_string());
687
+ core.register_topic("topic".to_string());
688
+ let (frame, data_offset, data_length) = sync_frame(5, "topic", silent_to_self(), &heads());
689
+ let mut buffer = Some(frame.clone());
690
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
691
+ assert!(core.pin(&[5u8; ID_LENGTH]));
692
+
693
+ let mut buffer = Some(frame);
694
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
695
+ assert!(
696
+ buffer.is_some(),
697
+ "duplicates of pinned entries keep their buffer"
698
+ );
699
+ assert_eq!(core.stash_len(), 1);
700
+
701
+ // Still pinned: flooding past the cap does not evict it.
702
+ for index in 0..WIRE_SYNC_MAX_STASHED_MESSAGES + 1 {
703
+ let (frame, data_offset, data_length) =
704
+ sync_frame(index as u8, "topic", silent_to_self(), &heads());
705
+ let mut frame = frame;
706
+ frame[2] = 0xff; // distinct id space from the pinned entry
707
+ frame[3] = (index >> 8) as u8;
708
+ let mut buffer = Some(frame);
709
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
710
+ }
711
+ assert!(core.blocks(&[5u8; ID_LENGTH], None).is_some());
712
+ assert!(core.release(&[5u8; ID_LENGTH]));
713
+ }
714
+
715
+ #[test]
716
+ fn restashing_same_id_replaces_entry() {
717
+ let mut core = WireSyncCore::new("self-hash".to_string());
718
+ core.register_topic("topic".to_string());
719
+ let (frame, data_offset, data_length) = sync_frame(9, "topic", silent_to_self(), &heads());
720
+ let mut buffer = Some(frame.clone());
721
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
722
+ let mut buffer = Some(frame);
723
+ assert!(core.try_stash(&mut buffer, data_offset, data_length));
724
+ assert_eq!(core.stash_len(), 1);
725
+ assert_eq!(core.counters.stashed, 2);
726
+ }
727
+ }