Skip to main content

epics_base_rs/server/database/
mod.rs

1pub(crate) mod breakpoint;
2pub mod db_access;
3mod field_io;
4pub mod filters;
5mod link_put_queue;
6mod link_set;
7mod links;
8mod processing;
9pub(crate) use processing::{AlarmPosts, alarm_field_posts};
10mod record_lock;
11pub(crate) mod scan_index;
12mod snapshot;
13
14pub use field_io::ProcessMode;
15pub(crate) use link_set::MetadataPlan;
16pub use link_set::{
17    DynLinkSet, LinkBacking, LinkDbfType, LinkDiagnostics, LinkMetadata, LinkPutOp, LinkSet,
18    LinkSetRegistry, PostBacking, PutAdmission, RemoteAlarm,
19};
20pub(crate) use links::{
21    multi_output_dispatch_owned, posts_software_event, reads_sell, resolves_subroutine_from_link,
22};
23pub use processing::{AsyncDbHandle, AsyncToken, InputLinkTexts, ProcStack};
24pub(crate) use record_lock::{LockRecord, SetGuard};
25pub use record_lock::{LockSetInfo, LockSetReport, ManyRecordWriteGuard, RecordWriteGuard};
26
27use crate::error::{CaError, CaResult};
28use arc_swap::{ArcSwap, ArcSwapOption};
29use snapshot::SnapshotCell;
30use std::collections::HashMap;
31use std::sync::Arc;
32
33use crate::server::pv::ProcessVariable;
34use crate::server::record::{Record, RecordCell, RecordInstance};
35use crate::types::EpicsValue;
36
37/// What a `.db` definition carries into the creation sink alongside the record
38/// itself: the `dbCommon` fields `db_loader::apply_fields` could not route to
39/// the record's own `field_list`, and the record's `info(...)` tags.
40///
41/// It exists so that [`PvDatabase::add_loaded_record`] receives a record's
42/// COMPLETE loaded state in one call. A caller cannot add the record and then
43/// apply its `.db` fields, because the sink runs C's `iocInit` passes — whose
44/// result depends on those fields — before the record is reachable at all.
45#[derive(Default, Debug, Clone)]
46pub struct RecordLoad {
47    /// `dbCommon` fields, in `.db` file order (a later `field(UDF,…)` wins).
48    pub common_fields: Vec<(String, EpicsValue)>,
49    /// `info(key, "value")` tags.
50    pub info_tags: Vec<(String, String)>,
51}
52
53impl RecordLoad {
54    /// The common fields alone — the shape every `.db` loader path produces
55    /// from [`crate::server::db_loader::apply_fields`].
56    pub fn from_common_fields(common_fields: Vec<(String, EpicsValue)>) -> Self {
57        Self {
58            common_fields,
59            info_tags: Vec::new(),
60        }
61    }
62}
63
64/// Parse a PV name into (base_name, field_name).
65/// "TEMP.EGU" → ("TEMP", "EGU")
66/// "TEMP"     → ("TEMP", "VAL")
67///
68/// The split is on the LAST `.`, where C `dbFindRecordPart`
69/// (`dbChannel.c:180-200`) takes the FIRST one via `strchr`. That difference
70/// is unobservable, and it is the record-name grammar that makes it so: C
71/// `dbRecordNameValidate` (`dbLexRoutines.c:1085-1119`) aborts the load with
72/// `yyerrorAbort` on a `.` anywhere in a record or alias name, and the port
73/// refuses the same names, so the base half can never itself contain a dot
74/// and the two splits can only differ on a name that no IOC can hold.
75///
76/// Measured, not reasoned: a `.db` naming records `OTHER:PV` and `A.B` is
77/// refused at load by both, so the `A.B.C` case cannot be built at all. The
78/// one shape that does load — `OTHER:PV` present, a link reading
79/// `OTHER:PV.1:X` — hangs C's `iocInit` (reproduced three times on softIoc
80/// R7.0.10-146, always at "Starting iocInit"), while the port loads and
81/// alarms; that is an upstream defect the port has no path to.
82///
83/// This is the channel-name half of the rule. Link text is split by
84/// [`crate::server::record::DbLink`], which follows C exactly and cuts at the
85/// first `.` before requiring the remainder to be a field identifier.
86pub fn parse_pv_name(name: &str) -> (&str, &str) {
87    match name.rsplit_once('.') {
88        Some((base, field)) => (base, field),
89        None => (name, "VAL"),
90    }
91}
92
93/// C `dbIsValueField` (`dbAccess.c:463-469`): is this field the record
94/// type's *value* field?
95///
96/// A record type's value field is the one the DBD names `VAL` — the DBD
97/// parser records exactly that field's index as `indvalFlddes`
98/// (`dbLexRoutines.c:777-780`), which is what `dbIsValueField` compares
99/// against. Metadata that C/pvxs apply "to VAL only" (e.g. QSRV's
100/// `Q:form` → `display.form.index`, `iocsource.cpp:53`) key on this
101/// predicate, so it lives beside [`parse_pv_name`], whose `"REC"` → `VAL`
102/// default is the other half of the same rule.
103pub fn is_value_field(field: &str) -> bool {
104    field.eq_ignore_ascii_case("VAL")
105}
106
107pub(crate) use tsel_stamp::TselStamp;
108
109/// C `recGblGetTimeStampSimm` (`recGbl.c:310-343`), whole and inseparable.
110///
111/// C's one function is two steps: resolve `TSEL` (`:314-322`), then turn `TSE`
112/// into `TIME` (`:323-343`). The port cannot do them in one call — the link
113/// read needs the database and must not hold the record's own data lock, while
114/// the store needs the lock — so it splits them across
115/// [`PvDatabase::read_tsel`] and [`TselStamp::stamp`]. The split is what this
116/// module exists to bound: `apply_timestamp` is private to it, so the ONLY way
117/// to reach the `TSE`→`TIME` half is to hold a [`TselStamp`], and the only way
118/// to hold one is to have read `TSEL` — at the stamp point, which is where C
119/// reads it. Resolving `TSEL` once at the head of the cycle instead handed a
120/// `.TIME` TSEL the source's *pre-cycle* stamp even when the record's own
121/// `INPn PP` had just reprocessed that source (`calcRecord.c:120-127` runs
122/// `fetch_values` first), and let the device-time override at the stamp point
123/// win over a TSEL C gives priority to.
124mod tsel_stamp {
125    use std::time::SystemTime;
126
127    use crate::server::record::CommonFields;
128
129    /// The TSEL half of C `recGblGetTimeStampSimm`, read but not yet stored.
130    #[derive(Debug, Clone, Copy, PartialEq)]
131    pub(crate) enum TselStamp {
132        /// No `TSEL`, a constant one — `if (!dbLinkIsConstant(&prec->tsel))`
133        /// (`recGbl.c:315`) skips the whole block — or a read that delivered
134        /// nothing. `TSE` keeps its own value.
135        None,
136        /// `DBLINK_FLAG_TSELisTIME` (`recGbl.c:316-321`): the source's
137        /// time+utag, copied over the record's own. C `return`s here, so this
138        /// arm also states that no `TSE` load and no event lookup follows —
139        /// and that `TSE` itself is not touched. There is no assignment to
140        /// `prec->tse` anywhere in epics-base; the flag that suppresses the
141        /// lookup lives in `plink->flags`, not in a field clients can read.
142        Time(SystemTime, u64),
143        /// Every other `TSEL` (`recGbl.c:322`): `dbGetLink(&prec->tsel,
144        /// DBR_SHORT, &prec->tse, 0, 0)`.
145        Tse(i16),
146    }
147
148    impl TselStamp {
149        /// C `recGblGetTimeStampSimm`'s body: store what `TSEL` yielded, then
150        /// resolve `TSE`→`TIME`. `is_soft` indicates a Soft Channel device
151        /// type.
152        pub(crate) fn stamp(self, name: &str, common: &mut CommonFields, is_soft: bool) {
153            match self {
154                TselStamp::Time(time, utag) => {
155                    common.time = time;
156                    common.utag = utag;
157                    // C's `return` (`recGbl.c:321`), which is the whole reason
158                    // the event lookup does not run. Writing `TSE = -2` to get
159                    // the same effect made the record report a value its `.db`
160                    // never declared, and overwrote one that did.
161                    return;
162                }
163                TselStamp::None => {}
164                TselStamp::Tse(tse) => common.tse = tse,
165            }
166            apply_timestamp(name, common, is_soft);
167        }
168    }
169
170    /// Apply timestamp to a record based on its TSE field.
171    /// `is_soft` indicates a Soft Channel device type.
172    ///
173    /// The second half of C `recGblGetTimeStampSimm` (recGbl.c:324-342). The
174    /// TSE constants are defined in `epicsTime.h:102-104`:
175    ///
176    ///   - `epicsTimeEventCurrentTime = 0` → wall-clock now
177    ///   - `epicsTimeEventBestTime    = -1` → generalTime BestTime providers
178    ///   - `epicsTimeEventDeviceTime  = -2` → device support already set time
179    ///   - `1..` → event-number providers
180    ///
181    /// Every non-`-2` case goes through one C call, `epicsTimeGetEvent(tse)`,
182    /// which delegates to `epicsTimeGetCurrent` for `tse==0` and to
183    /// `generalTimeGetEventPriority` otherwise. Only `-2` (device time)
184    /// is left untouched because the device support has already written
185    /// the timestamp before `recGblGetTimeStamp` is called.
186    ///
187    /// A TSE C rejects — anything below `epicsTimeEventBestTime`, or any event
188    /// number with no provider to answer it — is not a stamp: C writes nothing
189    /// into `precord->time` and errlogs, so a misconfigured record holds its
190    /// stale stamp rather than timestamping as if healthy.
191    fn apply_timestamp(name: &str, common: &mut CommonFields, _is_soft: bool) {
192        // Single owner of TSE -> TIME resolution; device support that must
193        // format the record's resolved time during `read()` routes through the
194        // same helper so the two never drift (see `recgbl::get_time_stamp`).
195        // For TSE=-2 the helper returns `common.time` unchanged, preserving the
196        // device-time "leave it alone" semantics.
197        match crate::server::recgbl::get_time_stamp(common.tse, common.time) {
198            Some(t) => common.time = t,
199            None => crate::runtime::log::errlog_printf(&format!(
200                "recGblGetTimeStampSimm: epicsTimeGetEvent failed, {name}.TSE = {}\n",
201                common.tse
202            )),
203        }
204    }
205}
206
207/// Unified entry in the PV database.
208pub enum PvEntry {
209    Simple(Arc<ProcessVariable>),
210    Record(Arc<RecordCell>),
211}
212
213/// Callback for resolving external PV names (CA/PVA links).
214/// Returns the *cached* value of the external PV, or `None` if unavailable.
215///
216/// **Sync**, for the same reason [`LinkSet::get_cached_value`] is: this runs
217/// on the record-processing thread with the record's L1 gate held, and C's
218/// `dbCaGetLink` likewise only reads `pca->pgetNative` under `pca->lock` —
219/// it never waits for the wire (`dbCa.c:419-506`). A resolver that cannot
220/// answer from cache must stage the open on its own executor and return
221/// `None` (C's `!pca->isConnected` arm, `dbCa.c:430-435`).
222pub type ExternalPvResolver = Arc<dyn Fn(&str) -> Option<EpicsValue> + Send + Sync>;
223
224/// Async hook invoked by [`PvDatabase::has_name`] when a name is not yet
225/// in the database. Used by the CA gateway and similar proxy components
226/// to lazily populate PVs on first search.
227///
228/// The resolver should:
229/// 1. Determine whether the name should be served (e.g., check ACL)
230/// 2. Take whatever action is needed to make `has_name` return true on
231///    a subsequent call (e.g., subscribe to an upstream IOC and call
232///    `add_pv` with a placeholder value)
233/// 3. Return `true` if the name is now resolvable, `false` otherwise
234///
235/// Returning `true` causes `has_name` to re-check the database. The
236/// resolver may take some time (TCP search, upstream connect handshake);
237/// the caller (UDP search responder, TCP CREATE_CHANNEL handler) will
238/// `.await` it.
239/// The second argument is the downstream client's socket address when
240/// the lookup originates from a CA/PVA search or channel-create on
241/// behalf of an identified peer (`None` for host-less internal lookups:
242/// preload, iocsh, link processing). the CA gateway needs
243/// this to evaluate `.pvlist` `DENY FROM host` rules at search time, the
244/// way C ca-gateway's `pvExistTest` passes the client host to
245/// `gateAs::findEntry`.
246pub type SearchResolver = Arc<
247    dyn Fn(
248            String,
249            Option<std::net::SocketAddr>,
250        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
251        + Send
252        + Sync,
253>;
254
255/// Per-request admission gate for an **already-registered** simple PV.
256///
257/// A plain IOC's simple PVs are authoritative: once registered they
258/// exist unconditionally, so no gate is installed and the cached-PV
259/// short-circuit in [`PvDatabase::find_entry_from`] /
260/// [`PvDatabase::has_name_from`] is unchanged. A CA gateway is
261/// different — its shadow PVs are projections of an upstream that can be
262/// host-denied for a given requester or disconnected — so it installs a
263/// gate that the lookup path consults *before* returning a cached simple
264/// PV. Returning `false` makes the database answer "does not exist" for
265/// that requester, exactly as C ca-gateway's `pvExistTest` returns
266/// `pverDoesNotExistHere` for a host-denied or disconnected PV
267/// (`gateServer.cc:1516-1637`) — without removing the PV object, so its
268/// cached value stays available for diagnostics and re-admission.
269///
270/// The first argument is the filter-suffix-stripped record path (the
271/// same key the simple-PV map and the gateway cache use); the second is
272/// the requesting peer (`None` for host-less internal lookups). The gate
273/// governs **only** simple PVs — records and aliases are never
274/// gateway-managed and bypass it.
275pub type ExistenceGate = Arc<
276    dyn Fn(
277            String,
278            Option<std::net::SocketAddr>,
279        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
280        + Send
281        + Sync,
282>;
283
284/// Internal state of [`PvDatabase`].
285///
286/// # Invariant — alias-aware lookup (epics-base PR #336)
287///
288/// **MUST**: every record-name lookup that originates from an
289/// external API (CA/PVA server, link processing, iocsh, bridge
290/// providers) MUST go through [`PvDatabase::get_record`] /
291/// [`PvDatabase::find_entry`] / [`PvDatabase::has_name`], never
292/// `inner.records.read().await.get(...)` directly.
293///
294/// **MUST NOT**: a function that takes an arbitrary record-name
295/// `&str` and reads `inner.records` directly, unless one of:
296/// - the function is itself an alias-management primitive
297///   (`add_record`, `remove_record`, `add_alias`,
298///   `find_entry_no_resolve`, `has_name_no_resolve`,
299///   `get_record_no_resolve`, `all_record_names`), OR
300/// - the name has been normalised to canonical earlier in the
301///   same scope (the `let canonical_owned; let name: &str = ...`
302///   pattern in `process_record_with_links_inner` /
303///   `complete_async_record_inner` / `put_record_field_from_ca` /
304///   `put_pv`).
305///
306/// **Owner/Gate:** `PvDatabase::get_record` (alias-aware path).
307///
308/// New code that adds a record-name entry point should call
309/// `get_record` first OR run the canonical-normalisation snippet
310/// at function entry. Direct `inner.records` access is reserved
311/// for the alias-management primitives listed above.
312/// One CP/CPP edge in the `PvDatabaseInner::cp_links` index: the record
313/// to (re)process when the source record changes.
314///
315/// `passive_only` distinguishes CPP from CP. C adds the `CA_DBPROCESS`
316/// action for a CP link unconditionally, but for a CPP link only when the
317/// link-holding record's `SCAN` is Passive (`dbCa.c:825`, `:959`, `:1034`). CP
318/// edges clear the flag; CPP edges set it, and `dispatch_cp_targets`
319/// honours it.
320#[derive(Clone, Debug)]
321pub struct CpTarget {
322    pub record: String,
323    pub passive_only: bool,
324}
325
326/// The scan index — one independently locked bucket per [`crate::server::record::ScanList`].
327///
328/// C's shape (`dbScan.c`): `scan_list` carries its own `epicsMutexId lock`
329/// (`:75`) and `scanList` / `addToList` / `deleteFromList` take only that
330/// one list's lock, so two periodic rates never wait on each other. The port
331/// used to hold a single `RwLock` over the whole `ScanList → bucket` map,
332/// which is coarser than C on the highest-contention path in the database.
333///
334/// There is no lock over the table itself, and that is structural rather than
335/// an optimisation: the set of scan lists is fixed by `menuScan`
336/// ([`crate::server::record::ScanList::count`]), so the table is fully populated at construction and
337/// never mutated. A bucket is reached by [`crate::server::record::ScanList::slot`], a total index —
338/// there is no absent-bucket case for a caller to handle, and therefore no
339/// path on which a lookup could take the wrong lock.
340///
341/// A scan list's sort key — C's feed order into `addToList`, spelled out.
342///
343/// `buildScanLists` (`dbScan.c:1054-1076`) feeds `scanAdd` **record-type-major**:
344/// the outer loop walks `pdbbase->recordTypeList`, which is DBD load order, and
345/// the inner loop walks that type's instances in `.db` load order. `addToList`
346/// (`:1085-1091`) appends after the last element whose `phas <=` the new
347/// record's, so within one PHAS the list is a stable FIFO over exactly that
348/// feed order. A key ordered by `.db` load order alone inverts, by one whole
349/// scan cycle, every same-PHAS reader/writer pair whose declaration order
350/// contradicts DBD order.
351///
352/// A struct rather than a tuple because the field order IS the sort rule: the
353/// derived `Ord` reads top to bottom, and a positional tuple gave the type
354/// ordinal and the load-order sequence the same shape.
355///
356/// The key also carries the instance it names. A sweep otherwise hands the
357/// frame a `&str` and the frame hashes it back into the records map once per
358/// record per cycle, re-deriving what the list already knew. `Weak` and not
359/// `Arc` so a key left behind by a removal keeps nothing alive; a sweep that
360/// cannot upgrade falls back to the name.
361///
362/// `Ord`/`Eq` are the four sort fields alone — the handle is what the key
363/// names, not part of which key it is — so the ordering contract above is
364/// unchanged and a key built for a lookup need not carry one.
365#[derive(Clone, Debug)]
366struct ScanKey {
367    phas: i16,
368    /// Position in [`RECORD_TYPE_ORDER`](crate::server::record::dbd_generated::RECORD_TYPE_ORDER). A record type no
369    /// vendored `.dbd` declares sorts after every one that is declared, which
370    /// is where C puts it too — a module `.dbd` is included after `base.dbd`,
371    /// so its types join `recordTypeList` behind base's.
372    record_type: u32,
373    load_order: u64,
374    /// An `Arc` and not a `String` because the cursor hands this name out once
375    /// per record per scan cycle, and a `String` there made every sweep
376    /// allocate — twice, since the cursor step clones the key as well.
377    /// `Ord` on `Arc<str>` is `str`'s, so the sort rule above is unchanged.
378    name: Arc<str>,
379    /// The instance `name` resolves to — see the type doc.
380    handle: std::sync::Weak<RecordCell>,
381}
382
383impl ScanKey {
384    fn sort_fields(&self) -> (i16, u32, u64, &str) {
385        (self.phas, self.record_type, self.load_order, &self.name)
386    }
387}
388
389impl PartialEq for ScanKey {
390    fn eq(&self, other: &Self) -> bool {
391        self.sort_fields() == other.sort_fields()
392    }
393}
394
395impl Eq for ScanKey {}
396
397impl PartialOrd for ScanKey {
398    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
399        Some(self.cmp(other))
400    }
401}
402
403impl Ord for ScanKey {
404    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
405        self.sort_fields().cmp(&other.sort_fields())
406    }
407}
408
409impl ScanKey {
410    fn new(
411        phas: i16,
412        record_type: &str,
413        load_order: u64,
414        name: &str,
415        handle: std::sync::Weak<RecordCell>,
416    ) -> Self {
417        use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
418        Self {
419            phas,
420            record_type: RECORD_TYPE_ORDER
421                .iter()
422                .position(|t| *t == record_type)
423                .unwrap_or(RECORD_TYPE_ORDER.len()) as u32,
424            load_order,
425            name: Arc::from(name),
426            handle,
427        }
428    }
429}
430
431/// One node of the list C's `dbFirstRecord` / `dbNextRecord` pair walks.
432///
433/// C keeps records and aliases in a single per-record-type `recList`:
434/// `dbCreateAlias` `ellAdd`s the alias node into the very list the records
435/// live in (`dbStaticLib.c:1703`). A walk that wants records only therefore
436/// has to SAY so — `if (dbIsAlias(pdbentry)) continue`, as `dbjlr`
437/// (`dbJLink.c:520`) and `dbcar` (`dbCaTest.c:85`) do — and a walk that says
438/// nothing lists both, as `dbl` (`dbTest.c:180-185`) and `dbglob`
439/// (`:325-333`) do.
440///
441/// This port holds the two in separate maps, so "record or alias" was a
442/// question each caller answered for itself, and every caller that answered
443/// it by walking `all_record_names` alone answered it wrong for aliases with
444/// no way to notice. [`PvDatabase::all_db_nodes`] reassembles C's one list;
445/// `alias_of` is `dbIsAlias`.
446#[derive(Clone, Debug, PartialEq, Eq)]
447pub struct DbNode {
448    /// The name this node is reached by — an alias node carries the ALIAS
449    /// name, which is what C prints from `dbGetRecordName`.
450    pub name: String,
451    /// The record an alias node stands for, or `None` for a record node.
452    pub alias_of: Option<String>,
453}
454
455struct PvDatabaseInner {
456    /// The simple-PV directory — C's `dbPvdLib.c` process-variable directory,
457    /// whose per-bucket `epicsMutexId lock` (`:30`, created `:119`) is taken
458    /// for both `dbPvdFind` (`:123-136`) and `dbPvdAdd` (`:150-162`). C has no
459    /// reader-writer primitive anywhere in the IOC
460    /// (`rg pthread_rwlock epics-base/modules/` → zero hits), so a PI mutex is
461    /// not a demotion against C — it *is* C's construction.
462    ///
463    /// **Every reader MUST bind the lookup result in a statement of its own**
464    /// (`let pv = …lock().get(name).cloned();`) rather than reading the map in
465    /// an `if let` scrutinee. The guard is `!Send`, and an `if let` scrutinee
466    /// temporary lives to the end of the `if let` *body* — so the scrutinee
467    /// form keeps the guard alive across any `.await` the body makes and turns
468    /// the enclosing `async fn` into a `!Send` future at its `tokio::spawn`
469    /// site. The rule is uniform across all 17 read sites, not applied only to
470    /// the ones that await today, so a new `.await` in an existing body cannot
471    /// re-open it.
472    simple_pvs:
473        crate::runtime::sync::PriorityInheritanceMutex<HashMap<String, Arc<ProcessVariable>>>,
474    /// Wake-up for servers holding channels on this database: something was
475    /// removed, re-check what you serve. Carries no name — the receiver
476    /// tests the target it already holds
477    /// ([`ProcessVariable::is_destroyed`] /
478    /// [`RecordInstance::is_destroyed`]), so an alias, a `.FIELD` suffix or
479    /// a `.{filter}` suffix on the client's channel name cannot make the
480    /// match miss the way a name comparison would.
481    pv_destroyed_tx: tokio::sync::broadcast::Sender<()>,
482    /// Every registered record, keyed by its canonical name.
483    ///
484    /// The key is an `Arc<str>` because it is the ONE allocation of that name:
485    /// `PvDatabase::lookup_record` hands it out and every hop of a process
486    /// chain — the cycle guard, the scan key, the lock-set lookup — carries a
487    /// share of it rather than a copy. A name-keyed map that owned `String`s
488    /// made a scan step allocate the name of every record it touched.
489    records: RecursiveReadLock<HashMap<Arc<str>, Arc<RecordCell>>>,
490    /// Scan index: maps scan list → sorted set of [`ScanKey`].
491    ///
492    /// C parity (`dbScan.c:1052-1095`): `buildScanLists` walks record types in
493    /// DBD load order and, within each, that type's instances in `.db` load
494    /// order; `addToList` inserts each after the last element with
495    /// `phas <= precord->phas`, so within one PHAS the list is a stable FIFO
496    /// over that feed order. Both halves of the feed order are in the key —
497    /// the record-type ordinal first, the `.db` load sequence second. The
498    /// record name is only a final tiebreak and never decides the order of two
499    /// real records.
500    /// Keyed by [`crate::server::record::ScanList`], not `ScanType`: a `Passive` or illegal SCAN names
501    /// no list (C `scanAdd`, dbScan.c:241-251) and so cannot be a key at all.
502    ///
503    /// **One lock per scan list, not one lock over the index.** C has one
504    /// `epicsMutexId lock` per `scan_list` (`dbScan.c:75`, created `:527`,
505    /// `:604`, `:908`) and never serialises two rates against each other. A
506    /// single map-wide lock would serialise the seven periodic threads (bands
507    /// 60–66) that C runs independently, on the one path where both ends of
508    /// the contention pair are banded. See [`scan_index::ScanIndex`].
509    scan_index: scan_index::ScanIndex,
510    /// Per-record load-order sequence number, assigned monotonically
511    /// at `add_record`. Used as the secondary scan-index sort key so
512    /// same-PHAS records preserve database load order. Survives a
513    /// `remove_record` + re-`add_record` (the re-add gets a fresh,
514    /// higher sequence — matching a fresh `.db` reload).
515    ///
516    /// Read-modify-write cell (`add_loaded_record` inserts, `remove_record`
517    /// removes), so it is a [`SnapshotCell`], not a bare `ArcSwap`: the
518    /// writer gate is what makes insert-then-publish atomic. Both writers
519    /// also hold [`Self::registration_mutex`] today, but the gate keeps the
520    /// RMW correct without depending on that — L46's type changes in step 4.
521    load_order: SnapshotCell<HashMap<String, u64>>,
522    /// Monotonic counter feeding `load_order`.
523    load_order_counter: std::sync::atomic::AtomicU64,
524    /// CP/CPP link index: maps source_record → target edges to process when
525    /// the source changes. Each edge carries the CP-vs-CPP distinction (see
526    /// [`CpTarget`]).
527    ///
528    /// Read-modify-write cell with **two writers that share no other gate**:
529    /// `register_cp_link` (`links.rs:2918`) takes no
530    /// [`Self::registration_mutex`], `remove_record` (`mod.rs:2056`) does. The
531    /// `RwLock`'s write exclusion was the only thing serialising them, so the
532    /// [`SnapshotCell`] writer gate here is required, not defensive.
533    cp_links: SnapshotCell<HashMap<String, Vec<CpTarget>>>,
534    /// External (CA/PVA) CP/CPP link index: maps the *external PV name*
535    /// (the cross-IOC source, e.g. `OTHER:PV` from `INP="OTHER:PV CP"`)
536    /// → holder edges to process when that remote PV changes. The local
537    /// [`Self::cp_links`] index is keyed by a local source RECORD that
538    /// processes here; a cross-IOC source never processes locally, so its
539    /// only trigger is the calink/pvalink CA monitor callback, which calls
540    /// [`PvDatabase::dispatch_external_cp_targets`]. Parity with C
541    /// `dbCa.c:958-962` `eventCallback` adding `CA_DBPROCESS`.
542    ///
543    /// Read-modify-write cell; sole writer `register_external_cp_link`
544    /// (`links.rs:2968`) merges into an existing edge list, so concurrent
545    /// registrations need the [`SnapshotCell`] writer gate.
546    external_cp_links: SnapshotCell<HashMap<String, Vec<CpTarget>>>,
547    /// Alias map: alternate-name → real-record-name. Mirrors epics-base
548    /// PR #336 (alias name validation + parsing). `find_entry` and
549    /// related lookups consult this map after the canonical record
550    /// table so an alias resolves transparently to its target.
551    aliases: RecursiveReadLock<HashMap<String, String>>,
552    /// Single gate that serializes
553    /// every `add_pv` / `add_pv_with_hook` / `add_record` /
554    /// `add_alias` / `remove_record` / `remove_simple_pv` /
555    /// `remove_alias`. Without this, the per-method write-lock
556    /// orders (`simple_pvs` first vs. `records` first vs.
557    /// `aliases` first) could deadlock under concurrent registrations,
558    /// and `add_record`'s post-insert `scan_index.write()` had a
559    /// TOCTOU window where `remove_record` could land between the
560    /// records map insert and the scan-index insert and leave a
561    /// phantom scan entry.
562    ///
563    /// Holding this mutex makes the cross-namespace `check_name_free`
564    /// peek atomic with the target-map insert, eliminates the
565    /// scan-index race, and lets `remove_*` purge dangling aliases
566    /// without a second pass.
567    ///
568    /// This gate is taken
569    /// **inside** the L1 record-gate window on the SCAN-put path
570    /// (`scan_index.rs:122`, reached from `field_io.rs`'s `update_scan_index`
571    /// calls), so it converts with L8a/L8b rather than after them — leaving it
572    /// async while the locks nested under it are blocking is the worst of both.
573    /// The acquisition-order MUST rule that governs the nesting is in
574    /// `record_lock.rs`'s module doc.
575    ///
576    /// **No holder may `.await` while holding it.** The guard is `!Send`, so
577    /// the compiler enforces this at every `tokio::spawn` site; the eight
578    /// holders were audited before the conversion and the only suspension
579    /// points any of them had were acquisitions of `simple_pvs` and
580    /// `scan_index`, both blocking now.
581    ///
582    /// Acquired ONLY through [`PvDatabase::lock_registration`], never
583    /// directly — that funnel is what turns a re-entrant take into a named
584    /// panic instead of a parked thread. See [`RegistrationGate`].
585    registration_mutex: crate::runtime::sync::PriorityInheritanceMutex<()>,
586    /// The IOC lifecycle phase — the port's `iocInit` boundary. See
587    /// [`DbInitPhase`], [`PvDatabase::begin_load`],
588    /// [`PvDatabase::schedule_record_init`] and [`PvDatabase::ioc_init`].
589    init_phase: std::sync::Mutex<DbInitPhase>,
590    /// Record inits parked because the record they classify is not registered
591    /// yet — see [`PvDatabase::schedule_record_init`]. Keyed by record name and
592    /// released by [`PvDatabase::add_loaded_record`] the moment that name lands
593    /// in `records`, which is what makes "the init observes a registered
594    /// record" hold by construction rather than by scheduler timing.
595    record_init_waiting: std::sync::Mutex<HashMap<String, Vec<RecordInit>>>,
596    /// Records whose device-support binding and `init_record` passes are OWED
597    /// to [`PvDatabase::ioc_init`] because they were created during the LOAD
598    /// phase — C's `dbLoadRecords` links a record into `pdbbase` at once but
599    /// runs `init_record` only at `iocInit`. [`PvDatabase::add_loaded_record`]
600    /// used to bind the dset and run the passes eagerly at load time, so a
601    /// record whose device-support PORT is configured by a later `st.cmd`
602    /// command (ADCore's `NDTimeSeriesConfigure` builds the `*_TS` port AFTER
603    /// `dbLoadRecords(NDStats.template)`) bound to a missing port and lost its
604    /// device support. Names are pushed in load order and drained once, in that
605    /// order, by `ioc_init`. Empty on every path but a LOAD.
606    deferred_record_inits: std::sync::Mutex<Vec<String>>,
607    /// C `plink->text != NULL` for the one case the port's link storage cannot
608    /// tell apart on its own: a link field the `.db` assigned the EMPTY string.
609    ///
610    /// `dbInitRecordLinks` checks a link only when the load gave it text
611    /// (`if (!plink->text) continue;`, `dbStaticLib.c:2213`), so
612    /// `field(INP,"")` on an `INST_IO` device is refused while the same record
613    /// with no `INP` line at all is not — and this port keeps link text in the
614    /// field itself, where those two are the same empty string. Only `INP` and
615    /// `OUT` can be refused while empty (every other link field expects
616    /// `CONSTANT`, which the empty text already is), and the loader always
617    /// routes those two through `RecordLoad::common_fields`, so recording the
618    /// empty assignments here makes the distinction exact rather than
619    /// approximate. Consumed and cleared by
620    /// [`PvDatabase::db_init_record_links`], the way C frees the text.
621    empty_link_assignments: std::sync::Mutex<HashMap<String, Vec<String>>>,
622    /// Lines queued by the iocsh `afterIocRunning <command>` directive
623    /// (epics-base PR #558). Drained by the IOC application after PINI
624    /// completes, then re-executed through a fresh IocShell so the
625    /// commands run with the database in its post-init state.
626    after_ioc_running: std::sync::Mutex<Vec<String>>,
627    /// Optional resolver for external PVs (ca://, pva:// links).
628    ///
629    /// Whole-value replace: the only writer stores a complete new value
630    /// ([`PvDatabase::set_external_resolver`]), so an
631    /// [`ArcSwapOption`] store IS the mutation and no writer gate is
632    /// needed. Readers take the `Arc` with no lock at all.
633    external_resolver: ArcSwapOption<ExternalPvResolver>,
634    /// Optional async resolver invoked on `has_name` misses (e.g. CA gateway).
635    ///
636    /// Whole-value replace, as [`Self::external_resolver`] (§3 row L8g).
637    search_resolver: ArcSwapOption<SearchResolver>,
638    /// Optional per-request gate consulted before a *cached* simple PV is
639    /// advertised as existing (e.g. CA gateway host/state admission). See
640    /// [`ExistenceGate`]. `None` for a plain IOC (short-circuit unchanged).
641    ///
642    /// Whole-value replace, as [`Self::external_resolver`] (§3 row L8h).
643    existence_gate: ArcSwapOption<ExistenceGate>,
644    /// The database debugger, installed by the first `dbb` and removed when
645    /// its last breakpoint goes — C's `lset_stack_count`, which `dbProcess`
646    /// tests before it calls either breakpoint hook (`dbAccess.c:504`,
647    /// `:614`).
648    ///
649    /// Whole-value replace, as [`Self::external_resolver`]: a database nobody
650    /// is debugging pays one relaxed atomic load per processed record where C
651    /// pays one comparison, and the mechanism stays testable and removable
652    /// instead of welded into `process_record_with_links_body` as a pair of
653    /// `if` branches.
654    breakpoints: ArcSwapOption<breakpoint::BreakpointTable>,
655    /// Whether anything is being debugged — C's `lset_stack_count != 0`
656    /// (`dbAccess.c:504`), the test both breakpoint hooks make on every
657    /// processed record.
658    ///
659    /// Derived from [`Self::breakpoints`], and written only by the two
660    /// functions that write that slot, so the pair cannot disagree. It exists
661    /// because the hooks ask a yes/no question and `ArcSwapOption::load_full`
662    /// answers it by cloning an `Arc` behind a hazard pointer — twice per
663    /// record per scan cycle for a database nobody is debugging, which is the
664    /// one comparison C pays turned into two.
665    ///
666    /// Ordered so a stale read is always the harmless one: the flag is raised
667    /// BEFORE the table is installed and lowered AFTER it is dropped, so a
668    /// reader can see `true` with no table (and find nothing to do) but never
669    /// `false` with a live table.
670    debugging: std::sync::atomic::AtomicBool,
671    /// Per-scheme link sets — pluggable backends for `pva://` /
672    /// `ca://` link resolution. Consulted before the legacy
673    /// [`ExternalPvResolver`] in `resolve_external_pv`.
674    /// Mirrors the C-EPICS lset abstraction.
675    ///
676    /// Read-modify-write cell (`register_link_set` inserts one scheme into
677    /// the existing registry), so it takes the [`SnapshotCell`] writer gate.
678    /// Every reader either resolves one scheme inside a single expression or
679    /// collects the lsets and drops the registry **before** awaiting — the
680    /// deliberate discipline documented at `links.rs:951-952` — so a coherent
681    /// snapshot is what the read paths already assumed.
682    link_sets: SnapshotCell<link_set::LinkSetRegistry>,
683    /// Pending external OUT-link writes — the `dbCa` `workList` analogue.
684    /// Record processing stages a write here and returns; the queue's single
685    /// owner task performs the `ca://`/`pva://` network write off the
686    /// record's advisory write gate, exactly as `dbCaTask` does
687    /// (`dbCa.c:1093-1260`). See [`link_put_queue`].
688    link_puts: Arc<link_put_queue::LinkPutQueue>,
689    /// True once the ScanScheduler has been started for this DB.
690    /// Prevents duplicate scan tasks when multiple protocol servers (CA + PVA)
691    /// both try to start scanning on the same DB.
692    scan_started: std::sync::atomic::AtomicBool,
693    /// True once PINI processing has completed. Non-owner schedulers await
694    /// this before running their hooks, preserving the "PINI before hooks"
695    /// ordering contract.
696    pini_done: std::sync::atomic::AtomicBool,
697    /// Fired by the scan owner after PINI completes. Non-owners register
698    /// interest on this before re-checking `pini_done` to avoid missing the
699    /// signal (`notify_waiters` does not store a permit).
700    pini_notify: tokio::sync::Notify,
701    /// Per-record advisory write gates — the Rust
702    /// counterpart of the C-EPICS `dbScanLock` / `dbLocker`
703    /// machinery. Every plain CA/PVA write, the QSRV atomic group
704    /// PUT/GET, and the pvalink atomic scan-on-update epoch all
705    /// acquire these gates, so no two of them can interleave on a
706    /// shared record. See [`record_lock`].
707    record_locks: record_lock::RecordLockRegistry,
708    /// Per-record-type attributes — C `dbRecordType::attributeList`.
709    ///
710    /// C materialises the list once the `.dbd` is read: `dbReadCOM`'s tail
711    /// (`dbLexRoutines.c:311-331`) gives every record type `RTYP` = its own
712    /// name and `VERS` = `"none specified"`, and `dbPutRecordAttribute`
713    /// (`dbStaticLib.c:1232-1277`) adds or overwrites from there. Both maps
714    /// are `BTreeMap`s because C keeps the inner list in `strcmp` order and
715    /// walks it in that order in `dbGetAttributePart` and
716    /// `dbDumpRecordType`; sorting is the container's job here rather than an
717    /// insertion dance.
718    ///
719    /// A leaf lock: no other lock is taken while it is held, and it is taken
720    /// while a record read guard is live (`PvDatabase::get_pv`), never the
721    /// reverse.
722    record_attributes: crate::runtime::sync::PriorityInheritanceMutex<
723        std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>,
724    >,
725    /// Subroutine functions by name, retained at runtime so the processing
726    /// path can re-resolve an aSub's subroutine when its name changes
727    /// (C `aSubRecord.c::fetch_values` `registryFunctionFind`, LFLG=READ /
728    /// SUBL). Populated once at iocInit from the IocApp/IocBuilder registry;
729    /// read-only thereafter.
730    ///
731    /// Whole-registry replace ([`PvDatabase::install_subroutine_registry`]),
732    /// so an [`ArcSwap`] store IS the mutation and no writer gate is needed
733    /// (§3 row L8j). `OnceLock` was rejected: install is a `pub async fn` that
734    /// tests and a second `iocInit` may call again, and `OnceLock` would
735    /// silently drop the second registry instead of replacing it.
736    subroutine_registry: ArcSwap<HashMap<String, Arc<crate::server::record::SubroutineFn>>>,
737    /// C's process-global device support table, seen through
738    /// `dbDTYPtoDevSup`: the lookup `doInitRecord0` makes to fill
739    /// `precord->dset` BEFORE it calls `prset->init_record(precord, 0)`
740    /// (`iocInit.c:530-536`).
741    ///
742    /// It lives on the DATABASE, not on the builder that collected the
743    /// factories, because the creation sink is the only place that can bind a
744    /// dset at C's position — ahead of the record's own init passes. While the
745    /// two builders each held their own copy, the bind could only happen after
746    /// the whole database had been built, so every record type's `init_record`
747    /// ran with `dset == NULL` invisible to it and executed the tail C's
748    /// `if (!pdset) return S_dev_noDSET` skips.
749    ///
750    /// `None` until a builder installs one — a database assembled by hand
751    /// (unit tests, `PvDatabase::new`) registers no device support at all,
752    /// which is C's empty `devList` and gives the same answer.
753    device_support_resolver: ArcSwapOption<crate::server::ioc_app::DeviceSupportResolver>,
754    /// Breakpoint tables by name (C `bptList`), shared by every db-load path so
755    /// `ai`/`ao` records with `LINR >= 3` resolve their linearisation table. An
756    /// `Arc` snapshot is installed on each record at creation; the master grows
757    /// (copy-on-write via [`PvDatabase::add_breaktables`]) as `dbLoadRecords`
758    /// loads more `breaktable(...)` definitions, so build-time and runtime
759    /// loads share one registry.
760    ///
761    /// Read-modify-write cell (`add_breaktables` clones the registry, inserts
762    /// and republishes), so it takes the [`SnapshotCell`] writer gate. The
763    /// value was already `Arc`-shared, so the cell replaces the outer lock
764    /// with nothing at all on the read side.
765    breaktable_registry: SnapshotCell<crate::server::cvt_bpt::BreakTableRegistry>,
766}
767
768thread_local! {
769    /// Set for exactly as long as this thread holds L46. Read only by
770    /// [`PvDatabase::lock_registration`].
771    static REGISTRATION_GATE_HELD: std::cell::Cell<Option<&'static str>> =
772        const { std::cell::Cell::new(None) };
773}
774
775/// Whether this thread is inside a [`RegistrationGate`] window — the L46
776/// half of the check a fresh lock-set acquisition makes in debug builds
777/// (`record_lock.rs`, `LockSet::acquire`).
778pub(super) fn registration_gate_held() -> bool {
779    REGISTRATION_GATE_HELD.with(|h| h.get().is_some())
780}
781
782thread_local! {
783    /// How many [`RecursiveReadLock`] read guards this thread holds. Read by
784    /// `LockSet::lock_fresh` (`record_lock.rs`) in debug builds.
785    static MAP_READ_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
786}
787
788/// Whether this thread holds a read guard on the records map or the alias
789/// table — the other half of the check a fresh lock-set acquisition makes
790/// in debug builds (`record_lock.rs`, `LockSet::lock_fresh`), beside
791/// [`registration_gate_held`].
792pub(super) fn map_read_held() -> bool {
793    MAP_READ_DEPTH.with(|d| d.get() > 0)
794}
795
796/// A reader-writer lock whose readers never queue behind a waiting writer.
797///
798/// The records map and the alias table are read from inside a record's lock
799/// set — every `get_record` on the process path — and written only by the
800/// registration entry points. With a fair `RwLock`, a reader arriving while
801/// a writer waits is parked, and that parks a thread holding a lock set: if
802/// the reader the writer is waiting on then wants that same set (a
803/// registration-path walk that reads records), the three are wedged. With
804/// `read_recursive` a reader is parked only by an ACTIVE writer, so a
805/// reader that already holds a set is never wedged by a writer.
806///
807/// The writer side needs the converse rule. `remove_record_entry` holds the
808/// removed record's lock set while it takes the write lock — its `destroy`
809/// must be exclusive against a process cycle — so a reader that takes a
810/// FRESH lock set under its read guard can be the reader that writer waits
811/// on, each holding what the other wants. **A read guard MUST NOT be held
812/// across a lock-set acquisition**: clone the `Arc<RecordCell>` out under
813/// the guard, drop the guard, then lock the record. This is the one place
814/// readers of these two maps are handed out, and every guard it hands out
815/// counts itself on the thread, so `LockSet::lock_fresh` can fail on the
816/// thread that broke the rule instead of wedging two threads later.
817pub(crate) struct RecursiveReadLock<T> {
818    lock: parking_lot::RwLock<T>,
819    /// Bumped under every write lock, so a reader can tell whether the map
820    /// may have changed since it last looked — what a cached link target
821    /// ([`crate::server::record::record_instance::LinkTargetResolver`])
822    /// checks instead of re-resolving a name every cycle.
823    revision: std::sync::atomic::AtomicU64,
824}
825
826impl<T> RecursiveReadLock<T> {
827    pub(crate) fn new(value: T) -> Self {
828        Self {
829            lock: parking_lot::RwLock::new(value),
830            revision: std::sync::atomic::AtomicU64::new(0),
831        }
832    }
833
834    pub(crate) fn read(&self) -> MapReadGuard<'_, T> {
835        let guard = self.lock.read_recursive();
836        MAP_READ_DEPTH.with(|d| d.set(d.get() + 1));
837        MapReadGuard { guard }
838    }
839
840    /// Bumps the revision once the write lock is held, so a reader that
841    /// loaded the revision before reading the map either saw the old number
842    /// or waited for this write to finish.
843    pub(crate) fn write(&self) -> parking_lot::RwLockWriteGuard<'_, T> {
844        let guard = self.lock.write();
845        self.revision
846            .fetch_add(1, std::sync::atomic::Ordering::Release);
847        guard
848    }
849
850    pub(crate) fn revision(&self) -> u64 {
851        self.revision.load(std::sync::atomic::Ordering::Acquire)
852    }
853}
854
855/// A [`RecursiveReadLock`] read guard, counted on the thread that holds it
856/// for as long as it lives — see [`map_read_held`].
857pub(crate) struct MapReadGuard<'a, T> {
858    guard: parking_lot::RwLockReadGuard<'a, T>,
859}
860
861impl<T> std::ops::Deref for MapReadGuard<'_, T> {
862    type Target = T;
863    fn deref(&self) -> &T {
864        &self.guard
865    }
866}
867
868impl<T> Drop for MapReadGuard<'_, T> {
869    fn drop(&mut self) {
870        MAP_READ_DEPTH.with(|d| d.set(d.get() - 1));
871    }
872}
873
874/// The database's end is every record's removal — `remove_record`'s rule,
875/// removal IS destruction, applied to the records the map still holds. What
876/// lets the cells drop with the map: a link target handle holds its target's
877/// `Arc`, so two records linked to each other hold each other until
878/// [`RecordInstance::destroy`] lets go.
879impl Drop for PvDatabaseInner {
880    fn drop(&mut self) {
881        for rec in self.records.lock.get_mut().values() {
882            rec.write().destroy();
883        }
884    }
885}
886
887/// RAII guard for L46, `PvDatabaseInner::registration_mutex`.
888///
889/// L46 is a `PriorityInheritanceMutex` and is therefore NOT reentrant: a
890/// thread that takes it twice parks on itself forever. That makes the
891/// caller-side rule a MUST, and it is the half the lock-order table in
892/// `super::record_lock` did not state —
893/// [`PvDatabase::update_scan_index`] is the single owner of a scan-index
894/// transition and takes L46 **itself**, so no caller may hold L46 across a
895/// call to it. Releasing early is also what C does: `iterateRecords`
896/// (`iocInit.c:562-586`) is a separate pass over an already-built database,
897/// holding no registration lock at all.
898///
899/// A violation used to surface as a hung thread, which reads as a flaky
900/// timeout and costs a bisect to attribute. This guard makes it surface as a
901/// panic naming both the holder and the re-entrant site.
902#[must_use = "L46 is released as soon as the guard is dropped"]
903pub(crate) struct RegistrationGate<'a> {
904    _guard: crate::runtime::sync::PriorityInheritanceMutexGuard<'a, ()>,
905}
906
907impl Drop for RegistrationGate<'_> {
908    fn drop(&mut self) {
909        REGISTRATION_GATE_HELD.with(|h| h.set(None));
910    }
911}
912
913/// Database of all process variables hosted by this server.
914#[derive(Clone)]
915pub struct PvDatabase {
916    inner: Arc<PvDatabaseInner>,
917}
918
919/// A record initialisation owed to `iocInit` — the port's `init_record`
920/// tail. Built by a record's `refresh_link_status` and handed to
921/// [`PvDatabase::schedule_record_init`].
922type RecordInit = std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>;
923
924/// The IOC lifecycle phase, and with it the answer to "may a record's links be
925/// classified against the database as it stands right now?".
926///
927/// C runs `init_record` — where a record classifies its links (`checkLinks`,
928/// `dbNameToAddr`) — from `iocInit`, i.e. after EVERY `dbLoadRecords` block
929/// has been read. A forward reference across two `dbLoadRecords` calls in one
930/// `st.cmd` is therefore a LOCAL link, deterministically, and the classified
931/// value is final the moment `iocInit` returns (`dbgf` is refused before it).
932///
933/// The boundary is `iocInit`, NOT a load group: gating on the load group left
934/// the multi-`dbLoadRecords` case every real `st.cmd` uses racing 9-in-15
935/// (R18-92). So the phase here is an ioc-lifecycle state.
936///
937/// # The lifecycle is ONE-WAY: `Unloaded → Loading → Running`
938///
939/// R18-92 modelled it with two states, `Loading` and `Complete`, where
940/// `Complete` meant BOTH "never loaded" and "iocInit has run" — so `begin_load`
941/// needed a `Complete → Loading` arm to open the phase at all, and that arm ran
942/// on a post-iocInit load too. One `dbLoadRecords` typed after `iocInit` then
943/// re-armed the queue that only `ioc_init` drains, and every later
944/// classification — including every runtime `special()` link re-point — was
945/// pushed into a `Vec` nothing polls (R19-62, measured: `iocInit;
946/// dbLoadRecords(b.db); dbpf CO.INPA "9.5"` froze `CO.INAV` at 0).
947///
948/// Splitting the two meanings is what closes it: `Loading` is now produced ONLY
949/// from `Unloaded`, so no function in the crate can transition backwards out of
950/// `Running`. The one-way-ness is a property of the transitions that exist, not
951/// of a runtime check.
952enum DbInitPhase {
953    /// No load has begun. `iocInit` is owed nothing, so a classification runs
954    /// immediately — a programmatically built or unit-test database.
955    Unloaded,
956    /// Between the first `dbLoadRecords`/builder load and `iocInit`; holds the
957    /// classifications owed, in issue order. A half-built database is never
958    /// observed, because no classification code runs against one.
959    Loading(Vec<RecordInit>),
960    /// Inside [`PvDatabase::ioc_init`], for the length of
961    /// [`PvDatabase::db_init_record_links`]. That pass rewrites link text, so
962    /// the classifications it triggers belong to THIS build and must be
963    /// awaited by the barrier — spawning them would let `ioc_init` return
964    /// while a record's `INAV`/`OUTV` still described text the pass replaced.
965    /// Also the barrier's own claim: a second `ioc_init` sees a phase that is
966    /// neither `Unloaded` nor `Loading` and returns, and a `dbLoadRecords`
967    /// racing the barrier is refused exactly as a post-`iocInit` load is.
968    Initialising(Vec<RecordInit>),
969    /// `iocInit` has run: the database is final and every link status is
970    /// classified. A classification issued now runs immediately, which is what a
971    /// runtime re-point (`special()` on a link field) needs. TERMINAL — nothing
972    /// re-opens the load phase.
973    Running,
974}
975
976/// [`PvDatabase::begin_load`] was called on a database whose `iocInit` has
977/// already run — C's `getIocState() != iocVoid` (R19-63).
978///
979/// The `Display` text is C's `errSymMsg(S_dbLib_postInitRecRegister)` verbatim
980/// (`dbStaticLib.h:269`), which is what `dbCreateRecord` prints:
981///
982/// ```text
983/// epics> dbCreateRecord(pdbbase,"ai","NEWREC")
984/// ERROR: 33554463 IOC already initialized - No new records can be added
985/// ```
986#[derive(Debug, Clone, Copy, PartialEq, Eq)]
987pub struct IocAlreadyInitialized;
988
989impl std::fmt::Display for IocAlreadyInitialized {
990    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
991        f.write_str("IOC already initialized - No new records can be added")
992    }
993}
994
995impl std::error::Error for IocAlreadyInitialized {}
996
997/// Which record kind a SELM link selection is being computed for.
998/// The Specified/Mask base differs between record types in C, so the
999/// shared selector must know the caller.
1000#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1001pub(crate) enum SelmKind {
1002    /// `fanout` / `seq`: Specified index is `SELN + OFFS` (0-based over
1003    /// LNK0..LNKF / group 0..15); Mask is shifted by `SHFT`.
1004    /// Mirrors `fanoutRecord.c:106-141` and `seqRecord.c:147-178`.
1005    FanoutSeq,
1006    /// `dfanout`: Specified index is `SELN - 1` (1-based, `SELN==0`
1007    /// means "drive nothing", `SELN > OUT_ARG_MAX` is invalid); Mask
1008    /// has NO `SHFT` and `SELN==0` means "no output".
1009    /// Mirrors `dfanoutRecord.c:308-339`.
1010    Dfanout,
1011}
1012
1013/// Result of resolving a SELM/SELN selection.
1014#[derive(Clone, Debug, Default)]
1015pub(crate) struct SelmResult {
1016    /// 0-based link indices to drive (into the LNK0../OUTA.. array).
1017    pub indices: Vec<usize>,
1018    /// `Some` when C would raise an alarm for an out-of-range
1019    /// `SELN`/`OFFS`/`SHFT`. C uses `recGblSetSevr(prec, SOFT_ALARM,
1020    /// INVALID_ALARM)` in every such path.
1021    pub alarm: Option<(u16, crate::server::record::AlarmSeverity)>,
1022}
1023
1024/// Convert a link value to `epicsUInt16` with C `dbGetLink(.., DBR_USHORT,
1025/// ..)` semantics, for the fanout/dfanout/seq `SELL`→`SELN` read — so a
1026/// constant, DB, CA, or PVA link source all convert by the one rule C applies
1027/// through `dbFastGetConvertRoutine`.
1028///
1029/// # The source type decides the rule, because in C it decides the routine
1030///
1031/// `dbFastGetConvertRoutine` is a 2-D table indexed by *both* the source DBF
1032/// and the destination DBR (`dbConvert.c:1571-1638`): a `DBF_LONG` source
1033/// reaches `getLongUshort`, a `DBF_DOUBLE` source reaches `getDoubleUshort`.
1034/// They are different functions, and C gives them different semantics:
1035///
1036/// * **Integer source** — `(epicsUInt16)(epicsInt32)v`. Conversion of an
1037///   out-of-range *integer* to an unsigned type is **defined** in C
1038///   (C17 6.3.1.3p2: reduce modulo `USHRT_MAX + 1`). Every compiler and
1039///   every target agrees, so this is a real contract and the port keeps it:
1040///   `SELL` pointing at a `DBF_LONG` field holding `-1` gives `SELN = 65535`.
1041/// * **Float source** — `(epicsUInt16)d`. Conversion of an out-of-range
1042///   *float* is **undefined** (C17 6.3.1.4p1), so compiled C is not
1043///   single-valued: x86-64 wraps, aarch64 saturates. What the port does about
1044///   that is [`crate::types::c_cast`]'s call — the single owner of the policy —
1045///   and deliberately not restated here.
1046///
1047/// Both rules already live in [`EpicsValue::convert_to`], the single
1048/// value-coercion owner: it takes the integer view (`as_int_i64`) when the
1049/// source has one and falls back to `c_cast` only for a genuine float. So this
1050/// is a thin projection onto that owner, NOT a second conversion table.
1051///
1052/// The previous revision called `c_cast::f64_to_u16(value.to_f64())` directly,
1053/// bypassing the owner — which silently applied the float rule to integer
1054/// sources too, losing the one wrap C actually defines.
1055pub(crate) fn dbr_ushort_cast(value: &EpicsValue) -> u16 {
1056    match value.convert_to(crate::types::DbFieldType::UShort) {
1057        EpicsValue::UShort(v) => v,
1058        // A link that delivers an array converts element-wise; C's
1059        // `dbGetLink(.., &prec->seln, 0, 0)` requests ONE element, so SELN
1060        // takes the first (an empty array leaves it 0).
1061        EpicsValue::UShortArray(v) => v.first().copied().unwrap_or(0),
1062        // `convert_to(UShort)` returns no other variant.
1063        _ => 0,
1064    }
1065}
1066
1067/// Select which link indices are active based on SELM/SELN, applying
1068/// the record-type-specific `OFFS`/`SHFT` bias.
1069///
1070/// SELM: 0 = All, 1 = Specified, 2 = Mask. `count` is the number of
1071/// link slots (16 for fanout/dfanout/seq).
1072///
1073/// `seln` is the native `DBF_USHORT` value: C declares `SELN` as
1074/// `epicsUInt16`, so every comparison below is unsigned, matching C's
1075/// selection arithmetic — never `-1`. What an out-of-range `SELL` converts
1076/// *to* is [`dbr_ushort_cast`]'s decision, not this function's.
1077///
1078/// C references:
1079/// * fanout — `fanoutRecord.c:106-141`
1080/// * dfanout — `dfanoutRecord.c:308-339`
1081/// * seq — `seqRecord.c:147-178`
1082pub(crate) fn select_link_indices_ex(
1083    kind: SelmKind,
1084    selm: i16,
1085    seln: u16,
1086    offs: i16,
1087    shft: i16,
1088    count: usize,
1089) -> SelmResult {
1090    use crate::server::recgbl::alarm_status::SOFT_ALARM;
1091    use crate::server::record::AlarmSeverity;
1092
1093    let invalid = || SelmResult {
1094        indices: Vec::new(),
1095        alarm: Some((SOFT_ALARM, AlarmSeverity::Invalid)),
1096    };
1097    let ok = |indices: Vec<usize>| SelmResult {
1098        indices,
1099        alarm: None,
1100    };
1101
1102    match selm {
1103        // All — every slot.
1104        0 => ok((0..count).collect()),
1105        // Specified.
1106        1 => match kind {
1107            SelmKind::FanoutSeq => {
1108                // C: `i = seln + offs;` with `seln` unsigned (epicsUInt16),
1109                // 0-based; `i<0 || i>=NLINKS` → INVALID. So `SELN=65535`
1110                // (from `SELL=-1`) yields `i>=NLINKS` → INVALID, never
1111                // drives link 0.
1112                let i = seln as i32 + offs as i32;
1113                if i < 0 || i >= count as i32 {
1114                    invalid()
1115                } else {
1116                    ok(vec![i as usize])
1117                }
1118            }
1119            SelmKind::Dfanout => {
1120                // C `dfanoutRecord.c:315-320`: `if (prec->seln > OUT_ARG_MAX)`
1121                // with `seln` unsigned → INVALID; `seln == 0` → no output;
1122                // otherwise drive `seln - 1`. OFFS is not a dfanout field.
1123                // `SELL=-1` → `SELN=65535` > count → INVALID (the signed
1124                // read used to see `-1`, take the `<= 0` branch, and drive
1125                // nothing with no alarm).
1126                let seln_i = seln as i32;
1127                if seln_i > count as i32 {
1128                    invalid()
1129                } else if seln_i == 0 {
1130                    ok(Vec::new())
1131                } else {
1132                    ok(vec![(seln_i - 1) as usize])
1133                }
1134            }
1135        },
1136        // Mask.
1137        2 => {
1138            let mask: u32 = match kind {
1139                SelmKind::FanoutSeq => {
1140                    // C: SHFT shift first, with `shft` range-checked to [-15,15].
1141                    if !(-15..=15).contains(&shft) {
1142                        return invalid();
1143                    }
1144                    let raw = seln as u32;
1145                    if shft >= 0 {
1146                        raw >> shft
1147                    } else {
1148                        raw << (-shft)
1149                    }
1150                }
1151                // dfanout Mask has no SHFT.
1152                SelmKind::Dfanout => seln as u32,
1153            };
1154            ok((0..count).filter(|i| mask & (1 << i) != 0).collect())
1155        }
1156        // Any other SELM value → C `default:` raises INVALID.
1157        _ => invalid(),
1158    }
1159}
1160
1161/// C `MAX_STRING_SIZE` is 40 and `dbPutRecordAttribute` NUL-terminates at
1162/// `[MAX_STRING_SIZE-1]`, so an attribute value keeps 39 characters.
1163const MAX_ATTRIBUTE_LEN: usize = 39;
1164
1165/// Why a `dbPutAttribute` was refused, carrying the C status the shell
1166/// reports and the `errSymLookup` text `errMessage` prints in front of it.
1167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1168pub enum RecordAttributeError {
1169    /// C `S_db_badField` (`dbAccess.c:443-445`) — no attribute name was
1170    /// given. C tests `!name`; an iocsh argument that is absent and one that
1171    /// is `""` are the same absent argument here.
1172    BadField,
1173    /// C `S_dbLib_recordTypeNotFound`, from `dbFindRecordType`
1174    /// (`dbAccess.c:453`) or `dbPutRecordAttribute`'s own `!precordType`
1175    /// guard (`dbStaticLib.c:1240`).
1176    RecordTypeNotFound,
1177}
1178
1179impl RecordAttributeError {
1180    /// The `errMdef.h` status number, as a C console prints it.
1181    #[must_use]
1182    pub const fn status(self) -> u32 {
1183        match self {
1184            // `M_dbAccess` is `511 << 16`, `S_db_badField` is `|15`.
1185            Self::BadField => (511 << 16) | 15,
1186            // `M_dbLib` is `512 << 16`, `S_dbLib_recordTypeNotFound` is `|1`.
1187            Self::RecordTypeNotFound => (512 << 16) | 1,
1188        }
1189    }
1190
1191    /// The `errSymLookup` text `errMessage` prefixes its own message with.
1192    #[must_use]
1193    pub const fn message(self) -> &'static str {
1194        match self {
1195            Self::BadField => "Illegal field value",
1196            Self::RecordTypeNotFound => "Record Type does not exist",
1197        }
1198    }
1199}
1200
1201/// C `dbReadCOM`'s tail (`dbLexRoutines.c:311-331`): once the `.dbd` is read,
1202/// every record type it declared carries `RTYP` = its own name and `VERS` =
1203/// `"none specified"`. The port's `.dbd` is the generated table, which is
1204/// complete before the first `dbLoadRecords`, so the seed happens at
1205/// construction instead of after a read.
1206fn seeded_record_attributes()
1207-> std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>> {
1208    crate::server::record::dbd_generated::RECORD_TYPES
1209        .iter()
1210        .map(|t| {
1211            let mut attrs = std::collections::BTreeMap::new();
1212            attrs.insert("RTYP".to_string(), (*t).to_string());
1213            attrs.insert("VERS".to_string(), "none specified".to_string());
1214            ((*t).to_string(), attrs)
1215        })
1216        .collect()
1217}
1218
1219impl PvDatabase {
1220    /// Acquire L46, `registration_mutex` — the ONE acquisition site.
1221    ///
1222    /// `site` names the acquiring function and appears in the panic message
1223    /// when the rule below is broken, so the report identifies the violator
1224    /// without a debugger.
1225    ///
1226    /// # Panics
1227    ///
1228    /// If this thread already holds L46. That is not a defensive check
1229    /// against an impossible input: L46 is a `PriorityInheritanceMutex`, so
1230    /// the second acquisition would park the thread on itself and never
1231    /// return. The panic replaces a hang, which is the worst failure shape
1232    /// available — it reaches CI as a timeout, and a timeout reads as a load
1233    /// flake rather than as the ordering bug it is.
1234    pub(crate) fn lock_registration(&self, site: &'static str) -> RegistrationGate<'_> {
1235        if let Some(holder) = REGISTRATION_GATE_HELD.with(|h| h.get()) {
1236            panic!(
1237                "L46 registration_mutex is not reentrant: `{site}` took it while \
1238                 this thread still holds it from `{holder}`. `update_scan_index` \
1239                 takes L46 itself and is the single owner of a scan-index \
1240                 transition, so a caller must DROP its registration gate before \
1241                 reaching it — see `RegistrationGate`."
1242            );
1243        }
1244        let guard = self.inner.registration_mutex.lock();
1245        REGISTRATION_GATE_HELD.with(|h| h.set(Some(site)));
1246        RegistrationGate { _guard: guard }
1247    }
1248
1249    pub fn new() -> Self {
1250        Self {
1251            inner: Arc::new(PvDatabaseInner {
1252                simple_pvs: crate::runtime::sync::PriorityInheritanceMutex::new(HashMap::new()),
1253                pv_destroyed_tx: tokio::sync::broadcast::channel(16).0,
1254                external_resolver: ArcSwapOption::empty(),
1255                breakpoints: ArcSwapOption::empty(),
1256                debugging: std::sync::atomic::AtomicBool::new(false),
1257                search_resolver: ArcSwapOption::empty(),
1258                existence_gate: ArcSwapOption::empty(),
1259                link_sets: SnapshotCell::new(link_set::LinkSetRegistry::new()),
1260                link_puts: Arc::new(link_put_queue::LinkPutQueue::default()),
1261                records: RecursiveReadLock::new(HashMap::new()),
1262                scan_index: scan_index::ScanIndex::new(),
1263                load_order: SnapshotCell::new(HashMap::new()),
1264                load_order_counter: std::sync::atomic::AtomicU64::new(0),
1265                cp_links: SnapshotCell::new(HashMap::new()),
1266                external_cp_links: SnapshotCell::new(HashMap::new()),
1267                aliases: RecursiveReadLock::new(HashMap::new()),
1268                registration_mutex: crate::runtime::sync::PriorityInheritanceMutex::new(()),
1269                init_phase: std::sync::Mutex::new(DbInitPhase::Unloaded),
1270                record_init_waiting: std::sync::Mutex::new(HashMap::new()),
1271                deferred_record_inits: std::sync::Mutex::new(Vec::new()),
1272                empty_link_assignments: std::sync::Mutex::new(HashMap::new()),
1273                after_ioc_running: std::sync::Mutex::new(Vec::new()),
1274                scan_started: std::sync::atomic::AtomicBool::new(false),
1275                pini_done: std::sync::atomic::AtomicBool::new(false),
1276                pini_notify: tokio::sync::Notify::new(),
1277                record_locks: record_lock::RecordLockRegistry::default(),
1278                record_attributes: crate::runtime::sync::PriorityInheritanceMutex::new(
1279                    seeded_record_attributes(),
1280                ),
1281                subroutine_registry: ArcSwap::from_pointee(HashMap::new()),
1282                device_support_resolver: ArcSwapOption::empty(),
1283                breaktable_registry: SnapshotCell::new(
1284                    crate::server::cvt_bpt::BreakTableRegistry::new(),
1285                ),
1286            }),
1287        }
1288    }
1289
1290    /// C `dbPutAttribute` (`dbAccess.c:436-460`) minus its shell wrapper:
1291    /// set or create one attribute of one record type.
1292    ///
1293    /// C's `!pdbbase` arm is unreachable here — the record-type set is the
1294    /// generated `dbd_generated::RECORD_TYPES` table, which exists before any
1295    /// database is loaded — so `S_db_notFound` has no site.
1296    ///
1297    /// `name` and `value` are `Option` because C's are `const char *` that
1298    /// iocsh passes as NULL for an argument the operator omitted, and the two
1299    /// NULLs mean different things: a missing name is `S_db_badField`
1300    /// (`dbAccess.c:443-445`) while a missing value is `""`
1301    /// (`:446-447`). An argument given as `""` is NOT missing — C's
1302    /// `dbPutRecordAttribute` happily creates an attribute whose name is the
1303    /// empty string, measured on `softIoc` R7.0.10-146.
1304    ///
1305    /// Truncation is C's: `strncpy(pattribute->value, value, MAX_STRING_SIZE)`
1306    /// followed by `value[MAX_STRING_SIZE-1] = 0` keeps 39 characters, not 40.
1307    pub fn put_record_type_attribute(
1308        &self,
1309        record_type: &str,
1310        name: Option<&str>,
1311        value: Option<&str>,
1312    ) -> Result<(), RecordAttributeError> {
1313        let Some(name) = name else {
1314            return Err(RecordAttributeError::BadField);
1315        };
1316        let value = value.unwrap_or("");
1317        if !crate::server::record::dbd_generated::RECORD_TYPES.contains(&record_type) {
1318            return Err(RecordAttributeError::RecordTypeNotFound);
1319        }
1320        // C truncates by byte (`strncpy` then `[MAX_STRING_SIZE-1] = 0`),
1321        // which can leave a partial UTF-8 sequence; truncating by character
1322        // keeps the same 39 for every name iocsh can pass and never produces
1323        // a value that is not a string.
1324        let truncated: String = value.chars().take(MAX_ATTRIBUTE_LEN).collect();
1325        self.inner
1326            .record_attributes
1327            .lock()
1328            .entry(record_type.to_string())
1329            .or_default()
1330            .insert(name.to_string(), truncated);
1331        Ok(())
1332    }
1333
1334    /// C `dbGetAttributePart` (`dbStaticLib.c:1279-1315`) for a whole name —
1335    /// the fallback `pvNameLookup` takes when `dbFindFieldPart` answers
1336    /// `S_dbLib_fieldNotFound` (`dbChannel.c:326-327`).
1337    ///
1338    /// The caller owes the shadowing test: C reaches this only after the
1339    /// record type's declared field list has missed, so a type that declares
1340    /// a field of the same name (`motor.VERS`) hides the attribute.
1341    pub fn record_type_attribute(&self, record_type: &str, name: &str) -> Option<String> {
1342        self.inner
1343            .record_attributes
1344            .lock()
1345            .get(record_type)?
1346            .get(name)
1347            .cloned()
1348    }
1349
1350    /// One record type's whole attribute list in C's `strcmp` order — the
1351    /// order `dbPutRecordAttribute` inserts in and `dbDumpRecordType` prints.
1352    pub fn record_type_attributes(&self, record_type: &str) -> Vec<(String, String)> {
1353        self.inner
1354            .record_attributes
1355            .lock()
1356            .get(record_type)
1357            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
1358            .unwrap_or_default()
1359    }
1360
1361    /// Merge `tables` into the shared breakpoint-table registry (C `bptList`
1362    /// accumulation across `dbLoadDatabase`/`dbLoadRecords`) and return the new
1363    /// snapshot. Copy-on-write: a new merged registry replaces the old one.
1364    ///
1365    /// `add_breaktables` is the single registry-mutation owner, so it also
1366    /// restores the invariant *every record can resolve against the current
1367    /// registry* on mutation: the new snapshot is re-installed into every
1368    /// existing record. That covers a record created before its table was
1369    /// loaded (an inline record added before `dbLoadRecords`, or a merge-reload
1370    /// that repoints `LINR` to a table loaded in the same command) — neither
1371    /// of which goes back through `add_record`'s install. `install_*` is a
1372    /// no-op for non-ai/ao records and resets the cached table so the new
1373    /// registry wins. Returns the current snapshot unchanged when `tables` is
1374    /// empty (no mutation, so no re-install).
1375    pub async fn add_breaktables(
1376        &self,
1377        tables: Vec<crate::server::cvt_bpt::BrkTable>,
1378    ) -> Arc<crate::server::cvt_bpt::BreakTableRegistry> {
1379        // Hold the registration gate across the registry write AND the record
1380        // snapshot below so this mutation cannot interleave with `add_record`'s
1381        // [registry read -> records-map insert] — both are gated by the same
1382        // mutex. Without it a record created concurrently could read the
1383        // pre-mutation registry (miss the just-loaded table) while not yet
1384        // being in the records map for the re-install below, leaving a
1385        // table-not-found alarm until the next load / LINR put. `add_record`
1386        // holds this gate across its whole body (registry read + map insert),
1387        // so taking it here closes that TOCTOU window. No `add_breaktables`
1388        // caller already holds the gate, so this is reentrancy-safe.
1389        let _gate = self.lock_registration("add_breaktables");
1390        if tables.is_empty() {
1391            return self.inner.breaktable_registry.load_full();
1392        }
1393        let snapshot = self.inner.breaktable_registry.update(|next| {
1394            for table in tables {
1395                next.insert(table);
1396            }
1397        });
1398        // Re-install into existing records. Snapshot the instance handles
1399        // under a brief read, then release the map lock BEFORE taking any
1400        // per-record write lock — collect-then-act, keeping the invariant
1401        // "never hold the records-map lock across a per-record lock" uniform
1402        // across the codebase (a7f5a74f). This is defensive: no current path
1403        // takes the per-record lock then the records-map lock, so there is no
1404        // confirmed cycle; uniform order forecloses one. Same idiom as
1405        // `all_record_names`. (The registry write lock was released above.)
1406        let instances: Vec<_> = self.inner.records.read().values().cloned().collect();
1407        // Record data is behind the record's lock set, which sits ABOVE L46
1408        // in the acquisition order; the gate has done its job (registry
1409        // update and map snapshot are one transaction) and is released
1410        // before any record is opened. A record registered from here on
1411        // installs the snapshot itself in `add_loaded_record`.
1412        drop(_gate);
1413        for inst in instances {
1414            inst.write()
1415                .record
1416                .install_breaktable_registry(snapshot.clone());
1417        }
1418        snapshot
1419    }
1420
1421    /// Install the by-name subroutine registry, retained for runtime
1422    /// re-resolution (aSub LFLG=READ / SUBL). Called once at iocInit with the
1423    /// IocApp/IocBuilder registry. See `Self::find_subroutine_named`.
1424    pub async fn install_subroutine_registry(
1425        &self,
1426        registry: HashMap<String, Arc<crate::server::record::SubroutineFn>>,
1427    ) {
1428        self.inner.subroutine_registry.store(Arc::new(registry));
1429    }
1430
1431    /// Install the process-wide device support table — C's registrars filling
1432    /// `devList` before `iocInit`. Every record created after this line binds
1433    /// its dset from it at creation, which is C's `doInitRecord0` order; a
1434    /// record created BEFORE it keeps no device support, exactly as a C record
1435    /// loaded before its `device()` lines would.
1436    pub fn install_device_support_resolver(
1437        &self,
1438        resolver: crate::server::ioc_app::DeviceSupportResolver,
1439    ) {
1440        self.inner
1441            .device_support_resolver
1442            .store(Some(Arc::new(resolver)));
1443    }
1444
1445    /// How many records hold device support — the `iocInit: N records, M with
1446    /// device support` count. Derived from the records themselves rather than
1447    /// tallied by a wiring pass, because there is no longer a wiring pass:
1448    /// the bind happens at each record's creation.
1449    pub(crate) async fn records_with_device_support(&self) -> usize {
1450        let names = self.all_record_names().await;
1451        names
1452            .iter()
1453            .filter_map(|name| self.get_record(name))
1454            .filter(|rec| rec.read().device.is_some())
1455            .count()
1456    }
1457
1458    /// Look up a registered subroutine by name. The processing path uses this
1459    /// to re-resolve an aSub's subroutine when SNAM changes (C `fetch_values`
1460    /// `registryFunctionFind`). `None` when the name is not registered, which
1461    /// the caller treats as C's `S_db_BadSub` (skip running the subroutine).
1462    pub(crate) fn find_subroutine_named(
1463        &self,
1464        name: &str,
1465    ) -> Option<Arc<crate::server::record::SubroutineFn>> {
1466        self.inner.subroutine_registry.load().get(name).cloned()
1467    }
1468
1469    /// Every registered subroutine, as `(name, entry address)`, unordered —
1470    /// the enumeration behind `registryDump`, whose C counterpart walks the
1471    /// one gpHash every registry shares (`registry.c:86-91` calling
1472    /// `gphDump`). [`Self::find_subroutine_named`] is the by-name half.
1473    ///
1474    /// C has a SECOND list of the same subroutines and this is deliberately
1475    /// not it. `registryFunctionAdd` puts a name and a function pointer in
1476    /// the runtime gpHash (`registryFunction.c:22-26`), while the `.dbd`
1477    /// parser's `dbFunction` (`dbLexRoutines.c:926-947`) appends the name
1478    /// alone to `pdbbase->functionList`, an `ELLLIST` of `dbText` that
1479    /// `dbWriteFunctionFP`
1480    /// (`dbStaticLib.c:1121-1134`, reached from `dbDumpFunction` at
1481    /// `:3515-3522`) walks in declaration order. The port collapsed both onto
1482    /// `subroutine_registry`, so the two views come off one map: this one
1483    /// carries the addresses the gpHash has and the names in no order,
1484    /// `registered_subroutine_names` carries the names alone. That is two
1485    /// accessors for two C structures, not two spellings of one — and the
1486    /// sort there stands in for a declaration order a `HashMap` cannot keep.
1487    pub(crate) fn subroutine_entries(&self) -> Vec<(String, usize)> {
1488        self.inner
1489            .subroutine_registry
1490            .load()
1491            .iter()
1492            .map(|(name, func)| (name.clone(), Arc::as_ptr(func) as *const () as usize))
1493            .collect()
1494    }
1495
1496    /// Atomically claim the right to start the scan scheduler for this DB.
1497    /// Returns `true` on the first call, `false` on subsequent calls.
1498    /// Used by `ScanScheduler::run` to prevent duplicate scan tasks
1499    /// when multiple protocol servers (CA + PVA) both try to start scanning.
1500    pub fn try_claim_scan_start(&self) -> bool {
1501        self.inner
1502            .scan_started
1503            .compare_exchange(
1504                false,
1505                true,
1506                std::sync::atomic::Ordering::AcqRel,
1507                std::sync::atomic::Ordering::Acquire,
1508            )
1509            .is_ok()
1510    }
1511
1512    /// Mark PINI processing complete. Wakes any non-owner scan schedulers
1513    /// that were waiting before running their hooks.
1514    pub fn mark_pini_done(&self) {
1515        self.inner
1516            .pini_done
1517            .store(true, std::sync::atomic::Ordering::Release);
1518        self.inner.pini_notify.notify_waiters();
1519    }
1520
1521    /// True once the PINI=YES pass has completed for this database —
1522    /// published by [`Self::mark_pini_done`]. The scan owner reads this
1523    /// to keep the pass exactly-once (C `initialProcess`, iocInit.c:653
1524    /// runs once, inside iocBuild): when the IOC init path already ran
1525    /// PINI, the owner skips its own pass instead of re-processing every
1526    /// PINI record.
1527    pub fn pini_done(&self) -> bool {
1528        self.inner
1529            .pini_done
1530            .load(std::sync::atomic::Ordering::Acquire)
1531    }
1532
1533    /// Wait until the scan owner has completed PINI processing.
1534    /// Returns immediately if PINI has already completed.
1535    pub async fn wait_for_pini(&self) {
1536        if self
1537            .inner
1538            .pini_done
1539            .load(std::sync::atomic::Ordering::Acquire)
1540        {
1541            return;
1542        }
1543        // Register interest BEFORE re-checking the flag to avoid missing a
1544        // signal that arrives between the load and the await — `notify_waiters`
1545        // does not store a permit for late subscribers.
1546        let notified = self.inner.pini_notify.notified();
1547        if self
1548            .inner
1549            .pini_done
1550            .load(std::sync::atomic::Ordering::Acquire)
1551        {
1552            return;
1553        }
1554        notified.await;
1555    }
1556
1557    /// Install an async resolver invoked when [`PvDatabase::has_name`]
1558    /// fails to find a name. Used by proxy/gateway implementations to
1559    /// lazily populate PVs on first search.
1560    pub async fn set_search_resolver(&self, resolver: SearchResolver) {
1561        self.inner.search_resolver.store(Some(Arc::new(resolver)));
1562    }
1563
1564    /// Remove the previously installed search resolver, if any.
1565    pub async fn clear_search_resolver(&self) {
1566        self.inner.search_resolver.store(None);
1567    }
1568
1569    /// Install the per-request existence gate (see [`ExistenceGate`]).
1570    /// Replaces any previously installed gate. Used by the CA gateway so
1571    /// a cached shadow PV re-runs host/state admission per request.
1572    pub async fn set_existence_gate(&self, gate: ExistenceGate) {
1573        self.inner.existence_gate.store(Some(Arc::new(gate)));
1574    }
1575
1576    /// Remove the previously installed existence gate, if any.
1577    pub async fn clear_existence_gate(&self) {
1578        self.inner.existence_gate.store(None);
1579    }
1580
1581    /// True when a cached simple PV named `name` must be treated as
1582    /// non-existent for `peer` because the installed [`ExistenceGate`]
1583    /// denied it. Always `false` when no gate is installed (a plain IOC)
1584    /// or when `name` does not resolve to a simple PV — records and
1585    /// aliases are never gateway-managed and bypass the gate.
1586    ///
1587    /// The single consultation point for the gate, shared by
1588    /// [`Self::find_entry_from`] and [`Self::has_name_from`] so the
1589    /// "cached simple PV ⇒ exists" short-circuit is closed uniformly on
1590    /// both the create and search paths.
1591    async fn simple_pv_gate_denies(&self, name: &str, peer: Option<std::net::SocketAddr>) -> bool {
1592        let Some(gate) = self.inner.existence_gate.load_full() else {
1593            return false;
1594        };
1595        let gate = (*gate).clone();
1596        // Strip the channel-filter suffix exactly as the lookups do
1597        // (CA-FR-8) so the gate sees the same record-path key the
1598        // simple-PV map and the gateway cache are keyed on.
1599        let record_path = filters::split_channel_name(name).record_path;
1600        // Own statement: the `!Send` guard must be down before the gate's
1601        // `.await` below (see the `simple_pvs` field doc).
1602        let known = self
1603            .inner
1604            .simple_pvs
1605            .lock()
1606            .contains_key(record_path.as_str());
1607        if !known {
1608            return false;
1609        }
1610        !gate(record_path, peer).await
1611    }
1612
1613    /// Set an external PV resolver for CA/PVA link resolution.
1614    /// The resolver is called synchronously from link reads.
1615    pub async fn set_external_resolver(&self, resolver: ExternalPvResolver) {
1616        self.inner.external_resolver.store(Some(Arc::new(resolver)));
1617    }
1618
1619    /// The database debugger, or `None` when nothing is being debugged.
1620    ///
1621    /// The hot-path read behind both breakpoint hooks: C's
1622    /// `if (lset_stack_count)` guard in `dbProcess`.
1623    pub(crate) fn breakpoints(&self) -> Option<Arc<breakpoint::BreakpointTable>> {
1624        self.inner.breakpoints.load_full()
1625    }
1626
1627    /// [`Self::breakpoints`] for the two hooks in the process frame: C's
1628    /// `if (lset_stack_count)` first, and the table only if it answers yes.
1629    /// See [`PvDatabaseInner::debugging`].
1630    pub(crate) fn breakpoints_if_debugging(&self) -> Option<Arc<breakpoint::BreakpointTable>> {
1631        if !self
1632            .inner
1633            .debugging
1634            .load(std::sync::atomic::Ordering::Relaxed)
1635        {
1636            return None;
1637        }
1638        self.breakpoints()
1639    }
1640
1641    /// The debugger, installing it on first use — C `dbBkptInit()`
1642    /// (`dbBkpt.c:254-260`), which `iocInit` calls unconditionally and which
1643    /// creates the stack semaphore once.
1644    ///
1645    /// Owner rule: this is the ONLY place the observer is installed, so a
1646    /// second `dbb` cannot replace a table that already holds breakpoints and
1647    /// strand a parked continuation thread behind an unreachable stack.
1648    pub(crate) fn breakpoints_or_install(&self) -> Arc<breakpoint::BreakpointTable> {
1649        if let Some(existing) = self.inner.breakpoints.load_full() {
1650            return existing;
1651        }
1652        // Raised before the table lands — see [`PvDatabaseInner::debugging`].
1653        self.inner
1654            .debugging
1655            .store(true, std::sync::atomic::Ordering::Relaxed);
1656        let fresh = Arc::new(breakpoint::BreakpointTable::new());
1657        // `compare_and_swap` against the empty state: two concurrent first
1658        // `dbb`s must agree on one table, and the loser's must be dropped
1659        // rather than stored.
1660        let prev = self.inner.breakpoints.compare_and_swap(
1661            &None::<Arc<breakpoint::BreakpointTable>>,
1662            Some(fresh.clone()),
1663        );
1664        match &*prev {
1665            Some(winner) => winner.clone(),
1666            None => fresh,
1667        }
1668    }
1669
1670    /// Drop the debugger once its last lock set is gone, so the hot path goes
1671    /// back to a `None` load — C's `--lset_stack_count` reaching zero
1672    /// (`dbBkpt.c:619`).
1673    pub(crate) fn retire_breakpoints_if_idle(&self) {
1674        if self
1675            .inner
1676            .breakpoints
1677            .load()
1678            .as_ref()
1679            .is_some_and(|t| t.is_empty())
1680        {
1681            self.inner.breakpoints.store(None);
1682            // Lowered after the table is gone — see [`PvDatabaseInner::debugging`].
1683            self.inner
1684                .debugging
1685                .store(false, std::sync::atomic::Ordering::Relaxed);
1686        }
1687    }
1688
1689    /// Register a [`LinkSet`] under `scheme` (e.g. `"pva"` /
1690    /// `"ca"`). The lset is consulted for `ParsedLink::Pva` /
1691    /// `ParsedLink::Ca` link reads/writes before falling back to
1692    /// the legacy [`ExternalPvResolver`]. Subsequent calls for the
1693    /// same scheme replace the previous binding.
1694    pub async fn register_link_set(&self, scheme: &str, lset: link_set::DynLinkSet) {
1695        self.inner.link_sets.update(|r| r.register(scheme, lset));
1696    }
1697
1698    /// Look up the lset for `scheme`, if any.
1699    pub async fn link_set(&self, scheme: &str) -> Option<link_set::DynLinkSet> {
1700        self.inner.link_sets.load().get(scheme)
1701    }
1702
1703    /// Snapshot of every registered scheme name. Stable order for
1704    /// `dbpvxr` dumps.
1705    pub async fn registered_link_schemes(&self) -> Vec<String> {
1706        let mut s = self.inner.link_sets.load().schemes();
1707        s.sort();
1708        s
1709    }
1710
1711    /// Wait for the CA links to local records to report
1712    /// `init_ready() == true` — connected, first monitor event cached,
1713    /// attribute fetch complete. Mirrors `dbCa: iocInit wait for local CA
1714    /// links to connect` (epics-base PR #768) as extended by #856's
1715    /// `testInitReady` all-conditions gate. The working set is
1716    /// exactly `Self::external_link_targets`: only the CA facility's
1717    /// local-target links — `pva://` links and non-local CA links connect
1718    /// in the background and are never waited on (pvxs parity).
1719    ///
1720    /// Polls every 100 ms. Returns:
1721    /// * `Ok(connected_count)` — the number of links that ended up
1722    ///   connected. May be smaller than the total when the timeout
1723    ///   expired before everyone was ready.
1724    /// * The total link count — i.e. the size of the working set
1725    ///   that was checked. `(connected, total)` lets the caller log
1726    ///   "M/N CA links connected".
1727    ///
1728    /// Pure no-op when no CA link set is registered, or when its
1729    /// `link_names()` has no local-target link yet (e.g. lazy-open lsets
1730    /// that haven't observed any record link — record processing creates
1731    /// the entries on first read, after iocInit returns).
1732    pub async fn wait_for_external_links(&self, timeout: std::time::Duration) -> (usize, usize) {
1733        // Collect (lset, name) pairs once. `link_names()` may grow
1734        // as record processing opens new links, but iocInit's wait
1735        // is bounded by the records loaded *before* Phase 3 — every
1736        // such link is already opened by the time wire_device_support
1737        // and setup_cp_links return.
1738        let targets = self.external_link_targets().await;
1739        let total = targets.len();
1740        if total == 0 {
1741            return (0, 0);
1742        }
1743        // `std::time::Instant`, not `crate::runtime::task::Instant`: the sleep
1744        // below is the background timer, which measures on std's clock on both
1745        // backends. Reading the deadline off tokio's clock made the two
1746        // disagree — under `start_paused` tokio's clock advances only when the
1747        // runtime decides to, while the timer thread follows the real one, so
1748        // the loop could sleep against one clock and expire against another.
1749        let deadline = std::time::Instant::now() + timeout;
1750        loop {
1751            let mut connected = 0usize;
1752            for (lset, name) in &targets {
1753                // `init_ready`, not `is_connected`: C `testInitReady`
1754                // (`dbCa.c:835` at `ef4829829`, epics-base #856 — post-
1755                // `R7.0.10` and in no tag) releases iocInit only when the
1756                // link's monitor AND attribute-fetch actions have all
1757                // completed, not on the bare connection edge.
1758                if lset.init_ready(name) {
1759                    connected += 1;
1760                }
1761            }
1762            if connected == total {
1763                return (connected, total);
1764            }
1765            if std::time::Instant::now() >= deadline {
1766                return (connected, total);
1767            }
1768            crate::runtime::task::sleep_background(std::time::Duration::from_millis(100)).await;
1769        }
1770    }
1771
1772    /// Snapshot the `(lset, link_name)` pairs the iocInit external-link
1773    /// wait reasons over. Shared by [`Self::wait_for_external_links`] and
1774    /// [`Self::unconnected_external_links`] so both see the identical
1775    /// working set.
1776    ///
1777    /// C parity: the iocInit connection-wait is a property of the CA link
1778    /// facility (dbCa) alone. The whole connection-wait is post-`R7.0.10`
1779    /// and in no tag, so these numbers are `717d69e1f`'s, not the pin's —
1780    /// at the pin `dbCaRun` is `dbCa.c:357-363` and does not spin at all.
1781    /// `dbCaRun` (`dbCa.c:370-380`) spins at `:376-378` on
1782    /// `initOutstanding`, which `dbCaAddLinkCallbackOpt` increments
1783    /// (`dbCa.c:408-409`) for every link that arrives carrying the
1784    /// `DBCA_CALLBACK_INIT_WAITING` bit. `dbInitLink` passes that bit — as
1785    /// part of `DBCA_CALLBACK_INIT_START` (0xf, `dbCaPvt.h:118`) — only for
1786    /// a CA link whose target is a LOCAL record (dbLink.c:128, :130):
1787    ///   int isLocal = dbChannelTest(pvname) == 0;
1788    ///   dbCaAddLinkCallbackOpt(..., isLocal ? DBCA_CALLBACK_INIT_START : 0)
1789    ///
1790    /// **Unreleased upstream.** None of that exists at the reference pin.
1791    /// R7.0.10 carries the REVERT of the first attempt (3f382f6b6), so its
1792    /// `dbLink.c:128` is a bare `dbCaAddLink(NULL, plink, dbfType);` and its
1793    /// `dbCa.c` has no `initOutstanding` at all; the wait re-landed in
1794    /// 717d69e1f and ef4829829, which are in no tag. This function
1795    /// therefore tracks epics-base HEAD, not the pin — a C IOC built from
1796    /// R7.0.10 does not wait for local CA links.
1797    ///
1798    /// No other external facility waits: pvxs pvalink's `linkGlobal_t::init`
1799    /// (ioc/pvalink.cpp) only calls `chan->open()` per channel — it opens
1800    /// in the background and never blocks iocInit. So the wait targets
1801    /// exactly the CA link set's local-target links; a non-local CA link
1802    /// (e.g. areaDetector's `ShutterStatusEPICS_RBV.INP = "test CP MS"`
1803    /// placeholder) and every `pva://` link connect asynchronously and are
1804    /// never held by iocInit, like C.
1805    async fn external_link_targets(&self) -> Vec<(link_set::DynLinkSet, String)> {
1806        // Only the CA facility participates — look it up directly rather
1807        // than iterating every registered scheme. `has_name_no_resolve`
1808        // is the `dbChannelTest` twin (target is a local record).
1809        let Some(ca_lset) = self.inner.link_sets.load().get("ca") else {
1810            return Vec::new();
1811        };
1812        let mut targets: Vec<(link_set::DynLinkSet, String)> = Vec::new();
1813        for n in ca_lset.link_names() {
1814            if self.has_name_no_resolve(&n) {
1815                targets.push((ca_lset.clone(), n));
1816            }
1817        }
1818        targets
1819    }
1820
1821    /// Names of the waited-on CA links (local-target, per
1822    /// `Self::external_link_targets`) that are opened but not yet
1823    /// connected. iocInit calls this after
1824    /// [`Self::wait_for_external_links`] times out so the
1825    /// "M/N connected" diagnostic can name the `N-M` it proceeded
1826    /// without, instead of leaving the operator to run `dbcar`.
1827    /// `pva://` links are not in this set — they never block iocInit.
1828    pub async fn unconnected_external_links(&self) -> Vec<String> {
1829        let mut names = Vec::new();
1830        for (lset, name) in self.external_link_targets().await {
1831            // Same predicate as the wait loop, so the M/N accounting and
1832            // this diagnostic agree on which links held iocInit.
1833            if !lset.init_ready(&name) {
1834                names.push(name);
1835            }
1836        }
1837        names
1838    }
1839
1840    /// Every link field the record HAS, with its raw text and the link-field
1841    /// type it must be parsed as — the single owner of *which fields on a
1842    /// record are links*.
1843    ///
1844    /// Keyed on the `.dbd` declaration through
1845    /// [`crate::types::dbf_link_class`], which is the same rule
1846    /// `check_link_put` gates a put with, so "is this field a link" has one
1847    /// answer for every caller. It used to be three name lists —
1848    /// `COMMON_LINK_FIELDS`, `Record::multi_input_links` and
1849    /// `CP_INPUT_LINK_FIELDS` — and everything they did not spell was
1850    /// invisible: a `fanout`'s `LNK1..LNK6`, a `dfanout`'s `OUTA..OUTH`, an
1851    /// `ai`'s `SIML`/`SIOL`, an `aSub`'s `SUBL` and `OUTA..OUTU`, a `seq`'s
1852    /// `LNK1..LNKA`. Measured against softIoc R7.0.10 with `dblsr`, that cost
1853    /// five of seven probed record shapes their lock-set members — a `fanout`
1854    /// whose `LNK1` names a local record shared C's set with it and the port's
1855    /// did not.
1856    ///
1857    /// Unfiltered on purpose. [`Self::record_link_fields`] narrows this to the
1858    /// fields that carry a parseable link and maps them through the locality
1859    /// fallthrough, which is what its consumers want; C's `iocInit` pass
1860    /// ([`Self::db_init_record_links`]) needs the fields with no text too,
1861    /// because it types a link from the record's device support whether the
1862    /// `.db` spelled it or not (`dbStaticLib.c:2185-2212`).
1863    fn link_field_texts(
1864        inst: &RecordInstance,
1865    ) -> Vec<(String, String, crate::server::record::LinkFieldType)> {
1866        use crate::server::record::LinkFieldType;
1867        use crate::types::DbfLinkClass;
1868        let record_type = inst.record.record_type();
1869        let mut out: Vec<(String, String, LinkFieldType)> = Vec::new();
1870        // `dbCommon` first, then the record's own, which is C's `papFldDes`
1871        // order; a record type that redeclares a `dbCommon` field keeps the
1872        // first entry, since that is the one `declared_field` resolves.
1873        for desc in crate::server::record::declared_fields(record_type) {
1874            let ftype = match crate::types::dbf_link_class(record_type, desc.name) {
1875                Some(DbfLinkClass::InLink) => LinkFieldType::In,
1876                Some(DbfLinkClass::OutLink) => LinkFieldType::Out,
1877                Some(DbfLinkClass::FwdLink) => LinkFieldType::Fwd,
1878                None => continue,
1879            };
1880            if out.iter().any(|(had, _, _)| had == desc.name) {
1881                continue;
1882            }
1883            // `INP`/`OUT`/`TSEL`/`SDIS`/`FLNK` live on `CommonFields` as raw
1884            // String and are absent from `field_list()`, so the declaration
1885            // says they exist and only the instance can say what they hold.
1886            let raw = match inst.common_link_text(desc.name) {
1887                Some(raw) => raw.to_string(),
1888                None => match inst.record.get_field(desc.name) {
1889                    Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
1890                    // Declared but not served by this instance's rset — a
1891                    // downstream type whose generated table outruns its
1892                    // `get_field`. C would have the `dbFldDes` and an empty
1893                    // link; the port has no text to offer, so the field is
1894                    // simply absent rather than reported as an empty link.
1895                    _ => continue,
1896                },
1897            };
1898            out.push((desc.name.to_string(), raw, ftype));
1899        }
1900        out
1901    }
1902
1903    /// Enumerate every link-shaped field on `record_name`. Returns
1904    /// `(field_name, link_string, parsed)` tuples for fields whose
1905    /// raw value parses as a non-trivial link via
1906    /// [`crate::server::record::parse_link_v2`]. Used by `dbpvxr` to
1907    /// dump per-record link state without hardcoding the field-name
1908    /// list — works across record types as long as they expose link
1909    /// strings via [`Record::get_field`].
1910    ///
1911    /// `parsed` is the **post-`dbInitLink`** view, not the bare parse: each
1912    /// link is mapped through `db_init_link_locality`, so a
1913    /// `Db` link naming a record this IOC does not have is reported as the
1914    /// `Ca` link C's `dbDbInitLink` → `dbCaAddLink` fallthrough makes it
1915    /// (`dbLink.c:118-130`, `dbDbLink.c:94-96`). Every consumer — the CP
1916    /// setup, the init open pass, `dbcaxr`, the pvalink install scan — then
1917    /// sees one consistent answer to "is this link local or external"
1918    /// instead of each re-deriving it. `link_string` is still the verbatim
1919    /// field text.
1920    ///
1921    /// Each field is parsed for ITS OWN link-field type: C `dbPutFieldLink`
1922    /// passes `pfldDes->field_type` to `dbParseLink` (`dbAccess.c:1094`), which
1923    /// then masks the modifiers by that type (`dbStaticLib.c:2380-2391`). `OUT`
1924    /// is `DBF_OUTLINK`, so its CP/CPP is discarded here rather than reaching
1925    /// `setup_cp_links` — an `OUT` link must never be registered as a CP holder.
1926    ///
1927    /// Returns an empty Vec when the record doesn't exist.
1928    pub fn record_link_fields(
1929        &self,
1930        record_name: &str,
1931    ) -> Vec<(String, String, crate::server::record::ParsedLink)> {
1932        let rec = match self.get_record(record_name) {
1933            Some(r) => r,
1934            None => return Vec::new(),
1935        };
1936        let mut out: Vec<(String, String, crate::server::record::ParsedLink)> =
1937            Self::link_field_texts(&rec.read())
1938                .into_iter()
1939                .filter(|(_, raw, _)| !raw.is_empty())
1940                .map(|(field, raw, ftype)| {
1941                    let parsed = crate::server::record::parse_link_field(&raw, ftype);
1942                    (field, raw, parsed)
1943                })
1944                .filter(|(_, _, parsed)| !matches!(parsed, crate::server::record::ParsedLink::None))
1945                .collect();
1946        // Apply C `dbInitLink`'s locality fallthrough once, here, so no
1947        // consumer re-derives it. Done after dropping the record-instance
1948        // guard: the locality query reads the database's record map, and
1949        // this is the only place that would otherwise hold an instance lock
1950        // across it.
1951        for entry in &mut out {
1952            entry.2 = self.db_init_link_locality(std::mem::replace(
1953                &mut entry.2,
1954                crate::server::record::ParsedLink::None,
1955            ));
1956        }
1957        out
1958    }
1959
1960    /// Resolve an external PV name. Dispatches through the
1961    /// `(scheme, name)` lset if one is registered; otherwise falls
1962    /// back to the legacy [`ExternalPvResolver`] closure. `name`
1963    /// may be the bare PV name (in which case `pva://` is assumed
1964    /// when an lset is registered for that scheme) or a fully
1965    /// scheme-prefixed string.
1966    ///
1967    /// # Cached read, then a staged open — C `dbCaGetLink`
1968    ///
1969    /// This is the record-processing read, so it reads the lset's
1970    /// monitor-fed cache ([`LinkSet::get_cached_value`]) and never the
1971    /// network: C `dbCaGetLink` (`dbCa.c:419-506`) copies out of
1972    /// `pca->pgetNative`, which the CA monitor callback keeps fresh on the
1973    /// `dbCaTask`, and returns -1 while the link is down (`dbCa.c:430-435`).
1974    ///
1975    /// A miss stages the link's OPEN on the same work queue the OUT writes
1976    /// use — the `addAction(pca, CA_CONNECT)` C reaches through
1977    /// `dbCaAddLink` (`dbCa.c:397-401`), which is `dbCa.c:393` inside
1978    /// `dbCaAddLinkCallback` (`:373-395`) — and returns `None` for this
1979    /// cycle.
1980    /// C's open happens at record init rather than at
1981    /// first read, but in both designs the connect runs on the link task and
1982    /// the reading record takes LINK/INVALID until the cache is warm.
1983    pub(crate) fn resolve_external_pv(&self, name: &str) -> Option<EpicsValue> {
1984        // Try lsets first. We accept both "scheme://body" and the
1985        // bare body (stored in ParsedLink::Pva/Ca after the
1986        // dispatch in record/link.rs). `Any` tries every registered
1987        // lset in turn; the first one with a cached value wins.
1988        let (target, body) = Self::split_external_link_name(name);
1989        for lset in link_put_queue::resolve_lsets(&self.inner, &target) {
1990            if let Some(v) = lset.get_cached_value(body) {
1991                return Some(v);
1992            }
1993        }
1994        self.stage_external_link_open_by_name(name);
1995        // Fall through to legacy resolver, which is addressed with the
1996        // caller's string verbatim (scheme prefix and all).
1997        let resolver = self
1998            .inner
1999            .external_resolver
2000            .load_full()
2001            .map(|r| (*r).clone());
2002        match resolver {
2003            Some(r) => r(name),
2004            None => None,
2005        }
2006    }
2007
2008    /// Split an external link's boundary name
2009    /// ([`crate::server::record::ParsedLink::external_pv_name`]) into the
2010    /// work-queue target plus the name the lset is addressed with.
2011    ///
2012    /// Single owner of the `ca://` / `pva://` prefix convention: the
2013    /// cache-miss stage in `resolve_external_pv` and the iocInit
2014    /// open pass ([`PvDatabase::setup_external_link_opens`]) must derive
2015    /// the same [`link_put_queue::LinkKey`] from the same link, or the
2016    /// queue's once-per-link open would fire twice under two spellings.
2017    fn split_external_link_name(name: &str) -> (link_put_queue::LinkTarget, &str) {
2018        if let Some(rest) = name.strip_prefix("pva://") {
2019            (link_put_queue::LinkTarget::Scheme("pva".to_string()), rest)
2020        } else if let Some(rest) = name.strip_prefix("ca://") {
2021            (link_put_queue::LinkTarget::Scheme("ca".to_string()), rest)
2022        } else {
2023            (link_put_queue::LinkTarget::Any, name)
2024        }
2025    }
2026
2027    /// Stage the open of the external link named `name` — the boundary
2028    /// form of [`Self::stage_external_link_open`], which splits the
2029    /// scheme prefix and applies the "an lset must exist" gate.
2030    ///
2031    /// The gate matters because the queue's open state is terminal: an
2032    /// open staged while no lset is registered would be serviced against
2033    /// an empty lset list and marked `Done`, burning the link's one and
2034    /// only connect. Returns true when this call is the one that staged
2035    /// it (false when already staged, or when no lset addresses it).
2036    pub(crate) fn stage_external_link_open_by_name(&self, name: &str) -> bool {
2037        let (target, body) = Self::split_external_link_name(name);
2038        if link_put_queue::resolve_lsets(&self.inner, &target).is_empty() {
2039            return false;
2040        }
2041        self.stage_external_link_open(target, body)
2042    }
2043
2044    /// How many staged writes to the external link `link_name` a later write
2045    /// on the same link overwrote before it reached the wire — C's
2046    /// `pca->nNoWrite`, which `dbcar` prints beside the link
2047    /// (`dbCaTest.c:111-116`).
2048    ///
2049    /// `link_name` is the link string as a record carries it, with or
2050    /// without a `ca://` / `pva://` prefix; it is split by
2051    /// `Self::split_external_link_name`, the same owner of that convention
2052    /// the write path stages under, so the two cannot key the counter
2053    /// differently.
2054    ///
2055    /// C keeps this per `caLink`, i.e. per link FIELD: two records whose OUT
2056    /// links name one PV have two `caLink`s and two counters. This port keeps
2057    /// one queue entry per `(scheme, PV name)`, so both fields report the
2058    /// shared count — the same aliasing `LinkSet` already applies to the
2059    /// link's cache and connection state.
2060    pub fn external_link_puts_coalesced_for(&self, link_name: &str) -> u64 {
2061        let (target, body) = Self::split_external_link_name(link_name);
2062        self.inner
2063            .link_puts
2064            .coalesced_for(&link_put_queue::LinkKey {
2065                target,
2066                name: body.to_string(),
2067            })
2068    }
2069
2070    /// C `dbCaLinkInit` (`dbCa.c:322-346`), which `iocBuild` calls before it
2071    /// announces `initHookAfterCaLinkInit`: the `dbCaLink` worker exists from
2072    /// init, not from the first external link put. Otherwise an IOC whose
2073    /// output links are all local has no `dbCaLink` task where C always has
2074    /// one — visible in `taskwdShow`.
2075    ///
2076    /// Returns false, having started nothing, when this database captured no
2077    /// reactor: the owner's network work has nowhere to run, which is the same
2078    /// condition [`Self::stage_external_link_open`] already refuses on.
2079    pub(crate) fn ca_link_init(&self) -> bool {
2080        if self.inner.link_puts.network().is_none() {
2081            return false;
2082        }
2083        self.inner
2084            .link_puts
2085            .ensure_owner(std::sync::Arc::downgrade(&self.inner));
2086        true
2087    }
2088
2089    /// Single owner of the "this external link needs opening" transition —
2090    /// the `addAction(pca, CA_CONNECT)` C reaches through `dbCaAddLink`
2091    /// (`dbCa.c:397-401`), which is `dbCa.c:393` inside
2092    /// `dbCaAddLinkCallback` (`:373-395`).
2093    ///
2094    /// Every caller routes through here so the open runs on the link work
2095    /// owner and nowhere else; no path may call
2096    /// [`link_set::LinkSet::connect_link`] directly from a record-processing
2097    /// thread. Cheap and idempotent: the queue drops a repeat stage for a
2098    /// link it has already opened.
2099    ///
2100    /// Private to the `database` module, and reached only through
2101    /// [`Self::stage_external_link_open_by_name`], so no caller can skip the
2102    /// scheme split or the lset gate and mint a `LinkKey` of its own shape.
2103    fn stage_external_link_open(&self, target: link_put_queue::LinkTarget, name: &str) -> bool {
2104        // Nothing is staged that no owner can perform. A database built with
2105        // no tokio runtime captured no reactor, and `connect_link` has nowhere
2106        // to run (see `LinkPutQueue::network`) — so refuse here rather than
2107        // enqueue an open the owner would have to drop. The link stays
2108        // unopened, its reads keep returning `None`, and the reading record
2109        // takes LINK/INVALID every cycle, which is C's `!pca->isConnected`
2110        // arm (`db/dbCa.c:430-435` (`dbCaGetLink`); epics-base R7.0.10).
2111        if self.inner.link_puts.network().is_none() {
2112            return false;
2113        }
2114        self.inner
2115            .link_puts
2116            .ensure_owner(std::sync::Arc::downgrade(&self.inner));
2117        self.inner.link_puts.stage_open(link_put_queue::LinkKey {
2118            target,
2119            name: name.to_string(),
2120        })
2121    }
2122
2123    /// Number of external-link opens the work owner has completed —
2124    /// diagnostic twin of [`Self::external_link_puts_completed`].
2125    pub fn external_link_opens_completed(&self) -> u64 {
2126        self.inner.link_puts.opened_count()
2127    }
2128
2129    /// Add a simple PV with an initial value.
2130    ///
2131    /// Returns `Err` when `name` is already registered as a simple PV,
2132    /// a record, or an alias — mirroring epics-base C IOC which treats
2133    /// duplicate `dbLoadRecords` names as a fatal error. Callers that
2134    /// want replace-on-overwrite semantics must first call
2135    /// `remove_simple_pv` / `remove_record` / `remove_alias`.
2136    ///
2137    /// Serialized through `registration_mutex` so the
2138    /// cross-namespace check is atomic with the insert and the lock
2139    /// order across all add_*/remove_* methods is identical (no
2140    /// cross-namespace deadlock).
2141    pub async fn add_pv(&self, name: &str, initial: EpicsValue) -> CaResult<()> {
2142        let _gate = self.lock_registration("add_pv");
2143        self.check_name_free(name)?;
2144        let pv = Arc::new(ProcessVariable::new(name.to_string(), initial));
2145        self.inner.simple_pvs.lock().insert(name.to_string(), pv);
2146        Ok(())
2147    }
2148
2149    /// Add a simple PV that already has a [`crate::server::pv::WriteHook`] installed.
2150    ///
2151    /// Equivalent to `add_pv` followed by `find_pv` + `set_write_hook`,
2152    /// but the PV is constructed with the hook in place so it is
2153    /// inserted into the `simple_pvs` map ATOMICALLY with the hook
2154    /// already attached. Closes a small race in proxy/gateway code
2155    /// where a downstream client could (in principle) `CREATE_CHAN` +
2156    /// `WRITE_NOTIFY` between the two awaits and hit the local
2157    /// `pv.set()` fallback path before the hook landed.
2158    ///
2159    /// Returns `Err` on duplicate name (see [`Self::add_pv`]).
2160    pub async fn add_pv_with_hook(
2161        &self,
2162        name: &str,
2163        initial: EpicsValue,
2164        hook: crate::server::pv::WriteHook,
2165    ) -> CaResult<()> {
2166        self.add_pv_with_hooks(name, initial, hook, None).await
2167    }
2168
2169    /// like [`Self::add_pv_with_hook`] but also installs an
2170    /// optional [`AccessHook`](crate::server::pv::AccessHook) so the CA
2171    /// gateway can route this shadow PV's read/write access-rights
2172    /// decision through its own ACF. Both hooks are attached before the
2173    /// PV is inserted into `simple_pvs`, so a downstream `CREATE_CHAN`
2174    /// cannot observe the PV without its access hook bound.
2175    pub async fn add_pv_with_hooks(
2176        &self,
2177        name: &str,
2178        initial: EpicsValue,
2179        write_hook: crate::server::pv::WriteHook,
2180        access_hook: Option<crate::server::pv::AccessHook>,
2181    ) -> CaResult<()> {
2182        self.add_pv_with_hooks_full(name, initial, write_hook, access_hook, None)
2183            .await
2184    }
2185
2186    /// like [`Self::add_pv_with_hooks`] but also installs an optional
2187    /// [`ReadHook`](crate::server::pv::ReadHook) so a proxy (the CA
2188    /// gateway in no-cache mode) can serve each downstream GET from a
2189    /// fresh upstream fetch instead of the stored value. All three hooks
2190    /// are attached before the PV is inserted into `simple_pvs`, so a
2191    /// downstream `CREATE_CHAN` cannot observe the PV without its hooks
2192    /// bound — the read hook lands atomically with registration, closing
2193    /// the same race the write/access hooks already close. `read_hook:
2194    /// None` is identical to [`Self::add_pv_with_hooks`].
2195    pub async fn add_pv_with_hooks_full(
2196        &self,
2197        name: &str,
2198        initial: EpicsValue,
2199        write_hook: crate::server::pv::WriteHook,
2200        access_hook: Option<crate::server::pv::AccessHook>,
2201        read_hook: Option<crate::server::pv::ReadHook>,
2202    ) -> CaResult<()> {
2203        let _gate = self.lock_registration("add_pv_with_hooks_full");
2204        self.check_name_free(name)?;
2205        let pv = Arc::new(ProcessVariable::new(name.to_string(), initial));
2206        pv.set_write_hook(write_hook);
2207        if let Some(access) = access_hook {
2208            pv.set_access_hook(access);
2209        }
2210        if let Some(read) = read_hook {
2211            pv.set_read_hook(read);
2212        }
2213        self.inner.simple_pvs.lock().insert(name.to_string(), pv);
2214        Ok(())
2215    }
2216
2217    /// Remove a simple PV by name. Returns `Some(pv)` if a PV was
2218    /// removed. Used by the gateway sweep so an evicted upstream
2219    /// subscription doesn't leave a stale shadow PV (with a now-dead
2220    /// `WriteHook` capturing an aborted upstream channel).
2221    ///
2222    /// Also purges any aliases that pointed AT this name
2223    /// (otherwise a re-add of the same alias name would fail with
2224    /// "already registered as an alias" even though its target is
2225    /// gone).
2226    pub async fn remove_simple_pv(&self, name: &str) -> Option<Arc<ProcessVariable>> {
2227        let _gate = self.lock_registration("remove_simple_pv");
2228        // Simple PVs cannot be alias targets (aliases point at
2229        // records), but a stale alias whose name MATCHES this PV
2230        // would have been rejected at add_alias time. No alias
2231        // cleanup needed for simple-PV removal.
2232        let removed = self.inner.simple_pvs.lock().remove(name);
2233        if let Some(pv) = &removed {
2234            // Removal IS destruction: this funnel is `destroy`'s only
2235            // caller, so a PV cannot leave the directory while a server
2236            // still serves it. C ca-gateway does the same on upstream
2237            // death — `delete vc` (gatePv.cc:601), the downstream monitors
2238            // stop rather than take one more frame.
2239            pv.destroy();
2240            self.signal_destroyed();
2241        }
2242        removed
2243    }
2244
2245    /// Register an already-built [`ProcessVariable`] under its own name.
2246    ///
2247    /// The `add_pv*` family builds the PV from parts; this one takes a PV
2248    /// the caller already holds, which is what a proxy re-installing a
2249    /// [`ProcessVariable::respawn`]ed replacement needs — the hooks and
2250    /// shadow metadata travel on the object instead of being re-derived at
2251    /// every call site.
2252    pub async fn add_simple_pv(&self, pv: ProcessVariable) -> CaResult<()> {
2253        let _gate = self.lock_registration("add_simple_pv");
2254        self.check_name_free(&pv.name)?;
2255        let name = pv.name.clone();
2256        self.inner.simple_pvs.lock().insert(name, Arc::new(pv));
2257        Ok(())
2258    }
2259
2260    /// Subscribe to the "something was removed" wake-up.
2261    ///
2262    /// A server that holds channels on this database races this against its
2263    /// socket read and sweeps out every channel whose target now answers
2264    /// `is_destroyed()`. Lagging is harmless: one missed wake still means
2265    /// "re-check", which is what the sweep does.
2266    pub fn pv_destroyed_events(&self) -> tokio::sync::broadcast::Receiver<()> {
2267        self.inner.pv_destroyed_tx.subscribe()
2268    }
2269
2270    /// Fire the wake-up. A send error means no server is attached, which is
2271    /// the normal state for a bare in-process database.
2272    fn signal_destroyed(&self) {
2273        let _ = self.inner.pv_destroyed_tx.send(());
2274    }
2275
2276    /// Enter the LOAD phase: records are being created and the database is not
2277    /// yet the one C would classify links against. Called by every path that
2278    /// begins creating records for an IOC — an `IocBuilder` build, an iocsh
2279    /// `dbLoadRecords` / `dbCreateRecord`, `IocApp::run` — and idempotent within
2280    /// the phase, because an `st.cmd` issues several loads and they are all one
2281    /// `iocInit` (R18-92).
2282    ///
2283    /// # Refused once the IOC is running (R19-63)
2284    ///
2285    /// C admits no record creation after `iocInit`: `dbReadCOM`
2286    /// (`dbLexRoutines.c:236`) fails every `.db`/`.dbd` read with `-2` once
2287    /// `getIocState() != iocVoid`, and `dbCreateRecordCallFunc`
2288    /// (`dbStaticIocRegister.c:288-291` at `f4ccf7bc8`, a command no release
2289    /// tag carries) fails with `S_dbLib_postInitRecRegister`.
2290    /// Asking to create records IS asking to enter the load phase, so the answer
2291    /// lives here and is a `Result` the caller cannot ignore — a creator that
2292    /// never asked cannot be written by accident, and one that asked cannot
2293    /// proceed on a refusal.
2294    ///
2295    /// The phase is left ONLY by [`Self::ioc_init`], and once left it is
2296    /// TERMINAL (R19-62): the queue is drained by exactly one `ioc_init`, so
2297    /// nothing can be pushed into it afterwards and stranded. A load that fails
2298    /// halfway leaves the phase open, which strands nothing: a queued
2299    /// classification blocks no caller, and it is dropped with the database.
2300    #[must_use = "C refuses a load after iocInit (dbReadCOM, dbLexRoutines.c:236); \
2301                  the refusal must be reported and no record created"]
2302    pub fn begin_load(&self) -> Result<(), IocAlreadyInitialized> {
2303        let mut phase = self.inner.init_phase.lock().unwrap();
2304        match *phase {
2305            // The only producer of `Loading`.
2306            DbInitPhase::Unloaded => {
2307                *phase = DbInitPhase::Loading(Vec::new());
2308                Ok(())
2309            }
2310            // An `st.cmd` issues several loads; they are all one `iocInit`.
2311            DbInitPhase::Loading(_) => Ok(()),
2312            // The barrier has begun, or has finished. Refused, as C refuses
2313            // a load once `iocInit` is under way.
2314            DbInitPhase::Initialising(_) | DbInitPhase::Running => Err(IocAlreadyInitialized),
2315        }
2316    }
2317
2318    /// Has [`Self::ioc_init`] run?
2319    ///
2320    /// C's `dbtr` / `dbtgf` / `dbtpf` refuse to touch a record whose
2321    /// `lset` is still NULL — the field `iocInit` fills — each printing
2322    /// `<its own name> only works after iocInit` and returning −1
2323    /// (`dbTest.c:476-478`, `:520-522`, `:621-623`). This is the port's
2324    /// twin of that test: the phase is the one owner of "has iocInit
2325    /// run", so the shell asks it rather than probing a record for an
2326    /// initialised-only side effect.
2327    pub fn ioc_is_running(&self) -> bool {
2328        matches!(*self.inner.init_phase.lock().unwrap(), DbInitPhase::Running)
2329    }
2330
2331    /// Schedule a record's link-status classification — the port's
2332    /// `init_record` tail (C `checkLinks`).
2333    ///
2334    /// During the LOAD phase the future is QUEUED for [`Self::ioc_init`]; a
2335    /// half-built database cannot be classified against because the code that
2336    /// would do it has not been polled. Before any load, and once `iocInit` has
2337    /// run, it is spawned at once — which is what a runtime `special()` link
2338    /// re-point needs.
2339    ///
2340    /// # `record` is what the init classifies, and it must exist first
2341    ///
2342    /// A record's first classification is issued from `set_async_context`,
2343    /// which [`Self::add_loaded_record`] calls *before* it inserts the record
2344    /// into `records` — the handle has to exist for `run_init_passes` to use
2345    /// it. So on the spawn-at-once paths the init would race its own record's
2346    /// registration and could post fields for a record the database does not
2347    /// have yet.
2348    ///
2349    /// This used to be papered over by starting each such future with a
2350    /// `crate::runtime::task::yield_now()`, on the assumption that yielding
2351    /// hands the thread back to the in-progress `add_record`. That assumption
2352    /// holds only on a current-thread runtime: on a multi-thread one the yield
2353    /// can return before the insert lands, and under `exec_backend` the init
2354    /// runs on the background executor's own thread, where a yield is not a
2355    /// synchronisation with `add_record` at all — it is nothing. The tests
2356    /// that read a link-status field right after `add_record` therefore failed
2357    /// by timing, with a different test failing per run.
2358    ///
2359    /// Now the ordering is a property of the data: an init naming a record that
2360    /// is not registered is *parked* under that name, and the only thing that
2361    /// can release it is the insert of that name. No yield, no window, and the
2362    /// same code path on every backend.
2363    pub(crate) fn schedule_record_init(
2364        &self,
2365        record: &str,
2366        init: impl std::future::Future<Output = ()> + Send + 'static,
2367    ) {
2368        if !self.inner.records.read().contains_key(record) {
2369            self.inner
2370                .record_init_waiting
2371                .lock()
2372                .unwrap()
2373                .entry(record.to_string())
2374                .or_default()
2375                .push(Box::pin(init));
2376            return;
2377        }
2378        self.dispatch_record_init(Box::pin(init));
2379    }
2380
2381    /// Queue or spawn an init whose record is known to be registered.
2382    fn dispatch_record_init(&self, init: RecordInit) {
2383        let mut phase = self.inner.init_phase.lock().unwrap();
2384        match &mut *phase {
2385            DbInitPhase::Loading(queued) | DbInitPhase::Initialising(queued) => queued.push(init),
2386            DbInitPhase::Unloaded | DbInitPhase::Running => {
2387                drop(phase);
2388                // Middle band, not the record's PRIO: C runs `init_record`
2389                // inline on the `iocInit` thread (`dbInitRecord`,
2390                // dbAccess.c), never on a callback queue, so there is no
2391                // per-record band for record init to inherit.
2392                crate::runtime::task::spawn_background(
2393                    crate::runtime::task::CallbackPriority::Medium,
2394                    init,
2395                );
2396            }
2397        }
2398    }
2399
2400    /// Release every init parked for `record` — called by the one site that
2401    /// registers the name, immediately after the insert that makes it real.
2402    fn release_record_inits(&self, record: &str) {
2403        let parked = self
2404            .inner
2405            .record_init_waiting
2406            .lock()
2407            .unwrap()
2408            .remove(record);
2409        for init in parked.into_iter().flatten() {
2410            self.dispatch_record_init(init);
2411        }
2412    }
2413
2414    /// The `iocInit` barrier: end the LOAD phase and run every classification
2415    /// owed, to completion.
2416    ///
2417    /// After this returns the database is complete and every link status is
2418    /// FINAL — C's guarantee, where `init_record` runs inside `iocInit` and a
2419    /// `dbgf REC.INAV` right after it reads the classified value (before it, C
2420    /// refuses `dbgf` outright). Idempotent: an `st.cmd` that spells `iocInit`
2421    /// out and the `IocApp` that runs one anyway are the same single boundary.
2422    pub async fn ioc_init(&self) {
2423        let mut owed = {
2424            let mut phase = self.inner.init_phase.lock().unwrap();
2425            match std::mem::replace(&mut *phase, DbInitPhase::Initialising(Vec::new())) {
2426                DbInitPhase::Loading(queued) => queued,
2427                // An IOC that loaded nothing (programmatic / unit-test
2428                // database) still crosses the barrier: the phase becomes
2429                // terminal. It owes no per-record init pass.
2430                DbInitPhase::Unloaded => Vec::new(),
2431                // Already claimed or already finished — put back what was
2432                // taken and leave.
2433                already @ (DbInitPhase::Initialising(_) | DbInitPhase::Running) => {
2434                    *phase = already;
2435                    return;
2436                }
2437            }
2438        };
2439        // C `iocBuild` calls `dbCaLinkInit()` between its two halves
2440        // (`iocInit.c:216`), so the `dbCaLink` worker is up before the record
2441        // init passes of `iocBuild_2` — and before anything they wire can
2442        // stage a link. Here rather than in `ioc_app`'s lifecycle because this
2443        // barrier is the one point every bring-up path crosses: the
2444        // `IocApplication` walk and `CaServer::run` both reach it, and only
2445        // one of them runs that lifecycle.
2446        // C's `initDatabase` per-record init pass — bind each LOAD-deferred
2447        // record's dset and run its `init_record`. The build lifecycle drains
2448        // this explicitly BEFORE `setup_io_intr` (C's `scanInit`), so a record's
2449        // dset is bound before I/O Intr wiring reads it; this call is the
2450        // catch-all for a path that reaches the barrier directly (a unit test, a
2451        // bare shell). Idempotent — the list is empty once drained. Before
2452        // `ca_link_init` to keep the order the eager path had, where the init
2453        // passes ran at load, ahead of the link worker.
2454        self.drain_deferred_record_inits();
2455        self.ca_link_init();
2456        // C's `dbInitRecordLinks`: every link is typed, checked and opened at
2457        // `iocInit`, not at `dbLoadRecords`. That is where a `{state:"NAME"}`
2458        // link's `dbStateCreate` happens — so its state exists from `iocInit`
2459        // onward whether or not anything ever reads it — and it is also where
2460        // a link the record's device support cannot take is refused, with the
2461        // record kept. Measured on softIoc R7.0.10: `dbStateShowAll 1` prints
2462        // nothing after `dbLoadRecords` alone and lists every name any link
2463        // mentions after `iocInit`, all FALSE, with nothing processed.
2464        //
2465        // Creating a state on first use alone — which the link read/write
2466        // owner still does, because a `dbLoadRecords` AFTER `iocInit` opens
2467        // its links then too — agrees on every value but left the registry
2468        // empty until a link was touched.
2469        self.db_init_record_links().await;
2470        // The pass is done and every link's text is final, so the phase becomes
2471        // terminal here — and what the pass queued joins the load's own
2472        // backlog, behind it, so a record classified twice publishes the later
2473        // verdict.
2474        {
2475            let mut phase = self.inner.init_phase.lock().unwrap();
2476            if let DbInitPhase::Initialising(queued) =
2477                std::mem::replace(&mut *phase, DbInitPhase::Running)
2478            {
2479                owed.extend(queued);
2480            }
2481        }
2482        // Sequential, in issue order: each classification is a short read of a
2483        // now-immutable record set, and C's `init_record` pass is a loop too.
2484        for init in owed {
2485            init.await;
2486        }
2487        // C `dbLockInitRecords` plus the merges `initDatabase` drives as it
2488        // opens each DB link (`iocInit.c:178-179`). It runs after the per-record
2489        // init pass for the same reason C runs it after `prepareLinks`: the link
2490        // fields must have their final text before the graph is read.
2491        self.build_lock_sets();
2492    }
2493
2494    /// C `dbInitRecordLinks` (`dbStaticLib.c:2171-2233`), which C runs once per
2495    /// record at `iocInit` and which this port runs once over the whole
2496    /// database at the same barrier.
2497    ///
2498    /// Three things happen to every link field, in C's order. Its TYPE comes
2499    /// from the device support the record's `DTYP` binds — `CONSTANT` for every
2500    /// field that is not the device link, since only `INP`/`OUT` have a
2501    /// `devSup` — and its payload starts empty (`:2185-2212`). A field the load
2502    /// gave no text to is then left exactly there (`:2213`), which is why
2503    /// `record(ai,"X"){field(DTYP,"Soft Timestamp")}` shows `INP : INST_IO @`
2504    /// and not `CONSTANT`. Text that does not fit the declared type is refused
2505    /// with a printed line and dropped — `dbInitRecordLinks` returns 0
2506    /// unconditionally, so the record stays, the load does not fail, and the
2507    /// IOC runs. Measured on softIoc R7.0.10 over a ten-record file with eight
2508    /// bad links: eight `ERROR:` lines at `iocInit`, ten names in `dbl` before
2509    /// and after, and `iocRun: All initialization complete`.
2510    ///
2511    /// The jlink `open` half rides here too, because in C it is the same pass:
2512    /// `dbSetLink` (`:2225`) is what calls a jlink's `open`, and it is reached
2513    /// only by a link that passed the check. So a `{state:…}` link on a field
2514    /// whose device support refuses `JSON_LINK` never creates its `dbState` —
2515    /// `lnkState_open` (`lnkState.c:110-116`) is `dbStateCreate`, find-or-create
2516    /// (`dbState.c:50-66`), and it is not called at all.
2517    ///
2518    /// C runs this pass BEFORE `init_record`; this port runs the init passes
2519    /// first — at load for a programmatic / `dbCreateRecord` record, and at the
2520    /// barrier just above (`init_deferred_record` draining
2521    /// `deferred_record_inits`) for a record loaded during the LOAD phase — so
2522    /// the port's order is still init-then-link, and that ordering IS
2523    /// observable. Measured against softIoc R7.0.10 on
2524    /// `record(calcout,"X"){field(INPA,"@instio p") field(OUT,"@instio q")}`:
2525    /// C reads `INAV`/`OUTV` as `Constant`, this port read `Ext PV NC`, because
2526    /// calcout had classified both at init from text this pass then replaced.
2527    /// A record's cached link status is therefore re-derived where the text
2528    /// changes (see [`Self::set_link_text`]) and once per record at the end of
2529    /// the loop, which is the same seam a runtime re-point uses. What is NOT
2530    /// closed is the ORDER of the two passes: device support reads the refused
2531    /// text at `init_record` where C reads the emptied link, because this pass
2532    /// runs after the deferred-init drain rather than before it. Closing it
2533    /// means running this pass ahead of that drain, a reordering the eager
2534    /// (`IocBuilder`, non-LOAD `dbLoadRecords`) callers would have to follow too.
2535    async fn db_init_record_links(&self) {
2536        use crate::runtime::log::ERL_ERROR;
2537        use crate::server::record::{
2538            DbLinkType, ParsedLink, declared_link_type, link_type_refusal,
2539        };
2540        let empty_assigned =
2541            std::mem::take(&mut *self.inner.empty_link_assignments.lock().unwrap());
2542        let states = crate::server::database::filters::sync::db_state_registry();
2543        for name in self.all_record_names().await {
2544            let Some(rec) = self.get_record(&name) else {
2545                continue;
2546            };
2547            let (record_type, dtyp, fields) = {
2548                let inst = rec.read();
2549                (
2550                    inst.record.record_type().to_string(),
2551                    inst.common.dtyp.as_str().to_string(),
2552                    Self::link_field_texts(&inst),
2553                )
2554            };
2555            let assigned_empty = empty_assigned.get(&name);
2556            for (field, text, ftype) in fields {
2557                // C `if (!plink->text) continue;` (`:2213`) — see
2558                // `PvDatabaseInner::empty_link_assignments` for why the empty
2559                // assignment needs its own record.
2560                let assigned = !text.is_empty()
2561                    || assigned_empty.is_some_and(|fields| fields.iter().any(|f| f == &field));
2562                let mut refused = false;
2563                if assigned {
2564                    if let Some(line) =
2565                        link_type_refusal(&name, &record_type, Some(&dtyp), &field, &text)
2566                    {
2567                        eprintln!("{ERL_ERROR}: {line}");
2568                        refused = true;
2569                    }
2570                }
2571                if !assigned || refused {
2572                    let empty = declared_link_type(&record_type, Some(&dtyp), &field)
2573                        .map_or("", DbLinkType::empty_link_text);
2574                    if empty != text {
2575                        Self::set_link_text(&mut rec.write(), &field, empty);
2576                    }
2577                    continue;
2578                }
2579                if let ParsedLink::State(state) =
2580                    crate::server::record::parse_link_field(&text, ftype)
2581                {
2582                    states.get_or_create(&state.name);
2583                }
2584            }
2585            // The record's links are final from here on. `init_links` is the
2586            // hook that says so — calcout and swait capture the common `OUT`
2587            // through it, because their `special()` deliberately does not
2588            // re-classify that one field — and it is idempotent, so the load
2589            // callers' earlier call is simply superseded by this one.
2590            let mut inst = rec.write();
2591            let inst = &mut *inst;
2592            inst.record.init_links(&inst.common);
2593        }
2594    }
2595
2596    /// Write a link field's text back through whichever storage owns it — the
2597    /// same split [`Self::link_field_texts`] reads it from. Only ever used to
2598    /// install the empty link of a declared type, so a rejected put would mean
2599    /// the two owners disagree about which fields are links.
2600    fn set_link_text(inst: &mut RecordInstance, field: &str, text: &str) {
2601        let value = EpicsValue::String(text.into());
2602        let put = if inst.common_link_text(field).is_some() {
2603            inst.put_common_field_db_load(field, value).map(|_| ())
2604        } else {
2605            inst.record.put_field(field, value)
2606        };
2607        debug_assert!(put.is_ok(), "{field} is a link field with no writer");
2608        // C brackets every link write with the special pair — `dbPutSpecial`
2609        // pass 0 then pass 1 around `dbSetLink` (`dbAccess.c:1174,1178`) — and
2610        // pass 1 is where a record re-derives what it caches from a link's
2611        // text: calcout `INAV..INUV`, transform's and (a)scalcout's link
2612        // tables. Neither writer above runs it, and this pass is the LAST
2613        // writer of every link field's text, so without it the classification
2614        // the record made at load stands against text that no longer exists.
2615        let _ = inst.record.special(field, true);
2616    }
2617
2618    /// **The only entry point that can serve a link-backed field's metadata.**
2619    ///
2620    /// C reads that metadata live: `get_units` and its three siblings call
2621    /// `dbGetUnits`/`dbGetPrecision`/`dbGetGraphicLimits`/`dbGetAlarmLimits`
2622    /// inline, and `dbDbLink.c:240-261` takes the TARGET's lock for the
2623    /// duration — legal there because `dbLock.c:725-760` merges every
2624    /// DB_LINK-connected record into one lock set behind one recursive mutex,
2625    /// so the source's lock and the target's lock are the same mutex. This
2626    /// port has lock sets too now (`record_lock`), but the lock C is taking
2627    /// there is the one guarding the record's DATA, and that one is still a
2628    /// `parking_lot::RwLock` per record — non-recursive and not merged — so
2629    /// reaching for the target's from under the source's still inverts the
2630    /// order record processing takes and two mutually linked records are still
2631    /// enough to deadlock.
2632    ///
2633    /// So the invariant is **no record lock is held while a link is
2634    /// resolved**, and this function is its owner: it asks under a short read
2635    /// lock which link (if any) backs `field`, drops the lock, resolves, and
2636    /// only then re-locks to build. The resolved value is handed to the
2637    /// builder as a borrowed [`LinkBacking`] and never stored, which is what
2638    /// makes *"a served snapshot's link-backed metadata was resolved during
2639    /// THIS build"* true by construction rather than by a freshness check.
2640    ///
2641    /// A field no link backs never leaves the first lock.
2642    ///
2643    /// `string_view` is the channel's `$` modifier, and it is not optional:
2644    /// there is no door here that takes a field name without one. C decides
2645    /// the view once, in `dbChannelCreate` (`dbChannel.c:486-505`), and the
2646    /// `dbChannel` carries it for the channel's whole life, so a delivery
2647    /// path that knows the field but not the view is a path that has lost
2648    /// half of what it was asked to serve. An ineligible `$` answers `None`
2649    /// — the same shape [`RecordInstance::snapshot_for_field`] uses to make
2650    /// a caller at the wrong door serve nothing rather than something wrong.
2651    pub fn channel_snapshot_for_field(
2652        &self,
2653        record: &Arc<RecordCell>,
2654        field: &str,
2655        string_view: bool,
2656    ) -> Option<crate::server::snapshot::Snapshot> {
2657        self.channel_snapshot_for_field_guarded(
2658            record,
2659            field,
2660            string_view,
2661            &mut std::collections::HashSet::new(),
2662        )
2663    }
2664
2665    /// [`Self::channel_snapshot_for_field`] carrying C's `DBLINK_FLAG_VISITED` set
2666    /// (`dbDbLink.c:253-257`).
2667    ///
2668    /// The resolve is recursive: a `calc` whose `INPA` points at another
2669    /// `calc` answers its `A` metadata from THAT record's rset, which routes
2670    /// through ITS link. C guards the recursion with one flag per link,
2671    /// cleared as the inner fetch returns, so a diamond still reports on both
2672    /// arms while a cycle stops. `db_target_metadata` passes its caller's set
2673    /// through to here, so one guard spans the whole resolve exactly as C's
2674    /// does.
2675    pub(crate) fn channel_snapshot_for_field_guarded(
2676        &self,
2677        record: &Arc<RecordCell>,
2678        field: &str,
2679        string_view: bool,
2680        visited: &mut std::collections::HashSet<String>,
2681    ) -> Option<crate::server::snapshot::Snapshot> {
2682        // 1. Under a short read lock: is this field link-backed at all, and if
2683        //    so what does its link field currently say. Nothing is resolved
2684        //    here — resolving needs the target's lock.
2685        let link = {
2686            let inst = record.read();
2687            match inst.link_backed_metadata_field_of(field) {
2688                None => {
2689                    return inst.channel_snapshot_for_field(
2690                        field,
2691                        string_view,
2692                        LinkBacking::none(),
2693                    );
2694                }
2695                Some(link_field) => inst.link_text(&link_field).map(|text| (link_field, text)),
2696            }
2697        };
2698
2699        // 2. No record lock held. A link that resolves to nothing — CONSTANT,
2700        //    an unresolvable target, or one the visited guard refused — leaves
2701        //    the map empty, which serves each slot's C seed.
2702        let mut resolved = HashMap::new();
2703        if let Some((link_field, text)) = link {
2704            let parsed = crate::server::record::parse_link_v2(&text);
2705            if let Some(meta) = self.link_metadata(&parsed, visited) {
2706                resolved.insert(link_field, meta);
2707            }
2708        }
2709
2710        // 3. Re-lock and build with what was just resolved. The borrow ends
2711        //    with this call, so there is nowhere to keep it.
2712        record.read().channel_snapshot_for_field(
2713            field,
2714            string_view,
2715            LinkBacking::resolved(&resolved),
2716        )
2717    }
2718
2719    /// Every link-backed field's metadata for one record, resolved once with
2720    /// no record lock held — what a process cycle or a put hands to the
2721    /// monitor posters, which run with the record's own lock held and so
2722    /// cannot resolve anything themselves.
2723    ///
2724    /// Empty for the record types that back no metadata with a link, which is
2725    /// all but `calc`, `calcout`, `sub` and `aSub`.
2726    pub fn resolve_link_backed_metadata(
2727        &self,
2728        record: &Arc<RecordCell>,
2729    ) -> HashMap<String, LinkMetadata> {
2730        self.resolve_link_backed_metadata_with(record, &InputLinkTexts::none())
2731    }
2732
2733    /// [`Self::resolve_link_backed_metadata`] for a POSTER — a process cycle,
2734    /// the async-completion tail, the simulation tail, or one of the two
2735    /// out-of-band field posters. All of them resolve so a monitor snapshot
2736    /// can carry the LINK's units/precision/limits instead of the record's
2737    /// own.
2738    ///
2739    /// Which is why this door may ask a question the serve door must not: a
2740    /// poster's result reaches a consumer only through
2741    /// `RecordInstance::make_monitor_snapshot`, and every caller of that sits
2742    /// inside a `self.subscribers.get(field)` hit
2743    /// (`notify_from_snapshot`, `notify_field_with_origin`,
2744    /// `notify_record_alarm`), so a record nothing is subscribed to has no
2745    /// reader for what this would resolve. C pays nothing here either way —
2746    /// its five metadata slots are `dbDb_lset` entries (`dbDbLink.c:414-415`)
2747    /// reached from `dbGet`, so a link-backed slot is resolved when a
2748    /// CONSUMER asks the link, never from `dbProcess`.
2749    ///
2750    /// A serve caller (`read_group`, `read_member`) must keep to
2751    /// [`Self::resolve_link_backed_metadata`]: it resolves for the client in
2752    /// front of it, about which the record's own subscriber list says nothing.
2753    ///
2754    /// Measured, 2000 `calc` records at 10 Hz each with one set `INPA`: 5.76
2755    /// us of CPU per process cycle through the ungated door, 3.01 through this
2756    /// one. The same record in C costs 0.45 us.
2757    pub(crate) fn resolve_link_backed_metadata_for_posts(
2758        &self,
2759        record: &Arc<RecordCell>,
2760    ) -> PostBacking {
2761        self.resolve_link_backed_metadata_for_posts_with(record, &InputLinkTexts::none())
2762    }
2763
2764    /// [`Self::resolve_link_backed_metadata_for_posts`] for the cycle that has
2765    /// already read this record's link fields — see
2766    /// [`Self::resolve_link_backed_metadata_with`].
2767    pub(crate) fn resolve_link_backed_metadata_for_posts_with(
2768        &self,
2769        record: &Arc<RecordCell>,
2770        prefetched: &InputLinkTexts,
2771    ) -> PostBacking {
2772        // Nothing to resolve and no lock taken — the same two answers the
2773        // ungated door gives, and both of them are answers, not declines.
2774        if prefetched.none_set() {
2775            return PostBacking::empty();
2776        }
2777        let plan = Self::plan_link_backed_metadata_for_posts(&record.read(), prefetched);
2778        self.resolve_link_backed_metadata_plan(plan)
2779    }
2780
2781    /// The first half of [`Self::resolve_link_backed_metadata_for_posts_with`]:
2782    /// everything it asks of THIS record, under the caller's acquisition of
2783    /// it. The process cycle asks from inside its entry guard, so the gate
2784    /// costs it no second acquisition; the other posters ask under a read
2785    /// lock of their own.
2786    pub(crate) fn plan_link_backed_metadata_for_posts(
2787        inst: &crate::server::record::RecordInstance,
2788        prefetched: &InputLinkTexts,
2789    ) -> MetadataPlan {
2790        if prefetched.none_set() || inst.link_backed_metadata_links().is_empty() {
2791            return MetadataPlan::Empty;
2792        }
2793        // **The gate, and the only place it is asked** — inside the one
2794        // acquisition the text read below needs anyway. A poster's result
2795        // reaches a consumer only through
2796        // `RecordInstance::make_monitor_snapshot`, and every caller of
2797        // that sits inside a `self.subscribers.get(field)` hit, so with
2798        // nobody subscribed the walk is work no one can read. C pays
2799        // nothing here either way: its five metadata slots are
2800        // `dbDb_lset` entries (`dbDbLink.c:414-415`) reached from `dbGet`,
2801        // so a link-backed slot is resolved when a CONSUMER asks the link,
2802        // never from `dbProcess`.
2803        //
2804        // The answer is a DECLINE, not an empty map. A subscriber can
2805        // arrive between a poster's read lock and the post's write lock, and
2806        // the post must be able to tell that its backing was never looked up —
2807        // otherwise that one event carries the record's own C seed where
2808        // the link's metadata belongs.
2809        if inst.subscribers.is_empty() {
2810            return MetadataPlan::Declined;
2811        }
2812        MetadataPlan::Links(Self::link_backed_metadata_texts(inst, prefetched))
2813    }
2814
2815    /// The second half: the walk, with NO lock on the record held — it
2816    /// reaches for each TARGET's lock, and a self-link's target is this
2817    /// record.
2818    pub(crate) fn resolve_link_backed_metadata_plan(&self, plan: MetadataPlan) -> PostBacking {
2819        match plan {
2820            MetadataPlan::Empty => PostBacking::empty(),
2821            MetadataPlan::Declined => PostBacking::declined(),
2822            MetadataPlan::Links(links) => {
2823                PostBacking::resolved(self.walk_link_backed_metadata(links))
2824            }
2825        }
2826    }
2827
2828    /// [`Self::resolve_link_backed_metadata`] for a caller that has already
2829    /// read some of this record's link fields this cycle.
2830    ///
2831    /// A record's link-backed metadata links are, for the calc class, exactly
2832    /// its `INPA`..`INPL` — the same twelve the process cycle's multi-input
2833    /// fetch reads a few stages later. Reading a field by name is a linear
2834    /// search of the record type's declared names, so asking for all twelve
2835    /// twice was the single most expensive thing in the cycle. The caller that
2836    /// has them passes them in; anything not covered is still read here.
2837    pub fn resolve_link_backed_metadata_with(
2838        &self,
2839        record: &Arc<RecordCell>,
2840        prefetched: &InputLinkTexts,
2841    ) -> HashMap<String, LinkMetadata> {
2842        // Every entry below needs a SET link at its input slot, so a pass that
2843        // read the links and found none is already done — without the record
2844        // lock the walk would otherwise take.
2845        if prefetched.none_set() {
2846            return HashMap::new();
2847        }
2848        let links = {
2849            let inst = record.read();
2850            Self::link_backed_metadata_texts(&inst, prefetched)
2851        };
2852        self.walk_link_backed_metadata(links)
2853    }
2854
2855    /// The link texts to walk, read under the caller's one acquisition of the
2856    /// record lock. Split from [`Self::walk_link_backed_metadata`] so each door
2857    /// owns its own lock scope: the gate the poster applies has to be asked in
2858    /// the same acquisition, and a shared body would have to be told which door
2859    /// called it — the flag whose two meanings this split removes.
2860    fn link_backed_metadata_texts(
2861        inst: &crate::server::record::RecordInstance,
2862        prefetched: &InputLinkTexts,
2863    ) -> Vec<(String, Arc<crate::server::record::ParsedLink>)> {
2864        inst.link_backed_metadata_links()
2865            .iter()
2866            // The field name is cloned only for a link that is actually set:
2867            // `then_some` took its argument eagerly, so every declared link
2868            // paid a `String` allocation per cycle — and the list this builds
2869            // is empty for the unwired record that is the common case.
2870            .zip(inst.link_backed_metadata_input_slots())
2871            .filter_map(|(lf, slot)| {
2872                let link = prefetched.link_at(*slot, inst, lf)?;
2873                Some((lf.clone(), link))
2874            })
2875            .collect()
2876    }
2877
2878    /// Walk each link to its target's metadata. Runs with NO record lock held —
2879    /// it reaches for the TARGET's lock, and C's own lock sets are what make
2880    /// that legal only from here.
2881    fn walk_link_backed_metadata(
2882        &self,
2883        links: Vec<(String, Arc<crate::server::record::ParsedLink>)>,
2884    ) -> HashMap<String, LinkMetadata> {
2885        let mut resolved = HashMap::new();
2886        for (link_field, parsed) in links {
2887            let mut visited = std::collections::HashSet::new();
2888            if let Some(meta) = self.link_metadata(&parsed, &mut visited) {
2889                resolved.insert(link_field, meta);
2890            }
2891        }
2892        resolved
2893    }
2894
2895    /// Add a record (accepts a boxed Record to avoid double-boxing).
2896    ///
2897    /// Returns `Err` when `name` collides with an existing record,
2898    /// simple PV, or alias. The C IOC's `dbLoadRecords` treats this as
2899    /// fatal; do not silently replace.
2900    ///
2901    /// The records-map insert AND scan-index insert run
2902    /// under the same `registration_mutex` hold, eliminating the
2903    /// TOCTOU window where `remove_record` could land between them
2904    /// and leave a phantom scan entry.
2905    pub async fn add_record(&self, name: &str, record: Box<dyn Record>) -> CaResult<()> {
2906        self.add_loaded_record(name, record, RecordLoad::default())
2907            .await
2908    }
2909
2910    /// Add a record together with the field set its `.db` definition loaded
2911    /// into it — the creation sink for every `dbLoadRecords` path.
2912    ///
2913    /// C's `dbLoadRecords` writes a record's ENTIRE field set through
2914    /// `dbStaticLib` (including the `UDF = 0` that `dbPutString`
2915    /// (`dbStaticLib.c:2653-2661`) implies for any put to a field named
2916    /// `VAL`), and only afterwards does `iocInit::doInitRecord0`
2917    /// (`iocInit.c:508-536`) evaluate `if (udf && stat == UDF_ALARM) sevr =
2918    /// udfs`. The port used to add the record first and apply its loaded common
2919    /// fields afterwards, so the init passes ran against a PRE-LOAD field set:
2920    /// every record with a `field(VAL,…)` latched `SEVR = INVALID` at creation
2921    /// and the `UDF = 0` that arrived a moment later could not lower it again.
2922    /// A whole `.db` of setpoint defaults and sim constants came up red.
2923    ///
2924    /// Taking the loaded fields here is what makes C's ordering hold by
2925    /// construction: there is no window in which the init passes can observe a
2926    /// record whose `.db` fields have not landed, because the record is not
2927    /// reachable until they have. `RecordInstance::run_init_passes` is
2928    /// crate-private for the same reason — the sink is the only caller.
2929    pub async fn add_loaded_record(
2930        &self,
2931        name: &str,
2932        record: Box<dyn Record>,
2933        load: RecordLoad,
2934    ) -> CaResult<()> {
2935        // A record created after `iocInit` needs a lock set, and its links —
2936        // in both directions — may merge it into existing ones. `None` while
2937        // the database is still loading, which is every ordinary
2938        // `dbLoadRecords`: `build_lock_sets` builds the whole graph at
2939        // `iocInit` instead. Declared ABOVE the gate: the relink takes lock
2940        // sets, which sit above L46, so it must run once the gate is down.
2941        let _relink = self.lock_set_membership_change(name);
2942        let gate = self.lock_registration("add_loaded_record");
2943        self.check_name_free(name)?;
2944        let mut instance = RecordInstance::new_boxed(name.to_string(), record);
2945        // Hand the record a cycle-free handle to its own database so it can
2946        // post out-of-band field updates / wire completion-driven re-entry
2947        // (asyn TRACE callback, sseq WAITn) without owning the database.
2948        // C records reach `dbCommon::pdba`/the IOC the same way at
2949        // `dbDefineRecord` init; the framework supplies the back-reference,
2950        // the record never constructs it. Defaulted no-op for records that
2951        // do not need it.
2952        instance
2953            .record
2954            .set_async_context(name.to_string(), self.async_handle());
2955
2956        // Hand the record the current breakpoint-table registry snapshot so a
2957        // LINR>=3 ai/ao record can resolve its table lazily at convert time.
2958        // add_record is the single creation sink (IocBuilder, dbLoadRecords,
2959        // dbCreateRecord, inline records all funnel through here), so this one
2960        // install covers every creation path uniformly. The trait default is a
2961        // no-op for records that don't use it; skipped when no tables are
2962        // loaded so the common case pays no Arc clone. A record created before
2963        // its table is loaded is re-installed by `add_breaktables`.
2964        {
2965            let snapshot = self.inner.breaktable_registry.load_full();
2966            if !snapshot.is_empty() {
2967                instance.record.install_breaktable_registry(snapshot);
2968            }
2969        }
2970
2971        // The `.db` load, applied to the instance BEFORE the init passes below
2972        // — C's `dbLoadRecords` → `iocInit` ordering. The `.db` value coercion
2973        // (`put_common_field_db_load`) differs from a runtime `dbPut`'s: C's
2974        // loader converter has a wider menu bound (`dbStaticRun.c`).
2975        //
2976        // The scan-index entry is built from `instance.common.scan` further
2977        // down, i.e. from the POST-load field set, so a `field(SCAN,…)` needs
2978        // no index fix-up here — the record has not been published yet.
2979        //
2980        // C stores each of them with `dbPutString` and, when that fails, prints
2981        // the refusal and calls `yyerror(NULL)` (`dbLexRoutines.c:1406-1416`):
2982        // the field keeps its default, the record's other fields still load,
2983        // and the load's status goes non-zero. The port used to print a warning
2984        // of its own and carry on, so `field(SCAN,"Passiv")` loaded a Passive
2985        // record where C refuses the database outright — and `field(DTYP,...)`
2986        // was not checked at all, because the DTYP arm stores whatever string
2987        // it is handed.
2988        //
2989        // This is where the menu arm of that put can be decided: a `DBF_DEVICE`
2990        // field's choices are the record type's registered device support, and
2991        // that registry is complete only once the builder has run every
2992        // registration — after the `.db` text was parsed. C has no such
2993        // ordering, since every `.dbd` is loaded before any `.db`.
2994        let mut refused: Option<String> = None;
2995        // C's `plink->text` for the empty assignment — see
2996        // `PvDatabaseInner::empty_link_assignments`. Taken from the load's own
2997        // field list because that is the only place the distinction between
2998        // `field(INP,"")` and no `INP` line survives.
2999        let mut empty_links: Vec<String> = Vec::new();
3000        for (field, value) in load.common_fields {
3001            if let crate::types::EpicsValue::String(text) = &value {
3002                if text.as_str_lossy().is_empty()
3003                    && instance.common_link_text(&field.to_uppercase()).is_some()
3004                {
3005                    empty_links.push(field.to_uppercase());
3006                }
3007            }
3008            if let crate::types::EpicsValue::String(text) = &value {
3009                if let Some(refusal) = crate::server::db_loader::menu_value_refusal(
3010                    instance.record.record_type(),
3011                    name,
3012                    &field,
3013                    &text.as_str_lossy(),
3014                ) {
3015                    if let Some(notice) = refusal.notice {
3016                        eprintln!("{notice}");
3017                    }
3018                    eprintln!("{}", refusal.line);
3019                    // C follows the refusal with `dbPutStringSuggest`, which
3020                    // proposes the closest choice or prints nothing
3021                    // (`dbLexRoutines.c:1414`). It reaches the operator through
3022                    // errlog rather than stderr, so C's own output shows the
3023                    // proposals batched at the end; one stream puts each under
3024                    // the line it explains.
3025                    if let Some(suggestion) = refusal.suggestion {
3026                        eprintln!("{suggestion}");
3027                    }
3028                    refused.get_or_insert(format!("{name}.{field}"));
3029                    continue;
3030                }
3031            }
3032            if let Err(e) = instance.put_common_field_db_load(&field, value) {
3033                eprintln!("put_common_field({field}) failed for {name}: {e}");
3034            }
3035        }
3036        if let Some(what) = refused {
3037            return Err(CaError::BadChoice(what));
3038        }
3039        // `info(...)` tags land before `init_record`, so device support that
3040        // reads them at init sees the values.
3041        for (key, value) in &load.info_tags {
3042            instance.set_info(key, value);
3043        }
3044
3045        // C's `iocInit` init passes, through their owner (the `doInitRecord0`
3046        // prologue — `pact = FALSE` plus the initial UDF severity — then
3047        // `init_record(0)`, `init_record(1)`, and the UDF tail). This sink is
3048        // the single site that runs them: a record built programmatically, by
3049        // iocsh `dbCreateRecord`, or from a `.db` is initialised the same way,
3050        // and — since the load above has already landed — always against its
3051        // FINAL field set.
3052        if !empty_links.is_empty() {
3053            self.inner
3054                .empty_link_assignments
3055                .lock()
3056                .unwrap()
3057                .insert(name.to_string(), empty_links);
3058        }
3059
3060        // C `doInitRecord0` (`iocInit.c:530-536`) binds the dset and only then
3061        // calls `init_record(pass 0)`. Both lines are here, in that order,
3062        // because every `<rec>Record.c init_record` opens by testing the dset
3063        // — `ao`'s `prec->init = TRUE`, `sub`'s MLST/ALST/LALM seed and `ai`'s
3064        // are all BELOW that test, and running the passes first is what made
3065        // them reachable for a record C refuses.
3066        //
3067        // During the LOAD phase this whole span is OWED to `iocInit` instead:
3068        // C links a record into `pdbbase` at `dbLoadRecords` but runs
3069        // `init_record` at `iocInit`, and binding the dset eagerly here bound
3070        // it against whatever device-support ports existed when the `.db` was
3071        // parsed — wrong for a record whose port a later `st.cmd` command
3072        // configures (ADCore's `NDTimeSeriesConfigure` builds the `*_TS` port
3073        // AFTER `dbLoadRecords(NDStats.template)`). The record is still
3074        // published below so a name check, an alias and `dbInitRecordLinks` all
3075        // see it; only its device-support binding and passes wait for the
3076        // barrier's drain of `deferred_record_inits`. Outside the LOAD phase —
3077        // programmatic creation, `dbCreateRecord` after `iocInit` — there is no
3078        // barrier to defer to, so the record is initialised in place, as before.
3079        let defer = self.is_load_deferring();
3080        if !defer {
3081            crate::server::ioc_app::attach_device_support(
3082                &mut instance,
3083                name,
3084                self.inner.device_support_resolver.load().as_deref(),
3085            );
3086            // C `registryFunctionFind` reads a process-global registry from
3087            // inside `init_record` pass 1, so the record is handed the registry
3088            // rather than resolved against it from out here: the lookup's
3089            // failure is an early return that the init tail must not run past,
3090            // and only the init owner can honour that.
3091            instance.arm_init_subroutines(self.inner.subroutine_registry.load_full());
3092            instance.run_init_passes(name);
3093
3094            // The init-seed owner: every CONSTANT link the record declares
3095            // (`Record::constant_init_links`) is loaded into its value field
3096            // ONCE, here — a constant delivers NOTHING at process time
3097            // (`dbConstLink.c:219-225`). `add_record` is the creation sink every
3098            // path funnels through, so this covers a record built
3099            // programmatically as well as one loaded from a .db;
3100            // `IocBuilder`/`dbLoadRecords` call the owner again after
3101            // `init_record(1)`, once the record's final NELM/FTVL buffer exists
3102            // for an array constant to land in. Seeding twice is a no-op — both
3103            // run before any client put.
3104            super::database::processing::seed_constant_links(&mut instance);
3105        }
3106
3107        let scan = instance.common.scan;
3108        let phas = instance.common.phas;
3109        let record_type = instance.record.record_type();
3110        let rec_arc = Arc::new(RecordCell::new(instance));
3111        // C `createLockRecord` allocates the `lockRecord` INTO the record:
3112        // the registry's answer for this name is this record's own cell,
3113        // adopted before the record is reachable by anyone.
3114        self.inner.record_locks.adopt(name, rec_arc.lock_record());
3115        self.inner
3116            .records
3117            .write()
3118            .insert(Arc::from(name), rec_arc.clone());
3119        // The record is reachable from this line on, so anything its
3120        // `set_async_context` parked above may now run. This is the only
3121        // release site because this is the only site that registers the name.
3122        self.release_record_inits(name);
3123
3124        // Assign a monotonic load-order sequence — the scan-index
3125        // secondary sort key, so same-PHAS records keep load order. Assigned in
3126        // both arms at load time so the deferred drain preserves load order.
3127        let seq = self
3128            .inner
3129            .load_order_counter
3130            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3131        self.inner.load_order.update(|m| {
3132            m.insert(name.to_string(), seq);
3133        });
3134
3135        if defer {
3136            // The dset binding, init passes, scan-index insert and the
3137            // `recGblInitSimm`/`wdogInit` tail are all owed to `ioc_init`; the
3138            // record is published (above) so the barrier finds it by name.
3139            self.inner
3140                .deferred_record_inits
3141                .lock()
3142                .unwrap()
3143                .push(name.to_string());
3144            return Ok(());
3145        }
3146
3147        self.add_to_scan_list(scan, phas, record_type, seq, name);
3148
3149        // Registration is complete and the name is published, so the gate has
3150        // nothing left to serialize. It is released HERE, before the tail
3151        // below, and that is load-bearing rather than tidy:
3152        // `recGblInitSimm` can swap SCAN, and a scan change is applied by
3153        // `update_scan_index`, which takes this same `registration_mutex`
3154        // itself as the single owner of a scan-index transition. Holding it
3155        // across the call is a self-deadlock on a non-reentrant mutex — the
3156        // record only has to carry a constant SIML and a periodic SCAN to
3157        // reach it.
3158        drop(gate);
3159
3160        // The rest of C's `init_record` pass 1, which needs the record
3161        // REGISTERED and so cannot run with `run_init_passes` above:
3162        // `recGblInitSimm` plus its `recGblInitConstantLink(&siol, …, &sval)`
3163        // (recGbl.c:439-446, from e.g. aiRecord.c:101), then `wdogInit`
3164        // (histogramRecord.c:168). C reaches both through `iterateRecords`
3165        // (`iocInit.c:562-586`), which visits every record in the database
3166        // whatever created it; here they sat on the loader callers instead, so
3167        // an inline or `dbCreateRecord` record got neither. Both are no-ops for
3168        // a record type that declares no SIMM / no SDEL. Running them outside
3169        // the gate is also what C does: `iterateRecords` is a separate pass
3170        // over an already-built database, holding no registration lock.
3171        self.rec_gbl_init_simm(&rec_arc);
3172        self.arm_watchdog(name);
3173        Ok(())
3174    }
3175
3176    /// Is the database in its LOAD phase, where a new record is published but
3177    /// its device-support binding and `init_record` passes are deferred to
3178    /// [`Self::ioc_init`]? See [`Self::add_loaded_record`] and
3179    /// [`Self::init_deferred_record`].
3180    fn is_load_deferring(&self) -> bool {
3181        matches!(
3182            *self.inner.init_phase.lock().unwrap(),
3183            DbInitPhase::Loading(_)
3184        )
3185    }
3186
3187    /// Run every record's OWED init — C's `initDatabase` per-record pass — for
3188    /// the records the LOAD phase deferred. The list is drained (`mem::take`),
3189    /// so a second call is a no-op: the build lifecycle calls this BEFORE
3190    /// `setup_io_intr`, and [`Self::ioc_init`] calls it again as a catch-all.
3191    /// Load order is preserved because the list was pushed in load order.
3192    pub(crate) fn drain_deferred_record_inits(&self) {
3193        let owed = std::mem::take(&mut *self.inner.deferred_record_inits.lock().unwrap());
3194        for name in owed {
3195            self.init_deferred_record(&name);
3196        }
3197    }
3198
3199    /// Is `name` still awaiting its deferred init — created during the LOAD
3200    /// phase and not yet drained by [`Self::drain_deferred_record_inits`]? The
3201    /// merge arm of the iocsh loader uses this to tell a record it must init in
3202    /// place (created before the load, already live) from one the barrier will
3203    /// init against the final merged fields.
3204    pub(crate) fn record_init_deferred(&self, name: &str) -> bool {
3205        self.inner
3206            .deferred_record_inits
3207            .lock()
3208            .unwrap()
3209            .iter()
3210            .any(|n| n == name)
3211    }
3212
3213    /// Run the OWED init half of [`Self::add_loaded_record`] for a record whose
3214    /// creation was deferred to `iocInit` (the LOAD-phase arm). The record is
3215    /// already published, its `.db` load applied and its load-order sequence
3216    /// assigned; what runs here is C's `doInitRecord0`/`init_record` half —
3217    /// bind the dset, arm subroutines, run the passes, seed constant links —
3218    /// then the scan-index insert, the `recGblInitSimm`/`wdogInit` tail, and
3219    /// the checkLinks / constant-INP seed the two loaders used to run right
3220    /// after `add_loaded_record`. All in the same order the eager path ran them
3221    /// across `add_loaded_record` and its caller.
3222    ///
3223    /// No registration gate is taken: the barrier drains this list from a
3224    /// single synchronous loop with no `.await`, so no other creation
3225    /// interleaves, and the gate's only job is to serialize concurrent
3226    /// creation. Holding the record's write lock across the passes is safe —
3227    /// no `init_record` path re-enters the records map for its own record
3228    /// (the map-reading async-handle calls live only in processing/reentry).
3229    /// `recGblInitSimm` reaches `update_scan_index`, which takes the gate
3230    /// itself, so it runs after the write lock is dropped, exactly as the eager
3231    /// arm runs it after its `drop(gate)`.
3232    fn init_deferred_record(&self, name: &str) {
3233        let Some(rec_arc) = self.get_record(name) else {
3234            return;
3235        };
3236        let (scan, phas, record_type) = {
3237            let mut guard = rec_arc.write();
3238            let instance = &mut *guard;
3239            crate::server::ioc_app::attach_device_support(
3240                instance,
3241                name,
3242                self.inner.device_support_resolver.load().as_deref(),
3243            );
3244            instance.arm_init_subroutines(self.inner.subroutine_registry.load_full());
3245            instance.run_init_passes(name);
3246            super::database::processing::seed_constant_links(instance);
3247            (
3248                instance.common.scan,
3249                instance.common.phas,
3250                instance.record.record_type(),
3251            )
3252        };
3253        let seq = self
3254            .inner
3255            .load_order
3256            .load()
3257            .get(name)
3258            .copied()
3259            .unwrap_or(u64::MAX);
3260        self.add_to_scan_list(scan, phas, record_type, seq, name);
3261        self.rec_gbl_init_simm(&rec_arc);
3262        self.arm_watchdog(name);
3263        // The tail both loaders ran right after `add_loaded_record`: C's
3264        // `init_record` checkLinks (`init_links`), then the constant-INP seed /
3265        // `dbLoadLinkArray` (`rec_gbl_init_constant_links`). For a deferred
3266        // record they belong here, after the passes, in the eager path's order —
3267        // the loaders no longer run them for a record the barrier owns.
3268        {
3269            let mut guard = rec_arc.write();
3270            let inst = &mut *guard;
3271            inst.record.init_links(&inst.common);
3272        }
3273        self.rec_gbl_init_constant_links(&rec_arc);
3274    }
3275
3276    /// Verify that `name` is not currently registered in any of the
3277    /// three namespaces. Caller MUST hold `registration_mutex` so the
3278    /// peek-then-insert sequence is atomic — without that, two tasks
3279    /// can both see the name as free and race the insert.
3280    ///
3281    /// Synchronous: all three namespaces are blocking locks now, so this peek
3282    /// makes no suspension point inside the `registration_mutex` hold — which
3283    /// is what lets that gate become a `PriorityInheritanceMutex` whose `!Send`
3284    /// guard may not cross an `.await`.
3285    fn check_name_free(&self, name: &str) -> CaResult<()> {
3286        let kind = if self.inner.simple_pvs.lock().contains_key(name) {
3287            Some("simple PV")
3288        } else if self.inner.records.read().contains_key(name) {
3289            Some("record")
3290        } else if self.inner.aliases.read().contains_key(name) {
3291            Some("alias")
3292        } else {
3293            None
3294        };
3295        if let Some(kind) = kind {
3296            return Err(CaError::DbParseError {
3297                line: 0,
3298                token: String::new(),
3299                message: format!("name '{name}' is already registered as a {kind}"),
3300            });
3301        }
3302        Ok(())
3303    }
3304
3305    /// Remove a record by name. Returns `true` if a record was removed,
3306    /// `false` if no such name was registered. Mirrors epics-base PR
3307    /// #505 — deletion at database creation, exposed here as a public
3308    /// API so iocsh `dbDeleteRecord` and tests can drive it.
3309    ///
3310    /// The cleanup covers the three indices that `add_record` populates:
3311    /// the records map, the scan index, and CP-link source/target lists.
3312    /// Live subscribers on the removed record drop their `Sender` clone
3313    /// when the `RecordInstance` is dropped — they observe `Closed` on
3314    /// next recv, matching the existing dbEvent cancel flow.
3315    pub async fn remove_record(&self, name: &str) -> bool {
3316        // C `dbDeleteRecord` frees the record's `lockRecord`, so the set it
3317        // was in loses a member and may fall apart into several. Declared
3318        // first so it drops last: the relink runs once the record is out of
3319        // the map AND once both gates below are down, since it takes lock
3320        // sets and those sit above L46.
3321        let _relink = self.lock_set_membership_change(name);
3322        let Some(removed) = self.remove_record_entry(name) else {
3323            return false;
3324        };
3325        // With both gates down: every other record's handle to the removed
3326        // one is dropped, one record's lock set at a time. A handle made
3327        // after this sweep would need the map to still answer the name, and
3328        // it stopped answering before the sweep began.
3329        let others: Vec<Arc<RecordCell>> = self.inner.records.read().values().cloned().collect();
3330        for rec in &others {
3331            rec.write().release_link_targets_to(&removed);
3332        }
3333        true
3334    }
3335
3336    /// [`Self::remove_record`] under its gates: the entry out of every map
3337    /// and index, the record destroyed. The removed cell, for the sweep the
3338    /// caller runs once the gates are down.
3339    fn remove_record_entry(&self, name: &str) -> Option<Arc<RecordCell>> {
3340        // The record's data — its SCAN for the index sweep, `destroy()` at
3341        // the end — is behind its lock set, which is taken ABOVE L46 in the
3342        // acquisition order. So the set is taken first, off the handle, and
3343        // the gate under it; the map is then re-read under the gate to prove
3344        // the handle locked is the handle registered, as `update_scan_index`
3345        // does. Reverse declaration order releases the gate first.
3346        let (rec_arc, _record_gate, _gate) = loop {
3347            let rec_arc = self.get_record_no_resolve(name)?;
3348            let record_gate = self.lock_instance(&rec_arc);
3349            let gate = self.lock_registration("remove_record");
3350            let registered = self
3351                .inner
3352                .records
3353                .read()
3354                .get(name)
3355                .is_some_and(|live| Arc::ptr_eq(live, &rec_arc));
3356            if registered {
3357                break (rec_arc, record_gate, gate);
3358            }
3359        };
3360        // 1) Remove from main map; keep scan + phas for scan-index cleanup.
3361        self.inner.records.write().remove(name);
3362        let scan = {
3363            let inst = rec_arc.read();
3364            inst.common.scan
3365        };
3366
3367        // 2) Drop from scan index if it was scheduled.
3368        self.delete_from_scan_list(scan, name);
3369
3370        // 2b) Drop the load-order entry.
3371        self.inner.load_order.update(|m| {
3372            m.remove(name);
3373        });
3374
3375        // 3) Drop from CP-link tables. Removed both as source (channel
3376        // change → trigger targets) and as target (other channels'
3377        // CP lists may still reference this name).
3378        self.inner.cp_links.update(|cp| {
3379            cp.remove(name);
3380            for targets in cp.values_mut() {
3381                targets.retain(|t| t.record != name);
3382            }
3383        });
3384
3385        // 4) Purge aliases that pointed AT the
3386        // removed record. Otherwise `find_pv("ALT")` returns None
3387        // (target gone) but `add_pv("ALT", ...)` still fails with
3388        // "already registered as an alias" — orphan blocks reuse.
3389        let mut aliases = self.inner.aliases.write();
3390        let orphaned: Vec<String> = aliases
3391            .iter()
3392            .filter(|(_, target)| *target == name)
3393            .map(|(alias, _)| alias.clone())
3394            .collect();
3395        aliases.retain(|_alias, target| target != name);
3396        drop(aliases);
3397        // The alias nodes go with the record, and so do their load-order
3398        // sequences: a name with a sequence and no node would keep a
3399        // node-list walk sorting against a node that no longer exists.
3400        if !orphaned.is_empty() {
3401            self.inner.load_order.update(|m| {
3402                for alias in &orphaned {
3403                    m.remove(alias);
3404                }
3405            });
3406        }
3407
3408        // Same rule as `remove_simple_pv`: removal IS destruction. The
3409        // record's own `Arc` outlives the map entry for as long as a CA
3410        // channel holds it, so without the mark a downstream monitor would
3411        // keep serving a record the database no longer has.
3412        rec_arc.write().destroy();
3413        self.signal_destroyed();
3414        Some(rec_arc)
3415    }
3416
3417    /// Internal: synchronous lookup without invoking the search resolver.
3418    async fn find_entry_no_resolve(&self, name: &str) -> Option<PvEntry> {
3419        // a channel name may carry a `.{"arr":...}` filter
3420        // suffix. Strip it before lookup — the suffix is a per-channel
3421        // filter spec, not part of the PV identity. `split_channel_name`
3422        // is the single owner of "channel name → record_path" and is
3423        // idempotent on an already-stripped name. Without this a
3424        // filtered SimplePv (`SP.{"arr":...}`) never matches
3425        // `simple_pvs` (keyed by the bare PV name) and even a filtered
3426        // record fails when the JSON contains a `.` (e.g.
3427        // `{"dbnd":{"d":0.5}}`), because the bare `parse_pv_name` last-dot
3428        // split would tear the suffix apart instead of removing it.
3429        let record_path = filters::split_channel_name(name).record_path;
3430        let (base, _field) = parse_pv_name(&record_path);
3431
3432        let simple = self
3433            .inner
3434            .simple_pvs
3435            .lock()
3436            .get(record_path.as_str())
3437            .cloned();
3438        if let Some(pv) = simple {
3439            return Some(PvEntry::Simple(pv));
3440        }
3441        if let Some(rec) = self.inner.records.read().get(base) {
3442            return Some(PvEntry::Record(rec.clone()));
3443        }
3444        // Alias resolve (epics-base PR #336): the alternate name maps
3445        // to a canonical record name. Look up the real record after
3446        // translating the base.
3447        if let Some(target) = self.inner.aliases.read().get(base).cloned() {
3448            if let Some(rec) = self.inner.records.read().get(target.as_str()) {
3449                return Some(PvEntry::Record(rec.clone()));
3450            }
3451        }
3452        None
3453    }
3454
3455    /// Register an alias `alias` for an existing record `target`.
3456    /// Mirrors epics-base PR #336. Returns `Err(...)` when the target
3457    /// does not exist or the alias name is already in use anywhere
3458    /// in the database (records, simple PVs, or other aliases).
3459    ///
3460    /// Pre-fix the alias path checked only
3461    /// `records` and `aliases` — a simple-PV with the same name as
3462    /// the proposed alias was missed, leaving the database in a
3463    /// state where `find_pv(alias)` could resolve to either the
3464    /// simple PV or the alias-mapped record depending on lookup
3465    /// order. Now we run the same cross-namespace `check_name_free`
3466    /// guard the other add_* paths use.
3467    pub async fn add_alias(&self, alias: &str, target: &str) -> CaResult<()> {
3468        // A link naming `alias` resolved to nothing until now, so the alias
3469        // can turn a dangling link into a real edge and merge two sets.
3470        // Declared above the gate so the relink, which takes lock sets, runs
3471        // after L46 is released.
3472        let _relink = self.lock_set_membership_change(target);
3473        let _gate = self.lock_registration("add_alias");
3474        if !self.inner.records.read().contains_key(target) {
3475            return Err(CaError::ChannelNotFound(format!(
3476                "alias target '{target}' is not a registered record"
3477            )));
3478        }
3479        self.check_name_free(alias)?;
3480        self.inner
3481            .aliases
3482            .write()
3483            .insert(alias.to_string(), target.to_string());
3484        // An alias is a node of the database, so it takes a sequence from the
3485        // same counter the records draw from — C numbers it identically,
3486        // `pnewnode->order = pdbentry->pdbbase->no_records++`
3487        // (`dbStaticLib.c:1704`), which is what puts an alias at its own load
3488        // position in the list `dbl` and `dbglob` walk.
3489        let seq = self
3490            .inner
3491            .load_order_counter
3492            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3493        self.inner.load_order.update(|m| {
3494            m.insert(alias.to_string(), seq);
3495        });
3496        Ok(())
3497    }
3498
3499    /// The current breakpoint-table registry snapshot. C reaches the same
3500    /// list as `pdbbase->bptList`, which `dbDumpBreaktable`
3501    /// (`dbStaticLib.c:3533-3555`) enumerates; the port keeps it here.
3502    ///
3503    /// Distinct from `add_breaktables(vec![])`, which reads the same cell but
3504    /// takes the registration gate to do it — a reader has nothing to
3505    /// serialise against, since the cell is replaced wholesale.
3506    pub fn breaktable_registry(&self) -> Arc<crate::server::cvt_bpt::BreakTableRegistry> {
3507        self.inner.breaktable_registry.load_full()
3508    }
3509
3510    /// Resolve an alias to its target record name, or `None` when the
3511    /// name is not an alias.
3512    pub fn resolve_alias(&self, name: &str) -> Option<String> {
3513        self.inner.aliases.read().get(name).cloned()
3514    }
3515
3516    /// Queue an iocsh command line for post-PINI execution.
3517    /// Mirrors epics-base PR #558 — `afterIocRunning <command>` lets
3518    /// the startup script schedule actions that run after iocInit
3519    /// completes (when the record set is fully wired up).
3520    pub fn queue_after_ioc_running(&self, line: impl Into<String>) {
3521        self.inner
3522            .after_ioc_running
3523            .lock()
3524            .unwrap()
3525            .push(line.into());
3526    }
3527
3528    /// Drain the post-PINI iocsh command queue. Called by
3529    /// `IocApplication::run` after PINI processing.
3530    pub fn take_after_ioc_running(&self) -> Vec<String> {
3531        std::mem::take(&mut *self.inner.after_ioc_running.lock().unwrap())
3532    }
3533
3534    /// Internal: synchronous existence check without resolver.
3535    fn has_name_no_resolve(&self, name: &str) -> bool {
3536        // strip the channel-filter suffix before lookup so a
3537        // filtered channel (`SP.{"arr":...}` / `REC.{"dbnd":{"d":0.5}}`)
3538        // resolves to its underlying PV at UDP-search time. This is the
3539        // search-side twin of `find_entry_no_resolve`; without it a
3540        // filtered SimplePv never answers a SEARCH and the client never
3541        // reaches CREATE_CHAN. See that function for the full rationale.
3542        let record_path = filters::split_channel_name(name).record_path;
3543        if self
3544            .inner
3545            .simple_pvs
3546            .lock()
3547            .contains_key(record_path.as_str())
3548        {
3549            return true;
3550        }
3551        // C's search-side test is `dbChannelTest` (`dbChannel.c:441-464`),
3552        // which resolves the FIELD too — `REC.NOSUCH` answers "does not
3553        // exist" rather than drawing the client into a CREATE_CHAN it must
3554        // then refuse (pvxs#193). Validate an explicit suffix; a bare name
3555        // binds `VAL`, which every record type declares, so the record's
3556        // existence alone answers it — that also keeps this function
3557        // lock-free per record for the DB-link locality callers, which
3558        // pass suffix-less record names.
3559        let (base, explicit_field) = match record_path.rsplit_once('.') {
3560            Some((base, field)) => (base, Some(field)),
3561            None => (record_path.as_str(), None),
3562        };
3563        let rec = self.inner.records.read().get(base).cloned().or_else(|| {
3564            // Alias entry exists and points to a live record
3565            // (epics-base PR #336).
3566            let target = self.inner.aliases.read().get(base).cloned();
3567            target.and_then(|t| self.inner.records.read().get(t.as_str()).cloned())
3568        });
3569        let Some(rec) = rec else {
3570            return false;
3571        };
3572        let Some(field) = explicit_field else {
3573            return true;
3574        };
3575        let instance = rec.read();
3576        // Trailing `$` is the long-string modifier, part of the channel
3577        // syntax (`dbChannel.c:486-505`): eligible only on a `DBF_STRING`
3578        // or link field, and `dbChannelTest` refuses it anywhere else.
3579        match field.strip_suffix('$') {
3580            Some(core) => instance
3581                .resolve_string_view_field(&core.to_ascii_uppercase())
3582                .is_some(),
3583            None => {
3584                // Existence is the DECLARED-name question, not the
3585                // has-a-value question: `dbNameToAddr` resolves any field
3586                // the `.dbd` declares — including `DBF_NOACCESS` ones like
3587                // `MLOK` — so pvxs answers the SEARCH for them and refuses
3588                // at CREATE instead (measured against `softIocPVX`:
3589                // `pvxget ORACLE:AI.MLOK` → `Refused to create Channel`).
3590                // Three name sources, matching the port's field model:
3591                // `resolve_field` (valued fields, incl. common/virtual ones
3592                // like `RTYP`/`TIME` that C answers from dbStaticLib),
3593                // `field_desc` (declared-but-valueless record fields), and
3594                // the `DBF_NOACCESS` internals the generated tables drop —
3595                // record-own (`BPTR`, from `record_noaccess_fields`) and
3596                // `dbCommon` (`MLOK`) alike.
3597                let upper = field.to_ascii_uppercase();
3598                instance.resolve_field(&upper).is_some()
3599                    || instance.field_desc(&upper).is_some()
3600                    || instance.resolves_noaccess_name(&upper)
3601                    // ... and the record type's attributes, which
3602                    // `pvNameLookup` reaches through `dbGetAttributePart`
3603                    // once the declared list has missed
3604                    // (`dbChannel.c:326-327`). Shadowing needs no test
3605                    // here: a declared name has already answered above.
3606                    || self
3607                        .record_type_attribute(instance.record.record_type(), &upper)
3608                        .is_some()
3609            }
3610        }
3611    }
3612
3613    /// Look up an entry by name. Supports "record.FIELD" syntax.
3614    ///
3615    /// If the name is not found and a search resolver is installed,
3616    /// the resolver is invoked once. If the resolver returns true, the
3617    /// database is re-checked.
3618    pub async fn find_entry(&self, name: &str) -> Option<PvEntry> {
3619        self.find_entry_from(name, None).await
3620    }
3621
3622    /// Like [`Self::find_entry`], but threads the downstream client's
3623    /// socket address into the search resolver. The CA TCP CREATE_CHANNEL
3624    /// handler passes the connection peer so the gateway can apply
3625    /// host-scoped `.pvlist` admission.
3626    pub async fn find_entry_from(
3627        &self,
3628        name: &str,
3629        peer: Option<std::net::SocketAddr>,
3630    ) -> Option<PvEntry> {
3631        if let Some(entry) = self.find_entry_no_resolve(name).await {
3632            // A cached simple PV must still pass the per-request
3633            // existence gate (CA gateway host/state admission). When the
3634            // gate denies it, answer does-not-exist for this requester
3635            // instead of returning the stale shadow entry — C ca-gateway
3636            // re-runs `gateAs::findEntry`/cache-state on every
3637            // `pvExistTest` (gateServer.cc:1516-1637). Records/aliases
3638            // bypass the gate (see `simple_pv_gate_denies`).
3639            if matches!(entry, PvEntry::Simple(_)) && self.simple_pv_gate_denies(name, peer).await {
3640                return None;
3641            }
3642            return Some(entry);
3643        }
3644        // Try the search resolver
3645        let resolver = self.inner.search_resolver.load_full().map(|r| (*r).clone());
3646        if let Some(r) = resolver {
3647            if r(name.to_string(), peer).await {
3648                return self.find_entry_no_resolve(name).await;
3649            }
3650        }
3651        None
3652    }
3653
3654    /// Check if a base name exists (for UDP search).
3655    ///
3656    /// If the name is not in the database and a search resolver is installed,
3657    /// the resolver is invoked. The resolver may populate the database
3658    /// (e.g., subscribe to an upstream IOC and add a placeholder PV) and
3659    /// return true; this method then re-checks.
3660    pub async fn has_name(&self, name: &str) -> bool {
3661        self.has_name_from(name, None).await
3662    }
3663
3664    /// Like [`Self::has_name`], but threads the downstream client's
3665    /// socket address into the search resolver. The CA UDP search
3666    /// responder passes the datagram source address so the gateway can
3667    /// apply host-scoped `.pvlist` admission.
3668    pub async fn has_name_from(&self, name: &str, peer: Option<std::net::SocketAddr>) -> bool {
3669        if self.has_name_no_resolve(name) {
3670            // Same per-request gate as `find_entry_from`: a cached simple
3671            // PV the gateway's host/state admission denies must answer
3672            // does-not-exist at search time. Records/aliases bypass.
3673            if self.simple_pv_gate_denies(name, peer).await {
3674                return false;
3675            }
3676            return true;
3677        }
3678        let resolver = self.inner.search_resolver.load_full().map(|r| (*r).clone());
3679        if let Some(r) = resolver {
3680            if r(name.to_string(), peer).await {
3681                return self.has_name_no_resolve(name);
3682            }
3683        }
3684        false
3685    }
3686
3687    /// Look up a simple PV by name (backward-compatible).
3688    pub async fn find_pv(&self, name: &str) -> Option<Arc<ProcessVariable>> {
3689        self.inner.simple_pvs.lock().get(name).cloned()
3690    }
3691
3692    /// Get a record Arc by name. Alias-aware (epics-base PR #336):
3693    /// when `name` is not a canonical record but matches a registered
3694    /// alias, the alias' target record is returned. Mirrors base
3695    /// `dbNameToAddr` behaviour, so dbpf/dbpr/dbgf, CA channel lookup,
3696    /// and DB-link target resolution all work transparently for
3697    /// aliases.
3698    ///
3699    /// Use [`Self::get_record_no_resolve`] when the caller already
3700    /// holds a canonical name and wants to suppress the alias path
3701    /// (e.g. to detect alias collisions during builder wiring).
3702    pub fn get_record(&self, name: &str) -> Option<Arc<RecordCell>> {
3703        if let Some(rec) = self.inner.records.read().get(name).cloned() {
3704            return Some(rec);
3705        }
3706        let target = self.inner.aliases.read().get(name).cloned()?;
3707        self.inner.records.read().get(target.as_str()).cloned()
3708    }
3709
3710    /// The record behind `name` AND the canonical name it is registered
3711    /// under — the one place a caller's `&str` becomes the shared `Arc<str>`
3712    /// that a process chain then carries from hop to hop.
3713    ///
3714    /// The records map is asked first and the alias table only on a miss. A
3715    /// canonical name is the overwhelming common case, and the two namespaces
3716    /// are disjoint — `add_loaded_record` refuses a name an alias already
3717    /// holds — so asking the alias table first would hash, on every scan step
3718    /// of every record, a name that is never in it.
3719    ///
3720    /// The `Arc` handed back is the map's own KEY, so nothing downstream
3721    /// allocates the name again: the cycle guard, the scan key and the
3722    /// lock-set lookup all share this one allocation, made when the record was
3723    /// registered. Alias-aware for the same reason
3724    /// [`Self::get_record`] is (epics-base PR #336) — and the name it returns
3725    /// for an alias is the TARGET's, which is what `visited` must key on if
3726    /// alias and canonical are not to count as two different records.
3727    pub(crate) fn lookup_record(&self, name: &str) -> Option<(Arc<str>, Arc<RecordCell>)> {
3728        {
3729            let records = self.inner.records.read();
3730            if let Some((canonical, rec)) = records.get_key_value(name) {
3731                return Some((canonical.clone(), rec.clone()));
3732            }
3733        }
3734        let target = self.inner.aliases.read().get(name).cloned()?;
3735        let records = self.inner.records.read();
3736        let (canonical, rec) = records.get_key_value(target.as_str())?;
3737        Some((canonical.clone(), rec.clone()))
3738    }
3739
3740    /// Strict variant of [`Self::get_record`] — does NOT consult the
3741    /// alias table. Returns `Some` only when a canonical record with
3742    /// that exact name exists.
3743    pub fn get_record_no_resolve(&self, name: &str) -> Option<Arc<RecordCell>> {
3744        self.inner.records.read().get(name).cloned()
3745    }
3746
3747    /// Every record name, in **database load order** — the single owner of
3748    /// whole-database iteration order.
3749    ///
3750    /// C parity: `dbFirstRecord`/`dbNextRecord` walk the record list of each
3751    /// record type in the order `dbReadDatabase` appended them, so every
3752    /// whole-database pass a C IOC makes — `initDevSup`, `initDatabase`,
3753    /// `initialProcess` (PINI), `dbl`/`dbgrep` dumps — visits records in load
3754    /// order. Device support is written against that contract: a dynamic device
3755    /// support whose record references another record (epics-modules/opcua's
3756    /// element records require their `opcuaItem` record to have bound first,
3757    /// linkParser.cpp:226-234) only boots if the referenced record was wired
3758    /// first, which the `.db` guarantees by declaring it first.
3759    ///
3760    /// The names live in a `HashMap`, so returning `keys()` made that order the
3761    /// hash order: neither load order nor even stable across runs of the same
3762    /// binary (`RandomState` reseeds per process). Booting the same database
3763    /// twice could wire records in two different orders — one boot succeeding
3764    /// and the next failing. Ordering here, at the one accessor every
3765    /// whole-database walk already goes through, makes every such pass
3766    /// deterministic and load-ordered at once.
3767    ///
3768    /// The order key is the existing per-record `load_order` sequence (the
3769    /// scan-index's secondary sort key), so this ordering and the scan lists'
3770    /// ordering are the same fact, not two. A record with no sequence — none
3771    /// exists; `add_record` is the only insertion path — would sort last by
3772    /// name rather than nondeterministically.
3773    pub async fn all_record_names(&self) -> Vec<String> {
3774        // Lock order records → load_order (matching `add_record`/`remove_record`):
3775        // the `records` map is a sync `parking_lot::RwLock` now, so its guard is
3776        // `!Send` and MUST NOT be held across the async `load_order` read. Snapshot
3777        // the keys under the records guard, release it (block close), then await
3778        // load_order — neither lock is ever held while waiting on the other, so the
3779        // records→load_order order is honoured without an AB-BA against add_record.
3780        // The two reads are no longer one atomic snapshot: a record inserted between
3781        // them is absent from `load_order` and sorts last by name via the
3782        // `unwrap_or(u64::MAX)` fallback — the same degradation already defined for a
3783        // sequence-less record, and every whole-database walk is racy against a
3784        // concurrent add/remove regardless.
3785        let mut names: Vec<String> = {
3786            let records = self.inner.records.read();
3787            records.keys().map(|n| n.to_string()).collect()
3788        };
3789        let load_order = self.inner.load_order.load();
3790        names.sort_by(|a, b| {
3791            let seq = |n: &String| load_order.get(n.as_str()).copied().unwrap_or(u64::MAX);
3792            seq(a).cmp(&seq(b)).then_with(|| a.cmp(b))
3793        });
3794        names
3795    }
3796
3797    /// Every node C's `dbFirstRecord` / `dbNextRecord` walk visits — the
3798    /// records AND the alias nodes — in database load order.
3799    ///
3800    /// See [`DbNode`] for why the alias nodes belong in this list. The order
3801    /// is C's for the same reason the record order is: both kinds of node draw
3802    /// their sequence from one counter (`pdbbase->no_records++`,
3803    /// `dbStaticLib.c:1704`), so an alias sits where it was declared rather
3804    /// than after every record.
3805    ///
3806    /// The caller groups by record type; an alias groups with its target,
3807    /// because C's alias node lives in the target's own type list.
3808    pub async fn all_db_nodes(&self) -> Vec<DbNode> {
3809        // Same lock discipline as `all_record_names`: snapshot each map under
3810        // its own guard and let the guard die with the statement, so no two
3811        // are ever held at once and none is live across an await.
3812        let mut nodes: Vec<DbNode> = {
3813            let records = self.inner.records.read();
3814            records
3815                .keys()
3816                .map(|name| DbNode {
3817                    name: name.to_string(),
3818                    alias_of: None,
3819                })
3820                .collect()
3821        };
3822        nodes.extend({
3823            let aliases = self.inner.aliases.read();
3824            aliases
3825                .iter()
3826                .map(|(alias, target)| DbNode {
3827                    name: alias.clone(),
3828                    alias_of: Some(target.clone()),
3829                })
3830                .collect::<Vec<_>>()
3831        });
3832        let load_order = self.inner.load_order.load();
3833        nodes.sort_by(|a, b| {
3834            let seq = |n: &DbNode| load_order.get(&n.name).copied().unwrap_or(u64::MAX);
3835            seq(a).cmp(&seq(b)).then_with(|| a.name.cmp(&b.name))
3836        });
3837        nodes
3838    }
3839
3840    /// Get all alias names registered against existing records.
3841    /// Mirrors the alias-half of base's `dbFirstRecord` iteration —
3842    /// `dbgrep` / `dbglob` / `dbsr` walk both record names and
3843    /// aliases when matching a glob.
3844    pub fn all_alias_names(&self) -> Vec<String> {
3845        self.inner.aliases.read().keys().cloned().collect()
3846    }
3847
3848    /// Return every alias that points at `canonical`. Sorted for
3849    /// stable output; empty when the record has no aliases. Used by
3850    /// `dbpr` to surface alias-form names so admins can see how
3851    /// clients may reach the record.
3852    pub fn aliases_for_record(&self, canonical: &str) -> Vec<String> {
3853        let aliases = self.inner.aliases.read();
3854        let mut hits: Vec<String> = aliases
3855            .iter()
3856            .filter_map(|(alias, target)| {
3857                if target == canonical {
3858                    Some(alias.clone())
3859                } else {
3860                    None
3861                }
3862            })
3863            .collect();
3864        hits.sort();
3865        hits
3866    }
3867
3868    /// Get all simple PV names.
3869    pub async fn all_simple_pv_names(&self) -> Vec<String> {
3870        self.inner.simple_pvs.lock().keys().cloned().collect()
3871    }
3872}
3873
3874// RTEMS-EXEC-MODEL-ALLOW(1):
3875// `the_snapshot_door_refuses_an_ineligible_dollar_view` is a `#[tokio::test]`,
3876// so the attribute builds the current-thread runtime it needs rather than
3877// borrowing an ambient one, and its body only takes database locks — it never
3878// reaches the `runtime::task` seam the exec backend replaces. Run under
3879// `EPICS_RS_BUILD_EXEC_BACKEND=thread`: it passes, so gating it out would drop
3880// live coverage of the `$`-view door.
3881#[cfg(test)]
3882mod channel_view_door_tests {
3883    use super::PvDatabase;
3884    use crate::server::records::ai::AiRecord;
3885
3886    /// The database's snapshot door takes the channel's `$` view, and an
3887    /// ineligible one answers `None` rather than the unviewed snapshot.
3888    ///
3889    /// C decides this in `dbChannelCreate`: `$` re-views a `DBF_STRING`
3890    /// (`dbChannel.c:488-493`) or a `DBF_INLINK..DBF_FWDLINK` link
3891    /// (`:494-498`), and anything else is `S_dbLib_fieldNotFound`
3892    /// (`:499-501`). There is deliberately no door here that takes a field
3893    /// name alone: `REC.VAL` resolves for any type, so a caller holding the
3894    /// field but not the view cannot tell the two cases apart and would
3895    /// serve `VAL$` on a `DBF_DOUBLE` as a double.
3896    #[tokio::test]
3897    async fn the_snapshot_door_refuses_an_ineligible_dollar_view() {
3898        let db = PvDatabase::new();
3899        db.add_record("VD:ai", Box::new(AiRecord::new(1.5)))
3900            .await
3901            .unwrap();
3902        let rec = db.get_record("VD:ai").expect("record");
3903
3904        assert!(
3905            db.channel_snapshot_for_field(&rec, "VAL", false).is_some(),
3906            "the unviewed VAL is an ordinary double snapshot"
3907        );
3908        assert!(
3909            db.channel_snapshot_for_field(&rec, "VAL", true).is_none(),
3910            "`VAL$` on a DBF_DOUBLE is S_dbLib_fieldNotFound, not a double"
3911        );
3912
3913        // Both eligible branches still answer, and answer the string the
3914        // view collapses to (pvxs `iocsource.cpp:133-136`).
3915        for eligible in ["DESC", "NAME", "EGU", "FLNK"] {
3916            let snap = db
3917                .channel_snapshot_for_field(&rec, eligible, true)
3918                .unwrap_or_else(|| panic!("`{eligible}$` must be eligible"));
3919            assert!(
3920                matches!(snap.value, crate::types::EpicsValue::String(_)),
3921                "`{eligible}$` serves the string, got {:?}",
3922                snap.value
3923            );
3924        }
3925    }
3926}
3927
3928#[cfg(test)]
3929mod tests {
3930    use super::*;
3931
3932    /// C `recGblGetTimeStampSimm` (recGbl.c:310-343) maps TSE values
3933    /// to epicsTime sources via the constants in `epicsTime.h:102-104`.
3934    /// The Rust port previously misread TSE=-1 as "device-provided
3935    /// with BestTime fallback" and gated the BestTime call on a
3936    /// UNIX_EPOCH check. C calls `epicsTimeGetEvent(-1)`
3937    /// unconditionally; only TSE=-2 (epicsTimeEventDeviceTime) leaves
3938    /// `precord->time` untouched.
3939    ///
3940    /// Regression: a stale device write (any non-epoch SystemTime)
3941    /// suppressed every BestTime refresh thereafter.
3942    #[test]
3943    fn apply_timestamp_tse_minus_one_always_overwrites_with_best_time() {
3944        use crate::server::record::CommonFields;
3945        use std::time::{Duration, SystemTime};
3946
3947        // Pre-populate `time` with a stale but non-epoch sentinel.
3948        let stale = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
3949        let mut common = CommonFields::default();
3950        common.tse = -1;
3951        common.time = stale;
3952
3953        TselStamp::None.stamp("REC", &mut common, false);
3954
3955        // BestTime must have run unconditionally — `common.time` is
3956        // no longer the stale sentinel.
3957        assert_ne!(
3958            common.time, stale,
3959            "TSE=-1 must always overwrite via generalTime BestTime, \
3960             matching C epicsTimeGetEvent(-1) called unconditionally"
3961        );
3962    }
3963
3964    /// C `epicsTimeEventDeviceTime = -2` (epicsTime.h:104). The C
3965    /// path does NOT call `epicsTimeGetEvent` for this TSE value;
3966    /// device support has already set `precord->time` before the
3967    /// recGbl call. The Rust port must leave `common.time` untouched.
3968    #[test]
3969    fn apply_timestamp_tse_minus_two_preserves_device_provided_time() {
3970        use crate::server::record::CommonFields;
3971        use std::time::{Duration, SystemTime};
3972
3973        let device_time = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000);
3974        let mut common = CommonFields::default();
3975        common.tse = -2;
3976        common.time = device_time;
3977
3978        TselStamp::None.stamp("REC", &mut common, false);
3979
3980        assert_eq!(
3981            common.time, device_time,
3982            "TSE=-2 (epicsTimeEventDeviceTime) must preserve device-provided time"
3983        );
3984    }
3985
3986    /// C `generalTimeGetEventPriority` rejects every event below
3987    /// `epicsTimeEventBestTime` with `S_time_badEvent`
3988    /// (`epicsGeneralTime.c:254-255`), and `recGblGetTimeStampSimm`
3989    /// (`recGbl.c:325-327`) writes nothing into `prec->time` on that status —
3990    /// it errlogs and the record keeps the stamp it had. `TSE` is
3991    /// `epicsInt16`, so `caput X.TSE -3` reaches this path.
3992    #[test]
3993    fn apply_timestamp_below_best_time_keeps_the_stale_stamp_and_errlogs() {
3994        use crate::server::record::CommonFields;
3995        use std::io::Write;
3996        use std::sync::{Arc, Mutex};
3997        use std::time::{Duration, SystemTime};
3998        use tracing_subscriber::fmt::MakeWriter;
3999
4000        #[derive(Clone, Default)]
4001        struct CaptureBuf(Arc<Mutex<Vec<u8>>>);
4002        impl Write for CaptureBuf {
4003            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4004                self.0.lock().unwrap().extend_from_slice(buf);
4005                Ok(buf.len())
4006            }
4007            fn flush(&mut self) -> std::io::Result<()> {
4008                Ok(())
4009            }
4010        }
4011        impl<'a> MakeWriter<'a> for CaptureBuf {
4012            type Writer = CaptureBuf;
4013            fn make_writer(&'a self) -> Self::Writer {
4014                self.clone()
4015            }
4016        }
4017
4018        let buf = CaptureBuf::default();
4019        let subscriber = tracing_subscriber::fmt()
4020            .with_writer(buf.clone())
4021            .with_max_level(tracing::Level::INFO)
4022            .with_target(false)
4023            .without_time()
4024            .finish();
4025        let _guard = tracing::subscriber::set_default(subscriber);
4026
4027        let stale = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
4028        let mut common = CommonFields::default();
4029        common.tse = -3;
4030        common.time = stale;
4031
4032        TselStamp::None.stamp("X", &mut common, false);
4033
4034        assert_eq!(
4035            common.time, stale,
4036            "TSE below epicsTimeEventBestTime must leave TIME alone, not stamp now"
4037        );
4038        let logged = String::from_utf8_lossy(&buf.0.lock().unwrap()).into_owned();
4039        assert!(
4040            logged.contains("recGblGetTimeStampSimm: epicsTimeGetEvent failed, X.TSE = -3"),
4041            "C errlogs the failed event lookup; captured: {logged:?}"
4042        );
4043    }
4044
4045    #[test]
4046    fn select_link_indices_fanout_all_specified_mask() {
4047        use crate::server::record::AlarmSeverity;
4048        // All — every slot.
4049        let r = select_link_indices_ex(SelmKind::FanoutSeq, 0, 0, 0, 0, 16);
4050        assert_eq!(r.indices, (0..16).collect::<Vec<_>>());
4051        assert!(r.alarm.is_none());
4052
4053        // Specified, 0-based: SELN=0 selects LNK0 (C parity, fanout).
4054        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, 0, 0, 0, 16);
4055        assert_eq!(r.indices, vec![0]);
4056        // Specified with OFFS bias: SELN=2 + OFFS=3 → index 5.
4057        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, 2, 3, 0, 16);
4058        assert_eq!(r.indices, vec![5]);
4059        // Out-of-range Specified → INVALID alarm, no links.
4060        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, 20, 0, 0, 16);
4061        assert!(r.indices.is_empty());
4062        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
4063        // Negative resolved index (SELN + negative OFFS) → INVALID.
4064        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, 0, -1, 0, 16);
4065        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
4066
4067        // Mask: SELN=0b101 → bits 0 and 2.
4068        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, 5, 0, 0, 16);
4069        assert_eq!(r.indices, vec![0, 2]);
4070        // Mask with SHFT: SELN=0b101 >> 1 = 0b10 → bit 1.
4071        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, 5, 0, 1, 16);
4072        assert_eq!(r.indices, vec![1]);
4073        // Mask with negative SHFT: SELN=0b101 << 1 = 0b1010 → bits 1,3.
4074        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, 5, 0, -1, 16);
4075        assert_eq!(r.indices, vec![1, 3]);
4076        // SHFT out of [-15,15] → INVALID.
4077        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, 5, 0, 16, 16);
4078        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
4079
4080        // Unknown SELM → INVALID.
4081        let r = select_link_indices_ex(SelmKind::FanoutSeq, 9, 0, 0, 0, 16);
4082        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
4083    }
4084
4085    #[test]
4086    fn select_link_indices_dfanout_specified_is_one_based() {
4087        use crate::server::record::AlarmSeverity;
4088        // dfanout Specified is 1-based: SELN=1 → OUTA (index 0).
4089        let r = select_link_indices_ex(SelmKind::Dfanout, 1, 1, 0, 0, 16);
4090        assert_eq!(r.indices, vec![0]);
4091        // SELN=2 → OUTB (index 1).
4092        let r = select_link_indices_ex(SelmKind::Dfanout, 1, 2, 0, 0, 16);
4093        assert_eq!(r.indices, vec![1]);
4094        // SELN=0 → drive nothing, NO alarm.
4095        let r = select_link_indices_ex(SelmKind::Dfanout, 1, 0, 0, 0, 16);
4096        assert!(r.indices.is_empty());
4097        assert!(r.alarm.is_none());
4098        // SELN > 16 → INVALID.
4099        let r = select_link_indices_ex(SelmKind::Dfanout, 1, 17, 0, 0, 16);
4100        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
4101        // dfanout Mask has no SHFT — SHFT arg ignored.
4102        let r = select_link_indices_ex(SelmKind::Dfanout, 2, 5, 0, 7, 16);
4103        assert_eq!(r.indices, vec![0, 2]);
4104    }
4105
4106    /// `SELN` is unsigned, and the rule that makes it unsigned depends on the
4107    /// SOURCE type — because in C the source type picks the conversion routine.
4108    #[test]
4109    fn seln_cast_follows_the_source_type() {
4110        // Integer source -> `getLongUshort`, `(epicsUInt16)(epicsInt32)v`.
4111        // C DEFINES this (C17 6.3.1.3p2, modulo 2^16), so we reproduce it.
4112        assert_eq!(dbr_ushort_cast(&EpicsValue::Long(-1)), 65535);
4113        assert_eq!(dbr_ushort_cast(&EpicsValue::Long(65536)), 0);
4114        assert_eq!(dbr_ushort_cast(&EpicsValue::Short(-1)), 65535);
4115        assert_eq!(dbr_ushort_cast(&EpicsValue::Int64(-1)), 65535);
4116        assert_eq!(dbr_ushort_cast(&EpicsValue::Long(3)), 3);
4117
4118        // Float source -> `getDoubleUshort`, `(epicsUInt16)d`. C leaves this
4119        // UNDEFINED (C17 6.3.1.4p1), and whatever `types::c_cast` decides to do
4120        // about that is a SEPARATE question from this one — the point here is
4121        // only that the float source takes the float rule and the integer
4122        // source does not.
4123        assert_eq!(
4124            dbr_ushort_cast(&EpicsValue::Double(-1.0)),
4125            crate::types::c_cast::f64_to_u16(-1.0)
4126        );
4127        assert_eq!(
4128            dbr_ushort_cast(&EpicsValue::Double(65536.0)),
4129            crate::types::c_cast::f64_to_u16(65536.0)
4130        );
4131        // In range: no policy in play, both rules truncate toward zero.
4132        assert_eq!(dbr_ushort_cast(&EpicsValue::Double(3.7)), 3);
4133    }
4134
4135    /// Whatever produced it, a `SELN` of 65535 selects nothing under Specified
4136    /// (out of range -> INVALID) and everything under Mask.
4137    #[test]
4138    fn seln_at_the_unsigned_maximum_selects_by_selm() {
4139        use crate::server::record::AlarmSeverity;
4140        let seln_max = 65535u16;
4141        // fanout/seq Specified: C `i = (epicsUInt16)seln + offs` = 65535 →
4142        // out of range → INVALID. A signed read would clamp to 0 and wrongly
4143        // drive link 0.
4144        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, seln_max, 0, 0, 16);
4145        assert!(r.indices.is_empty());
4146        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
4147        // fanout/seq Mask: 65535 → all 16 low bits set → every link. A signed
4148        // read would produce an empty mask.
4149        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, seln_max, 0, 0, 16);
4150        assert_eq!(r.indices, (0..16).collect::<Vec<_>>());
4151        // dfanout Specified: 65535 > count → INVALID. A signed read would see
4152        // -1 ≤ 0 → drive nothing, with no alarm.
4153        let r = select_link_indices_ex(SelmKind::Dfanout, 1, seln_max, 0, 0, 16);
4154        assert!(r.indices.is_empty());
4155        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
4156    }
4157
4158    /// Lset that flips to "connected" after a configurable delay.
4159    /// Drives the wait_for_external_links time-budget tests below.
4160    struct DelayedConnectLset {
4161        names: Vec<String>,
4162        connect_at: crate::runtime::task::Instant,
4163    }
4164
4165    #[async_trait::async_trait]
4166    impl link_set::LinkSet for DelayedConnectLset {
4167        fn is_connected(&self, _: &str) -> bool {
4168            crate::runtime::task::Instant::now() >= self.connect_at
4169        }
4170        fn get_cached_value(&self, _: &str) -> Option<EpicsValue> {
4171            None
4172        }
4173        async fn get_value(&self, name: &str) -> Option<EpicsValue> {
4174            self.get_cached_value(name)
4175        }
4176        fn link_names(&self) -> Vec<String> {
4177            self.names.clone()
4178        }
4179    }
4180
4181    #[epics_macros_rs::epics_test]
4182    async fn wait_for_external_links_returns_zero_zero_when_no_lsets() {
4183        let db = PvDatabase::new();
4184        let (c, t) = db
4185            .wait_for_external_links(std::time::Duration::from_millis(50))
4186            .await;
4187        assert_eq!((c, t), (0, 0));
4188    }
4189
4190    #[epics_macros_rs::epics_test]
4191    async fn wait_for_external_links_connected_quickly() {
4192        let db = PvDatabase::new();
4193        // Local-target forced-CA links (dbChannelTest==0 → isLocal): these
4194        // get DBCA_CALLBACK_INIT_START, so iocInit waits for them.
4195        db.add_pv("pv:A", EpicsValue::Long(0)).await.unwrap();
4196        db.add_pv("pv:B", EpicsValue::Long(0)).await.unwrap();
4197        let lset = Arc::new(DelayedConnectLset {
4198            names: vec!["pv:A".to_string(), "pv:B".to_string()],
4199            connect_at: crate::runtime::task::Instant::now(),
4200        });
4201        // Registered under "ca": the iocInit wait is CA-facility only, so
4202        // the working set comes from the "ca" link set (these forced-CA
4203        // local-target links), never from a "pva" set.
4204        db.register_link_set("ca", lset).await;
4205        let (c, t) = db
4206            .wait_for_external_links(std::time::Duration::from_secs(1))
4207            .await;
4208        assert_eq!((c, t), (2, 2));
4209    }
4210
4211    #[epics_macros_rs::epics_test]
4212    async fn wait_for_external_links_returns_partial_on_timeout() {
4213        let db = PvDatabase::new();
4214        // Local target so the link is in the init-wait set (dbLink.c:130);
4215        // connect-time well past the budget below, so the wait must return
4216        // (0, 1) instead of blocking.
4217        db.add_pv("slow:pv", EpicsValue::Long(0)).await.unwrap();
4218        let lset = Arc::new(DelayedConnectLset {
4219            names: vec!["slow:pv".to_string()],
4220            connect_at: crate::runtime::task::Instant::now() + std::time::Duration::from_secs(60),
4221        });
4222        db.register_link_set("ca", lset).await;
4223        let started = crate::runtime::task::Instant::now();
4224        let (c, t) = db
4225            .wait_for_external_links(std::time::Duration::from_millis(250))
4226            .await;
4227        let elapsed = started.elapsed();
4228        assert_eq!((c, t), (0, 1));
4229        assert!(
4230            elapsed >= std::time::Duration::from_millis(200),
4231            "wait must consume at least the configured budget, got {:?}",
4232            elapsed
4233        );
4234        assert!(
4235            elapsed < std::time::Duration::from_secs(2),
4236            "wait must not exceed the budget by much, got {:?}",
4237            elapsed
4238        );
4239    }
4240
4241    /// C parity (dbLink.c:130): a link whose target is NOT a local record
4242    /// (`dbChannelTest != 0`) gets no DBCA_CALLBACK_INIT_START, so iocInit
4243    /// must not block on it. An areaDetector `test CP MS` placeholder — a CP
4244    /// link to a PV that exists nowhere — must drop straight through, leaving
4245    /// the link to connect (or dangle) asynchronously and silently, like C.
4246    #[epics_macros_rs::epics_test]
4247    async fn wait_for_external_links_skips_nonlocal_targets() {
4248        let db = PvDatabase::new();
4249        // "test" has no local record and would never connect.
4250        let lset = Arc::new(DelayedConnectLset {
4251            names: vec!["test".to_string()],
4252            connect_at: crate::runtime::task::Instant::now() + std::time::Duration::from_secs(60),
4253        });
4254        db.register_link_set("ca", lset).await;
4255        let started = crate::runtime::task::Instant::now();
4256        let (c, t) = db
4257            .wait_for_external_links(std::time::Duration::from_secs(10))
4258            .await;
4259        // Non-local target is excluded from the wait set entirely, so the
4260        // call returns (0, 0) immediately rather than blocking the budget.
4261        assert_eq!((c, t), (0, 0));
4262        assert!(
4263            started.elapsed() < std::time::Duration::from_secs(1),
4264            "non-local link must not be waited on, got {:?}",
4265            started.elapsed()
4266        );
4267        // And it is reported as unconnected by neither path (silent, like C).
4268        assert!(db.unconnected_external_links().await.is_empty());
4269    }
4270
4271    /// Lset that is connected but whose post-connect init actions (the
4272    /// metadata fetch) never complete: `init_ready` stays false.
4273    struct ConnectedMetaPendingLset {
4274        names: Vec<String>,
4275    }
4276
4277    #[async_trait::async_trait]
4278    impl link_set::LinkSet for ConnectedMetaPendingLset {
4279        fn is_connected(&self, _: &str) -> bool {
4280            true
4281        }
4282        fn init_ready(&self, _: &str) -> bool {
4283            false
4284        }
4285        fn get_cached_value(&self, _: &str) -> Option<EpicsValue> {
4286            None
4287        }
4288        async fn get_value(&self, name: &str) -> Option<EpicsValue> {
4289            self.get_cached_value(name)
4290        }
4291        fn link_names(&self) -> Vec<String> {
4292            self.names.clone()
4293        }
4294    }
4295
4296    /// epics-base #856 (ef4829829, "dbCa: iocInit wait for all
4297    /// conditions"): a connected link whose attribute fetch has not
4298    /// completed still holds iocInit — the wait polls `init_ready`
4299    /// (C `testInitReady`'s three-bit gate), not `is_connected` alone,
4300    /// and the timeout diagnostic names the link it proceeded without.
4301    #[epics_macros_rs::epics_test]
4302    async fn wait_for_external_links_holds_until_init_ready() {
4303        let db = PvDatabase::new();
4304        db.add_pv("meta:pending", EpicsValue::Long(0))
4305            .await
4306            .unwrap();
4307        let lset = Arc::new(ConnectedMetaPendingLset {
4308            names: vec!["meta:pending".to_string()],
4309        });
4310        db.register_link_set("ca", lset).await;
4311        let (c, t) = db
4312            .wait_for_external_links(std::time::Duration::from_millis(250))
4313            .await;
4314        assert_eq!((c, t), (0, 1));
4315        assert_eq!(
4316            db.unconnected_external_links().await,
4317            vec!["meta:pending".to_string()]
4318        );
4319    }
4320
4321    // epics-base PR #336 — alias parsing + lookup integration tests.
4322
4323    #[epics_macros_rs::epics_test]
4324    async fn alias_resolves_through_find_entry() {
4325        let db = PvDatabase::new();
4326        db.add_record(
4327            "TARGET",
4328            Box::new(crate::server::records::ai::AiRecord::new(42.0)),
4329        )
4330        .await
4331        .unwrap();
4332        db.add_alias("ALIAS_NAME", "TARGET").await.unwrap();
4333
4334        // find_entry on the alias must return the same record as
4335        // find_entry on the target.
4336        let via_alias = db.find_entry("ALIAS_NAME").await;
4337        let via_target = db.find_entry("TARGET").await;
4338        assert!(via_alias.is_some());
4339        assert!(via_target.is_some());
4340        // has_name flips true for the alias too.
4341        assert!(db.has_name("ALIAS_NAME").await);
4342        assert!(db.has_name("TARGET").await);
4343        assert!(!db.has_name("NOT:THERE").await);
4344    }
4345
4346    /// C answers a SEARCH through `dbChannelTest` (`dbChannel.c:441-464`),
4347    /// which validates the field: `REC.NOSUCH` is "does not exist", not an
4348    /// invitation to a CREATE_CHAN the server must then refuse (pvxs#193).
4349    /// One case per boundary: bare name, real field, missing field, the
4350    /// alias twin of each, and the `$` modifier's eligibility split.
4351    #[epics_macros_rs::epics_test]
4352    async fn search_gate_refuses_a_field_the_record_does_not_have() {
4353        let db = PvDatabase::new();
4354        db.add_record(
4355            "TARGET",
4356            Box::new(crate::server::records::ai::AiRecord::new(42.0)),
4357        )
4358        .await
4359        .unwrap();
4360        db.add_alias("ALIAS_NAME", "TARGET").await.unwrap();
4361
4362        assert!(db.has_name("TARGET.VAL").await);
4363        assert!(db.has_name("TARGET.SEVR").await);
4364        assert!(!db.has_name("TARGET.NOSUCH").await);
4365        // Declared but valueless (`MLOK` is `DBF_NOACCESS`): `dbNameToAddr`
4366        // resolves it, so the search answers and CREATE is where the
4367        // refusal lands — measured `pvxget ORACLE:AI.MLOK` → `Refused to
4368        // create Channel` (see `search_claims_every_dbd_name_but_create_
4369        // gates_on_a_servable_field` in `epics-pva-rs`).
4370        assert!(db.has_name("TARGET.MLOK").await);
4371        // Record-own `DBF_NOACCESS` twin (`waveform.BPTR`): C's resolver
4372        // does not distinguish common from record-own internals
4373        // (`dbFindField` walks the type's full `dbFldDes` set), so the
4374        // same answer-then-refuse applies. The generated tables drop the
4375        // descs but keep the names (`record_noaccess_fields`).
4376        db.add_record(
4377            "WF",
4378            Box::new(crate::server::records::waveform::WaveformRecord::new(
4379                8,
4380                crate::types::DbFieldType::Double,
4381            )),
4382        )
4383        .await
4384        .unwrap();
4385        assert!(db.has_name("WF.BPTR").await);
4386        assert!(!db.has_name("WF.NOSUCH").await);
4387        assert!(db.has_name("ALIAS_NAME.EGU").await);
4388        assert!(!db.has_name("ALIAS_NAME.NOSUCH").await);
4389        // `$` re-views a DBF_STRING/link field as a char array; anything
4390        // else is `S_dbLib_fieldNotFound` (`dbChannel.c:486-505`).
4391        assert!(db.has_name("TARGET.EGU$").await);
4392        assert!(!db.has_name("TARGET.VAL$").await);
4393    }
4394
4395    #[epics_macros_rs::epics_test]
4396    async fn alias_target_must_exist() {
4397        let db = PvDatabase::new();
4398        let err = db.add_alias("DANGLING", "MISSING_TARGET").await;
4399        assert!(err.is_err(), "alias to missing target must be rejected");
4400    }
4401
4402    #[epics_macros_rs::epics_test]
4403    async fn alias_collision_with_existing_record_rejected() {
4404        let db = PvDatabase::new();
4405        db.add_record(
4406            "EXISTING",
4407            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4408        )
4409        .await
4410        .unwrap();
4411        db.add_record(
4412            "OTHER",
4413            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4414        )
4415        .await
4416        .unwrap();
4417        let err = db.add_alias("EXISTING", "OTHER").await;
4418        assert!(
4419            err.is_err(),
4420            "alias name colliding with record must be rejected"
4421        );
4422    }
4423
4424    #[epics_macros_rs::epics_test]
4425    async fn get_record_resolves_alias() {
4426        // Regression: get_record must transparently resolve
4427        // aliases so dbpf / dbgf / dbpr / CA put paths see the same
4428        // record whether the caller uses the canonical name or the
4429        // alias.
4430        let db = PvDatabase::new();
4431        db.add_record(
4432            "TARGET",
4433            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4434        )
4435        .await
4436        .unwrap();
4437        db.add_alias("ALIAS", "TARGET").await.unwrap();
4438
4439        let via_canonical = db.get_record("TARGET");
4440        let via_alias = db.get_record("ALIAS");
4441        assert!(via_canonical.is_some());
4442        assert!(via_alias.is_some(), "get_record must resolve alias");
4443        // Both calls return the same Arc (pointer equality).
4444        assert!(Arc::ptr_eq(&via_canonical.unwrap(), &via_alias.unwrap()));
4445    }
4446
4447    /// `add_record` is the single creation sink: a record added AFTER its
4448    /// breakpoint table is loaded must receive the registry snapshot so a
4449    /// `LINR >= 3` conversion resolves — without any explicit per-call-site
4450    /// `install_breaktable_registry`. This covers the dbCreateRecord and
4451    /// inline-record creation paths that previously skipped the install.
4452    /// A link target handle holds its target's `Arc`, so two records that
4453    /// read each other hold each other; the database's drop must still free
4454    /// both. The strong count proves the handle is strong, the `Weak`
4455    /// proves the sweep breaks the cycle.
4456    #[epics_macros_rs::epics_test]
4457    async fn dropping_the_database_frees_records_that_link_to_each_other() {
4458        let db = PvDatabase::new();
4459        for (name, other) in [("CYC:A", "CYC:B"), ("CYC:B", "CYC:A")] {
4460            let mut rec = crate::server::records::calc::CalcRecord::new("A+1");
4461            rec.put_field(
4462                "INPA",
4463                EpicsValue::String(format!("{other} NPP NMS").into()),
4464            )
4465            .unwrap();
4466            db.add_record(name, Box::new(rec)).await.unwrap();
4467        }
4468        db.process_record("CYC:A").await.unwrap();
4469        db.process_record("CYC:B").await.unwrap();
4470        let a = db.get_record("CYC:A").unwrap();
4471        let b = db.get_record("CYC:B").unwrap();
4472        assert_eq!(
4473            (Arc::strong_count(&a), Arc::strong_count(&b)),
4474            (3, 3),
4475            "the map, this test and the other record's handle each hold the cell"
4476        );
4477        let (weak_a, weak_b) = (Arc::downgrade(&a), Arc::downgrade(&b));
4478        drop((a, b, db));
4479        assert!(
4480            weak_a.upgrade().is_none() && weak_b.upgrade().is_none(),
4481            "the cycle of handles outlived the database"
4482        );
4483    }
4484
4485    /// The other half of the rule: a record's removal drops every handle
4486    /// that named it, so a holder that never processes again does not keep
4487    /// the removed record alive.
4488    #[epics_macros_rs::epics_test]
4489    async fn removing_a_record_releases_every_handle_to_it() {
4490        let db = PvDatabase::new();
4491        let mut reader = crate::server::records::calc::CalcRecord::new("A");
4492        reader
4493            .put_field("INPA", EpicsValue::String("REL:SRC NPP NMS".into()))
4494            .unwrap();
4495        db.add_record(
4496            "REL:SRC",
4497            Box::new(crate::server::records::calc::CalcRecord::new("1")),
4498        )
4499        .await
4500        .unwrap();
4501        db.add_record("REL:READER", Box::new(reader)).await.unwrap();
4502        db.process_record("REL:READER").await.unwrap();
4503        let src = db.get_record("REL:SRC").unwrap();
4504        assert_eq!(
4505            Arc::strong_count(&src),
4506            3,
4507            "the map, the reader's handle, this test"
4508        );
4509        assert!(db.remove_record("REL:SRC").await);
4510        assert_eq!(
4511            Arc::strong_count(&src),
4512            1,
4513            "the reader's handle outlived the removal"
4514        );
4515    }
4516
4517    #[epics_macros_rs::epics_test]
4518    async fn add_record_installs_breaktable_registry_from_snapshot() {
4519        let db = PvDatabase::new();
4520        let ramp = crate::server::cvt_bpt::BrkTable::build(
4521            "ramp",
4522            &[(0.0, 0.0), (100.0, 10.0), (300.0, 30.0)],
4523        )
4524        .unwrap();
4525        db.add_breaktables(vec![ramp]).await;
4526
4527        let mut rec = crate::server::records::ai::AiRecord::new(0.0);
4528        rec.put_field("LINR", EpicsValue::Short(15)).unwrap(); // ramp = first user-table index
4529        db.add_record("AI:BPT", Box::new(rec)).await.unwrap();
4530
4531        let arc = db.get_record("AI:BPT").unwrap();
4532        let mut inst = arc.write();
4533        inst.record.put_field("RVAL", EpicsValue::Long(50)).unwrap();
4534        inst.record.process().unwrap();
4535        // raw 50 in [0,100] -> eng 5.0, proving the registry was installed by
4536        // add_record alone.
4537        assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Double(5.0)));
4538    }
4539
4540    /// `add_breaktables` re-installs the new snapshot into records that
4541    /// already exist, so a record created BEFORE its table was loaded (inline
4542    /// records added before dbLoadRecords; merge-reloads repointing LINR) can
4543    /// still resolve `LINR >= 3`. Without the re-install the record keeps an
4544    /// empty registry and never linearises.
4545    #[epics_macros_rs::epics_test]
4546    async fn add_breaktables_reinstalls_registry_into_existing_records() {
4547        let db = PvDatabase::new();
4548        // Record added while the registry is still empty: add_record installs
4549        // nothing (the inline-record / pre-load ordering case).
4550        let mut rec = crate::server::records::ai::AiRecord::new(0.0);
4551        rec.put_field("LINR", EpicsValue::Short(15)).unwrap(); // ramp = first user-table index
4552        db.add_record("AI:BPT", Box::new(rec)).await.unwrap();
4553
4554        // Load the table afterwards — re-install must reach the existing record.
4555        let ramp = crate::server::cvt_bpt::BrkTable::build(
4556            "ramp",
4557            &[(0.0, 0.0), (100.0, 10.0), (300.0, 30.0)],
4558        )
4559        .unwrap();
4560        db.add_breaktables(vec![ramp]).await;
4561
4562        let arc = db.get_record("AI:BPT").unwrap();
4563        let mut inst = arc.write();
4564        inst.record.put_field("RVAL", EpicsValue::Long(50)).unwrap();
4565        inst.record.process().unwrap();
4566        assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Double(5.0)));
4567    }
4568
4569    #[epics_macros_rs::epics_test]
4570    async fn get_record_no_resolve_skips_alias_table() {
4571        // Strict variant must NOT see aliases — keeps the canonical
4572        // distinction available for builder code paths.
4573        let db = PvDatabase::new();
4574        db.add_record(
4575            "TARGET",
4576            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4577        )
4578        .await
4579        .unwrap();
4580        db.add_alias("ALIAS", "TARGET").await.unwrap();
4581
4582        assert!(db.get_record_no_resolve("TARGET").is_some());
4583        assert!(
4584            db.get_record_no_resolve("ALIAS").is_none(),
4585            "get_record_no_resolve must not follow alias table"
4586        );
4587    }
4588
4589    #[epics_macros_rs::epics_test]
4590    async fn register_cp_link_normalises_alias_to_canonical() {
4591        // Regression: CP link registration must store the
4592        // canonical record names. dispatch_cp_targets looks up by
4593        // canonical, so an alias-keyed entry is functionally dead.
4594        let db = PvDatabase::new();
4595        db.add_record(
4596            "SRC_REAL",
4597            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4598        )
4599        .await
4600        .unwrap();
4601        db.add_record(
4602            "DST_REAL",
4603            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4604        )
4605        .await
4606        .unwrap();
4607        db.add_alias("SRC_ALIAS", "SRC_REAL").await.unwrap();
4608        db.add_alias("DST_ALIAS", "DST_REAL").await.unwrap();
4609
4610        // Register using the alias forms (CP edge: passive_only = false).
4611        db.register_cp_link("SRC_ALIAS", "DST_ALIAS", false).await;
4612
4613        // Lookup must succeed via the canonical source name.
4614        let targets = db.get_cp_targets("SRC_REAL");
4615        assert_eq!(targets.len(), 1);
4616        assert_eq!(targets[0].record, "DST_REAL");
4617        assert!(!targets[0].passive_only);
4618        // Alias-keyed lookup must NOT have been registered.
4619        let alias_lookup = db.get_cp_targets("SRC_ALIAS");
4620        assert!(alias_lookup.is_empty());
4621    }
4622
4623    #[epics_macros_rs::epics_test]
4624    async fn aliases_for_record_returns_sorted_targets_only() {
4625        let db = PvDatabase::new();
4626        db.add_record(
4627            "TARGET",
4628            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4629        )
4630        .await
4631        .unwrap();
4632        db.add_record(
4633            "OTHER",
4634            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4635        )
4636        .await
4637        .unwrap();
4638        db.add_alias("ZZ", "TARGET").await.unwrap();
4639        db.add_alias("AA", "TARGET").await.unwrap();
4640        db.add_alias("MM", "OTHER").await.unwrap();
4641
4642        // Sorted, only TARGET's aliases.
4643        assert_eq!(
4644            db.aliases_for_record("TARGET"),
4645            vec!["AA".to_string(), "ZZ".to_string()]
4646        );
4647        // OTHER's alone.
4648        assert_eq!(db.aliases_for_record("OTHER"), vec!["MM".to_string()]);
4649        // Unknown record → empty, not None.
4650        assert!(db.aliases_for_record("MISSING").is_empty());
4651    }
4652
4653    #[epics_macros_rs::epics_test]
4654    async fn all_alias_names_returns_registered_aliases() {
4655        let db = PvDatabase::new();
4656        db.add_record(
4657            "TARGET",
4658            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4659        )
4660        .await
4661        .unwrap();
4662        db.add_alias("ALIAS_A", "TARGET").await.unwrap();
4663        db.add_alias("ALIAS_B", "TARGET").await.unwrap();
4664
4665        let mut aliases = db.all_alias_names();
4666        aliases.sort();
4667        assert_eq!(aliases, vec!["ALIAS_A".to_string(), "ALIAS_B".to_string()]);
4668        // Canonical names are NOT returned here.
4669        assert!(!aliases.contains(&"TARGET".to_string()));
4670    }
4671
4672    #[epics_macros_rs::epics_test]
4673    async fn complete_async_record_accepts_alias() {
4674        // Invariant audit: complete_async_record (the
4675        // entry point used by async device-support callbacks to
4676        // finish processing) must accept an alias name. Pre-fix it
4677        // walked `inner.records` directly and would
4678        // `ChannelNotFound` if the original name was an alias.
4679        let db = PvDatabase::new();
4680        db.add_record(
4681            "TARGET",
4682            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4683        )
4684        .await
4685        .unwrap();
4686        db.add_alias("ALIAS", "TARGET").await.unwrap();
4687
4688        // Use complete_async_record by alias — must not error.
4689        db.complete_async_record("ALIAS").await.unwrap();
4690        // And by canonical too — keeps existing behaviour.
4691        db.complete_async_record("TARGET").await.unwrap();
4692    }
4693
4694    #[epics_macros_rs::epics_test]
4695    async fn process_record_accepts_alias() {
4696        // Regression: process_record must accept an alias
4697        // name. Pre-fix it walked `inner.records` directly.
4698        let db = PvDatabase::new();
4699        db.add_record(
4700            "TARGET",
4701            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4702        )
4703        .await
4704        .unwrap();
4705        db.add_alias("ALIAS", "TARGET").await.unwrap();
4706
4707        // Both should succeed and reach the same record.
4708        db.process_record("TARGET").await.unwrap();
4709        db.process_record("ALIAS").await.unwrap();
4710
4711        // A bogus name still errors.
4712        assert!(db.process_record("MISSING").await.is_err());
4713    }
4714
4715    #[epics_macros_rs::epics_test]
4716    async fn process_record_with_links_accepts_alias_and_avoids_cycle() {
4717        // Regression: process_record_with_links normalises
4718        // the alias so that (a) the records-map lookup hits and
4719        // (b) the cycle-detection set doesn't treat alias and
4720        // canonical as two distinct entries (which would let a
4721        // self-loop slip past the visited check).
4722        let db = PvDatabase::new();
4723        db.add_record(
4724            "TARGET",
4725            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4726        )
4727        .await
4728        .unwrap();
4729        db.add_alias("ALIAS", "TARGET").await.unwrap();
4730
4731        // The marker's lifetime is the frame, so by the time the call
4732        // returns the stack is empty and so is the set — see the invariant on
4733        // `run_process_frame`. What this pins is that the alias resolved to
4734        // the canonical name on the way IN: seed the set with "TARGET" and the
4735        // entry must find itself already on the stack and decline.
4736        let mut visited = ProcStack::new();
4737        db.process_record_with_links("ALIAS", &mut visited)
4738            .await
4739            .unwrap();
4740        assert!(
4741            visited.is_empty(),
4742            "a finished frame leaves no marker behind: {visited:?}",
4743        );
4744
4745        let mut seeded = epics_base_rs::server::database::ProcStack::new();
4746        let target = db.get_record("TARGET").unwrap();
4747        seeded.claim(&target);
4748        db.process_record_with_links("ALIAS", &mut seeded)
4749            .await
4750            .unwrap();
4751        assert!(
4752            seeded.holds(&target),
4753            "the alias resolves to the seeded cell: {seeded:?}",
4754        );
4755        assert_eq!(
4756            seeded.len(),
4757            1,
4758            "the alias resolved to TARGET and was declined, adding nothing: {seeded:?}",
4759        );
4760    }
4761
4762    #[epics_macros_rs::epics_test]
4763    async fn alias_duplicate_rejected() {
4764        let db = PvDatabase::new();
4765        db.add_record(
4766            "TARGET",
4767            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
4768        )
4769        .await
4770        .unwrap();
4771        db.add_alias("ALIAS", "TARGET").await.unwrap();
4772        // Re-registering the same alias name (even to the same target)
4773        // must fail — base behaviour: aliases are inserted once.
4774        let err = db.add_alias("ALIAS", "TARGET").await;
4775        assert!(err.is_err(), "duplicate alias name must be rejected");
4776    }
4777
4778    /// `add_pv`, `add_pv_with_hook`, and `add_record` must
4779    /// refuse to silently replace an existing registration. Mirrors
4780    /// epics-base C IOC which treats a duplicate `dbLoadRecords` name
4781    /// as a fatal load error.
4782    #[epics_macros_rs::epics_test]
4783    async fn add_pv_and_add_record_reject_duplicates_across_namespaces() {
4784        use crate::server::records::ai::AiRecord;
4785
4786        let db = PvDatabase::new();
4787        db.add_pv("A", EpicsValue::Double(1.0)).await.unwrap();
4788        // Same name as simple_pv — every namespace must see it.
4789        assert!(db.add_pv("A", EpicsValue::Double(2.0)).await.is_err());
4790        let noop_hook: crate::server::pv::WriteHook =
4791            std::sync::Arc::new(|_v, _ctx| Box::pin(async { Ok(()) }));
4792        assert!(
4793            db.add_pv_with_hook("A", EpicsValue::Double(2.0), noop_hook)
4794                .await
4795                .is_err()
4796        );
4797        assert!(
4798            db.add_record("A", Box::new(AiRecord::new(0.0)))
4799                .await
4800                .is_err()
4801        );
4802        assert!(db.add_alias("A", "A").await.is_err());
4803
4804        db.add_record("R", Box::new(AiRecord::new(0.0)))
4805            .await
4806            .unwrap();
4807        assert!(
4808            db.add_record("R", Box::new(AiRecord::new(1.0)))
4809                .await
4810                .is_err()
4811        );
4812        assert!(db.add_pv("R", EpicsValue::Double(0.0)).await.is_err());
4813        assert!(db.add_alias("R", "R").await.is_err());
4814
4815        db.add_alias("AL", "R").await.unwrap();
4816        assert!(db.add_pv("AL", EpicsValue::Double(0.0)).await.is_err());
4817        assert!(
4818            db.add_record("AL", Box::new(AiRecord::new(0.0)))
4819                .await
4820                .is_err()
4821        );
4822    }
4823
4824    /// Removing a record must purge aliases
4825    /// that pointed AT it. Otherwise the alias name stays
4826    /// "registered" forever and `add_pv` / `add_record` rejecting
4827    /// reuse causes a permanent name leak.
4828    #[epics_macros_rs::epics_test]
4829    async fn remove_record_purges_dangling_aliases() {
4830        use crate::server::records::ai::AiRecord;
4831
4832        let db = PvDatabase::new();
4833        db.add_record("R", Box::new(AiRecord::new(0.0)))
4834            .await
4835            .unwrap();
4836        db.add_alias("ALT1", "R").await.unwrap();
4837        db.add_alias("ALT2", "R").await.unwrap();
4838        // An alias that points elsewhere must NOT be touched.
4839        db.add_record("OTHER", Box::new(AiRecord::new(0.0)))
4840            .await
4841            .unwrap();
4842        db.add_alias("KEEPER", "OTHER").await.unwrap();
4843
4844        assert!(db.remove_record("R").await);
4845
4846        // Both aliases pointing at R should be gone — `add_pv` of
4847        // those names succeeds again.
4848        db.add_pv("ALT1", EpicsValue::Double(0.0)).await.unwrap();
4849        db.add_pv("ALT2", EpicsValue::Double(0.0)).await.unwrap();
4850        // The unrelated alias must survive.
4851        assert_eq!(db.resolve_alias("KEEPER"), Some("OTHER".to_string()));
4852    }
4853
4854    /// The node list is C's `recList`: records and aliases in ONE sequence,
4855    /// each at the position it was declared at — C draws both from
4856    /// `pdbbase->no_records++` (`dbStaticLib.c:1704`), which is why an alias
4857    /// of the first record precedes the second record rather than trailing
4858    /// every record.
4859    #[epics_macros_rs::epics_test]
4860    async fn all_db_nodes_interleaves_aliases_at_their_load_position() {
4861        use crate::server::records::ai::AiRecord;
4862
4863        let db = PvDatabase::new();
4864        db.add_record("FIRST", Box::new(AiRecord::new(0.0)))
4865            .await
4866            .unwrap();
4867        db.add_alias("FIRST:ALT", "FIRST").await.unwrap();
4868        db.add_record("SECOND", Box::new(AiRecord::new(0.0)))
4869            .await
4870            .unwrap();
4871
4872        assert_eq!(
4873            db.all_db_nodes().await,
4874            vec![
4875                DbNode {
4876                    name: "FIRST".into(),
4877                    alias_of: None
4878                },
4879                DbNode {
4880                    name: "FIRST:ALT".into(),
4881                    alias_of: Some("FIRST".into())
4882                },
4883                DbNode {
4884                    name: "SECOND".into(),
4885                    alias_of: None
4886                },
4887            ]
4888        );
4889    }
4890
4891    /// The other end of the same sequence: `remove_record` is the only path
4892    /// that drops an alias, and it must drop the alias's place in the list
4893    /// with it. A sequence left behind would order the walk against a node
4894    /// that no longer exists, and a later alias of the same name would then
4895    /// sort at the dead position instead of its own.
4896    #[epics_macros_rs::epics_test]
4897    async fn removing_a_record_drops_its_alias_nodes_from_the_list() {
4898        use crate::server::records::ai::AiRecord;
4899
4900        let db = PvDatabase::new();
4901        db.add_record("GONE", Box::new(AiRecord::new(0.0)))
4902            .await
4903            .unwrap();
4904        db.add_alias("GONE:ALT", "GONE").await.unwrap();
4905        db.add_record("STAYS", Box::new(AiRecord::new(0.0)))
4906            .await
4907            .unwrap();
4908
4909        assert!(db.remove_record("GONE").await);
4910        assert_eq!(
4911            db.all_db_nodes().await,
4912            vec![DbNode {
4913                name: "STAYS".into(),
4914                alias_of: None
4915            }]
4916        );
4917
4918        // Re-registering the freed name puts it at the END of the list, the
4919        // position its NEW sequence names — not the hole the old one left.
4920        db.add_record("GONE", Box::new(AiRecord::new(0.0)))
4921            .await
4922            .unwrap();
4923        db.add_alias("GONE:ALT", "GONE").await.unwrap();
4924        assert_eq!(
4925            db.all_db_nodes()
4926                .await
4927                .into_iter()
4928                .map(|node| node.name)
4929                .collect::<Vec<_>>(),
4930            vec!["STAYS", "GONE", "GONE:ALT"]
4931        );
4932    }
4933
4934    /// `add_alias` must reject collisions with
4935    /// every namespace, including simple PVs (which the pre-fix
4936    /// code missed).
4937    #[epics_macros_rs::epics_test]
4938    async fn add_alias_rejects_simple_pv_collision() {
4939        use crate::server::records::ai::AiRecord;
4940
4941        let db = PvDatabase::new();
4942        db.add_pv("PVX", EpicsValue::Double(0.0)).await.unwrap();
4943        db.add_record("TARGET", Box::new(AiRecord::new(0.0)))
4944            .await
4945            .unwrap();
4946        // alias name "PVX" collides with the simple PV — must fail.
4947        assert!(db.add_alias("PVX", "TARGET").await.is_err());
4948    }
4949
4950    /// Concurrent `add_pv` and `add_record` with
4951    /// the same name must not deadlock and must serialize so that
4952    /// exactly one succeeds. Pre-fix the two methods grabbed
4953    /// different write locks first, opening a cross-lock-order
4954    /// deadlock window.
4955    #[epics_macros_rs::epics_test]
4956    async fn concurrent_add_pv_and_add_record_do_not_deadlock() {
4957        use crate::server::records::ai::AiRecord;
4958
4959        let db = std::sync::Arc::new(PvDatabase::new());
4960        let db1 = db.clone();
4961        let db2 = db.clone();
4962        let reactor =
4963            crate::runtime::task::Reactor::current().expect("the test driver enters an executor");
4964        let h1 = reactor.spawn(async move { db1.add_pv("RACE", EpicsValue::Double(1.0)).await });
4965        let h2 = reactor
4966            .spawn(async move { db2.add_record("RACE", Box::new(AiRecord::new(0.0))).await });
4967        // Both complete within a reasonable bound — pre-fix this
4968        // could hang because T1 holds simple_pvs.write and waits
4969        // for records.read while T2 holds records.write and waits
4970        // for simple_pvs.read.
4971        let r1 = crate::runtime::task::timeout(std::time::Duration::from_secs(2), h1)
4972            .await
4973            .expect("add_pv must not block on add_record");
4974        let r2 = crate::runtime::task::timeout(std::time::Duration::from_secs(2), h2)
4975            .await
4976            .expect("add_record must not block on add_pv");
4977        let r1 = r1.unwrap();
4978        let r2 = r2.unwrap();
4979        // Exactly one of the two wins; the other reports
4980        // "already registered".
4981        assert!(
4982            (r1.is_ok() && r2.is_err()) || (r1.is_err() && r2.is_ok()),
4983            "exactly one of the racing inserts must succeed: r1={r1:?} r2={r2:?}",
4984        );
4985    }
4986
4987    #[epics_macros_rs::epics_test]
4988    async fn existence_gate_blocks_cached_simple_pv_per_request() {
4989        // A cached simple PV must re-pass the installed existence gate on
4990        // both the search (`has_name_from`) and create (`find_entry_from`)
4991        // paths. Records bypass the gate. With no gate the short-circuit
4992        // is unchanged (plain-IOC behaviour).
4993        use std::net::SocketAddr;
4994
4995        let db = PvDatabase::new();
4996        db.add_pv("SHADOW:x", EpicsValue::Double(1.0))
4997            .await
4998            .unwrap();
4999        db.add_record(
5000            "REC",
5001            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
5002        )
5003        .await
5004        .unwrap();
5005
5006        let denied: SocketAddr = "127.0.0.1:5064".parse().unwrap();
5007        let allowed: SocketAddr = "192.0.2.5:5064".parse().unwrap();
5008
5009        // No gate installed: the cached simple PV resolves unconditionally.
5010        assert!(db.has_name_from("SHADOW:x", Some(denied)).await);
5011        assert!(db.find_entry_from("SHADOW:x", Some(denied)).await.is_some());
5012
5013        // Gate denies the simple PV only for `denied` (the gateway's
5014        // host-scoped `.pvlist` admission has exactly this shape).
5015        let gate: ExistenceGate = Arc::new(move |name, peer| {
5016            Box::pin(async move { !(name == "SHADOW:x" && peer == Some(denied)) })
5017        });
5018        db.set_existence_gate(gate).await;
5019
5020        // Denied peer: does-not-exist on both paths despite the PV being
5021        // cached in `simple_pvs`.
5022        assert!(!db.has_name_from("SHADOW:x", Some(denied)).await);
5023        assert!(db.find_entry_from("SHADOW:x", Some(denied)).await.is_none());
5024
5025        // Allowed peer: still resolves.
5026        assert!(db.has_name_from("SHADOW:x", Some(allowed)).await);
5027        assert!(
5028            db.find_entry_from("SHADOW:x", Some(allowed))
5029                .await
5030                .is_some()
5031        );
5032
5033        // Records are never gateway-managed — the gate must not gate them
5034        // even for the denied peer.
5035        assert!(db.has_name_from("REC", Some(denied)).await);
5036        assert!(db.find_entry_from("REC", Some(denied)).await.is_some());
5037    }
5038
5039    /// The declaration-keyed sweep sees every name the three hand lists it
5040    /// replaced spelled, and the names they did NOT spell.
5041    ///
5042    /// The old lists were `COMMON_LINK_FIELDS`, `Record::multi_input_links`
5043    /// and `links::CP_INPUT_LINK_FIELDS`; the third is deleted, so its content
5044    /// is repeated here as the oracle rather than read from it. It carried one
5045    /// hard-won fact worth keeping visible — `SVL` and not `SGNL`, because
5046    /// `histogramRecord.dbd.pod:212` declares `field(SVL,DBF_INLINK)` while
5047    /// `SGNL` (:202) is the `DBF_DOUBLE` the link reads INTO. The declaration
5048    /// now carries that by itself, which is the point: this asserts it does.
5049    #[test]
5050    fn the_declared_class_sweep_covers_every_name_the_hand_lists_spelled() {
5051        use crate::types::{DbfLinkClass, dbf_link_class};
5052
5053        // (name, the type that declares it, the class it must resolve to).
5054        let cp_inputs: &[(&str, &str)] = &[
5055            ("DOL", "ao"),
5056            ("DOL0", "seq"),
5057            ("DOLF", "seq"),
5058            ("DOL1", "sseq"),
5059            ("DOLA", "sseq"),
5060            ("NVL", "sel"),
5061            ("SELL", "sseq"),
5062            ("SVL", "histogram"),
5063        ];
5064        for (field, record_type) in cp_inputs {
5065            assert_eq!(
5066                dbf_link_class(record_type, field),
5067                Some(DbfLinkClass::InLink),
5068                "{record_type}.{field} was a CP_INPUT_LINK_FIELDS name and must \
5069                 still resolve as an input link"
5070            );
5071        }
5072        // SGNL is the value, not the link — the bug the old list carried.
5073        assert_eq!(dbf_link_class("histogram", "SGNL"), None);
5074
5075        for (field, _) in crate::server::record::record_instance::COMMON_LINK_FIELDS {
5076            assert!(
5077                dbf_link_class("ai", field).is_some() || dbf_link_class("ao", field).is_some(),
5078                "{field} was a COMMON_LINK_FIELDS name and must still resolve"
5079            );
5080        }
5081
5082        // The names no hand list spelled, which is why five of seven probed
5083        // record shapes lost lock-set members: `dblsr` on softIoc R7.0.10 put
5084        // a `fanout` and its `LNK1` target in one set, the port did not.
5085        let unspelled: &[(&str, &str, DbfLinkClass)] = &[
5086            ("fanout", "LNK1", DbfLinkClass::FwdLink),
5087            ("dfanout", "OUTA", DbfLinkClass::OutLink),
5088            ("ai", "SIML", DbfLinkClass::InLink),
5089            ("ai", "SIOL", DbfLinkClass::InLink),
5090            ("aSub", "SUBL", DbfLinkClass::InLink),
5091            ("aSub", "OUTA", DbfLinkClass::OutLink),
5092            ("seq", "LNK1", DbfLinkClass::OutLink),
5093        ];
5094        for (record_type, field, class) in unspelled {
5095            assert_eq!(
5096                dbf_link_class(record_type, field),
5097                Some(*class),
5098                "{record_type}.{field}"
5099            );
5100        }
5101    }
5102
5103    /// Every link field a record declares reaches `link_field_texts`, and
5104    /// nothing else does.
5105    ///
5106    /// The boundary the name lists could not hold: a `fanout`'s `LNK1` is
5107    /// `DBF_FWDLINK` while an `sseq`'s `LNK1` is `DBF_OUTLINK`, so the same
5108    /// spelling is two classes and only the declaration can tell them apart.
5109    #[epics_macros_rs::epics_test]
5110    async fn link_field_texts_is_the_declared_link_set() {
5111        use crate::server::record::LinkFieldType;
5112        use crate::server::records::fanout::FanoutRecord;
5113
5114        let db = PvDatabase::new();
5115        db.add_record("FAN", Box::new(FanoutRecord::default()))
5116            .await
5117            .unwrap();
5118        {
5119            let rec = db.get_record("FAN").unwrap();
5120            let mut inst = rec.write();
5121            inst.record
5122                .put_field("LNK1", EpicsValue::String("TARGET".into()))
5123                .unwrap();
5124        }
5125        let inst = db.get_record("FAN").unwrap();
5126        let inst = inst.read();
5127        let fields = PvDatabase::link_field_texts(&inst);
5128        let lnk1 = fields
5129            .iter()
5130            .find(|(f, _, _)| f == "LNK1")
5131            .unwrap_or_else(|| panic!("fanout.LNK1 must be enumerated, got {fields:?}"));
5132        assert_eq!(lnk1.1, "TARGET");
5133        assert!(
5134            matches!(lnk1.2, LinkFieldType::Fwd),
5135            "fanout.LNK1 is DBF_FWDLINK, got {:?}",
5136            lnk1.2
5137        );
5138        assert!(
5139            !fields.iter().any(|(f, _, _)| f == "VAL"),
5140            "a non-link field must not be enumerated, got {fields:?}"
5141        );
5142    }
5143
5144    /// `record_link_fields` must surface a record's device-support `INP`
5145    /// link. An `ai`'s `INP` is a `DBF_INLINK` field stored in
5146    /// `common.inp` — it is not a `DbFieldType::String` entry in
5147    /// `field_list()` — so the earlier `field_list()` scan filtered by
5148    /// `String` silently dropped it. The pvalink install scan walks this
5149    /// method, so a Passive `ai` carrying a CP/CPP pvalink `INP` never had
5150    /// its monitor opened at iocInit. Enumerating the canonical
5151    /// `common.inp` storage fixes it; C `dbpvar`/`dbcar` likewise dump
5152    /// every link field including device-support INP/OUT.
5153    #[epics_macros_rs::epics_test]
5154    async fn record_link_fields_surfaces_device_support_inp() {
5155        use crate::server::record::ParsedLink;
5156        use crate::server::records::ai::AiRecord;
5157
5158        let db = PvDatabase::new();
5159        // A `pva` link set has to be installed for a `pva://` link to survive
5160        // `db_init_link_locality`, which refuses a link whose scheme nothing
5161        // can service. This test is about the ENUMERATION reaching
5162        // `common.inp`, so it installs the lset the link names rather than
5163        // asserting the refusal.
5164        db.register_link_set(
5165            "pva",
5166            std::sync::Arc::new(DelayedConnectLset {
5167                names: Vec::new(),
5168                connect_at: crate::runtime::task::Instant::now(),
5169            }),
5170        )
5171        .await;
5172        db.add_record("AI", Box::new(AiRecord::new(0.0)))
5173            .await
5174            .unwrap();
5175        // Device-support INP lives in `common.inp` (DBF_INLINK), the
5176        // exact storage a `field_list()` String scan cannot reach.
5177        {
5178            let rec = db.get_record("AI").unwrap();
5179            rec.write().common.inp = "pva://mini:current?proc=CP".to_string();
5180        }
5181
5182        let links = db.record_link_fields("AI");
5183        let inp = links
5184            .iter()
5185            .find(|(f, _, _)| f == "INP")
5186            .unwrap_or_else(|| panic!("INP link must be surfaced, got {links:?}"));
5187        assert_eq!(inp.1, "pva://mini:current?proc=CP");
5188        assert!(
5189            matches!(inp.2, ParsedLink::Pva(_)),
5190            "a pva:// INP must parse to ParsedLink::Pva, got {:?}",
5191            inp.2
5192        );
5193    }
5194
5195    /// The watchdog table an operator compares against C's: C `iocBuild`
5196    /// calls `dbCaLinkInit` (`iocInit.c:216`), so `dbCaLink` is one of the
5197    /// threads a C IOC lists whether or not any link is external. The port
5198    /// started the owner from the first staged external link, so an IOC whose
5199    /// output links are all local listed no `dbCaLink` at all.
5200    ///
5201    /// Asserted with no link ever staged — a test that staged one first would
5202    /// pass on the lazy path too.
5203    // RTEMS-EXEC-MODEL-ALLOW(1): needs the ambient reactor because that is
5204    // exactly what the test is about — `ca_link_init` starts nothing without
5205    // one. Green on the exec backend.
5206    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5207    async fn ioc_init_starts_the_ca_link_owner() {
5208        fn table() -> String {
5209            let out = std::cell::RefCell::new(String::new());
5210            crate::runtime::taskwd::taskwd_show(1, &|line| {
5211                out.borrow_mut().push_str(line);
5212                out.borrow_mut().push('\n');
5213            });
5214            out.into_inner()
5215        }
5216
5217        assert!(
5218            !table().contains("dbCaLink"),
5219            "the link owner was registered before any IOC init"
5220        );
5221
5222        let db = PvDatabase::new();
5223        db.ioc_init().await;
5224
5225        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
5226        while !table().contains("dbCaLink") {
5227            assert!(
5228                std::time::Instant::now() < deadline,
5229                "`dbCaLink` never reached the watchdog table:\n{}",
5230                table()
5231            );
5232            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
5233        }
5234    }
5235
5236    /// The other boundary: a database that captured no reactor has nowhere to
5237    /// run the owner's network work, which is the same condition
5238    /// `stage_external_link_open` already refuses on. Starting a watchdog-
5239    /// registered task that can never do its job would be worse than the
5240    /// missing row — the table would claim a working link owner.
5241    // The `exec_backend` executor is not the ambient tokio reactor, and
5242    // `BlockingBridge::try_capture` finds it with no runtime entered — so
5243    // there is no reactor-less database to test in that configuration.
5244    #[cfg(tokio_backend)]
5245    #[test]
5246    fn ca_link_init_starts_nothing_without_a_reactor() {
5247        let db = PvDatabase::new();
5248        assert!(
5249            !db.ca_link_init(),
5250            "a database with no captured reactor must refuse to start the owner"
5251        );
5252
5253        let out = std::cell::RefCell::new(String::new());
5254        crate::runtime::taskwd::taskwd_show(1, &|line| {
5255            out.borrow_mut().push_str(line);
5256            out.borrow_mut().push('\n');
5257        });
5258        assert!(
5259            !out.into_inner().contains("dbCaLink"),
5260            "the refused owner still reached the watchdog table"
5261        );
5262    }
5263}