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