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