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}