Skip to main content

epics_base_rs/server/database/
record_lock.rs

1//! Database-level record locking — the Rust counterpart of the
2//! C-EPICS `dbScanLock` / `dbScanLockMany` machinery and pvxs's
3//! `ioc::DBManyLock` / `ioc::DBManyLocker`.
4//!
5//! C EPICS / pvxs background
6//! -------------------------
7//! Every `dbPutField` / `dbProcess` in C EPICS takes the mutex of the
8//! target record's *lock set* via `dbScanLock(precord)`: `dbLock.c:187`
9//! reads `precord->lset`, and `:192`/`:196` resolve it to that record's
10//! current `lockSet` and lock the single `ls->lock` that `makeSet` gave
11//! the set (`:86`). There is no `dbCommon::lock` member, and
12//! `precord->mlok` is a different mutex — `dbEvent.c:123-124`'s
13//! `LOCKREC`/`UNLOCKREC` over the record's event list, created at
14//! `iocInit.c:516` (the review's `R7.0.10` pin; this machine's checkout
15//! carries one extra line at `:188`, so the same statement reads `:517`
16//! against the working tree). A multi-record transaction — a QSRV *atomic group*
17//! operation, or a pvalink *atomic* scan-on-update set — must apply,
18//! read or scan several records as one indivisible unit, so pvxs
19//! builds a `DBManyLock` over every member record and holds a
20//! `DBManyLocker` across the whole member loop:
21//!
22//! * `epics-base/modules/database/src/ioc/db/dbLock.c:349` —
23//!   `dbLockerAlloc` builds a locker over a fixed record set.
24//! * `epics-base/modules/database/src/ioc/db/dbLock.c:384` —
25//!   `dbScanLockMany` sorts the lock sets and acquires every one,
26//!   skipping duplicates.
27//! * `pvxs/ioc/groupconfigprocessor.cpp:1165` `initialiseDbLocker` /
28//!   `pvxs/ioc/groupsource.cpp:492,621` — atomic group GET/PUT.
29//! * `pvxs/ioc/pvalink_channel.cpp:409,423` — `DBManyLock` /
30//!   `DBManyLocker` over the atomic pvalink scan-target records.
31//!
32//! `DBManyLock` locks the member records in a deadlock-free canonical
33//! order (`dbLock.c` sorts the lock set), and because those are the
34//! *same* mutexes a plain `dbPutField` takes, a direct CA/PVA write
35//! to a backing member record cannot interleave with the transaction.
36//!
37//! Rust port
38//! ---------
39//! `epics-base-rs` stores each record in a `RecordCell`, and the ONLY lock
40//! over its data is the record's lock set: `RecordCell::read` / `write` take
41//! the set (recursively, as `dbScanLock` does) and hand out a `RefCell`-style
42//! borrow that panics on same-thread aliasing. There is no second lock on the
43//! data — the `parking_lot::RwLock<RecordInstance>` an earlier port kept
44//! under the set cost a second acquisition on every field access and could
45//! wedge a thread that re-entered it while the set already serialised the
46//! access.
47//!
48//! This module is C's **lock sets**: one mutex per connected component of
49//! the DB-link graph, with the record-to-set map C keeps in
50//! `dbCommon::lset`. That cell is [`LockRecord`], and a record is born with
51//! one pointing at the **bootstrap set** ([`bootstrap_set`], id 0, never on
52//! the active or free list) — C's null `lset` made lockable — until the
53//! registry adopts the cell at registration ([`Registry::adopt`]) and moves
54//! it onto a set of its own ([`Registry::mint_for`]). After `iocInit` the
55//! two happen together: a record the registry adopts once
56//! [`PvDatabase::build_lock_sets`] has run is minted a set before it is
57//! published, so no registered record is ever on the bootstrap set then and
58//! no set-taker after `iocInit` needs to reach for id 0.
59//!
60//! * A plain CA/PVA write (`put_record_field_from_ca`, `put_pv`,
61//!   `process_record`) takes the target record's set for the duration of the
62//!   write via [`PvDatabase::lock_record`] — `dbScanLock`.
63//! * A multi-record transaction — the QSRV atomic group PUT/GET and the
64//!   pvalink atomic scan-on-update epoch — takes every set its members are
65//!   behind, up front and in set-id order, via [`PvDatabase::lock_records`] —
66//!   `dbScanLockMany`, including its skip of the duplicates that appear when
67//!   several members share one set.
68//!
69//! Because every path resolves through the same registry, a direct
70//! backing-record write blocks until the transaction owning that record
71//! finishes, a QSRV atomic group PUT and a pvalink atomic scan can never
72//! interleave on a shared record, and — this is what the per-record gate could
73//! not do — two records a link joins are serialised against each other exactly
74//! as C serialises them.
75//!
76//! **Rule R — a cell moves only under its set.** A [`LockRecord`] is
77//! re-pointed at another set only by a thread holding the set it currently
78//! points at (see [`Registry::place`]), which is what makes `set(); acquire;
79//! re-check set()` in [`LockRecord::acquire`] a stable hold: once the set is
80//! held, the record cannot leave it. Movers take the sets first — in id
81//! order through [`hold_sorted`] — and the registry mutex under them, and
82//! re-verify [`Registry::revision`] before moving anything.
83//!
84//! A link-field put takes both ends up front — the record and the local
85//! target its new text names — as C's `dbPutFieldLink` does through
86//! `dbScanLockMany`, and the relink runs inside that window
87//! ([`RelinkScope::LinkEdit`]). A change of membership (a record added,
88//! removed, or re-aliased after `iocInit`) relinks over every set with
89//! nothing held on entry ([`RelinkScope::Membership`]).
90//!
91//! ### How a set is built, and what still is not
92//!
93//! One `lockSet` carries a single `epicsMutexId lock` and an `ELLLIST
94//! lockRecordList` of the records behind it (`dbLockPvt.h:29-44`, the list at
95//! `:31`, the mutex at `:32`), and `dbCommon.LSET` is a `lockRecord *` pointing
96//! into that set (`:48`, `:53-70`). Membership is not static: creating a DB
97//! link merges the two records' sets (`dbDbLink.c:110` and `:124`, both
98//! `dbLockSetMerge`) and removing one splits them again (`:141`
99//! `dbLockSetSplit`), so a record and everything its links reach sit behind
100//! ONE mutex.
101//!
102//! [`PvDatabase::build_lock_sets`] reproduces both halves of C's construction
103//! in the order C performs them — `dbLockInitRecords` gives every record its
104//! own set, then one merge per DB link (`iocInit.c:178-179`) — which is why
105//! `dbLockShowLocked` reports `0` and `0` before `iocInit` and why the sets a
106//! merge empties show up on the free list rather than vanishing.
107//!
108//! ### The runtime relink, and who owns it
109//!
110//! **Invariant.** The partition the registry holds MUST equal the connected
111//! components of the DB-link graph over the records the database currently
112//! has. No path may leave a link field written and the partition unchanged.
113//!
114//! **Owner.** [`PvDatabase::relink_lock_sets`] is the only mutator of the
115//! partition after [`PvDatabase::build_lock_sets`], and it is private:
116//! nothing outside this module can call it. The only way to reach it is to
117//! hold a [`LockSetEdit`], whose destructor calls it — so a link-field
118//! write that does not relink is not something a caller can forget to do,
119//! it is something they cannot express. Every exit path of the write body
120//! (`?`, an early `return`, a panic unwind) drops the token and relinks.
121//!
122//! **Why a re-partition and not C's incremental pair.** C calls
123//! `dbLockSetMerge` on link creation and `dbLockSetSplit` on removal
124//! (`dbDbLink.c:110`, `:124`, `:141`), and the split is *itself* a
125//! breadth-first reachability recomputation over the live graph
126//! (`dbLock.c:710-760`) — C only avoids recomputing on the merge side
127//! because it already knows the two endpoints. Keeping the old target on
128//! the port's side to reproduce that pair would mean storing the edge set a
129//! second time, next to the link text that already is the edge set, and a
130//! second copy of a fact is what goes stale. The owner therefore re-derives
131//! the affected component from the live link text and re-partitions it,
132//! which is one rule for creation, removal, retarget, record deletion and
133//! alias changes alike instead of a case per verb.
134//!
135//! Set ids follow C where C fixes them: the component holding the edited
136//! record keeps its id (C's `dbLockSetMerge` keeps `pfirst`'s set, and
137//! `pfirst` is the record whose link moved), a component that splits off
138//! takes a fresh set from the free list as `makeSet` does, and the sets a
139//! merge empties go back on the free list.
140//!
141//! `field_io.rs`'s `NotifyClaim` is unchanged by this: it closes dbNotify's
142//! test-then-install window inside the critical section that tested the slot,
143//! which is a stronger guarantee than the lock-set region it was standing in
144//! for, not a substitute that lock sets now make unnecessary.
145//!
146//! What the gate *is* — a blocking priority-inheritance mutex
147//! ----------------------------------------------------------
148//! The gate is a [`crate::runtime::sync::PriorityInheritanceMutex`], the
149//! same primitive L46 (`registration_mutex`), L8a (`simple_pvs`) and L8b
150//! (one `scan_index` bucket) already use, and [`PvDatabase::lock_record`] /
151//! [`PvDatabase::lock_records`] are plain synchronous `fn`s returning RAII
152//! guards. This is the parity shape rather than a Rust-side invention: the
153//! `ls->lock` C's `dbScanLock` takes is a plain `epicsMutex` (`dbLock.c:86`),
154//! and on
155//! the RTEMS arm
156//! base compiles the POSIX implementation
157//! (`configure/toolchain.c:31-35` selects `OS_API = posix` for
158//! `__RTEMS_MAJOR__ >= 5`; `os/RTEMS-posix/osdMutex.c:8` is one `#include
159//! "../posix/osdMutex.c"`), whose `globalAttrInit`
160//! (`os/posix/osdMutex.c:71-88`) builds every `epicsMutex` with
161//! `PTHREAD_PRIO_INHERIT` — probing it once and silently degrading to
162//! `PTHREAD_PRIO_NONE` if the target refuses. `PriorityInheritanceMutex` is
163//! that same construction on that same API, including the probe.
164//!
165//! ### The band-ordered wait queue is gone, and why that is not a loss
166//!
167//! Until §5 step 4 this gate was an async lock, and between steps 2 and 5 it
168//! was a hand-rolled `PriorityGate` whose waiters were parked in a
169//! `BTreeMap` keyed by the waiter's declared EPICS band, highest band first,
170//! FIFO among equals. That queue existed for exactly one reason: while the
171//! gate was async, both ends of a contention pair were *tasks* parked on a
172//! userspace queue the kernel could not see, so nothing but our own code
173//! could order them. It was the async bridge, not the target design.
174//!
175//! With a blocking PI mutex the waiters are real threads blocked in
176//! `pthread_mutex_lock`, so the *OS* orders the queue — by thread priority,
177//! which on the RTEMS backend is the EPICS band the thread declared through
178//! `enter_ioc_thread` — and additionally boosts a preempted low-band holder
179//! to the highest waiting band. The band-ordered wake order is therefore
180//! replaced by the kernel's PI wait order, which is strictly stronger: it is
181//! what closes handoff §8.0 **gap 4** (priority inheritance), which no
182//! userspace queue could close at all. `PriorityGate`, its `BTreeMap` wait
183//! queue, `GateAcquire` and the `DECLARED_BAND` thread-local that fed it are
184//! deleted with this flip.
185//!
186//! ### Where the ordering actually holds — [`crate::runtime::sync::is_pi_mutex_active`]
187//!
188//! Priority inheritance is a property of the *build and the target*, and the
189//! function above is the single place that answers whether this process got
190//! it:
191//!
192//! * **RTEMS** — PI, and the answer is a *probe* result rather than a `cfg!`,
193//!   matching C's own degrade path (`os/posix/osdMutex.c:77-85`, reported by
194//!   `epicsMutexShowAll` at `:199-205`).
195//! * **Linux with the `linux-rt` Cargo feature** — PI unconditionally.
196//! * **every other build, including a default hosted Linux `cargo test`** —
197//!   `parking_lot::Mutex`, which has **no** priority inheritance and no
198//!   priority ordering. The host suite therefore verifies the *exclusion*
199//!   this module provides, never its ordering; ordering is on-target
200//!   territory.
201//!
202//! Read as a claim about *this* gate: on the host, `lock_record` excludes and
203//! nothing more, and no host test can be written that would catch a lost
204//! inversion. Two further conditions have to hold on target before the
205//! ordering is real, and neither is this module's to enforce — the probe must
206//! have returned `PTHREAD_PRIO_INHERIT`, and the contending threads must
207//! actually carry distinct scheduling priorities, which requires
208//! `RtPolicy::AllowRealtime` in [`crate::runtime::task`]. With the RT switch
209//! off, every thread is one priority and PI has nothing to inherit.
210//!
211//! Acquisition order — MUST
212//! ------------------------
213//! Written down because every lock in the chain is now *blocking* and a
214//! cycle would wedge a thread rather than a task. The order below is the
215//! one the code actually takes, not an aspiration — it was derived by reading
216//! every nesting site, and the bypass audit is in the commit that added it.
217//!
218//! > **A thread MUST acquire these in this order and MUST NOT acquire any of
219//! > them while holding one that appears later:**
220//! >
221//! > 0. **L33** — `epics-bridge-rs`' `GroupPvDef::atomic_write_lock`, the
222//! >    QSRV per-group atomic-PUT gate (`PriorityInheritanceMutex`). Outside
223//! >    this crate, and only the atomic group PUT takes it — see the L33
224//! >    section below.
225//! > 1. **L1** — the per-record advisory gate ([`PvDatabase::lock_record`] /
226//! >    [`PvDatabase::lock_records`]), *this* module
227//! >    (`PriorityInheritanceMutex`).
228//! > 2. **L46** — `PvDatabaseInner::registration_mutex`
229//! >    (`PriorityInheritanceMutex`).
230//! > 3. the leaves, none of which is ever held while another lock is taken:
231//! >    **L8a** `simple_pvs`, **L8b** one `scan_index` bucket and **L7**
232//! >    `ProcessVariable::subscribers` (all `PriorityInheritanceMutex`), plus
233//! >    the `records` map and `aliases` (each a `RecursiveReadLock`, whose
234//! >    readers never queue behind a waiting writer, so a reader under L1
235//! >    cannot be wedged by a writer that is itself waiting for L1; and
236//! >    whose readers never take L1 fresh under the guard, so a writer
237//! >    under L1 — `remove_record_entry` — cannot be wedged by a reader).
238//! >    A record's data has no lock of its own: it is behind L1.
239//! >
240//! > Every rung is a blocking lock. There is no async lock left anywhere on
241//! > the put/process path, which is what makes the order a MUST rather than a
242//! > preference: a cycle wedges a thread.
243//!
244//! [`RecordLockRegistry`]'s own mutex (a `std::sync::Mutex`) is *not* a rung of
245//! that order: it is a leaf taken UNDER the sets — a mover holds its region
246//! first and consults or edits the registry inside — and no lock set is ever
247//! acquired while it is held. A guard's release path deliberately does not
248//! touch it — everything a release needs lives in the set's own cell.
249//!
250//! **Owner/Gate:** [`PvDatabase::update_scan_index`] and
251//! `PvDatabase::remove_record` are the **only** production functions that
252//! take L46 from inside an L1-held window, and each does it the same way:
253//! the record is looked up, its set taken, the gate taken, and the map entry
254//! re-verified by `Arc::ptr_eq` under the gate, retrying if it changed. Every
255//! other L46 holder (`add_pv`, `add_pv_with_hooks_full`, `remove_simple_pv`,
256//! `add_loaded_record`, `add_alias`, `add_breaktables`) is a registration
257//! entry point reached from `.db` load, iocsh or the gateway, never from
258//! inside a put/process cycle, and reads no record data under the gate
259//! (`add_breaktables` drops it before installing its tables) — verified with
260//! `rg` over those symbols in `field_io.rs`, `processing.rs`, `links.rs`,
261//! `qsrv/group.rs` and `pvalink/integration.rs`, where every hit is inside a
262//! `#[cfg(test)]` module. `LockSet::acquire` asserts the rule in debug
263//! builds: a fresh L1 acquisition on a thread holding L46 panics naming both
264//! ends. A third function that nests L46 under L1 follows the same shape, or
265//! the order above stops being checkable by reading one function.
266//!
267//! The table orders the rungs against each other; it does not say what
268//! happens when one rung is taken twice. For L46 that matters, because
269//! `update_scan_index` takes L46 itself: **no caller may hold L46 when
270//! reaching it.** `PriorityInheritanceMutex` is not reentrant, so a caller
271//! that does parks on itself, and the symptom is a hung registration rather
272//! than an error. Every L46 acquisition therefore goes through
273//! [`PvDatabase::lock_registration`], which knows whether this thread already
274//! holds the gate and panics naming both ends instead of parking. The rule is
275//! C's too: `iterateRecords` (`iocInit.c:562-586`) walks an already-built
276//! database in a separate pass, holding no registration lock.
277//!
278//! **L1 does NOT have that rule: it recurses, as C's does.** That is forced by
279//! lock sets rather than chosen. Once a set spans every record a DB link
280//! reaches, processing a record and then following its `FLNK` takes ONE mutex
281//! under two different record names, so a non-reentrant L1 would wedge the
282//! ordinary process path. C is under the same constraint and answers it the
283//! same way: `epicsMutex` must be recursive — the header states the contract
284//! in prose, not code, at `epicsMutex.h:16` "An epicsMutex may be claimed
285//! recursively" and `:38` "MUST implement recursive locking" — and
286//! `dbLock.c:224-234` counts the nesting under `LOCKSET_DEBUG`.
287//!
288//! `PriorityInheritanceMutex` is not reentrant, so the recursion is built here,
289//! on top of it: each set records the thread inside it and how deep, the first
290//! acquisition takes the mutex and the rest only raise the count, and the mutex
291//! is released when the count returns to zero. The owner field is written only
292//! by the owning thread, so a non-owner can never match it. What this replaced
293//! — a thread-local held-name set that PANICKED on re-entry — was the right
294//! guard while a gate was one record, and is exactly wrong once a gate is a
295//! component: it would have fired on the first `FLNK`.
296//!
297//! `dbScanLockMany`'s own refusal (`cantProceed("dbScanLockMany(%p) already
298//! locked.  Recursive locking not allowed")`, `dbLock.c:392-395`) is about
299//! re-using ONE `dbLocker` object, not about a thread holding two. Every
300//! [`PvDatabase::lock_records`] call builds its own, so there is nothing here
301//! to refuse.
302//!
303//! ### The rule's teeth are structural, and now they cover L1 too
304//!
305//! Every guard in the list above is `!Send`. A `!Send` value held across an
306//! `.await` makes the enclosing future `!Send`, which the compiler rejects at
307//! every `tokio::spawn` / `runtime::task::spawn` site in this workspace — so
308//! "no suspension point inside a gate window" is a build error rather than a
309//! review convention. That is the structural guarantee holders H1–H9 were
310//! staged to make reachable: each holder
311//! was first rewritten so its gate-held region contained zero `.await`s, and
312//! only then did the gate become a type that refuses to be held across one.
313//!
314//! `!Send`ness is deliberate on both arms of [`crate::runtime::sync::PriorityInheritanceMutex`],
315//! and on the PI arm it is also a correctness requirement, not only a
316//! lint: POSIX requires a mutex to be unlocked by the thread that locked it,
317//! so a guard that could migrate between threads would call
318//! `pthread_mutex_unlock` from a non-owner.
319//!
320//! The compiler only *reports* it at a spawn site, though, so the standing
321//! check is a direct one — for every binding of a gate guard, read forward to
322//! the end of its drop scope and find no `.await`:
323//!
324//! ```text
325//! rg -n 'let (mut )?\w+ = .*\.(lock_record|lock_records|acquire_put_gate)\(' crates/
326//! ```
327//!
328//! ### L33 — the QSRV atomic-PUT group lock, relative to L1
329//!
330//! `epics-bridge-rs`' `GroupPvDef::atomic_write_lock` (`qsrv/group_config.rs`)
331//! is a group-vs-group serialization aid: it lives in a different crate and
332//! has no nesting relationship with L46/L8a/L8b, but it *is* held across L1
333//! and so occupies rung 0 of the order above. It is acquired in
334//! `GroupChannel::put`'s atomic branch **before** [`PvDatabase::lock_records`]
335//! — first so a conversion failure in the up-front value-conversion phase
336//! aborts the whole atomic PUT before any member-record gate is even
337//! requested, second so two atomic PUTs to the *same* group serialize before
338//! either reaches L1 at all.
339//!
340//! It is a `PriorityInheritanceMutex`. It was a `tokio::sync::Mutex` for
341//! exactly as long as L1 was async: its window contains `lock_records`, which
342//! used to be a genuine suspension point, and a `!Send` guard across that
343//! await would not compile at the connection-task spawn site. That window is
344//! now the conversion phase, a synchronous `lock_records`, and a synchronous
345//! member loop — zero `.await`s — so the reason to keep it async is gone.
346
347// No RTEMS-EXEC-MODEL-ALLOW marker: this file's tests are all plain `#[test]`s
348// now that the gate is a blocking lock (a contender has to be a real thread),
349// so none of them needs a reactor and there is nothing to account for.
350
351use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
352use std::sync::Arc;
353use std::sync::atomic::{AtomicPtr, AtomicU64, AtomicUsize, Ordering};
354
355use crate::server::record::RecordCell;
356
357use crate::runtime::sync::{
358    MutexInfo, PriorityInheritanceMutex, PriorityInheritanceMutexGuard, mutex_report,
359};
360
361use super::PvDatabase;
362
363/// C's `lockSet` (`dbLockPvt.h:29-44`): the one mutex every member record
364/// locks through, plus the bookkeeping the guards need without the registry.
365///
366/// The membership list is NOT here — a merge rewrites it, and it lives in
367/// [`Registry`] behind the registry mutex. What a guard needs at release time
368/// is here instead, so releasing never takes the registry lock: doing that
369/// while holding the set would close a cycle against [`Registry::real_set_of`],
370/// which takes the registry lock and then the set.
371struct LockSet {
372    /// C's `lockSet::id` (`dbLockPvt.h:33`). Assigned once by
373    /// [`Registry::make_set`] and kept for the cell's whole life, free-list
374    /// round trips included, exactly as C's is.
375    id: u64,
376    /// C's `lockSet::lock` (`:32`) — the mutex `dbScanLock` takes.
377    lock: PriorityInheritanceMutex<()>,
378    /// Position of this set's mutex among this file's entries in the process
379    /// mutex list, so [`lock_set_mutex_rows`] can hand back the row
380    /// `epicsMutexShow` prints for it. See [`SET_MUTEX_SEQ`].
381    mutex_seq: u64,
382    /// The thread currently inside `lock`, or 0. Written only by that thread.
383    owner: AtomicU64,
384    /// How deep that thread's recursion is. C's `epicsMutex` is recursive by
385    /// contract (`epicsMutex.h:16`, `:38`) and `dbLock.c:224-234` counts the
386    /// same nesting under `LOCKSET_DEBUG`.
387    depth: AtomicUsize,
388    /// References ABOVE the one-per-member baseline. `dbScanLockMany` adds one
389    /// per set for its locked list (`dbLock.c:404`) and `dbScanLock` does not
390    /// — it drops its transient reference the moment it holds the mutex
391    /// (`:220-222`) — which is why an idle set reports exactly as many refs as
392    /// it has members, as the oracle capture in `iocsh`'s `dblsr` shows.
393    many_holds: AtomicUsize,
394    /// The mutex guard of the current outermost hold, kept here rather than
395    /// in that hold's [`SetGuard`] so that a frame which does not own the
396    /// guard can still give the set up and take it back — [`Self::unheld`],
397    /// C's `dbScanUnlock` from inside `dbBkpt` (`dbBkpt.c:795`). Touched only
398    /// by the thread named in `owner`, under the mutex itself.
399    held: std::cell::UnsafeCell<Option<PriorityInheritanceMutexGuard<'static, ()>>>,
400}
401
402// SAFETY: `held` is the one non-`Sync` field, and it is read or written only
403// by the thread that holds `lock` (`acquire`'s fresh arm, `SetGuard::drop`'s
404// outermost arm, and `unheld`, each of which checks or establishes
405// `owner == thread_key()` first). The guard never leaves that thread: the
406// same thread that stashes it takes it out, so the `!Send` guard's
407// unlock-by-owner rule holds.
408unsafe impl Sync for LockSet {}
409
410/// The `'static` handle to a lock set. Sets are leaked: a guard hands out a
411/// `'static` borrow of the mutex, and a set outlives every record it holds —
412/// C keeps its emptied sets on `lockSetsFree` for the same reason and frees
413/// them only in `dbLockCleanupRecords` (`dbLock.c:563-576`).
414type Set = &'static LockSet;
415
416/// C's `lockRecord` (`dbLockPvt.h:52-60`) — the cell `dbCommon::lset` points
417/// at, and the ONLY way a record reaches its lock set.
418///
419/// C's `dbScanLock` is `precord->lset` → `lr->plockSet` → `lock`: two pointer
420/// derefs, no name anywhere. The port had the same association spelled as a
421/// name-keyed map, so taking a record's gate cost an alias lookup plus two
422/// hashes of the record name under the registry mutex — 0.85 us of a 11.5 us
423/// calc cycle, measured. A record that owns its cell pays neither.
424///
425/// `plock_set` is the association C guards with the `lockRecord`'s spinlock
426/// (`dbLockPvt.h:53-57`): written only by [`Registry`], which holds the
427/// registry mutex, and read with no lock at all, exactly as C reads it under
428/// either lock. A stale read is not a hazard — it is what the re-check loop in
429/// [`Self::acquire_fresh`] exists to catch.
430pub(crate) struct LockRecord {
431    plock_set: AtomicPtr<LockSet>,
432}
433
434impl LockRecord {
435    fn new(set: Set) -> Arc<Self> {
436        Arc::new(Self {
437            plock_set: AtomicPtr::new(Self::as_ptr(set)),
438        })
439    }
440
441    /// The cell a record is born with — C's null `lset` before
442    /// `dbLockInitRecords`, spelled as a pointer at the one shared
443    /// [`bootstrap_set`] so that the record's data is guarded from its first
444    /// access, before the registry has given it a set of its own.
445    pub(crate) fn bootstrap() -> Arc<Self> {
446        Self::new(bootstrap_set())
447    }
448
449    /// Whether this record still locks through the bootstrap set, which is
450    /// what `dblsr` reports as "no lock set" (`dbLock.c:900-901`).
451    pub(crate) fn is_bootstrap(&self) -> bool {
452        std::ptr::eq(self.set(), bootstrap_set())
453    }
454
455    /// C `dbScanLock`'s body (`dbLock.c:184-213`) — take the set the cell
456    /// names, then check the cell still names it.
457    ///
458    /// The re-check is not optional: a merge running concurrently moves the
459    /// record behind another mutex, and the one just taken would guard
460    /// nothing. C compares `lockSet` pointers under the record's spinlock;
461    /// the cell's store is `Release` and the load `Acquire`, which is the same
462    /// publication. The mover holds the set it moves records OUT of (see
463    /// [`Registry::place`]), so a thread that took the old set and lost the
464    /// re-check was never inside the record's data — it merely waited.
465    #[inline]
466    pub(crate) fn acquire(&self) -> SetGuard {
467        let set = self.set();
468        if set.owner.load(Ordering::Acquire) == thread_key() {
469            // This thread holds the set, so by Rule R the record cannot leave
470            // it: no re-check, and nothing but the depth counter to touch.
471            // This is every `rec.read()` / `rec.write()` inside a process
472            // cycle, which is why it is inlined and the rest is not.
473            return set.reenter(false);
474        }
475        self.acquire_fresh()
476    }
477
478    /// The set was not held: take whatever set the record is behind by the
479    /// time the mutex is ours — a merge can move the record while this
480    /// thread waits, and the set it then holds would be the wrong one.
481    #[cold]
482    #[inline(never)]
483    fn acquire_fresh(&self) -> SetGuard {
484        loop {
485            let set = self.set();
486            let guard = set.acquire(false);
487            if std::ptr::eq(self.set(), set) {
488                return guard;
489            }
490            drop(guard);
491        }
492    }
493
494    /// See [`LockSet::unheld`]: give up the set this record is in for the
495    /// duration of `f`. This thread must hold that set exactly once.
496    pub(crate) fn unheld<R>(&self, f: impl FnOnce() -> R) -> R {
497        self.set().unheld(f)
498    }
499
500    fn as_ptr(set: Set) -> *mut LockSet {
501        set as *const LockSet as *mut LockSet
502    }
503
504    /// C `lr->plockSet`.
505    fn set(&self) -> Set {
506        let p = self.plock_set.load(Ordering::Acquire);
507        // SAFETY: every pointer stored here came from [`Registry::make_set`],
508        // which leaks its `LockSet`, so the referent is live for `'static`;
509        // a merge only ever moves the set onto `Registry::free`, which keeps
510        // the allocation. The cell is constructed with a set and `store` is
511        // the only other writer, so it is never null.
512        unsafe { &*p }
513    }
514
515    fn store(&self, set: Set) {
516        self.plock_set.store(Self::as_ptr(set), Ordering::Release);
517    }
518}
519
520/// C's `next_id` starts at 1 and `makeSet` uses the POST-increment
521/// (`dbLock.c:70`, `:87`), so C's first lock set is number 2. Matching it
522/// costs nothing and makes an A/B against a C IOC read straight across.
523const FIRST_SET_ID: u64 = 2;
524
525/// Serialises lock-set mutex creation with its sequence counter.
526///
527/// The process mutex list ([`mutex_report`]) has no per-mutex accessor, so a
528/// set finds its own row positionally. That is exact only if the order sets
529/// are appended to the list is the order they take sequence numbers, which
530/// this lock is what guarantees — several `PvDatabase`s in one process each
531/// run their own registry mutex and would otherwise interleave. Nothing is
532/// acquired while it is held.
533static SET_MUTEX_SEQ: std::sync::Mutex<u64> = std::sync::Mutex::new(0);
534
535/// Mint one set. Every `LockSet` in the process comes from here.
536fn new_set(id: u64) -> Set {
537    let mut seq = SET_MUTEX_SEQ.lock().unwrap_or_else(|e| e.into_inner());
538    let mutex_seq = *seq;
539    *seq += 1;
540    // The mutex is created UNDER `SET_MUTEX_SEQ` so its position in the
541    // process mutex list matches `mutex_seq`. This is the only
542    // `PriorityInheritanceMutex::new` in this file, which is what makes
543    // filtering that list by creating file select exactly these mutexes.
544    let set: Set = Box::leak(Box::new(LockSet {
545        id,
546        lock: PriorityInheritanceMutex::new(()),
547        mutex_seq,
548        owner: AtomicU64::new(0),
549        depth: AtomicUsize::new(0),
550        many_holds: AtomicUsize::new(0),
551        held: std::cell::UnsafeCell::new(None),
552    }));
553    drop(seq);
554    set
555}
556
557/// The set every record locks through until a registry gives it one — C's
558/// null `lset` made lockable, so that a record's data is guarded by a lock
559/// set from its first access rather than only from `iocInit` on.
560///
561/// One per process, never in any registry's `active` or `free` list, and
562/// its id `0` is never a set id (`FIRST_SET_ID` is 2). It is created in this
563/// file under [`SET_MUTEX_SEQ`] like every other set, so it holds a row in
564/// the process mutex list and shifts nothing — see [`lock_set_mutex_rows`].
565fn bootstrap_set() -> Set {
566    static BOOTSTRAP: std::sync::OnceLock<Set> = std::sync::OnceLock::new();
567    BOOTSTRAP.get_or_init(|| new_set(0))
568}
569
570/// A process-unique non-zero key for the current thread.
571///
572/// `ThreadId` has no stable integer form on stable Rust, and the value only
573/// has to be comparable and never reused while a thread lives.
574///
575/// The slot is `const`-initialised and holds a `Cell` rather than being
576/// initialised from `NEXT` directly, because that is what makes the read a
577/// plain TLS offset: a `thread_local!` with a runtime initialiser carries a
578/// lazy-init flag and a destructor registration, and goes through
579/// `LocalKey::try_with` on every access. `LockSet::acquire` asks for this key
580/// on every `dbScanLock`, so that check was the single largest cost in taking
581/// a record's lock set. Zero is the "not yet minted" value and never a key,
582/// which is why `NEXT` starts at 1.
583fn thread_key() -> u64 {
584    static NEXT: AtomicU64 = AtomicU64::new(1);
585    thread_local! {
586        static KEY: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
587    }
588    KEY.with(|k| match k.get() {
589        0 => {
590            let minted = NEXT.fetch_add(1, Ordering::Relaxed);
591            k.set(minted);
592            minted
593        }
594        key => key,
595    })
596}
597
598impl LockSet {
599    /// C `dbScanLock` (`dbLock.c:180-234`) minus the reference dance: take the
600    /// set's mutex, or count one more level if this thread is already inside
601    /// it.
602    ///
603    /// Recursion is not a convenience. Once a lock set spans every record a
604    /// DB link reaches, processing a record and then its `FLNK` target takes
605    /// the SAME mutex twice on one thread, which is precisely why C's
606    /// `epicsMutex` is required to be recursive.
607    ///
608    /// The re-entry is the hot path: every `rec.read()` / `rec.write()` on
609    /// the process path lands here with the set already held, so it is one
610    /// TLS load, one compare and a plain counter — no read-modify-write.
611    /// `depth` is written only by the owning thread, which is what makes a
612    /// load-then-store correct.
613    fn acquire(&'static self, many: bool) -> SetGuard {
614        let me = thread_key();
615        if self.owner.load(Ordering::Acquire) == me {
616            return self.reenter(many);
617        }
618        self.lock_fresh(me);
619        if many {
620            self.many_holds.fetch_add(1, Ordering::Relaxed);
621        }
622        SetGuard {
623            set: self,
624            outermost: true,
625            many,
626        }
627    }
628
629    /// One more level on a thread that already holds the set. The caller
630    /// has checked `owner`.
631    #[inline]
632    fn reenter(&'static self, many: bool) -> SetGuard {
633        self.depth
634            .store(self.depth.load(Ordering::Relaxed) + 1, Ordering::Relaxed);
635        if many {
636            self.many_holds.fetch_add(1, Ordering::Relaxed);
637        }
638        SetGuard {
639            set: self,
640            outermost: false,
641            many,
642        }
643    }
644
645    /// Take the mutex on a thread that does not hold it and publish the hold.
646    fn lock_fresh(&'static self, me: u64) {
647        // A fresh acquisition is rung L1 of the order in the module doc. L46
648        // sits below it, so a thread that holds the registration gate and
649        // reaches here is closing the cycle `update_scan_index` opens from
650        // the other side. Fail here, on the thread that made the mistake,
651        // rather than wedge two threads later.
652        debug_assert!(
653            !super::registration_gate_held(),
654            "a lock set (L1) was taken while this thread holds the registration \
655             gate (L46); record data is behind L1 and must be read before the \
656             gate is taken, or through a set this thread already holds"
657        );
658        // The records map and the alias table are leaves too, and
659        // `remove_record_entry` writes them while holding the removed
660        // record's set: a fresh set taken under a read guard on either is
661        // the reader that writer can be waiting on. Clone the cell out, drop
662        // the guard, then lock (`RecursiveReadLock`, `mod.rs`).
663        debug_assert!(
664            !super::map_read_held(),
665            "a lock set (L1) was taken while this thread holds a read guard on \
666             the records map or the alias table; clone the record handle out \
667             under the guard and drop it before locking the record"
668        );
669        let guard = self.lock.lock();
670        // SAFETY: this thread holds `lock`, so it is the only one allowed at
671        // `held` (see the `Sync` impl).
672        unsafe { *self.held.get() = Some(guard) };
673        self.owner.store(me, Ordering::Release);
674        self.depth.store(1, Ordering::Relaxed);
675    }
676
677    /// Release the mutex this thread holds and clear the hold. Only the
678    /// owner, at depth 1, may call this.
679    fn unlock_outermost(&'static self) {
680        // Clear ownership BEFORE the mutex is released, or the next owner
681        // could publish itself and be overwritten by this store.
682        self.depth.store(0, Ordering::Relaxed);
683        self.owner.store(0, Ordering::Release);
684        // SAFETY: as in `lock_fresh` — the mutex is still held here.
685        let guard = unsafe { (*self.held.get()).take() };
686        drop(guard);
687    }
688
689    /// Run `f` with this set given up, then take it back — C's
690    /// `dbScanUnlock(precord); epicsThreadSuspendSelf(); dbScanLock(precord);`
691    /// (`dbBkpt.c:794-796`), the breakpoint park.
692    ///
693    /// The set is released from a frame that does not own the outermost
694    /// [`SetGuard`] (a `FLNK` target's hook parks under the entry record's
695    /// gate), which is why the guard lives in [`Self::held`] and not in that
696    /// `SetGuard`. The same set is retaken so the outer guard's release
697    /// stays paired with its acquisition, whatever moved meanwhile: a record
698    /// re-linked out of this set while it was given up is guarded by its new
699    /// set on every later access, because every access goes through
700    /// [`LockRecord::acquire`] and not through the outer gate.
701    ///
702    /// # Panics
703    ///
704    /// If this thread does not hold the set, or holds it more than once —
705    /// a nested hold means a record borrow is live, and nothing may borrow
706    /// record data across a park.
707    fn unheld<R>(&'static self, f: impl FnOnce() -> R) -> R {
708        let me = thread_key();
709        assert_eq!(
710            self.owner.load(Ordering::Acquire),
711            me,
712            "a lock set was given up by a thread that does not hold it"
713        );
714        assert_eq!(
715            self.depth.load(Ordering::Relaxed),
716            1,
717            "a lock set was given up with a nested hold live"
718        );
719        assert_eq!(
720            self.many_holds.load(Ordering::Relaxed),
721            0,
722            "a lock set was given up from inside a many-lock transaction"
723        );
724        self.unlock_outermost();
725        let r = f();
726        self.lock_fresh(me);
727        r
728    }
729
730    /// C's `epicsMutexTryLock` probe in `dbLockShowLocked` (`dbLock.c:963-965`).
731    fn is_locked(&self) -> bool {
732        self.lock.try_lock().is_none()
733    }
734}
735
736/// One acquisition of one lock set — C's `dbScanLock`/`dbScanUnlock` pair.
737///
738/// `!Send` on both backends, because the inner guard is: an `Option` is `Send`
739/// only when its payload is, so the recursive re-entry that carries `None` is
740/// `!Send` too.
741pub(crate) struct SetGuard {
742    set: Set,
743    /// `false` for a recursive re-entry, which acquired no new mutex. The
744    /// outermost hold's mutex guard is in [`LockSet::held`].
745    outermost: bool,
746    many: bool,
747}
748
749impl SetGuard {
750    /// Whether `record` is in the set this guard holds — C `dbLockGetLockId`
751    /// equality. Rule R makes the answer stable for the guard's lifetime:
752    /// a record cannot leave a set while a thread holds it.
753    #[inline]
754    pub(crate) fn holds(&self, record: &LockRecord) -> bool {
755        std::ptr::eq(record.set(), self.set)
756    }
757}
758
759impl Drop for SetGuard {
760    fn drop(&mut self) {
761        if self.many {
762            self.set.many_holds.fetch_sub(1, Ordering::Relaxed);
763        }
764        if self.outermost {
765            self.set.unlock_outermost();
766        } else {
767            self.set.depth.store(
768                self.set.depth.load(Ordering::Relaxed) - 1,
769                Ordering::Relaxed,
770            );
771        }
772    }
773}
774
775/// Hold every set in `sets`, each once, in id order — the acquisition
776/// discipline of `dbScanLockMany` (`dbLock.c:384-440`), used by every path
777/// that takes more than one set so that two such paths cannot deadlock.
778fn hold_sorted(sets: &[Set]) -> Vec<SetGuard> {
779    let mut sets: Vec<Set> = sets.to_vec();
780    sets.sort_unstable_by_key(|set| set.id);
781    sets.dedup_by_key(|set| set.id);
782    sets.into_iter().map(|set| set.acquire(false)).collect()
783}
784
785/// C's `lockSetsActive` / `lockSetsFree` / `next_id` (`dbLock.c:42-70`) plus
786/// the record-to-set mapping C keeps in `dbCommon::lset`.
787struct Registry {
788    /// C `lockSetsActive`, keyed by set id — which is also C's list ORDER,
789    /// because ids ascend with creation and a merge only ever removes an
790    /// entry. `dblsr` and `dbLockShowLocked` walk this list in that order.
791    active: BTreeMap<u64, SetState>,
792    /// C `lockSetsFree` (`:44`): sets a merge emptied. Their id and mutex
793    /// survive and are handed back by the next [`Registry::make_set`], which
794    /// is why C's free count is not simply "sets that ever existed minus live
795    /// ones". `ellGet` takes the head, so this is a queue.
796    free: VecDeque<Set>,
797    /// C's `dbCommon::lset` by canonical record name — the port's answer for
798    /// the callers that have only a name: [`PvDatabase::lock_records`] accepts
799    /// names that were never records, and the reports below print by name.
800    ///
801    /// The [`LockRecord`] here is the SAME cell the record instance holds, so
802    /// there is one association and not two: everything that moves a record
803    /// between sets moves it by storing into this cell.
804    of_record: HashMap<String, Arc<LockRecord>>,
805    next_id: u64,
806    /// Bumped by every change to the partition — a set minted, a cell moved,
807    /// an entry dropped. A mover snapshots it, takes the sets it means to
808    /// move records out of, and re-reads it under the registry lock: equal
809    /// means the sets it holds are the sets those records are still behind.
810    revision: u64,
811    /// Whether `dbLockInitRecords` has run — [`PvDatabase::build_lock_sets`].
812    /// C reaches `dbLockSetMerge` only from links opened after it
813    /// (`dbDbInitLink`/`dbDbAddLink`), so a link written before it merges
814    /// nothing, even though this port's write gate has already minted the
815    /// two records their sets. The flag is that moment, kept apart from
816    /// "some set exists", which the gate makes true earlier.
817    built: bool,
818}
819
820/// One entry of [`Registry::active`]: the shared cell plus C's
821/// `lockSet::lockRecordList` (`dbLockPvt.h:31`).
822struct SetState {
823    set: Set,
824    members: BTreeSet<String>,
825}
826
827impl Default for Registry {
828    fn default() -> Self {
829        Self {
830            active: BTreeMap::new(),
831            free: VecDeque::new(),
832            of_record: HashMap::new(),
833            next_id: FIRST_SET_ID - 1,
834            revision: 0,
835            built: false,
836        }
837    }
838}
839
840impl Registry {
841    /// C `makeSet` (`dbLock.c:72-101`): reuse a freed set, keeping its id and
842    /// its mutex, or mint one.
843    fn make_set(&mut self) -> Set {
844        if let Some(set) = self.free.pop_front() {
845            debug_assert_eq!(
846                set.many_holds.load(Ordering::Relaxed),
847                0,
848                "C asserts refcount==0 for a set on the free list (dbLock.c:571)"
849            );
850            return set;
851        }
852        self.next_id += 1;
853        self.revision += 1;
854        new_set(self.next_id)
855    }
856
857    /// The cell `name` locks through, or `None` when the name has none —
858    /// including a registered record that is still on the bootstrap set,
859    /// which is C's null `lset` and what `dblsr` reports as no set at all
860    /// (`dbLock.c:900-901`).
861    fn real_set_of(&self, name: &str) -> Option<Set> {
862        self.of_record
863            .get(name)
864            .map(|lr| lr.set())
865            .filter(|set| !std::ptr::eq(*set, bootstrap_set()))
866    }
867
868    /// [`Self::real_set_of`] for a relink after `iocInit`, where a name the
869    /// link graph reaches either has a real set or is no longer registered
870    /// at all — it was removed between the graph read and this call, and
871    /// its cell went with it. A registered name still on the bootstrap set
872    /// is the invariant [`Self::adopt`] keeps, broken.
873    fn set_of_registered(&self, name: &str) -> Option<Set> {
874        let set = self.real_set_of(name);
875        debug_assert!(
876            set.is_some() || !self.of_record.contains_key(name),
877            "registered record {name} is on the bootstrap set after iocInit"
878        );
879        set
880    }
881
882    /// The cell a NAME reaches, minting a one-member set for a name that has
883    /// none — C `createLockRecord` (`dbLock.c:505-527`) for
884    /// [`PvDatabase::lock_records`]' callers, which may name a record that
885    /// never existed (`dbLockerAlloc` accepts the pointers it is given).
886    ///
887    /// Never mints for a registered record: its cell was adopted from the
888    /// record itself ([`Self::adopt`]) and points at the bootstrap set until
889    /// [`Self::mint_for`] moves it, under the hold that move requires.
890    fn lock_record_of(&mut self, name: &str) -> Arc<LockRecord> {
891        if let Some(lr) = self.of_record.get(name) {
892            return lr.clone();
893        }
894        let set = self.make_set();
895        let lr = LockRecord::new(set);
896        self.of_record.insert(name.to_string(), lr.clone());
897        self.active.insert(
898            set.id,
899            SetState {
900                set,
901                members: BTreeSet::from([name.to_string()]),
902            },
903        );
904        lr
905    }
906
907    /// Register the cell a record was born with as the one its name reaches
908    /// — C's `dbCommon::lset`, which `createLockRecord` allocates INTO the
909    /// record so that the record and the registry can never hold two answers
910    /// to "which set". Called before the record is published, so nothing can
911    /// hold it yet and the cell may be re-pointed without a hold.
912    ///
913    /// A name that was many-locked before its record existed already has a
914    /// registry-only cell with a real set; the record's cell takes that set
915    /// over, so the epoch that holds it goes on excluding the record.
916    ///
917    /// After [`PvDatabase::build_lock_sets`] the cell is minted a set here as
918    /// well — `createLockRecord` at the only moment C could not reach it —
919    /// so that a registered record is never on the bootstrap set once the
920    /// IOC is initialised. That is what lets every set-taker after `iocInit`
921    /// ([`Registry::every_set`], [`PvDatabase::relink_lock_sets`]) leave the
922    /// bootstrap set alone: a `LinkEdit` relink runs under a put that already
923    /// holds the seed's set, and reaching for id 0 from there would invert
924    /// the id order a `Membership` relink takes the sets in.
925    fn adopt(&mut self, name: &str, lr: &Arc<LockRecord>) {
926        match self.of_record.get(name) {
927            Some(existing) if Arc::ptr_eq(existing, lr) => {}
928            Some(existing) => {
929                lr.store(existing.set());
930                self.of_record.insert(name.to_string(), lr.clone());
931                self.revision += 1;
932            }
933            None => {
934                self.of_record.insert(name.to_string(), lr.clone());
935                if self.built {
936                    self.mint_for(name, lr);
937                }
938            }
939        }
940    }
941
942    /// Give a bootstrap cell a set of its own — `dbLockInitRecords`'
943    /// `createLockRecord` for one record. **The caller holds the bootstrap
944    /// set, or nothing can hold the cell yet**: the cell is being moved out
945    /// of the bootstrap set, and [`Self::place`]'s rule is that a mover holds
946    /// the set it moves a record out of. [`Self::adopt`] is the one caller
947    /// that holds nothing — it runs before the record is in the records map,
948    /// so no thread can be inside the record's data through the cell.
949    fn mint_for(&mut self, name: &str, lr: &Arc<LockRecord>) {
950        if !lr.is_bootstrap() {
951            return;
952        }
953        let set = self.make_set();
954        lr.store(set);
955        self.active.insert(
956            set.id,
957            SetState {
958                set,
959                members: BTreeSet::from([name.to_string()]),
960            },
961        );
962        self.of_record
963            .entry(name.to_string())
964            .or_insert_with(|| lr.clone());
965    }
966
967    /// Move every member of `component` behind `set` — the ONE way a record
968    /// changes lock set, so the cell a record holds and the `of_record` entry
969    /// a name reaches can never disagree.
970    ///
971    /// **MUST be called holding the set each moved record is currently
972    /// behind.** The set is the record's data lock: a thread inside the
973    /// record's data holds that set, and moving the record out from under it
974    /// would let a holder of the destination in beside it. Holding the source
975    /// is what makes the store safe; the destination needs no hold, because
976    /// nothing is inside a record the mover has just excluded everyone from.
977    /// Every caller — [`Self::merge`], [`Self::repartition`],
978    /// [`Self::mint_for`] — is reached only through a [`PvDatabase`] method
979    /// that took those sets first, in id order, and re-read
980    /// [`Self::revision`] under the registry lock to prove they are still the
981    /// records' sets.
982    fn place(&mut self, names: impl IntoIterator<Item = String>, set: Set) {
983        self.revision += 1;
984        for name in names {
985            match self.of_record.get(&name) {
986                Some(lr) => lr.store(set),
987                None => {
988                    self.of_record.insert(name, LockRecord::new(set));
989                }
990            }
991        }
992    }
993
994    /// C `dbLockSetMerge` (`dbLock.c:580-666`): every record behind
995    /// `second`'s mutex moves behind `first`'s, and the emptied set goes on
996    /// the free list with its id and mutex intact.
997    ///
998    /// The direction matters and is C's: the SOURCE record's set survives
999    /// (`dbDbLink.c:110` passes `plink->precord` first), so which id ends up
1000    /// holding a component depends on link order exactly as in C.
1001    ///
1002    /// Both names have real sets — [`PvDatabase::merge_sets`] gave them one
1003    /// and holds `second`'s, which is the one records move out of.
1004    fn merge(&mut self, first: &str, second: &str) {
1005        let (Some(a), Some(b)) = (self.real_set_of(first), self.real_set_of(second)) else {
1006            return;
1007        };
1008        let (a, b) = (a.id, b.id);
1009        if a == b {
1010            return;
1011        }
1012        let moved = self
1013            .active
1014            .remove(&b)
1015            .expect("every id in of_record names an active set");
1016        let survivor = self.active[&a].set;
1017        self.place(moved.members.iter().cloned(), survivor);
1018        let target = self
1019            .active
1020            .get_mut(&a)
1021            .expect("every id in of_record names an active set");
1022        target.members.extend(moved.members);
1023        self.free.push_back(moved.set);
1024    }
1025
1026    /// **The partition transition itself** — see [`PvDatabase::relink_lock_sets`],
1027    /// which is its only caller.
1028    ///
1029    /// `seed` is the record whose link text just moved. The affected region is
1030    /// the closure of `seed` under two relations at once: "is linked to" and
1031    /// "is currently in the same set as". Closing over both is what makes one
1032    /// rule cover creation, removal and retarget — a merge widens the region
1033    /// through the first relation, a split narrows it through the second, and
1034    /// neither needs to know which one happened.
1035    ///
1036    /// `held` is every set the caller holds, and every set a record of the
1037    /// region is behind MUST be among them — see [`Self::place`]. The seed's
1038    /// own set is real: [`PvDatabase::relink_lock_sets`] minted it before
1039    /// taking the region.
1040    fn repartition(
1041        &mut self,
1042        seed: &str,
1043        adjacency: &HashMap<String, BTreeSet<String>>,
1044        held: &[Set],
1045    ) {
1046        let Some(seed_set) = self.of_record.get(seed).map(|lr| lr.set()) else {
1047            return;
1048        };
1049        let seed_id = seed_set.id;
1050
1051        let mut affected: BTreeSet<String> = BTreeSet::new();
1052        let mut work: Vec<String> = vec![seed.to_string()];
1053        while let Some(name) = work.pop() {
1054            if !affected.insert(name.clone()) {
1055                continue;
1056            }
1057            if let Some(targets) = adjacency.get(&name) {
1058                work.extend(targets.iter().cloned());
1059            }
1060            if let Some(id) = self.real_set_of(&name).map(|set| set.id) {
1061                work.extend(self.active[&id].members.iter().cloned());
1062            }
1063        }
1064
1065        // Every set the region covers. Each is emptied below and either
1066        // re-used for one of the new components or returned to the free list.
1067        let touched: BTreeSet<u64> = affected
1068            .iter()
1069            .filter_map(|name| self.real_set_of(name).map(|set| set.id))
1070            .collect();
1071        debug_assert!(
1072            affected.iter().all(|name| {
1073                self.of_record
1074                    .get(name)
1075                    .is_none_or(|lr| held.iter().any(|set| std::ptr::eq(*set, lr.set())))
1076            }),
1077            "repartition reached a record behind a set the caller does not hold"
1078        );
1079
1080        // A record the database no longer has leaves the partition with it —
1081        // `dbDeleteRecord` frees the `lockRecord` — so it is dropped here
1082        // rather than being carried into a component of one.
1083        //
1084        // An instance still alive behind someone's `Arc` keeps its cell, which
1085        // goes on pointing at the set it had. C's does too, until
1086        // `dbLockCleanupRecords`. The set may later be re-minted off the free
1087        // list for another component, so such a gate can end up sharing a
1088        // mutex with live records — it over-locks a record nothing processes,
1089        // and can never under-lock one, because every LIVE record was placed
1090        // into its component's set above.
1091        for name in &affected {
1092            if !adjacency.contains_key(name) {
1093                self.of_record.remove(name);
1094            }
1095        }
1096
1097        // The components of the region, the seed's first when the seed is
1098        // still a record. Each is closed inside `affected` by construction:
1099        // `affected` was built by following the same adjacency.
1100        let seed_present = adjacency.contains_key(seed);
1101        let mut components: Vec<BTreeSet<String>> = Vec::new();
1102        let mut placed: BTreeSet<String> = BTreeSet::new();
1103        let starts = seed_present
1104            .then(|| seed.to_string())
1105            .into_iter()
1106            .chain(affected.iter().cloned());
1107        for start in starts {
1108            if placed.contains(&start) || !adjacency.contains_key(&start) {
1109                continue;
1110            }
1111            let mut component: BTreeSet<String> = BTreeSet::new();
1112            let mut walk = vec![start];
1113            while let Some(name) = walk.pop() {
1114                if !component.insert(name.clone()) {
1115                    continue;
1116                }
1117                if let Some(targets) = adjacency.get(&name) {
1118                    walk.extend(targets.iter().cloned());
1119                }
1120            }
1121            placed.extend(component.iter().cloned());
1122            components.push(component);
1123        }
1124
1125        // Which id each component keeps. C fixes two of these: the component
1126        // holding the edited record keeps the set that record was already in
1127        // (`dbLockSetMerge` keeps `pfirst`'s), and a component that was a
1128        // whole set already and is untouched keeps its own. A seed that has
1129        // been deleted reserves nothing — its set is freed with the rest.
1130        let mut keeps: BTreeSet<u64> = BTreeSet::new();
1131        if seed_present {
1132            keeps.insert(seed_id);
1133        }
1134        let mut assigned: Vec<Option<u64>> = Vec::with_capacity(components.len());
1135        for component in &components {
1136            if seed_present && component.contains(seed) {
1137                assigned.push(Some(seed_id));
1138                continue;
1139            }
1140            let mut ids = component
1141                .iter()
1142                .map(|name| self.real_set_of(name).map(|set| set.id));
1143            let first = ids.next().flatten();
1144            let uniform =
1145                first.filter(|id| !keeps.contains(id) && ids.all(|other| other == Some(*id)));
1146            if let Some(id) = uniform {
1147                keeps.insert(id);
1148            }
1149            assigned.push(uniform);
1150        }
1151
1152        // Mint before freeing, as C does: `dbLockSetSplit` calls `makeSet`
1153        // while the set it is splitting is still live, so the new set comes
1154        // off whatever was already on the free list rather than off the id
1155        // this very edit is about to release.
1156        let fresh: Vec<Set> = assigned
1157            .iter()
1158            .filter(|id| id.is_none())
1159            .map(|_| self.make_set())
1160            .collect();
1161        let mut fresh = fresh.into_iter();
1162
1163        for (component, id) in components.into_iter().zip(assigned) {
1164            let set = match id {
1165                Some(id) => self.active[&id].set,
1166                None => fresh
1167                    .next()
1168                    .expect("one fresh set per unassigned component"),
1169            };
1170            self.place(component.iter().cloned(), set);
1171            self.active.insert(
1172                set.id,
1173                SetState {
1174                    set,
1175                    members: component,
1176                },
1177            );
1178        }
1179
1180        for id in touched.difference(&keeps) {
1181            let dropped = self
1182                .active
1183                .remove(id)
1184                .expect("every touched id named an active set");
1185            self.free.push_back(dropped.set);
1186        }
1187        self.revision += 1;
1188    }
1189
1190    /// Every set a registered record can be behind after `iocInit`: the
1191    /// active ones. Not the bootstrap set — [`Self::adopt`] mints a set for
1192    /// every record registered after [`PvDatabase::build_lock_sets`], and
1193    /// [`PvDatabase::init_sets`] for every one registered before it, so no
1194    /// record a relink can reach is on id 0.
1195    fn every_set(&self) -> Vec<Set> {
1196        self.active.values().map(|state| state.set).collect()
1197    }
1198
1199    fn info(&self, id: u64, rows: &HashMap<u64, MutexInfo>) -> LockSetInfo {
1200        let state = &self.active[&id];
1201        LockSetInfo {
1202            id,
1203            members: state.members.iter().cloned().collect(),
1204            refs: state.members.len() + state.set.many_holds.load(Ordering::Relaxed),
1205            locked: state.set.is_locked(),
1206            mutex: rows.get(&state.set.mutex_seq).cloned(),
1207        }
1208    }
1209}
1210
1211/// The row `epicsMutexShow` prints for each lock-set mutex, keyed by
1212/// [`LockSet::mutex_seq`].
1213///
1214/// Positional because the process mutex list exposes no per-mutex accessor.
1215/// It is exact: [`Registry::make_set`] is the only `PriorityInheritanceMutex`
1216/// created in this file, so filtering by creating file selects exactly the
1217/// lock-set mutexes; creation is serialised by [`SET_MUTEX_SEQ`]; and a set's
1218/// cell is never dropped, so no entry ever leaves the list and shifts the
1219/// ones behind it.
1220fn lock_set_mutex_rows() -> HashMap<u64, MutexInfo> {
1221    mutex_report(false)
1222        .shown
1223        .into_iter()
1224        .filter(|info| info.file() == file!())
1225        .enumerate()
1226        .map(|(seq, info)| (seq as u64, info))
1227        .collect()
1228}
1229
1230/// One active lock set, as `dblsr` and `dbLockShowLocked` report it.
1231pub struct LockSetInfo {
1232    /// C's `lockSet::id`.
1233    pub id: u64,
1234    /// C's `lockRecordList`, in the order `dblsr` walks it.
1235    pub members: Vec<String>,
1236    /// C's `lockSet::refcount`: one per member record, plus one for each
1237    /// [`PvDatabase::lock_records`] epoch currently holding this set.
1238    pub refs: usize,
1239    /// Whether the set's mutex cannot be taken right now — C's
1240    /// `epicsMutexTryLock` filter in `dbLockShowLocked`.
1241    pub locked: bool,
1242    /// The `epicsMutexShow` row for this set's mutex.
1243    pub mutex: Option<MutexInfo>,
1244}
1245
1246/// What one `dblsr` / `dbLockShowLocked` call sees.
1247pub struct LockSetReport {
1248    /// C's `lockSetsActive`, in list order.
1249    pub active: Vec<LockSetInfo>,
1250    /// `ellCount(&lockSetsFree)`.
1251    pub free: usize,
1252}
1253
1254/// The lock sets of one database — C's `lockSetsActive` and `lockSetsFree`.
1255///
1256/// Every record is behind exactly one set, and a DB link puts both of its
1257/// records behind the same one. Sets are created by
1258/// [`PvDatabase::build_lock_sets`] at IOC init, and lazily for a name that
1259/// reaches [`PvDatabase::lock_record`] without one.
1260///
1261/// Nothing is ever destroyed: a merged-away set moves to the free list, and
1262/// its mutex must outlive it because a `'static` guard may still be unwinding
1263/// through it. That bounds memory by the record count, which is what C's own
1264/// free list does.
1265#[derive(Default)]
1266pub(crate) struct RecordLockRegistry {
1267    inner: std::sync::Mutex<Registry>,
1268}
1269
1270impl RecordLockRegistry {
1271    fn lock(&self) -> std::sync::MutexGuard<'_, Registry> {
1272        self.inner
1273            .lock()
1274            .unwrap_or_else(|poisoned| poisoned.into_inner())
1275    }
1276
1277    /// Which set `record` is behind right now, without creating one.
1278    fn set_id_of(&self, record: &str) -> Option<u64> {
1279        self.lock().real_set_of(record).map(|set| set.id)
1280    }
1281
1282    /// See [`Registry::adopt`].
1283    pub(crate) fn adopt(&self, name: &str, lr: &Arc<LockRecord>) {
1284        self.lock().adopt(name, lr);
1285    }
1286}
1287
1288impl PvDatabase {
1289    /// Build the lock sets — C `dbLockInitRecords` followed by the
1290    /// `dbLockSetMerge` every DB link performs as it is opened
1291    /// (`iocInit.c:178-179`, `dbDbLink.c:110`).
1292    ///
1293    /// Called once from `ioc_init`, which is why `dbLockShowLocked` on a
1294    /// loaded-but-not-initialised IOC reports `0` and `0` exactly as C's does:
1295    /// before this runs no record has a set.
1296    ///
1297    /// C merges incrementally because it cannot afford to recompute; the
1298    /// result is the same either way, because `dbLockSetSplit` is itself a
1299    /// reachability recomputation (`dbLock.c:710-717`). Doing it as C does —
1300    /// one set per record, then one merge per link — is what reproduces the
1301    /// free-list count, which a components-first construction would report as
1302    /// zero.
1303    pub fn build_lock_sets(&self) {
1304        let mut names: Vec<String> = self
1305            .inner
1306            .records
1307            .read()
1308            .keys()
1309            .map(|n| n.to_string())
1310            .collect();
1311        names.sort();
1312        // Every edge is collected BEFORE the registry lock is taken:
1313        // `record_link_fields` reads the record map and each record's own
1314        // lock, and neither may be acquired underneath the registry.
1315        let mut edges: Vec<(String, String)> = Vec::new();
1316        for name in &names {
1317            for target in self.db_link_targets(name) {
1318                edges.push((name.clone(), target));
1319            }
1320        }
1321        self.init_sets(&names);
1322        for (from, to) in edges {
1323            self.merge_sets(&from, &to);
1324        }
1325    }
1326
1327    /// C `dbLockInitRecords` (`dbLock.c:526-532`) through `createLockRecord`:
1328    /// one set per record, before any link has merged anything. The
1329    /// bootstrap set is held across the whole pass, because every record
1330    /// given a set here is being moved out of it.
1331    ///
1332    /// Every cell the registry has adopted is minted, not only `names`: a
1333    /// record adopted after `names` was read is registered but unnamed, and
1334    /// `built` promises that no registered record is on the bootstrap set
1335    /// from here on ([`Registry::every_set`]).
1336    fn init_sets(&self, names: &[String]) {
1337        let _out_of = bootstrap_set().acquire(false);
1338        let mut registry = self.inner.record_locks.lock();
1339        for name in names {
1340            let lr = registry.lock_record_of(name);
1341            registry.mint_for(name, &lr);
1342        }
1343        let mut adopted: Vec<(String, Arc<LockRecord>)> = registry
1344            .of_record
1345            .iter()
1346            .filter(|(_, lr)| lr.is_bootstrap())
1347            .map(|(name, lr)| (name.clone(), lr.clone()))
1348            .collect();
1349        adopted.sort_by(|a, b| a.0.cmp(&b.0));
1350        for (name, lr) in &adopted {
1351            registry.mint_for(name, lr);
1352        }
1353        registry.built = true;
1354    }
1355
1356    /// C `dbLockSetMerge` under C's protocol: both sets are taken before
1357    /// the registry decides anything, in id order, and the merge happens
1358    /// only if they are still the two records' sets once it is held.
1359    fn merge_sets(&self, first: &str, second: &str) {
1360        loop {
1361            let (a, b) = {
1362                let registry = self.inner.record_locks.lock();
1363                match (registry.real_set_of(first), registry.real_set_of(second)) {
1364                    (Some(a), Some(b)) => (a, b),
1365                    _ => return,
1366                }
1367            };
1368            if std::ptr::eq(a, b) {
1369                return;
1370            }
1371            let _held = hold_sorted(&[a, b]);
1372            let mut registry = self.inner.record_locks.lock();
1373            let unchanged = registry
1374                .real_set_of(first)
1375                .is_some_and(|set| std::ptr::eq(set, a))
1376                && registry
1377                    .real_set_of(second)
1378                    .is_some_and(|set| std::ptr::eq(set, b));
1379            if unchanged {
1380                registry.merge(first, second);
1381                return;
1382            }
1383        }
1384    }
1385
1386    /// The cell `name` locks through, with a real set — minted now if the
1387    /// name has none. The by-name form of [`Self::ensure_set_for`].
1388    fn ensure_set(&self, name: &str) -> Arc<LockRecord> {
1389        let lr = self.inner.record_locks.lock().lock_record_of(name);
1390        self.ensure_set_for(name, &lr);
1391        lr
1392    }
1393
1394    /// Move `lr` off the bootstrap set onto one of its own, if it is still
1395    /// there — `createLockRecord` for a record that reached a lock before
1396    /// `iocInit`; after it [`Registry::adopt`] has already done this. The
1397    /// bootstrap set is taken first and the registry under it: a mover holds
1398    /// the set it moves out of, and no set is ever taken under the registry
1399    /// lock.
1400    fn ensure_set_for(&self, name: &str, lr: &Arc<LockRecord>) {
1401        if !lr.is_bootstrap() {
1402            return;
1403        }
1404        let _out_of = bootstrap_set().acquire(false);
1405        self.inner.record_locks.lock().mint_for(name, lr);
1406    }
1407
1408    /// The records `record`'s DB links reach, canonicalised.
1409    ///
1410    /// Only a link that resolved to a LOCAL record merges: C reaches
1411    /// `dbLockSetMerge` from `dbDbInitLink`, and a target this IOC does not
1412    /// have falls through to `dbCaAddLink` instead (`dbLink.c:118-130`), which
1413    /// merges nothing. `record_link_fields` has already applied that same
1414    /// locality rule, so a `ca://` link to a local record is a `Ca` link here
1415    /// and does not widen the set.
1416    fn db_link_targets(&self, record: &str) -> Vec<String> {
1417        use crate::server::record::ParsedLink;
1418        self.record_link_fields(record)
1419            .into_iter()
1420            .filter_map(|(_, _, parsed)| match parsed {
1421                ParsedLink::Db(link) => {
1422                    // `DbLink::target` is where the record name stops and the
1423                    // channel filter begins — C reaches `dbLockSetMerge` with
1424                    // `dbChannelRecord(chan)` (`dbDbLink.c:94-109`), the record
1425                    // the whole `pvname` resolved to, so `SRC.[2]` merges into
1426                    // SRC's set exactly as `SRC` does. Matching on the raw text
1427                    // instead left every filtered reader in a set of its own,
1428                    // and a slice read outside the source's set can tear
1429                    // against the source's own processing.
1430                    let name = self
1431                        .resolve_alias(&link.target().record)
1432                        .unwrap_or_else(|| link.target().record.clone());
1433                    self.get_record_no_resolve(&name).map(|_| name)
1434                }
1435                _ => None,
1436            })
1437            .collect()
1438    }
1439
1440    /// C's `lockSetsActive` and `lockSetsFree` as `dblsr("*", n)` and
1441    /// `dbLockShowLocked(n)` read them.
1442    pub fn lock_set_report(&self) -> LockSetReport {
1443        let rows = lock_set_mutex_rows();
1444        let registry = self.inner.record_locks.lock();
1445        LockSetReport {
1446            active: registry
1447                .active
1448                .keys()
1449                .map(|id| registry.info(*id, &rows))
1450                .collect(),
1451            free: registry.free.len(),
1452        }
1453    }
1454
1455    /// The lock set one record is behind, or `None` when it has none —
1456    /// `dblsr`'s `if (!plockRecord) return 0;` before `iocInit`
1457    /// (`dbLock.c:900-901`).
1458    ///
1459    /// Does not create a set: asking about a record must not change the
1460    /// report.
1461    pub fn lock_set_of(&self, record: &str) -> Option<LockSetInfo> {
1462        let canonical = self
1463            .resolve_alias(record)
1464            .unwrap_or_else(|| record.to_string());
1465        let rows = lock_set_mutex_rows();
1466        let registry = self.inner.record_locks.lock();
1467        let id = registry.real_set_of(&canonical)?.id;
1468        Some(registry.info(id, &rows))
1469    }
1470
1471    /// The obligation to re-derive `record`'s lock set, taken out BEFORE a DB
1472    /// link field on it is written.
1473    ///
1474    /// `None` — no obligation — when the field is not a DBF link field, or
1475    /// when no lock sets exist yet: before `iocInit` C has no `lockRecord` to
1476    /// merge, so a `.db` load rewrites link text with nothing to maintain and
1477    /// [`PvDatabase::build_lock_sets`] does the whole job afterwards.
1478    ///
1479    /// Declare it ABOVE the record guard in the put body. Rust drops in
1480    /// reverse declaration order, so the record lock is down by the time the
1481    /// relink runs, which is the order the owner needs: it reads the link
1482    /// text of every record in the affected component.
1483    pub(crate) fn link_field_write<'a>(
1484        &'a self,
1485        record: &str,
1486        field: &str,
1487    ) -> Option<LockSetEdit<'a>> {
1488        let canonical = self
1489            .resolve_alias(record)
1490            .unwrap_or_else(|| record.to_string());
1491        if !self.is_dbf_link_field(&canonical, field) {
1492            return None;
1493        }
1494        if !self.inner.record_locks.lock().built {
1495            return None;
1496        }
1497        Some(LockSetEdit {
1498            db: self,
1499            record: canonical,
1500            scope: RelinkScope::LinkEdit,
1501        })
1502    }
1503
1504    /// The same obligation for a change of MEMBERSHIP rather than of link
1505    /// text: a record added or removed after `iocInit`, or an alias that
1506    /// makes a link resolve to a record it did not resolve to before.
1507    ///
1508    /// One owner covers all three because the owner re-derives the component
1509    /// instead of tracking a verb — see the module doc. Declare it above the
1510    /// mutation, so the relink sees the database as it is afterwards.
1511    pub(crate) fn lock_set_membership_change<'a>(
1512        &'a self,
1513        record: &str,
1514    ) -> Option<LockSetEdit<'a>> {
1515        if !self.inner.record_locks.lock().built {
1516            return None;
1517        }
1518        Some(LockSetEdit {
1519            db: self,
1520            record: self
1521                .resolve_alias(record)
1522                .unwrap_or_else(|| record.to_string()),
1523            scope: RelinkScope::Membership,
1524        })
1525    }
1526
1527    /// **The single owner of the lock-set partition after `iocInit`.**
1528    ///
1529    /// Re-derives the connected component `record` now sits in and
1530    /// re-partitions every set that component touches. C reaches the same
1531    /// result through `dbLockSetMerge` on link creation and `dbLockSetSplit`
1532    /// on removal (`dbDbLink.c:110`, `:124`, `:141`); see the module doc for
1533    /// why the port re-derives instead of tracking the endpoint pair.
1534    ///
1535    /// Private, and reachable only by dropping a [`LockSetEdit`].
1536    ///
1537    /// The sets are the records' data locks, so the region is taken before
1538    /// it is read and held while it is re-partitioned — C's `dbPutFieldLink`
1539    /// holds both `dbLockSetMerge` operands through `dbScanLockMany`
1540    /// (`dbAccess.c:1115-1123`, `dbLock.c:587-607`). Which sets make up the
1541    /// region is the scope's business:
1542    ///
1543    /// * [`RelinkScope::LinkEdit`] — the seed's set and the sets of the
1544    ///   records its links now reach. Both are what the put that landed the
1545    ///   link already holds ([`PvDatabase::acquire_put_gate`] takes them as
1546    ///   `dbPutFieldLink` does), so the acquisition here is a re-entry. The
1547    ///   partition invariant puts every OTHER link of the region inside it;
1548    ///   a target found outside is an invariant already broken, and the loop
1549    ///   widens the region to it rather than move a record it does not hold.
1550    /// * [`RelinkScope::Membership`] — every active set. A record added,
1551    ///   removed or newly reachable through an alias can join or leave a
1552    ///   component from anywhere, and the links that name it can be in any
1553    ///   set. Registration-path only, with no set held by the caller, so
1554    ///   taking them all in id order is the plain `dbScanLockMany`
1555    ///   discipline.
1556    ///
1557    /// Neither scope takes the bootstrap set: after `iocInit` no registered
1558    /// record is on it ([`Registry::adopt`] mints the set before the record
1559    /// is published), and a `LinkEdit` relink, entered under the seed's set,
1560    /// could not take id 0 in id order anyway — a `Membership` relink that
1561    /// held it first would be waiting on the seed's set.
1562    ///
1563    /// Sets are taken first and the registry lock under them, never the
1564    /// reverse; [`Registry::revision`] proves, under that lock, that the sets
1565    /// held are still the ones the region's records are behind.
1566    fn relink_lock_sets(&self, record: &str, scope: RelinkScope) {
1567        let mut region: Vec<Set> = Vec::new();
1568        loop {
1569            let revision = {
1570                let registry = self.inner.record_locks.lock();
1571                match scope {
1572                    RelinkScope::Membership => region = registry.every_set(),
1573                    RelinkScope::LinkEdit => {
1574                        if region.is_empty() {
1575                            region.extend(registry.real_set_of(record));
1576                        }
1577                    }
1578                }
1579                registry.revision
1580            };
1581            if scope == RelinkScope::LinkEdit {
1582                // The targets are read under the seed's set, which the put
1583                // holds; their sets come from the registry, not from a lock.
1584                let targets = self.db_link_targets(record);
1585                let registry = self.inner.record_locks.lock();
1586                for target in &targets {
1587                    let Some(set) = registry.set_of_registered(target) else {
1588                        continue;
1589                    };
1590                    if !region.iter().any(|held| std::ptr::eq(*held, set)) {
1591                        region.push(set);
1592                    }
1593                }
1594            }
1595            let held = hold_sorted(&region);
1596            let adjacency = match scope {
1597                RelinkScope::Membership => self.db_link_adjacency(),
1598                RelinkScope::LinkEdit => {
1599                    let members: Vec<String> = {
1600                        let registry = self.inner.record_locks.lock();
1601                        region
1602                            .iter()
1603                            .filter_map(|set| registry.active.get(&set.id))
1604                            .flat_map(|state| state.members.iter().cloned())
1605                            .collect()
1606                    };
1607                    self.db_link_adjacency_of(&members)
1608                }
1609            };
1610            let mut registry = self.inner.record_locks.lock();
1611            if registry.revision != revision {
1612                drop(registry);
1613                drop(held);
1614                continue;
1615            }
1616            let outside: Vec<Set> = adjacency
1617                .values()
1618                .flatten()
1619                .filter_map(|name| registry.set_of_registered(name))
1620                .filter(|set| !region.iter().any(|held| std::ptr::eq(*held, *set)))
1621                .collect();
1622            if !outside.is_empty() {
1623                region.extend(outside);
1624                region.sort_unstable_by_key(|set| set.id);
1625                region.dedup_by_key(|set| set.id);
1626                drop(registry);
1627                drop(held);
1628                continue;
1629            }
1630            registry.repartition(record, &adjacency, &region);
1631            return;
1632        }
1633    }
1634
1635    /// The DB-link graph as an undirected adjacency map.
1636    ///
1637    /// Undirected because C's merge is: `dbLockSetMerge(locker, plink->precord,
1638    /// target)` puts both endpoints behind one mutex regardless of which way
1639    /// the link points, and `dbLockSetSplit` walks `bklnk` as well as the
1640    /// record's own links (`dbLock.c:735-770`) for the same reason. A record
1641    /// that is only ever pointed AT is as much a member as the one pointing.
1642    fn db_link_adjacency(&self) -> HashMap<String, BTreeSet<String>> {
1643        let names: Vec<String> = self
1644            .inner
1645            .records
1646            .read()
1647            .keys()
1648            .map(|n| n.to_string())
1649            .collect();
1650        self.db_link_adjacency_of(&names)
1651    }
1652
1653    /// [`Self::db_link_adjacency`] over `names` only — the members of the
1654    /// sets a [`RelinkScope::LinkEdit`] relink holds. A target outside
1655    /// `names` still appears as a neighbour, which is how the relink notices
1656    /// a set it does not hold.
1657    fn db_link_adjacency_of(&self, names: &[String]) -> HashMap<String, BTreeSet<String>> {
1658        let mut adjacency: HashMap<String, BTreeSet<String>> = HashMap::new();
1659        for name in names {
1660            adjacency.entry(name.clone()).or_default();
1661        }
1662        for name in names {
1663            for target in self.db_link_targets(name) {
1664                adjacency
1665                    .entry(name.clone())
1666                    .or_default()
1667                    .insert(target.clone());
1668                adjacency.entry(target).or_default().insert(name.clone());
1669            }
1670        }
1671        adjacency
1672    }
1673}
1674
1675/// A DB link field write that has landed in the record but not yet in the
1676/// lock-set graph.
1677///
1678/// The token exists so that the illegal state — link text changed, partition
1679/// stale — cannot be constructed rather than merely being checked for. It is
1680/// minted by [`PvDatabase::link_field_write`], holds the only reference
1681/// through which [`PvDatabase::relink_lock_sets`] is reachable, and performs
1682/// the relink from its destructor, so no exit path of a put body can skip it.
1683#[must_use = "the lock-set graph is only re-derived when this is dropped;               binding it to `_` drops it immediately and relinks too early"]
1684pub(crate) struct LockSetEdit<'a> {
1685    db: &'a PvDatabase,
1686    record: String,
1687    scope: RelinkScope,
1688}
1689
1690impl Drop for LockSetEdit<'_> {
1691    fn drop(&mut self) {
1692        self.db.relink_lock_sets(&self.record, self.scope);
1693    }
1694}
1695
1696/// Which sets a relink must hold — see [`PvDatabase::relink_lock_sets`].
1697#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1698enum RelinkScope {
1699    /// A DB link field of the seed was written; the put holds the seed's set
1700    /// and the new target's.
1701    LinkEdit,
1702    /// The seed joined or left the database, or an alias changed what a
1703    /// link naming it resolves to; nothing is held.
1704    Membership,
1705}
1706
1707/// RAII guard for one record's lock set.
1708///
1709/// Held for the duration of a plain CA/PVA write — the `dbScanLock` +
1710/// `dbScanUnlock` pair around one `dbPutField`. `!Send`, so the compiler
1711/// refuses to let it live across an `.await` in any spawned future; see the
1712/// module doc.
1713#[must_use = "the lock set is released as soon as the guard is dropped"]
1714pub struct RecordWriteGuard {
1715    _guard: SetGuard,
1716}
1717
1718/// RAII guard for the lock sets of a declared record set — the `DBManyLocker`
1719/// equivalent.
1720///
1721/// Acquired by [`PvDatabase::lock_records`] over every member record of a
1722/// multi-record transaction (QSRV atomic group PUT/GET, pvalink atomic
1723/// scan-on-update epoch) and held across the whole member loop. While alive,
1724/// every plain write to any record of any of those sets blocks.
1725#[must_use = "the locked epoch ends as soon as the guard is dropped"]
1726pub struct ManyRecordWriteGuard {
1727    _guards: Vec<SetGuard>,
1728}
1729
1730impl PvDatabase {
1731    /// Acquire the lock set of a single record — the `dbScanLock(precord)`
1732    /// analogue.
1733    ///
1734    /// `record` is alias-resolved here and nowhere else: this is the single
1735    /// owner of "which lock set does this name name", so an alias and its
1736    /// target always reach the same set and no caller has to resolve first to
1737    /// make that true. Resolution borrows — a name that is not an alias, which
1738    /// is every name in a database that declares none, reaches the lookup
1739    /// without a copy of itself being made.
1740    ///
1741    /// **Blocks the calling thread** when another thread holds the set. A
1742    /// thread that already holds it recurses, as C's recursive `epicsMutex`
1743    /// does — which is not optional once a set spans a whole link component,
1744    /// because processing a record and then its link target takes one mutex
1745    /// twice.
1746    pub fn lock_record(&self, record: &str) -> RecordWriteGuard {
1747        let resolved = self.resolve_alias(record);
1748        let canonical: &str = resolved.as_deref().unwrap_or(record);
1749        let lr = self.ensure_set(canonical);
1750        RecordWriteGuard {
1751            _guard: lr.acquire(),
1752        }
1753    }
1754
1755    /// C `dbScanLock(precord)` itself — the gate taken by a caller that
1756    /// already holds the record, which in C is the only form there is.
1757    ///
1758    /// Prefer it to [`Self::lock_record`] wherever the record is in hand. It
1759    /// reads the record's own cell and takes the set's mutex, and does
1760    /// nothing else; reaching the same set by name costs an alias lookup plus
1761    /// a hash of the record name under the registry mutex, twice. On the
1762    /// process path — which always has the record, having just read it out of
1763    /// the records map — that was 0.85 us of a 11.5 us calc cycle, measured
1764    /// on 2000 records at 10 Hz.
1765    pub fn lock_instance(&self, rec: &Arc<RecordCell>) -> RecordWriteGuard {
1766        let lr = rec.lock_record();
1767        if lr.is_bootstrap() {
1768            // First gate this record has ever been given. C mints the set in
1769            // `dbLockInitRecords` before anything can lock; the port allows a
1770            // database with no `iocInit` at all, so the set is minted on
1771            // demand here — once per record, off every later pass. The name
1772            // is read and the guard dropped BEFORE the move: a guard taken
1773            // through the bootstrap set must not outlive the record's stay
1774            // in it.
1775            let name = rec.read().name.clone();
1776            self.ensure_set_for(&name, lr);
1777        }
1778        RecordWriteGuard {
1779            _guard: lr.acquire(),
1780        }
1781    }
1782
1783    /// Acquire the lock sets covering a set of records — the `DBManyLock` /
1784    /// `DBManyLocker` equivalent, C's `dbScanLockMany` (`dbLock.c:384-440`).
1785    ///
1786    /// Every name is alias-resolved, mapped to its lock set, then the sets are
1787    /// sorted by id and de-duplicated before any is taken. Sorting gives two
1788    /// overlapping transactions the same acquisition order so they cannot
1789    /// deadlock; de-duplication is C's own — several member records commonly
1790    /// share one set, and `dbScanLockMany` skips the repeats (`:399-402`).
1791    ///
1792    /// The returned [`ManyRecordWriteGuard`] must be held for the whole
1793    /// transaction. Each set it holds reports one extra ref while it lives,
1794    /// which is the `+1` C's locked list adds.
1795    ///
1796    /// Names that do not resolve to a record still get a set, matching
1797    /// `dbLockerAlloc`, which accepts the record pointers it is given without
1798    /// a liveness re-check.
1799    pub fn lock_records<I, S>(&self, records: I) -> ManyRecordWriteGuard
1800    where
1801        I: IntoIterator<Item = S>,
1802        S: AsRef<str>,
1803    {
1804        let names: Vec<String> = records
1805            .into_iter()
1806            .map(|record| {
1807                let record = record.as_ref();
1808                self.resolve_alias(record)
1809                    .unwrap_or_else(|| record.to_string())
1810            })
1811            .collect();
1812        let cells: Vec<Arc<LockRecord>> = names.iter().map(|name| self.ensure_set(name)).collect();
1813
1814        loop {
1815            let mut sets: Vec<Set> = cells.iter().map(|lr| lr.set()).collect();
1816            sets.sort_unstable_by_key(|set| set.id);
1817            sets.dedup_by_key(|set| set.id);
1818
1819            let guards: Vec<SetGuard> = sets.iter().map(|set| set.acquire(true)).collect();
1820
1821            // `dbLockUpdateRefs(locker, 0)` (`dbLock.c:432-436`): if a merge
1822            // moved any member while the sets were being taken, release
1823            // everything and start again.
1824            let held: BTreeSet<u64> = sets.iter().map(|set| set.id).collect();
1825            if names
1826                .iter()
1827                .all(|name| matches!(self.inner.record_locks.set_id_of(name), Some(id) if held.contains(&id)))
1828            {
1829                return ManyRecordWriteGuard { _guards: guards };
1830            }
1831            drop(guards);
1832        }
1833    }
1834}
1835
1836#[cfg(test)]
1837mod tests {
1838    use super::*;
1839    use std::sync::Arc;
1840    use std::sync::atomic::{AtomicUsize, Ordering};
1841    use std::time::Duration;
1842
1843    // These are plain `#[test]`s, not `#[tokio::test]`s, and that is forced
1844    // rather than stylistic: the gate blocks the calling *thread*, so a
1845    // contending waiter has to be a real thread. Parking one on a
1846    // `current_thread` runtime's only worker would wedge the runtime instead
1847    // of demonstrating exclusion. Being reactor-free they also run on the exec
1848    // backend and add no site to this file's RTEMS-EXEC-MODEL-ALLOW census.
1849
1850    /// Long enough that a non-blocking (broken) gate would have let the
1851    /// contender through, short enough not to dominate the suite.
1852    const SETTLE: Duration = Duration::from_millis(50);
1853
1854    /// A single-record gate excludes a concurrent same-record locker.
1855    #[test]
1856    fn lock_record_excludes_same_record() {
1857        let db = PvDatabase::new();
1858        let order = Arc::new(AtomicUsize::new(0));
1859
1860        let g = db.lock_record("ai:1");
1861
1862        let db2 = db.clone();
1863        let order2 = order.clone();
1864        let h = std::thread::spawn(move || {
1865            let _g2 = db2.lock_record("ai:1");
1866            // This must observe the first holder having released (1).
1867            order2.fetch_add(10, Ordering::SeqCst);
1868        });
1869
1870        // Give the spawned thread time to block on the gate.
1871        std::thread::sleep(SETTLE);
1872        // First holder still owns the gate: counter untouched.
1873        assert_eq!(order.load(Ordering::SeqCst), 0);
1874        order.fetch_add(1, Ordering::SeqCst);
1875        drop(g);
1876
1877        h.join().unwrap();
1878        assert_eq!(order.load(Ordering::SeqCst), 11);
1879    }
1880
1881    /// `lock_records` blocks a plain single-record write to a member.
1882    #[test]
1883    fn lock_records_excludes_single_member_write() {
1884        let db = PvDatabase::new();
1885        let many = db.lock_records(["g:a", "g:b", "g:c"]);
1886
1887        let db2 = db.clone();
1888        let acquired = Arc::new(AtomicUsize::new(0));
1889        let acquired2 = acquired.clone();
1890        let h = std::thread::spawn(move || {
1891            // Plain write to a member must block until `many` drops.
1892            let _g = db2.lock_record("g:b");
1893            acquired2.store(1, Ordering::SeqCst);
1894        });
1895
1896        std::thread::sleep(SETTLE);
1897        assert_eq!(
1898            acquired.load(Ordering::SeqCst),
1899            0,
1900            "single-member write must block while ManyRecordWriteGuard is held"
1901        );
1902
1903        drop(many);
1904        h.join().unwrap();
1905        assert_eq!(acquired.load(Ordering::SeqCst), 1);
1906    }
1907
1908    /// Two overlapping `lock_records` sets acquire in canonical order
1909    /// and therefore cannot deadlock even with reversed input order.
1910    ///
1911    /// With blocking gates a violated order wedges both threads outright,
1912    /// which is why this runs the two sets on real threads and joins them
1913    /// under a bounded wait rather than trusting a scheduler yield.
1914    #[test]
1915    fn lock_records_overlapping_sets_no_deadlock() {
1916        let db = PvDatabase::new();
1917        let done = Arc::new(AtomicUsize::new(0));
1918
1919        let handles: Vec<_> = [["x", "y", "z"], ["z", "y", "x"]]
1920            .into_iter()
1921            .map(|set| {
1922                let db = db.clone();
1923                let done = done.clone();
1924                std::thread::spawn(move || {
1925                    for _ in 0..500 {
1926                        // Reversed input order on one side — sort makes the
1927                        // real acquisition order identical, so no deadlock.
1928                        let _g = db.lock_records(set);
1929                        std::thread::yield_now();
1930                    }
1931                    done.fetch_add(1, Ordering::SeqCst);
1932                })
1933            })
1934            .collect();
1935
1936        let deadline = std::time::Instant::now() + Duration::from_secs(10);
1937        while done.load(Ordering::SeqCst) < 2 {
1938            assert!(
1939                std::time::Instant::now() < deadline,
1940                "overlapping lock_records sets must not deadlock"
1941            );
1942            std::thread::sleep(Duration::from_millis(10));
1943        }
1944        for h in handles {
1945            h.join().unwrap();
1946        }
1947    }
1948
1949    /// An epoch over a record set excludes a second epoch that shares
1950    /// any record until the first guard drops — and overlapping sets
1951    /// listed in opposite orders never deadlock (sorted acquisition).
1952    #[test]
1953    fn overlapping_epochs_are_mutually_exclusive_and_deadlock_free() {
1954        let db = PvDatabase::new();
1955        let a = vec!["RECA".to_string(), "RECB".to_string()];
1956        // Opposite order on purpose — sorted acquisition must still
1957        // make this safe.
1958        let b = vec!["RECB".to_string(), "RECC".to_string()];
1959
1960        let guard_a = db.lock_records(&a);
1961
1962        // A second epoch sharing RECB must not be acquirable while
1963        // `guard_a` is alive.
1964        let db2 = db.clone();
1965        let entered = Arc::new(AtomicUsize::new(0));
1966        let entered2 = entered.clone();
1967        let handle = std::thread::spawn(move || {
1968            let _guard_b = db2.lock_records(&b);
1969            entered2.store(1, Ordering::SeqCst);
1970        });
1971
1972        std::thread::sleep(SETTLE);
1973        assert_eq!(
1974            entered.load(Ordering::SeqCst),
1975            0,
1976            "epoch B must block on shared RECB"
1977        );
1978
1979        drop(guard_a);
1980        handle.join().expect("epoch B thread");
1981        assert_eq!(entered.load(Ordering::SeqCst), 1);
1982    }
1983
1984    /// Two non-overlapping epochs run concurrently — no false
1985    /// serialisation. Taking the second on *this* thread while the first is
1986    /// still held is the assertion: a gate keyed too coarsely would
1987    /// self-deadlock here rather than merely being slow.
1988    #[test]
1989    fn disjoint_epochs_do_not_block_each_other() {
1990        let db = PvDatabase::new();
1991        let _g1 = db.lock_records(&["X1".to_string()]);
1992        // Disjoint set: must acquire immediately without blocking.
1993        let _g2 = db.lock_records(&["X2".to_string()]);
1994    }
1995
1996    /// One name reaches one set, and two unlinked records reach two — C's
1997    /// state straight after `dbLockInitRecords`, before any link merges.
1998    #[test]
1999    fn a_name_reaches_one_set_and_two_unlinked_records_reach_two() {
2000        let db = PvDatabase::new();
2001        assert!(
2002            std::ptr::eq(db.ensure_set("REC:A").set(), db.ensure_set("REC:A").set()),
2003            "the same canonical name must map to the same lock set"
2004        );
2005        assert!(
2006            !std::ptr::eq(db.ensure_set("REC:A").set(), db.ensure_set("REC:B").set()),
2007            "records no link joins must not share a lock set"
2008        );
2009    }
2010
2011    /// A second epoch overlapping the first on one thread recurses rather
2012    /// than wedging. C's refusal (`cantProceed("dbScanLockMany(%p) already
2013    /// locked...")`, `dbLock.c:392-395`) is about re-using ONE `dbLocker`,
2014    /// which has no analogue here — every call builds its own — while the
2015    /// overlap itself is what C's recursive `epicsMutex` absorbs.
2016    #[test]
2017    fn a_second_overlapping_epoch_on_one_thread_recurses() {
2018        let db = PvDatabase::new();
2019        let _epoch = db.lock_records(&["RE:A".to_string(), "RE:B".to_string()]);
2020        let _overlapping = db.lock_records(&["RE:B".to_string(), "RE:C".to_string()]);
2021    }
2022
2023    /// `dbScanLock` recurses because `epicsMutex` must (`epicsMutex.h:16`,
2024    /// `:38`), and once a lock set spans a link component the port has no
2025    /// choice either: processing a record and then its link target is this
2026    /// sequence with two different names behind one mutex.
2027    #[test]
2028    fn re_taking_one_record_s_set_recurses_like_db_scan_lock() {
2029        let db = PvDatabase::new();
2030        let _held = db.lock_record("RE:SELF");
2031        let _again = db.lock_record("RE:SELF");
2032    }
2033
2034    /// The recursion is per THREAD: a second thread still blocks, and the
2035    /// depth the first one built up does not let it through early.
2036    #[test]
2037    fn recursion_does_not_let_a_second_thread_in() {
2038        let db = PvDatabase::new();
2039        let outer = db.lock_record("RE:DEPTH");
2040        let inner = db.lock_record("RE:DEPTH");
2041
2042        let db2 = db.clone();
2043        let entered = Arc::new(AtomicUsize::new(0));
2044        let entered2 = entered.clone();
2045        let h = std::thread::spawn(move || {
2046            let _g = db2.lock_record("RE:DEPTH");
2047            entered2.store(1, Ordering::SeqCst);
2048        });
2049
2050        std::thread::sleep(SETTLE);
2051        assert_eq!(entered.load(Ordering::SeqCst), 0, "outer level still held");
2052        drop(inner);
2053        std::thread::sleep(SETTLE);
2054        assert_eq!(
2055            entered.load(Ordering::SeqCst),
2056            0,
2057            "one release of two must not hand the set over"
2058        );
2059        drop(outer);
2060        h.join().unwrap();
2061        assert_eq!(entered.load(Ordering::SeqCst), 1);
2062    }
2063
2064    /// The positional association between a set and its `epicsMutexShow` row
2065    /// is only exact while `make_set` is the ONLY `PriorityInheritanceMutex`
2066    /// created in this file. This is that check: one row for every set ever
2067    /// made in this process, and not one more.
2068    #[test]
2069    fn this_file_creates_no_mutex_but_lock_sets() {
2070        let db = PvDatabase::new();
2071        for name in ["MS:1", "MS:2", "MS:3"] {
2072            drop(db.lock_record(name));
2073        }
2074        let made = *SET_MUTEX_SEQ.lock().unwrap();
2075        assert_eq!(
2076            lock_set_mutex_rows().len() as u64,
2077            made,
2078            "a second mutex created in this file would shift every set's row"
2079        );
2080        for set in db.lock_set_report().active {
2081            assert!(set.mutex.is_some(), "set {} has no row", set.id);
2082        }
2083    }
2084
2085    /// `RecordCell::read_in` rides the set its caller holds when the target
2086    /// is in that set — C's `dbScanLock` on a member of the held set is a
2087    /// recursion, which the port skips outright — and takes the target's own
2088    /// set when it is not.
2089    #[test]
2090    fn read_in_rides_the_held_set_and_takes_another() {
2091        use crate::server::record::{RecordCell, RecordInstance};
2092        use crate::server::records::calc::CalcRecord;
2093        let db = PvDatabase::new();
2094        let cell = |name: &str| {
2095            let cell = Arc::new(RecordCell::new(RecordInstance::new(
2096                name.into(),
2097                CalcRecord::default(),
2098            )));
2099            db.inner.record_locks.adopt(name, cell.lock_record());
2100            db.ensure_set_for(name, cell.lock_record());
2101            cell
2102        };
2103        let reader = cell("RI:A");
2104        let linked = cell("RI:B");
2105        let apart = cell("RI:C");
2106        db.merge_sets("RI:A", "RI:B");
2107
2108        let mut held = reader.write();
2109        let (_, set) = held.split();
2110        assert!(
2111            linked.read_in(set).rides_held_set(),
2112            "a record of the held set must be read without a second hold"
2113        );
2114        assert!(
2115            !apart.read_in(set).rides_held_set(),
2116            "a record of another set must take that set"
2117        );
2118    }
2119
2120    /// The set is released with the guard, so the ordinary sequential
2121    /// pattern — lock, write, drop, lock again — is untouched.
2122    #[test]
2123    fn the_set_is_released_when_the_guard_drops() {
2124        let db = PvDatabase::new();
2125        drop(db.lock_record("RE:SEQ"));
2126        drop(db.lock_record("RE:SEQ"));
2127        drop(db.lock_records(&["RE:SEQ".to_string()]));
2128        // And a disjoint pair may be held together on one thread.
2129        let _a = db.lock_record("RE:ONE");
2130        let _b = db.lock_record("RE:TWO");
2131    }
2132
2133    /// A record cell as `add_loaded_record` hands it to the registry:
2134    /// adopted, not yet in the records map, no lock taken through it.
2135    fn adopted_cell(db: &PvDatabase, name: &str) -> Arc<crate::server::record::RecordCell> {
2136        use crate::server::record::{RecordCell, RecordInstance};
2137        use crate::server::records::calc::CalcRecord;
2138        let cell = Arc::new(RecordCell::new(RecordInstance::new(
2139            name.into(),
2140            CalcRecord::default(),
2141        )));
2142        db.inner.record_locks.adopt(name, cell.lock_record());
2143        cell
2144    }
2145
2146    /// Boundary `built == false`: a record adopted before `iocInit` waits on
2147    /// the bootstrap set, as C's null `lset` does until `dbLockInitRecords`.
2148    #[test]
2149    fn adopt_before_build_leaves_the_record_on_the_bootstrap_set() {
2150        let db = PvDatabase::new();
2151        let cell = adopted_cell(&db, "AD:PRE");
2152        assert!(cell.lock_record().is_bootstrap());
2153        assert_eq!(db.inner.record_locks.set_id_of("AD:PRE"), None);
2154    }
2155
2156    /// Boundary `built == true`: a record adopted after `iocInit` has a real
2157    /// set before anything can lock it, so no relink has to reach for the
2158    /// bootstrap set on its behalf.
2159    #[test]
2160    fn adopt_after_build_mints_the_record_s_set() {
2161        let db = PvDatabase::new();
2162        db.build_lock_sets();
2163        let cell = adopted_cell(&db, "AD:POST");
2164        assert!(!cell.lock_record().is_bootstrap());
2165        assert_eq!(
2166            db.inner.record_locks.set_id_of("AD:POST"),
2167            Some(cell.lock_record().set().id)
2168        );
2169    }
2170
2171    /// A cell adopted before `build_lock_sets` but absent from the records
2172    /// map it reads its names from is still minted by `init_sets`: `built`
2173    /// promises no registered record is on the bootstrap set.
2174    #[test]
2175    fn build_lock_sets_mints_every_adopted_cell() {
2176        let db = PvDatabase::new();
2177        let cell = adopted_cell(&db, "AD:STRAGGLER");
2178        assert!(cell.lock_record().is_bootstrap());
2179        db.build_lock_sets();
2180        assert!(!cell.lock_record().is_bootstrap());
2181        assert!(db.inner.record_locks.set_id_of("AD:STRAGGLER").is_some());
2182    }
2183
2184    /// Boundary: a fresh set taken under a records-map read guard is the
2185    /// reader `remove_record_entry` can be waiting on while it holds that
2186    /// set. Debug builds fail on the offending thread.
2187    #[cfg(debug_assertions)]
2188    #[test]
2189    #[should_panic(expected = "read guard on the records map")]
2190    fn a_fresh_set_under_the_map_read_guard_is_refused() {
2191        let db = PvDatabase::new();
2192        let _map = db.inner.records.read();
2193        let _g = db.lock_record("MR:FRESH");
2194    }
2195
2196    /// Boundary: the same under the alias table's guard.
2197    #[cfg(debug_assertions)]
2198    #[test]
2199    #[should_panic(expected = "read guard on the records map")]
2200    fn a_fresh_set_under_the_alias_read_guard_is_refused() {
2201        let db = PvDatabase::new();
2202        let _map = db.inner.aliases.read();
2203        let _g = db.lock_record("MR:ALIAS");
2204    }
2205
2206    /// Boundary: a set this thread already holds is re-entered, not taken,
2207    /// so reading the map inside a held set and reaching the same record
2208    /// again — every `get_record` on the process path — is untouched. And
2209    /// once the guard is dropped, a fresh set is ordinary again.
2210    #[test]
2211    fn re_entry_under_the_map_read_guard_and_a_fresh_set_after_it_are_fine() {
2212        let db = PvDatabase::new();
2213        let _held = db.lock_record("MR:HELD");
2214        {
2215            let _map = db.inner.records.read();
2216            drop(db.lock_record("MR:HELD"));
2217        }
2218        drop(db.lock_record("MR:OTHER"));
2219    }
2220
2221    /// The set a `Membership` relink takes never includes the bootstrap set,
2222    /// so no relink can invert id order against a `LinkEdit` relink that
2223    /// enters already holding a seed's set.
2224    #[test]
2225    fn every_set_after_build_excludes_the_bootstrap_set() {
2226        let db = PvDatabase::new();
2227        adopted_cell(&db, "AD:ES1");
2228        db.build_lock_sets();
2229        adopted_cell(&db, "AD:ES2");
2230        let registry = db.inner.record_locks.lock();
2231        let sets = registry.every_set();
2232        assert_eq!(sets.len(), 2);
2233        assert!(
2234            sets.iter().all(|set| !std::ptr::eq(*set, bootstrap_set())),
2235            "every_set() must not hand a relink the bootstrap set"
2236        );
2237    }
2238}