Skip to main content

sefer_region/
sync_region.rs

1//! [`SyncRegion`] — the safe concurrent default: a `Region` behind an `RwLock`.
2
3use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, TryLockError};
4
5use crate::{Handle, Region};
6
7/// A thread-safe wrapper around [`Region<T>`] — the trusted concurrent baseline.
8///
9/// This is a coarse-grained `std::sync::RwLock<Region<T>>` with an ergonomic
10/// guard-based API: multiple readers (`read`) or one writer (`write`) at a time.
11/// It is the *always-shippable* concurrent answer: correct under any interleaving
12/// because every mutation serialises through the lock. Note: "correct under any
13/// interleaving" refers to **safety/serialization** — memory safety and API
14/// contract invariants — not to bounded writer latency. `std::sync::RwLock` does
15/// not guarantee portable fairness or starvation-freedom; on some platforms a new
16/// reader may be delayed behind an awaiting writer depending on OS scheduling.
17/// Finer-grained or lock-free alternatives are out of scope for this crate.
18///
19/// The wrapper stays `#![forbid(unsafe_code)]`: all interior mutability comes
20/// from `std`'s `RwLock`. Use [`read`](Self::read) / [`write`](Self::write) for
21/// multi-operation transactions (the borrows tie to the guard), or the
22/// one-shot convenience methods ([`insert`](Self::insert),
23/// [`remove`](Self::remove), …) which take `&self` and lock internally.
24/// `reserve` and `capacity` have no one-shot form — reach them through a
25/// held guard, e.g. `sr.write().reserve(n)` / `sr.read().capacity()`.
26///
27/// ## Poisoning policy
28///
29/// A panic while the **write** guard is held poisons the `RwLock` — `std` never
30/// poisons on a read-guard panic (e.g. a panicking `T::clone` inside
31/// [`get_cloned`](Self::get_cloned) releases the read lock cleanly, with no
32/// poison). The **container structure** (Region/slotmap invariants) stays
33/// intact regardless of the panic — this crate guarantees no memory
34/// corruption. However, **interior side effects are the responsibility of `T`**:
35/// if `T::clone` modifies internal state (e.g. `Cell`, atomics, internal `Mutex`)
36/// before panicking, that modification persists. The `RwLock` itself does not
37/// poison on read-guard panic, so the `SyncRegion` remains usable.
38///
39/// **Poison recovery guarantees container integrity only, not operation completion.**
40/// The recovered `Region` has no memory corruption, but an interrupted operation
41/// may have left partial effects visible: a panicking `T::Drop` during `clear()`
42/// leaves the region partially cleared -- container-valid and reusable, but the
43/// exact set of surviving values is an unspecified implementation detail of the
44/// underlying `slotmap` version's unwind cleanup, not a stable contract (see
45/// `Region::clear`'s own documentation), and a panicked multi-op `write()`
46/// transaction leaves whatever partial effects it already applied. Callers whose
47/// `T` carries cross-value invariants, or whose multi-op transactions need
48/// all-or-nothing semantics, must implement their own signaling — this crate
49/// provides none beyond what's documented here.
50///
51/// **Poison is cleared on recovery.** [`read`](Self::read) and
52/// [`write`](Self::write) (and therefore every one-shot convenience method,
53/// which locks internally) clear the lock's poison flag immediately after
54/// recovering from it. This is a deliberate policy consistent with "poison
55/// recovery guarantees container integrity only" above: since this crate
56/// already always trusts the container after ANY recovery, permanently
57/// forcing every subsequent access down `std::sync::RwLock`'s slower
58/// poisoned-recovery path would cost real performance for no additional
59/// safety — the container was already proven sound on the FIRST recovery.
60/// One consequence: `SyncRegion` never exposes an `is_poisoned()` check,
61/// because poisoned state is never observable for longer than the single
62/// access that first recovers from it (except through [`Debug`], which
63/// reports it without clearing). Applications that need a durable,
64/// observable "a writer panicked here" signal for their own cross-value
65/// invariants must implement it themselves (e.g. an `AtomicBool` alongside
66/// the `SyncRegion`) — this crate's own poison flag is not a substitute,
67/// by design.
68///
69/// ## Reentrancy
70///
71/// [`get_cloned`](Self::get_cloned) runs `T::clone`, and [`clear`](Self::clear) runs each
72/// `T::Drop`, while the internal lock is held. If `T`'s `Clone` or `Drop` implementation
73/// re-enters the same `SyncRegion` (directly or transitively), the thread deadlocks or
74/// panics per `std::sync::RwLock`'s documented same-thread reacquisition behavior.
75/// Even non-reentrant but slow `Clone`/`Drop` delays every other user: `clear` holds
76/// the write lock across its entire linear sweep, while `get_cloned` holds the read lock
77/// across the clone (readers are unaffected by the latter, but writers block). Never
78/// call a one-shot convenience method (or a nested `read`/`write`) while the calling
79/// thread already holds a read/write guard from the same `SyncRegion` — the one-shots
80/// lock internally and the nested acquisition deadlocks (`std`'s `RwLock` is not reentrant;
81/// even read-after-read can block behind a queued writer, since the platform's priority
82/// policy is unspecified).
83///
84/// ## Contended reads
85///
86/// Under multi-threaded read contention, the one-shot convenience methods
87/// ([`get_cloned`](Self::get_cloned), [`contains`](Self::contains),
88/// [`len`](Self::len), [`is_empty`](Self::is_empty)) anti-scale: each call pays a
89/// shared-cache-line lock acquisition that dominates the nanosecond-scale lookup.
90/// Historical measurement (harness: `examples/contended_reads.rs`, regime: 8
91/// readers, one-shot vs. batched reads on a noisy dev host) showed a ~4×
92/// aggregate throughput loss at 8 readers and ~30× speedup from batching. A
93/// later, more rigorous gate (`docs/perf/R828_STRUCTURAL_LEVERS_GATE.md` §2)
94/// measured the same question with a different harness (`r828_batch_guard_probe.rs`)
95/// and found **9.15×** — explicitly recorded as an open discrepancy, not silently
96/// reconciled. The numbers above are retained for historical context; do not
97/// treat them as a stable measurement of batching's benefit without consulting
98/// the R828 gate's analysis.
99///
100/// ## Async runtimes
101///
102/// `SyncRegion` uses blocking `std::sync::RwLock`, which is not async-aware.
103/// In an async context (e.g. `tokio`), this has concrete hazards:
104///
105/// - **Holding a guard across `.await` blocks the executor worker** for the
106///   entire await duration, not just the critical section. The worker cannot
107///   schedule other tasks while blocked.
108///
109/// - **One-shot methods (`get_cloned`, `insert`, `remove`, …) synchronously
110///   block the OS thread** — they are not "async-safe just because they're
111///   fast." Contention can still stall an executor worker.
112///
113/// - **`tokio::time::timeout` does NOT cancel a blocking lock acquisition.**
114///   The timeout fires after the operation completes (or never fires if the
115///   lock is held forever), but the blocking wait itself cannot be
116///   interrupted.
117///
118/// - **`spawn_blocking` does NOT make an already-started operation
119///   cancellation-safe.** Once a blocking call is in flight on a worker thread,
120///   dropping the `JoinHandle` does not abort it.
121///
122/// For async-friendly ownership, use an async lock type such as
123/// `tokio::sync::RwLock` or `async_rwlock` instead of this wrapper.
124/// Guard-batching (see the contended reads section above) is valid only within
125/// a synchronous section — it does not make blocking safe in async code.
126pub struct SyncRegion<T> {
127    inner: RwLock<Region<T>>,
128}
129
130impl<T> From<Region<T>> for SyncRegion<T> {
131    /// Wraps an existing `Region<T>` in a `SyncRegion` for safe concurrent access.
132    ///
133    /// This provides a zero-copy conversion path from single-threaded to
134    /// concurrent usage without invalidating existing handles — all `Handle<T>`
135    /// values from the original `Region` remain valid and resolve correctly in
136    /// the wrapped `SyncRegion`.
137    fn from(region: Region<T>) -> Self {
138        Self {
139            inner: RwLock::new(region),
140        }
141    }
142}
143
144impl<T> SyncRegion<T> {
145    /// Extracts the inner `Region<T>`, consuming this `SyncRegion`.
146    ///
147    /// This is the inverse of `From<Region<T>> for SyncRegion<T>`. It provides
148    /// a zero-cost conversion from concurrent back to single-threaded usage,
149    /// preserving all handles (they remain valid in the extracted `Region<T>`).
150    ///
151    /// If the `RwLock` is poisoned (due to a panic in a writer thread), this
152    /// method recovers the `Region` anyway — the container structure is
153    /// guaranteed intact, and `T`'s invariants are the caller's responsibility
154    /// (see the [poisoning policy](Self#poisoning-policy) for full details).
155    #[must_use]
156    pub fn into_inner(self) -> Region<T> {
157        self.inner
158            .into_inner()
159            .unwrap_or_else(PoisonError::into_inner)
160    }
161}
162
163impl<T> SyncRegion<T> {
164    /// Creates an empty region that allocates nothing until first use.
165    ///
166    /// # Panics
167    ///
168    /// Delegates to [`Region::new`] — see its `# Panics` section for the exact
169    /// conditions (process-wide `region_id` counter exhaustion).
170    #[must_use]
171    pub fn new() -> Self {
172        Self {
173            inner: RwLock::new(Region::new()),
174        }
175    }
176
177    /// Creates an empty region with space pre-reserved for `capacity` entries.
178    ///
179    /// # Panics
180    ///
181    /// Delegates to [`Region::with_capacity`] — see its `# Panics` section for
182    /// the exact conditions (capacity limit, allocation size overflow, and
183    /// process-wide `region_id` counter exhaustion).
184    #[must_use]
185    pub fn with_capacity(capacity: usize) -> Self {
186        Self {
187            inner: RwLock::new(Region::with_capacity(capacity)),
188        }
189    }
190
191    /// Locks for shared read, returning a guard that hands out `&Region<T>`.
192    ///
193    /// Multiple readers may hold the guard concurrently. Recovers from poison
194    /// and clears it (see the [poisoning policy](Self#poisoning-policy)'s
195    /// "Poison is cleared on recovery" note) so a single past writer panic
196    /// does not permanently slow every future access. Returns `std`'s
197    /// own guard type directly — a deliberate, stable API commitment; migrating
198    /// the internal lock implementation in the future would be a breaking change.
199    pub fn read(&self) -> RwLockReadGuard<'_, Region<T>> {
200        match self.inner.read() {
201            Ok(g) => g,
202            Err(p) => {
203                let g = p.into_inner();
204                self.inner.clear_poison();
205                g
206            }
207        }
208    }
209
210    /// Locks for exclusive write, returning a guard that hands out `&mut Region<T>`.
211    ///
212    /// Blocks all other readers and writers until dropped. Recovers from poison
213    /// and clears it (see the [poisoning policy](Self#poisoning-policy)'s
214    /// "Poison is cleared on recovery" note) so a single past writer panic
215    /// does not permanently slow every future access. Returns `std`'s
216    /// own guard type directly — a deliberate, stable API commitment; migrating
217    /// the internal lock implementation in the future would be a breaking change.
218    pub fn write(&self) -> RwLockWriteGuard<'_, Region<T>> {
219        match self.inner.write() {
220            Ok(g) => g,
221            Err(p) => {
222                let g = p.into_inner();
223                self.inner.clear_poison();
224                g
225            }
226        }
227    }
228
229    /// Inserts `value`, returning a fresh handle that resolves to it (I1).
230    ///
231    /// One-shot convenience that locks for write internally. For a transaction
232    /// that does several ops under one lock, use [`write`](Self::write) instead.
233    ///
234    /// # Panics
235    ///
236    /// Panics if the backing `slotmap` is full (2^32 - 2 live entries).
237    #[must_use]
238    pub fn insert(&self, value: T) -> Handle<T> {
239        self.write().insert(value)
240    }
241
242    /// Removes and returns the value for `handle`, or `None` if stale/removed.
243    ///
244    /// One-shot convenience that locks for write internally. The write guard is
245    /// released before the removed value is dropped by the caller, so a reentrant
246    /// `Drop` on the removed value is safe against the deadlock class described
247    /// in the [reentrancy section](Self#reentrancy).
248    pub fn remove(&self, handle: Handle<T>) -> Option<T> {
249        self.write().remove(handle)
250    }
251
252    /// Whether `handle` currently resolves to a live value.
253    ///
254    /// One-shot convenience that locks for read internally. Note that under
255    /// concurrency a `true` result may be stale by the time the caller acts on it;
256    /// acting on a stale handle can only ever produce `None` at the point of use,
257    /// never resolve to a wrong live value within roughly `2^31` reuse cycles of
258    /// that slot. Callers who need an atomic check-then-act use [`write`](Self::write);
259    /// callers who only need an atomic check-then-**read** can use the cheaper
260    /// [`read`](Self::read) instead — either way, one held guard instead of two
261    /// separate lock acquisitions.
262    #[must_use]
263    pub fn contains(&self, handle: Handle<T>) -> bool {
264        self.read().contains(handle)
265    }
266
267    /// Number of live values (I4).
268    ///
269    /// One-shot convenience that locks for read internally. Note that under
270    /// concurrency the count is a momentary snapshot, not a stable property.
271    #[must_use]
272    pub fn len(&self) -> usize {
273        self.read().len()
274    }
275
276    /// Whether the region holds no live values (I4).
277    ///
278    /// One-shot convenience that locks for read internally. Note that under
279    /// concurrency this is a momentary snapshot, not a stable property.
280    #[must_use]
281    pub fn is_empty(&self) -> bool {
282        self.read().is_empty()
283    }
284
285    /// Removes every value, invalidating all outstanding handles.
286    ///
287    /// One-shot convenience that locks for write internally.
288    /// The partial-clear-under-panic contract is [`Region::clear`]'s — see there.
289    ///
290    /// # Reentrancy hazard
291    ///
292    /// If `T::Drop` attempts to acquire a read or write lock on the same `SyncRegion`,
293    /// a deadlock will occur (the lock is already held for write). Prefer extracting
294    /// values to a temporary container and dropping them after the lock is released.
295    pub fn clear(&self) {
296        self.write().clear();
297    }
298
299    /// Clones the value for `handle` out without leaving the caller holding a guard,
300    /// or `None` if stale/removed. One-shot convenience that locks for read internally.
301    ///
302    /// Prefer this over [`read`](Self::read) when you only need a by-value copy
303    /// and don't want to hold the guard across other work. Note that the `T::clone`
304    /// call itself runs under the read lock (this is unavoidable due to borrowing
305    /// semantics), so for expensive-Clone payloads every call extends the lock hold
306    /// by the full clone duration and delays any writer arriving during that window
307    /// by up to that much (measured ~1.5–1.8 ms worst-case writer stall for a 4 MiB
308    /// payload). For such payloads, store `Arc<T>` instead so the "clone" is a cheap
309    /// refcount bump.
310    ///
311    /// See the [reentrancy section](Self#reentrancy) for the deadlock hazard
312    /// when `T::clone` re-enters the same `SyncRegion`.
313    pub fn get_cloned(&self, handle: Handle<T>) -> Option<T>
314    where
315        T: Clone,
316    {
317        self.read().get(handle).cloned()
318    }
319}
320
321impl<T> From<SyncRegion<T>> for Region<T> {
322    fn from(sr: SyncRegion<T>) -> Self {
323        sr.into_inner()
324    }
325}
326
327impl<T> Default for SyncRegion<T> {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333impl<T> std::fmt::Debug for SyncRegion<T> {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        // Pattern borrowed from std::sync::RwLock's own Debug impl: try_read()
336        // first, then discriminate *why* it failed. `try_read()` returns
337        // `Err` for two structurally different reasons that must not be
338        // collapsed into the same placeholder:
339        //   - `WouldBlock`: another thread genuinely holds the lock right
340        //     now. There is no data to show — "<locked>" is the only honest
341        //     answer.
342        //   - `Poisoned(_)`: a writer panicked while holding the write guard,
343        //     but the lock itself is free right now (poisoning does not hold
344        //     the lock). The container is intact and usable (see the
345        //     poisoning-policy doc above), so this branch recovers the data
346        //     via `into_inner()` and reports `poisoned: true` — matching how
347        //     `std::sync::RwLock`'s own `Debug` renders a poisoned lock
348        //     (`RwLock { data: .., poisoned: true, .. }`), verified by direct
349        //     comparison against `std`'s actual output.
350        match self.inner.try_read() {
351            Ok(guard) => f.debug_struct("SyncRegion").field("inner", &guard).finish(),
352            Err(TryLockError::Poisoned(e)) => f
353                .debug_struct("SyncRegion")
354                .field("inner", &*e.into_inner())
355                .field("poisoned", &true)
356                .finish(),
357            Err(TryLockError::WouldBlock) => f
358                .debug_struct("SyncRegion")
359                .field("inner", &"<locked>")
360                .finish(),
361        }
362    }
363}