Skip to main content

epics_base_rs/server/database/
mod.rs

1pub mod db_access;
2mod field_io;
3pub mod filters;
4mod link_put_queue;
5mod link_set;
6mod links;
7mod processing;
8mod record_lock;
9mod scan_index;
10mod snapshot;
11
12pub use link_set::{
13    DynLinkSet, LinkDbfType, LinkMetadata, LinkPutOp, LinkSet, LinkSetRegistry, PutAdmission,
14    RemoteAlarm,
15};
16pub use processing::{AsyncDbHandle, AsyncToken};
17pub use record_lock::{ManyRecordWriteGuard, RecordWriteGuard};
18
19use crate::error::{CaError, CaResult};
20use arc_swap::{ArcSwap, ArcSwapOption};
21use snapshot::SnapshotCell;
22use std::collections::{BTreeSet, HashMap};
23use std::sync::Arc;
24
25use crate::server::pv::ProcessVariable;
26use crate::server::record::{Record, RecordInstance, ScanList};
27use crate::types::EpicsValue;
28
29/// What a `.db` definition carries into the creation sink alongside the record
30/// itself: the `dbCommon` fields `db_loader::apply_fields` could not route to
31/// the record's own `field_list`, and the record's `info(...)` tags.
32///
33/// It exists so that [`PvDatabase::add_loaded_record`] receives a record's
34/// COMPLETE loaded state in one call. A caller cannot add the record and then
35/// apply its `.db` fields, because the sink runs C's `iocInit` passes — whose
36/// result depends on those fields — before the record is reachable at all.
37#[derive(Default, Debug, Clone)]
38pub struct RecordLoad {
39    /// `dbCommon` fields, in `.db` file order (a later `field(UDF,…)` wins).
40    pub common_fields: Vec<(String, EpicsValue)>,
41    /// `info(key, "value")` tags.
42    pub info_tags: Vec<(String, String)>,
43}
44
45impl RecordLoad {
46    /// The common fields alone — the shape every `.db` loader path produces
47    /// from [`crate::server::db_loader::apply_fields`].
48    pub fn from_common_fields(common_fields: Vec<(String, EpicsValue)>) -> Self {
49        Self {
50            common_fields,
51            info_tags: Vec::new(),
52        }
53    }
54}
55
56/// Parse a PV name into (base_name, field_name).
57/// "TEMP.EGU" → ("TEMP", "EGU")
58/// "TEMP"     → ("TEMP", "VAL")
59pub fn parse_pv_name(name: &str) -> (&str, &str) {
60    match name.rsplit_once('.') {
61        Some((base, field)) => (base, field),
62        None => (name, "VAL"),
63    }
64}
65
66/// C `dbIsValueField` (`dbAccess.c:463-469`): is this field the record
67/// type's *value* field?
68///
69/// A record type's value field is the one the DBD names `VAL` — the DBD
70/// parser records exactly that field's index as `indvalFlddes`
71/// (`dbLexRoutines.c:777-780`), which is what `dbIsValueField` compares
72/// against. Metadata that C/pvxs apply "to VAL only" (e.g. QSRV's
73/// `Q:form` → `display.form.index`, `iocsource.cpp:53`) key on this
74/// predicate, so it lives beside [`parse_pv_name`], whose `"REC"` → `VAL`
75/// default is the other half of the same rule.
76pub fn is_value_field(field: &str) -> bool {
77    field.eq_ignore_ascii_case("VAL")
78}
79
80/// Apply timestamp to a record based on its TSE field.
81/// `is_soft` indicates a Soft Channel device type.
82///
83/// Mirrors C `recGblGetTimeStampSimm` (recGbl.c:310-343). The TSE
84/// constants are defined in `epicsTime.h:102-104`:
85///
86///   - `epicsTimeEventCurrentTime = 0` → wall-clock now
87///   - `epicsTimeEventBestTime    = -1` → generalTime BestTime providers
88///   - `epicsTimeEventDeviceTime  = -2` → device support already set time
89///   - `1..` → event-number providers
90///
91/// The C path is symmetric: every non-`-2` case unconditionally
92/// overwrites `precord->time` via `epicsTimeGetEvent(tse)`, which
93/// delegates to `epicsTimeGetCurrent` for `tse==0` and to
94/// `generalTimeGetEventPriority` otherwise. Only `-2` (device time)
95/// is left untouched because the device support has already written
96/// the timestamp before `recGblGetTimeStamp` is called.
97fn apply_timestamp(common: &mut super::record::CommonFields, _is_soft: bool) {
98    // Single owner of TSE -> TIME resolution; device support that must
99    // format the record's resolved time during `read()` routes through the
100    // same helper so the two never drift (see `recgbl::get_time_stamp`).
101    // For TSE=-2 the helper returns `common.time` unchanged, preserving the
102    // device-time "leave it alone" semantics.
103    common.time = crate::server::recgbl::get_time_stamp(common.tse, common.time);
104}
105
106/// Unified entry in the PV database.
107pub enum PvEntry {
108    Simple(Arc<ProcessVariable>),
109    Record(Arc<parking_lot::RwLock<RecordInstance>>),
110}
111
112/// Callback for resolving external PV names (CA/PVA links).
113/// Returns the *cached* value of the external PV, or `None` if unavailable.
114///
115/// **Sync**, for the same reason [`LinkSet::get_cached_value`] is: this runs
116/// on the record-processing thread with the record's L1 gate held, and C's
117/// `dbCaGetLink` likewise only reads `pca->pgetNative` under `pca->lock` —
118/// it never waits for the wire (`dbCa.c:448-535`). A resolver that cannot
119/// answer from cache must stage the open on its own executor and return
120/// `None` (C's `!pca->isConnected` arm, `dbCa.c:459-464`).
121pub type ExternalPvResolver = Arc<dyn Fn(&str) -> Option<EpicsValue> + Send + Sync>;
122
123/// Async hook invoked by [`PvDatabase::has_name`] when a name is not yet
124/// in the database. Used by the CA gateway and similar proxy components
125/// to lazily populate PVs on first search.
126///
127/// The resolver should:
128/// 1. Determine whether the name should be served (e.g., check ACL)
129/// 2. Take whatever action is needed to make `has_name` return true on
130///    a subsequent call (e.g., subscribe to an upstream IOC and call
131///    `add_pv` with a placeholder value)
132/// 3. Return `true` if the name is now resolvable, `false` otherwise
133///
134/// Returning `true` causes `has_name` to re-check the database. The
135/// resolver may take some time (TCP search, upstream connect handshake);
136/// the caller (UDP search responder, TCP CREATE_CHANNEL handler) will
137/// `.await` it.
138/// The second argument is the downstream client's socket address when
139/// the lookup originates from a CA/PVA search or channel-create on
140/// behalf of an identified peer (`None` for host-less internal lookups:
141/// preload, iocsh, link processing). the CA gateway needs
142/// this to evaluate `.pvlist` `DENY FROM host` rules at search time, the
143/// way C ca-gateway's `pvExistTest` passes the client host to
144/// `gateAs::findEntry`.
145pub type SearchResolver = Arc<
146    dyn Fn(
147            String,
148            Option<std::net::SocketAddr>,
149        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
150        + Send
151        + Sync,
152>;
153
154/// Per-request admission gate for an **already-registered** simple PV.
155///
156/// A plain IOC's simple PVs are authoritative: once registered they
157/// exist unconditionally, so no gate is installed and the cached-PV
158/// short-circuit in [`PvDatabase::find_entry_from`] /
159/// [`PvDatabase::has_name_from`] is unchanged. A CA gateway is
160/// different — its shadow PVs are projections of an upstream that can be
161/// host-denied for a given requester or disconnected — so it installs a
162/// gate that the lookup path consults *before* returning a cached simple
163/// PV. Returning `false` makes the database answer "does not exist" for
164/// that requester, exactly as C ca-gateway's `pvExistTest` returns
165/// `pverDoesNotExistHere` for a host-denied or disconnected PV
166/// (`gateServer.cc:1516-1637`) — without removing the PV object, so its
167/// cached value stays available for diagnostics and re-admission.
168///
169/// The first argument is the filter-suffix-stripped record path (the
170/// same key the simple-PV map and the gateway cache use); the second is
171/// the requesting peer (`None` for host-less internal lookups). The gate
172/// governs **only** simple PVs — records and aliases are never
173/// gateway-managed and bypass it.
174pub type ExistenceGate = Arc<
175    dyn Fn(
176            String,
177            Option<std::net::SocketAddr>,
178        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
179        + Send
180        + Sync,
181>;
182
183/// Internal state of [`PvDatabase`].
184///
185/// # Invariant — alias-aware lookup (epics-base PR #336)
186///
187/// **MUST**: every record-name lookup that originates from an
188/// external API (CA/PVA server, link processing, iocsh, bridge
189/// providers) MUST go through [`PvDatabase::get_record`] /
190/// [`PvDatabase::find_entry`] / [`PvDatabase::has_name`], never
191/// `inner.records.read().await.get(...)` directly.
192///
193/// **MUST NOT**: a function that takes an arbitrary record-name
194/// `&str` and reads `inner.records` directly, unless one of:
195/// - the function is itself an alias-management primitive
196///   (`add_record`, `remove_record`, `add_alias`,
197///   `find_entry_no_resolve`, `has_name_no_resolve`,
198///   `get_record_no_resolve`, `all_record_names`), OR
199/// - the name has been normalised to canonical earlier in the
200///   same scope (the `let canonical_owned; let name: &str = ...`
201///   pattern in `process_record_with_links_inner` /
202///   `complete_async_record_inner` / `put_record_field_from_ca` /
203///   `put_pv`).
204///
205/// **Owner/Gate:** `PvDatabase::get_record` (alias-aware path).
206///
207/// New code that adds a record-name entry point should call
208/// `get_record` first OR run the canonical-normalisation snippet
209/// at function entry. Direct `inner.records` access is reserved
210/// for the alias-management primitives listed above.
211/// One CP/CPP edge in the `PvDatabaseInner::cp_links` index: the record
212/// to (re)process when the source record changes.
213///
214/// `passive_only` distinguishes CPP from CP. C adds the `CA_DBPROCESS`
215/// action for a CP link unconditionally, but for a CPP link only when the
216/// link-holding record's `SCAN` is Passive (`dbCa.c:854,994,1072`). CP
217/// edges clear the flag; CPP edges set it, and `dispatch_cp_targets`
218/// honours it.
219#[derive(Clone, Debug)]
220pub struct CpTarget {
221    pub record: String,
222    pub passive_only: bool,
223}
224
225/// The scan index — one independently locked bucket per [`ScanList`].
226///
227/// C's shape (`dbScan.c`): `scan_list` carries its own `epicsMutexId lock`
228/// (`:75`) and `scanList` / `addToList` / `deleteFromList` take only that
229/// one list's lock, so two periodic rates never wait on each other. The port
230/// used to hold a single `RwLock` over the whole `ScanList → bucket` map,
231/// which is coarser than C on the highest-contention path in the database.
232///
233/// There is no lock over the table itself, and that is structural rather than
234/// an optimisation: the set of scan lists is fixed by `menuScan`
235/// ([`ScanList::ALL`]), so the table is fully populated at construction and
236/// never mutated. A bucket is reached by [`ScanList::slot`], a total index —
237/// there is no absent-bucket case for a caller to handle, and therefore no
238/// path on which a lookup could take the wrong lock.
239///
240/// `doc/rtems-priority-locks-design.md` §5.3 addendum, row L8b.
241struct ScanIndex {
242    buckets: [crate::runtime::sync::PriorityInheritanceMutex<BTreeSet<(i16, u64, String)>>;
243        ScanList::COUNT],
244}
245
246impl ScanIndex {
247    fn new() -> Self {
248        Self {
249            buckets: std::array::from_fn(|_| {
250                crate::runtime::sync::PriorityInheritanceMutex::new(BTreeSet::new())
251            }),
252        }
253    }
254
255    /// The bucket holding `list`'s records. Total — see [`ScanList::slot`].
256    fn bucket(
257        &self,
258        list: ScanList,
259    ) -> &crate::runtime::sync::PriorityInheritanceMutex<BTreeSet<(i16, u64, String)>> {
260        &self.buckets[list.slot()]
261    }
262}
263
264struct PvDatabaseInner {
265    /// The simple-PV directory — C's `dbPvdLib.c` process-variable directory,
266    /// whose per-bucket `epicsMutexId lock` (`:30`, created `:119`) is taken
267    /// for both `dbPvdFind` (`:123-136`) and `dbPvdAdd` (`:150-162`). C has no
268    /// reader-writer primitive anywhere in the IOC
269    /// (`rg pthread_rwlock epics-base/modules/` → zero hits), so a PI mutex is
270    /// not a demotion against C — it *is* C's construction.
271    /// `doc/rtems-priority-locks-design.md` §5.3 addendum, row L8a.
272    ///
273    /// **Every reader MUST bind the lookup result in a statement of its own**
274    /// (`let pv = …lock().get(name).cloned();`) rather than reading the map in
275    /// an `if let` scrutinee. The guard is `!Send`, and an `if let` scrutinee
276    /// temporary lives to the end of the `if let` *body* — so the scrutinee
277    /// form keeps the guard alive across any `.await` the body makes and turns
278    /// the enclosing `async fn` into a `!Send` future at its `tokio::spawn`
279    /// site. The rule is uniform across all 17 read sites, not applied only to
280    /// the ones that await today, so a new `.await` in an existing body cannot
281    /// re-open it.
282    simple_pvs:
283        crate::runtime::sync::PriorityInheritanceMutex<HashMap<String, Arc<ProcessVariable>>>,
284    records: parking_lot::RwLock<HashMap<String, Arc<parking_lot::RwLock<RecordInstance>>>>,
285    /// Scan index: maps scan type → sorted set of
286    /// `(PHAS, load_order, record_name)`.
287    ///
288    /// C parity (`dbScan.c:1052-1095`): `buildScanLists` walks records
289    /// in database / record-type **load order** and `addToList`
290    /// inserts each after the last element with `phas <= precord->phas`
291    /// — so within one PHAS value the scan list is a stable FIFO in
292    /// load order. The secondary sort key is the per-record
293    /// `load_order` sequence (NOT the record name), so two records
294    /// sharing a PHAS scan in the order they were loaded, matching a
295    /// C IOC built from the same `.db` file.
296    /// Keyed by [`ScanList`], not `ScanType`: a `Passive` or illegal SCAN names
297    /// no list (C `scanAdd`, dbScan.c:241-251) and so cannot be a key at all.
298    ///
299    /// **One lock per scan list, not one lock over the index.** C has one
300    /// `epicsMutexId lock` per `scan_list` (`dbScan.c:75`, created `:527`,
301    /// `:604`, `:908`) and never serialises two rates against each other. A
302    /// single map-wide lock would serialise the seven periodic threads (bands
303    /// 60–66) that C runs independently, on the one path where both ends of
304    /// the contention pair are banded — see
305    /// `doc/rtems-priority-locks-design.md` §5.3 addendum, row L8b. See
306    /// [`ScanIndex`].
307    scan_index: ScanIndex,
308    /// Per-record load-order sequence number, assigned monotonically
309    /// at `add_record`. Used as the secondary scan-index sort key so
310    /// same-PHAS records preserve database load order. Survives a
311    /// `remove_record` + re-`add_record` (the re-add gets a fresh,
312    /// higher sequence — matching a fresh `.db` reload).
313    ///
314    /// Read-modify-write cell (`add_loaded_record` inserts, `remove_record`
315    /// removes), so it is a [`SnapshotCell`], not a bare `ArcSwap`: the
316    /// writer gate is what makes insert-then-publish atomic. Both writers
317    /// also hold [`Self::registration_mutex`] today, but the gate keeps the
318    /// RMW correct without depending on that — L46's type changes in step 4.
319    /// `doc/rtems-priority-locks-design.md` §3 row L8c.
320    load_order: SnapshotCell<HashMap<String, u64>>,
321    /// Monotonic counter feeding `load_order`.
322    load_order_counter: std::sync::atomic::AtomicU64,
323    /// CP/CPP link index: maps source_record → target edges to process when
324    /// the source changes. Each edge carries the CP-vs-CPP distinction (see
325    /// [`CpTarget`]).
326    ///
327    /// Read-modify-write cell with **two writers that share no other gate**:
328    /// `register_cp_link` (`links.rs:2596`) takes no
329    /// [`Self::registration_mutex`], `remove_record` (`mod.rs:1533`) does. The
330    /// `RwLock`'s write exclusion was the only thing serialising them, so the
331    /// [`SnapshotCell`] writer gate here is required, not defensive.
332    /// `doc/rtems-priority-locks-design.md` §3 row L8d.
333    cp_links: SnapshotCell<HashMap<String, Vec<CpTarget>>>,
334    /// External (CA/PVA) CP/CPP link index: maps the *external PV name*
335    /// (the cross-IOC source, e.g. `OTHER:PV` from `INP="OTHER:PV CP"`)
336    /// → holder edges to process when that remote PV changes. The local
337    /// [`Self::cp_links`] index is keyed by a local source RECORD that
338    /// processes here; a cross-IOC source never processes locally, so its
339    /// only trigger is the calink/pvalink CA monitor callback, which calls
340    /// [`PvDatabase::dispatch_external_cp_targets`]. Parity with C
341    /// `dbCa.c:993-994` `eventCallback` adding `CA_DBPROCESS`.
342    ///
343    /// Read-modify-write cell; sole writer `register_external_cp_link`
344    /// (`links.rs:2647`) merges into an existing edge list, so concurrent
345    /// registrations need the [`SnapshotCell`] writer gate.
346    /// `doc/rtems-priority-locks-design.md` §3 row L8e.
347    external_cp_links: SnapshotCell<HashMap<String, Vec<CpTarget>>>,
348    /// Alias map: alternate-name → real-record-name. Mirrors epics-base
349    /// PR #336 (alias name validation + parsing). `find_entry` and
350    /// related lookups consult this map after the canonical record
351    /// table so an alias resolves transparently to its target.
352    aliases: parking_lot::RwLock<HashMap<String, String>>,
353    /// Single gate that serializes
354    /// every `add_pv` / `add_pv_with_hook` / `add_record` /
355    /// `add_alias` / `remove_record` / `remove_simple_pv` /
356    /// `remove_alias`. Without this, the per-method write-lock
357    /// orders (`simple_pvs` first vs. `records` first vs.
358    /// `aliases` first) could deadlock under concurrent registrations,
359    /// and `add_record`'s post-insert `scan_index.write()` had a
360    /// TOCTOU window where `remove_record` could land between the
361    /// records map insert and the scan-index insert and leave a
362    /// phantom scan entry.
363    ///
364    /// Holding this mutex makes the cross-namespace `check_name_free`
365    /// peek atomic with the target-map insert, eliminates the
366    /// scan-index race, and lets `remove_*` purge dangling aliases
367    /// without a second pass.
368    ///
369    /// `doc/rtems-priority-locks-design.md` §3 row L46. This gate is taken
370    /// **inside** the L1 record-gate window on the SCAN-put path
371    /// (`scan_index.rs:30`, reached from `field_io.rs`'s `update_scan_index`
372    /// calls), so it converts with L8a/L8b rather than after them — leaving it
373    /// async while the locks nested under it are blocking is the worst of both.
374    /// The acquisition-order MUST rule that governs the nesting is in
375    /// `record_lock.rs`'s module doc.
376    ///
377    /// **No holder may `.await` while holding it.** The guard is `!Send`, so
378    /// the compiler enforces this at every `tokio::spawn` site; the eight
379    /// holders were audited before the conversion and the only suspension
380    /// points any of them had were acquisitions of `simple_pvs` and
381    /// `scan_index`, both blocking now.
382    registration_mutex: crate::runtime::sync::PriorityInheritanceMutex<()>,
383    /// The IOC lifecycle phase — the port's `iocInit` boundary. See
384    /// [`DbInitPhase`], [`PvDatabase::begin_load`],
385    /// [`PvDatabase::schedule_record_init`] and [`PvDatabase::ioc_init`].
386    init_phase: std::sync::Mutex<DbInitPhase>,
387    /// Record inits parked because the record they classify is not registered
388    /// yet — see [`PvDatabase::schedule_record_init`]. Keyed by record name and
389    /// released by [`PvDatabase::add_loaded_record`] the moment that name lands
390    /// in `records`, which is what makes "the init observes a registered
391    /// record" hold by construction rather than by scheduler timing.
392    record_init_waiting: std::sync::Mutex<HashMap<String, Vec<RecordInit>>>,
393    /// Lines queued by the iocsh `afterIocRunning <command>` directive
394    /// (epics-base PR #558). Drained by the IOC application after PINI
395    /// completes, then re-executed through a fresh IocShell so the
396    /// commands run with the database in its post-init state.
397    after_ioc_running: std::sync::Mutex<Vec<String>>,
398    /// Optional resolver for external PVs (ca://, pva:// links).
399    ///
400    /// Whole-value replace: the only writer stores a complete new value
401    /// ([`PvDatabase::set_external_resolver`]), so an
402    /// [`ArcSwapOption`] store IS the mutation and no writer gate is
403    /// needed. Readers take the `Arc` with no lock at all — see
404    /// `doc/rtems-priority-locks-design.md` §3 row L8f.
405    external_resolver: ArcSwapOption<ExternalPvResolver>,
406    /// Optional async resolver invoked on `has_name` misses (e.g. CA gateway).
407    ///
408    /// Whole-value replace, as [`Self::external_resolver`] (§3 row L8g).
409    search_resolver: ArcSwapOption<SearchResolver>,
410    /// Optional per-request gate consulted before a *cached* simple PV is
411    /// advertised as existing (e.g. CA gateway host/state admission). See
412    /// [`ExistenceGate`]. `None` for a plain IOC (short-circuit unchanged).
413    ///
414    /// Whole-value replace, as [`Self::external_resolver`] (§3 row L8h).
415    existence_gate: ArcSwapOption<ExistenceGate>,
416    /// Per-scheme link sets — pluggable backends for `pva://` /
417    /// `ca://` link resolution. Consulted before the legacy
418    /// [`ExternalPvResolver`] in [`Self::resolve_external_pv`].
419    /// Mirrors the C-EPICS lset abstraction.
420    ///
421    /// Read-modify-write cell (`register_link_set` inserts one scheme into
422    /// the existing registry), so it takes the [`SnapshotCell`] writer gate.
423    /// Every reader either resolves one scheme inside a single expression or
424    /// collects the lsets and drops the registry **before** awaiting — the
425    /// deliberate discipline documented at `links.rs:878-881` — so a coherent
426    /// snapshot is what the read paths already assumed.
427    /// `doc/rtems-priority-locks-design.md` §3 row L8i.
428    link_sets: SnapshotCell<link_set::LinkSetRegistry>,
429    /// Pending external OUT-link writes — the `dbCa` `workList` analogue.
430    /// Record processing stages a write here and returns; the queue's single
431    /// owner task performs the `ca://`/`pva://` network write off the
432    /// record's advisory write gate, exactly as `dbCaTask` does
433    /// (`dbCa.c:1158-1333`). See [`link_put_queue`].
434    link_puts: Arc<link_put_queue::LinkPutQueue>,
435    /// True once the ScanScheduler has been started for this DB.
436    /// Prevents duplicate scan tasks when multiple protocol servers (CA + PVA)
437    /// both try to start scanning on the same DB.
438    scan_started: std::sync::atomic::AtomicBool,
439    /// True once PINI processing has completed. Non-owner schedulers await
440    /// this before running their hooks, preserving the "PINI before hooks"
441    /// ordering contract.
442    pini_done: std::sync::atomic::AtomicBool,
443    /// Fired by the scan owner after PINI completes. Non-owners register
444    /// interest on this before re-checking `pini_done` to avoid missing the
445    /// signal (`notify_waiters` does not store a permit).
446    pini_notify: tokio::sync::Notify,
447    /// Per-record advisory write gates — the Rust
448    /// counterpart of the C-EPICS `dbScanLock` / `dbLocker`
449    /// machinery. Every plain CA/PVA write, the QSRV atomic group
450    /// PUT/GET, and the pvalink atomic scan-on-update epoch all
451    /// acquire these gates, so no two of them can interleave on a
452    /// shared record. See [`record_lock`].
453    record_locks: record_lock::RecordLockRegistry,
454    /// Subroutine functions by name, retained at runtime so the processing
455    /// path can re-resolve an aSub's subroutine when its name changes
456    /// (C `aSubRecord.c::fetch_values` `registryFunctionFind`, LFLG=READ /
457    /// SUBL). Populated once at iocInit from the IocApp/IocBuilder registry;
458    /// read-only thereafter.
459    ///
460    /// Whole-registry replace ([`PvDatabase::install_subroutine_registry`]),
461    /// so an [`ArcSwap`] store IS the mutation and no writer gate is needed
462    /// (§3 row L8j). `OnceLock` was rejected: install is a `pub async fn` that
463    /// tests and a second `iocInit` may call again, and `OnceLock` would
464    /// silently drop the second registry instead of replacing it.
465    subroutine_registry: ArcSwap<HashMap<String, Arc<crate::server::record::SubroutineFn>>>,
466    /// Breakpoint tables by name (C `bptList`), shared by every db-load path so
467    /// `ai`/`ao` records with `LINR >= 3` resolve their linearisation table. An
468    /// `Arc` snapshot is installed on each record at creation; the master grows
469    /// (copy-on-write via [`PvDatabase::add_breaktables`]) as `dbLoadRecords`
470    /// loads more `breaktable(...)` definitions, so build-time and runtime
471    /// loads share one registry.
472    ///
473    /// Read-modify-write cell (`add_breaktables` clones the registry, inserts
474    /// and republishes), so it takes the [`SnapshotCell`] writer gate. The
475    /// value was already `Arc`-shared, so the cell replaces the outer lock
476    /// with nothing at all on the read side.
477    /// `doc/rtems-priority-locks-design.md` §3 row L8k.
478    breaktable_registry: SnapshotCell<crate::server::cvt_bpt::BreakTableRegistry>,
479}
480
481/// Database of all process variables hosted by this server.
482#[derive(Clone)]
483pub struct PvDatabase {
484    inner: Arc<PvDatabaseInner>,
485}
486
487/// A record initialisation owed to `iocInit` — the port's `init_record`
488/// tail. Built by a record's `refresh_link_status` and handed to
489/// [`PvDatabase::schedule_record_init`].
490type RecordInit = std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>;
491
492/// The IOC lifecycle phase, and with it the answer to "may a record's links be
493/// classified against the database as it stands right now?".
494///
495/// C runs `init_record` — where a record classifies its links (`checkLinks`,
496/// `dbNameToAddr`) — from `iocInit`, i.e. after EVERY `dbLoadRecords` block
497/// has been read. A forward reference across two `dbLoadRecords` calls in one
498/// `st.cmd` is therefore a LOCAL link, deterministically, and the classified
499/// value is final the moment `iocInit` returns (`dbgf` is refused before it).
500///
501/// The boundary is `iocInit`, NOT a load group: gating on the load group left
502/// the multi-`dbLoadRecords` case every real `st.cmd` uses racing 9-in-15
503/// (R18-92). So the phase here is an ioc-lifecycle state.
504///
505/// # The lifecycle is ONE-WAY: `Unloaded → Loading → Running`
506///
507/// R18-92 modelled it with two states, `Loading` and `Complete`, where
508/// `Complete` meant BOTH "never loaded" and "iocInit has run" — so `begin_load`
509/// needed a `Complete → Loading` arm to open the phase at all, and that arm ran
510/// on a post-iocInit load too. One `dbLoadRecords` typed after `iocInit` then
511/// re-armed the queue that only `ioc_init` drains, and every later
512/// classification — including every runtime `special()` link re-point — was
513/// pushed into a `Vec` nothing polls (R19-62, measured: `iocInit;
514/// dbLoadRecords(b.db); dbpf CO.INPA "9.5"` froze `CO.INAV` at 0).
515///
516/// Splitting the two meanings is what closes it: `Loading` is now produced ONLY
517/// from `Unloaded`, so no function in the crate can transition backwards out of
518/// `Running`. The one-way-ness is a property of the transitions that exist, not
519/// of a runtime check.
520enum DbInitPhase {
521    /// No load has begun. `iocInit` is owed nothing, so a classification runs
522    /// immediately — a programmatically built or unit-test database.
523    Unloaded,
524    /// Between the first `dbLoadRecords`/builder load and `iocInit`; holds the
525    /// classifications owed, in issue order. A half-built database is never
526    /// observed, because no classification code runs against one.
527    Loading(Vec<RecordInit>),
528    /// `iocInit` has run: the database is final and every link status is
529    /// classified. A classification issued now runs immediately, which is what a
530    /// runtime re-point (`special()` on a link field) needs. TERMINAL — nothing
531    /// re-opens the load phase.
532    Running,
533}
534
535/// [`PvDatabase::begin_load`] was called on a database whose `iocInit` has
536/// already run — C's `getIocState() != iocVoid` (R19-63).
537///
538/// The `Display` text is C's `errSymMsg(S_dbLib_postInitRecRegister)` verbatim
539/// (`dbStaticLib.h:269`), which is what `dbCreateRecord` prints:
540///
541/// ```text
542/// epics> dbCreateRecord(pdbbase,"ai","NEWREC")
543/// ERROR: 33554463 IOC already initialized - No new records can be added
544/// ```
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546pub struct IocAlreadyInitialized;
547
548impl std::fmt::Display for IocAlreadyInitialized {
549    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
550        f.write_str("IOC already initialized - No new records can be added")
551    }
552}
553
554impl std::error::Error for IocAlreadyInitialized {}
555
556/// Which record kind a SELM link selection is being computed for.
557/// The Specified/Mask base differs between record types in C, so the
558/// shared selector must know the caller.
559#[derive(Clone, Copy, PartialEq, Eq, Debug)]
560pub(crate) enum SelmKind {
561    /// `fanout` / `seq`: Specified index is `SELN + OFFS` (0-based over
562    /// LNK0..LNKF / group 0..15); Mask is shifted by `SHFT`.
563    /// Mirrors `fanoutRecord.c:106-141` and `seqRecord.c:147-178`.
564    FanoutSeq,
565    /// `dfanout`: Specified index is `SELN - 1` (1-based, `SELN==0`
566    /// means "drive nothing", `SELN > OUT_ARG_MAX` is invalid); Mask
567    /// has NO `SHFT` and `SELN==0` means "no output".
568    /// Mirrors `dfanoutRecord.c:307-339`.
569    Dfanout,
570}
571
572/// Result of resolving a SELM/SELN selection.
573#[derive(Clone, Debug, Default)]
574pub(crate) struct SelmResult {
575    /// 0-based link indices to drive (into the LNK0../OUTA.. array).
576    pub indices: Vec<usize>,
577    /// `Some` when C would raise an alarm for an out-of-range
578    /// `SELN`/`OFFS`/`SHFT`. C uses `recGblSetSevr(prec, SOFT_ALARM,
579    /// INVALID_ALARM)` in every such path.
580    pub alarm: Option<(u16, crate::server::record::AlarmSeverity)>,
581}
582
583/// Convert a link value to `epicsUInt16` with C `dbGetLink(.., DBR_USHORT,
584/// ..)` semantics, for the fanout/dfanout/seq `SELL`→`SELN` read — so a
585/// constant, DB, CA, or PVA link source all convert by the one rule C applies
586/// through `dbFastGetConvertRoutine`.
587///
588/// # The source type decides the rule, because in C it decides the routine
589///
590/// `dbFastGetConvertRoutine` is a 2-D table indexed by *both* the source DBF
591/// and the destination DBR (`dbConvert.c:1571-1638`): a `DBF_LONG` source
592/// reaches `getLongUshort`, a `DBF_DOUBLE` source reaches `getDoubleUshort`.
593/// They are different functions, and C gives them different semantics:
594///
595/// * **Integer source** — `(epicsUInt16)(epicsInt32)v`. Conversion of an
596///   out-of-range *integer* to an unsigned type is **defined** in C
597///   (C17 6.3.1.3p2: reduce modulo `USHRT_MAX + 1`). Every compiler and
598///   every target agrees, so this is a real contract and the port keeps it:
599///   `SELL` pointing at a `DBF_LONG` field holding `-1` gives `SELN = 65535`.
600/// * **Float source** — `(epicsUInt16)d`. Conversion of an out-of-range
601///   *float* is **undefined** (C17 6.3.1.4p1), so compiled C is not
602///   single-valued: x86-64 wraps, aarch64 saturates. What the port does about
603///   that is [`crate::types::c_cast`]'s call — the single owner of the policy —
604///   and deliberately not restated here.
605///
606/// Both rules already live in [`EpicsValue::convert_to`], the single
607/// value-coercion owner: it takes the integer view (`as_int_i64`) when the
608/// source has one and falls back to `c_cast` only for a genuine float. So this
609/// is a thin projection onto that owner, NOT a second conversion table.
610///
611/// The previous revision called `c_cast::f64_to_u16(value.to_f64())` directly,
612/// bypassing the owner — which silently applied the float rule to integer
613/// sources too, losing the one wrap C actually defines.
614pub(crate) fn dbr_ushort_cast(value: &EpicsValue) -> u16 {
615    match value.convert_to(crate::types::DbFieldType::UShort) {
616        EpicsValue::UShort(v) => v,
617        // A link that delivers an array converts element-wise; C's
618        // `dbGetLink(.., &prec->seln, 0, 0)` requests ONE element, so SELN
619        // takes the first (an empty array leaves it 0).
620        EpicsValue::UShortArray(v) => v.first().copied().unwrap_or(0),
621        // `convert_to(UShort)` returns no other variant.
622        _ => 0,
623    }
624}
625
626/// Select which link indices are active based on SELM/SELN, applying
627/// the record-type-specific `OFFS`/`SHFT` bias.
628///
629/// SELM: 0 = All, 1 = Specified, 2 = Mask. `count` is the number of
630/// link slots (16 for fanout/dfanout/seq).
631///
632/// `seln` is the native `DBF_USHORT` value: C declares `SELN` as
633/// `epicsUInt16`, so every comparison below is unsigned, matching C's
634/// selection arithmetic — never `-1`. What an out-of-range `SELL` converts
635/// *to* is [`dbr_ushort_cast`]'s decision, not this function's.
636///
637/// C references:
638/// * fanout — `fanoutRecord.c:106-141`
639/// * dfanout — `dfanoutRecord.c:307-339`
640/// * seq — `seqRecord.c:147-178`
641pub(crate) fn select_link_indices_ex(
642    kind: SelmKind,
643    selm: i16,
644    seln: u16,
645    offs: i16,
646    shft: i16,
647    count: usize,
648) -> SelmResult {
649    use crate::server::recgbl::alarm_status::SOFT_ALARM;
650    use crate::server::record::AlarmSeverity;
651
652    let invalid = || SelmResult {
653        indices: Vec::new(),
654        alarm: Some((SOFT_ALARM, AlarmSeverity::Invalid)),
655    };
656    let ok = |indices: Vec<usize>| SelmResult {
657        indices,
658        alarm: None,
659    };
660
661    match selm {
662        // All — every slot.
663        0 => ok((0..count).collect()),
664        // Specified.
665        1 => match kind {
666            SelmKind::FanoutSeq => {
667                // C: `i = seln + offs;` with `seln` unsigned (epicsUInt16),
668                // 0-based; `i<0 || i>=NLINKS` → INVALID. So `SELN=65535`
669                // (from `SELL=-1`) yields `i>=NLINKS` → INVALID, never
670                // drives link 0.
671                let i = seln as i32 + offs as i32;
672                if i < 0 || i >= count as i32 {
673                    invalid()
674                } else {
675                    ok(vec![i as usize])
676                }
677            }
678            SelmKind::Dfanout => {
679                // C `dfanoutRecord.c:315-320`: `if (prec->seln > OUT_ARG_MAX)`
680                // with `seln` unsigned → INVALID; `seln == 0` → no output;
681                // otherwise drive `seln - 1`. OFFS is not a dfanout field.
682                // `SELL=-1` → `SELN=65535` > count → INVALID (the signed
683                // read used to see `-1`, take the `<= 0` branch, and drive
684                // nothing with no alarm).
685                let seln_i = seln as i32;
686                if seln_i > count as i32 {
687                    invalid()
688                } else if seln_i == 0 {
689                    ok(Vec::new())
690                } else {
691                    ok(vec![(seln_i - 1) as usize])
692                }
693            }
694        },
695        // Mask.
696        2 => {
697            let mask: u32 = match kind {
698                SelmKind::FanoutSeq => {
699                    // C: SHFT shift first, with `shft` range-checked to [-15,15].
700                    if !(-15..=15).contains(&shft) {
701                        return invalid();
702                    }
703                    let raw = seln as u32;
704                    if shft >= 0 {
705                        raw >> shft
706                    } else {
707                        raw << (-shft)
708                    }
709                }
710                // dfanout Mask has no SHFT.
711                SelmKind::Dfanout => seln as u32,
712            };
713            ok((0..count).filter(|i| mask & (1 << i) != 0).collect())
714        }
715        // Any other SELM value → C `default:` raises INVALID.
716        _ => invalid(),
717    }
718}
719
720impl PvDatabase {
721    pub fn new() -> Self {
722        Self {
723            inner: Arc::new(PvDatabaseInner {
724                simple_pvs: crate::runtime::sync::PriorityInheritanceMutex::new(HashMap::new()),
725                external_resolver: ArcSwapOption::empty(),
726                search_resolver: ArcSwapOption::empty(),
727                existence_gate: ArcSwapOption::empty(),
728                link_sets: SnapshotCell::new(link_set::LinkSetRegistry::new()),
729                link_puts: Arc::new(link_put_queue::LinkPutQueue::default()),
730                records: parking_lot::RwLock::new(HashMap::new()),
731                scan_index: ScanIndex::new(),
732                load_order: SnapshotCell::new(HashMap::new()),
733                load_order_counter: std::sync::atomic::AtomicU64::new(0),
734                cp_links: SnapshotCell::new(HashMap::new()),
735                external_cp_links: SnapshotCell::new(HashMap::new()),
736                aliases: parking_lot::RwLock::new(HashMap::new()),
737                registration_mutex: crate::runtime::sync::PriorityInheritanceMutex::new(()),
738                init_phase: std::sync::Mutex::new(DbInitPhase::Unloaded),
739                record_init_waiting: std::sync::Mutex::new(HashMap::new()),
740                after_ioc_running: std::sync::Mutex::new(Vec::new()),
741                scan_started: std::sync::atomic::AtomicBool::new(false),
742                pini_done: std::sync::atomic::AtomicBool::new(false),
743                pini_notify: tokio::sync::Notify::new(),
744                record_locks: record_lock::RecordLockRegistry::default(),
745                subroutine_registry: ArcSwap::from_pointee(HashMap::new()),
746                breaktable_registry: SnapshotCell::new(
747                    crate::server::cvt_bpt::BreakTableRegistry::new(),
748                ),
749            }),
750        }
751    }
752
753    /// Merge `tables` into the shared breakpoint-table registry (C `bptList`
754    /// accumulation across `dbLoadDatabase`/`dbLoadRecords`) and return the new
755    /// snapshot. Copy-on-write: a new merged registry replaces the old one.
756    ///
757    /// `add_breaktables` is the single registry-mutation owner, so it also
758    /// restores the invariant *every record can resolve against the current
759    /// registry* on mutation: the new snapshot is re-installed into every
760    /// existing record. That covers a record created before its table was
761    /// loaded (an inline record added before `dbLoadRecords`, or a merge-reload
762    /// that repoints `LINR` to a table loaded in the same command) — neither
763    /// of which goes back through `add_record`'s install. `install_*` is a
764    /// no-op for non-ai/ao records and resets the cached table so the new
765    /// registry wins. Returns the current snapshot unchanged when `tables` is
766    /// empty (no mutation, so no re-install).
767    pub async fn add_breaktables(
768        &self,
769        tables: Vec<crate::server::cvt_bpt::BrkTable>,
770    ) -> Arc<crate::server::cvt_bpt::BreakTableRegistry> {
771        // Hold the registration gate across the registry write AND the record
772        // snapshot below so this mutation cannot interleave with `add_record`'s
773        // [registry read -> records-map insert] — both are gated by the same
774        // mutex. Without it a record created concurrently could read the
775        // pre-mutation registry (miss the just-loaded table) while not yet
776        // being in the records map for the re-install below, leaving a
777        // table-not-found alarm until the next load / LINR put. `add_record`
778        // holds this gate across its whole body (registry read + map insert),
779        // so taking it here closes that TOCTOU window. No `add_breaktables`
780        // caller already holds the gate, so this is reentrancy-safe.
781        let _gate = self.inner.registration_mutex.lock();
782        if tables.is_empty() {
783            return self.inner.breaktable_registry.load_full();
784        }
785        let snapshot = self.inner.breaktable_registry.update(|next| {
786            for table in tables {
787                next.insert(table);
788            }
789        });
790        // Re-install into existing records. Snapshot the instance handles
791        // under a brief read, then release the map lock BEFORE taking any
792        // per-record write lock — collect-then-act, keeping the invariant
793        // "never hold the records-map lock across a per-record lock" uniform
794        // across the codebase (a7f5a74f). This is defensive: no current path
795        // takes the per-record lock then the records-map lock, so there is no
796        // confirmed cycle; uniform order forecloses one. Same idiom as
797        // `all_record_names`. (The registry write lock was released above.)
798        let instances: Vec<_> = self.inner.records.read().values().cloned().collect();
799        for inst in instances {
800            inst.write()
801                .record
802                .install_breaktable_registry(snapshot.clone());
803        }
804        snapshot
805    }
806
807    /// Install the by-name subroutine registry, retained for runtime
808    /// re-resolution (aSub LFLG=READ / SUBL). Called once at iocInit with the
809    /// IocApp/IocBuilder registry. See `Self::find_subroutine_named`.
810    pub async fn install_subroutine_registry(
811        &self,
812        registry: HashMap<String, Arc<crate::server::record::SubroutineFn>>,
813    ) {
814        self.inner.subroutine_registry.store(Arc::new(registry));
815    }
816
817    /// Look up a registered subroutine by name. The processing path uses this
818    /// to re-resolve an aSub's subroutine when SNAM changes (C `fetch_values`
819    /// `registryFunctionFind`). `None` when the name is not registered, which
820    /// the caller treats as C's `S_db_BadSub` (skip running the subroutine).
821    pub(crate) fn find_subroutine_named(
822        &self,
823        name: &str,
824    ) -> Option<Arc<crate::server::record::SubroutineFn>> {
825        self.inner.subroutine_registry.load().get(name).cloned()
826    }
827
828    /// Atomically claim the right to start the scan scheduler for this DB.
829    /// Returns `true` on the first call, `false` on subsequent calls.
830    /// Used by `ScanScheduler::run` to prevent duplicate scan tasks
831    /// when multiple protocol servers (CA + PVA) both try to start scanning.
832    pub fn try_claim_scan_start(&self) -> bool {
833        self.inner
834            .scan_started
835            .compare_exchange(
836                false,
837                true,
838                std::sync::atomic::Ordering::AcqRel,
839                std::sync::atomic::Ordering::Acquire,
840            )
841            .is_ok()
842    }
843
844    /// Mark PINI processing complete. Wakes any non-owner scan schedulers
845    /// that were waiting before running their hooks.
846    pub fn mark_pini_done(&self) {
847        self.inner
848            .pini_done
849            .store(true, std::sync::atomic::Ordering::Release);
850        self.inner.pini_notify.notify_waiters();
851    }
852
853    /// True once the PINI=YES pass has completed for this database —
854    /// published by [`Self::mark_pini_done`]. The scan owner reads this
855    /// to keep the pass exactly-once (C `initialProcess`, iocInit.c:653
856    /// runs once, inside iocBuild): when the IOC init path already ran
857    /// PINI, the owner skips its own pass instead of re-processing every
858    /// PINI record.
859    pub fn pini_done(&self) -> bool {
860        self.inner
861            .pini_done
862            .load(std::sync::atomic::Ordering::Acquire)
863    }
864
865    /// Wait until the scan owner has completed PINI processing.
866    /// Returns immediately if PINI has already completed.
867    pub async fn wait_for_pini(&self) {
868        if self
869            .inner
870            .pini_done
871            .load(std::sync::atomic::Ordering::Acquire)
872        {
873            return;
874        }
875        // Register interest BEFORE re-checking the flag to avoid missing a
876        // signal that arrives between the load and the await — `notify_waiters`
877        // does not store a permit for late subscribers.
878        let notified = self.inner.pini_notify.notified();
879        if self
880            .inner
881            .pini_done
882            .load(std::sync::atomic::Ordering::Acquire)
883        {
884            return;
885        }
886        notified.await;
887    }
888
889    /// Install an async resolver invoked when [`PvDatabase::has_name`]
890    /// fails to find a name. Used by proxy/gateway implementations to
891    /// lazily populate PVs on first search.
892    pub async fn set_search_resolver(&self, resolver: SearchResolver) {
893        self.inner.search_resolver.store(Some(Arc::new(resolver)));
894    }
895
896    /// Remove the previously installed search resolver, if any.
897    pub async fn clear_search_resolver(&self) {
898        self.inner.search_resolver.store(None);
899    }
900
901    /// Install the per-request existence gate (see [`ExistenceGate`]).
902    /// Replaces any previously installed gate. Used by the CA gateway so
903    /// a cached shadow PV re-runs host/state admission per request.
904    pub async fn set_existence_gate(&self, gate: ExistenceGate) {
905        self.inner.existence_gate.store(Some(Arc::new(gate)));
906    }
907
908    /// Remove the previously installed existence gate, if any.
909    pub async fn clear_existence_gate(&self) {
910        self.inner.existence_gate.store(None);
911    }
912
913    /// True when a cached simple PV named `name` must be treated as
914    /// non-existent for `peer` because the installed [`ExistenceGate`]
915    /// denied it. Always `false` when no gate is installed (a plain IOC)
916    /// or when `name` does not resolve to a simple PV — records and
917    /// aliases are never gateway-managed and bypass the gate.
918    ///
919    /// The single consultation point for the gate, shared by
920    /// [`Self::find_entry_from`] and [`Self::has_name_from`] so the
921    /// "cached simple PV ⇒ exists" short-circuit is closed uniformly on
922    /// both the create and search paths.
923    async fn simple_pv_gate_denies(&self, name: &str, peer: Option<std::net::SocketAddr>) -> bool {
924        let Some(gate) = self.inner.existence_gate.load_full() else {
925            return false;
926        };
927        let gate = (*gate).clone();
928        // Strip the channel-filter suffix exactly as the lookups do
929        // (CA-FR-8) so the gate sees the same record-path key the
930        // simple-PV map and the gateway cache are keyed on.
931        let record_path = filters::split_channel_name(name).record_path;
932        // Own statement: the `!Send` guard must be down before the gate's
933        // `.await` below (see the `simple_pvs` field doc).
934        let known = self
935            .inner
936            .simple_pvs
937            .lock()
938            .contains_key(record_path.as_str());
939        if !known {
940            return false;
941        }
942        !gate(record_path, peer).await
943    }
944
945    /// Set an external PV resolver for CA/PVA link resolution.
946    /// The resolver is called synchronously from link reads.
947    pub async fn set_external_resolver(&self, resolver: ExternalPvResolver) {
948        self.inner.external_resolver.store(Some(Arc::new(resolver)));
949    }
950
951    /// Register a [`LinkSet`] under `scheme` (e.g. `"pva"` /
952    /// `"ca"`). The lset is consulted for `ParsedLink::Pva` /
953    /// `ParsedLink::Ca` link reads/writes before falling back to
954    /// the legacy [`ExternalPvResolver`]. Subsequent calls for the
955    /// same scheme replace the previous binding.
956    pub async fn register_link_set(&self, scheme: &str, lset: link_set::DynLinkSet) {
957        self.inner.link_sets.update(|r| r.register(scheme, lset));
958    }
959
960    /// Look up the lset for `scheme`, if any.
961    pub async fn link_set(&self, scheme: &str) -> Option<link_set::DynLinkSet> {
962        self.inner.link_sets.load().get(scheme)
963    }
964
965    /// Snapshot of every registered scheme name. Stable order for
966    /// `dbpvxr` dumps.
967    pub async fn registered_link_schemes(&self) -> Vec<String> {
968        let mut s = self.inner.link_sets.load().schemes();
969        s.sort();
970        s
971    }
972
973    /// Wait for the CA links to local records to report
974    /// `is_connected() == true`. Mirrors `dbCa: iocInit wait for local CA
975    /// links to connect` (epics-base PR #768/#856). The working set is
976    /// exactly `Self::external_link_targets`: only the CA facility's
977    /// local-target links — `pva://` links and non-local CA links connect
978    /// in the background and are never waited on (pvxs parity).
979    ///
980    /// Polls every 100 ms. Returns:
981    /// * `Ok(connected_count)` — the number of links that ended up
982    ///   connected. May be smaller than the total when the timeout
983    ///   expired before everyone was ready.
984    /// * The total link count — i.e. the size of the working set
985    ///   that was checked. `(connected, total)` lets the caller log
986    ///   "M/N CA links connected".
987    ///
988    /// Pure no-op when no CA link set is registered, or when its
989    /// `link_names()` has no local-target link yet (e.g. lazy-open lsets
990    /// that haven't observed any record link — record processing creates
991    /// the entries on first read, after iocInit returns).
992    pub async fn wait_for_external_links(&self, timeout: std::time::Duration) -> (usize, usize) {
993        // Collect (lset, name) pairs once. `link_names()` may grow
994        // as record processing opens new links, but iocInit's wait
995        // is bounded by the records loaded *before* Phase 3 — every
996        // such link is already opened by the time wire_device_support
997        // and setup_cp_links return.
998        let targets = self.external_link_targets().await;
999        let total = targets.len();
1000        if total == 0 {
1001            return (0, 0);
1002        }
1003        // `std::time::Instant`, not `crate::runtime::task::Instant`: the sleep below
1004        // is the runtime seam, whose clock is std's on both backends. Reading
1005        // the deadline off tokio's clock made the two disagree — under
1006        // `start_paused` tokio's clock advances only when the runtime decides
1007        // to, while the RTEMS backend's timer thread follows the real one, so
1008        // the loop could sleep against one clock and expire against another.
1009        let deadline = std::time::Instant::now() + timeout;
1010        loop {
1011            let mut connected = 0usize;
1012            for (lset, name) in &targets {
1013                if lset.is_connected(name) {
1014                    connected += 1;
1015                }
1016            }
1017            if connected == total {
1018                return (connected, total);
1019            }
1020            if std::time::Instant::now() >= deadline {
1021                return (connected, total);
1022            }
1023            crate::runtime::task::sleep(std::time::Duration::from_millis(100)).await;
1024        }
1025    }
1026
1027    /// Snapshot the `(lset, link_name)` pairs the iocInit external-link
1028    /// wait reasons over. Shared by [`Self::wait_for_external_links`] and
1029    /// [`Self::unconnected_external_links`] so both see the identical
1030    /// working set.
1031    ///
1032    /// C parity: the iocInit connection-wait is a property of the CA link
1033    /// facility (dbCa) alone. `dbCaRun` (dbCa.c:370-380) blocks on
1034    /// `initOutstanding`, the count of CA links flagged
1035    /// `DBCA_CALLBACK_INIT_WAIT` — set only for a CA link whose target is
1036    /// a LOCAL record (dbLink.c:128-130):
1037    ///   int isLocal = dbChannelTest(pvname) == 0;
1038    ///   dbCaAddLinkCallbackOpt(..., isLocal ? DBCA_CALLBACK_INIT_WAIT : 0)
1039    /// No other external facility waits: pvxs pvalink's `linkGlobal_t::init`
1040    /// (ioc/pvalink.cpp) only calls `chan->open()` per channel — it opens
1041    /// in the background and never blocks iocInit. So the wait targets
1042    /// exactly the CA link set's local-target links; a non-local CA link
1043    /// (e.g. areaDetector's `ShutterStatusEPICS_RBV.INP = "test CP MS"`
1044    /// placeholder) and every `pva://` link connect asynchronously and are
1045    /// never held by iocInit, like C.
1046    async fn external_link_targets(&self) -> Vec<(link_set::DynLinkSet, String)> {
1047        // Only the CA facility participates — look it up directly rather
1048        // than iterating every registered scheme. `has_name_no_resolve`
1049        // is the `dbChannelTest` twin (target is a local record).
1050        let Some(ca_lset) = self.inner.link_sets.load().get("ca") else {
1051            return Vec::new();
1052        };
1053        let mut targets: Vec<(link_set::DynLinkSet, String)> = Vec::new();
1054        for n in ca_lset.link_names() {
1055            if self.has_name_no_resolve(&n) {
1056                targets.push((ca_lset.clone(), n));
1057            }
1058        }
1059        targets
1060    }
1061
1062    /// Names of the waited-on CA links (local-target, per
1063    /// `Self::external_link_targets`) that are opened but not yet
1064    /// connected. iocInit calls this after
1065    /// [`Self::wait_for_external_links`] times out so the
1066    /// "M/N connected" diagnostic can name the `N-M` it proceeded
1067    /// without, instead of leaving the operator to run `dbcar`.
1068    /// `pva://` links are not in this set — they never block iocInit.
1069    pub async fn unconnected_external_links(&self) -> Vec<String> {
1070        let mut names = Vec::new();
1071        for (lset, name) in self.external_link_targets().await {
1072            if !lset.is_connected(&name) {
1073                names.push(name);
1074            }
1075        }
1076        names
1077    }
1078
1079    /// Enumerate every link-shaped field on `record_name`. Returns
1080    /// `(field_name, link_string, parsed)` tuples for fields whose
1081    /// raw value parses as a non-trivial link via
1082    /// [`crate::server::record::parse_link_v2`]. Used by `dbpvxr` to
1083    /// dump per-record link state without hardcoding the field-name
1084    /// list — works across record types as long as they expose link
1085    /// strings via [`Record::get_field`].
1086    ///
1087    /// `parsed` is the **post-`dbInitLink`** view, not the bare parse: each
1088    /// link is mapped through [`PvDatabase::db_init_link_locality`], so a
1089    /// `Db` link naming a record this IOC does not have is reported as the
1090    /// `Ca` link C's `dbDbInitLink` → `dbCaAddLink` fallthrough makes it
1091    /// (`dbLink.c:117-129`, `dbDbLink.c:94-96`). Every consumer — the CP
1092    /// setup, the init open pass, `dbcaxr`, the pvalink install scan — then
1093    /// sees one consistent answer to "is this link local or external"
1094    /// instead of each re-deriving it. `link_string` is still the verbatim
1095    /// field text.
1096    ///
1097    /// Returns an empty Vec when the record doesn't exist.
1098    pub fn record_link_fields(
1099        &self,
1100        record_name: &str,
1101    ) -> Vec<(String, String, crate::server::record::ParsedLink)> {
1102        let rec = match self.get_record(record_name) {
1103            Some(r) => r,
1104            None => return Vec::new(),
1105        };
1106        let inst = rec.read();
1107        let mut out = Vec::new();
1108        // Each field is parsed for ITS OWN link-field type: C `dbPutFieldLink`
1109        // passes `pfldDes->field_type` to `dbParseLink` (`dbAccess.c:1094`),
1110        // which then masks the modifiers by that type (`dbStaticLib.c:2380-2391`).
1111        // `OUT` is `DBF_OUTLINK`, so its CP/CPP is discarded here rather than
1112        // reaching `setup_cp_links` — an `OUT` link must never be registered as
1113        // a CP holder.
1114        let push = |field: &str,
1115                    raw: &str,
1116                    ftype: crate::server::record::LinkFieldType,
1117                    out: &mut Vec<_>| {
1118            if raw.is_empty() {
1119                return;
1120            }
1121            let parsed = crate::server::record::parse_link_field(raw, ftype);
1122            if !matches!(parsed, crate::server::record::ParsedLink::None) {
1123                out.push((field.to_string(), raw.to_string(), parsed));
1124            }
1125        };
1126        use crate::server::record::LinkFieldType;
1127        // Canonical link-bearing fields stored on `CommonFields` as raw
1128        // String. These do NOT appear as `DbFieldType::String` entries in
1129        // `field_list()`: an `ai`'s `INP` / an `ao`'s `OUT` carry
1130        // `DBF_INLINK` / `DBF_OUTLINK` descriptors (and `INP`/`OUT` are
1131        // not in the record's static field table at all), so the previous
1132        // `field_list()` scan filtered by `String` silently dropped every
1133        // device-support link — the holder's pvalink monitor was never
1134        // opened. Enumerate the canonical storage directly so this method
1135        // is the single owner of "which fields on a record are links",
1136        // shared by `setup_cp_links` (CA CP/CPP) and the pvalink install
1137        // scan (PVA CP/CPP).
1138        //
1139        // The field list itself comes from `COMMON_LINK_FIELDS`, the one
1140        // owner of "which `dbCommon` fields are links and under which C link
1141        // type" — `FLNK` present here but absent from that list (or vice
1142        // versa) is exactly the divergence that left external forward links
1143        // un-opened at init.
1144        for (field, ftype) in crate::server::record::record_instance::COMMON_LINK_FIELDS {
1145            let Some(raw) = inst.common_link_text(field) else {
1146                continue;
1147            };
1148            push(field, raw, ftype, &mut out);
1149        }
1150        // Record-specific multi-input links (INPA..INPL for
1151        // calc/calcout/sel/sub) and the CP-capable input link fields
1152        // (DOL family, NVL, SELL, SGNL).
1153        let mut field_names: Vec<&str> = inst
1154            .record
1155            .multi_input_links()
1156            .iter()
1157            .map(|(lf, _vf)| *lf)
1158            .collect();
1159        field_names.extend_from_slice(crate::server::database::links::CP_INPUT_LINK_FIELDS);
1160        for field in field_names {
1161            if let Some(EpicsValue::String(s)) = inst.record.get_field(field) {
1162                push(field, &s.as_str_lossy(), LinkFieldType::In, &mut out);
1163            }
1164        }
1165        // Apply C `dbInitLink`'s locality fallthrough once, here, so no
1166        // consumer re-derives it. Done after dropping the record-instance
1167        // guard: the locality query reads the database's record map, and
1168        // this is the only place that would otherwise hold an instance lock
1169        // across it.
1170        drop(inst);
1171        for entry in &mut out {
1172            entry.2 = self.db_init_link_locality(std::mem::replace(
1173                &mut entry.2,
1174                crate::server::record::ParsedLink::None,
1175            ));
1176        }
1177        out
1178    }
1179
1180    /// Resolve an external PV name. Dispatches through the
1181    /// `(scheme, name)` lset if one is registered; otherwise falls
1182    /// back to the legacy [`ExternalPvResolver`] closure. `name`
1183    /// may be the bare PV name (in which case `pva://` is assumed
1184    /// when an lset is registered for that scheme) or a fully
1185    /// scheme-prefixed string.
1186    ///
1187    /// # Cached read, then a staged open — C `dbCaGetLink`
1188    ///
1189    /// This is the record-processing read, so it reads the lset's
1190    /// monitor-fed cache ([`LinkSet::get_cached_value`]) and never the
1191    /// network: C `dbCaGetLink` (`dbCa.c:448-535`) copies out of
1192    /// `pca->pgetNative`, which the CA monitor callback keeps fresh on the
1193    /// `dbCaTask`, and returns -1 while the link is down (`dbCa.c:459-464`).
1194    ///
1195    /// A miss stages the link's OPEN on the same work queue the OUT writes
1196    /// use — C `dbCaAddLink`'s `CA_CONNECT` (`dbCa.c:735-800`) — and returns
1197    /// `None` for this cycle. C's open happens at record init rather than at
1198    /// first read, but in both designs the connect runs on the link task and
1199    /// the reading record takes LINK/INVALID until the cache is warm.
1200    pub(crate) fn resolve_external_pv(&self, name: &str) -> Option<EpicsValue> {
1201        // Try lsets first. We accept both "scheme://body" and the
1202        // bare body (stored in ParsedLink::Pva/Ca after the
1203        // dispatch in record/link.rs). `Any` tries every registered
1204        // lset in turn; the first one with a cached value wins.
1205        let (target, body) = Self::split_external_link_name(name);
1206        for lset in link_put_queue::resolve_lsets(&self.inner, &target) {
1207            if let Some(v) = lset.get_cached_value(body) {
1208                return Some(v);
1209            }
1210        }
1211        self.stage_external_link_open_by_name(name);
1212        // Fall through to legacy resolver, which is addressed with the
1213        // caller's string verbatim (scheme prefix and all).
1214        let resolver = self
1215            .inner
1216            .external_resolver
1217            .load_full()
1218            .map(|r| (*r).clone());
1219        match resolver {
1220            Some(r) => r(name),
1221            None => None,
1222        }
1223    }
1224
1225    /// Split an external link's boundary name
1226    /// ([`crate::server::record::ParsedLink::external_pv_name`]) into the
1227    /// work-queue target plus the name the lset is addressed with.
1228    ///
1229    /// Single owner of the `ca://` / `pva://` prefix convention: the
1230    /// cache-miss stage in [`Self::resolve_external_pv`] and the iocInit
1231    /// open pass ([`PvDatabase::setup_external_link_opens`]) must derive
1232    /// the same [`link_put_queue::LinkKey`] from the same link, or the
1233    /// queue's once-per-link open would fire twice under two spellings.
1234    fn split_external_link_name(name: &str) -> (link_put_queue::LinkTarget, &str) {
1235        if let Some(rest) = name.strip_prefix("pva://") {
1236            (link_put_queue::LinkTarget::Scheme("pva".to_string()), rest)
1237        } else if let Some(rest) = name.strip_prefix("ca://") {
1238            (link_put_queue::LinkTarget::Scheme("ca".to_string()), rest)
1239        } else {
1240            (link_put_queue::LinkTarget::Any, name)
1241        }
1242    }
1243
1244    /// Stage the open of the external link named `name` — the boundary
1245    /// form of [`Self::stage_external_link_open`], which splits the
1246    /// scheme prefix and applies the "an lset must exist" gate.
1247    ///
1248    /// The gate matters because the queue's open state is terminal: an
1249    /// open staged while no lset is registered would be serviced against
1250    /// an empty lset list and marked `Done`, burning the link's one and
1251    /// only connect. Returns true when this call is the one that staged
1252    /// it (false when already staged, or when no lset addresses it).
1253    pub(crate) fn stage_external_link_open_by_name(&self, name: &str) -> bool {
1254        let (target, body) = Self::split_external_link_name(name);
1255        if link_put_queue::resolve_lsets(&self.inner, &target).is_empty() {
1256            return false;
1257        }
1258        self.stage_external_link_open(target, body)
1259    }
1260
1261    /// Single owner of the "this external link needs opening" transition —
1262    /// C `dbCaAddLink`'s `addAction(pca, CA_CONNECT)` (`dbCa.c:735-800`).
1263    ///
1264    /// Every caller routes through here so the open runs on the link work
1265    /// owner and nowhere else; no path may call
1266    /// [`link_set::LinkSet::connect_link`] directly from a record-processing
1267    /// thread. Cheap and idempotent: the queue drops a repeat stage for a
1268    /// link it has already opened.
1269    ///
1270    /// Private to the `database` module, and reached only through
1271    /// [`Self::stage_external_link_open_by_name`], so no caller can skip the
1272    /// scheme split or the lset gate and mint a `LinkKey` of its own shape.
1273    fn stage_external_link_open(&self, target: link_put_queue::LinkTarget, name: &str) -> bool {
1274        self.inner
1275            .link_puts
1276            .ensure_owner(std::sync::Arc::downgrade(&self.inner));
1277        self.inner.link_puts.stage_open(link_put_queue::LinkKey {
1278            target,
1279            name: name.to_string(),
1280        })
1281    }
1282
1283    /// Number of external-link opens the work owner has completed —
1284    /// diagnostic twin of [`Self::external_link_puts_completed`].
1285    pub fn external_link_opens_completed(&self) -> u64 {
1286        self.inner.link_puts.opened_count()
1287    }
1288
1289    /// Add a simple PV with an initial value.
1290    ///
1291    /// Returns `Err` when `name` is already registered as a simple PV,
1292    /// a record, or an alias — mirroring epics-base C IOC which treats
1293    /// duplicate `dbLoadRecords` names as a fatal error. Callers that
1294    /// want replace-on-overwrite semantics must first call
1295    /// `remove_simple_pv` / `remove_record` / `remove_alias`.
1296    ///
1297    /// Serialized through `registration_mutex` so the
1298    /// cross-namespace check is atomic with the insert and the lock
1299    /// order across all add_*/remove_* methods is identical (no
1300    /// cross-namespace deadlock).
1301    pub async fn add_pv(&self, name: &str, initial: EpicsValue) -> CaResult<()> {
1302        let _gate = self.inner.registration_mutex.lock();
1303        self.check_name_free(name)?;
1304        let pv = Arc::new(ProcessVariable::new(name.to_string(), initial));
1305        self.inner.simple_pvs.lock().insert(name.to_string(), pv);
1306        Ok(())
1307    }
1308
1309    /// Add a simple PV that already has a [`crate::server::pv::WriteHook`] installed.
1310    ///
1311    /// Equivalent to `add_pv` followed by `find_pv` + `set_write_hook`,
1312    /// but the PV is constructed with the hook in place so it is
1313    /// inserted into the `simple_pvs` map ATOMICALLY with the hook
1314    /// already attached. Closes a small race in proxy/gateway code
1315    /// where a downstream client could (in principle) `CREATE_CHAN` +
1316    /// `WRITE_NOTIFY` between the two awaits and hit the local
1317    /// `pv.set()` fallback path before the hook landed.
1318    ///
1319    /// Returns `Err` on duplicate name (see [`Self::add_pv`]).
1320    pub async fn add_pv_with_hook(
1321        &self,
1322        name: &str,
1323        initial: EpicsValue,
1324        hook: crate::server::pv::WriteHook,
1325    ) -> CaResult<()> {
1326        self.add_pv_with_hooks(name, initial, hook, None).await
1327    }
1328
1329    /// like [`Self::add_pv_with_hook`] but also installs an
1330    /// optional [`AccessHook`](crate::server::pv::AccessHook) so the CA
1331    /// gateway can route this shadow PV's read/write access-rights
1332    /// decision through its own ACF. Both hooks are attached before the
1333    /// PV is inserted into `simple_pvs`, so a downstream `CREATE_CHAN`
1334    /// cannot observe the PV without its access hook bound.
1335    pub async fn add_pv_with_hooks(
1336        &self,
1337        name: &str,
1338        initial: EpicsValue,
1339        write_hook: crate::server::pv::WriteHook,
1340        access_hook: Option<crate::server::pv::AccessHook>,
1341    ) -> CaResult<()> {
1342        self.add_pv_with_hooks_full(name, initial, write_hook, access_hook, None)
1343            .await
1344    }
1345
1346    /// like [`Self::add_pv_with_hooks`] but also installs an optional
1347    /// [`ReadHook`](crate::server::pv::ReadHook) so a proxy (the CA
1348    /// gateway in no-cache mode) can serve each downstream GET from a
1349    /// fresh upstream fetch instead of the stored value. All three hooks
1350    /// are attached before the PV is inserted into `simple_pvs`, so a
1351    /// downstream `CREATE_CHAN` cannot observe the PV without its hooks
1352    /// bound — the read hook lands atomically with registration, closing
1353    /// the same race the write/access hooks already close. `read_hook:
1354    /// None` is identical to [`Self::add_pv_with_hooks`].
1355    pub async fn add_pv_with_hooks_full(
1356        &self,
1357        name: &str,
1358        initial: EpicsValue,
1359        write_hook: crate::server::pv::WriteHook,
1360        access_hook: Option<crate::server::pv::AccessHook>,
1361        read_hook: Option<crate::server::pv::ReadHook>,
1362    ) -> CaResult<()> {
1363        let _gate = self.inner.registration_mutex.lock();
1364        self.check_name_free(name)?;
1365        let pv = Arc::new(ProcessVariable::new(name.to_string(), initial));
1366        pv.set_write_hook(write_hook);
1367        if let Some(access) = access_hook {
1368            pv.set_access_hook(access);
1369        }
1370        if let Some(read) = read_hook {
1371            pv.set_read_hook(read);
1372        }
1373        self.inner.simple_pvs.lock().insert(name.to_string(), pv);
1374        Ok(())
1375    }
1376
1377    /// Remove a simple PV by name. Returns `Some(pv)` if a PV was
1378    /// removed. Used by the gateway sweep so an evicted upstream
1379    /// subscription doesn't leave a stale shadow PV (with a now-dead
1380    /// `WriteHook` capturing an aborted upstream channel).
1381    ///
1382    /// Also purges any aliases that pointed AT this name
1383    /// (otherwise a re-add of the same alias name would fail with
1384    /// "already registered as an alias" even though its target is
1385    /// gone).
1386    pub async fn remove_simple_pv(&self, name: &str) -> Option<Arc<ProcessVariable>> {
1387        let _gate = self.inner.registration_mutex.lock();
1388        // Simple PVs cannot be alias targets (aliases point at
1389        // records), but a stale alias whose name MATCHES this PV
1390        // would have been rejected at add_alias time. No alias
1391        // cleanup needed for simple-PV removal.
1392        self.inner.simple_pvs.lock().remove(name)
1393    }
1394
1395    /// Enter the LOAD phase: records are being created and the database is not
1396    /// yet the one C would classify links against. Called by every path that
1397    /// begins creating records for an IOC — an `IocBuilder` build, an iocsh
1398    /// `dbLoadRecords` / `dbCreateRecord`, `IocApp::run` — and idempotent within
1399    /// the phase, because an `st.cmd` issues several loads and they are all one
1400    /// `iocInit` (R18-92).
1401    ///
1402    /// # Refused once the IOC is running (R19-63)
1403    ///
1404    /// C admits no record creation after `iocInit`: `dbReadCOM`
1405    /// (`dbLexRoutines.c:236`) fails every `.db`/`.dbd` read with `-2` once
1406    /// `getIocState() != iocVoid`, and `dbCreateRecordCallFunc`
1407    /// (`dbStaticIocRegister.c:288`) fails with `S_dbLib_postInitRecRegister`.
1408    /// Asking to create records IS asking to enter the load phase, so the answer
1409    /// lives here and is a `Result` the caller cannot ignore — a creator that
1410    /// never asked cannot be written by accident, and one that asked cannot
1411    /// proceed on a refusal.
1412    ///
1413    /// The phase is left ONLY by [`Self::ioc_init`], and once left it is
1414    /// TERMINAL (R19-62): the queue is drained by exactly one `ioc_init`, so
1415    /// nothing can be pushed into it afterwards and stranded. A load that fails
1416    /// halfway leaves the phase open, which strands nothing: a queued
1417    /// classification blocks no caller, and it is dropped with the database.
1418    #[must_use = "C refuses a load after iocInit (dbReadCOM, dbLexRoutines.c:236); \
1419                  the refusal must be reported and no record created"]
1420    pub fn begin_load(&self) -> Result<(), IocAlreadyInitialized> {
1421        let mut phase = self.inner.init_phase.lock().unwrap();
1422        match *phase {
1423            // The only producer of `Loading`.
1424            DbInitPhase::Unloaded => {
1425                *phase = DbInitPhase::Loading(Vec::new());
1426                Ok(())
1427            }
1428            // An `st.cmd` issues several loads; they are all one `iocInit`.
1429            DbInitPhase::Loading(_) => Ok(()),
1430            // Post-`iocInit`: terminal. Refused, as C refuses it.
1431            DbInitPhase::Running => Err(IocAlreadyInitialized),
1432        }
1433    }
1434
1435    /// Schedule a record's link-status classification — the port's
1436    /// `init_record` tail (C `checkLinks`).
1437    ///
1438    /// During the LOAD phase the future is QUEUED for [`Self::ioc_init`]; a
1439    /// half-built database cannot be classified against because the code that
1440    /// would do it has not been polled. Before any load, and once `iocInit` has
1441    /// run, it is spawned at once — which is what a runtime `special()` link
1442    /// re-point needs.
1443    ///
1444    /// # `record` is what the init classifies, and it must exist first
1445    ///
1446    /// A record's first classification is issued from `set_async_context`,
1447    /// which [`Self::add_loaded_record`] calls *before* it inserts the record
1448    /// into `records` — the handle has to exist for `run_init_passes` to use
1449    /// it. So on the spawn-at-once paths the init would race its own record's
1450    /// registration and could post fields for a record the database does not
1451    /// have yet.
1452    ///
1453    /// This used to be papered over by starting each such future with a
1454    /// `crate::runtime::task::yield_now()`, on the assumption that yielding hands the
1455    /// thread back to the in-progress `add_record`. That assumption holds only
1456    /// on a current-thread runtime: on a multi-thread one the yield can return
1457    /// before the insert lands, and under `rtems-exec-model` the init runs on
1458    /// the background executor's own thread, where a yield is not a
1459    /// synchronisation with `add_record` at all — it is nothing. The tests that
1460    /// read a link-status field right after `add_record` therefore failed by
1461    /// timing, with a different test failing per run.
1462    ///
1463    /// Now the ordering is a property of the data: an init naming a record that
1464    /// is not registered is *parked* under that name, and the only thing that
1465    /// can release it is the insert of that name. No yield, no window, and the
1466    /// same code path on every backend.
1467    pub(crate) fn schedule_record_init(
1468        &self,
1469        record: &str,
1470        init: impl std::future::Future<Output = ()> + Send + 'static,
1471    ) {
1472        if !self.inner.records.read().contains_key(record) {
1473            self.inner
1474                .record_init_waiting
1475                .lock()
1476                .unwrap()
1477                .entry(record.to_string())
1478                .or_default()
1479                .push(Box::pin(init));
1480            return;
1481        }
1482        self.dispatch_record_init(Box::pin(init));
1483    }
1484
1485    /// Queue or spawn an init whose record is known to be registered.
1486    fn dispatch_record_init(&self, init: RecordInit) {
1487        let mut phase = self.inner.init_phase.lock().unwrap();
1488        match &mut *phase {
1489            DbInitPhase::Loading(queued) => queued.push(init),
1490            DbInitPhase::Unloaded | DbInitPhase::Running => {
1491                drop(phase);
1492                crate::runtime::task::spawn(init);
1493            }
1494        }
1495    }
1496
1497    /// Release every init parked for `record` — called by the one site that
1498    /// registers the name, immediately after the insert that makes it real.
1499    fn release_record_inits(&self, record: &str) {
1500        let parked = self
1501            .inner
1502            .record_init_waiting
1503            .lock()
1504            .unwrap()
1505            .remove(record);
1506        for init in parked.into_iter().flatten() {
1507            self.dispatch_record_init(init);
1508        }
1509    }
1510
1511    /// The `iocInit` barrier: end the LOAD phase and run every classification
1512    /// owed, to completion.
1513    ///
1514    /// After this returns the database is complete and every link status is
1515    /// FINAL — C's guarantee, where `init_record` runs inside `iocInit` and a
1516    /// `dbgf REC.INAV` right after it reads the classified value (before it, C
1517    /// refuses `dbgf` outright). Idempotent: an `st.cmd` that spells `iocInit`
1518    /// out and the `IocApp` that runs one anyway are the same single boundary.
1519    pub async fn ioc_init(&self) {
1520        let owed = {
1521            let mut phase = self.inner.init_phase.lock().unwrap();
1522            match std::mem::replace(&mut *phase, DbInitPhase::Running) {
1523                DbInitPhase::Loading(queued) => queued,
1524                // An IOC that loaded nothing (programmatic / unit-test database)
1525                // still crosses the barrier: the phase becomes terminal.
1526                DbInitPhase::Unloaded => return,
1527                DbInitPhase::Running => return,
1528            }
1529        };
1530        // Sequential, in issue order: each classification is a short read of a
1531        // now-immutable record set, and C's `init_record` pass is a loop too.
1532        for init in owed {
1533            init.await;
1534        }
1535    }
1536
1537    /// Add a record (accepts a boxed Record to avoid double-boxing).
1538    ///
1539    /// Returns `Err` when `name` collides with an existing record,
1540    /// simple PV, or alias. The C IOC's `dbLoadRecords` treats this as
1541    /// fatal; do not silently replace.
1542    ///
1543    /// The records-map insert AND scan-index insert run
1544    /// under the same `registration_mutex` hold, eliminating the
1545    /// TOCTOU window where `remove_record` could land between them
1546    /// and leave a phantom scan entry.
1547    pub async fn add_record(&self, name: &str, record: Box<dyn Record>) -> CaResult<()> {
1548        self.add_loaded_record(name, record, RecordLoad::default())
1549            .await
1550    }
1551
1552    /// Add a record together with the field set its `.db` definition loaded
1553    /// into it — the creation sink for every `dbLoadRecords` path.
1554    ///
1555    /// C's `dbLoadRecords` writes a record's ENTIRE field set through
1556    /// `dbStaticLib` (including the `UDF = 0` that `dbPutString`
1557    /// (`dbStaticLib.c:2653-2661`) implies for any put to a field named
1558    /// `VAL`), and only afterwards does `iocInit::doInitRecord0`
1559    /// (`iocInit.c:508-536`) evaluate `if (udf && stat == UDF_ALARM) sevr =
1560    /// udfs`. The port used to add the record first and apply its loaded common
1561    /// fields afterwards, so the init passes ran against a PRE-LOAD field set:
1562    /// every record with a `field(VAL,…)` latched `SEVR = INVALID` at creation
1563    /// and the `UDF = 0` that arrived a moment later could not lower it again.
1564    /// A whole `.db` of setpoint defaults and sim constants came up red.
1565    ///
1566    /// Taking the loaded fields here is what makes C's ordering hold by
1567    /// construction: there is no window in which the init passes can observe a
1568    /// record whose `.db` fields have not landed, because the record is not
1569    /// reachable until they have. `RecordInstance::run_init_passes` is
1570    /// crate-private for the same reason — the sink is the only caller.
1571    pub async fn add_loaded_record(
1572        &self,
1573        name: &str,
1574        record: Box<dyn Record>,
1575        load: RecordLoad,
1576    ) -> CaResult<()> {
1577        let _gate = self.inner.registration_mutex.lock();
1578        self.check_name_free(name)?;
1579        let mut instance = RecordInstance::new_boxed(name.to_string(), record);
1580        // Hand the record a cycle-free handle to its own database so it can
1581        // post out-of-band field updates / wire completion-driven re-entry
1582        // (asyn TRACE callback, sseq WAITn) without owning the database.
1583        // C records reach `dbCommon::pdba`/the IOC the same way at
1584        // `dbDefineRecord` init; the framework supplies the back-reference,
1585        // the record never constructs it. Defaulted no-op for records that
1586        // do not need it.
1587        instance
1588            .record
1589            .set_async_context(name.to_string(), self.async_handle());
1590
1591        // Hand the record the current breakpoint-table registry snapshot so a
1592        // LINR>=3 ai/ao record can resolve its table lazily at convert time.
1593        // add_record is the single creation sink (IocBuilder, dbLoadRecords,
1594        // dbCreateRecord, inline records all funnel through here), so this one
1595        // install covers every creation path uniformly. The trait default is a
1596        // no-op for records that don't use it; skipped when no tables are
1597        // loaded so the common case pays no Arc clone. A record created before
1598        // its table is loaded is re-installed by `add_breaktables`.
1599        {
1600            let snapshot = self.inner.breaktable_registry.load_full();
1601            if !snapshot.is_empty() {
1602                instance.record.install_breaktable_registry(snapshot);
1603            }
1604        }
1605
1606        // The `.db` load, applied to the instance BEFORE the init passes below
1607        // — C's `dbLoadRecords` → `iocInit` ordering. The `.db` value coercion
1608        // (`put_common_field_db_load`) differs from a runtime `dbPut`'s: C's
1609        // loader converter has a wider menu bound (`dbStaticRun.c`).
1610        //
1611        // The scan-index entry is built from `instance.common.scan` further
1612        // down, i.e. from the POST-load field set, so a `field(SCAN,…)` needs
1613        // no index fix-up here — the record has not been published yet.
1614        for (field, value) in load.common_fields {
1615            if let Err(e) = instance.put_common_field_db_load(&field, value) {
1616                eprintln!("put_common_field({field}) failed for {name}: {e}");
1617            }
1618        }
1619        // `info(...)` tags land before `init_record`, so device support that
1620        // reads them at init sees the values.
1621        for (key, value) in &load.info_tags {
1622            instance.set_info(key, value);
1623        }
1624
1625        // C's `iocInit` init passes, through their owner (the `doInitRecord0`
1626        // prologue — `pact = FALSE` plus the initial UDF severity — then
1627        // `init_record(0)`, `init_record(1)`, and the UDF tail). This sink is
1628        // the single site that runs them: a record built programmatically, by
1629        // iocsh `dbCreateRecord`, or from a `.db` is initialised the same way,
1630        // and — since the load above has already landed — always against its
1631        // FINAL field set.
1632        instance.run_init_passes(name);
1633
1634        // The init-seed owner: every CONSTANT link the record declares
1635        // (`Record::constant_init_links`) is loaded into its value field ONCE,
1636        // here — a constant delivers NOTHING at process time
1637        // (`dbConstLink.c:219-225`). `add_record` is the creation sink every
1638        // path funnels through, so this covers a record built programmatically
1639        // as well as one loaded from a .db; `IocBuilder`/`dbLoadRecords` call
1640        // the owner again after `init_record(1)`, once the record's final
1641        // NELM/FTVL buffer exists for an array constant to land in. Seeding
1642        // twice is a no-op — both run before any client put.
1643        super::database::processing::seed_constant_links(&mut instance);
1644
1645        let scan = instance.common.scan;
1646        let phas = instance.common.phas;
1647        self.inner.records.write().insert(
1648            name.to_string(),
1649            Arc::new(parking_lot::RwLock::new(instance)),
1650        );
1651        // The record is reachable from this line on, so anything its
1652        // `set_async_context` parked above may now run. This is the only
1653        // release site because this is the only site that registers the name.
1654        self.release_record_inits(name);
1655
1656        // Assign a monotonic load-order sequence — the scan-index
1657        // secondary sort key, so same-PHAS records keep load order.
1658        let seq = self
1659            .inner
1660            .load_order_counter
1661            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1662        self.inner.load_order.update(|m| {
1663            m.insert(name.to_string(), seq);
1664        });
1665
1666        if let Some(list) = scan.scan_list() {
1667            self.inner
1668                .scan_index
1669                .bucket(list)
1670                .lock()
1671                .insert((phas, seq, name.to_string()));
1672        }
1673        Ok(())
1674    }
1675
1676    /// Verify that `name` is not currently registered in any of the
1677    /// three namespaces. Caller MUST hold `registration_mutex` so the
1678    /// peek-then-insert sequence is atomic — without that, two tasks
1679    /// can both see the name as free and race the insert.
1680    ///
1681    /// Synchronous: all three namespaces are blocking locks now, so this peek
1682    /// makes no suspension point inside the `registration_mutex` hold — which
1683    /// is what lets that gate become a `PriorityInheritanceMutex` whose `!Send`
1684    /// guard may not cross an `.await`.
1685    fn check_name_free(&self, name: &str) -> CaResult<()> {
1686        let kind = if self.inner.simple_pvs.lock().contains_key(name) {
1687            Some("simple PV")
1688        } else if self.inner.records.read().contains_key(name) {
1689            Some("record")
1690        } else if self.inner.aliases.read().contains_key(name) {
1691            Some("alias")
1692        } else {
1693            None
1694        };
1695        if let Some(kind) = kind {
1696            return Err(CaError::DbParseError {
1697                line: 0,
1698                column: 0,
1699                message: format!("name '{name}' is already registered as a {kind}"),
1700            });
1701        }
1702        Ok(())
1703    }
1704
1705    /// Remove a record by name. Returns `true` if a record was removed,
1706    /// `false` if no such name was registered. Mirrors epics-base PR
1707    /// #505 — deletion at database creation, exposed here as a public
1708    /// API so iocsh `dbDeleteRecord` and tests can drive it.
1709    ///
1710    /// The cleanup covers the three indices that `add_record` populates:
1711    /// the records map, the scan index, and CP-link source/target lists.
1712    /// Live subscribers on the removed record drop their `Sender` clone
1713    /// when the `RecordInstance` is dropped — they observe `Closed` on
1714    /// next recv, matching the existing dbEvent cancel flow.
1715    pub async fn remove_record(&self, name: &str) -> bool {
1716        let _gate = self.inner.registration_mutex.lock();
1717        // 1) Remove from main map; keep scan + phas for scan-index cleanup.
1718        let removed = self.inner.records.write().remove(name);
1719        let Some(rec_arc) = removed else {
1720            return false;
1721        };
1722        let scan = {
1723            let inst = rec_arc.read();
1724            inst.common.scan
1725        };
1726
1727        // 2) Drop from scan index if it was scheduled. Match by record
1728        // name only — PHAS and load_order are not needed and may be
1729        // stale relative to the entry actually present.
1730        if let Some(list) = scan.scan_list() {
1731            self.inner
1732                .scan_index
1733                .bucket(list)
1734                .lock()
1735                .retain(|(_, _, n)| n != name);
1736        }
1737
1738        // 2b) Drop the load-order entry.
1739        self.inner.load_order.update(|m| {
1740            m.remove(name);
1741        });
1742
1743        // 3) Drop from CP-link tables. Removed both as source (channel
1744        // change → trigger targets) and as target (other channels'
1745        // CP lists may still reference this name).
1746        self.inner.cp_links.update(|cp| {
1747            cp.remove(name);
1748            for targets in cp.values_mut() {
1749                targets.retain(|t| t.record != name);
1750            }
1751        });
1752
1753        // 4) Purge aliases that pointed AT the
1754        // removed record. Otherwise `find_pv("ALT")` returns None
1755        // (target gone) but `add_pv("ALT", ...)` still fails with
1756        // "already registered as an alias" — orphan blocks reuse.
1757        let mut aliases = self.inner.aliases.write();
1758        aliases.retain(|_alias, target| target != name);
1759
1760        true
1761    }
1762
1763    /// Internal: synchronous lookup without invoking the search resolver.
1764    async fn find_entry_no_resolve(&self, name: &str) -> Option<PvEntry> {
1765        // a channel name may carry a `.{"arr":...}` filter
1766        // suffix. Strip it before lookup — the suffix is a per-channel
1767        // filter spec, not part of the PV identity. `split_channel_name`
1768        // is the single owner of "channel name → record_path" and is
1769        // idempotent on an already-stripped name. Without this a
1770        // filtered SimplePv (`SP.{"arr":...}`) never matches
1771        // `simple_pvs` (keyed by the bare PV name) and even a filtered
1772        // record fails when the JSON contains a `.` (e.g.
1773        // `{"dbnd":{"d":0.5}}`), because the bare `parse_pv_name` last-dot
1774        // split would tear the suffix apart instead of removing it.
1775        let record_path = filters::split_channel_name(name).record_path;
1776        let (base, _field) = parse_pv_name(&record_path);
1777
1778        let simple = self
1779            .inner
1780            .simple_pvs
1781            .lock()
1782            .get(record_path.as_str())
1783            .cloned();
1784        if let Some(pv) = simple {
1785            return Some(PvEntry::Simple(pv));
1786        }
1787        if let Some(rec) = self.inner.records.read().get(base) {
1788            return Some(PvEntry::Record(rec.clone()));
1789        }
1790        // Alias resolve (epics-base PR #336): the alternate name maps
1791        // to a canonical record name. Look up the real record after
1792        // translating the base.
1793        if let Some(target) = self.inner.aliases.read().get(base).cloned() {
1794            if let Some(rec) = self.inner.records.read().get(&target) {
1795                return Some(PvEntry::Record(rec.clone()));
1796            }
1797        }
1798        None
1799    }
1800
1801    /// Register an alias `alias` for an existing record `target`.
1802    /// Mirrors epics-base PR #336. Returns `Err(...)` when the target
1803    /// does not exist or the alias name is already in use anywhere
1804    /// in the database (records, simple PVs, or other aliases).
1805    ///
1806    /// Pre-fix the alias path checked only
1807    /// `records` and `aliases` — a simple-PV with the same name as
1808    /// the proposed alias was missed, leaving the database in a
1809    /// state where `find_pv(alias)` could resolve to either the
1810    /// simple PV or the alias-mapped record depending on lookup
1811    /// order. Now we run the same cross-namespace `check_name_free`
1812    /// guard the other add_* paths use.
1813    pub async fn add_alias(&self, alias: &str, target: &str) -> CaResult<()> {
1814        let _gate = self.inner.registration_mutex.lock();
1815        if !self.inner.records.read().contains_key(target) {
1816            return Err(CaError::ChannelNotFound(format!(
1817                "alias target '{target}' is not a registered record"
1818            )));
1819        }
1820        self.check_name_free(alias)?;
1821        self.inner
1822            .aliases
1823            .write()
1824            .insert(alias.to_string(), target.to_string());
1825        Ok(())
1826    }
1827
1828    /// Resolve an alias to its target record name, or `None` when the
1829    /// name is not an alias.
1830    pub fn resolve_alias(&self, name: &str) -> Option<String> {
1831        self.inner.aliases.read().get(name).cloned()
1832    }
1833
1834    /// Queue an iocsh command line for post-PINI execution.
1835    /// Mirrors epics-base PR #558 — `afterIocRunning <command>` lets
1836    /// the startup script schedule actions that run after iocInit
1837    /// completes (when the record set is fully wired up).
1838    pub fn queue_after_ioc_running(&self, line: impl Into<String>) {
1839        self.inner
1840            .after_ioc_running
1841            .lock()
1842            .unwrap()
1843            .push(line.into());
1844    }
1845
1846    /// Drain the post-PINI iocsh command queue. Called by
1847    /// `IocApplication::run` after PINI processing.
1848    pub fn take_after_ioc_running(&self) -> Vec<String> {
1849        std::mem::take(&mut *self.inner.after_ioc_running.lock().unwrap())
1850    }
1851
1852    /// Internal: synchronous existence check without resolver.
1853    fn has_name_no_resolve(&self, name: &str) -> bool {
1854        // strip the channel-filter suffix before lookup so a
1855        // filtered channel (`SP.{"arr":...}` / `REC.{"dbnd":{"d":0.5}}`)
1856        // resolves to its underlying PV at UDP-search time. This is the
1857        // search-side twin of `find_entry_no_resolve`; without it a
1858        // filtered SimplePv never answers a SEARCH and the client never
1859        // reaches CREATE_CHAN. See that function for the full rationale.
1860        let record_path = filters::split_channel_name(name).record_path;
1861        let (base, _) = parse_pv_name(&record_path);
1862        if self
1863            .inner
1864            .simple_pvs
1865            .lock()
1866            .contains_key(record_path.as_str())
1867        {
1868            return true;
1869        }
1870        if self.inner.records.read().contains_key(base) {
1871            return true;
1872        }
1873        // Alias entry exists and points to a live record
1874        // (epics-base PR #336).
1875        if let Some(target) = self.inner.aliases.read().get(base) {
1876            return self.inner.records.read().contains_key(target);
1877        }
1878        false
1879    }
1880
1881    /// Look up an entry by name. Supports "record.FIELD" syntax.
1882    ///
1883    /// If the name is not found and a search resolver is installed,
1884    /// the resolver is invoked once. If the resolver returns true, the
1885    /// database is re-checked.
1886    pub async fn find_entry(&self, name: &str) -> Option<PvEntry> {
1887        self.find_entry_from(name, None).await
1888    }
1889
1890    /// Like [`Self::find_entry`], but threads the downstream client's
1891    /// socket address into the search resolver. The CA TCP CREATE_CHANNEL
1892    /// handler passes the connection peer so the gateway can apply
1893    /// host-scoped `.pvlist` admission.
1894    pub async fn find_entry_from(
1895        &self,
1896        name: &str,
1897        peer: Option<std::net::SocketAddr>,
1898    ) -> Option<PvEntry> {
1899        if let Some(entry) = self.find_entry_no_resolve(name).await {
1900            // A cached simple PV must still pass the per-request
1901            // existence gate (CA gateway host/state admission). When the
1902            // gate denies it, answer does-not-exist for this requester
1903            // instead of returning the stale shadow entry — C ca-gateway
1904            // re-runs `gateAs::findEntry`/cache-state on every
1905            // `pvExistTest` (gateServer.cc:1516-1637). Records/aliases
1906            // bypass the gate (see `simple_pv_gate_denies`).
1907            if matches!(entry, PvEntry::Simple(_)) && self.simple_pv_gate_denies(name, peer).await {
1908                return None;
1909            }
1910            return Some(entry);
1911        }
1912        // Try the search resolver
1913        let resolver = self.inner.search_resolver.load_full().map(|r| (*r).clone());
1914        if let Some(r) = resolver {
1915            if r(name.to_string(), peer).await {
1916                return self.find_entry_no_resolve(name).await;
1917            }
1918        }
1919        None
1920    }
1921
1922    /// Check if a base name exists (for UDP search).
1923    ///
1924    /// If the name is not in the database and a search resolver is installed,
1925    /// the resolver is invoked. The resolver may populate the database
1926    /// (e.g., subscribe to an upstream IOC and add a placeholder PV) and
1927    /// return true; this method then re-checks.
1928    pub async fn has_name(&self, name: &str) -> bool {
1929        self.has_name_from(name, None).await
1930    }
1931
1932    /// Like [`Self::has_name`], but threads the downstream client's
1933    /// socket address into the search resolver. The CA UDP search
1934    /// responder passes the datagram source address so the gateway can
1935    /// apply host-scoped `.pvlist` admission.
1936    pub async fn has_name_from(&self, name: &str, peer: Option<std::net::SocketAddr>) -> bool {
1937        if self.has_name_no_resolve(name) {
1938            // Same per-request gate as `find_entry_from`: a cached simple
1939            // PV the gateway's host/state admission denies must answer
1940            // does-not-exist at search time. Records/aliases bypass.
1941            if self.simple_pv_gate_denies(name, peer).await {
1942                return false;
1943            }
1944            return true;
1945        }
1946        let resolver = self.inner.search_resolver.load_full().map(|r| (*r).clone());
1947        if let Some(r) = resolver {
1948            if r(name.to_string(), peer).await {
1949                return self.has_name_no_resolve(name);
1950            }
1951        }
1952        false
1953    }
1954
1955    /// Look up a simple PV by name (backward-compatible).
1956    pub async fn find_pv(&self, name: &str) -> Option<Arc<ProcessVariable>> {
1957        self.inner.simple_pvs.lock().get(name).cloned()
1958    }
1959
1960    /// Get a record Arc by name. Alias-aware (epics-base PR #336):
1961    /// when `name` is not a canonical record but matches a registered
1962    /// alias, the alias' target record is returned. Mirrors base
1963    /// `dbNameToAddr` behaviour, so dbpf/dbpr/dbgf, CA channel lookup,
1964    /// and DB-link target resolution all work transparently for
1965    /// aliases.
1966    ///
1967    /// Use [`Self::get_record_no_resolve`] when the caller already
1968    /// holds a canonical name and wants to suppress the alias path
1969    /// (e.g. to detect alias collisions during builder wiring).
1970    pub fn get_record(&self, name: &str) -> Option<Arc<parking_lot::RwLock<RecordInstance>>> {
1971        if let Some(rec) = self.inner.records.read().get(name).cloned() {
1972            return Some(rec);
1973        }
1974        let target = self.inner.aliases.read().get(name).cloned()?;
1975        self.inner.records.read().get(&target).cloned()
1976    }
1977
1978    /// Strict variant of [`Self::get_record`] — does NOT consult the
1979    /// alias table. Returns `Some` only when a canonical record with
1980    /// that exact name exists.
1981    pub fn get_record_no_resolve(
1982        &self,
1983        name: &str,
1984    ) -> Option<Arc<parking_lot::RwLock<RecordInstance>>> {
1985        self.inner.records.read().get(name).cloned()
1986    }
1987
1988    /// Every record name, in **database load order** — the single owner of
1989    /// whole-database iteration order.
1990    ///
1991    /// C parity: `dbFirstRecord`/`dbNextRecord` walk the record list of each
1992    /// record type in the order `dbReadDatabase` appended them, so every
1993    /// whole-database pass a C IOC makes — `initDevSup`, `initDatabase`,
1994    /// `initialProcess` (PINI), `dbl`/`dbgrep` dumps — visits records in load
1995    /// order. Device support is written against that contract: a dynamic device
1996    /// support whose record references another record (epics-modules/opcua's
1997    /// element records require their `opcuaItem` record to have bound first,
1998    /// linkParser.cpp:226-234) only boots if the referenced record was wired
1999    /// first, which the `.db` guarantees by declaring it first.
2000    ///
2001    /// The names live in a `HashMap`, so returning `keys()` made that order the
2002    /// hash order: neither load order nor even stable across runs of the same
2003    /// binary (`RandomState` reseeds per process). Booting the same database
2004    /// twice could wire records in two different orders — one boot succeeding
2005    /// and the next failing. Ordering here, at the one accessor every
2006    /// whole-database walk already goes through, makes every such pass
2007    /// deterministic and load-ordered at once.
2008    ///
2009    /// The order key is the existing per-record `load_order` sequence (the
2010    /// scan-index's secondary sort key), so this ordering and the scan lists'
2011    /// ordering are the same fact, not two. A record with no sequence — none
2012    /// exists; `add_record` is the only insertion path — would sort last by
2013    /// name rather than nondeterministically.
2014    pub async fn all_record_names(&self) -> Vec<String> {
2015        // Lock order records → load_order (matching `add_record`/`remove_record`):
2016        // the `records` map is a sync `parking_lot::RwLock` now, so its guard is
2017        // `!Send` and MUST NOT be held across the async `load_order` read. Snapshot
2018        // the keys under the records guard, release it (block close), then await
2019        // load_order — neither lock is ever held while waiting on the other, so the
2020        // records→load_order order is honoured without an AB-BA against add_record.
2021        // The two reads are no longer one atomic snapshot: a record inserted between
2022        // them is absent from `load_order` and sorts last by name via the
2023        // `unwrap_or(u64::MAX)` fallback — the same degradation already defined for a
2024        // sequence-less record, and every whole-database walk is racy against a
2025        // concurrent add/remove regardless.
2026        let mut names: Vec<String> = {
2027            let records = self.inner.records.read();
2028            records.keys().cloned().collect()
2029        };
2030        let load_order = self.inner.load_order.load();
2031        names.sort_by(|a, b| {
2032            let seq = |n: &String| load_order.get(n).copied().unwrap_or(u64::MAX);
2033            seq(a).cmp(&seq(b)).then_with(|| a.cmp(b))
2034        });
2035        names
2036    }
2037
2038    /// Get all alias names registered against existing records.
2039    /// Mirrors the alias-half of base's `dbFirstRecord` iteration —
2040    /// `dbgrep` / `dbglob` / `dbsr` walk both record names and
2041    /// aliases when matching a glob.
2042    pub fn all_alias_names(&self) -> Vec<String> {
2043        self.inner.aliases.read().keys().cloned().collect()
2044    }
2045
2046    /// Return every alias that points at `canonical`. Sorted for
2047    /// stable output; empty when the record has no aliases. Used by
2048    /// `dbpr` to surface alias-form names so admins can see how
2049    /// clients may reach the record.
2050    pub fn aliases_for_record(&self, canonical: &str) -> Vec<String> {
2051        let aliases = self.inner.aliases.read();
2052        let mut hits: Vec<String> = aliases
2053            .iter()
2054            .filter_map(|(alias, target)| {
2055                if target == canonical {
2056                    Some(alias.clone())
2057                } else {
2058                    None
2059                }
2060            })
2061            .collect();
2062        hits.sort();
2063        hits
2064    }
2065
2066    /// Get all simple PV names.
2067    pub async fn all_simple_pv_names(&self) -> Vec<String> {
2068        self.inner.simple_pvs.lock().keys().cloned().collect()
2069    }
2070}
2071
2072#[cfg(test)]
2073mod tests {
2074    use super::*;
2075
2076    /// C `recGblGetTimeStampSimm` (recGbl.c:310-343) maps TSE values
2077    /// to epicsTime sources via the constants in `epicsTime.h:102-104`.
2078    /// The Rust port previously misread TSE=-1 as "device-provided
2079    /// with BestTime fallback" and gated the BestTime call on a
2080    /// UNIX_EPOCH check. C calls `epicsTimeGetEvent(-1)`
2081    /// unconditionally; only TSE=-2 (epicsTimeEventDeviceTime) leaves
2082    /// `precord->time` untouched.
2083    ///
2084    /// Regression: a stale device write (any non-epoch SystemTime)
2085    /// suppressed every BestTime refresh thereafter.
2086    #[test]
2087    fn apply_timestamp_tse_minus_one_always_overwrites_with_best_time() {
2088        use crate::server::record::CommonFields;
2089        use std::time::{Duration, SystemTime};
2090
2091        // Pre-populate `time` with a stale but non-epoch sentinel.
2092        let stale = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
2093        let mut common = CommonFields::default();
2094        common.tse = -1;
2095        common.time = stale;
2096
2097        apply_timestamp(&mut common, false);
2098
2099        // BestTime must have run unconditionally — `common.time` is
2100        // no longer the stale sentinel.
2101        assert_ne!(
2102            common.time, stale,
2103            "TSE=-1 must always overwrite via generalTime BestTime, \
2104             matching C epicsTimeGetEvent(-1) called unconditionally"
2105        );
2106    }
2107
2108    /// C `epicsTimeEventDeviceTime = -2` (epicsTime.h:104). The C
2109    /// path does NOT call `epicsTimeGetEvent` for this TSE value;
2110    /// device support has already set `precord->time` before the
2111    /// recGbl call. The Rust port must leave `common.time` untouched.
2112    #[test]
2113    fn apply_timestamp_tse_minus_two_preserves_device_provided_time() {
2114        use crate::server::record::CommonFields;
2115        use std::time::{Duration, SystemTime};
2116
2117        let device_time = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000);
2118        let mut common = CommonFields::default();
2119        common.tse = -2;
2120        common.time = device_time;
2121
2122        apply_timestamp(&mut common, false);
2123
2124        assert_eq!(
2125            common.time, device_time,
2126            "TSE=-2 (epicsTimeEventDeviceTime) must preserve device-provided time"
2127        );
2128    }
2129
2130    #[test]
2131    fn select_link_indices_fanout_all_specified_mask() {
2132        use crate::server::record::AlarmSeverity;
2133        // All — every slot.
2134        let r = select_link_indices_ex(SelmKind::FanoutSeq, 0, 0, 0, 0, 16);
2135        assert_eq!(r.indices, (0..16).collect::<Vec<_>>());
2136        assert!(r.alarm.is_none());
2137
2138        // Specified, 0-based: SELN=0 selects LNK0 (C parity, fanout).
2139        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, 0, 0, 0, 16);
2140        assert_eq!(r.indices, vec![0]);
2141        // Specified with OFFS bias: SELN=2 + OFFS=3 → index 5.
2142        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, 2, 3, 0, 16);
2143        assert_eq!(r.indices, vec![5]);
2144        // Out-of-range Specified → INVALID alarm, no links.
2145        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, 20, 0, 0, 16);
2146        assert!(r.indices.is_empty());
2147        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
2148        // Negative resolved index (SELN + negative OFFS) → INVALID.
2149        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, 0, -1, 0, 16);
2150        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
2151
2152        // Mask: SELN=0b101 → bits 0 and 2.
2153        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, 5, 0, 0, 16);
2154        assert_eq!(r.indices, vec![0, 2]);
2155        // Mask with SHFT: SELN=0b101 >> 1 = 0b10 → bit 1.
2156        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, 5, 0, 1, 16);
2157        assert_eq!(r.indices, vec![1]);
2158        // Mask with negative SHFT: SELN=0b101 << 1 = 0b1010 → bits 1,3.
2159        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, 5, 0, -1, 16);
2160        assert_eq!(r.indices, vec![1, 3]);
2161        // SHFT out of [-15,15] → INVALID.
2162        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, 5, 0, 16, 16);
2163        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
2164
2165        // Unknown SELM → INVALID.
2166        let r = select_link_indices_ex(SelmKind::FanoutSeq, 9, 0, 0, 0, 16);
2167        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
2168    }
2169
2170    #[test]
2171    fn select_link_indices_dfanout_specified_is_one_based() {
2172        use crate::server::record::AlarmSeverity;
2173        // dfanout Specified is 1-based: SELN=1 → OUTA (index 0).
2174        let r = select_link_indices_ex(SelmKind::Dfanout, 1, 1, 0, 0, 16);
2175        assert_eq!(r.indices, vec![0]);
2176        // SELN=2 → OUTB (index 1).
2177        let r = select_link_indices_ex(SelmKind::Dfanout, 1, 2, 0, 0, 16);
2178        assert_eq!(r.indices, vec![1]);
2179        // SELN=0 → drive nothing, NO alarm.
2180        let r = select_link_indices_ex(SelmKind::Dfanout, 1, 0, 0, 0, 16);
2181        assert!(r.indices.is_empty());
2182        assert!(r.alarm.is_none());
2183        // SELN > 16 → INVALID.
2184        let r = select_link_indices_ex(SelmKind::Dfanout, 1, 17, 0, 0, 16);
2185        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
2186        // dfanout Mask has no SHFT — SHFT arg ignored.
2187        let r = select_link_indices_ex(SelmKind::Dfanout, 2, 5, 0, 7, 16);
2188        assert_eq!(r.indices, vec![0, 2]);
2189    }
2190
2191    /// `SELN` is unsigned, and the rule that makes it unsigned depends on the
2192    /// SOURCE type — because in C the source type picks the conversion routine.
2193    #[test]
2194    fn seln_cast_follows_the_source_type() {
2195        // Integer source -> `getLongUshort`, `(epicsUInt16)(epicsInt32)v`.
2196        // C DEFINES this (C17 6.3.1.3p2, modulo 2^16), so we reproduce it.
2197        assert_eq!(dbr_ushort_cast(&EpicsValue::Long(-1)), 65535);
2198        assert_eq!(dbr_ushort_cast(&EpicsValue::Long(65536)), 0);
2199        assert_eq!(dbr_ushort_cast(&EpicsValue::Short(-1)), 65535);
2200        assert_eq!(dbr_ushort_cast(&EpicsValue::Int64(-1)), 65535);
2201        assert_eq!(dbr_ushort_cast(&EpicsValue::Long(3)), 3);
2202
2203        // Float source -> `getDoubleUshort`, `(epicsUInt16)d`. C leaves this
2204        // UNDEFINED (C17 6.3.1.4p1), and whatever `types::c_cast` decides to do
2205        // about that is a SEPARATE question from this one — the point here is
2206        // only that the float source takes the float rule and the integer
2207        // source does not.
2208        assert_eq!(
2209            dbr_ushort_cast(&EpicsValue::Double(-1.0)),
2210            crate::types::c_cast::f64_to_u16(-1.0)
2211        );
2212        assert_eq!(
2213            dbr_ushort_cast(&EpicsValue::Double(65536.0)),
2214            crate::types::c_cast::f64_to_u16(65536.0)
2215        );
2216        // In range: no policy in play, both rules truncate toward zero.
2217        assert_eq!(dbr_ushort_cast(&EpicsValue::Double(3.7)), 3);
2218    }
2219
2220    /// Whatever produced it, a `SELN` of 65535 selects nothing under Specified
2221    /// (out of range -> INVALID) and everything under Mask.
2222    #[test]
2223    fn seln_at_the_unsigned_maximum_selects_by_selm() {
2224        use crate::server::record::AlarmSeverity;
2225        let seln_max = 65535u16;
2226        // fanout/seq Specified: C `i = (epicsUInt16)seln + offs` = 65535 →
2227        // out of range → INVALID. A signed read would clamp to 0 and wrongly
2228        // drive link 0.
2229        let r = select_link_indices_ex(SelmKind::FanoutSeq, 1, seln_max, 0, 0, 16);
2230        assert!(r.indices.is_empty());
2231        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
2232        // fanout/seq Mask: 65535 → all 16 low bits set → every link. A signed
2233        // read would produce an empty mask.
2234        let r = select_link_indices_ex(SelmKind::FanoutSeq, 2, seln_max, 0, 0, 16);
2235        assert_eq!(r.indices, (0..16).collect::<Vec<_>>());
2236        // dfanout Specified: 65535 > count → INVALID. A signed read would see
2237        // -1 ≤ 0 → drive nothing, with no alarm.
2238        let r = select_link_indices_ex(SelmKind::Dfanout, 1, seln_max, 0, 0, 16);
2239        assert!(r.indices.is_empty());
2240        assert_eq!(r.alarm, Some((15, AlarmSeverity::Invalid)));
2241    }
2242
2243    /// Lset that flips to "connected" after a configurable delay.
2244    /// Drives the wait_for_external_links time-budget tests below.
2245    struct DelayedConnectLset {
2246        names: Vec<String>,
2247        connect_at: crate::runtime::task::Instant,
2248    }
2249
2250    #[async_trait::async_trait]
2251    impl link_set::LinkSet for DelayedConnectLset {
2252        fn is_connected(&self, _: &str) -> bool {
2253            crate::runtime::task::Instant::now() >= self.connect_at
2254        }
2255        fn get_cached_value(&self, _: &str) -> Option<EpicsValue> {
2256            None
2257        }
2258        async fn get_value(&self, name: &str) -> Option<EpicsValue> {
2259            self.get_cached_value(name)
2260        }
2261        fn link_names(&self) -> Vec<String> {
2262            self.names.clone()
2263        }
2264    }
2265
2266    #[epics_macros_rs::epics_test]
2267    async fn wait_for_external_links_returns_zero_zero_when_no_lsets() {
2268        let db = PvDatabase::new();
2269        let (c, t) = db
2270            .wait_for_external_links(std::time::Duration::from_millis(50))
2271            .await;
2272        assert_eq!((c, t), (0, 0));
2273    }
2274
2275    #[epics_macros_rs::epics_test]
2276    async fn wait_for_external_links_connected_quickly() {
2277        let db = PvDatabase::new();
2278        // Local-target forced-CA links (dbChannelTest==0 → isLocal): these
2279        // get DBCA_CALLBACK_INIT_WAIT, so iocInit waits for them.
2280        db.add_pv("pv:A", EpicsValue::Long(0)).await.unwrap();
2281        db.add_pv("pv:B", EpicsValue::Long(0)).await.unwrap();
2282        let lset = Arc::new(DelayedConnectLset {
2283            names: vec!["pv:A".to_string(), "pv:B".to_string()],
2284            connect_at: crate::runtime::task::Instant::now(),
2285        });
2286        // Registered under "ca": the iocInit wait is CA-facility only, so
2287        // the working set comes from the "ca" link set (these forced-CA
2288        // local-target links), never from a "pva" set.
2289        db.register_link_set("ca", lset).await;
2290        let (c, t) = db
2291            .wait_for_external_links(std::time::Duration::from_secs(1))
2292            .await;
2293        assert_eq!((c, t), (2, 2));
2294    }
2295
2296    #[epics_macros_rs::epics_test]
2297    async fn wait_for_external_links_returns_partial_on_timeout() {
2298        let db = PvDatabase::new();
2299        // Local target so the link is in the init-wait set (dbLink.c:130);
2300        // connect-time well past the budget below, so the wait must return
2301        // (0, 1) instead of blocking.
2302        db.add_pv("slow:pv", EpicsValue::Long(0)).await.unwrap();
2303        let lset = Arc::new(DelayedConnectLset {
2304            names: vec!["slow:pv".to_string()],
2305            connect_at: crate::runtime::task::Instant::now() + std::time::Duration::from_secs(60),
2306        });
2307        db.register_link_set("ca", lset).await;
2308        let started = crate::runtime::task::Instant::now();
2309        let (c, t) = db
2310            .wait_for_external_links(std::time::Duration::from_millis(250))
2311            .await;
2312        let elapsed = started.elapsed();
2313        assert_eq!((c, t), (0, 1));
2314        assert!(
2315            elapsed >= std::time::Duration::from_millis(200),
2316            "wait must consume at least the configured budget, got {:?}",
2317            elapsed
2318        );
2319        assert!(
2320            elapsed < std::time::Duration::from_secs(2),
2321            "wait must not exceed the budget by much, got {:?}",
2322            elapsed
2323        );
2324    }
2325
2326    /// C parity (dbLink.c:130): a link whose target is NOT a local record
2327    /// (`dbChannelTest != 0`) gets no DBCA_CALLBACK_INIT_WAIT, so iocInit
2328    /// must not block on it. An areaDetector `test CP MS` placeholder — a CP
2329    /// link to a PV that exists nowhere — must drop straight through, leaving
2330    /// the link to connect (or dangle) asynchronously and silently, like C.
2331    #[epics_macros_rs::epics_test]
2332    async fn wait_for_external_links_skips_nonlocal_targets() {
2333        let db = PvDatabase::new();
2334        // "test" has no local record and would never connect.
2335        let lset = Arc::new(DelayedConnectLset {
2336            names: vec!["test".to_string()],
2337            connect_at: crate::runtime::task::Instant::now() + std::time::Duration::from_secs(60),
2338        });
2339        db.register_link_set("ca", lset).await;
2340        let started = crate::runtime::task::Instant::now();
2341        let (c, t) = db
2342            .wait_for_external_links(std::time::Duration::from_secs(10))
2343            .await;
2344        // Non-local target is excluded from the wait set entirely, so the
2345        // call returns (0, 0) immediately rather than blocking the budget.
2346        assert_eq!((c, t), (0, 0));
2347        assert!(
2348            started.elapsed() < std::time::Duration::from_secs(1),
2349            "non-local link must not be waited on, got {:?}",
2350            started.elapsed()
2351        );
2352        // And it is reported as unconnected by neither path (silent, like C).
2353        assert!(db.unconnected_external_links().await.is_empty());
2354    }
2355
2356    // epics-base PR #336 — alias parsing + lookup integration tests.
2357
2358    #[epics_macros_rs::epics_test]
2359    async fn alias_resolves_through_find_entry() {
2360        let db = PvDatabase::new();
2361        db.add_record(
2362            "TARGET",
2363            Box::new(crate::server::records::ai::AiRecord::new(42.0)),
2364        )
2365        .await
2366        .unwrap();
2367        db.add_alias("ALIAS_NAME", "TARGET").await.unwrap();
2368
2369        // find_entry on the alias must return the same record as
2370        // find_entry on the target.
2371        let via_alias = db.find_entry("ALIAS_NAME").await;
2372        let via_target = db.find_entry("TARGET").await;
2373        assert!(via_alias.is_some());
2374        assert!(via_target.is_some());
2375        // has_name flips true for the alias too.
2376        assert!(db.has_name("ALIAS_NAME").await);
2377        assert!(db.has_name("TARGET").await);
2378        assert!(!db.has_name("NOT:THERE").await);
2379    }
2380
2381    #[epics_macros_rs::epics_test]
2382    async fn alias_target_must_exist() {
2383        let db = PvDatabase::new();
2384        let err = db.add_alias("DANGLING", "MISSING_TARGET").await;
2385        assert!(err.is_err(), "alias to missing target must be rejected");
2386    }
2387
2388    #[epics_macros_rs::epics_test]
2389    async fn alias_collision_with_existing_record_rejected() {
2390        let db = PvDatabase::new();
2391        db.add_record(
2392            "EXISTING",
2393            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2394        )
2395        .await
2396        .unwrap();
2397        db.add_record(
2398            "OTHER",
2399            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2400        )
2401        .await
2402        .unwrap();
2403        let err = db.add_alias("EXISTING", "OTHER").await;
2404        assert!(
2405            err.is_err(),
2406            "alias name colliding with record must be rejected"
2407        );
2408    }
2409
2410    #[epics_macros_rs::epics_test]
2411    async fn get_record_resolves_alias() {
2412        // Regression: get_record must transparently resolve
2413        // aliases so dbpf / dbgf / dbpr / CA put paths see the same
2414        // record whether the caller uses the canonical name or the
2415        // alias.
2416        let db = PvDatabase::new();
2417        db.add_record(
2418            "TARGET",
2419            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2420        )
2421        .await
2422        .unwrap();
2423        db.add_alias("ALIAS", "TARGET").await.unwrap();
2424
2425        let via_canonical = db.get_record("TARGET");
2426        let via_alias = db.get_record("ALIAS");
2427        assert!(via_canonical.is_some());
2428        assert!(via_alias.is_some(), "get_record must resolve alias");
2429        // Both calls return the same Arc (pointer equality).
2430        assert!(Arc::ptr_eq(&via_canonical.unwrap(), &via_alias.unwrap()));
2431    }
2432
2433    /// `add_record` is the single creation sink: a record added AFTER its
2434    /// breakpoint table is loaded must receive the registry snapshot so a
2435    /// `LINR >= 3` conversion resolves — without any explicit per-call-site
2436    /// `install_breaktable_registry`. This covers the dbCreateRecord and
2437    /// inline-record creation paths that previously skipped the install.
2438    #[epics_macros_rs::epics_test]
2439    async fn add_record_installs_breaktable_registry_from_snapshot() {
2440        let db = PvDatabase::new();
2441        let ramp = crate::server::cvt_bpt::BrkTable::build(
2442            "ramp",
2443            &[(0.0, 0.0), (100.0, 10.0), (300.0, 30.0)],
2444        )
2445        .unwrap();
2446        db.add_breaktables(vec![ramp]).await;
2447
2448        let mut rec = crate::server::records::ai::AiRecord::new(0.0);
2449        rec.put_field("LINR", EpicsValue::Short(15)).unwrap(); // ramp = first user-table index
2450        db.add_record("AI:BPT", Box::new(rec)).await.unwrap();
2451
2452        let arc = db.get_record("AI:BPT").unwrap();
2453        let mut inst = arc.write();
2454        inst.record.put_field("RVAL", EpicsValue::Long(50)).unwrap();
2455        inst.record.process().unwrap();
2456        // raw 50 in [0,100] -> eng 5.0, proving the registry was installed by
2457        // add_record alone.
2458        assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Double(5.0)));
2459    }
2460
2461    /// `add_breaktables` re-installs the new snapshot into records that
2462    /// already exist, so a record created BEFORE its table was loaded (inline
2463    /// records added before dbLoadRecords; merge-reloads repointing LINR) can
2464    /// still resolve `LINR >= 3`. Without the re-install the record keeps an
2465    /// empty registry and never linearises.
2466    #[epics_macros_rs::epics_test]
2467    async fn add_breaktables_reinstalls_registry_into_existing_records() {
2468        let db = PvDatabase::new();
2469        // Record added while the registry is still empty: add_record installs
2470        // nothing (the inline-record / pre-load ordering case).
2471        let mut rec = crate::server::records::ai::AiRecord::new(0.0);
2472        rec.put_field("LINR", EpicsValue::Short(15)).unwrap(); // ramp = first user-table index
2473        db.add_record("AI:BPT", Box::new(rec)).await.unwrap();
2474
2475        // Load the table afterwards — re-install must reach the existing record.
2476        let ramp = crate::server::cvt_bpt::BrkTable::build(
2477            "ramp",
2478            &[(0.0, 0.0), (100.0, 10.0), (300.0, 30.0)],
2479        )
2480        .unwrap();
2481        db.add_breaktables(vec![ramp]).await;
2482
2483        let arc = db.get_record("AI:BPT").unwrap();
2484        let mut inst = arc.write();
2485        inst.record.put_field("RVAL", EpicsValue::Long(50)).unwrap();
2486        inst.record.process().unwrap();
2487        assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Double(5.0)));
2488    }
2489
2490    #[epics_macros_rs::epics_test]
2491    async fn get_record_no_resolve_skips_alias_table() {
2492        // Strict variant must NOT see aliases — keeps the canonical
2493        // distinction available for builder code paths.
2494        let db = PvDatabase::new();
2495        db.add_record(
2496            "TARGET",
2497            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2498        )
2499        .await
2500        .unwrap();
2501        db.add_alias("ALIAS", "TARGET").await.unwrap();
2502
2503        assert!(db.get_record_no_resolve("TARGET").is_some());
2504        assert!(
2505            db.get_record_no_resolve("ALIAS").is_none(),
2506            "get_record_no_resolve must not follow alias table"
2507        );
2508    }
2509
2510    #[epics_macros_rs::epics_test]
2511    async fn register_cp_link_normalises_alias_to_canonical() {
2512        // Regression: CP link registration must store the
2513        // canonical record names. dispatch_cp_targets looks up by
2514        // canonical, so an alias-keyed entry is functionally dead.
2515        let db = PvDatabase::new();
2516        db.add_record(
2517            "SRC_REAL",
2518            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2519        )
2520        .await
2521        .unwrap();
2522        db.add_record(
2523            "DST_REAL",
2524            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2525        )
2526        .await
2527        .unwrap();
2528        db.add_alias("SRC_ALIAS", "SRC_REAL").await.unwrap();
2529        db.add_alias("DST_ALIAS", "DST_REAL").await.unwrap();
2530
2531        // Register using the alias forms (CP edge: passive_only = false).
2532        db.register_cp_link("SRC_ALIAS", "DST_ALIAS", false).await;
2533
2534        // Lookup must succeed via the canonical source name.
2535        let targets = db.get_cp_targets("SRC_REAL");
2536        assert_eq!(targets.len(), 1);
2537        assert_eq!(targets[0].record, "DST_REAL");
2538        assert!(!targets[0].passive_only);
2539        // Alias-keyed lookup must NOT have been registered.
2540        let alias_lookup = db.get_cp_targets("SRC_ALIAS");
2541        assert!(alias_lookup.is_empty());
2542    }
2543
2544    #[epics_macros_rs::epics_test]
2545    async fn aliases_for_record_returns_sorted_targets_only() {
2546        let db = PvDatabase::new();
2547        db.add_record(
2548            "TARGET",
2549            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2550        )
2551        .await
2552        .unwrap();
2553        db.add_record(
2554            "OTHER",
2555            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2556        )
2557        .await
2558        .unwrap();
2559        db.add_alias("ZZ", "TARGET").await.unwrap();
2560        db.add_alias("AA", "TARGET").await.unwrap();
2561        db.add_alias("MM", "OTHER").await.unwrap();
2562
2563        // Sorted, only TARGET's aliases.
2564        assert_eq!(
2565            db.aliases_for_record("TARGET"),
2566            vec!["AA".to_string(), "ZZ".to_string()]
2567        );
2568        // OTHER's alone.
2569        assert_eq!(db.aliases_for_record("OTHER"), vec!["MM".to_string()]);
2570        // Unknown record → empty, not None.
2571        assert!(db.aliases_for_record("MISSING").is_empty());
2572    }
2573
2574    #[epics_macros_rs::epics_test]
2575    async fn all_alias_names_returns_registered_aliases() {
2576        let db = PvDatabase::new();
2577        db.add_record(
2578            "TARGET",
2579            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2580        )
2581        .await
2582        .unwrap();
2583        db.add_alias("ALIAS_A", "TARGET").await.unwrap();
2584        db.add_alias("ALIAS_B", "TARGET").await.unwrap();
2585
2586        let mut aliases = db.all_alias_names();
2587        aliases.sort();
2588        assert_eq!(aliases, vec!["ALIAS_A".to_string(), "ALIAS_B".to_string()]);
2589        // Canonical names are NOT returned here.
2590        assert!(!aliases.contains(&"TARGET".to_string()));
2591    }
2592
2593    #[epics_macros_rs::epics_test]
2594    async fn complete_async_record_accepts_alias() {
2595        // Invariant audit: complete_async_record (the
2596        // entry point used by async device-support callbacks to
2597        // finish processing) must accept an alias name. Pre-fix it
2598        // walked `inner.records` directly and would
2599        // `ChannelNotFound` if the original name was an alias.
2600        let db = PvDatabase::new();
2601        db.add_record(
2602            "TARGET",
2603            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2604        )
2605        .await
2606        .unwrap();
2607        db.add_alias("ALIAS", "TARGET").await.unwrap();
2608
2609        // Use complete_async_record by alias — must not error.
2610        db.complete_async_record("ALIAS").await.unwrap();
2611        // And by canonical too — keeps existing behaviour.
2612        db.complete_async_record("TARGET").await.unwrap();
2613    }
2614
2615    #[epics_macros_rs::epics_test]
2616    async fn process_record_accepts_alias() {
2617        // Regression: process_record must accept an alias
2618        // name. Pre-fix it walked `inner.records` directly.
2619        let db = PvDatabase::new();
2620        db.add_record(
2621            "TARGET",
2622            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2623        )
2624        .await
2625        .unwrap();
2626        db.add_alias("ALIAS", "TARGET").await.unwrap();
2627
2628        // Both should succeed and reach the same record.
2629        db.process_record("TARGET").await.unwrap();
2630        db.process_record("ALIAS").await.unwrap();
2631
2632        // A bogus name still errors.
2633        assert!(db.process_record("MISSING").await.is_err());
2634    }
2635
2636    #[epics_macros_rs::epics_test]
2637    async fn process_record_with_links_accepts_alias_and_avoids_cycle() {
2638        // Regression: process_record_with_links normalises
2639        // the alias so that (a) the records-map lookup hits and
2640        // (b) the cycle-detection set doesn't treat alias and
2641        // canonical as two distinct entries (which would let a
2642        // self-loop slip past the visited check).
2643        let db = PvDatabase::new();
2644        db.add_record(
2645            "TARGET",
2646            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2647        )
2648        .await
2649        .unwrap();
2650        db.add_alias("ALIAS", "TARGET").await.unwrap();
2651
2652        let mut visited = std::collections::HashSet::new();
2653        db.process_record_with_links("ALIAS", &mut visited, 0)
2654            .await
2655            .unwrap();
2656
2657        // visited should contain the *canonical* name only.
2658        assert!(
2659            visited.contains("TARGET"),
2660            "visited must record the canonical name: {visited:?}",
2661        );
2662        assert!(
2663            !visited.contains("ALIAS"),
2664            "visited must NOT record the alias form: {visited:?}",
2665        );
2666    }
2667
2668    #[epics_macros_rs::epics_test]
2669    async fn alias_duplicate_rejected() {
2670        let db = PvDatabase::new();
2671        db.add_record(
2672            "TARGET",
2673            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2674        )
2675        .await
2676        .unwrap();
2677        db.add_alias("ALIAS", "TARGET").await.unwrap();
2678        // Re-registering the same alias name (even to the same target)
2679        // must fail — base behaviour: aliases are inserted once.
2680        let err = db.add_alias("ALIAS", "TARGET").await;
2681        assert!(err.is_err(), "duplicate alias name must be rejected");
2682    }
2683
2684    /// `add_pv`, `add_pv_with_hook`, and `add_record` must
2685    /// refuse to silently replace an existing registration. Mirrors
2686    /// epics-base C IOC which treats a duplicate `dbLoadRecords` name
2687    /// as a fatal load error.
2688    #[epics_macros_rs::epics_test]
2689    async fn add_pv_and_add_record_reject_duplicates_across_namespaces() {
2690        use crate::server::records::ai::AiRecord;
2691
2692        let db = PvDatabase::new();
2693        db.add_pv("A", EpicsValue::Double(1.0)).await.unwrap();
2694        // Same name as simple_pv — every namespace must see it.
2695        assert!(db.add_pv("A", EpicsValue::Double(2.0)).await.is_err());
2696        let noop_hook: crate::server::pv::WriteHook =
2697            std::sync::Arc::new(|_v, _ctx| Box::pin(async { Ok(()) }));
2698        assert!(
2699            db.add_pv_with_hook("A", EpicsValue::Double(2.0), noop_hook)
2700                .await
2701                .is_err()
2702        );
2703        assert!(
2704            db.add_record("A", Box::new(AiRecord::new(0.0)))
2705                .await
2706                .is_err()
2707        );
2708        assert!(db.add_alias("A", "A").await.is_err());
2709
2710        db.add_record("R", Box::new(AiRecord::new(0.0)))
2711            .await
2712            .unwrap();
2713        assert!(
2714            db.add_record("R", Box::new(AiRecord::new(1.0)))
2715                .await
2716                .is_err()
2717        );
2718        assert!(db.add_pv("R", EpicsValue::Double(0.0)).await.is_err());
2719        assert!(db.add_alias("R", "R").await.is_err());
2720
2721        db.add_alias("AL", "R").await.unwrap();
2722        assert!(db.add_pv("AL", EpicsValue::Double(0.0)).await.is_err());
2723        assert!(
2724            db.add_record("AL", Box::new(AiRecord::new(0.0)))
2725                .await
2726                .is_err()
2727        );
2728    }
2729
2730    /// Removing a record must purge aliases
2731    /// that pointed AT it. Otherwise the alias name stays
2732    /// "registered" forever and `add_pv` / `add_record` rejecting
2733    /// reuse causes a permanent name leak.
2734    #[epics_macros_rs::epics_test]
2735    async fn remove_record_purges_dangling_aliases() {
2736        use crate::server::records::ai::AiRecord;
2737
2738        let db = PvDatabase::new();
2739        db.add_record("R", Box::new(AiRecord::new(0.0)))
2740            .await
2741            .unwrap();
2742        db.add_alias("ALT1", "R").await.unwrap();
2743        db.add_alias("ALT2", "R").await.unwrap();
2744        // An alias that points elsewhere must NOT be touched.
2745        db.add_record("OTHER", Box::new(AiRecord::new(0.0)))
2746            .await
2747            .unwrap();
2748        db.add_alias("KEEPER", "OTHER").await.unwrap();
2749
2750        assert!(db.remove_record("R").await);
2751
2752        // Both aliases pointing at R should be gone — `add_pv` of
2753        // those names succeeds again.
2754        db.add_pv("ALT1", EpicsValue::Double(0.0)).await.unwrap();
2755        db.add_pv("ALT2", EpicsValue::Double(0.0)).await.unwrap();
2756        // The unrelated alias must survive.
2757        assert_eq!(db.resolve_alias("KEEPER"), Some("OTHER".to_string()));
2758    }
2759
2760    /// `add_alias` must reject collisions with
2761    /// every namespace, including simple PVs (which the pre-fix
2762    /// code missed).
2763    #[epics_macros_rs::epics_test]
2764    async fn add_alias_rejects_simple_pv_collision() {
2765        use crate::server::records::ai::AiRecord;
2766
2767        let db = PvDatabase::new();
2768        db.add_pv("PVX", EpicsValue::Double(0.0)).await.unwrap();
2769        db.add_record("TARGET", Box::new(AiRecord::new(0.0)))
2770            .await
2771            .unwrap();
2772        // alias name "PVX" collides with the simple PV — must fail.
2773        assert!(db.add_alias("PVX", "TARGET").await.is_err());
2774    }
2775
2776    /// Concurrent `add_pv` and `add_record` with
2777    /// the same name must not deadlock and must serialize so that
2778    /// exactly one succeeds. Pre-fix the two methods grabbed
2779    /// different write locks first, opening a cross-lock-order
2780    /// deadlock window.
2781    #[epics_macros_rs::epics_test]
2782    async fn concurrent_add_pv_and_add_record_do_not_deadlock() {
2783        use crate::server::records::ai::AiRecord;
2784
2785        let db = std::sync::Arc::new(PvDatabase::new());
2786        let db1 = db.clone();
2787        let db2 = db.clone();
2788        let h1 = crate::runtime::task::spawn(async move {
2789            db1.add_pv("RACE", EpicsValue::Double(1.0)).await
2790        });
2791        let h2 = crate::runtime::task::spawn(async move {
2792            db2.add_record("RACE", Box::new(AiRecord::new(0.0))).await
2793        });
2794        // Both complete within a reasonable bound — pre-fix this
2795        // could hang because T1 holds simple_pvs.write and waits
2796        // for records.read while T2 holds records.write and waits
2797        // for simple_pvs.read.
2798        let r1 = crate::runtime::task::timeout(std::time::Duration::from_secs(2), h1)
2799            .await
2800            .expect("add_pv must not block on add_record");
2801        let r2 = crate::runtime::task::timeout(std::time::Duration::from_secs(2), h2)
2802            .await
2803            .expect("add_record must not block on add_pv");
2804        let r1 = r1.unwrap();
2805        let r2 = r2.unwrap();
2806        // Exactly one of the two wins; the other reports
2807        // "already registered".
2808        assert!(
2809            (r1.is_ok() && r2.is_err()) || (r1.is_err() && r2.is_ok()),
2810            "exactly one of the racing inserts must succeed: r1={r1:?} r2={r2:?}",
2811        );
2812    }
2813
2814    #[epics_macros_rs::epics_test]
2815    async fn existence_gate_blocks_cached_simple_pv_per_request() {
2816        // A cached simple PV must re-pass the installed existence gate on
2817        // both the search (`has_name_from`) and create (`find_entry_from`)
2818        // paths. Records bypass the gate. With no gate the short-circuit
2819        // is unchanged (plain-IOC behaviour).
2820        use std::net::SocketAddr;
2821
2822        let db = PvDatabase::new();
2823        db.add_pv("SHADOW:x", EpicsValue::Double(1.0))
2824            .await
2825            .unwrap();
2826        db.add_record(
2827            "REC",
2828            Box::new(crate::server::records::ai::AiRecord::new(0.0)),
2829        )
2830        .await
2831        .unwrap();
2832
2833        let denied: SocketAddr = "127.0.0.1:5064".parse().unwrap();
2834        let allowed: SocketAddr = "192.0.2.5:5064".parse().unwrap();
2835
2836        // No gate installed: the cached simple PV resolves unconditionally.
2837        assert!(db.has_name_from("SHADOW:x", Some(denied)).await);
2838        assert!(db.find_entry_from("SHADOW:x", Some(denied)).await.is_some());
2839
2840        // Gate denies the simple PV only for `denied` (the gateway's
2841        // host-scoped `.pvlist` admission has exactly this shape).
2842        let gate: ExistenceGate = Arc::new(move |name, peer| {
2843            Box::pin(async move { !(name == "SHADOW:x" && peer == Some(denied)) })
2844        });
2845        db.set_existence_gate(gate).await;
2846
2847        // Denied peer: does-not-exist on both paths despite the PV being
2848        // cached in `simple_pvs`.
2849        assert!(!db.has_name_from("SHADOW:x", Some(denied)).await);
2850        assert!(db.find_entry_from("SHADOW:x", Some(denied)).await.is_none());
2851
2852        // Allowed peer: still resolves.
2853        assert!(db.has_name_from("SHADOW:x", Some(allowed)).await);
2854        assert!(
2855            db.find_entry_from("SHADOW:x", Some(allowed))
2856                .await
2857                .is_some()
2858        );
2859
2860        // Records are never gateway-managed — the gate must not gate them
2861        // even for the denied peer.
2862        assert!(db.has_name_from("REC", Some(denied)).await);
2863        assert!(db.find_entry_from("REC", Some(denied)).await.is_some());
2864    }
2865
2866    /// `record_link_fields` must surface a record's device-support `INP`
2867    /// link. An `ai`'s `INP` is a `DBF_INLINK` field stored in
2868    /// `common.inp` — it is not a `DbFieldType::String` entry in
2869    /// `field_list()` — so the earlier `field_list()` scan filtered by
2870    /// `String` silently dropped it. The pvalink install scan walks this
2871    /// method, so a Passive `ai` carrying a CP/CPP pvalink `INP` never had
2872    /// its monitor opened at iocInit. Enumerating the canonical
2873    /// `common.inp` storage fixes it; C `dbpvar`/`dbcar` likewise dump
2874    /// every link field including device-support INP/OUT.
2875    #[epics_macros_rs::epics_test]
2876    async fn record_link_fields_surfaces_device_support_inp() {
2877        use crate::server::record::ParsedLink;
2878        use crate::server::records::ai::AiRecord;
2879
2880        let db = PvDatabase::new();
2881        db.add_record("AI", Box::new(AiRecord::new(0.0)))
2882            .await
2883            .unwrap();
2884        // Device-support INP lives in `common.inp` (DBF_INLINK), the
2885        // exact storage a `field_list()` String scan cannot reach.
2886        {
2887            let rec = db.get_record("AI").unwrap();
2888            rec.write().common.inp = "pva://mini:current?proc=CP".to_string();
2889        }
2890
2891        let links = db.record_link_fields("AI");
2892        let inp = links
2893            .iter()
2894            .find(|(f, _, _)| f == "INP")
2895            .unwrap_or_else(|| panic!("INP link must be surfaced, got {links:?}"));
2896        assert_eq!(inp.1, "pva://mini:current?proc=CP");
2897        assert!(
2898            matches!(inp.2, ParsedLink::Pva(_)),
2899            "a pva:// INP must parse to ParsedLink::Pva, got {:?}",
2900            inp.2
2901        );
2902    }
2903}