Skip to main content

subetha_cxc/
peer_directory.rs

1//! `PeerDirectory` - the shared topology substrate behind the
2//! automatic [`AdaptiveRing`](crate::AdaptiveRing).
3//!
4//! One small MMF region (file / named-shm / anonymous, mirroring the
5//! ring backings' three locales) that every process attached to the
6//! same ring maps. It carries the facts the automatic behavior needs
7//! to be CROSS-PROCESS instead of per-process:
8//!
9//! - **Peer slot claims.** Producers and consumers claim / release
10//!   slot ids in shared bitmaps, so ids are unique across processes
11//!   and slots recycle on release. Registration in any process is
12//!   visible to every process.
13//! - **Ring publication.** `published()` is the count of per-producer
14//!   ring backings whose files exist and are fully initialized.
15//!   A grower creates the backing files FIRST, then advances the
16//!   count (Release); openers that observe the count (Acquire) can
17//!   open the files without racing initialisation.
18//! - **Topology epoch.** One shared counter bumped on every peer /
19//!   publication change. Hot paths compare it against a process-local
20//!   cache - one relaxed load per op while the topology is stable -
21//!   and run the sync slow path only on change.
22//! - **MPMC ring ownership.** A per-ring owner table replaces the
23//!   modulus partition so the consumer set can grow and shrink at
24//!   runtime. Each per-producer ring is drained by exactly ONE
25//!   consumer slot (the Lamport cores are single-reader); ownership
26//!   moves only by (a) CAS-claim of an unowned ring, (b) the current
27//!   owner handing off after a rebalance request, or (c) takeover of
28//!   a slot whose process is gone (pid liveness probe) or whose slot
29//!   was released. (a)-(c) all keep the single-reader invariant: no
30//!   two live consumers ever pop the same ring concurrently.
31//!
32//! The region is fixed-size: [`PRODUCER_SLOT_CEILING`] and
33//! [`CONSUMER_SLOT_CEILING`] are substrate-wide architectural
34//! ceilings (like the Vyukov 56-byte slot), NOT per-ring tunables -
35//! per-ring limits come from the caller's declared
36//! [`RingContract`](crate::ring_contract::RingContract), and only a declared
37//! contract makes registration fallible. Slots recycle on release, so
38//! the ceilings bound CONCURRENT peers, not lifetime attachments.
39
40use std::fs::{File, OpenOptions};
41use std::path::Path;
42use std::sync::atomic::{AtomicU32, AtomicU64, Ordering as AtomOrd};
43
44use memmap2::{MmapMut, MmapOptions};
45
46use crate::shared_ring::RingError;
47
48/// Substrate-wide ceiling on CONCURRENTLY-claimed producer slots per
49/// ring. Backing files are created on demand, so the ceiling costs
50/// only directory bytes (one owner word + one bitmap bit per slot),
51/// not ring storage.
52pub const PRODUCER_SLOT_CEILING: usize = 4096;
53
54/// Substrate-wide ceiling on concurrently-claimed consumer slots per
55/// ring. Sized past the ring contract's expressible `u8` bound.
56pub const CONSUMER_SLOT_CEILING: usize = 256;
57
58/// "No consumer" marker in the ring owner table.
59pub const OWNER_NONE: u16 = u16::MAX;
60
61/// Pid word of a consumer slot that held a claim and gave it up. Distinct
62/// from the zero a slot carries before it is ever claimed: a claim that
63/// was released names a consumer that has finished with its rings, while
64/// zero names one that never registered and may still be draining.
65const SLOT_RELEASED: u64 = u64::MAX;
66
67const DIR_MAGIC: u32 = 0x5045_4552; // "PEER"
68
69const P_WORDS: usize = PRODUCER_SLOT_CEILING / 64;
70const C_WORDS: usize = CONSUMER_SLOT_CEILING / 64;
71
72#[repr(C)]
73struct DirHeader {
74    magic: AtomicU32,
75    _rsvd: u32,
76    /// Topology epoch: bumped on every claim / release / publish.
77    epoch: AtomicU64,
78    /// Per-producer ring backings that exist and are initialized.
79    published: AtomicU32,
80    active_producers: AtomicU32,
81    active_consumers: AtomicU32,
82    _pad: u32,
83}
84
85const OFF_P_BITMAP: usize = std::mem::size_of::<DirHeader>();
86const OFF_C_BITMAP: usize = OFF_P_BITMAP + P_WORDS * 8;
87const OFF_C_PIDS: usize = OFF_C_BITMAP + C_WORDS * 8;
88const OFF_P_PIDS: usize = OFF_C_PIDS + CONSUMER_SLOT_CEILING * 8;
89const OFF_OWNERS: usize = OFF_P_PIDS + PRODUCER_SLOT_CEILING * 8;
90
91/// Total mapped size of a peer directory region.
92pub const fn peer_directory_size() -> usize {
93    OFF_OWNERS + PRODUCER_SLOT_CEILING * 8
94}
95
96/// Backing-store owner; mirrors the ordering region's pattern.
97#[allow(dead_code)]
98enum DirBacking {
99    Anon(MmapMut),
100    File(File, MmapMut),
101    Shm(crate::shm_file::ShmFile),
102}
103
104/// The mapped peer directory in any of the three locales.
105pub struct PeerDirectory {
106    _backing: DirBacking,
107    raw_ptr: *mut u8,
108}
109
110unsafe impl Send for PeerDirectory {}
111unsafe impl Sync for PeerDirectory {}
112
113/// Zero the claims / counters and stamp the magic. Called exactly
114/// once per region lifetime (creator side); attachers never re-init.
115unsafe fn init_dir_layout(ptr: *mut u8) {
116    unsafe {
117        std::ptr::write_bytes(ptr, 0, peer_directory_size());
118        // Owner table starts all-OWNER_NONE, not zero.
119        let owners = ptr.add(OFF_OWNERS) as *mut u64;
120        let none = pack_owner(OWNER_NONE, OWNER_NONE);
121        for i in 0..PRODUCER_SLOT_CEILING {
122            std::ptr::write(owners.add(i), none);
123        }
124        let header = &*(ptr as *const DirHeader);
125        header.magic.store(DIR_MAGIC, AtomOrd::Release);
126    }
127}
128
129#[inline]
130const fn pack_owner(owner: u16, pending: u16) -> u64 {
131    (owner as u64) | ((pending as u64) << 16)
132}
133
134#[inline]
135const fn unpack_owner(word: u64) -> (u16, u16) {
136    (word as u16, (word >> 16) as u16)
137}
138
139impl PeerDirectory {
140    /// Anonymous in-process directory (the Anon ring locale).
141    pub fn create_anon() -> Result<Self, RingError> {
142        let mut mmap = MmapOptions::new().len(peer_directory_size()).map_anon()?;
143        unsafe { init_dir_layout(mmap.as_mut_ptr()) };
144        let raw_ptr = mmap.as_mut_ptr();
145        Ok(Self { _backing: DirBacking::Anon(mmap), raw_ptr })
146    }
147
148    /// File-backed directory at `path`, initialized by the creator.
149    /// Obtain the file-backed directory at `path`, initializing it only if it
150    /// does not yet exist. Attaching leaves live claims in place; use
151    /// [`reset`](Self::reset) to deliberately wipe them.
152    pub fn create(path: impl AsRef<Path>) -> Result<Self, RingError> {
153        let total = peer_directory_size();
154        let (file, mut mmap) = crate::mmf_attach::create_or_attach(
155            path.as_ref(),
156            total,
157            |ptr| unsafe { init_dir_layout(ptr) },
158            |ptr| {
159                let header = unsafe { &*(ptr as *const DirHeader) };
160                header.magic.load(AtomOrd::Acquire) == DIR_MAGIC
161            },
162        )
163        .map_err(|e| RingError::IoError(e.kind()))?;
164        let raw_ptr = mmap.as_mut_ptr();
165        Ok(Self { _backing: DirBacking::File(file, mmap), raw_ptr })
166    }
167
168    /// Reinitialise the directory at `path`, discarding every claim a live peer
169    /// holds. For a caller that knows it owns the path.
170    pub fn reset(path: impl AsRef<Path>) -> Result<Self, RingError> {
171        let total = peer_directory_size();
172        let (file, mut mmap) =
173            crate::mmf_attach::reset(path.as_ref(), total, |ptr| unsafe { init_dir_layout(ptr) })
174                .map_err(|e| RingError::IoError(e.kind()))?;
175        let raw_ptr = mmap.as_mut_ptr();
176        Ok(Self { _backing: DirBacking::File(file, mmap), raw_ptr })
177    }
178
179    /// Open an existing file-backed directory; validates the magic
180    /// and never re-initialises (live claims survive the attach).
181    pub fn open(path: impl AsRef<Path>) -> Result<Self, RingError> {
182        let total = peer_directory_size();
183        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
184        if (file.metadata()?.len() as usize) < total {
185            return Err(RingError::LayoutMismatch);
186        }
187        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
188        let header = unsafe { &*(mmap.as_ptr() as *const DirHeader) };
189        if header.magic.load(AtomOrd::Acquire) != DIR_MAGIC {
190            return Err(RingError::LayoutMismatch);
191        }
192        let raw_ptr = mmap.as_ptr() as *mut u8;
193        Ok(Self { _backing: DirBacking::File(file, mmap), raw_ptr })
194    }
195
196    /// Named-shm directory. `create_or_open` semantics: the region is
197    /// initialized only when its magic is absent, so racing attachers
198    /// never wipe live claims.
199    pub fn create_or_open_shm(name: &str) -> Result<Self, RingError> {
200        let mut shm = crate::shm_file::ShmFile::create_or_open_named(
201            name, peer_directory_size(),
202        ).map_err(|e| RingError::IoError(e.kind()))?;
203        if shm.len() < peer_directory_size() {
204            return Err(RingError::LayoutMismatch);
205        }
206        let raw_ptr = shm.as_mut_slice().as_mut_ptr();
207        let dir = Self { _backing: DirBacking::Shm(shm), raw_ptr };
208        let header = dir.header();
209        if header.magic
210            .compare_exchange(0, 1, AtomOrd::AcqRel, AtomOrd::Acquire)
211            .is_ok()
212        {
213            // We won the init race: full layout write, then publish
214            // the real magic (attachers spin below until it lands).
215            unsafe { init_dir_layout(raw_ptr) };
216        } else {
217            let mut spins = 0u32;
218            while dir.header().magic.load(AtomOrd::Acquire) != DIR_MAGIC {
219                std::hint::spin_loop();
220                spins += 1;
221                if spins > 100_000_000 {
222                    return Err(RingError::LayoutMismatch);
223                }
224            }
225        }
226        Ok(dir)
227    }
228
229    #[inline]
230    fn header(&self) -> &DirHeader {
231        unsafe { &*(self.raw_ptr as *const DirHeader) }
232    }
233
234    #[inline]
235    fn bitmap_word(&self, base: usize, i: usize) -> &AtomicU64 {
236        unsafe { &*(self.raw_ptr.add(base + i * 8) as *const AtomicU64) }
237    }
238
239    #[inline]
240    fn owner_word(&self, ring: usize) -> &AtomicU64 {
241        debug_assert!(ring < PRODUCER_SLOT_CEILING);
242        unsafe { &*(self.raw_ptr.add(OFF_OWNERS + ring * 8) as *const AtomicU64) }
243    }
244
245    #[inline]
246    fn pid_word(&self, slot: usize) -> &AtomicU64 {
247        debug_assert!(slot < CONSUMER_SLOT_CEILING);
248        unsafe { &*(self.raw_ptr.add(OFF_C_PIDS + slot * 8) as *const AtomicU64) }
249    }
250
251    #[inline]
252    fn producer_pid_word(&self, slot: usize) -> &AtomicU64 {
253        debug_assert!(slot < PRODUCER_SLOT_CEILING);
254        unsafe { &*(self.raw_ptr.add(OFF_P_PIDS + slot * 8) as *const AtomicU64) }
255    }
256
257    /// Current topology epoch. Hot paths compare this against a
258    /// process-local cache; equality means nothing changed.
259    #[inline]
260    pub fn epoch(&self) -> u64 {
261        self.header().epoch.load(AtomOrd::Acquire)
262    }
263
264    /// Bump the topology epoch (any peer / publication change).
265    pub fn bump_epoch(&self) -> u64 {
266        self.header().epoch.fetch_add(1, AtomOrd::AcqRel) + 1
267    }
268
269    /// Ring backings published (files exist + initialized).
270    #[inline]
271    pub fn published(&self) -> usize {
272        self.header().published.load(AtomOrd::Acquire) as usize
273    }
274
275    /// Advance the published-ring count to `to` after creating the
276    /// backing files for every slot below it. Monotone max, so
277    /// concurrent growers publishing different highs converge.
278    pub fn publish_rings(&self, to: usize) {
279        self.header().published.fetch_max(to as u32, AtomOrd::AcqRel);
280        self.bump_epoch();
281    }
282
283    /// Live producer count across all attached processes.
284    #[inline]
285    pub fn active_producers(&self) -> usize {
286        self.header().active_producers.load(AtomOrd::Acquire) as usize
287    }
288
289    /// Live consumer count across all attached processes.
290    #[inline]
291    pub fn active_consumers(&self) -> usize {
292        self.header().active_consumers.load(AtomOrd::Acquire) as usize
293    }
294
295    /// Claim the lowest free producer slot. `None` only at the
296    /// substrate ceiling ([`PRODUCER_SLOT_CEILING`] CONCURRENT
297    /// producers).
298    pub fn claim_producer_slot(&self) -> Option<usize> {
299        let slot = self.claim_bit(OFF_P_BITMAP, P_WORDS)?;
300        self.producer_pid_word(slot).store(std::process::id() as u64, AtomOrd::Release);
301        self.header().active_producers.fetch_add(1, AtomOrd::AcqRel);
302        self.bump_epoch();
303        Some(slot)
304    }
305
306    /// Release a producer slot claimed by
307    /// [`claim_producer_slot`](Self::claim_producer_slot).
308    pub fn release_producer_slot(&self, slot: usize) {
309        if slot < PRODUCER_SLOT_CEILING {
310            self.producer_pid_word(slot).store(0, AtomOrd::Release);
311        }
312        if self.release_bit(OFF_P_BITMAP, P_WORDS, slot) {
313            self.header().active_producers.fetch_sub(1, AtomOrd::AcqRel);
314            self.bump_epoch();
315        }
316    }
317
318    /// Release every peer slot whose recorded process is gone
319    /// (crashed / exited without unregistering). Called from the
320    /// topology sync SLOW path only - it probes at most one pid per
321    /// claimed slot. Rings owned by reaped consumer slots become
322    /// claimable via the normal takeover / claim paths.
323    pub fn reap_dead_peers(&self) {
324        for slot in self.claimed_slots(OFF_P_BITMAP, P_WORDS) {
325            let pid = self.producer_pid_word(slot).load(AtomOrd::Acquire);
326            if pid != 0 && pid != std::process::id() as u64
327                && !process_alive(pid as u32)
328            {
329                self.release_producer_slot(slot);
330            }
331        }
332        for slot in self.claimed_slots(OFF_C_BITMAP, C_WORDS) {
333            let pid = self.pid_word(slot).load(AtomOrd::Acquire);
334            if pid != 0 && pid != std::process::id() as u64
335                && !process_alive(pid as u32)
336            {
337                self.release_consumer_slot(slot);
338            }
339        }
340    }
341
342    fn claimed_slots(&self, base: usize, words: usize) -> Vec<usize> {
343        let mut slots = Vec::new();
344        for w in 0..words {
345            let mut word = self.bitmap_word(base, w).load(AtomOrd::Acquire);
346            while word != 0 {
347                let bit = word.trailing_zeros() as usize;
348                slots.push(w * 64 + bit);
349                word &= word - 1;
350            }
351        }
352        slots
353    }
354
355    /// Claim the lowest free consumer slot, recording the claiming
356    /// process id for the crash-takeover liveness probe.
357    pub fn claim_consumer_slot(&self) -> Option<usize> {
358        let slot = self.claim_bit(OFF_C_BITMAP, C_WORDS)?;
359        self.pid_word(slot).store(std::process::id() as u64, AtomOrd::Release);
360        self.header().active_consumers.fetch_add(1, AtomOrd::AcqRel);
361        self.bump_epoch();
362        Some(slot)
363    }
364
365    /// Release a consumer slot. The caller transfers its ring
366    /// ownership out FIRST (it is the single owner, so direct owner
367    /// writes are safe), then releases.
368    pub fn release_consumer_slot(&self, slot: usize) {
369        if slot >= CONSUMER_SLOT_CEILING {
370            return;
371        }
372        self.pid_word(slot).store(SLOT_RELEASED, AtomOrd::Release);
373        if self.release_bit(OFF_C_BITMAP, C_WORDS, slot) {
374            self.header().active_consumers.fetch_sub(1, AtomOrd::AcqRel);
375            self.bump_epoch();
376        }
377    }
378
379    /// Whether `slot` currently holds a consumer claim.
380    pub fn consumer_slot_claimed(&self, slot: usize) -> bool {
381        if slot >= CONSUMER_SLOT_CEILING {
382            return false;
383        }
384        let word = self.bitmap_word(OFF_C_BITMAP, slot / 64).load(AtomOrd::Acquire);
385        word & (1u64 << (slot % 64)) != 0
386    }
387
388    /// Dense list of currently-claimed consumer slots (rebalance
389    /// input). Snapshot semantics: claims racing the scan are picked
390    /// up by the next epoch-triggered rebalance.
391    pub fn claimed_consumer_slots(&self) -> Vec<u16> {
392        let mut slots = Vec::new();
393        for w in 0..C_WORDS {
394            let mut word = self.bitmap_word(OFF_C_BITMAP, w).load(AtomOrd::Acquire);
395            while word != 0 {
396                let bit = word.trailing_zeros() as usize;
397                slots.push((w * 64 + bit) as u16);
398                word &= word - 1;
399            }
400        }
401        slots
402    }
403
404    /// `(owner, pending)` for one ring's owner-table entry.
405    #[inline]
406    pub fn ring_owner(&self, ring: usize) -> (u16, u16) {
407        unpack_owner(self.owner_word(ring).load(AtomOrd::Acquire))
408    }
409
410    /// CAS-claim an unowned ring for `me`. The only ownership entry
411    /// point that does not go through the current owner, and it
412    /// requires owner == OWNER_NONE, so the single-reader invariant
413    /// holds by construction.
414    pub fn try_claim_ring(&self, ring: usize, me: u16) -> bool {
415        let cur = pack_owner(OWNER_NONE, OWNER_NONE);
416        self.owner_word(ring)
417            .compare_exchange(cur, pack_owner(me, OWNER_NONE),
418                              AtomOrd::AcqRel, AtomOrd::Acquire)
419            .is_ok()
420    }
421
422    /// Request that `ring` move to `target`. The CURRENT owner
423    /// applies the handoff on its next scan ([`Self::apply_handoff`]);
424    /// until then it keeps draining, so no items strand.
425    pub fn request_handoff(&self, ring: usize, target: u16) {
426        let word = self.owner_word(ring);
427        let mut cur = word.load(AtomOrd::Acquire);
428        loop {
429            let (owner, _) = unpack_owner(cur);
430            if owner == target || owner == OWNER_NONE {
431                return; // already there / claimable directly
432            }
433            match word.compare_exchange(cur, pack_owner(owner, target),
434                                        AtomOrd::AcqRel, AtomOrd::Acquire) {
435                Ok(_) => return,
436                Err(actual) => cur = actual,
437            }
438        }
439    }
440
441    /// Owner-side handoff: if `me` owns `ring` and a handoff is
442    /// pending, transfer ownership and return the new owner. Called
443    /// from the owner's own pop scan - the single-writer transfer.
444    pub fn apply_handoff(&self, ring: usize, me: u16) -> Option<u16> {
445        let word = self.owner_word(ring);
446        let cur = word.load(AtomOrd::Acquire);
447        let (owner, pending) = unpack_owner(cur);
448        if owner != me || pending == OWNER_NONE {
449            return None;
450        }
451        match word.compare_exchange(cur, pack_owner(pending, OWNER_NONE),
452                                    AtomOrd::AcqRel, AtomOrd::Acquire) {
453            Ok(_) => {
454                self.bump_epoch();
455                Some(pending)
456            }
457            Err(_) => None,
458        }
459    }
460
461    /// Direct ownership transfer by the CURRENT owner (unregister
462    /// path: the leaving consumer parcels its rings out itself).
463    pub fn transfer_ring(&self, ring: usize, me: u16, to: u16) {
464        let word = self.owner_word(ring);
465        let mut cur = word.load(AtomOrd::Acquire);
466        loop {
467            let (owner, _) = unpack_owner(cur);
468            if owner != me {
469                return;
470            }
471            match word.compare_exchange(cur, pack_owner(to, OWNER_NONE),
472                                        AtomOrd::AcqRel, AtomOrd::Acquire) {
473                Ok(_) => return,
474                Err(actual) => cur = actual,
475            }
476        }
477    }
478
479    /// Crash takeover: steal `ring` from `dead_owner` when that owner
480    /// released its consumer slot, or still holds one whose recorded
481    /// process is gone. An owner that never claimed a slot, or whose
482    /// process is alive, is left alone, so each core keeps a single
483    /// reader.
484    pub fn try_takeover(&self, ring: usize, dead_owner: u16, me: u16) -> bool {
485        let slot = dead_owner as usize;
486        let pid = self.pid_word(slot).load(AtomOrd::Acquire);
487        if pid != SLOT_RELEASED {
488            if !self.consumer_slot_claimed(slot) {
489                return false;
490            }
491            if pid == std::process::id() as u64 || pid == 0 || process_alive(pid as u32) {
492                return false;
493            }
494        }
495        let word = self.owner_word(ring);
496        let cur = word.load(AtomOrd::Acquire);
497        let (owner, _) = unpack_owner(cur);
498        if owner != dead_owner {
499            return false;
500        }
501        let swapped = word.compare_exchange(
502            cur, pack_owner(me, OWNER_NONE), AtomOrd::AcqRel, AtomOrd::Acquire,
503        ).is_ok();
504        if swapped {
505            self.bump_epoch();
506        }
507        swapped
508    }
509
510    fn claim_bit(&self, base: usize, words: usize) -> Option<usize> {
511        for w in 0..words {
512            let word = self.bitmap_word(base, w);
513            let mut cur = word.load(AtomOrd::Acquire);
514            loop {
515                if cur == u64::MAX {
516                    break; // word full; next word
517                }
518                let bit = (!cur).trailing_zeros() as usize;
519                match word.compare_exchange(cur, cur | (1u64 << bit),
520                                            AtomOrd::AcqRel, AtomOrd::Acquire) {
521                    Ok(_) => return Some(w * 64 + bit),
522                    Err(actual) => cur = actual,
523                }
524            }
525        }
526        None
527    }
528
529    fn release_bit(&self, base: usize, words: usize, slot: usize) -> bool {
530        if slot >= words * 64 {
531            return false;
532        }
533        let word = self.bitmap_word(base, slot / 64);
534        let mask = 1u64 << (slot % 64);
535        word.fetch_and(!mask, AtomOrd::AcqRel) & mask != 0
536    }
537}
538
539/// Whether the OS process `pid` is alive. Used only by the crash
540/// takeover; a false ALIVE (pid reuse) is the benign direction (the
541/// ring stays with the stale owner until an explicit release).
542#[cfg(unix)]
543fn process_alive(pid: u32) -> bool {
544    let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
545    rc == 0
546        || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
547}
548
549#[cfg(windows)]
550fn process_alive(pid: u32) -> bool {
551    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
552    use windows_sys::Win32::System::Threading::{
553        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
554    };
555    unsafe {
556        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
557        if handle.is_null() {
558            // Access denied still proves existence; a missing pid
559            // yields ERROR_INVALID_PARAMETER instead.
560            return std::io::Error::last_os_error().raw_os_error()
561                == Some(5 /* ERROR_ACCESS_DENIED */);
562        }
563        let mut code: u32 = 0;
564        let ok = GetExitCodeProcess(handle, &mut code);
565        CloseHandle(handle);
566        ok != 0 && code == STILL_ACTIVE as u32
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn producer_slots_claim_release_recycle() {
576        let dir = PeerDirectory::create_anon().unwrap();
577        assert_eq!(dir.claim_producer_slot(), Some(0));
578        assert_eq!(dir.claim_producer_slot(), Some(1));
579        assert_eq!(dir.claim_producer_slot(), Some(2));
580        assert_eq!(dir.active_producers(), 3);
581        dir.release_producer_slot(1);
582        assert_eq!(dir.active_producers(), 2);
583        // Lowest free slot recycles.
584        assert_eq!(dir.claim_producer_slot(), Some(1));
585        assert_eq!(dir.active_producers(), 3);
586    }
587
588    #[test]
589    fn epoch_bumps_on_every_topology_change() {
590        let dir = PeerDirectory::create_anon().unwrap();
591        let e0 = dir.epoch();
592        let p = dir.claim_producer_slot().unwrap();
593        assert!(dir.epoch() > e0);
594        let e1 = dir.epoch();
595        dir.release_producer_slot(p);
596        assert!(dir.epoch() > e1);
597        let e2 = dir.epoch();
598        dir.publish_rings(4);
599        assert!(dir.epoch() > e2);
600        assert_eq!(dir.published(), 4);
601        // publish is monotone max.
602        dir.publish_rings(2);
603        assert_eq!(dir.published(), 4);
604    }
605
606    #[test]
607    fn ring_ownership_claim_handoff_transfer() {
608        let dir = PeerDirectory::create_anon().unwrap();
609        assert_eq!(dir.ring_owner(0), (OWNER_NONE, OWNER_NONE));
610        assert!(dir.try_claim_ring(0, 3));
611        assert!(!dir.try_claim_ring(0, 5), "claimed ring must reject CAS");
612        assert_eq!(dir.ring_owner(0), (3, OWNER_NONE));
613
614        // Rebalance request parks as pending until the owner applies.
615        dir.request_handoff(0, 7);
616        assert_eq!(dir.ring_owner(0), (3, 7));
617        assert_eq!(dir.apply_handoff(0, 5), None, "non-owner cannot apply");
618        assert_eq!(dir.apply_handoff(0, 3), Some(7));
619        assert_eq!(dir.ring_owner(0), (7, OWNER_NONE));
620
621        // Unregister-path direct transfer by the owner.
622        dir.transfer_ring(0, 7, 2);
623        assert_eq!(dir.ring_owner(0), (2, OWNER_NONE));
624    }
625
626    #[test]
627    fn takeover_requires_dead_or_released_owner() {
628        let dir = PeerDirectory::create_anon().unwrap();
629        let slot = dir.claim_consumer_slot().unwrap() as u16;
630        assert!(dir.try_claim_ring(0, slot));
631        // The claiming slot belongs to THIS live process: never stolen.
632        assert!(!dir.try_takeover(0, slot, 9));
633        // Released slot: takeover permitted.
634        dir.release_consumer_slot(slot as usize);
635        assert!(dir.try_takeover(0, slot, 9));
636        assert_eq!(dir.ring_owner(0), (9, OWNER_NONE));
637    }
638
639    /// A consumer that drains through `try_claim_ring` owns rings without
640    /// ever claiming a consumer slot, which is what every in-process
641    /// consumer does. Such an owner is live and popping, so its rings are
642    /// not available for takeover; stealing one puts a second reader on a
643    /// single-reader core and both drain the same slots.
644    #[test]
645    fn an_owner_that_never_claimed_a_slot_is_not_taken_over() {
646        let dir = PeerDirectory::create_anon().unwrap();
647        let owner: u16 = 3;
648        assert!(!dir.consumer_slot_claimed(owner as usize));
649        assert!(dir.try_claim_ring(0, owner), "ring claim needs no slot");
650
651        assert!(
652            !dir.try_takeover(0, owner, 9),
653            "an owner with no slot claim is live, not departed"
654        );
655        assert_eq!(dir.ring_owner(0), (owner, OWNER_NONE));
656
657        // The same owner having held and released a slot IS departed.
658        let slot = dir.claim_consumer_slot().unwrap();
659        dir.release_consumer_slot(slot);
660        assert!(dir.try_claim_ring(1, slot as u16));
661        assert!(dir.try_takeover(1, slot as u16, 9));
662        assert_eq!(dir.ring_owner(1), (9, OWNER_NONE));
663    }
664
665    #[test]
666    fn consumer_slots_record_pid_and_enumerate_densely() {
667        let dir = PeerDirectory::create_anon().unwrap();
668        let a = dir.claim_consumer_slot().unwrap();
669        let b = dir.claim_consumer_slot().unwrap();
670        let c = dir.claim_consumer_slot().unwrap();
671        assert_eq!((a, b, c), (0, 1, 2));
672        dir.release_consumer_slot(b);
673        assert_eq!(dir.claimed_consumer_slots(), vec![0u16, 2]);
674        assert_eq!(dir.active_consumers(), 2);
675    }
676
677    #[test]
678    fn file_backed_directory_shares_claims_across_handles() {
679        let base = std::env::temp_dir()
680            .join(format!("subetha_peerdir_{}", std::process::id()));
681        let path = base.with_extension("peers.bin");
682        let dir_a = PeerDirectory::create(&path).unwrap();
683        let p = dir_a.claim_producer_slot().unwrap();
684        dir_a.publish_rings(p + 1);
685
686        let dir_b = PeerDirectory::open(&path).unwrap();
687        assert_eq!(dir_b.active_producers(), 1);
688        assert_eq!(dir_b.published(), p + 1);
689        assert_eq!(dir_b.claim_producer_slot(), Some(p + 1));
690        assert_eq!(dir_a.active_producers(), 2, "claim visible both ways");
691
692        drop(dir_a);
693        drop(dir_b);
694        std::fs::remove_file(&path).ok();
695    }
696}