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