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}