umsh_text/engine/
fragment.rs

1//! Outbound fragmentation planning and fixed-capacity reassembly.
2//!
3//! Reassembly storage is one global page pool shared by all conversations: a
4//! fragment's bytes are stored in linked 80-byte pages, and a slot holds only
5//! its key, presence bitmaps, per-fragment lengths/page heads, first-fragment
6//! metadata, and deadlines. A conversation with no incomplete fragmented
7//! message consumes no page storage.
8
9use crate::engine::sequence::{MessageHandle, StreamKey};
10use crate::model::{FRAGMENT_BODY_MAX, FRAGMENT_COUNT_MAX, MessageType, Regarding};
11
12pub const PAGE_SIZE: usize = 80;
13const PAGE_NONE: u8 = 0xFF;
14
15/// Message-level metadata retained from fragment zero (spec: options from the
16/// first fragment apply to the entire reassembled message).
17///
18/// Only fields consulted on *later* receive calls are retained. Presentation
19/// metadata (sender handle, colors) is not: the announcing Insert mutation is
20/// always emitted during the call that delivered fragment zero, so it borrows
21/// those directly from the validated content, at full fidelity.
22#[derive(Clone, Copy, Debug, Default)]
23pub struct FirstMeta {
24    pub message_type_byte: u8,
25    pub regarding: Option<Regarding>,
26    pub editing: Option<u8>,
27}
28
29impl FirstMeta {
30    pub fn message_type(&self) -> MessageType {
31        MessageType::from_byte(self.message_type_byte)
32    }
33}
34
35/// One in-progress reassembly.
36#[derive(Clone, Debug)]
37pub struct Slot {
38    pub stream: StreamKey,
39    pub epoch: u16,
40    pub message_id: u8,
41    pub count: u8,
42    /// Bitmap of fragments whose bytes are stored.
43    pub present: u16,
44    /// Bitmap of fragments the sender reported unavailable.
45    pub unavailable: u16,
46    /// Missing fragments whose automatic repair budget was exhausted. This
47    /// is separate from `unavailable`: the body remains pending until the
48    /// reassembly TTL, but the scheduler must not reset and retry forever.
49    pub repair_exhausted: u16,
50    pub frag_len: [u8; FRAGMENT_COUNT_MAX as usize],
51    frag_head: [u8; FRAGMENT_COUNT_MAX as usize],
52    pub meta: FirstMeta,
53    pub have_meta: bool,
54    /// An Insert mutation has been emitted for this slot's handle.
55    pub announced: bool,
56    /// This reassembly reused a gap placeholder handle, so its announcing
57    /// Insert should be flagged "received late".
58    pub late: bool,
59    /// A notify-eligible mutation (completion or notify deadline) has already
60    /// been emitted for this reassembly, so it must not fire again.
61    pub notified: bool,
62    pub handle: MessageHandle,
63    pub created_ms: u64,
64    pub deadline_ms: u64,
65    /// Next time repair scheduling may consider this slot.
66    pub repair_at_ms: u64,
67    /// When the newest fragment was stored; arrivals defer repair from here.
68    pub last_fragment_ms: u64,
69}
70
71impl Slot {
72    pub fn is_complete(&self) -> bool {
73        let all = (1u16 << self.count) - 1;
74        self.present == all
75    }
76
77    /// Every fragment is either present or reported unavailable, so no
78    /// further repair can improve this slot.
79    pub fn is_settled(&self) -> bool {
80        let all = (1u16 << self.count) - 1;
81        (self.present | self.unavailable) == all
82    }
83
84    pub fn missing(&self) -> impl Iterator<Item = u8> + '_ {
85        (0..self.count).filter(|index| {
86            let bit = 1u16 << index;
87            self.present & bit == 0 && self.unavailable & bit == 0
88        })
89    }
90
91    pub fn repairable_missing(&self) -> impl Iterator<Item = u8> + '_ {
92        self.missing().filter(|index| {
93            let bit = 1u16 << index;
94            self.repair_exhausted & bit == 0
95        })
96    }
97
98    fn fragment_len(&self, index: u8) -> usize {
99        self.frag_len[index as usize] as usize
100    }
101}
102
103/// Outcome of storing one received fragment.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum InsertOutcome {
106    /// Stored; the slot may now be complete.
107    Stored,
108    /// The fragment was already present with identical bytes.
109    Duplicate,
110    /// The fragment was already present with different bytes; the original
111    /// authenticated bytes were kept.
112    Conflict,
113    /// No page capacity; the caller should evict and retry or drop.
114    NoSpace,
115    /// The fragment body exceeds the wire maximum and was not stored.
116    TooLarge,
117}
118
119/// Fixed-capacity reassembly pool.
120pub struct ReassemblyPool<const SLOTS: usize, const PAGES: usize> {
121    pages: [[u8; PAGE_SIZE]; PAGES],
122    next_page: [u8; PAGES],
123    free_head: u8,
124    pub slots: [Option<Slot>; SLOTS],
125}
126
127impl<const SLOTS: usize, const PAGES: usize> ReassemblyPool<SLOTS, PAGES> {
128    pub fn new() -> Self {
129        assert!(PAGES < PAGE_NONE as usize, "page index must fit in u8");
130        let mut next_page = [PAGE_NONE; PAGES];
131        for (index, next) in next_page
132            .iter_mut()
133            .enumerate()
134            .take(PAGES.saturating_sub(1))
135        {
136            *next = (index + 1) as u8;
137        }
138        Self {
139            pages: [[0; PAGE_SIZE]; PAGES],
140            next_page,
141            free_head: if PAGES == 0 { PAGE_NONE } else { 0 },
142            slots: [const { None }; SLOTS],
143        }
144    }
145
146    fn free_pages(&self) -> usize {
147        let mut count = 0;
148        let mut cursor = self.free_head;
149        while cursor != PAGE_NONE {
150            count += 1;
151            cursor = self.next_page[cursor as usize];
152        }
153        count
154    }
155
156    fn alloc_chain(&mut self, len: usize) -> Option<u8> {
157        let needed = len.div_ceil(PAGE_SIZE).max(1);
158        if self.free_pages() < needed {
159            return None;
160        }
161        let head = self.free_head;
162        let mut cursor = head;
163        for _ in 1..needed {
164            cursor = self.next_page[cursor as usize];
165        }
166        self.free_head = self.next_page[cursor as usize];
167        self.next_page[cursor as usize] = PAGE_NONE;
168        Some(head)
169    }
170
171    fn free_chain(&mut self, head: u8) {
172        if head == PAGE_NONE {
173            return;
174        }
175        let mut cursor = head;
176        while self.next_page[cursor as usize] != PAGE_NONE {
177            cursor = self.next_page[cursor as usize];
178        }
179        self.next_page[cursor as usize] = self.free_head;
180        self.free_head = head;
181    }
182
183    fn write_chain(&mut self, head: u8, bytes: &[u8]) {
184        let mut cursor = head;
185        for chunk in bytes.chunks(PAGE_SIZE) {
186            self.pages[cursor as usize][..chunk.len()].copy_from_slice(chunk);
187            cursor = self.next_page[cursor as usize];
188        }
189    }
190
191    fn read_chain(&self, head: u8, len: usize, out: &mut [u8]) {
192        let mut cursor = head;
193        let mut offset = 0;
194        while offset < len {
195            let take = (len - offset).min(PAGE_SIZE);
196            out[offset..offset + take].copy_from_slice(&self.pages[cursor as usize][..take]);
197            offset += take;
198            cursor = self.next_page[cursor as usize];
199        }
200    }
201
202    /// Copy a stored fragment into `out` (which must hold
203    /// [`FRAGMENT_BODY_MAX`] bytes), returning its length.
204    pub fn read_fragment(&self, slot_index: usize, fragment: u8, out: &mut [u8]) -> usize {
205        let slot = self.slots[slot_index].as_ref().expect("occupied slot");
206        let len = slot.fragment_len(fragment);
207        self.read_chain(slot.frag_head[fragment as usize], len, out);
208        len
209    }
210
211    pub fn find_slot(&self, stream: &StreamKey, epoch: u16, message_id: u8) -> Option<usize> {
212        self.slots.iter().position(|slot| {
213            slot.as_ref().is_some_and(|slot| {
214                slot.stream == *stream && slot.epoch == epoch && slot.message_id == message_id
215            })
216        })
217    }
218
219    pub fn open_slot(&mut self, slot: Slot) -> Option<usize> {
220        let index = self.slots.iter().position(Option::is_none)?;
221        self.slots[index] = Some(slot);
222        Some(index)
223    }
224
225    /// Index of the oldest incomplete slot, for eviction under pressure.
226    pub fn oldest_slot(&self) -> Option<usize> {
227        self.slots
228            .iter()
229            .enumerate()
230            .filter_map(|(index, slot)| slot.as_ref().map(|slot| (index, slot.created_ms)))
231            .min_by_key(|(_, created)| *created)
232            .map(|(index, _)| index)
233    }
234
235    /// Release a slot and all its pages, returning it.
236    pub fn close_slot(&mut self, index: usize) -> Option<Slot> {
237        let slot = self.slots[index].take()?;
238        for head in slot.frag_head {
239            self.free_chain(head);
240        }
241        Some(slot)
242    }
243
244    /// Drop every slot belonging to `stream` (after a sequence reset),
245    /// returning how many were dropped.
246    pub fn drop_stream(&mut self, stream: &StreamKey) -> usize {
247        let mut dropped = 0;
248        for index in 0..SLOTS {
249            if self.slots[index]
250                .as_ref()
251                .is_some_and(|slot| slot.stream == *stream)
252            {
253                self.close_slot(index);
254                dropped += 1;
255            }
256        }
257        dropped
258    }
259
260    /// Store one fragment's bytes into a slot.
261    pub fn insert_fragment(&mut self, index: usize, fragment: u8, bytes: &[u8]) -> InsertOutcome {
262        // The wire maximum is enforced at validation; this guard keeps the
263        // pool's u8 lengths and fixed read buffers sound regardless.
264        if bytes.len() > FRAGMENT_BODY_MAX {
265            return InsertOutcome::TooLarge;
266        }
267        let slot = self.slots[index].as_ref().expect("occupied slot");
268        let bit = 1u16 << fragment;
269        if slot.present & bit != 0 {
270            let mut existing = [0u8; FRAGMENT_BODY_MAX];
271            let len = self.read_fragment(index, fragment, &mut existing);
272            return if &existing[..len] == bytes {
273                InsertOutcome::Duplicate
274            } else {
275                InsertOutcome::Conflict
276            };
277        }
278        let Some(head) = self.alloc_chain(bytes.len()) else {
279            return InsertOutcome::NoSpace;
280        };
281        self.write_chain(head, bytes);
282        let slot = self.slots[index].as_mut().expect("occupied slot");
283        slot.frag_head[fragment as usize] = head;
284        slot.frag_len[fragment as usize] = bytes.len() as u8;
285        slot.present |= bit;
286        slot.unavailable &= !bit;
287        InsertOutcome::Stored
288    }
289}
290
291impl<const SLOTS: usize, const PAGES: usize> Default for ReassemblyPool<SLOTS, PAGES> {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297pub fn empty_slot(
298    stream: StreamKey,
299    epoch: u16,
300    message_id: u8,
301    count: u8,
302    handle: MessageHandle,
303    now_ms: u64,
304) -> Slot {
305    Slot {
306        stream,
307        epoch,
308        message_id,
309        count,
310        present: 0,
311        unavailable: 0,
312        repair_exhausted: 0,
313        frag_len: [0; FRAGMENT_COUNT_MAX as usize],
314        frag_head: [PAGE_NONE; FRAGMENT_COUNT_MAX as usize],
315        meta: FirstMeta::default(),
316        have_meta: false,
317        announced: false,
318        late: false,
319        notified: false,
320        handle,
321        created_ms: now_ms,
322        deadline_ms: now_ms,
323        repair_at_ms: now_ms,
324        last_fragment_ms: now_ms,
325    }
326}
327
328/// Incremental UTF-8 writer that carries partial code points across fragment
329/// boundaries and replaces invalid bytes with U+FFFD.
330struct LossyWriter<'a> {
331    out: &'a mut [u8],
332    pos: usize,
333    carry: [u8; 4],
334    carry_len: usize,
335    truncated: bool,
336    had_invalid: bool,
337}
338
339impl<'a> LossyWriter<'a> {
340    fn new(out: &'a mut [u8]) -> Self {
341        Self {
342            out,
343            pos: 0,
344            carry: [0; 4],
345            carry_len: 0,
346            truncated: false,
347            had_invalid: false,
348        }
349    }
350
351    fn emit(&mut self, bytes: &[u8]) {
352        let space = self.out.len() - self.pos;
353        if bytes.len() > space {
354            // Truncate at a code-point boundary.
355            let mut take = space;
356            while take > 0 && bytes[take] & 0xC0 == 0x80 {
357                take -= 1;
358            }
359            self.out[self.pos..self.pos + take].copy_from_slice(&bytes[..take]);
360            self.pos += take;
361            self.truncated = true;
362            return;
363        }
364        self.out[self.pos..self.pos + bytes.len()].copy_from_slice(bytes);
365        self.pos += bytes.len();
366    }
367
368    fn emit_replacement(&mut self) {
369        self.had_invalid = true;
370        self.emit("\u{FFFD}".as_bytes());
371    }
372
373    /// Push raw bytes, validating incrementally.
374    fn push(&mut self, mut bytes: &[u8]) {
375        // Complete a carried partial code point first.
376        while self.carry_len > 0 && !bytes.is_empty() {
377            let needed = utf8_len(self.carry[0]).unwrap_or(1);
378            let take = (needed - self.carry_len).min(bytes.len());
379            self.carry[self.carry_len..self.carry_len + take].copy_from_slice(&bytes[..take]);
380            self.carry_len += take;
381            bytes = &bytes[take..];
382            if self.carry_len == needed {
383                let carried = self.carry;
384                let carry_len = self.carry_len;
385                self.carry_len = 0;
386                match core::str::from_utf8(&carried[..carry_len]) {
387                    Ok(_) => self.emit(&carried[..carry_len]),
388                    Err(_) => {
389                        // The lead byte's sequence is invalid; resynchronize
390                        // after the lead byte.
391                        self.emit_replacement();
392                        let rest = carry_len - 1;
393                        let resume: [u8; 4] = carried;
394                        // Reprocess the bytes after the bad lead byte.
395                        self.push_inner(&resume[1..1 + rest]);
396                    }
397                }
398            }
399        }
400        self.push_inner(bytes);
401    }
402
403    fn push_inner(&mut self, mut bytes: &[u8]) {
404        loop {
405            match core::str::from_utf8(bytes) {
406                Ok(text) => {
407                    self.emit(text.as_bytes());
408                    return;
409                }
410                Err(error) => {
411                    let valid = error.valid_up_to();
412                    self.emit(&bytes[..valid]);
413                    match error.error_len() {
414                        Some(bad) => {
415                            self.emit_replacement();
416                            bytes = &bytes[valid + bad..];
417                        }
418                        None => {
419                            // Incomplete trailing sequence: carry it.
420                            let tail = &bytes[valid..];
421                            self.carry[..tail.len()].copy_from_slice(tail);
422                            self.carry_len = tail.len();
423                            return;
424                        }
425                    }
426                }
427            }
428        }
429    }
430
431    /// End a run of contiguous fragments. When the run borders a gap, an
432    /// incomplete trailing code point is discarded (the sentinel covers it);
433    /// at true end of message it is invalid input and becomes U+FFFD.
434    fn end_run(&mut self, at_gap: bool) {
435        if self.carry_len > 0 {
436            if !at_gap {
437                self.emit_replacement();
438            }
439            self.carry_len = 0;
440        }
441    }
442}
443
444fn utf8_len(lead: u8) -> Option<usize> {
445    match lead {
446        0x00..=0x7F => Some(1),
447        0xC0..=0xDF => Some(2),
448        0xE0..=0xEF => Some(3),
449        0xF0..=0xF4 => Some(4),
450        _ => None,
451    }
452}
453
454/// Sentinels inserted for absent portions of a partial message.
455#[derive(Clone, Copy, Debug)]
456pub struct RenderSentinels {
457    pub pending: &'static str,
458    pub missing: &'static str,
459    pub unavailable: &'static str,
460}
461
462impl Default for RenderSentinels {
463    fn default() -> Self {
464        Self {
465            pending: "[PENDING]",
466            missing: "[MISSING]",
467            unavailable: "[UNAVAILABLE]",
468        }
469    }
470}
471
472/// Result of rendering a slot.
473#[derive(Clone, Copy, Debug, PartialEq, Eq)]
474pub struct RenderResult {
475    pub len: usize,
476    pub complete: bool,
477    pub had_invalid: bool,
478    pub truncated: bool,
479}
480
481/// Render a slot's fragments into `out` as UTF-8, inserting one sentinel per
482/// absent run and skipping code points damaged by missing byte boundaries.
483pub fn render_slot<const SLOTS: usize, const PAGES: usize>(
484    pool: &ReassemblyPool<SLOTS, PAGES>,
485    slot_index: usize,
486    sentinels: &RenderSentinels,
487    final_render: bool,
488    out: &mut [u8],
489) -> RenderResult {
490    let slot = pool.slots[slot_index].as_ref().expect("occupied slot");
491    let mut writer = LossyWriter::new(out);
492    let mut index = 0u8;
493    let mut after_gap = false;
494    while index < slot.count {
495        let bit = 1u16 << index;
496        if slot.present & bit != 0 {
497            let mut buffer = [0u8; FRAGMENT_BODY_MAX];
498            let len = pool.read_fragment(slot_index, index, &mut buffer);
499            let mut bytes = &buffer[..len];
500            if after_gap {
501                // Skip continuation bytes orphaned by the missing boundary.
502                while let Some((first, rest)) = bytes.split_first() {
503                    if first & 0xC0 == 0x80 {
504                        bytes = rest;
505                    } else {
506                        break;
507                    }
508                }
509                after_gap = false;
510            }
511            writer.push(bytes);
512            index += 1;
513            continue;
514        }
515        // A gap: absent fragments sharing one repair state render as one
516        // sentinel, and the run splits where that state changes, so a
517        // disclaimed portion reads [UNAVAILABLE] immediately even when it
518        // borders a still-repairable one. Sentinel count is bounded by the
519        // fragment count.
520        writer.end_run(true);
521        while index < slot.count && slot.present & (1u16 << index) == 0 {
522            let run_unavailable = slot.unavailable & (1u16 << index) != 0;
523            while index < slot.count
524                && slot.present & (1u16 << index) == 0
525                && (slot.unavailable & (1u16 << index) != 0) == run_unavailable
526            {
527                index += 1;
528            }
529            let sentinel = if run_unavailable {
530                sentinels.unavailable
531            } else if final_render {
532                sentinels.missing
533            } else {
534                sentinels.pending
535            };
536            writer.emit(sentinel.as_bytes());
537        }
538        after_gap = true;
539    }
540    writer.end_run(false);
541    RenderResult {
542        len: writer.pos,
543        complete: slot.is_complete(),
544        had_invalid: writer.had_invalid,
545        truncated: writer.truncated,
546    }
547}
548
549/// The body exceeds the wire maximum of 10 fragments × 160 bytes.
550#[derive(Clone, Copy, Debug, PartialEq, Eq)]
551pub struct BodyTooLarge;
552
553/// Plan for splitting an outbound body into fragments.
554#[derive(Clone, Copy, Debug, PartialEq, Eq)]
555pub struct FragmentPlan {
556    pub count: u8,
557    body_len: usize,
558}
559
560impl FragmentPlan {
561    /// Plan fragmentation for a body. Returns `None` when the body fits in a
562    /// single frame (given its encoded option overhead) and `Some` plan
563    /// otherwise. Errors when the body exceeds the wire maximum.
564    pub fn plan(body_len: usize, single_frame_budget: usize) -> Result<Option<Self>, BodyTooLarge> {
565        if body_len <= single_frame_budget && body_len <= FRAGMENT_BODY_MAX {
566            return Ok(None);
567        }
568        let count = body_len.div_ceil(FRAGMENT_BODY_MAX);
569        if count > FRAGMENT_COUNT_MAX as usize || count < 2 {
570            if count < 2 {
571                // Options alone exceed the frame; still send as two fragments.
572                return Ok(Some(Self { count: 2, body_len }));
573            }
574            return Err(BodyTooLarge);
575        }
576        Ok(Some(Self {
577            count: count as u8,
578            body_len,
579        }))
580    }
581
582    /// Byte range of fragment `index` within the body.
583    pub fn range(&self, index: u8) -> core::ops::Range<usize> {
584        let per = self
585            .body_len
586            .div_ceil(self.count as usize)
587            .min(FRAGMENT_BODY_MAX);
588        let start = per * index as usize;
589        let end = (start + per).min(self.body_len);
590        start..end
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::model::{ConversationKey, SenderScope};
598    use umsh_core::ChannelTag;
599
600    fn stream() -> StreamKey {
601        StreamKey {
602            conversation: ConversationKey::ChannelGroup {
603                channel: ChannelTag([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
604            },
605            sender: SenderScope::ClaimedMember(umsh_core::NodeHint([9, 9, 9])),
606        }
607    }
608
609    fn pool_with_slot(count: u8) -> (ReassemblyPool<4, 24>, usize) {
610        let mut pool = ReassemblyPool::<4, 24>::new();
611        let slot = empty_slot(stream(), 0, 7, count, MessageHandle(1), 0);
612        let index = pool.open_slot(slot).unwrap();
613        (pool, index)
614    }
615
616    #[test]
617    fn duplicate_and_conflict_detection() {
618        let (mut pool, index) = pool_with_slot(3);
619        assert_eq!(
620            pool.insert_fragment(index, 0, b"hello"),
621            InsertOutcome::Stored
622        );
623        assert_eq!(
624            pool.insert_fragment(index, 0, b"hello"),
625            InsertOutcome::Duplicate
626        );
627        assert_eq!(
628            pool.insert_fragment(index, 0, b"jello"),
629            InsertOutcome::Conflict
630        );
631    }
632
633    #[test]
634    fn partial_render_inserts_sentinel_and_skips_split_code_point() {
635        let (mut pool, index) = pool_with_slot(3);
636        // "héllo" split so the é (0xC3 0xA9) straddles the gap boundary.
637        pool.insert_fragment(index, 0, b"ab h\xC3").unwrap_stored();
638        pool.insert_fragment(index, 2, b"\xA9nd tail")
639            .unwrap_stored();
640        let mut out = [0u8; 128];
641        let result = render_slot(&pool, index, &RenderSentinels::default(), false, &mut out);
642        let text = core::str::from_utf8(&out[..result.len]).unwrap();
643        assert_eq!(text, "ab h[PENDING]nd tail");
644        assert!(!result.complete);
645    }
646
647    #[test]
648    fn complete_render_heals_boundary_code_points() {
649        let (mut pool, index) = pool_with_slot(2);
650        pool.insert_fragment(index, 0, b"h\xC3").unwrap_stored();
651        pool.insert_fragment(index, 1, b"\xA9!").unwrap_stored();
652        let mut out = [0u8; 64];
653        let result = render_slot(&pool, index, &RenderSentinels::default(), true, &mut out);
654        assert!(result.complete);
655        assert_eq!(core::str::from_utf8(&out[..result.len]).unwrap(), "hé!");
656    }
657
658    #[test]
659    fn oversized_fragment_is_rejected_not_truncated() {
660        let (mut pool, index) = pool_with_slot(2);
661        let big = [b'x'; FRAGMENT_BODY_MAX + 40];
662        assert_eq!(
663            pool.insert_fragment(index, 0, &big),
664            InsertOutcome::TooLarge
665        );
666        // Nothing was stored: no pages consumed, no presence bit set.
667        assert_eq!(pool.free_pages(), 24);
668        assert!(pool.slots[index].as_ref().unwrap().present == 0);
669    }
670
671    #[test]
672    fn split_absent_run_renders_one_sentinel_per_repair_state() {
673        let (mut pool, index) = pool_with_slot(5);
674        pool.insert_fragment(index, 0, b"a").unwrap_stored();
675        pool.insert_fragment(index, 4, b"z").unwrap_stored();
676        // Fragments 1..=3 are absent; 2 is disclaimed, its neighbors pending.
677        pool.slots[index].as_mut().unwrap().unavailable = 1 << 2;
678        let mut out = [0u8; 128];
679        let result = render_slot(&pool, index, &RenderSentinels::default(), false, &mut out);
680        let text = core::str::from_utf8(&out[..result.len]).unwrap();
681        assert_eq!(text, "a[PENDING][UNAVAILABLE][PENDING]z");
682
683        // At final render, pending sub-runs become missing.
684        let result = render_slot(&pool, index, &RenderSentinels::default(), true, &mut out);
685        let text = core::str::from_utf8(&out[..result.len]).unwrap();
686        assert_eq!(text, "a[MISSING][UNAVAILABLE][MISSING]z");
687    }
688
689    #[test]
690    fn pages_recycle_after_close() {
691        let (mut pool, index) = pool_with_slot(2);
692        let big = [b'x'; 160];
693        pool.insert_fragment(index, 0, &big).unwrap_stored();
694        pool.insert_fragment(index, 1, &big).unwrap_stored();
695        let free_before = pool.free_pages();
696        pool.close_slot(index);
697        assert!(pool.free_pages() > free_before);
698        assert_eq!(pool.free_pages(), 24);
699    }
700
701    trait UnwrapStored {
702        fn unwrap_stored(self);
703    }
704    impl UnwrapStored for InsertOutcome {
705        fn unwrap_stored(self) {
706            assert_eq!(self, InsertOutcome::Stored);
707        }
708    }
709
710    #[test]
711    fn fragment_plan_ranges_cover_body() {
712        let plan = FragmentPlan::plan(400, 200).unwrap().unwrap();
713        assert_eq!(plan.count, 3);
714        let mut covered = 0;
715        for index in 0..plan.count {
716            let range = plan.range(index);
717            assert_eq!(range.start, covered);
718            covered = range.end;
719            assert!(range.len() <= FRAGMENT_BODY_MAX);
720        }
721        assert_eq!(covered, 400);
722    }
723}