composefs_splitdirfdstream/transport.rs
1//! Source-agnostic FD-transport mechanics for the splitdirfdstream wire protocol.
2//!
3//! This module contains the pure, Storage/Layer-independent helpers that govern
4//! *how* a set of file descriptors is partitioned into transport frames and how
5//! the sparse dirfd-slot layout is computed. Both the `composefs-storage`
6//! layer-transfer producer and future producers (e.g. a composefs-repo producer)
7//! can share these primitives without pulling in any higher-level dependencies.
8//!
9//! # Public API
10//!
11//! | Item | Role |
12//! |------|------|
13//! | [`MAX_FDS_PER_FRAME`] | Enforced per-frame fd cap (240, safely below 253). |
14//! | [`seed_from_id`] | Derive a deterministic `u64` seed from an opaque string id. |
15//! | [`open_devnull`] | Open `/dev/null` for use as a dummy fd. |
16//! | [`FdLimitError`] | Error from [`split_fds_into_frames`] when `more=false` overflows. |
17//! | [`LayerFdLayout`] | Complete assembled wire layout from [`build_layer_fd_layout`]. |
18//! | [`build_layer_fd_layout`] | Assemble the full sparse dirfd+keepalive+lifetime fd array. |
19//! | [`split_fds_into_frames`] | Partition fds into frames, enforcing `more=false` cap. |
20//! | [`spawn_self_reaping_producer`] | Spawn the 3-phase keepalive-lock producer task (requires `tokio` feature). |
21//!
22//! The producer-side seed jitter (sparse-slot placement, frame count, extra
23//! lifetime fds) is computed by crate-internal helpers (`sparse_dir_slots`,
24//! `compute_n_frames`, `n_extra_lifetime_fds`); the consumer never recomputes
25//! it, since every `FileBackedData` chunk carries an explicit `dirfd_index`.
26
27use std::os::fd::OwnedFd;
28
29use rand::seq::SliceRandom as _;
30use rand_pcg::Pcg64;
31use rand_pcg::rand_core::SeedableRng as _;
32
33// ── Constants ────────────────────────────────────────────────────────────────
34
35/// Our enforced per-frame fd cap.
36///
37/// Kept safely below the hard kernel limit of 253 fds per `sendmsg(SCM_RIGHTS)`
38/// call. Every transport frame carries at most this many fds.
39pub const MAX_FDS_PER_FRAME: usize = 240;
40
41// ── Frame splitting ───────────────────────────────────────────────────────────
42
43/// Partition `fds` into `n_frames` contiguous batches as evenly as possible.
44///
45/// The first `fds.len() % n_frames` batches receive one extra fd (so all
46/// batches are non-empty when `n_frames <= fds.len()`). Ordering is preserved:
47/// concatenating the returned batches in order reconstructs the original vec.
48///
49/// # Panics
50/// Panics if `n_frames == 0` or `n_frames > fds.len()`.
51pub(crate) fn split_into_frames(fds: Vec<OwnedFd>, n_frames: usize) -> Vec<Vec<OwnedFd>> {
52 assert!(n_frames > 0 && n_frames <= fds.len());
53 let fd_count = fds.len();
54 let base = fd_count / n_frames;
55 let remainder = fd_count % n_frames;
56 let mut result = Vec::with_capacity(n_frames);
57 let mut it = fds.into_iter();
58 for i in 0..n_frames {
59 let batch_size = base + if i < remainder { 1 } else { 0 };
60 result.push(it.by_ref().take(batch_size).collect());
61 }
62 result
63}
64
65// ── Seed derivation ───────────────────────────────────────────────────────────
66
67/// Derive a deterministic `u64` seed from an opaque string identifier.
68///
69/// Uses the first 8 bytes of SHA-256(id) interpreted as little-endian u64.
70/// The result is stable across runs for the same id string.
71pub fn seed_from_id(id: &str) -> u64 {
72 let hash = openssl::hash::hash(openssl::hash::MessageDigest::sha256(), id.as_bytes())
73 .expect("SHA-256 hashing should not fail");
74 u64::from_le_bytes(hash[..8].try_into().expect("sha256 is at least 8 bytes"))
75}
76
77// ── Frame-count helpers ───────────────────────────────────────────────────────
78
79/// Compute the *hash-derived* minimum frame count for `fd_count` FDs keyed by `seed`.
80///
81/// - If `fd_count < 3`: returns `max(fd_count, 1)` (never more frames than FDs,
82/// but always at least one so `split_into_frames` has a valid divisor). This
83/// branch is effectively dead: a real transport array is always ≥4 FDs
84/// (pipe + ≥2 dirfd slots + keepalive).
85/// - Otherwise: `max(3, seed % (fd_count + 1))` clamped to `fd_count`.
86///
87/// This guarantees ≥3 frames whenever there are ≥3 FDs, which exercises the
88/// multi-frame path in tests while keeping the arithmetic deterministic.
89///
90/// **This is the hash-min component only.** The call site additionally enforces
91/// a per-frame cap ([`MAX_FDS_PER_FRAME`]) by taking `hash_min.max(cap_min)` where
92/// `cap_min = ceil(fd_count / MAX_FDS_PER_FRAME)`. For small layers
93/// (≤`MAX_FDS_PER_FRAME` fds) `cap_min = 1` so the hash-min always wins,
94/// preserving test-hardening behaviour.
95pub(crate) fn n_frames_for(seed: u64, fd_count: usize) -> usize {
96 if fd_count < 3 {
97 return fd_count.max(1);
98 }
99 let raw = (seed % (fd_count as u64 + 1)) as usize;
100 raw.max(3).min(fd_count)
101}
102
103/// Compute the actual number of transport frames for a `more=true` call.
104///
105/// Returns the larger of:
106/// - the hash-derived minimum ([`n_frames_for`]`(seed, fd_count)`, ≥3 for real
107/// layers), which exercises multi-frame paths in tests; and
108/// - `ceil(fd_count / MAX_FDS_PER_FRAME)`, the minimum needed so every frame
109/// carries at most [`MAX_FDS_PER_FRAME`] fds (kernel SCM_RIGHTS cap).
110///
111/// The result is additionally clamped to `[1, fd_count]` so `split_into_frames`
112/// never receives an out-of-range value.
113///
114/// Proved invariant: `fd_count.div_ceil(n) <= MAX_FDS_PER_FRAME` for the
115/// returned `n`, because `n >= ceil(fd_count / MAX_FDS_PER_FRAME)`.
116pub(crate) fn compute_n_frames(seed: u64, fd_count: usize) -> usize {
117 let hash_min = n_frames_for(seed, fd_count);
118 let cap_min = fd_count.div_ceil(MAX_FDS_PER_FRAME);
119 hash_min.max(cap_min).min(fd_count).max(1)
120}
121
122// ── Dummy-fd helpers ──────────────────────────────────────────────────────────
123
124/// Open `/dev/null` as an opaque dummy fd.
125///
126/// Returns a plain [`std::io::Result`] so callers can map it to their own error type.
127pub fn open_devnull() -> std::io::Result<OwnedFd> {
128 rustix::fs::open(
129 c"/dev/null",
130 rustix::fs::OFlags::RDONLY,
131 rustix::fs::Mode::empty(),
132 )
133 .map_err(std::io::Error::from)
134}
135
136/// Open an anonymous `memfd` as an opaque dummy fd.
137///
138/// Returns a plain [`std::io::Result`] so callers can map it to their own error type.
139pub(crate) fn open_memfd() -> std::io::Result<OwnedFd> {
140 rustix::fs::memfd_create(
141 c"composefs-layer-transfer-dummy",
142 rustix::fs::MemfdFlags::CLOEXEC,
143 )
144 .map_err(std::io::Error::from)
145}
146
147// ── Sparse-slot layout ────────────────────────────────────────────────────────
148
149/// Describes the sparse dirfd-slot layout produced by [`sparse_dir_slots`].
150///
151/// The `dirfds` array passed to the consumer has length `total_slots` and contains
152/// real diff-dir fds at `real_slot_indices[i]` (for chain layer `i`) and dummy fds
153/// at all other positions.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub(crate) struct SparseLayout {
156 /// Total number of slots in the dirfds array (real + dummy).
157 pub(crate) total_slots: usize,
158 /// Ascending slot indices assigned to real layers.
159 ///
160 /// `real_slot_indices[i]` is the slot index for chain layer `i`.
161 pub(crate) real_slot_indices: Vec<u32>,
162 /// Slot indices that are dummy (complement of `real_slot_indices` in `0..total_slots`).
163 pub(crate) dummy_slot_indices: Vec<u32>,
164}
165
166/// Compute the sparse slot assignment for `n_real` real layers using `seed`.
167///
168/// This is a pure function (no I/O) that encodes the deterministic slot-placement
169/// algorithm:
170///
171/// 1. `n_dummy = (seed >> 8) % (n_real + 1) + 1` (at least 1 dummy).
172/// 2. `total_slots = n_real + n_dummy`.
173/// 3. Deterministically shuffle `0..total_slots` with a `seed`-seeded PCG RNG,
174/// take the first `n_real` as real-layer slot indices, sort them ascending.
175/// 4. Dummy slots are `0..total_slots` minus the real indices.
176///
177/// The shuffle uses [`Pcg64`], whose algorithm is stable across `rand_pcg`
178/// releases, so the placement is reproducible from the seed (useful for tests).
179/// Reproducibility is *not* a correctness requirement: the consumer is driven
180/// entirely by the explicit `dirfd_index` in each `FileBackedData` chunk and
181/// never recomputes this layout.
182///
183/// The returned [`SparseLayout`] records `total_slots`, `real_slot_indices`
184/// (ascending, length `n_real`), and `dummy_slot_indices` (ascending).
185pub(crate) fn sparse_dir_slots(n_real: usize, seed: u64) -> SparseLayout {
186 // ── 1/2: total slot count (at least 1 dummy) ─────────────────────────────
187 let n_dummy: usize = ((seed >> 8) % (n_real as u64 + 1) + 1) as usize;
188 let total_slots = n_real + n_dummy;
189
190 // ── 3: select real-layer slot positions ──────────────────────────────────
191 // Deterministically shuffle all slot indices from the seed, take the first
192 // n_real as the real-layer positions, then sort them ascending.
193 let mut rng = Pcg64::seed_from_u64(seed);
194 let mut shuffled_slots: Vec<u32> = (0..total_slots as u32).collect();
195 shuffled_slots.shuffle(&mut rng);
196 let mut real_indices: Vec<u32> = shuffled_slots[..n_real].to_vec();
197 real_indices.sort_unstable();
198
199 // ── 4: compute dummy indices (complement) ─────────────────────────────────
200 let real_set: std::collections::HashSet<u32> = real_indices.iter().copied().collect();
201 let dummy_indices: Vec<u32> = (0..total_slots as u32)
202 .filter(|i| !real_set.contains(i))
203 .collect();
204
205 SparseLayout {
206 total_slots,
207 real_slot_indices: real_indices,
208 dummy_slot_indices: dummy_indices,
209 }
210}
211
212// ── Lifetime dummy count ──────────────────────────────────────────────────────
213
214/// Compute the number of extra opaque lifetime dummy fds for a given seed.
215///
216/// Returns 0, 1, or 2 — derived as `(seed >> 16) % 3`. These are additional
217/// dummy fds (beyond the keepalive pipe write-end) that the client must hold open
218/// until it has finished reading the splitdirfdstream, then close. Alternating
219/// `/dev/null` and `memfd` fd kinds exercise transparent client pass-through.
220pub(crate) fn n_extra_lifetime_fds(seed: u64) -> usize {
221 ((seed >> 16) % 3) as usize
222}
223
224// ── High-level fd-layout helpers ─────────────────────────────────────────────
225
226/// Error returned by [`split_fds_into_frames`] when `more=false` and the fd
227/// count exceeds [`MAX_FDS_PER_FRAME`].
228///
229/// The caller must map this to their interface error and instruct the client to
230/// retry with `more=true`.
231#[derive(Debug, thiserror::Error)]
232#[error("fd count {fd_count} exceeds per-frame limit {max_per_frame}; retry with more=true")]
233pub struct FdLimitError {
234 /// Total number of fds that would be sent.
235 pub fd_count: usize,
236 /// The per-frame cap that was exceeded.
237 pub max_per_frame: usize,
238}
239
240/// Assembled FD layout ready to wire to the client in one or more transport frames.
241///
242/// This is the output of [`build_layer_fd_layout`]: a complete description of
243/// every fd that will be sent across the socket, split into two groups:
244///
245/// * The **wire region** — everything that travels to the client:
246/// - `fds_all[0]` — pipe read end (carries the `splitdirfdstream` bytes).
247/// - `fds_all[1..=dir_count]` — dirfds region (sparse: real dirs + dummies).
248/// - `fds_all[dir_count+1..]` — lifetime region (keepalive write + extras).
249/// * The **keepalive read end** — retained on the server; when the client drops
250/// the keepalive write end the server sees EOF and may release resources.
251///
252/// The `real_indices` slice maps chain layer `i` → slot index within the
253/// *dirfds region* where that layer's real diff-dir fd sits. Pass this to the
254/// producer (via [`build_layer_fd_layout`]'s return value) so it can write the
255/// correct `dirfd_index` into each `FileBackedData` chunk.
256#[derive(Debug)]
257pub struct LayerFdLayout {
258 /// All fds to send to the client, ordered as described above.
259 pub fds_all: Vec<OwnedFd>,
260 /// Number of slots in the dirfds region (`dir_count` in reply and in the
261 /// wire layout: `fds_all[1..=dir_count]`).
262 pub dir_count: u32,
263 /// Slot index within the dirfds region for each real layer (ascending).
264 ///
265 /// `real_indices[i]` is the `dirfd_index` the producer must use for chain
266 /// layer `i`.
267 pub real_indices: Vec<u32>,
268 /// Server-side read end of the keepalive pipe.
269 ///
270 /// Hold this `OwnedFd` until the client has finished consuming the layer
271 /// (or until the request is complete if keepalive is not needed for resource
272 /// management). When it drops, the client's matching write end sees EOF.
273 pub keepalive_read: OwnedFd,
274}
275
276/// Build the complete wire fd layout for a producer serving `n_real` real diff
277/// directories.
278///
279/// This pure-ish (only I/O: `pipe`, `/dev/null`, `memfd`) function:
280///
281/// 1. Calls [`sparse_dir_slots`] to compute the sparse placement.
282/// 2. Opens dummy fds (`/dev/null` alternating with `memfd`) for gap slots.
283/// 3. Builds the dirfds region by interleaving real and dummy fds in slot order.
284/// 4. Creates a keepalive pipe; the write end goes into `fds_all`, the read end
285/// is returned in [`LayerFdLayout::keepalive_read`].
286/// 5. Adds `n_extra_lifetime_fds(seed)` extra dummy lifetime fds.
287/// 6. Prepends the `pipe_read` fd so `fds_all[0]` is always the data pipe.
288///
289/// The caller is responsible for:
290/// * Passing the correct `real_fds` (in chain order) — the first real_fd gets
291/// slot `real_indices[0]`, the second gets `real_indices[1]`, etc.
292/// * Spawning the producer with `write_fd` (not returned here; the caller opens
293/// the pipe and passes `write_fd` to the producer separately).
294///
295/// # Arguments
296/// * `pipe_read` — Read end of the data pipe (will become `fds_all[0]`).
297/// * `real_fds` — Pre-opened real diff-directory file descriptors, one per
298/// chain layer, in chain order.
299/// * `seed` — Deterministic seed (see [`seed_from_id`]).
300///
301/// # Errors
302/// Returns `io::Error` if any dummy-fd or keepalive-pipe creation fails.
303pub fn build_layer_fd_layout(
304 pipe_read: OwnedFd,
305 real_fds: Vec<OwnedFd>,
306 seed: u64,
307) -> std::io::Result<LayerFdLayout> {
308 let n_real = real_fds.len();
309 let layout = sparse_dir_slots(n_real, seed);
310 let total_slots = layout.total_slots;
311
312 // ── Build dirfds region: slot i = real fd or dummy fd ────────────────────
313 let mut dirfd_region: Vec<Option<OwnedFd>> = (0..total_slots).map(|_| None).collect();
314
315 // Place real fds at their assigned slots.
316 for (chain_idx, real_fd) in real_fds.into_iter().enumerate() {
317 let slot = layout.real_slot_indices[chain_idx] as usize;
318 dirfd_region[slot] = Some(real_fd);
319 }
320
321 // Fill dummy slots, alternating /dev/null and memfd.
322 for (dummy_ordinal, &dummy_slot) in layout.dummy_slot_indices.iter().enumerate() {
323 let dummy_fd = if dummy_ordinal % 2 == 0 {
324 open_devnull()?
325 } else {
326 open_memfd()?
327 };
328 dirfd_region[dummy_slot as usize] = Some(dummy_fd);
329 }
330
331 // Unwrap: every slot was filled above.
332 let dirfd_region: Vec<OwnedFd> = dirfd_region.into_iter().map(|o| o.unwrap()).collect();
333
334 // ── Keepalive pipe ────────────────────────────────────────────────────────
335 let (keepalive_read, keepalive_write) =
336 rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).map_err(std::io::Error::from)?;
337
338 // ── Extra lifetime dummy fds ──────────────────────────────────────────────
339 let n_extra = n_extra_lifetime_fds(seed);
340 let mut extra_lifetime: Vec<OwnedFd> = Vec::with_capacity(n_extra);
341 for i in 0..n_extra {
342 let fd = if i % 2 == 0 {
343 open_devnull()?
344 } else {
345 open_memfd()?
346 };
347 extra_lifetime.push(fd);
348 }
349
350 // ── Assemble fds_all ──────────────────────────────────────────────────────
351 // index 0 : pipe read
352 // index 1..=dir_count : dirfds region
353 // index dir_count+1 : keepalive write end
354 // remaining : extra lifetime dummies
355 let dir_count = total_slots as u32;
356 let mut fds_all: Vec<OwnedFd> = Vec::with_capacity(1 + total_slots + 1 + n_extra);
357 fds_all.push(pipe_read);
358 fds_all.extend(dirfd_region);
359 fds_all.push(keepalive_write);
360 fds_all.extend(extra_lifetime);
361
362 Ok(LayerFdLayout {
363 fds_all,
364 dir_count,
365 real_indices: layout.real_slot_indices,
366 keepalive_read,
367 })
368}
369
370/// Partition a complete fd array into transport frames.
371///
372/// When `more` is `true` the fds are split across `compute_n_frames(seed, n)`
373/// batches (≥3 for test hardening, cap-limited to ≤[`MAX_FDS_PER_FRAME`] per
374/// frame).
375///
376/// When `more` is `false` all fds must fit in a single frame. If the total
377/// count exceeds [`MAX_FDS_PER_FRAME`] the fds are returned intact inside an
378/// [`Err(FdLimitError)`] so they can be dropped cleanly by the caller before
379/// returning an error reply.
380///
381/// # Returns
382/// `Ok(Vec<Vec<OwnedFd>>)` — one inner vec per transport frame (1…N frames).
383/// `Err(FdLimitError { fds, .. })` — the original fds are returned so the
384/// caller can drop them; the error contains the count and cap for a diagnostic.
385pub fn split_fds_into_frames(
386 fds: Vec<OwnedFd>,
387 seed: u64,
388 more: bool,
389) -> Result<Vec<Vec<OwnedFd>>, (Vec<OwnedFd>, FdLimitError)> {
390 let fd_count = fds.len();
391 if !more && fd_count > MAX_FDS_PER_FRAME {
392 return Err((
393 fds,
394 FdLimitError {
395 fd_count,
396 max_per_frame: MAX_FDS_PER_FRAME,
397 },
398 ));
399 }
400 let n = if more {
401 compute_n_frames(seed, fd_count)
402 } else {
403 1
404 };
405 Ok(split_into_frames(fds, n))
406}
407
408// ─────────────────────────────────────────────────────────────────────────────
409// Self-reaping producer task (optional: requires `tokio` feature)
410// ─────────────────────────────────────────────────────────────────────────────
411
412/// Spawn a self-reaping blocking producer task that implements the 3-phase
413/// keepalive-lock protocol used by both the repo and cstor `GetLayer`
414/// implementations.
415///
416/// # Phases
417///
418/// 1. **Produce**: call `produce(write_fd_as_File)`. When `produce` returns the
419/// write end of the data pipe drops, signalling data-EOF to the consumer.
420/// 2. **Keepalive wait**: read from `keepalive_read` until EOF. The consumer
421/// drops its keepalive write end after it finishes draining the data pipe,
422/// so this phase acts as a rendezvous before releasing any held resources.
423/// 3. **Release**: drop `guard`. For the repo path `guard` is `()` (no-op);
424/// for the cstor path `guard` is a `LayerStoreLock` whose drop releases the
425/// shared flock.
426///
427/// # Ordering guarantee
428///
429/// Phase 2 begins **after** `write_fd` is dropped (end of Phase 1), so the
430/// data pipe is always at EOF before we start waiting on the keepalive. The
431/// reverse would cause a deadlock: if the consumer must drain to EOF to drop
432/// the keepalive but the producer never finishes, the pipe never EOF.
433///
434/// # Arguments
435///
436/// * `write_fd` — Write end of the data pipe; moved into the closure and
437/// dropped at the end of Phase 1.
438/// * `keepalive_read` — Read end of the keepalive pipe; retained until Phase 2
439/// EOF, then dropped.
440/// * `guard` — Opaque lifetime guard released in Phase 3 (e.g. a lock).
441/// * `produce` — Called with a `std::fs::File` wrapping `write_fd`. Should
442/// write the complete `splitdirfdstream` into the file and return. Errors
443/// are logged as warnings; a short/corrupt stream will be detected later by
444/// the consumer's integrity check.
445#[cfg(feature = "tokio")]
446pub fn spawn_self_reaping_producer(
447 write_fd: OwnedFd,
448 keepalive_read: OwnedFd,
449 guard: impl Send + 'static,
450 produce: impl FnOnce(std::fs::File) + Send + 'static,
451) {
452 tokio::task::spawn_blocking(move || {
453 // Phase 1: produce into write_fd; drop write_fd (data-pipe EOF).
454 {
455 let wf = std::fs::File::from(write_fd);
456 produce(wf);
457 // write_fd drops here → data-pipe EOF visible to consumer.
458 }
459
460 // Phase 2: wait for consumer to signal completion via keepalive EOF.
461 let mut buf = [0u8; 64];
462 loop {
463 match rustix::io::read(&keepalive_read, &mut buf) {
464 Ok(0) => break, // EOF — consumer dropped its write end
465 Ok(_) => {} // unexpected bytes — drain and continue
466 Err(rustix::io::Errno::INTR) => {} // EINTR — retry
467 Err(_) => break, // other error — bail
468 }
469 }
470
471 // Phase 3: release guard (e.g. drop the shared flock).
472 drop(guard);
473 });
474}
475
476// ─────────────────────────────────────────────────────────────────────────────
477// Tests
478// ─────────────────────────────────────────────────────────────────────────────
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 /// Seed for "child-layer-001" (SHA-256("child-layer-001")[0..8] as LE-u64).
485 ///
486 /// Verified: `seed_from_id("child-layer-001") == LAYER_SEED`.
487 const LAYER_SEED: u64 = 9_551_015_030_439_334_514;
488
489 /// `compute_n_frames` must ensure every frame holds ≤ MAX_FDS_PER_FRAME fds
490 /// for a large synthetic fd count where the hash-min alone would be too small.
491 #[test]
492 fn test_compute_n_frames_caps_large_fd_count() {
493 // Use a seed whose hash-min is small (e.g. seed=0 → raw=0 → max(3,0)=3).
494 let seed: u64 = 0;
495 // Choose fd_count large enough that 3 frames would exceed 240 each.
496 // ceil(1000 / 3) = 334 > 240, so cap_min must win.
497 let fd_count: usize = 1000;
498
499 let n = compute_n_frames(seed, fd_count);
500
501 // Invariant: every frame carries at most MAX_FDS_PER_FRAME fds.
502 let max_frame_size = fd_count.div_ceil(n);
503 assert!(
504 max_frame_size <= MAX_FDS_PER_FRAME,
505 "frame size {max_frame_size} exceeds cap {MAX_FDS_PER_FRAME} (n={n}, fd_count={fd_count})",
506 );
507
508 // Also verify n is large enough: ceil(1000/240) = 5.
509 let cap_min = fd_count.div_ceil(MAX_FDS_PER_FRAME);
510 assert!(n >= cap_min, "n={n} < cap_min={cap_min}",);
511 }
512
513 /// For small fd counts (≤ MAX_FDS_PER_FRAME) the hash-min should still
514 /// dominate so test-hardening (≥3 frames for real layers) is preserved.
515 #[test]
516 fn test_compute_n_frames_small_fd_count_preserves_hash_min() {
517 // The existing test layer has 9 fds → cap_min=1, hash_min=4.
518 let n = compute_n_frames(LAYER_SEED, 9);
519 // EXPECTED_N_FRAMES = 4 for "child-layer-001"
520 assert_eq!(n, 4, "hash_min should win for small fd counts");
521
522 // Any fd count ≤ MAX_FDS_PER_FRAME has cap_min=1; hash_min (≥3) always wins.
523 for fd_count in 3..=MAX_FDS_PER_FRAME {
524 let n = compute_n_frames(LAYER_SEED, fd_count);
525 let max_frame_size = fd_count.div_ceil(n);
526 assert!(
527 max_frame_size <= MAX_FDS_PER_FRAME,
528 "frame size {max_frame_size} > cap for fd_count={fd_count}",
529 );
530 }
531 }
532
533 /// `compute_n_frames` invariant holds across a sweep of large fd counts.
534 #[test]
535 fn test_compute_n_frames_cap_invariant_sweep() {
536 let seeds: &[u64] = &[0, 1, 42, LAYER_SEED, u64::MAX];
537 let fd_counts: &[usize] = &[
538 MAX_FDS_PER_FRAME,
539 MAX_FDS_PER_FRAME + 1,
540 MAX_FDS_PER_FRAME * 2,
541 MAX_FDS_PER_FRAME * 5,
542 1000,
543 5000,
544 ];
545 for &seed in seeds {
546 for &fd_count in fd_counts {
547 let n = compute_n_frames(seed, fd_count);
548 let max_frame_size = fd_count.div_ceil(n);
549 assert!(
550 max_frame_size <= MAX_FDS_PER_FRAME,
551 "invariant violated: seed={seed} fd_count={fd_count} n={n} \
552 max_frame_size={max_frame_size} cap={MAX_FDS_PER_FRAME}",
553 );
554 }
555 }
556 }
557
558 /// A non-streaming (`more=false`) call that would exceed MAX_FDS_PER_FRAME
559 /// must be detected by the over-cap check before the producer is spawned.
560 ///
561 /// We test the pure decision logic (`!more && fd_count > MAX_FDS_PER_FRAME`)
562 /// without opening real fds, keeping the test deterministic and cheap.
563 #[test]
564 fn test_more_false_over_cap_decision() {
565 // Verify the threshold is exactly MAX_FDS_PER_FRAME.
566 assert!(
567 !(!false && MAX_FDS_PER_FRAME > MAX_FDS_PER_FRAME),
568 "fd_count == cap should NOT trigger error",
569 );
570 assert!(
571 !false && (MAX_FDS_PER_FRAME + 1) > MAX_FDS_PER_FRAME,
572 "fd_count == cap+1 MUST trigger error",
573 );
574
575 // Simulate: if !more and fd_count > MAX_FDS_PER_FRAME → error.
576 let should_error =
577 |more: bool, fd_count: usize| -> bool { !more && fd_count > MAX_FDS_PER_FRAME };
578
579 assert!(
580 !should_error(true, MAX_FDS_PER_FRAME + 1),
581 "more=true should never error on fd count"
582 );
583 assert!(
584 !should_error(false, MAX_FDS_PER_FRAME),
585 "more=false at exactly cap should not error"
586 );
587 assert!(
588 should_error(false, MAX_FDS_PER_FRAME + 1),
589 "more=false over cap must error"
590 );
591 assert!(
592 should_error(false, 1000),
593 "more=false with 1000 fds must error"
594 );
595 }
596
597 /// `seed_from_id` must match the precomputed value for "child-layer-001".
598 #[test]
599 fn test_seed_from_id_child_layer() {
600 assert_eq!(
601 seed_from_id("child-layer-001"),
602 LAYER_SEED,
603 "seed_from_id must match precomputed SHA-256-derived value"
604 );
605 }
606
607 /// `sparse_dir_slots` must produce a valid, seed-reproducible layout.
608 ///
609 /// The dummy count derives from the seed independently of placement, so for
610 /// "child-layer-001" (seed = 9551015030439334514): `n_dummy = (seed >> 8) %
611 /// (2+1) + 1 = 3`, hence `total_slots = 5`. Real-slot *placement* comes from
612 /// a `Pcg64` shuffle: we assert the structural invariants plus determinism
613 /// (same seed → same layout) rather than pinning the exact slot indices.
614 #[test]
615 fn test_sparse_dir_slots_known_layout() {
616 let layout = sparse_dir_slots(2, LAYER_SEED);
617
618 // Seed-derived counts (independent of the shuffle).
619 assert_eq!(layout.total_slots, 5, "total_slots must be 5");
620 assert_eq!(layout.real_slot_indices.len(), 2, "two real layers");
621 assert_eq!(layout.dummy_slot_indices.len(), 3, "three dummy slots");
622
623 // Structural invariants: both lists ascending, disjoint, and together
624 // covering exactly 0..total_slots.
625 assert!(
626 layout.real_slot_indices.windows(2).all(|w| w[0] < w[1]),
627 "real_slot_indices ascending"
628 );
629 assert!(
630 layout.dummy_slot_indices.windows(2).all(|w| w[0] < w[1]),
631 "dummy_slot_indices ascending"
632 );
633 let mut all: Vec<u32> = layout
634 .real_slot_indices
635 .iter()
636 .chain(&layout.dummy_slot_indices)
637 .copied()
638 .collect();
639 all.sort_unstable();
640 assert_eq!(
641 all,
642 (0..layout.total_slots as u32).collect::<Vec<_>>(),
643 "real and dummy slots must partition 0..total_slots"
644 );
645
646 // Determinism: the Pcg64 shuffle is reproducible from the seed.
647 assert_eq!(
648 sparse_dir_slots(2, LAYER_SEED),
649 layout,
650 "same seed must yield the same layout"
651 );
652 }
653
654 /// `n_extra_lifetime_fds` must return 2 for "child-layer-001"'s seed.
655 #[test]
656 fn test_n_extra_lifetime_fds_child_layer() {
657 assert_eq!(
658 n_extra_lifetime_fds(LAYER_SEED),
659 2,
660 "n_extra_lifetime_fds must be 2 for child-layer-001 seed"
661 );
662 }
663}