Shared Handle Table
SharedHandleTable<T>
Cross-process ECS-style slotmap. Same architectural shape as the
in-process AdaptiveHandle::Slotmap, but the slot table lives in
an MMF so handles are valid across processes. Handles are 64 bits
packing (generation: u32, slot: u32); generation parity (even =
vacant, odd = occupied) gives safe-after-free detection across the
process boundary.
The “cross-process ECS handles” primitive. Insert lands a u64 handle that any process opening the same file can resolve. Stale handles return None without alias-into-new-value risk. ABA-free Treiber-stack free list.
Constraints (read first):
Native sidecar integration: the struct carries a
HandshakeHeader+ObservationRingand implementssubetha_sidecar::AdaptiveInstance. Wrap inSidecarBox::newto register with the global sidecar; rawcreate()/open()return the unregistered type unchanged.T: Copy + 'staticplus stable#[repr(C)]layout.SLOT_PAYLOAD_BYTES = 48(source line 57): per-slot payload capacity (slot is 64-byte cache line minus header fields).Handle packing (rustdoc lines 4-8): u64 =
(gen: u32 << 32) | slot: u32.Handle::NULL = 0is the reserved sentinel.Generation parity (rustdoc lines 32-37): even = vacant, odd = occupied. Bumped on every insert and every remove.
ABA-free Treiber stack free list (rustdoc lines 39-45): head packed as
(counter: u32, slot_idx: u32). CAS bumps the counter on every push/pop.Each slot is 64 bytes (source line 75:
#[repr(C, align(64))]). generation + occupied + next_free + pad + 48-byte payload.Capacity fixed at create: no auto-grow. Insert returns
HandleTableError::Fullpast capacity.openrequires expected_capacity match (source:expected_capacityarg).Cross-process backed by MMF.
Table of contents
- What it is
- Generation parity
- ABA-free free list
- Worked examples
- Bench evidence
- Use case patterns
- Known limitations
- Common pitfalls
- References
What it is
SharedHandleTable<T> is an MMF-backed slotmap. Layout:
block-beta
columns 1
hdr["HandleHeader - 64 B: magic, capacity, free_list_head (packed), live_count"]
s0["Slot 0 - 64 B: generation, occupied, next_free, payload"]
dots["..."]
sn["Slot capacity - 1"]
classDef hdrC fill:#1e3a8a,color:#ffffff
classDef slotC fill:#0f766e,color:#ffffff
classDef padC fill:#475569,color:#ffffff
class hdr hdrC
class s0,sn slotC
class dots padC
Handles encode (generation, slot_idx). get(handle) indexes
slot by handle.slot(), compares slot.generation against
handle.generation(), and returns the payload only on match.
Generation parity
Even generation = vacant. Odd = occupied. Each transition bumps the generation:
- Insert into vacant slot: gen N -> gen N+1 (even -> odd).
- Remove from occupied slot: gen N -> gen N+1 (odd -> even).
A handle from generation N matches only when the slot is currently at gen N. After remove + re-insert into the same slot the generation differs by 2, so the original handle is stale.
ABA-free free list
The free-list head is a packed (counter: u32, slot_idx: u32)
CAS’d as one u64. Each pop or push increments the counter, so the
classic ABA scenario (head=A, intermediate=B, restored=A with
different next pointer) is detected by the counter mismatch.
Bench evidence
Bench harness: crates/subetha-cxc/benches/shared_handle_table.rs.
Captured 2026-06-01 on Windows 11 / Zen+ R7 2700, Criterion with
--sample-size=15 --warm-up-time=1 --measurement-time=2.
| Op | SharedHandleTable | RwLock<HashMap<Handle, T>> |
|---|---|---|
| insert | 53.81 ns | 62.71 ns |
| get (live) | 10.90 ns | 33.90 ns |
| get (stale handle) | 10.94 ns | n/a (HashMap can’t detect stale) |
SharedHandleTable wins 3.11x on the get hot path vs a
RwLock
Rule 3b bench audit
- Fair contender:
RwLock<HashMap<Handle, T>>is the textbook “map handle to value with safe-after-free” pattern in Rust. - Same operation semantics: handle-based get; insert returns new handle.
- MMF lifecycle managed.
What the numbers do NOT show
- Cross-process visibility: the bench is in-process. The
architectural lever (handles valid in OTHER processes) is what
the RwLock
baseline cannot do. - Concurrent insert/remove: bench is single-threaded.
Multi-thread workloads exercise the ABA-free CAS protocol;
the source’s
concurrent_inserts_and_removes_preserve_countunit test verifies correctness.
Worked examples
Cross-process handle issuance
Process A:
use subetha_cxc::shared_handle_table::SharedHandleTable;
let t: SharedHandleTable<u64> = SharedHandleTable::create("/tmp/ht.bin", 1024).unwrap();
let h = t.insert(42).unwrap();
// Write the handle.raw() to a SharedCell or pipe; process B looks it up.
Process B:
let t: SharedHandleTable<u64> = SharedHandleTable::open("/tmp/ht.bin", 1024).unwrap();
// Reconstruct the handle from the raw u64 bits received from A
// (handle packs (generation << 32) | slot):
use subetha_cxc::shared_handle_table::Handle;
let h = Handle::from_parts((handle_bits >> 32) as u32, handle_bits as u32);
assert_eq!(t.get(h), Some(42));Stale handle rejection
use subetha_cxc::shared_handle_table::SharedHandleTable;
let t: SharedHandleTable<u64> = SharedHandleTable::create("/tmp/ht2.bin", 16).unwrap();
let h = t.insert(100).unwrap();
assert_eq!(t.get(h), Some(100));
t.remove(h);
assert_eq!(t.get(h), None); // stale handle returns None
let h2 = t.insert(200).unwrap();
// h2 likely reuses h's slot but with a different generation.
assert_eq!(t.get(h), None); // original h still stale
assert_eq!(t.get(h2), Some(200));Use case patterns
Pattern: cross-process entity registry
A scene / world manager keeps entities in a SharedHandleTable; worker processes look up entities by handle.
Pattern: connection / session table
Network daemon owns the table; worker processes resolve handles to session metadata.
Pattern: capability tokens
Issued handles act as capabilities that can be passed between processes; stale handles can be revoked by remove + re-insert with a fresh handle to a new value.
Known limitations
- Capacity fixed at create: no auto-grow.
- Payload capped at 48 bytes per slot: larger T uses a pointer-indirection pattern.
openrequires expected_capacity match.- Generation wrap at 2^32: extremely high-churn slots (4 billion inserts on the same slot index) can alias.
- Cross-process backed by MMF.
Common pitfalls
Forgetting to check
getreturn value before use. Stale handles return None; treating None as a programming error rather than expected behavior is wrong.Hardcoding capacity differently between create and open. LayoutMismatch.
Storing T that does not have a stable repr(C) layout. Cross-process reads may see scrambled bytes.
Treating handles as raw indices. They are
(gen, slot)packed. A consumer that strips the gen and indexes by slot only loses the safe-after-free guarantee.
References
- Source:
crates/subetha-cxc/src/shared_handle_table.rs(with unit tests covering insert/get/remove, stale rejection, full-table error, cross-handle visibility, concurrent inserts/removes, disk persistence, NULL handle, packing, struct payload). - Bench:
crates/subetha-cxc/benches/shared_handle_table.rs(insert, get live, get stale vs RwLockbaseline). - Sibling primitive: OFFSET_PTR.md - the underlying MMF-backed pointer.
- Sibling primitive: SHARED_HASH_MAP.md - cross-process hash map (uses keys instead of handles).
- Sibling primitive: TAGGED_OFFSET_PTR.md - offset pointer with extra tag bits for state.