Skip to main content

epics_base_rs/server/database/
links.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use crate::runtime::sync::RwLock;
5use crate::server::record::{AlarmSeverity, NotifyWaitSet, OutTarget, RecordInstance, ScanType};
6use crate::types::{DbFieldType, EpicsValue, PvString};
7
8use super::link_set::LinkDbfType;
9use super::processing::join_put_notify;
10use super::{LinkPutOp, PvDatabase, SelmKind, SelmResult, dbr_ushort_cast, select_link_indices_ex};
11
12/// **The one classifier of a process-time read that delivered no value.**
13///
14/// C `dbGetLink` reports `(status, buffer)`, and the two outcomes a `None`
15/// collapses are not the same: a CONSTANT (or unset) link is `dbConstGetValue`
16/// (`dbConstLink.c:219-225`) — status 0, `*pnRequest = 0`, buffer untouched —
17/// while any other link that produced nothing FAILED and owes the reader a LINK
18/// alarm. Every process-time reader ends here ([`PvDatabase::read_link_with_alarm`]
19/// for the input-fetch/control-link paths, [`PvDatabase::read_link_value_as`] for
20/// the `ReadDbLink` executor), so a constant cannot be no-data on one path and a
21/// live value on another.
22fn empty_read_fetch(
23    link: &crate::server::record::ParsedLink,
24) -> crate::server::recgbl::simm::LinkFetch {
25    use crate::server::recgbl::simm::LinkFetch;
26    if crate::server::recgbl::simm::is_constant(link) {
27        LinkFetch::NoData
28    } else {
29        LinkFetch::Failed
30    }
31}
32
33/// The string a `DBF_CHAR`/`DBF_UCHAR` source spells, for a reader that asked
34/// for [`LinkReadAs::CharArrayAsString`](crate::server::record::LinkReadAs) —
35/// C `dbGetLink(&dol, DBF_CHAR, &s, 0, &n_elements)` copying `n` bytes into the
36/// reader's char buffer, which every later `strcmp`/`atof` then reads as a C
37/// string (`sseqRecord.c:682-696`).
38///
39/// `max_elements` is the reader's own clamp (C `if (n_elements>40) n_elements=40`
40/// against its `char s[40]`). The bytes stop at the first NUL, which is where the
41/// C string ends; a scalar `CHAR` source contributes its single byte, exactly as
42/// C's `n_elements == 1` read does.
43fn char_bytes_as_string(value: &EpicsValue, max_elements: usize) -> Option<PvString> {
44    let bytes: &[u8] = match value {
45        EpicsValue::CharArray(b) | EpicsValue::UCharArray(b) => b,
46        EpicsValue::Char(b) | EpicsValue::UChar(b) => std::slice::from_ref(b),
47        // Not a char-class source after all (the metadata said it was): C would
48        // have copied raw bytes of whatever it found. There is no faithful
49        // rendering, so deliver nothing — the caller treats it as a failed read.
50        _ => return None,
51    };
52    let n = bytes.len().min(max_elements);
53    let text = &bytes[..n];
54    let end = text.iter().position(|&b| b == 0).unwrap_or(n);
55    Some(PvString::from_bytes(text[..end].to_vec()))
56}
57
58/// C `dbDBRoldToDBFnew[pca->dbrType]` (`dbCa.c:701`): the link set's cached
59/// remote type expressed as the DBF type a device support switches on.
60fn link_dbf_to_field_type(t: LinkDbfType) -> DbFieldType {
61    match t {
62        LinkDbfType::Char => DbFieldType::Char,
63        LinkDbfType::UChar => DbFieldType::UChar,
64        LinkDbfType::Short => DbFieldType::Short,
65        LinkDbfType::UShort => DbFieldType::UShort,
66        LinkDbfType::Long => DbFieldType::Long,
67        LinkDbfType::ULong => DbFieldType::ULong,
68        LinkDbfType::Int64 => DbFieldType::Int64,
69        LinkDbfType::UInt64 => DbFieldType::UInt64,
70        LinkDbfType::Float => DbFieldType::Float,
71        LinkDbfType::Double => DbFieldType::Double,
72        LinkDbfType::String => DbFieldType::String,
73        LinkDbfType::Enum => DbFieldType::Enum,
74    }
75}
76
77/// Inverse of [`link_dbf_to_field_type`]: a local DB link's target field type
78/// expressed as the link-set DBF code. C `dbDbGetDBFtype` reports the target's
79/// `dbChannelFinalFieldType` verbatim (`dbDbLink.c:151-155`), so this is a
80/// straight relabelling, not a conversion.
81fn field_type_to_link_dbf(t: DbFieldType) -> LinkDbfType {
82    match t {
83        DbFieldType::Char => LinkDbfType::Char,
84        DbFieldType::UChar => LinkDbfType::UChar,
85        DbFieldType::Short => LinkDbfType::Short,
86        DbFieldType::UShort => LinkDbfType::UShort,
87        DbFieldType::Long => LinkDbfType::Long,
88        DbFieldType::ULong => LinkDbfType::ULong,
89        DbFieldType::Int64 => LinkDbfType::Int64,
90        DbFieldType::UInt64 => LinkDbfType::UInt64,
91        DbFieldType::Float => LinkDbfType::Float,
92        DbFieldType::Double => LinkDbfType::Double,
93        DbFieldType::String => LinkDbfType::String,
94        DbFieldType::Enum => LinkDbfType::Enum,
95    }
96}
97
98/// dfanout output-link fields, index-aligned with `MultiOut::Dfanout`
99/// (`dfanoutRecord.c:39` — OUTA..OUTP). Named so a failed OUTn put can
100/// report WHICH link failed in the record's AMSG, as C `setLinkAlarm` does.
101pub(crate) const DFANOUT_LINK_FIELDS: [&str; 16] = [
102    "OUTA", "OUTB", "OUTC", "OUTD", "OUTE", "OUTF", "OUTG", "OUTH", "OUTI", "OUTJ", "OUTK", "OUTL",
103    "OUTM", "OUTN", "OUTO", "OUTP",
104];
105
106/// seq / fanout link fields, index-aligned with `MultiOut::Seq` /
107/// `MultiOut::Fanout` (`seqRecord.c:86`, `fanoutRecord.c:39` — LNK0..LNKF).
108pub(crate) const LNK_LINK_FIELDS: [&str; 16] = [
109    "LNK0", "LNK1", "LNK2", "LNK3", "LNK4", "LNK5", "LNK6", "LNK7", "LNK8", "LNK9", "LNKA", "LNKB",
110    "LNKC", "LNKD", "LNKE", "LNKF",
111];
112
113/// Record-specific input link fields that may carry a CP/CPP modifier:
114/// DOL (ao/bo/longout/mbbo), DOL0-DOLF (seq — 16 groups), DOL1-DOLA
115/// (sseq — legacy 10 groups), NVL (sel), SELL (sseq), SVL (histogram).
116///
117/// `SVL` — not `SGNL`: `histogramRecord.dbd.pod:212` declares
118/// `field(SVL,DBF_INLINK)`, while SGNL is the `DBF_DOUBLE` value the link is
119/// read INTO (:202). The list named SGNL, which is not a link field, so a
120/// `field(SVL,"SRC CP")` histogram never got a CP monitor.
121///
122/// Shared by [`PvDatabase::record_link_fields`] (the single owner of
123/// "which fields on a record are links") and consumed transitively by
124/// both [`PvDatabase::setup_cp_links`] (CA CP/CPP) and the pvalink
125/// install scan (PVA CP/CPP), so the two enumerations cannot diverge.
126pub(crate) const CP_INPUT_LINK_FIELDS: &[&str] = &[
127    "DOL", "DOL0", "DOL1", "DOL2", "DOL3", "DOL4", "DOL5", "DOL6", "DOL7", "DOL8", "DOL9", "DOLA",
128    "DOLB", "DOLC", "DOLD", "DOLE", "DOLF", "NVL", "SELL", "SVL",
129];
130
131/// Alarm state from a link source, used for MS/NMS propagation.
132///
133/// `amsg` is the alarm-message string — propagated from the source
134/// record's `common.amsg` so a downstream MS link sees the same
135/// human-readable explanation. Empty when the source has no message
136/// or when the link source is not a DB record.
137#[derive(Clone, Debug)]
138pub(crate) struct LinkAlarm {
139    pub stat: u16,
140    pub sevr: AlarmSeverity,
141    pub amsg: String,
142}
143
144impl LinkAlarm {
145    /// The record's PENDING alarm (`nsta`/`nsev`/`namsg`) — what an OUT-link
146    /// put inherits into its target. C `dbDbPutValue` calls
147    /// `recGblInheritSevrMsg(..., pdest, psrce->nsta, psrce->nsev,
148    /// psrce->namsg)` (dbDbLink.c:382-383): `dbPutLink` runs from inside the
149    /// source's `process()`, BEFORE `recGblResetAlarms` commits the cycle, so
150    /// the alarm the source raised THIS cycle is still only pending. Reading
151    /// the committed `stat`/`sevr` here would carry the PREVIOUS cycle's
152    /// severity to every MS-class target.
153    ///
154    /// EVERY OUT-link put site uses this: the record's own OUT, the generic
155    /// multi-output pairs, the fanout/dfanout/seq dispatch, the `WriteDbLink`
156    /// / `WriteDbLinkNotify` process actions, and sseq's `put_link_notify`
157    /// (sseqRecord.c: `dbPutLink` in `processCallback`, `recGblResetAlarms` in
158    /// `asyncFinish` — the puts still precede the commit).
159    pub(crate) fn pending(common: &crate::server::record::CommonFields) -> Self {
160        LinkAlarm {
161            stat: common.nsta,
162            sevr: common.nsev,
163            amsg: common.namsg.clone(),
164        }
165    }
166
167    /// The record's COMMITTED alarm (`stat`/`sevr`/`amsg`) — what an INPUT
168    /// link read inherits from the record it read. C `dbDbGetValue` calls
169    /// `recGblInheritSevrMsg(..., dbChannelRecord(chan)->stat, ->sevr,
170    /// ->amsg)` (dbDbLink.c:229-232): the source there is a foreign record
171    /// that finished its own cycle, so its alarm is the committed one.
172    pub(crate) fn committed(common: &crate::server::record::CommonFields) -> Self {
173        LinkAlarm {
174            stat: common.stat,
175            sevr: common.sevr,
176            amsg: common.amsg.clone(),
177        }
178    }
179}
180
181/// Apply C `recGblInheritSevrMsg` (recGbl.c:263-281) for one MS-class
182/// link: fold the link source's alarm (`src`) into the destination
183/// record's PENDING alarm (`dest`) per the maximize-severity mode `ms`.
184///
185/// * **NMS** — no propagation.
186/// * **MS**  — raise dest severity to `src.sevr` under `LINK_ALARM`
187///   (NOT the source's stat); no message.
188/// * **MSI** — same as MS, but only when the source is at `INVALID`.
189/// * **MSS** — copy the source's stat + severity + amsg (the only mode
190///   that propagates the message).
191///
192/// Shared by the INPUT-link read path (`processing.rs`, where `dest` is
193/// the record reading its inputs) and the DB OUT-link write path
194/// ([`Database::write_db_link_value`], C `dbDbPutValue` →
195/// `recGblInheritSevrMsg`, dbDbLink.c:382-383, where `dest` is the
196/// OUT-link target). One implementation keeps the two sides from
197/// diverging — an earlier INPUT-side variant wrongly treated MS like
198/// MSS (propagating source stat + amsg through plain MS).
199pub(crate) fn inherit_sevr_msg(
200    dest: &mut crate::server::record::CommonFields,
201    ms: crate::server::record::MonitorSwitch,
202    src: &LinkAlarm,
203) {
204    use crate::server::recgbl::{alarm_status, rec_gbl_set_sevr, rec_gbl_set_sevr_msg};
205    use crate::server::record::{AlarmSeverity, MonitorSwitch};
206    match ms {
207        MonitorSwitch::Maximize => {
208            rec_gbl_set_sevr(dest, alarm_status::LINK_ALARM, src.sevr);
209        }
210        MonitorSwitch::MaximizeIfInvalid => {
211            if src.sevr == AlarmSeverity::Invalid {
212                rec_gbl_set_sevr(dest, alarm_status::LINK_ALARM, src.sevr);
213            }
214        }
215        MonitorSwitch::MaximizeStatus => {
216            rec_gbl_set_sevr_msg(dest, src.stat, src.sevr, src.amsg.clone());
217        }
218        MonitorSwitch::NoMaximize => {} // NMS: do not propagate
219    }
220}
221
222/// The source record's per-cycle state propagated to a DB OUT-link
223/// target, captured once from the source and threaded to every OUT-link
224/// write so all targets in one cycle see the same snapshot:
225///
226/// * `putf` / `notify` — the PUTF bit and put-notify wait-set that C
227///   `processTarget` carries to each target (dbDbLink.c:460-474).
228/// * `alarm` — the PENDING alarm that C `recGblInheritSevrMsg` folds
229///   into the dest per the link's MS-class switch (dbDbLink.c:382-383
230///   reads `psrce->nsta`/`nsev`/`namsg`, before the source commits them).
231/// * `field` — the source's link FIELD name (`OUT`, `SIOL`, `OUTA`, …),
232///   which C `setLinkAlarm` puts in the source's alarm message on a failed
233///   put (`dbLink.c:319-323`: `"field %s", dbLinkFieldName(plink)`).
234///
235/// Bundled so the OUT-link write path threads one snapshot instead of
236/// four positional arguments. The FLNK process-trigger path keeps the
237/// lighter `PutNotifyCtx` (a forward link propagates no value and thus
238/// no alarm).
239#[derive(Clone, Copy)]
240pub(crate) struct OutLinkSrc<'a> {
241    pub putf: bool,
242    pub notify: Option<&'a Arc<NotifyWaitSet>>,
243    pub alarm: &'a LinkAlarm,
244    pub field: &'a str,
245}
246
247/// Which of C's two gates a `processTarget` call is coming through — the two
248/// are NOT the same test (R18-94).
249#[derive(Clone, Copy, PartialEq, Eq, Debug)]
250pub(crate) enum ProcessTargetGate {
251    /// C `dbScanPassive` (dbDbLink.c:427-434): `if (pto->scan != 0) return 0;`
252    /// A Passive target only. This is FLNK (`dbDbScanFwdLink`), fanout `LNKn`,
253    /// and the `pvlOptPP` arm of `dbDbPutValue` (:388, `pdest->scan == 0`).
254    ScanPassive,
255    /// C `dbDbPutValue`'s FIRST arm (dbDbLink.c:387): `dbChannelField(chan) ==
256    /// &pdest->proc` — the link addresses the target's `.PROC` field. It
257    /// carries NO scan test, so a DB link writing `TARGET.PROC` processes the
258    /// target on ANY scan, and is independent of the `PP` flag.
259    ///
260    /// softIoc: `SRCO.OUT="TGT.PROC"` with `TGT` on `SCAN="10 second"` —
261    /// each `dbpf SRCO.PROC 1` advances `TGT.VAL` immediately (0 → 1 → 2).
262    /// The port's CA put route already honours this (field_io.rs `.PROC`
263    /// handling); this makes the DB-link route agree with it.
264    ProcField,
265}
266
267impl ProcessTargetGate {
268    /// Does a target with this SCAN reach `processTarget` through this gate?
269    fn admits(self, target_scan: ScanType) -> bool {
270        match self {
271            Self::ScanPassive => target_scan == ScanType::Passive,
272            Self::ProcField => true,
273        }
274    }
275}
276
277/// One `seq` link group — C `linkGrp { dly, dol, dov, lnk }`.
278#[derive(Clone, Debug)]
279pub(crate) struct SeqGroup {
280    /// DOLn input link string (empty when unset).
281    pub dol: String,
282    /// LNKn output link string (empty when unset).
283    pub lnk: String,
284    /// DLYn per-group delay in seconds.
285    pub dly: f64,
286    /// DOn value-storage field (`linkGrp.dov`) — used when DOLn is
287    /// an empty/constant link.
288    pub dov: f64,
289}
290
291/// Typed multi-output payload — replaces the legacy `\0`-packed
292/// `Vec<String>` so a link string containing an embedded NUL can
293/// never mis-split (parity review 04-L3).
294///
295/// `sseq` is NOT a variant here: a `sseq` record drives its per-step
296/// `LNKn` writes itself, in `SseqRecord::process()`, through the async
297/// PACT machine (C `sseqRecord.c::processCallback`) — not via this
298/// all-at-once dispatch.
299pub(crate) enum MultiOut {
300    /// fanout — 16 forward-link strings (LNK0..LNKF).
301    Fanout(Vec<String>),
302    /// dfanout — 16 output-link strings (OUTA..OUTP).
303    Dfanout(Vec<String>),
304    /// seq — 16 link groups (0..F).
305    Seq(Vec<SeqGroup>),
306}
307
308impl MultiOut {
309    /// Number of link slots — the `count` passed to the SELM selector.
310    fn len(&self) -> usize {
311        match self {
312            MultiOut::Fanout(v) => v.len(),
313            MultiOut::Dfanout(v) => v.len(),
314            MultiOut::Seq(v) => v.len(),
315        }
316    }
317}
318
319/// Record types whose multi-output link groups are dispatched by
320/// [`PvDatabase::dispatch_multi_output`].
321///
322/// SINGLE-OWNER INVARIANT — each of these record types' output links
323/// (fanout `LNKn`, dfanout `OUTn`, seq `LNKn`) is dispatched (value
324/// written + target forward-link processed) **exactly once per process
325/// cycle, by `dispatch_multi_output` and by nothing else**.
326///
327/// `dispatch_multi_output` is the sole owner because it is the only
328/// path that performs the full C-record model: SELL→SELN resolution,
329/// SELM/OFFS/SHFT selection, per-group DOLn input fetch, and per-group
330/// DLYn delay.
331///
332/// `sseq` is deliberately NOT listed: its `LNKn` writes are owned by
333/// `SseqRecord::process()` (the async PACT machine, C
334/// `sseqRecord.c::processCallback`), not by this dispatch. `sseq` also
335/// does not implement `Record::multi_output_links`, so the generic
336/// block skips it for that reason — there is no second dispatcher to
337/// gate against.
338///
339/// MUST NOT: the generic `multi_output_links` block in `processing.rs`
340/// (run unconditionally for every record after `dispatch_multi_output`)
341/// must skip any record type listed here. `multi_output_dispatch_owned`
342/// is consulted by that block (see `run_forward_link_tail_with_putf`
343/// §4.6) so a double-dispatch is structurally impossible, not merely
344/// removed at one call site.
345pub(crate) fn multi_output_dispatch_owned(record_type: &str) -> bool {
346    matches!(record_type, "fanout" | "dfanout" | "seq")
347}
348
349/// Which phase of a process cycle a multi-output record's links belong to.
350///
351/// The phase follows from what the links ARE, not from the record's name:
352///
353/// * A record whose links carry a VALUE (`dbPutLink`) — dfanout `OUTn`
354///   (`dfanoutRecord.c:323`), seq `LNKn` (`seqRecord.c:264`) — dispatches
355///   PRE-commit, because C issues those puts from inside `process()` /
356///   `processCallback`, before `recGblResetAlarms` commits the cycle
357///   (dfanout: `monitor()`, seq: `asyncFinish`, seqRecord.c:227). A failed
358///   put's `LINK_ALARM`/`INVALID` and a `SELN`-out-of-range
359///   `SOFT_ALARM`/`INVALID` must therefore land in the alarm THIS cycle
360///   commits and posts.
361/// * A record whose links only SCAN a target — fanout `LNK0..LNKF` are
362///   `DBF_FWDLINK` (`fanoutRecord.dbd`), dispatched via `dbScanFwdLink`
363///   (`fanoutRecord.c:110/121/138`) — dispatches in the post-commit
364///   forward-link tail: no value, no put status, no alarm to fold.
365#[derive(Clone, Copy, PartialEq, Eq)]
366pub(crate) enum MultiOutPhaseKind {
367    Output,
368    ForwardLink,
369}
370
371/// The phase argument of [`PvDatabase::dispatch_multi_output`]. The `Output`
372/// variant carries `skip_out` — the IVOA=Don't_drive veto as decided by the
373/// cycle's single IVOA owner (`process_record_with_links_inner`), on the
374/// severity `checkAlarms` produced. That is the one decision C makes
375/// (`dfanoutRecord.c:128`: `if (prec->nsev < INVALID_ALARM) push_values();
376/// else switch (ivoa)`), taken before ANY output runs; the IVOA=IVOV arm has
377/// already stored IVOV in the record's own value field
378/// ([`Record::apply_invalid_output_value`] — C `prec->val = prec->ivov`,
379/// dfanoutRecord.c:137), so the push here just reads VAL, as C's `push_values`
380/// does.
381#[derive(Clone, Copy)]
382pub(crate) enum MultiOutPhase {
383    Output { skip_out: bool },
384    ForwardLink,
385}
386
387/// The phase a record type's multi-output links belong to — see
388/// [`MultiOutPhaseKind`]. Both call sites (the pre-commit output stage and
389/// the forward-link tail) run `dispatch_multi_output` unconditionally and
390/// this single classifier decides which types act, so a record cannot be
391/// dispatched in both phases or in neither.
392pub(crate) fn multi_out_phase_of(record_type: &str) -> MultiOutPhaseKind {
393    match record_type {
394        "dfanout" | "seq" => MultiOutPhaseKind::Output,
395        _ => MultiOutPhaseKind::ForwardLink,
396    }
397}
398
399impl PvDatabase {
400    /// Read a `Db`-variant link's value honoring C `dbInitLink`'s
401    /// locality rule (`dbLink.c:118-130`): a PV link whose target record
402    /// exists in this IOC reads from the local database; a non-local
403    /// target is a CA link — `dbDbInitLink` fails to resolve it locally
404    /// and falls through to `dbCaAddLinkCallbackOpt`, so its value comes
405    /// from the external resolver. Pre-fix every `Db` arm read only the
406    /// local DB (`get_pv`) and returned `None` for a non-local target, so
407    /// a plain `INP="other:pv"` (no modifier) and a re-parsed multi-input
408    /// (`INPA`..`INPL`) / `DOL` link never read the remote value.
409    ///
410    /// This is the single owner of that rule for the value-read path, so
411    /// it holds uniformly — for every link field and regardless of an
412    /// explicit `CP`/`CPP`/`CA` modifier — not only the
413    /// `INP`/`OUT`/`TSEL`/`SDIS` parse caches the iocInit CP scan
414    /// rewrites (the per-cycle re-parsed links have no cache to rewrite,
415    /// so an init-time conversion can never reach them). The external
416    /// read routes through the lset's lazy-open path: the first read
417    /// opens the CA link and returns `None` until the monitor connects,
418    /// then serves the cached value — exactly C `dbCaGetLink`.
419    async fn read_db_link_value(&self, db: &crate::server::record::DbLink) -> Option<EpicsValue> {
420        self.read_target_value(&db.record, &db.field).await
421    }
422
423    /// Read a `(record, field)` link target's value, dispatching by C
424    /// `dbInitLink` locality (`dbLink.c:118-130`): a target present in
425    /// this IOC reads from the local database (`get_pv`); a non-local
426    /// target is a CA link, resolved through the external path
427    /// (`dbCaGetLink`). The single owner of that locality decision for
428    /// the value-read path — shared by [`Self::read_db_link_value`] (the
429    /// record's own `Db` link) and the lnkCalc input loop
430    /// ([`Self::evaluate_calc_link`]), whose `A..L` inputs are each their
431    /// own `dbInitLink` link and so become CA links when non-local.
432    async fn read_target_value(&self, record: &str, field: &str) -> Option<EpicsValue> {
433        let pv_name = if field == "VAL" {
434            record.to_string()
435        } else {
436            format!("{record}.{field}")
437        };
438        if self.has_name_no_resolve(record).await {
439            self.get_pv(&pv_name).await.ok()
440        } else {
441            self.resolve_external_pv(&pv_name).await
442        }
443    }
444
445    /// Read a value from a parsed link (DB, Constant, or external Ca/Pva).
446    ///
447    /// `visited` / `depth` are the caller's processing-chain state so a
448    /// PP source is processed within the same chain — see
449    /// [`Self::process_passive_db_source`] for why a fresh set / depth 0
450    /// would defeat the cycle guard.
451    pub(crate) async fn read_link_value(
452        &self,
453        link: &crate::server::record::ParsedLink,
454        visited: &mut HashSet<String>,
455        depth: usize,
456    ) -> Option<EpicsValue> {
457        match link {
458            crate::server::record::ParsedLink::None => None,
459            crate::server::record::ParsedLink::Ca(ca) => self.resolve_external_pv(&ca.pv).await,
460            crate::server::record::ParsedLink::Pva(name) => self.resolve_external_pv(name).await,
461            // Resolve through the per-link identity key (not bare `j.pv`)
462            // so two same-PV structured links keep distinct configs —
463            // matches the boundary key the write/scan/alarm paths use via
464            // `external_pv_name` (pvxs per-link `pvaLinkConfig`,
465            // ioc/pvalink.h:65).
466            crate::server::record::ParsedLink::PvaJson(j) => {
467                self.resolve_external_pv(&j.link_identity_key()).await
468            }
469            // A CONSTANT (or unset) link delivers NOTHING at process time —
470            // `dbConstGetValue` (`dbConstLink.c:219-225`) sets `*pnRequest = 0`
471            // and returns 0, so the reader's buffer keeps whatever it held. The
472            // constant reaches the record exactly once, at init, through
473            // [`super::PvDatabase::rec_gbl_init_constant_links`]. Handing the
474            // parsed text back here is what let the `ReadDbLink` executor
475            // re-apply a constant every cycle (sseq `SELL="3"` stomping a
476            // client's `caput SELN 5`, a compress with `INP="5"` filling its
477            // buffer with 5s). `None` here is classified as
478            // [`LinkFetch::NoData`] — success, nothing delivered — by
479            // [`empty_read_fetch`], never as a failed read.
480            crate::server::record::ParsedLink::Constant(_) => None,
481            crate::server::record::ParsedLink::Db(db) => {
482                // PP: process source record if Passive before reading.
483                // Threads the caller's `visited`/`depth` so an A↔B PP
484                // cycle terminates at the existing cycle guard instead
485                // of recursing with a fresh set.
486                self.process_passive_db_source(db, visited, depth).await;
487                self.read_db_link_value(db).await
488            }
489            // Hardware links are dispatched by device support directly
490            // — there's no canonical "value" available from a generic
491            // read; return None so the framework treats the link as
492            // unresolvable for value-read purposes.
493            crate::server::record::ParsedLink::Hw(_) => None,
494            // lnkCalc: fetch each input PV, evaluate the expr,
495            // return the result. Timestamp passthrough is handled by
496            // `read_calc_link_with_time` for callers that need it.
497            crate::server::record::ParsedLink::Calc(calc) => self.evaluate_calc_link(calc).await,
498        }
499    }
500
501    /// C `dbGetLink(plink, dbrType, ...)` — an input-link read for the DBR class
502    /// the READER asked for ([`LinkReadAs`]), not the source's native type.
503    ///
504    /// The typed READ seam, twin of the typed WRITE seam
505    /// ([`Record::typed_output_buffer`] + [`Self::resolve_out_target`]). C's
506    /// conversion happens at the SOURCE (`dbConvert.c`'s
507    /// `[field_type][dbrType]` table), which is why it belongs here and not at
508    /// the reader's `put_field`: only this side can reach the source record's
509    /// choice tables, so a `DBF_ENUM`/`DBF_MENU` source read as `DBR_STRING`
510    /// delivers its state LABEL (`sseqRecord.c:644-661`) where a native read
511    /// would hand over a bare index, and a `DBF_CHAR` array read as
512    /// `DBF_CHAR` delivers the bytes it spells (`:682-686`) where a native read
513    /// would hand over an array no numeric target can absorb.
514    ///
515    /// Returns C's `(status, buffer)` pair through the same
516    /// [`LinkFetch`] classification [`Self::read_link_with_alarm`] uses — a
517    /// CONSTANT link is [`LinkFetch::NoData`] (success, nothing written), a
518    /// read or conversion that failed is [`LinkFetch::Failed`]. The two used to
519    /// be one `Option` here, which is how the `ReadDbLink` executor came to
520    /// re-deliver a constant on every cycle while the multi-input fetch (same
521    /// links, same records) correctly ignored it.
522    pub(crate) async fn read_link_value_as(
523        &self,
524        link: &crate::server::record::ParsedLink,
525        read_as: crate::server::record::LinkReadAs,
526        visited: &mut HashSet<String>,
527        depth: usize,
528    ) -> crate::server::recgbl::simm::LinkFetch {
529        use crate::server::recgbl::simm::LinkFetch;
530        use crate::server::record::LinkReadAs;
531        let Some(value) = self.read_link_value(link, visited, depth).await else {
532            return empty_read_fetch(link);
533        };
534        // A conversion the SOURCE cannot satisfy is C's non-zero `dbGetLink`
535        // status (`dbConvert.c`'s NULL conversion slot), i.e. a FAILED read —
536        // never a silent no-op.
537        let converted = match read_as {
538            LinkReadAs::Native => Some(value),
539            // C `dbGetLink(..., DBR_DOUBLE, ...)`: an array source contributes
540            // its element 0 (C asks for one element, so `dbGet` converts offset
541            // 0), the same rule the multi-input fetch applies.
542            LinkReadAs::Double => {
543                let scalar = if value.is_array() {
544                    value.first_element()
545                } else {
546                    Some(value)
547                };
548                scalar.and_then(|s| s.to_f64()).map(EpicsValue::Double)
549            }
550            LinkReadAs::String => self
551                .dbr_string_of(link, &value)
552                .await
553                .map(EpicsValue::String),
554            LinkReadAs::CharArrayAsString { max_elements } => {
555                char_bytes_as_string(&value, max_elements).map(EpicsValue::String)
556            }
557        };
558        match converted {
559            Some(v) => LinkFetch::Value(v),
560            None => LinkFetch::Failed,
561        }
562    }
563
564    /// The `DBR_STRING` form of a value just read from `link`: rendered by the
565    /// SOURCE record when the link is local (its choice tables are what turn an
566    /// `ENUM`/`MENU` index into a state label — C `getEnumString`), by the value
567    /// itself otherwise (an external link / constant / lnkCalc result carries no
568    /// reachable field metadata).
569    async fn dbr_string_of(
570        &self,
571        link: &crate::server::record::ParsedLink,
572        value: &EpicsValue,
573    ) -> Option<PvString> {
574        if let crate::server::record::ParsedLink::Db(db) = link
575            && self.has_name_no_resolve(&db.record).await
576            && let Some(rec) = self.get_record(&db.record).await
577        {
578            let guard = rec.read().await;
579            return guard.field_as_dbr_string(&db.field);
580        }
581        crate::server::record::value_as_dbr_string(value)
582    }
583
584    /// Read a link's current value WITHOUT processing a Passive source —
585    /// the parity of C `dbGetLink` (`dbLink.c:325` → `dbTryGetLink` →
586    /// `lset->getValue`), which fetches the value for *any* link type
587    /// (DB / CA / PVA / constant / lnkCalc) and never processes the
588    /// target record.
589    ///
590    /// Distinct from [`Self::read_link_value`], which threads
591    /// `visited`/`depth` to PP-process a Passive DB source before reading
592    /// (the INPUT-link path). `dbGetLink` does no such processing, so the
593    /// DB arm here reads with a plain `get_pv` exactly as the pre-fix
594    /// control-link sites did.
595    ///
596    /// Used by the control links that C reads via `dbGetLink` every
597    /// process cycle — `SDIS`→`disa`, `SIML`→`simm`, `SELL`→`seln`, and
598    /// `TSEL`'s `TSE` load. The pre-fix sites open-coded an
599    /// `if let ParsedLink::Db` read, so a control link sourced over
600    /// CA/PVA or given as a constant was silently ignored.
601    pub(crate) async fn read_link_value_no_process(
602        &self,
603        link: &crate::server::record::ParsedLink,
604    ) -> Option<EpicsValue> {
605        match link {
606            crate::server::record::ParsedLink::None => None,
607            crate::server::record::ParsedLink::Ca(ca) => self.resolve_external_pv(&ca.pv).await,
608            crate::server::record::ParsedLink::Pva(name) => self.resolve_external_pv(name).await,
609            // Per-link identity key, as in `read_link_value` above.
610            crate::server::record::ParsedLink::PvaJson(j) => {
611                self.resolve_external_pv(&j.link_identity_key()).await
612            }
613            crate::server::record::ParsedLink::Constant(_) => link.constant_value(),
614            crate::server::record::ParsedLink::Db(db) => self.read_db_link_value(db).await,
615            // Hardware links carry no generic readable value.
616            crate::server::record::ParsedLink::Hw(_) => None,
617            crate::server::record::ParsedLink::Calc(calc) => self.evaluate_calc_link(calc).await,
618        }
619    }
620
621    /// lnkCalc evaluation: fetch each input PV, bind to calc engine
622    /// vars A..L, run `expr`, return the result as `EpicsValue::Double`.
623    /// Returns `None` if any input fetch fails, expr compile fails, or
624    /// eval fails — the caller treats the link as unresolvable.
625    pub async fn evaluate_calc_link(
626        &self,
627        calc: &crate::server::record::CalcLink,
628    ) -> Option<EpicsValue> {
629        use crate::calc::engine::{CALC_NARGS, NumericInputs};
630        // lnkCalc binds inputs to calc engine vars A..L (12). A link
631        // string carrying more than `CALC_NARGS` inputs is malformed —
632        // reject it rather than silently dropping the overflow args
633        // (the pre-fix `.take(12)` masked the misconfiguration).
634        if calc.args.len() > CALC_NARGS {
635            return None;
636        }
637        let mut vars = [0.0f64; CALC_NARGS];
638        for (i, arg) in calc.args.iter().enumerate() {
639            // Each lnkCalc input is its own `dbInitLink` link, so a
640            // non-local input record is a CA link — read it through the
641            // locality owner, not a local-only `get_pv`. `arg` is a bare
642            // record name or `record.FIELD`; split on the last `.`.
643            let (record, field) = match arg.rsplit_once('.') {
644                Some((r, f)) => (r, f),
645                None => (arg.as_str(), "VAL"),
646            };
647            let v = self.read_target_value(record, field).await?;
648            vars[i] = v.to_f64()?;
649        }
650        let compiled = crate::calc::compile(&calc.expr).ok()?;
651        let mut inputs = NumericInputs::with_vars(vars);
652        let result = crate::calc::eval(&compiled, &mut inputs).ok()?;
653        Some(EpicsValue::Double(result))
654    }
655
656    /// lnkCalc evaluation that also returns the timestamp pulled from
657    /// the input named by `time_source` (e.g. `'A'` → first input).
658    /// Returns `(value, Some(time))` when `time_source` is set and
659    /// the referenced input record has a timestamp, `(value, None)`
660    /// otherwise. The caller (link read path) uses `None` to mean
661    /// "consumer keeps its own apply_timestamp time".
662    pub async fn evaluate_calc_link_with_time(
663        &self,
664        calc: &crate::server::record::CalcLink,
665    ) -> Option<(EpicsValue, Option<std::time::SystemTime>)> {
666        let value = self.evaluate_calc_link(calc).await?;
667        let time = match calc.time_source {
668            Some(letter) => {
669                let idx = (letter as u8).saturating_sub(b'A') as usize;
670                let src = calc.args.get(idx)?;
671                // Strip `.FIELD` suffix to land on the record name.
672                let record_name = src.rsplit_once('.').map(|(r, _)| r).unwrap_or(src);
673                if self.has_name_no_resolve(record_name).await {
674                    let rec = self.get_record(record_name).await?;
675                    let inst = rec.read().await;
676                    Some(inst.common.time)
677                } else {
678                    // Non-local time source → CA link; pull the remote
679                    // `.TIME` through the external resolver (C
680                    // `dbGetTimeStamp` on a CA link), same as the
681                    // non-local TSEL `.TIME` adoption.
682                    let (secs, ns, _utag) = self
683                        .external_link_time(&format!("ca://{record_name}"))
684                        .await?;
685                    let secs = secs.max(0) as u64;
686                    let ns = (ns.max(0) as u32).min(999_999_999);
687                    Some(std::time::UNIX_EPOCH + std::time::Duration::new(secs, ns))
688                }
689            }
690            None => None,
691        };
692        Some((value, time))
693    }
694
695    /// **The single owner of a process-time link read that carries both a
696    /// value and an alarm** — the input-fetch readers (INPA..L, INAA..LL,
697    /// SUBL, output-time DOL) go through here.
698    ///
699    /// Returns C's `(status, buffer)` pair, not an `Option`: a CONSTANT link is
700    /// [`LinkFetch::NoData`] — SUCCESS with nothing delivered
701    /// (`dbConstLink.c:219-225` `dbConstGetValue`: `*pnRequest = 0; return 0`)
702    /// — which is NOT the same as [`LinkFetch::Failed`]. Collapsing the two
703    /// into `Option` is what made a constant input both re-apply its value on
704    /// every cycle (destroying a client's `caput A=99` on a
705    /// `field(INPA,"5")` calc) and, once it stopped delivering, look like a
706    /// failed read that gates `fetch_values`. The constant reaches the record
707    /// exactly once, at init, through
708    /// [`super::PvDatabase::rec_gbl_init_constant_links`].
709    pub(crate) async fn read_link_with_alarm(
710        &self,
711        link: &crate::server::record::ParsedLink,
712    ) -> (crate::server::recgbl::simm::LinkFetch, Option<LinkAlarm>) {
713        use crate::server::recgbl::simm::LinkFetch;
714        let (value, alarm) = self.read_link_value_and_alarm(link).await;
715        let fetch = match value {
716            Some(v) => LinkFetch::Value(v),
717            None => empty_read_fetch(link),
718        };
719        (fetch, alarm)
720    }
721
722    /// **The single owner of input-link severity inheritance** — C
723    /// `dbDbGetValue`'s tail (`dbDbLink.c:228-232`), which EVERY healthy
724    /// `dbGetLink` on a DB link runs:
725    ///
726    /// ```c
727    /// if (!status && precord != dbChannelRecord(chan))
728    ///     recGblInheritSevrMsg(plink->value.pv_link.pvlMask & pvlOptMsMode,
729    ///         plink->precord, dbChannelRecord(chan)->stat,
730    ///         dbChannelRecord(chan)->sevr, dbChannelRecord(chan)->amsg);
731    /// ```
732    ///
733    /// Given the reader, the link it just read and the source alarm that read
734    /// produced, it returns the `(MS class, source alarm)` pair the reader owes
735    /// its PENDING alarm — or `None` when C inherits nothing. Callers hand the
736    /// pair to [`inherit_sevr_msg`]; no caller decides for itself which links
737    /// inherit, which is how the `ReadDbLink` path came to drop MS entirely.
738    ///
739    /// Two rules live here and nowhere else:
740    ///
741    /// * **Self-exclusion** — C's `precord != dbChannelRecord(chan)` guard. A
742    ///   link that reads the reader's OWN field must not fold the reader's
743    ///   committed severity back into its pending one: `rec_gbl_reset_alarms`
744    ///   would re-commit it next cycle, so a single MAJOR would latch forever.
745    ///   Alias-aware, because C compares record pointers.
746    /// * **MS class per scheme** — DB and CA links carry their own parsed
747    ///   `MonitorSwitch`; a PVA link's lset has already applied the MS/NMS/MSI
748    ///   gate, so its (already final) severity folds as `MaximizeStatus` to keep
749    ///   the remote stat + message. Constant/Hw/Calc links inherit nothing.
750    pub(crate) async fn input_link_inheritance(
751        &self,
752        reader_name: &str,
753        link: &crate::server::record::ParsedLink,
754        alarm: Option<LinkAlarm>,
755    ) -> Option<(crate::server::record::MonitorSwitch, LinkAlarm)> {
756        let alarm = alarm?;
757        match link {
758            crate::server::record::ParsedLink::Db(db) => {
759                let target = self
760                    .resolve_alias(&db.record)
761                    .await
762                    .unwrap_or_else(|| db.record.clone());
763                let reader = self
764                    .resolve_alias(reader_name)
765                    .await
766                    .unwrap_or_else(|| reader_name.to_string());
767                if target == reader {
768                    return None;
769                }
770                Some((db.monitor_switch, alarm))
771            }
772            crate::server::record::ParsedLink::Ca(ca) => Some((ca.monitor_switch, alarm)),
773            crate::server::record::ParsedLink::Pva(_)
774            | crate::server::record::ParsedLink::PvaJson(_) => {
775                Some((crate::server::record::MonitorSwitch::MaximizeStatus, alarm))
776            }
777            _ => None,
778        }
779    }
780
781    /// The raw value+alarm read behind [`Self::read_link_with_alarm`]. Never
782    /// call this directly from a process path — it cannot tell "constant" from
783    /// "failed"; that is the classifier's job.
784    async fn read_link_value_and_alarm(
785        &self,
786        link: &crate::server::record::ParsedLink,
787    ) -> (Option<EpicsValue>, Option<LinkAlarm>) {
788        match link {
789            crate::server::record::ParsedLink::Db(db) => {
790                let pv_name = if db.field == "VAL" {
791                    db.record.clone()
792                } else {
793                    format!("{}.{}", db.record, db.field)
794                };
795                // C `dbInitLink` locality (`dbLink.c:118-130`): a target
796                // record present in this IOC is a DB link read from the
797                // local database; a non-local target is a CA link, so its
798                // value and raw remote alarm come from the external
799                // resolver — identical to the `Ca`/`Pva` arm below.
800                if !self.has_name_no_resolve(&db.record).await {
801                    return (
802                        self.resolve_external_pv(&pv_name).await,
803                        self.external_link_alarm(&pv_name).await,
804                    );
805                }
806                let value = self.get_pv(&pv_name).await.ok();
807                // Read source record's alarm state — alias-aware
808                // (epics-base PR #336) so a link target spelled with
809                // an alias still propagates MS/NMS alarm correctly.
810                let alarm = if let Some(rec) = self.get_record(&db.record).await {
811                    let inst = rec.read().await;
812                    Some(LinkAlarm::committed(&inst.common))
813                } else {
814                    None
815                };
816                (value, alarm)
817            }
818            // A CONSTANT link delivers nothing at process time; the classifier
819            // above turns this `None` into `LinkFetch::NoData` (success), not
820            // a failure.
821            crate::server::record::ParsedLink::Constant(_) => (None, None),
822            // External Pva/Ca link: the value comes from the lset's
823            // cached snapshot, the alarm from the lset's accessors.
824            //
825            // PVA: the `?sevr=` modifier is stripped before epics-base-rs
826            // parses the link, so the lset retains and applies the
827            // `MS`/`NMS`/`MSI` gate itself — a returned `Some(sev)` is
828            // already gated and the caller folds it as `MaximizeStatus`.
829            //
830            // CA: the `MS`/`NMS`/`MSI`/`MSS` modifier is
831            // now carried in the `CaLink`, so the resolver returns the
832            // *raw* remote alarm and record processing applies the gate
833            // using `link.monitor_switch()`. Either way this fn just
834            // reads the raw/gated alarm; the switch pairing happens in
835            // `processing.rs`. Without this, a connected external link
836            // carrying a remote MINOR/MAJOR severity never folded into
837            // the owning record's LINK_ALARM (B2).
838            crate::server::record::ParsedLink::Pva(_)
839            | crate::server::record::ParsedLink::PvaJson(_)
840            | crate::server::record::ParsedLink::Ca(_) => {
841                let name = link
842                    .external_pv_name()
843                    .expect("Ca/Pva/PvaJson link carries a PV name");
844                let value = self.resolve_external_pv(&name).await;
845                let alarm = self.external_link_alarm(&name).await;
846                (value, alarm)
847            }
848            // A `lnkCalc` link computes its value from its own inputs; the
849            // jlink has no source record whose alarm could be inherited
850            // (`lnkCalc`'s lset implements no `getAlarm`), so it reads as a
851            // value with no alarm.
852            crate::server::record::ParsedLink::Calc(calc) => {
853                (self.evaluate_calc_link(calc).await, None)
854            }
855            // Hardware links carry no generic readable value; `None` links
856            // deliver nothing.
857            crate::server::record::ParsedLink::Hw(_) | crate::server::record::ParsedLink::None => {
858                (None, None)
859            }
860        }
861    }
862
863    /// Latched upstream timestamp from the lset, when the
864    /// link is configured with `time=true`. The lset gates internally
865    /// (returning `None` for links without the `time` option), so a
866    /// `Some` here is the authoritative remote timestamp the
867    /// processing path should adopt into the owning record's
868    /// `common.time` and `common.utag`. Mirrors pvxs
869    /// `pvalink_lset.cpp:427`.
870    ///
871    /// Returns `(seconds_since_epoch, nanoseconds, userTag)` exactly as
872    /// the lset reports them; the caller folds the time into the
873    /// record's `SystemTime` via `UNIX_EPOCH + Duration::new(...)` and
874    /// adopts the `userTag` into `common.utag`. The `userTag` is the
875    /// remote `timeStamp.userTag` widened without sign extension, or `0`
876    /// when the source carries none.
877    /// Every registered lset, as a snapshot.
878    ///
879    /// The bare-name link paths try each lset in turn, and every lset call is
880    /// now an await. Snapshotting the registry (rather than iterating it under
881    /// its read guard) keeps the registry lock off the await path, so an lset
882    /// that registers or unregisters another while resolving cannot deadlock
883    /// against the caller.
884    async fn registered_link_sets(&self) -> Vec<crate::server::database::DynLinkSet> {
885        let registry = self.inner.link_sets.read().await;
886        registry
887            .schemes()
888            .iter()
889            .filter_map(|s| registry.get(s))
890            .collect()
891    }
892
893    pub(crate) async fn external_link_time(&self, name: &str) -> Option<(i64, i32, u64)> {
894        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
895            ("pva", rest)
896        } else if let Some(rest) = name.strip_prefix("ca://") {
897            ("ca", rest)
898        } else {
899            // Bare name — try every registered lset.
900            for lset in self.registered_link_sets().await {
901                if let Some(ts) = lset.time_stamp(name).await {
902                    return Some(ts);
903                }
904            }
905            return None;
906        };
907        let lset = self.inner.link_sets.read().await.get(scheme)?;
908        lset.time_stamp(body).await
909    }
910
911    /// Build a [`LinkAlarm`] from the registered lset's alarm
912    /// accessors for an external (`pva://` / `ca://`) link, or `None`
913    /// when no lset is registered or the lset reports no alarm.
914    ///
915    /// The lset's `alarm_severity` is the gated severity (see
916    /// [`crate::server::database::LinkSet::alarm_severity`]); when it
917    /// is `Some`, the `stat` is `LINK_ALARM` and the message comes
918    /// from `alarm_message`.
919    async fn external_link_alarm(&self, name: &str) -> Option<LinkAlarm> {
920        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
921            ("pva", rest)
922        } else if let Some(rest) = name.strip_prefix("ca://") {
923            ("ca", rest)
924        } else {
925            // Bare name — try every registered lset until one reports
926            // a severity (mirrors `resolve_external_pv`'s bare path).
927            for lset in self.registered_link_sets().await {
928                if let Some(sev) = lset.alarm_severity(name).await {
929                    return Some(LinkAlarm {
930                        // prefer the remote STAT for MSS;
931                        // fall back to LINK_ALARM when the lset has none.
932                        stat: lset
933                            .alarm_status(name)
934                            .await
935                            .map(|s| s as u16)
936                            .unwrap_or(crate::server::recgbl::alarm_status::LINK_ALARM),
937                        sevr: crate::server::record::AlarmSeverity::from_u16(sev as u16),
938                        amsg: lset.alarm_message(name).await.unwrap_or_default(),
939                    });
940                }
941            }
942            return None;
943        };
944        let lset = self.inner.link_sets.read().await.get(scheme)?;
945        let sev = lset.alarm_severity(body).await?;
946        Some(LinkAlarm {
947            // remote STAT for MSS, else LINK_ALARM.
948            stat: lset
949                .alarm_status(body)
950                .await
951                .map(|s| s as u16)
952                .unwrap_or(crate::server::recgbl::alarm_status::LINK_ALARM),
953            sevr: crate::server::record::AlarmSeverity::from_u16(sev as u16),
954            amsg: lset.alarm_message(body).await.unwrap_or_default(),
955        })
956    }
957
958    /// Ungated remote alarm snapshot for an external (`pva://` /
959    /// `ca://`) link — the DB-link inspection counterpart of
960    /// [`Self::external_link_alarm`].
961    ///
962    /// Where `external_link_alarm` returns the **gated** maximize-severity
963    /// contribution folded into the owning record's `LINK_ALARM` (pvxs
964    /// `pvaGetValue` applying the `MS`/`NMS`/`MSI` gate,
965    /// `pvalink_lset.cpp:424-431`), this returns the **ungated** remote
966    /// `(severity, status, message)` snapshot pvxs exposes through
967    /// `dbGetAlarm` / `dbGetAlarmMsg` (`pvaGetAlarmMsg`,
968    /// `pvalink_lset.cpp:542-575`). A default `NMS` link reports its
969    /// remote severity here even though it leaves the owning record
970    /// unraised.
971    ///
972    /// `None` when no lset is registered for the scheme, the link is not
973    /// connected, or the lset does not track remote alarms. Scheme
974    /// dispatch mirrors [`Self::external_link_alarm`].
975    pub async fn external_link_alarm_snapshot(
976        &self,
977        name: &str,
978    ) -> Option<crate::server::database::RemoteAlarm> {
979        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
980            ("pva", rest)
981        } else if let Some(rest) = name.strip_prefix("ca://") {
982            ("ca", rest)
983        } else {
984            for lset in self.registered_link_sets().await {
985                if let Some(snap) = lset.remote_alarm(name).await {
986                    return Some(snap);
987                }
988            }
989            return None;
990        };
991        let lset = self.inner.link_sets.read().await.get(scheme)?;
992        lset.remote_alarm(body).await
993    }
994
995    /// Remote display / control / valueAlarm metadata for an external
996    /// (`pva://` / `ca://`) link, resolved through the registered
997    /// lset's [`LinkSet::link_metadata`] hook.
998    ///
999    /// This is the DB-link-API entry point that exposes the linked PV
1000    /// metadata pvxs's pvalink lset surfaces through its
1001    /// `pvaGetDBFtype` / `pvaGetElements` / `pvaGetControlLimits` /
1002    /// `pvaGetGraphicLimits` / `pvaGetAlarmLimits` / `pvaGetPrecision`
1003    /// / `pvaGetUnits` getters
1004    /// (`pvxs/ioc/pvalink_lset.cpp:700`). Scheme dispatch mirrors
1005    /// [`Self::external_link_alarm`]: an explicit `pva://` / `ca://`
1006    /// prefix selects the lset directly, a bare name tries every
1007    /// registered lset until one reports metadata.
1008    ///
1009    /// `None` when no lset is registered for the scheme or the lset
1010    /// has no cached value for the link (not yet connected).
1011    pub async fn external_link_metadata(
1012        &self,
1013        name: &str,
1014    ) -> Option<crate::server::database::LinkMetadata> {
1015        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
1016            ("pva", rest)
1017        } else if let Some(rest) = name.strip_prefix("ca://") {
1018            ("ca", rest)
1019        } else {
1020            for lset in self.registered_link_sets().await {
1021                if let Some(meta) = lset.link_metadata(name).await {
1022                    return Some(meta);
1023                }
1024            }
1025            return None;
1026        };
1027        let lset = self.inner.link_sets.read().await.get(scheme)?;
1028        lset.link_metadata(body).await
1029    }
1030
1031    /// C `dbGetControlLimits` / `dbGetGraphicLimits` / `dbGetAlarmLimits` /
1032    /// `dbGetPrecision` / `dbGetUnits` (`dbLink.c:344-393`) — the five
1033    /// metadata slots a record fetches THROUGH one of its links.
1034    ///
1035    /// The single owner of "what does this link say about display/control/
1036    /// alarm limits, units and precision". Record support (the routing
1037    /// layer) calls this from its `get_graphic_double` / `get_control_double`
1038    /// / `get_alarm_double` / `get_units` / `get_precision` implementations
1039    /// for the link-backed fields — calc/calcout/sub/aSub INPA..INPU,
1040    /// aSub OUTA.., seq DOn (`calcRecord.c:223-230`, `aSubRecord.c:356-369`,
1041    /// `subRecord.c:260-266`, `seqRecord.c:332-336`).
1042    ///
1043    /// # Return contract — this is C's status, not a convenience `Option`
1044    ///
1045    /// Each of C's five `dbGet*` entry points writes the caller's buffer
1046    /// ONLY on a zero return; every caller listed above ignores the status
1047    /// and keeps whatever the buffer already held. The two levels of
1048    /// `Option` here encode exactly that:
1049    ///
1050    /// * `None` — C returned non-zero (`S_db_noLSET` / `S_dbLib_badLink`).
1051    ///   The caller MUST leave its buffer untouched.
1052    /// * `Some(meta)` — C returned 0. Each `Some` field is a value C wrote;
1053    ///   a `None` field is a slot this link cannot report.
1054    ///
1055    /// # Per-link-class behaviour, from the C lset tables
1056    ///
1057    /// * **Constant link** — `None`, always. `dbConst_lset`
1058    ///   (`dbConstLink.c:234-248`) leaves all five slots `NULL`, so the
1059    ///   `!plset->getGraphicLimits` test in `dbGetGraphicLimits`
1060    ///   (`dbLink.c:358-359`) short-circuits to `S_db_noLSET` and NOTHING is
1061    ///   written. This is the case for the oracle's `field(INPA,"5")`
1062    ///   records: C's answer is *not* a propagated limit and *not* a
1063    ///   DBF-type-range default — the record's buffer keeps the seed it held
1064    ///   before the fetch (see the routing contract below). Measured: a
1065    ///   `calc` `field(INPA,"5")` serves display limits `0/0`.
1066    /// * **DB link** — `Some(meta)` from the target field, via
1067    ///   [`Self::db_link_metadata`].
1068    /// * **CA/PVA link** — delegated to the registered lset
1069    ///   ([`Self::external_link_metadata`]); C installs `dbCa`/`pvalink`'s
1070    ///   own lset for these, not `dbDb_lset`.
1071    /// * **Everything else** (unset link, hardware link) — `None`. C leaves
1072    ///   `plink->lset` NULL for these, so `dbLink.c:358` returns
1073    ///   `S_db_noLSET`.
1074    ///
1075    /// `visited` is the caller's chain state and carries C's
1076    /// `DBLINK_FLAG_VISITED` recursion guard — see
1077    /// [`Self::db_link_metadata`].
1078    ///
1079    /// # Contract for the routing layer
1080    ///
1081    /// This method reports what the LINK says. It does not know what the
1082    /// calling record should do with a `None`, because C's answer to that is
1083    /// per-slot and not uniform. Record support must seed its buffer, call
1084    /// this, and overwrite only on `Some` — the seed IS the answer whenever
1085    /// the fetch reports nothing. C's seeds, and the four slots that route
1086    /// through a link at all:
1087    ///
1088    /// | rset slot | seed before the fetch | C site |
1089    /// |---|---|---|
1090    /// | `get_graphic_double` | `(0.0, 0.0)` | `dbAccess.c:216` |
1091    /// | `get_alarm_double` | `NaN×4` | `dbAccess.c:290` |
1092    /// | `get_units` | `""` (NOT the record's `EGU`) | `dbAccess.c:378` |
1093    /// | `get_precision` | the record's own `PREC` | `calcRecord.c:191` |
1094    /// | `get_control_double` | **never fetched — see below** | |
1095    ///
1096    /// Two of those seeds are counter-intuitive and are measured facts, not
1097    /// deductions (softIoc + `caget -d DBR_CTRL_DOUBLE`, EPICS 7 base): on a
1098    /// `calc` with `field(INPA,"5") field(PREC,"7") field(EGU,"volts")`, C
1099    /// serves `INPA` with **precision 7** (the record's own `PREC` survives,
1100    /// because `get_precision` seeds `*pprecision = prec->prec` *before* the
1101    /// fetch and overwrites only on a zero status) but **units `""`** (the
1102    /// `strncpy(units, prec->egu)` fallback sits in the `else` arm that a
1103    /// link-backed field never takes — `calcRecord.c:172-181`).
1104    ///
1105    /// **Control limits must NOT be routed through a link.** `dbDbLink.c:414`
1106    /// really does install `dbDbGetControlLimits`, and `dbGetControlLimits` is
1107    /// public API (`dbLink.h:436`) — so this method reports it — but NOTHING
1108    /// in EPICS base calls it. Every record instead sends its link-backed
1109    /// fields to `recGblGetControlDouble` (`calcRecord.c:251`,
1110    /// `subRecord.c`, `calcoutRecord.c`, `aSubRecord.c:376`), i.e. to
1111    /// `getMaxRangeValues` — which is why C serves a `calc` `INPA` with
1112    /// `±1e300` control limits (measured) regardless of what the link points
1113    /// at. Routing this method's `control_limits` into a record's
1114    /// `get_control_double` would be a defect.
1115    pub async fn link_metadata(
1116        &self,
1117        link: &crate::server::record::ParsedLink,
1118        visited: &mut HashSet<String>,
1119    ) -> Option<crate::server::database::LinkMetadata> {
1120        use crate::server::record::ParsedLink;
1121        match link {
1122            ParsedLink::Db(db) => self.db_link_metadata(db, visited).await,
1123            ParsedLink::Ca(_) | ParsedLink::Pva(_) | ParsedLink::PvaJson(_) => {
1124                let name = link
1125                    .external_pv_name()
1126                    .expect("Ca/Pva/PvaJson link carries a PV name");
1127                self.external_link_metadata(&name).await
1128            }
1129            // Constant / unset / hardware / lnkCalc: no metadata lset slots.
1130            _ => None,
1131        }
1132    }
1133
1134    /// C `dbDbGetControlLimits` / `dbDbGetGraphicLimits` /
1135    /// `dbDbGetAlarmLimits` / `dbDbGetPrecision` / `dbDbGetUnits`
1136    /// (`dbDbLink.c:263-345`) — the `dbDb_lset` metadata slots
1137    /// (`dbDbLink.c:414-415`).
1138    ///
1139    /// Each C slot is one `dbDbGetOptionLoopSafe(plink, DBR_DOUBLE, &buf,
1140    /// DBR_<OPTION>)` (`dbDbLink.c:239-261`), i.e. a `dbGet` on the target's
1141    /// `dbAddr` asking for one option. This port issues the equivalent single
1142    /// [`snapshot_for_field`] — the port's `dbGet`-with-options — and reads
1143    /// all five out of it. One fetch instead of five is observationally
1144    /// identical: `dbGet` computes each option independently from the target
1145    /// record's own rset slots.
1146    ///
1147    /// # The recursion guard is load-bearing
1148    ///
1149    /// C flags the link `DBLINK_FLAG_VISITED` across the inner `dbGet` and
1150    /// clears it after (`dbDbLink.c:248-258`), because — in its own words
1151    /// (`dbDbLink.c:236-238`) — "Some records get options (precsision,
1152    /// units, ...) for some fields from an input link. We need to catch the
1153    /// case that this link points back to the same field or we will end in
1154    /// an infinite recursion." A re-entered link leaves `status` at its
1155    /// `S_dbLib_badLink` initialiser (`dbDbLink.c:246`) and writes nothing.
1156    ///
1157    /// The port cannot hang a flag on the link: [`ParsedLink`] is a cloned
1158    /// value, not C's stable `struct link *`. The guard therefore rides in
1159    /// the caller's `visited` set, keyed by link TARGET (`record.field`) —
1160    /// the same substitution `process_passive_db_source` makes for C's PACT
1161    /// guard, and it terminates the identical cycles: an `A.INPA -> B.VAL`,
1162    /// `B.INPA -> A.VAL` pair blocks on the second visit to `B.VAL`.
1163    ///
1164    /// # Fallback chain when the target field has no support
1165    ///
1166    /// `dbGet` does NOT fail when the target's rset lacks a slot — it fills
1167    /// a default, turns the option bit off, and still returns 0, so the
1168    /// `dbDbGet*` wrapper reads that default out and returns 0 (writing it
1169    /// to the record). Transcribed per option:
1170    ///
1171    /// | slot | no support on target | C site |
1172    /// |---|---|---|
1173    /// | graphic limits | `(0.0, 0.0)` — `memset` | `dbAccess.c:241-242` |
1174    /// | control limits | `(0.0, 0.0)` — `memset` | `dbAccess.c:281-283` |
1175    /// | alarm limits | `NaN×4` — pre-filled initialiser, assigned unconditionally | `dbAccess.c:290,317-330` |
1176    /// | precision | `0` — `memset`, also when the field is not FLOAT/DOUBLE | `dbAccess.c:387-395` |
1177    /// | units | `""` — `memset` | `dbAccess.c:377-386` |
1178    ///
1179    /// Note graphic/control zero but alarm is NaN: `get_alarm` seeds
1180    /// `{epicsNAN,...}` and assigns the buffer whether or not the slot ran,
1181    /// whereas `get_graphics`/`get_control` `memset` the buffer in their
1182    /// no-data arm. The port's [`Snapshot`] accessors already return `None`
1183    /// for exactly "record type has no such rset slot", so each default is a
1184    /// single `unwrap_or` below.
1185    ///
1186    /// This is why the link layer never reaches `getMaxRangeValues`: C's
1187    /// DBF-type-range default is produced by the TARGET record's own
1188    /// `get_graphic_double` calling `recGblGetGraphicDouble`
1189    /// (`recGbl.c:146-153`), which is inside `snapshot_for_field` — it comes
1190    /// back through the "has support" arm, already applied.
1191    ///
1192    /// [`snapshot_for_field`]: crate::server::record::RecordInstance::snapshot_for_field
1193    /// [`Snapshot`]: crate::server::snapshot::Snapshot
1194    /// [`ParsedLink`]: crate::server::record::ParsedLink
1195    async fn db_link_metadata(
1196        &self,
1197        db: &crate::server::record::DbLink,
1198        visited: &mut HashSet<String>,
1199    ) -> Option<crate::server::database::LinkMetadata> {
1200        let key = format!("{}.{}", db.record, db.field);
1201        // C `if (!(mutable_plink->flags & DBLINK_FLAG_VISITED))`
1202        // (`dbDbLink.c:253`): a re-entered link returns the `S_dbLib_badLink`
1203        // initialiser without touching the buffer.
1204        if !visited.insert(key.clone()) {
1205            return None;
1206        }
1207        let meta = self.db_target_metadata(db).await;
1208        // C `mutable_plink->flags &= ~DBLINK_FLAG_VISITED` (`dbDbLink.c:257`)
1209        // — the guard spans only the inner fetch, so a diamond (two distinct
1210        // links onto one target) still reports metadata on both.
1211        visited.remove(&key);
1212        meta
1213    }
1214
1215    /// The target-side half of [`Self::db_link_metadata`], with C
1216    /// `dbInitLink`'s locality dispatch (`dbLink.c:118-130`): a target that
1217    /// is not in this IOC never gets `dbDb_lset` at all — it is made a CA
1218    /// link, so its metadata comes from the `dbCa` lset. Mirrors the same
1219    /// split [`Self::read_target_value`] makes for the value path.
1220    async fn db_target_metadata(
1221        &self,
1222        db: &crate::server::record::DbLink,
1223    ) -> Option<crate::server::database::LinkMetadata> {
1224        if !self.has_name_no_resolve(&db.record).await {
1225            let pv = if db.field == "VAL" {
1226                db.record.clone()
1227            } else {
1228                format!("{}.{}", db.record, db.field)
1229            };
1230            return self.external_link_metadata(&pv).await;
1231        }
1232        let record = self.get_record(&db.record).await?;
1233        // C `dbGet(paddr, DBR_DOUBLE, &buffer, &option, ...)` under the
1234        // target's lock (`dbDbLink.c:252-256` between `dbScanLock` /
1235        // `dbScanUnlock`). A field the target does not have is C's
1236        // `dbNameToAddr` failure at link-init time, i.e. no lset — `None`.
1237        let snapshot = record.read().await.snapshot_for_field(&db.field)?;
1238        Some(crate::server::database::LinkMetadata {
1239            // `dbDbGetDBFtype` = `dbChannelFinalFieldType` (`dbDbLink.c:151-155`)
1240            // and `dbDbGetElements` = `dbChannelFinalElements`
1241            // (`dbDbLink.c:157-162`). A local DB link is always connected
1242            // (`dbDbIsConnected` returns TRUE unconditionally,
1243            // `dbDbLink.c:146-149`), so these are never `None` here.
1244            dbf_type: Some(field_type_to_link_dbf(snapshot.value.db_field_type())),
1245            element_count: Some(snapshot.value.count() as i64),
1246            graphic_limits: Some(snapshot.graphic_limits().unwrap_or((0.0, 0.0))),
1247            control_limits: Some(snapshot.control_limits().unwrap_or((0.0, 0.0))),
1248            alarm_limits: Some(snapshot.alarm_limits().unwrap_or((
1249                f64::NAN,
1250                f64::NAN,
1251                f64::NAN,
1252                f64::NAN,
1253            ))),
1254            precision: Some(snapshot.precision().unwrap_or(0)),
1255            units: Some(
1256                snapshot
1257                    .units()
1258                    .map(|u| u.as_str_lossy().into_owned())
1259                    .unwrap_or_default(),
1260            ),
1261            // `display.description` has no C lset slot — it is a pvxs-only
1262            // pvalink extra (`LinkMetadata::description`).
1263            description: None,
1264        })
1265    }
1266
1267    /// C `dbGetLink` PP rule: if a DB input link is `ProcessPassive`
1268    /// and its source record is `Passive`-scanned, process the source
1269    /// record before its value is read so the reader sees a freshly
1270    /// computed value. No-op for non-PP links or non-passive sources.
1271    ///
1272    /// Shared by `read_link_value_soft` (single-INP path) and the
1273    /// multi-input fetch loop (`INPA..INPL` for calc/sel/sub/aSub) so
1274    /// both paths get the identical C-correct PP-processing behavior.
1275    ///
1276    /// The caller's `visited` set and `depth` are threaded through into
1277    /// the source's processing cycle — NOT a fresh set / depth 0. This
1278    /// is required for the cycle guard to span the PP hop: in C,
1279    /// `calcRecord.c::process` sets `prec->pact = TRUE` *before*
1280    /// `fetch_values()` (calcRecord.c:119-120), so when a PP input link
1281    /// re-enters `dbProcess` on a record already mid-fetch, the
1282    /// `if (precord->pact) goto all_done;` guard (dbAccess.c:537-557)
1283    /// terminates the cycle after one bounce. The Rust port sets its
1284    /// PACT `AtomicBool` only on `AsyncPending` *after* `record.process()`
1285    /// returns, so it cannot catch a record mid-link-fetch. Threading
1286    /// the caller's `visited` set makes the existing `visited.insert`
1287    /// cycle guard (`process_record_with_links_inner`, processing.rs)
1288    /// fire instead — an A↔B `PP` cycle bails when the second hop tries
1289    /// to re-insert a name already on the chain. The FLNK path threads
1290    /// `visited`/`depth` the same way (processing.rs FLNK dispatch).
1291    pub(crate) async fn process_passive_db_source(
1292        &self,
1293        db: &crate::server::record::DbLink,
1294        visited: &mut HashSet<String>,
1295        depth: usize,
1296    ) {
1297        if db.policy != crate::server::record::LinkProcessPolicy::ProcessPassive {
1298            return;
1299        }
1300        if let Some(src) = self.get_record(&db.record).await {
1301            let is_passive =
1302                src.read().await.common.scan == crate::server::record::ScanType::Passive;
1303            if is_passive {
1304                // recursive INP-link source processing within
1305                // one chain — gate held by the foreign entry record.
1306                let _ = self
1307                    .process_record_with_links_recursive(&db.record, visited, depth + 1)
1308                    .await;
1309            }
1310        }
1311    }
1312
1313    /// Read a value from a parsed link for INP (only reads DB links when soft channel).
1314    ///
1315    /// `visited` / `depth` are the caller's processing-chain state — a PP
1316    /// input link's source is processed *within* that same chain so the
1317    /// `visited` cycle guard spans the PP hop (see
1318    /// [`Self::process_passive_db_source`]).
1319    pub async fn read_link_value_soft(
1320        &self,
1321        link: &crate::server::record::ParsedLink,
1322        is_soft: bool,
1323        visited: &mut HashSet<String>,
1324        depth: usize,
1325    ) -> Option<EpicsValue> {
1326        match link {
1327            // A CONSTANT input link delivers NOTHING at process time. C
1328            // `dbConstGetValue` (`dbConstLink.c:219-225`) returns status 0
1329            // having written nothing to the buffer, so `read_ai`'s
1330            // `dbGetLink(&prec->inp, ...)` leaves VAL exactly as it was; the
1331            // array/soft-callback dev supports state the same rule outright
1332            // (`devWfSoft.c::read_wf` and `devAaiSoft.c::read_aai`:
1333            // `if (dbLinkIsConstant(pinp)) return 0;`). The constant reaches
1334            // the record ONCE, at init, through the
1335            // `recGblInitConstantLink`/`dbLoadLinkArray` owner
1336            // (`rec_gbl_init_constant_links`).
1337            //
1338            // Re-delivering it every cycle — as this arm did — made a constant
1339            // INP overwrite the record's VAL on every process, so a client
1340            // caput to a `field(INP, "5")` ai (or the data a client pushed
1341            // into an aai/waveform whose INP is an unset/constant link, which
1342            // is how EVERY device-fed and client-fed array record is
1343            // configured) was clobbered on the next scan.
1344            crate::server::record::ParsedLink::Constant(_) => None,
1345            crate::server::record::ParsedLink::Db(db) if is_soft => {
1346                // PP: process source record if Passive before reading
1347                self.process_passive_db_source(db, visited, depth).await;
1348                self.read_db_link_value(db).await
1349            }
1350            crate::server::record::ParsedLink::Ca(_)
1351            | crate::server::record::ParsedLink::Pva(_)
1352            | crate::server::record::ParsedLink::PvaJson(_)
1353                if is_soft =>
1354            {
1355                let name = link
1356                    .external_pv_name()
1357                    .expect("Ca/Pva/PvaJson link carries a PV name");
1358                self.resolve_external_pv(&name).await
1359            }
1360            // lnkCalc evaluates regardless of `is_soft` — the input
1361            // PVs may themselves be local DB targets (which need the
1362            // soft path) or remote CA/PVA, but the calc evaluation
1363            // is uniform either way.
1364            crate::server::record::ParsedLink::Calc(calc) => self.evaluate_calc_link(calc).await,
1365            _ => None,
1366        }
1367    }
1368
1369    /// C `processTarget` (dbDbLink.c:474-528) — and the gate that is the ONLY
1370    /// way to reach it. The single owner of the link-side `PUTF`/`RPRO`
1371    /// transition.
1372    ///
1373    /// **Invariant:** `PUTF`/`RPRO` on a link target are written only by
1374    /// `processTarget`, and `processTarget` is reachable only for a PASSIVE
1375    /// target or a `.PROC` write. C reaches it through exactly two gates, both
1376    /// of which return BEFORE it otherwise — see [`ProcessTargetGate`].
1377    ///
1378    /// The port had five clones of the body, each applying the Passive test to
1379    /// the *process* call only and mutating PUTF/RPRO above it. So an FLNK to a
1380    /// busy periodic record got `RPRO = 1`, and its async completion fired an
1381    /// extra unscheduled cycle — an extra device write and an extra FLNK chain
1382    /// (R18-93). The gate now lives inside the owner, where it cannot be
1383    /// forgotten.
1384    ///
1385    /// The body is C's, in order:
1386    ///
1387    /// * target not PACT — normal propagation, `target.putf = src_putf`, and
1388    ///   the put-notify join (C `dbNotifyAdd`, dbDbLink.c:460);
1389    /// * target PACT, `src_putf`, and the target is not already on this process
1390    ///   chain (C `claim_dst`) — `target.rpro = true`, `target.putf = false`, so
1391    ///   the in-flight cycle reprocesses on completion and attributes the put to
1392    ///   the originator;
1393    /// * otherwise nothing: the target is being processed recursively by us, or
1394    ///   this was not a `dbPutField`.
1395    ///
1396    /// A PACT target is not processed (C `dbProcess` on an active record
1397    /// returns without running the record, dbAccess.c:536-557).
1398    pub(crate) async fn process_target(
1399        &self,
1400        target_name: &str,
1401        gate: ProcessTargetGate,
1402        src_putf: bool,
1403        src_notify: Option<&Arc<NotifyWaitSet>>,
1404        visited: &mut HashSet<String>,
1405        depth: usize,
1406    ) {
1407        let Some(target_rec) = self.get_record(target_name).await else {
1408            return;
1409        };
1410        let process = {
1411            let mut tg = target_rec.write().await;
1412            // The gate. A target that does not pass it never reaches
1413            // `processTarget`, so it gets no PUTF, no RPRO, and no put-notify
1414            // join — the join in particular would `enter` the wait-set without
1415            // ever `leave`ing it.
1416            if !gate.admits(tg.common.scan) {
1417                return;
1418            }
1419            let pact = tg.is_processing();
1420            if !pact {
1421                tg.common.putf = src_putf;
1422                join_put_notify(&mut tg, src_notify);
1423            } else if src_putf && !visited.contains(target_name) {
1424                tg.common.rpro = 1;
1425                tg.common.putf = false;
1426            }
1427            !pact
1428        };
1429        if process {
1430            // Recursive target processing within one chain — the gate is
1431            // already held by the foreign entry record.
1432            let _ = self
1433                .process_record_with_links_recursive(target_name, visited, depth + 1)
1434                .await;
1435        }
1436    }
1437
1438    /// Write a value through a DbLink, optionally processing the target if PP and Passive.
1439    ///
1440    /// Returns `true` when the write failed — C `dbPutLink` status `!= 0`:
1441    /// the local `dbPut` was rejected (conversion error, missing field) or
1442    /// the non-local external write returned `Err`. Callers that mirror a
1443    /// record's `dbPutLink` status (e.g. dfanout `push_values` raising
1444    /// `LINK_ALARM/MAJOR`, dfanoutRecord.c:312) fold this into the source's
1445    /// alarm; the fanout/seq dispatch paths ignore it.
1446    ///
1447    /// `src_putf` carries the source record's `PUTF` bit so the target inherits
1448    /// it the same way C `dbDbLink.c::processTarget` propagates it (lines 470-498):
1449    ///
1450    /// - target not pact: `target.putf = src_putf` (normal propagation),
1451    /// - target pact AND `src_putf` AND target not on current process chain:
1452    ///   `target.rpro = true`, `target.putf = false` so the in-flight cycle
1453    ///   reprocesses on completion attributing the put to the originator,
1454    /// - otherwise: no PUTF change (target is either being processed
1455    ///   recursively by us, or wasn't triggered by a dbPutField).
1456    ///
1457    /// Without this, a CA WRITE_NOTIFY landing on an upstream calc/seq/dfanout
1458    /// that fanned out via DB OUT links would see `target.putf = 0` on every
1459    /// downstream record — breaking dbNotify completion attribution and any
1460    /// device-support code that uses PUTF to distinguish operator-driven from
1461    /// scan-driven processing.
1462    pub(crate) async fn write_db_link_value(
1463        &self,
1464        link: &crate::server::record::DbLink,
1465        value: EpicsValue,
1466        src: OutLinkSrc<'_>,
1467        visited: &mut HashSet<String>,
1468        depth: usize,
1469    ) -> bool {
1470        let target_name = if link.field == "VAL" {
1471            link.record.clone()
1472        } else {
1473            format!("{}.{}", link.record, link.field)
1474        };
1475        // C `dbInitLink` locality (`dbLink.c:118-130`): a target record
1476        // not present in this IOC is a CA link, so its write is routed
1477        // through the external put path (`dbCaPutLink`), not a local
1478        // `dbPut`. The alarm-inheritance / PUTF / `processTarget`
1479        // machinery below is the local-DB `dbDbPutValue` body
1480        // (dbDbLink.c:372-393), which `dbCaPutLink` performs none of —
1481        // so a non-local target returns right after the remote write.
1482        // OUTPUT-side twin of the `read_db_link_value` locality fallback.
1483        if !self.has_name_no_resolve(&link.record).await {
1484            let op = Self::external_put_op(src.notify);
1485            if let Err(e) = self.write_external_pv(&target_name, value, op).await {
1486                eprintln!("OUT-link write to external PV '{target_name}' failed: {e}");
1487                return true;
1488            }
1489            return false;
1490        }
1491        // an OUT-link write-back is an internal step of the
1492        // processing chain that already holds the entry record's
1493        // advisory write gate (`dbScanLock` analogue). It must use the
1494        // `_already_locked` write so it does not re-acquire a gate: a
1495        // self-referencing OUT link (`SELF PP`) would otherwise
1496        // dead-lock on the entry record's own non-reentrant gate. C
1497        // `dbDbPutValue` writes the OUT-link target under the same
1498        // lock set the chain already owns.
1499        let put_result = self.put_pv_already_locked(&target_name, value).await;
1500
1501        // C `dbDbPutValue` (dbDbLink.c:382-383) folds the SOURCE
1502        // record's alarm into the destination via `recGblInheritSevrMsg`,
1503        // AFTER the `dbPut` and BEFORE `processTarget`. The fields C reads
1504        // there are `psrce->nsta/nsev/namsg` — the source's PENDING alarm,
1505        // because the put runs inside the source's `process()`, before its
1506        // `recGblResetAlarms`. Every OUT-put site in this port drives its
1507        // writes in that same pre-commit window and hands them
1508        // [`LinkAlarm::pending`]; a committed snapshot would carry the
1509        // PREVIOUS cycle's severity. The inherited
1510        // severity lands in the dest's PENDING nsev/nsta(/namsg for MSS);
1511        // the dest commits it on its next `rec_gbl_reset_alarms` — its
1512        // own process cycle, reached below for a `.PROC`/`PP` link, or a
1513        // later independent scan otherwise. NMS (the common case) skips
1514        // the dest lookup/lock entirely.
1515        if link.monitor_switch != crate::server::record::MonitorSwitch::NoMaximize {
1516            if let Some(target_rec) = self.get_record(&link.record).await {
1517                let mut tg = target_rec.write().await;
1518                inherit_sevr_msg(&mut tg.common, link.monitor_switch, src.alarm);
1519            }
1520        }
1521
1522        // C `dbDbPutValue` (dbDbLink.c:384-385) returns the put status
1523        // immediately after the alarm inheritance and BEFORE the
1524        // `.PROC`/`PP` `processTarget` branch: only a successful write
1525        // reaches target processing. A failed OUT-link write (missing
1526        // field, record put rejection) must therefore NOT trigger the
1527        // target's process cycle, which would otherwise run the target
1528        // on its stale field value and diverge from C on side effects,
1529        // FLNK, alarms, and put-notify completion ordering. The alarm
1530        // inheritance above already ran (C folds it regardless of
1531        // status), matching the C ordering exactly.
1532        //
1533        // An empty array into a scalar field is NOT such a failure: C
1534        // `dbPut` accepts it, raises LINK/INVALID on the destination and
1535        // returns 0 (`dbAccess.c:1370-1372`), so a `PP` link still
1536        // processes its target — see `field_io::PutRequest`.
1537        if put_result.is_err() {
1538            return true;
1539        }
1540
1541        // C `dbDbPutValue` (`dbDbLink.c:387-390`) processes the target when the
1542        // destination field is `.PROC` **or** the link carries `pvlOptPP` (an
1543        // explicit ` PP` token → `ProcessPassive`) and the target is Passive.
1544        // The two arms are different gates, not one: the `.PROC` arm is
1545        // independent of both the PP flag and the target's SCAN (R18-94). It is
1546        // therefore checked here in the write path rather than encoded as a
1547        // parse-time policy: a modifier-less link defaults to `NoProcess`
1548        // uniformly (INPUT and OUTPUT alike), and writing into a record's
1549        // `.PROC` field still forces a process.
1550        let gate = if link.field == "PROC" {
1551            Some(ProcessTargetGate::ProcField)
1552        } else if link.policy == crate::server::record::LinkProcessPolicy::ProcessPassive {
1553            Some(ProcessTargetGate::ScanPassive)
1554        } else {
1555            None
1556        };
1557        if let Some(gate) = gate {
1558            // Through the single `processTarget` owner, which holds the gate.
1559            // Alias-aware: `process_target` resolves the name, as
1560            // `process_record_with_links` does at entry.
1561            self.process_target(&link.record, gate, src.putf, src.notify, visited, depth)
1562                .await;
1563        }
1564        // Successful local write (C `dbPutLink` status 0).
1565        false
1566    }
1567
1568    /// Write a value to an external (`ca://` / `pva://`) OUT link
1569    /// through the registered [`LinkSet`].
1570    ///
1571    /// This is the OUTPUT-side twin of [`Self::resolve_external_pv`]:
1572    /// the input side dispatches a `ParsedLink::Ca`/`Pva` read through
1573    /// `lset.get_value`, this dispatches a record's OUT-link write
1574    /// through `lset.put_value`. Mirrors C `dbLink.c::dbPutLink`
1575    /// (dbLink.c:434-448), which routes every link write — DB or CA —
1576    /// through `plink->lset->putValue` and raises a link alarm
1577    /// (`setLinkAlarm`) on failure.
1578    ///
1579    /// `name` may be a fully scheme-prefixed string (`pva://X`,
1580    /// `ca://X`) or the bare body (the form stored in
1581    /// `ParsedLink::Ca`/`Pva` after `record/link.rs` strips the
1582    /// scheme). For a bare name every registered lset is tried in
1583    /// turn — the first whose `put_value` succeeds wins.
1584    ///
1585    /// Returns `Ok(())` on a successful remote write, `Err(reason)`
1586    /// when no lset is registered for the scheme or the lset rejects
1587    /// the write (the caller folds that into a LINK alarm — it must
1588    /// never panic).
1589    ///
1590    /// `op` carries the delivery semantics the lset must honour:
1591    /// [`LinkPutOp::Async`] when the write is part of a put-notify /
1592    /// blocking-put chain (mirrors C `dbPutLinkAsync` / pvxs
1593    /// `pvaPutValueAsync`), [`LinkPutOp::Plain`] otherwise.
1594    pub(crate) async fn write_external_pv(
1595        &self,
1596        name: &str,
1597        value: EpicsValue,
1598        op: LinkPutOp,
1599    ) -> Result<(), String> {
1600        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
1601            ("pva", rest)
1602        } else if let Some(rest) = name.strip_prefix("ca://") {
1603            ("ca", rest)
1604        } else {
1605            // Bare name — try every registered lset in turn, first
1606            // accepting write wins (mirrors `resolve_external_pv`'s
1607            // bare-name path).
1608            let lsets = self.registered_link_sets().await;
1609            if lsets.is_empty() {
1610                return Err(format!("no link set registered for external link '{name}'"));
1611            }
1612            let mut last_err = String::new();
1613            for lset in lsets {
1614                match lset.put_value(name, value.clone(), op).await {
1615                    Ok(()) => {
1616                        // Production drain of any retry-queued OUT
1617                        // writes on this lset now that a write has
1618                        // reached it (the channel may have just
1619                        // reconnected). pvxs replays the queued
1620                        // put from record processing, not test code
1621                        // (`pvalink_channel.cpp:220-263`).
1622                        lset.flush_puts().await;
1623                        return Ok(());
1624                    }
1625                    Err(e) => last_err = e,
1626                }
1627            }
1628            return Err(last_err);
1629        };
1630        let lset = self
1631            .inner
1632            .link_sets
1633            .read()
1634            .await
1635            .get(scheme)
1636            .ok_or_else(|| format!("no '{scheme}' link set registered for '{name}'"))?;
1637        let result = lset.put_value(body, value, op).await;
1638        if result.is_ok() {
1639            lset.flush_puts().await;
1640        }
1641        result
1642    }
1643
1644    /// Fire a forward link (FLNK) whose target is an external
1645    /// (`pva://` / `ca://`) PV — the FWD-link counterpart of
1646    /// [`Self::write_external_pv`]. Resolves the scheme (or tries every
1647    /// registered lset for a bare name, first to accept wins, exactly as
1648    /// the OUT-write path does) and delegates to [`LinkSet::scan_forward`].
1649    ///
1650    /// Mirrors C `dbScanFwdLink` → `plink->lset->scanForward`
1651    /// (`dbLink.c:475-480`): the database hands the forward link to the
1652    /// link set, which (for pvalink) runs `pvaScanForward`. Returns the
1653    /// lset's `Err` unchanged so the caller can raise LINK/INVALID on the
1654    /// owning record (pvxs `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM)`).
1655    pub(crate) async fn scan_forward_external_pv(&self, name: &str) -> Result<(), String> {
1656        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
1657            ("pva", rest)
1658        } else if let Some(rest) = name.strip_prefix("ca://") {
1659            ("ca", rest)
1660        } else {
1661            // Bare name — try every registered lset in turn, first
1662            // accepting the forward wins (mirrors `write_external_pv`'s
1663            // bare-name path so FWD and OUT route a bare target alike).
1664            let lsets = self.registered_link_sets().await;
1665            if lsets.is_empty() {
1666                return Err(format!("no link set registered for forward link '{name}'"));
1667            }
1668            let mut last_err = String::new();
1669            for lset in lsets {
1670                match lset.scan_forward(name).await {
1671                    Ok(()) => return Ok(()),
1672                    Err(e) => last_err = e,
1673                }
1674            }
1675            return Err(last_err);
1676        };
1677        let lset = self
1678            .inner
1679            .link_sets
1680            .read()
1681            .await
1682            .get(scheme)
1683            .ok_or_else(|| format!("no '{scheme}' link set registered for '{name}'"))?;
1684        lset.scan_forward(body).await
1685    }
1686
1687    /// Map a record's put-completion wait-set to the external-link put
1688    /// op. A write that originates inside a put-notify / blocking-put
1689    /// chain (the source record carries a completion wait-set) is
1690    /// delivered as [`LinkPutOp::Async`] (the pvxs `pvaPutValueAsync` /
1691    /// C `dbPutLinkAsync` path); a plain record-processing OUT write is
1692    /// [`LinkPutOp::Plain`]. Single owner of the notify→op mapping so
1693    /// the external-OUT dispatch sites cannot diverge.
1694    fn external_put_op(src_notify: Option<&Arc<NotifyWaitSet>>) -> LinkPutOp {
1695        if src_notify.is_some() {
1696            LinkPutOp::Async
1697        } else {
1698            LinkPutOp::Plain
1699        }
1700    }
1701
1702    /// Resolve an OUT link's TARGET metadata — the DBF field type, the
1703    /// element capacity, and whether C would classify the link as a
1704    /// `CA_LINK`. Single owner of the target-metadata lookup every soft
1705    /// device support's write-buffer switch reads
1706    /// ([`Record::multi_output_buffer`]).
1707    ///
1708    /// C's resolution, mirrored branch for branch:
1709    /// - local DB target — `dbNameToAddr` (`devsCalcoutSoft.c:127-131`):
1710    ///   `field_type` = the target field's DBF type, `no_elements` = its
1711    ///   capacity. An unresolvable name leaves C's initializers
1712    ///   (`field_type = 0`, `n_elements = 1`) — the port reports
1713    ///   [`OutTarget::UNRESOLVED`], whose `None` type routes to the device
1714    ///   support's `default:` arm.
1715    /// - external target (explicit `ca://`/`pva://` OR a DB-parsed name not
1716    ///   in this IOC, which C classifies as a CA link) —
1717    ///   `dbCaGetLinkDBFtype` / `dbCaGetNelements` (`dbCa.c:662-704`,
1718    ///   both `-1` while disconnected): the lset's cached
1719    ///   [`LinkMetadata`], `UNRESOLVED` when the link is down.
1720    ///
1721    /// `no_elements` is a field CAPACITY, which the port does not carry per
1722    /// field: an array field reports its record's `NELM` when it has one and
1723    /// its current length otherwise; a scalar field reports 1.
1724    pub(crate) async fn resolve_out_target(
1725        &self,
1726        link: &crate::server::record::ParsedLink,
1727    ) -> OutTarget {
1728        let external = |name: String| async move {
1729            match self.external_link_metadata(&name).await {
1730                Some(m) => {
1731                    let field_type = m.dbf_type.map(link_dbf_to_field_type);
1732                    OutTarget {
1733                        field_type,
1734                        element_count: m.element_count.unwrap_or(1).max(1),
1735                        is_ca_link: true,
1736                        // A remote channel reports the DBR type its DBF class
1737                        // is served as (`dbCaGetLinkDBFtype` → the channel's
1738                        // type): a remote `DBF_MENU`/`DBF_DEVICE`/link field
1739                        // arrives as `DBR_ENUM`/`DBR_STRING`, so the two wire
1740                        // types ARE the string class over a CA link.
1741                        puts_as_string: matches!(
1742                            field_type,
1743                            Some(DbFieldType::String) | Some(DbFieldType::Enum)
1744                        ),
1745                    }
1746                }
1747                None => OutTarget {
1748                    is_ca_link: true,
1749                    ..OutTarget::UNRESOLVED
1750                },
1751            }
1752        };
1753        match link {
1754            crate::server::record::ParsedLink::Db(db) => {
1755                if !self.has_name_no_resolve(&db.record).await {
1756                    // Non-local ⇒ CA link in C (`dbInitLink` locality).
1757                    let target_name = if db.field == "VAL" {
1758                        db.record.clone()
1759                    } else {
1760                        format!("{}.{}", db.record, db.field)
1761                    };
1762                    return external(target_name).await;
1763                }
1764                let Some(target) = self.get_record(&db.record).await else {
1765                    return OutTarget::UNRESOLVED;
1766                };
1767                let guard = target.read().await;
1768                let field_type = crate::server::record::record_instance::declared_field_type_of(
1769                    guard.record.as_ref(),
1770                    &db.field,
1771                )
1772                .or_else(|| guard.record.get_field(&db.field).map(|v| v.db_field_type()));
1773                let element_count = match guard.record.get_field(&db.field) {
1774                    Some(v) if v.is_array() => guard
1775                        .record
1776                        .get_field("NELM")
1777                        .and_then(|n| n.as_int_i64())
1778                        .filter(|n| *n > 0)
1779                        .unwrap_or(v.count() as i64),
1780                    _ => 1,
1781                };
1782                OutTarget {
1783                    field_type,
1784                    element_count: element_count.max(1),
1785                    is_ca_link: false,
1786                    // C's `dbNameToAddr` gives the target field's DBF CLASS,
1787                    // which includes `DBF_MENU` / `DBF_DEVICE` — classes the
1788                    // port's DBR-typed `field_type` cannot name. The target
1789                    // record classifies its own field.
1790                    puts_as_string: guard.field_puts_as_string(&db.field),
1791                }
1792            }
1793            crate::server::record::ParsedLink::Ca(_)
1794            | crate::server::record::ParsedLink::Pva(_)
1795            | crate::server::record::ParsedLink::PvaJson(_) => {
1796                let name = link
1797                    .external_pv_name()
1798                    .expect("Ca/Pva/PvaJson link carries a PV name");
1799                external(name.to_string()).await
1800            }
1801            // Constant / Hw / Calc / None: not a writable target — the write
1802            // path no-ops, so the metadata is never used.
1803            _ => OutTarget::UNRESOLVED,
1804        }
1805    }
1806
1807    /// Apply the writing record's device-support write-buffer switch to a
1808    /// multi-output pair: resolve the TARGET ([`Self::resolve_out_target`]),
1809    /// then let the record pick the buffer C's `write_*` would put
1810    /// ([`Record::multi_output_buffer`]).
1811    ///
1812    /// The record lock is NOT held across the target resolution — a
1813    /// self-referencing OUT link would re-enter the source record's own
1814    /// gate — so the target is resolved first and the record re-read to
1815    /// make the pick, exactly as C's device support reads its own fields
1816    /// after `dbNameToAddr` / `dbCaGet*`.
1817    pub(crate) async fn multi_out_buffer_choice(
1818        &self,
1819        rec: &Arc<RwLock<RecordInstance>>,
1820        link_field: &str,
1821        link: &crate::server::record::ParsedLink,
1822        staged: EpicsValue,
1823    ) -> EpicsValue {
1824        let target = self.resolve_out_target(link).await;
1825        let guard = rec.read().await;
1826        guard
1827            .record
1828            .multi_output_buffer(link_field, staged, &target)
1829    }
1830
1831    /// The record's generic multi-output OUT writes — scalcout / acalcout
1832    /// `OUT`->`OVAL`, the pairs a record declares through
1833    /// [`Record::multi_output_links`]. C's soft device support performs these
1834    /// from `writeValue`, i.e. inside `process()` and BEFORE `monitor()`
1835    /// commits the cycle's alarm, so this runs pre-commit: a failed put's
1836    /// `LINK_ALARM`/`INVALID` (raised inside [`Self::write_out_link_value`])
1837    /// lands in the SAME cycle's committed SEVR and monitor posts.
1838    ///
1839    /// SINGLE-OWNER INVARIANT: a record type whose link groups are dispatched
1840    /// by [`Self::dispatch_multi_output`] (fanout/dfanout/seq) MUST be skipped
1841    /// here — otherwise its `LNKn`/`OUTn` would be written twice per cycle.
1842    /// `sseq` once implemented `Record::multi_output_links` as well and was
1843    /// double-dispatched; the `multi_output_dispatch_owned` gate makes that
1844    /// structurally impossible rather than fixed at the record.
1845    pub(crate) async fn dispatch_multi_output_values(
1846        &self,
1847        rec: &Arc<RwLock<RecordInstance>>,
1848        src: OutLinkSrc<'_>,
1849        skip_out: bool,
1850        visited: &mut HashSet<String>,
1851        depth: usize,
1852    ) {
1853        let pairs = {
1854            let instance = rec.read().await;
1855            // IVOA=Don't_drive veto (execOutput `nsev >= INVALID` →
1856            // Don't_drive `break`, sCalcoutRecord.c:794). `skip_out` is the
1857            // decision the cycle's single IVOA owner already made, on the
1858            // severity `checkAlarms` produced — re-deriving it here from
1859            // `nsev` would read an alarm the OUT write above may have raised
1860            // itself (a failed put's LINK_ALARM/INVALID), acting on a veto C
1861            // never applied.
1862            let links = if skip_out || multi_output_dispatch_owned(instance.record.record_type()) {
1863                &[][..]
1864            } else {
1865                instance.record.multi_output_links()
1866            };
1867            let mut pairs = Vec::new();
1868            for &(link_field, val_field) in links {
1869                let link_str = match instance.record.get_field(link_field) {
1870                    Some(EpicsValue::String(s)) => s,
1871                    _ => continue,
1872                };
1873                if link_str.is_empty() {
1874                    continue;
1875                }
1876                if let Some(val) = instance.record.get_field(val_field) {
1877                    pairs.push((link_field, link_str, val));
1878                }
1879            }
1880            pairs
1881        };
1882        for (link_field, link_str, val) in pairs {
1883            // `multi_output_links` carries record OUT links (scalcout /
1884            // acalcout `OUT` — `DBF_OUTLINK`) driven via `dbPutLink` →
1885            // `dbDbPutValue` (`dbDbLink.c:388`): a bare DB link is NPP, the
1886            // value is written but the target is NOT processed.
1887            // `parse_output_link_v2` applies that OUT-link-correct NPP default;
1888            // `parse_link_v2` would wrongly default a bare link to
1889            // ProcessPassive and re-process the target. An external
1890            // `ca://`/`pva://` OUT link routes through the link set's
1891            // `putValue` (C `dbLink.c::dbPutLink`, dbLink.c:434-448).
1892            let parsed =
1893                crate::server::record::parse_output_link_v2(link_str.as_str_lossy().as_ref());
1894            // Device-support write-buffer switch: the resolved target's DBF
1895            // type / element count decides which of the record's buffers C
1896            // would actually put (`devsCalcoutSoft.c:66-144`,
1897            // `devaCalcoutSoft.c:75-87`).
1898            let val = self
1899                .multi_out_buffer_choice(rec, link_field, &parsed, val)
1900                .await;
1901            self.write_out_link_value(
1902                rec,
1903                &parsed,
1904                val,
1905                OutLinkSrc {
1906                    field: link_field,
1907                    ..src
1908                },
1909                visited,
1910                depth,
1911            )
1912            .await;
1913        }
1914    }
1915
1916    /// **The put owner** — C `dbLink.c::dbPutLink` (434-448). Writes a value
1917    /// through a parsed OUT link, dispatching DB links to
1918    /// [`Self::write_db_link_value`] and external (`ca://`/`pva://`) links to
1919    /// [`Self::write_external_pv`], and — this is the part no caller may skip
1920    /// — raising the source record's `LINK_ALARM`/`INVALID` when the put
1921    /// fails:
1922    ///
1923    /// ```c
1924    /// status = plset->putValue(plink, dbrType, pbuffer, nRequest);
1925    /// if (status) {
1926    ///     setLinkAlarm(plink);        /* LINK_ALARM / INVALID_ALARM */
1927    /// }
1928    /// ```
1929    ///
1930    /// INVARIANT: *every* failing OUT-link put alarms the writing record, and
1931    /// the alarm is raised HERE, inside the put — not by each caller. C puts
1932    /// it inside `dbPutLink` for exactly that reason (the async twin
1933    /// `dbPutLinkAsync`, `:469-471`, repeats it), so a record whose OUT/`OUTn`
1934    /// target is down goes INVALID no matter which code path issued the write.
1935    /// The alarm lands in the record's PENDING alarm, so the cycle's
1936    /// `rec_gbl_reset_alarms` — which C runs from `monitor()`, AFTER the
1937    /// record's output writes — commits it in the SAME cycle.
1938    ///
1939    /// `src.field` names the link field for the alarm message, as C's
1940    /// `setLinkAlarm` does (`"field %s"`, `dbLinkFieldName(plink)`).
1941    ///
1942    /// This is also the OUTPUT-side counterpart of [`Self::read_link_value`]'s
1943    /// scheme dispatch: the OUT-link write stage in `processing.rs`
1944    /// must route a `ParsedLink::Ca`/`Pva` through the link set, not
1945    /// only handle `ParsedLink::Db`.
1946    ///
1947    /// `Constant`/`Hw`/`Calc`/`None` OUT links are not writable
1948    /// targets and are silently skipped (C `dbPutLink` returns
1949    /// `S_db_noLSET` for a link with no lset — the same no-op, and C does NOT
1950    /// alarm on `S_db_noLSET`: `dbGetLink` explicitly maps it to a plain `-1`
1951    /// with no `setLinkAlarm`).
1952    ///
1953    /// Returns C's `dbPutLink` status as a bool — `true` when the put failed.
1954    /// A caller needs it only to reproduce a record-specific alarm C raises ON
1955    /// TOP of the put's own (dfanout's `LINK_ALARM`/`MAJOR`); the INVALID that
1956    /// every failing put owes the record is already raised here.
1957    pub(crate) async fn write_out_link_value(
1958        &self,
1959        src_rec: &Arc<RwLock<RecordInstance>>,
1960        link: &crate::server::record::ParsedLink,
1961        value: EpicsValue,
1962        src: OutLinkSrc<'_>,
1963        visited: &mut HashSet<String>,
1964        depth: usize,
1965    ) -> bool {
1966        let failed = match link {
1967            crate::server::record::ParsedLink::Db(db) => {
1968                self.write_db_link_value(db, value, src, visited, depth)
1969                    .await
1970            }
1971            crate::server::record::ParsedLink::Ca(_)
1972            | crate::server::record::ParsedLink::Pva(_)
1973            | crate::server::record::ParsedLink::PvaJson(_) => {
1974                let name = link
1975                    .external_pv_name()
1976                    .expect("Ca/Pva/PvaJson link carries a PV name");
1977                let op = Self::external_put_op(src.notify);
1978                match self.write_external_pv(&name, value, op).await {
1979                    Ok(()) => false,
1980                    Err(e) => {
1981                        eprintln!("OUT-link write to external PV '{name}' failed: {e}");
1982                        true
1983                    }
1984                }
1985            }
1986            // Constant / Hw / Calc / None are not writable OUT-link
1987            // targets — no-op (C `dbPutLink` → `S_db_noLSET`).
1988            _ => false,
1989        };
1990        if failed {
1991            let mut inst = src_rec.write().await;
1992            crate::server::recgbl::rec_gbl_set_link_alarm(&mut inst.common, src.field);
1993        }
1994        failed
1995    }
1996
1997    /// Read a record String field, defaulting to empty.
1998    fn field_str(instance: &RecordInstance, field: &str) -> String {
1999        match instance.record.get_field(field) {
2000            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
2001            _ => String::new(),
2002        }
2003    }
2004
2005    /// Read a record numeric field as `i16`, defaulting to 0.
2006    fn field_i16(instance: &RecordInstance, field: &str) -> i16 {
2007        instance
2008            .record
2009            .get_field(field)
2010            .and_then(|v| v.to_f64())
2011            .unwrap_or(0.0) as i16
2012    }
2013
2014    /// Read a record numeric field as `u16`, defaulting to 0.
2015    ///
2016    /// Used for `DBF_USHORT` fields such as `SELN`: the native unsigned
2017    /// carrier returns its value directly, while any other DBR type is
2018    /// truncated through the `i64` integer view (C `dbPut` cast), so a
2019    /// value ≥ 32768 round-trips instead of saturating at `i16::MAX`.
2020    fn field_u16(instance: &RecordInstance, field: &str) -> u16 {
2021        match instance.record.get_field(field) {
2022            Some(EpicsValue::UShort(v)) => v,
2023            Some(other) => other.as_int_i64().unwrap_or(0) as u16,
2024            None => 0,
2025        }
2026    }
2027
2028    /// Apply a SELM-resolved out-of-range alarm to the record.
2029    ///
2030    /// C raises this alarm inside `process()` (before `recGblResetAlarms`)
2031    /// via `recGblSetSevr(prec, SOFT_ALARM, INVALID_ALARM)`. The Rust
2032    /// multi-output dispatch runs after the record's own alarm reset,
2033    /// so we apply the severity directly to `common.sevr/stat`, refresh
2034    /// the live STAT/SEVR fields, and post the monitor — matching the
2035    /// observable end state (record reads INVALID/SOFT_ALARM, a
2036    /// `DBE_ALARM` subscriber on STAT/SEVR is notified).
2037    async fn apply_selm_alarm(
2038        rec: &Arc<RwLock<RecordInstance>>,
2039        alarm: Option<(u16, AlarmSeverity)>,
2040    ) {
2041        let Some((stat, sevr)) = alarm else {
2042            return;
2043        };
2044        let posted = {
2045            let mut inst = rec.write().await;
2046            // Raise-only, mirroring recGblSetSevr.
2047            if (sevr as u16) > (inst.common.sevr as u16) {
2048                inst.common.sevr = sevr;
2049                inst.common.stat = stat;
2050                true
2051            } else {
2052                false
2053            }
2054        };
2055        if posted {
2056            // Write guard: a value-class post advances the record's
2057            // already-published state (`RecordInstance::record_value_post`),
2058            // so posting is a `&mut` operation.
2059            let mut inst = rec.write().await;
2060            inst.notify_field("SEVR", crate::server::recgbl::EventMask::ALARM);
2061            inst.notify_field("STAT", crate::server::recgbl::EventMask::VALUE);
2062        }
2063    }
2064
2065    /// Multi-output dispatch for fanout, dfanout, seq record types.
2066    ///
2067    /// The per-record payload is a typed [`MultiOut`] — seq / sseq
2068    /// groups are kept as struct fields, NOT `\0`-packed strings
2069    /// (the pre-fix encoding could mis-split a link string that
2070    /// happened to contain an embedded NUL).
2071    ///
2072    /// `phase` selects the dispatch phase — see [`MultiOutPhase`]. A record
2073    /// whose links do not belong to the calling phase is skipped, so each
2074    /// type dispatches exactly once per cycle.
2075    ///
2076    /// In the [`MultiOutPhase::Output`] phase this returns the pending alarm
2077    /// `(stat, sevr)` the C record raises alongside its puts — a failed
2078    /// `dbPutLink` (dfanout's own `LINK_ALARM/MAJOR`,
2079    /// `dfanoutRecord.c:311-312`) or a `SELN` out of range
2080    /// (`SOFT_ALARM/INVALID`, `dfanoutRecord.c:317` / `seqRecord.c:157`) —
2081    /// for the caller to fold into `nsev` before the alarm commit. The
2082    /// [`MultiOutPhase::ForwardLink`] phase returns `None`: a fanout raises
2083    /// its range alarm directly into the already-committed SEVR.
2084    pub(crate) async fn dispatch_multi_output(
2085        &self,
2086        rec: &Arc<RwLock<RecordInstance>>,
2087        phase: MultiOutPhase,
2088        visited: &mut HashSet<String>,
2089        depth: usize,
2090    ) -> Option<(u16, AlarmSeverity)> {
2091        // Phase gate, keyed on what the record's links ARE (see
2092        // `multi_out_phase_of`), not on which argument the caller passed.
2093        let record_type = rec.read().await.record.record_type().to_string();
2094        let is_value_phase = matches!(phase, MultiOutPhase::Output { .. });
2095        if matches!(multi_out_phase_of(&record_type), MultiOutPhaseKind::Output) != is_value_phase {
2096            return None;
2097        }
2098
2099        // Snapshot the source record's PUTF bit + put-notify wait-set so
2100        // every write_db_link_value call below propagates them to its
2101        // target — C `dbDbLink.c::processTarget` PUTF and `dbNotifyAdd`
2102        // wait-set invariants (see write_db_link_value doc). The PENDING
2103        // alarm travels the same way for `recGblInheritSevrMsg` MS-class
2104        // propagation into each OUT-link target: the OUTn/LNKn puts precede
2105        // the source's `recGblResetAlarms`, so C reads `psrce->nsta/nsev`
2106        // here ([`LinkAlarm::pending`], dbDbLink.c:382-383).
2107        let (src_putf, src_notify, src_alarm) = {
2108            let guard = rec.read().await;
2109            (
2110                guard.common.putf,
2111                guard.notify.clone(),
2112                LinkAlarm::pending(&guard.common),
2113            )
2114        };
2115        // One snapshot threaded to every OUT-link write below; each arm
2116        // overrides `field` with the link field it is driving (OUTn / LNKn),
2117        // which C `setLinkAlarm` reports in the record's AMSG.
2118        let out_src = OutLinkSrc {
2119            putf: src_putf,
2120            notify: src_notify.as_ref(),
2121            alarm: &src_alarm,
2122            field: "",
2123        };
2124
2125        // Resolve the SELL link into SELN before SELN is read below.
2126        // C `fanoutRecord.c:103` and `dfanoutRecord.c:126` call
2127        // `dbGetLink(&prec->sell, DBR_USHORT, &prec->seln, 0, 0)` at the
2128        // top of `process()`, *before* the SELM switch, so SELN is
2129        // refreshed from SELL every cycle regardless of SELM.
2130        // `seqRecord.c:148` places the identical `dbGetLink` *inside* the
2131        // `else` of `if (prec->selm == seqSELM_All)`, so an All-mode seq
2132        // never reads SELL — SELN stays frozen at its last value and posts
2133        // no spurious change (the post-process `if (seln != oldn)` monitor
2134        // is then a no-op). Match that per-record placement: fanout/dfanout
2135        // always, seq only when SELM != All. Only the `sel` record's
2136        // NVL->SELN binding was previously wired; fanout/dfanout/seq never
2137        // read the SELL link, so a SELL pointing at another record's value
2138        // field never updated SELN — the selection was frozen at whatever
2139        // SELN was initialised to. `sseq` reads its own SELL→SELN in
2140        // `SseqRecord::pre_input_link_actions` (the async machine owns the
2141        // whole cycle), so it is not handled here.
2142        {
2143            let sell = {
2144                let instance = rec.read().await;
2145                match instance.record.record_type() {
2146                    "fanout" | "dfanout" => Some(Self::field_str(&instance, "SELL")),
2147                    // seqSELM_All == 0 (seqRecord.dbd menu(seqSELM) order
2148                    // All/Specified/Mask). C skips the SELL→SELN read in
2149                    // All mode, so the port must too.
2150                    "seq" if Self::field_i16(&instance, "SELM") != 0 => {
2151                        Some(Self::field_str(&instance, "SELL"))
2152                    }
2153                    _ => None,
2154                }
2155            };
2156            if let Some(sell) = sell {
2157                if !sell.is_empty() {
2158                    // C reads SELL via `dbGetLink(&prec->sell, DBR_USHORT,
2159                    // &prec->seln, 0, 0)` for any link type; the pre-fix
2160                    // port only read a `ParsedLink::Db` SELL, so a SELL
2161                    // sourced over CA/PVA or given as a constant never
2162                    // updated SELN.
2163                    let parsed = crate::server::record::parse_link_v2(&sell);
2164                    // Through the one classifier: a CONSTANT SELL delivers
2165                    // NOTHING here (`dbConstGetValue`) — its value reached SELN
2166                    // once, at init (`fanoutRecord.c:88`, `dfanoutRecord.c:102`,
2167                    // `seqRecord.c:121` `recGblInitConstantLink(&sell,
2168                    // DBF_USHORT, &seln)`), so a `caput REC.SELN` STAYS put.
2169                    if let Some(val) = self.fetch_link(rec, &parsed).await.value() {
2170                        // C reads SELL with `dbGetLink(&prec->sell,
2171                        // DBR_USHORT, &prec->seln, 0, 0)`, which lands in a
2172                        // conversion routine chosen by the SOURCE type — an
2173                        // integer source wraps mod 2^16, a float source is UB.
2174                        // `dbr_ushort_cast` owns that split. SELN is a
2175                        // `DBF_USHORT` (u16) field either way, and
2176                        // `select_link_indices_ex` consumes it as `u16` —
2177                        // never as a signed value that could clamp to 0.
2178                        let seln = dbr_ushort_cast(&val);
2179                        let mut instance = rec.write().await;
2180                        let _ = instance.record.put_field("SELN", EpicsValue::UShort(seln));
2181                    }
2182                }
2183            }
2184        }
2185
2186        let dispatch_info: Option<(SelmResult, MultiOut, Option<EpicsValue>)> = {
2187            let instance = rec.read().await;
2188            match instance.record.record_type() {
2189                "fanout" => {
2190                    let selm = Self::field_i16(&instance, "SELM");
2191                    let seln = Self::field_u16(&instance, "SELN");
2192                    let offs = Self::field_i16(&instance, "OFFS");
2193                    let shft = Self::field_i16(&instance, "SHFT");
2194                    // C parity (fanoutRecord.c:39): 16 forward links
2195                    // LNK0..LNKF. LNK0 is the natural first slot.
2196                    let links: Vec<String> = LNK_LINK_FIELDS
2197                        .iter()
2198                        .map(|f| Self::field_str(&instance, f))
2199                        .collect();
2200                    // SELM resolution with OFFS/SHFT bias (fanoutRecord.c).
2201                    let sel = select_link_indices_ex(
2202                        SelmKind::FanoutSeq,
2203                        selm,
2204                        seln,
2205                        offs,
2206                        shft,
2207                        links.len(),
2208                    );
2209                    Some((sel, MultiOut::Fanout(links), None))
2210                }
2211                "dfanout" => {
2212                    let selm = Self::field_i16(&instance, "SELM");
2213                    let seln = Self::field_u16(&instance, "SELN");
2214                    // C `push_values` pushes `prec->val` — nothing else
2215                    // (`dfanoutRecord.c:309/321/331`: `dbPutLink(plink,
2216                    // DBR_DOUBLE, &prec->val, 1)`). IVOA is not re-decided
2217                    // here: the cycle's IVOA owner already ran
2218                    //
2219                    //     case menuIvoaSet_output_to_IVOV:
2220                    //         prec->val = prec->ivov;      /* :137 */
2221                    //         push_values(prec);
2222                    //
2223                    // through `Record::apply_invalid_output_value`, so VAL
2224                    // already IS IVOV when that arm was taken — and VAL, not a
2225                    // second read of IVOV, is what the record posts to its own
2226                    // monitors. `skip_out` is the Don't_drive veto (`:139`,
2227                    // `break` — no push) from that same decision.
2228                    //
2229                    // Reading `nsev` here instead would re-derive the decision
2230                    // AFTER the record's other outputs ran, off an alarm they
2231                    // may have raised themselves.
2232                    let val = match phase {
2233                        MultiOutPhase::Output { skip_out: true } => None,
2234                        MultiOutPhase::Output { skip_out: false } => instance.record.val(),
2235                        // Unreachable: the phase gate above already returned
2236                        // for a dfanout reached in the forward-link tail.
2237                        MultiOutPhase::ForwardLink => return None,
2238                    };
2239                    let links: Vec<String> = DFANOUT_LINK_FIELDS
2240                        .iter()
2241                        .map(|f| Self::field_str(&instance, f))
2242                        .collect();
2243                    // dfanout Specified is 1-based; Mask has no SHFT
2244                    // (dfanoutRecord.c:307-339).
2245                    let sel =
2246                        select_link_indices_ex(SelmKind::Dfanout, selm, seln, 0, 0, links.len());
2247                    Some((sel, MultiOut::Dfanout(links), val))
2248                }
2249                "seq" => {
2250                    let selm = Self::field_i16(&instance, "SELM");
2251                    let seln = Self::field_u16(&instance, "SELN");
2252                    let offs = Self::field_i16(&instance, "OFFS");
2253                    let shft = Self::field_i16(&instance, "SHFT");
2254                    // C parity (seqRecord.c:86): 16 link groups 0..F,
2255                    // each DOLn / DOn (value storage) / DLYn / LNKn.
2256                    let dol_names = [
2257                        "DOL0", "DOL1", "DOL2", "DOL3", "DOL4", "DOL5", "DOL6", "DOL7", "DOL8",
2258                        "DOL9", "DOLA", "DOLB", "DOLC", "DOLD", "DOLE", "DOLF",
2259                    ];
2260                    let lnk_names = LNK_LINK_FIELDS;
2261                    let dly_names = [
2262                        "DLY0", "DLY1", "DLY2", "DLY3", "DLY4", "DLY5", "DLY6", "DLY7", "DLY8",
2263                        "DLY9", "DLYA", "DLYB", "DLYC", "DLYD", "DLYE", "DLYF",
2264                    ];
2265                    let do_names = [
2266                        "DO0", "DO1", "DO2", "DO3", "DO4", "DO5", "DO6", "DO7", "DO8", "DO9",
2267                        "DOA", "DOB", "DOC", "DOD", "DOE", "DOF",
2268                    ];
2269                    let groups: Vec<SeqGroup> = (0..16)
2270                        .map(|i| SeqGroup {
2271                            dol: Self::field_str(&instance, dol_names[i]),
2272                            lnk: Self::field_str(&instance, lnk_names[i]),
2273                            dly: instance
2274                                .record
2275                                .get_field(dly_names[i])
2276                                .and_then(|v| v.to_f64())
2277                                .unwrap_or(0.0),
2278                            dov: instance
2279                                .record
2280                                .get_field(do_names[i])
2281                                .and_then(|v| v.to_f64())
2282                                .unwrap_or(0.0),
2283                        })
2284                        .collect();
2285                    let sel = select_link_indices_ex(
2286                        SelmKind::FanoutSeq,
2287                        selm,
2288                        seln,
2289                        offs,
2290                        shft,
2291                        groups.len(),
2292                    );
2293                    Some((sel, MultiOut::Seq(groups), None))
2294                }
2295                _ => None,
2296            }
2297        };
2298
2299        let (sel, payload, val) = match dispatch_info {
2300            Some(info) => info,
2301            None => return None,
2302        };
2303        debug_assert!(sel.indices.iter().all(|&i| i < payload.len()));
2304        // Single-owner invariant: every record type that produces a
2305        // `MultiOut` payload here MUST be listed in
2306        // `multi_output_dispatch_owned` so the generic
2307        // `multi_output_links` block in `processing.rs` skips it. If
2308        // this fires, the two lists have diverged and the skipped
2309        // type would be dispatched twice per cycle.
2310        debug_assert!(multi_output_dispatch_owned(
2311            rec.read().await.record.record_type()
2312        ));
2313
2314        // C raises SOFT_ALARM/INVALID_ALARM when SELN/OFFS/SHFT resolve
2315        // out of range (fanoutRecord.c:116, dfanoutRecord.c:317,
2316        // seqRecord.c:157). The value-put phase (dfanout/seq) runs PRE-commit,
2317        // so its out-of-range alarm must fold into the pending nsev the caller
2318        // commits THIS cycle — captured here and returned below, not
2319        // raised+posted now (a direct SEVR raise would be clobbered by the
2320        // caller's `recGblResetAlarms`). A fanout dispatches in the
2321        // post-commit tail and raises it directly into the committed SEVR,
2322        // posting SEVR/STAT immediately (apply_selm_alarm).
2323        let pending_selm_alarm = sel.alarm;
2324        if !is_value_phase {
2325            Self::apply_selm_alarm(rec, sel.alarm).await;
2326        }
2327        let indices = sel.indices;
2328        // C `dfanoutRecord.c` push_values raises LINK_ALARM/MAJOR per failed
2329        // dbPutLink; accumulated across the selected OUT links below.
2330        let mut link_failed = false;
2331
2332        match payload {
2333            MultiOut::Fanout(links) => {
2334                for idx in indices {
2335                    let link_str = &links[idx];
2336                    if link_str.is_empty() {
2337                        continue;
2338                    }
2339                    // fanout `LNK1`..`LNKF` are `DBF_FWDLINK`
2340                    // (`fanoutRecord.dbd.pod:144`), so C masks their modifiers
2341                    // to `pvlOptCA` alone (`dbStaticLib.c:2390`).
2342                    let parsed = crate::server::record::parse_forward_link_v2(link_str);
2343                    if let crate::server::record::ParsedLink::Db(ref db) = parsed {
2344                        // C `fanoutRecord.c:110/121/138` dispatches each
2345                        // selected LNKn via `dbScanFwdLink` →
2346                        // `dbDbScanFwdLink` → `dbScanPassive`
2347                        // (`dbDbLink.c:425-432`), which processes the
2348                        // target ONLY when its SCAN is Passive
2349                        // (`if (pto->scan != 0) return 0;`). A LNKn
2350                        // pointing at a Periodic/Event/I/O-Intr record
2351                        // must NOT be re-processed by the fanout — that
2352                        // record runs on its own scan. `dbScanPassive`
2353                        // then calls `processTarget`, which propagates
2354                        // PUTF (and sets RPRO on a busy target) exactly
2355                        // like the explicit FLNK path — so this goes
2356                        // through the same single owner.
2357                        self.process_target(
2358                            &db.record,
2359                            ProcessTargetGate::ScanPassive,
2360                            src_putf,
2361                            src_notify.as_ref(),
2362                            visited,
2363                            depth,
2364                        )
2365                        .await;
2366                    }
2367                }
2368            }
2369            MultiOut::Dfanout(links) => {
2370                if let Some(ref val) = val {
2371                    for idx in indices {
2372                        let link_str = &links[idx];
2373                        if link_str.is_empty() {
2374                            continue;
2375                        }
2376                        // C `dfanoutRecord.c:323` drives each OUTn via
2377                        // `dbPutLink` → `dbDbPutValue`: an `DBF_OUTLINK`
2378                        // target is processed only when the link carries
2379                        // an explicit ` PP` token or the destination is
2380                        // `.PROC` (`dbDbLink.c:387-390`). A modifier-less
2381                        // OUTn is NPP — the value is written but the
2382                        // target is NOT re-processed (otherwise a
2383                        // Soft-Channel ai target's `convert()` would
2384                        // clobber the value just written). The NPP
2385                        // default and the `.PROC`/`PP` processing rule
2386                        // are both honoured by `parse_output_link_v2`
2387                        // (uniform NoProcess default) +
2388                        // `write_db_link_value` (write-path target
2389                        // processing), so no per-call downgrade is needed.
2390                        let parsed = crate::server::record::parse_output_link_v2(link_str);
2391                        // Through the put owner, so a failed OUTn raises the
2392                        // record's LINK_ALARM/INVALID from inside the put (C
2393                        // `dbPutLink`'s `setLinkAlarm`) — an external
2394                        // `ca://`/`pva://` OUTn goes through the link set's
2395                        // `putValue` exactly as a DB link does
2396                        // (dbLink.c:434-448). `link_failed` additionally
2397                        // carries dfanout's OWN LINK_ALARM/MAJOR
2398                        // (`dfanoutRecord.c:311-312`), which C raises on top of
2399                        // (and is subsumed by) the put's INVALID.
2400                        if self
2401                            .write_out_link_value(
2402                                rec,
2403                                &parsed,
2404                                val.clone(),
2405                                OutLinkSrc {
2406                                    field: DFANOUT_LINK_FIELDS[idx],
2407                                    ..out_src
2408                                },
2409                                visited,
2410                                depth,
2411                            )
2412                            .await
2413                        {
2414                            link_failed = true;
2415                        }
2416                    }
2417                }
2418            }
2419            MultiOut::Seq(groups) => {
2420                // DOn value-storage field names (`linkGrp.dov`),
2421                // index-aligned with the LNKn/DOLn groups.
2422                const DO_NAMES: [&str; 16] = [
2423                    "DO0", "DO1", "DO2", "DO3", "DO4", "DO5", "DO6", "DO7", "DO8", "DO9", "DOA",
2424                    "DOB", "DOC", "DOD", "DOE", "DOF",
2425                ];
2426                // C `dbLinkIsConstant` is true for empty and numeric-literal
2427                // links; a real link is a DB/CA/PVA PV reference.
2428                let is_real = |s: &str| {
2429                    !s.is_empty()
2430                        && !matches!(
2431                            crate::server::record::parse_link_v2(s),
2432                            crate::server::record::ParsedLink::Constant(_)
2433                        )
2434                };
2435                let rec_name = rec.read().await.name.clone();
2436                for idx in indices {
2437                    let grp = &groups[idx];
2438                    // C `seqRecord.c:182-189`: a group is processed iff its
2439                    // LNKn OR DOLn is a real (non-constant) link. When both
2440                    // are constant/empty the group is skipped entirely — so
2441                    // a DOL-only group (real DOLn, empty LNKn) still reads
2442                    // back DOn and posts it, even though nothing is driven.
2443                    let lnk_real = is_real(&grp.lnk);
2444                    let dol_real = is_real(&grp.dol);
2445                    if !lnk_real && !dol_real {
2446                        continue;
2447                    }
2448                    // Per-group DLYn staggering — C `seqRecord.c`
2449                    // schedules each group after its delay. Groups
2450                    // process sequentially in index order, each after
2451                    // its own delay (callbackRequestDelayed chain).
2452                    if grp.dly > 0.0 {
2453                        tokio::time::sleep(std::time::Duration::from_secs_f64(grp.dly)).await;
2454                    }
2455                    // C `seqRecord.c:259` `dbGetLink(&dol, DBR_DOUBLE,
2456                    // &dov)`: read DOLn into the DOn value field. A
2457                    // constant/empty DOL — or a failed read — leaves DOn at
2458                    // its previous value.
2459                    let new_dov = if dol_real {
2460                        let dol_parsed = crate::server::record::parse_link_v2(&grp.dol);
2461                        self.read_link_value(&dol_parsed, visited, depth)
2462                            .await
2463                            .and_then(|v| v.to_f64())
2464                            .unwrap_or(grp.dov)
2465                    } else {
2466                        grp.dov
2467                    };
2468                    // C `seqRecord.c:264` drives LNKn via `dbPutLink`
2469                    // (`DBR_DOUBLE, &dov`), whose `DBF_OUTLINK` target is
2470                    // processed by `dbDbPutValue` (`dbDbLink.c:388`) only
2471                    // when the link carries an explicit `PP` modifier. A
2472                    // bare `LNKn` is NPP — the value is written but the
2473                    // target is NOT processed. `parse_output_link_v2`
2474                    // applies that NPP default (the dfanout arm above
2475                    // open-codes the same downgrade). LNKn may be a local DB
2476                    // link or an external `ca://`/`pva://` link — C
2477                    // `dbPutLink` routes both through the link set's
2478                    // `putValue` (dbLink.c:434-448). Only a real LNK does
2479                    // anything; a constant LNK is a no-op.
2480                    if lnk_real {
2481                        let lnk_parsed = crate::server::record::parse_output_link_v2(&grp.lnk);
2482                        // Through the put owner: a failed LNKn `dbPutLink`
2483                        // raises the seq record's LINK_ALARM/INVALID from
2484                        // inside the put (C `dbLink.c:444-446`).
2485                        self.write_out_link_value(
2486                            rec,
2487                            &lnk_parsed,
2488                            EpicsValue::Double(new_dov),
2489                            OutLinkSrc {
2490                                field: LNK_LINK_FIELDS[idx],
2491                                ..out_src
2492                            },
2493                            visited,
2494                            depth,
2495                        )
2496                        .await;
2497                    }
2498                    // C `seqRecord.c:266-268`: store DOn and post a
2499                    // DBE_VALUE|DBE_LOG monitor only when it changed. The
2500                    // field already holds the old value (snapshot read), so
2501                    // `post_fields` (write + post) is needed only on change.
2502                    if new_dov != grp.dov {
2503                        let _ = self
2504                            .post_fields(
2505                                &rec_name,
2506                                vec![(DO_NAMES[idx].to_string(), EpicsValue::Double(new_dov))],
2507                            )
2508                            .await;
2509                    }
2510                }
2511            }
2512        }
2513
2514        // Value-put phase (dfanout / seq): return the pending alarm the C
2515        // record raises alongside its puts, for the caller to fold into `nsev`
2516        // before `recGblResetAlarms`. C raises at most one: SOFT_ALARM/INVALID
2517        // for a `seln` out of range (dfanoutRecord.c:317, seqRecord.c:157 — no
2518        // push at all) OR dfanout's own LINK_ALARM/MAJOR for a failed
2519        // dbPutLink (dfanoutRecord.c:312/324/333). They are mutually exclusive
2520        // in C's control flow; if both were somehow set, fold the higher
2521        // severity (recGblSetSevr is raise-only). `link_failed` is dfanout-only
2522        // — seq's failed LNKn put raises LINK_ALARM/INVALID from inside the put
2523        // owner (`write_out_link_value`), into the same pending alarm, and
2524        // seqRecord.c adds nothing on top of it.
2525        if is_value_phase {
2526            let link_alarm = if link_failed {
2527                Some((
2528                    crate::server::recgbl::alarm_status::LINK_ALARM,
2529                    AlarmSeverity::Major,
2530                ))
2531            } else {
2532                None
2533            };
2534            return match (pending_selm_alarm, link_alarm) {
2535                (Some(a), Some(b)) => Some(if (a.1 as u16) >= (b.1 as u16) { a } else { b }),
2536                (a, b) => a.or(b),
2537            };
2538        }
2539        None
2540    }
2541
2542    /// Post the software event named by an `event` record's `VAL`.
2543    ///
2544    /// Mirrors C `eventRecord.c:120` `postEvent(prec->epvt)` — every
2545    /// `process()` of an event record posts its event, waking the
2546    /// `SCAN="Event"` records whose `EVNT` resolves to that name.
2547    /// No-op for any other record type, or when `VAL` is empty /
2548    /// resolves to event 0 (`eventNameToHandle` returns NULL).
2549    pub(crate) async fn dispatch_event_record(&self, rec: &Arc<RwLock<RecordInstance>>) {
2550        let event_name = {
2551            let instance = rec.read().await;
2552            if instance.record.record_type() != "event" {
2553                return;
2554            }
2555            match instance.record.get_field("VAL") {
2556                Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
2557                _ => return,
2558            }
2559        };
2560        if event_name.trim().is_empty() {
2561            return;
2562        }
2563        // C `postEvent` queues callbacks on the scan ring buffer —
2564        // the event-scanned records run on a separate callback thread,
2565        // NOT recursively inside this process cycle. Spawn the routed
2566        // post so a chain of event records cannot recurse unboundedly
2567        // and the current cycle's FLNK/CP dispatch is not blocked.
2568        let db = self.clone();
2569        crate::runtime::task::spawn(async move {
2570            db.post_event_named(&event_name).await;
2571        });
2572    }
2573
2574    /// Register a CP link: when source_record changes, process target_record.
2575    ///
2576    /// Both names are normalised to canonical form so the cp_links
2577    /// map's key/value always match the canonical record name that
2578    /// `dispatch_cp_targets` uses for lookup. Without this, a user
2579    /// who wrote `INP="ALIAS_NAME CP"` in their .db file would
2580    /// register the CP edge under the alias key and then never see
2581    /// the target processed (the source record's canonical-name
2582    /// dispatch would miss).
2583    /// `passive_only` is `true` for a CPP edge (process the target only when
2584    /// its `SCAN` is Passive) and `false` for CP (always process). When the
2585    /// same source→target edge is registered from both a CP and a CPP link,
2586    /// CP dominates: the merged edge keeps `passive_only == false`, matching
2587    /// C, where an unconditional CP `CA_DBPROCESS` overrides any CPP gate on
2588    /// the same record.
2589    pub async fn register_cp_link(
2590        &self,
2591        source_record: &str,
2592        target_record: &str,
2593        passive_only: bool,
2594    ) {
2595        let source = self
2596            .resolve_alias(source_record)
2597            .await
2598            .unwrap_or_else(|| source_record.to_string());
2599        let target = self
2600            .resolve_alias(target_record)
2601            .await
2602            .unwrap_or_else(|| target_record.to_string());
2603        let mut cp = self.inner.cp_links.write().await;
2604        let targets = cp.entry(source).or_default();
2605        if let Some(existing) = targets.iter_mut().find(|t| t.record == target) {
2606            existing.passive_only = existing.passive_only && passive_only;
2607        } else {
2608            targets.push(super::CpTarget {
2609                record: target,
2610                passive_only,
2611            });
2612        }
2613    }
2614
2615    /// Get target edges to process when `source_record` changes (CP/CPP links).
2616    pub async fn get_cp_targets(&self, source_record: &str) -> Vec<super::CpTarget> {
2617        self.inner
2618            .cp_links
2619            .read()
2620            .await
2621            .get(source_record)
2622            .cloned()
2623            .unwrap_or_default()
2624    }
2625
2626    /// Register an EXTERNAL CP/CPP link: when the remote PV `external_pv`
2627    /// (a cross-IOC CA/PVA source, e.g. `OTHER:PV` from
2628    /// `INP="OTHER:PV CP"`) changes, process `target_record`.
2629    ///
2630    /// Twin of [`Self::register_cp_link`] for cross-IOC sources. The key is
2631    /// the **scheme-stripped external PV name** — it is not a local record,
2632    /// so it is NOT alias-resolved. It MUST equal the name the calink/pvalink
2633    /// monitor dispatches under: that monitor is opened through the lset,
2634    /// which strips the `ca://` / `pva://` scheme first, so its `pv_name`
2635    /// (passed to [`Self::dispatch_external_cp_targets`]) is the bare PV.
2636    /// Stripping the same schemes here — the set [`Self::resolve_external_pv`]
2637    /// already knows — guarantees the registry key and the dispatch key can
2638    /// never diverge. The `target_record` (the local holder) IS alias-resolved
2639    /// so the dispatch processes the canonical record. CP dominates CPP on a
2640    /// merged edge, identical to the local path.
2641    pub async fn register_external_cp_link(
2642        &self,
2643        external_pv: &str,
2644        target_record: &str,
2645        passive_only: bool,
2646    ) {
2647        let key = external_pv
2648            .strip_prefix("ca://")
2649            .or_else(|| external_pv.strip_prefix("pva://"))
2650            .unwrap_or(external_pv);
2651        let target = self
2652            .resolve_alias(target_record)
2653            .await
2654            .unwrap_or_else(|| target_record.to_string());
2655        let mut cp = self.inner.external_cp_links.write().await;
2656        let targets = cp.entry(key.to_string()).or_default();
2657        if let Some(existing) = targets.iter_mut().find(|t| t.record == target) {
2658            existing.passive_only = existing.passive_only && passive_only;
2659        } else {
2660            targets.push(super::CpTarget {
2661                record: target,
2662                passive_only,
2663            });
2664        }
2665    }
2666
2667    /// Get holder edges to process when the remote PV `external_pv` changes
2668    /// (external CA/PVA CP/CPP links).
2669    pub async fn get_external_cp_targets(&self, external_pv: &str) -> Vec<super::CpTarget> {
2670        self.inner
2671            .external_cp_links
2672            .read()
2673            .await
2674            .get(external_pv)
2675            .cloned()
2676            .unwrap_or_default()
2677    }
2678
2679    /// Every external PV name that has at least one CP/CPP holder edge.
2680    /// The calink/pvalink integration warms (opens a monitor for) each of
2681    /// these at iocInit so the remote source has a live subscription whose
2682    /// callback can drive [`Self::dispatch_external_cp_targets`] — a Passive
2683    /// CP holder is otherwise never read and its monitor never opens.
2684    pub async fn external_cp_pv_names(&self) -> Vec<String> {
2685        self.inner
2686            .external_cp_links
2687            .read()
2688            .await
2689            .keys()
2690            .cloned()
2691            .collect()
2692    }
2693
2694    /// Classify one parsed CP/CPP link, deciding the local-vs-external
2695    /// routing — this method is the single owner of that decision, so the
2696    /// CP trigger registry and the holder's value-read parse cache stay
2697    /// consistent.
2698    ///
2699    /// A link with no CP/CPP policy (`cp_passive_only() == None`), and
2700    /// every non-`Db`/`Ca` variant, is ignored.
2701    ///
2702    /// `Ca`: an external `ca://OTHER CP` holder always drives the cross-IOC
2703    /// path — C `dbCa.c` `eventCallback` adds `CA_DBPROCESS` for every CP link
2704    /// (`dbCa.c:993-994`). Note `OTHER CP CA` is NOT such a link: `CA` and `CP`
2705    /// are mutually exclusive process classes and `CA` is matched first
2706    /// (`dbStaticLib.c:2369-2373`), so it is a plain CA link with no CP policy.
2707    ///
2708    /// `Db`: C `dbInitLink` (`dbLink.c:118-130`) makes any link carrying a
2709    /// `CP`/`CPP`/`CA` option a CA link, and `dbDbInitLink` keeps it local
2710    /// only when the named record exists in this IOC. So a CP/CPP `Db`
2711    /// link whose target is NOT a local record (e.g. `INP="other:pv CP"`,
2712    /// no explicit `CA`) must be served externally — both the calink
2713    /// trigger monitor (`ext_links`) AND the holder's value read. The read
2714    /// half is the `convert_to_ca` entry: it carries the field name and an
2715    /// equivalent [`CaLink`] so the caller rewrites the holder's cached
2716    /// parse from `Db` to `Ca`, routing the read through the external
2717    /// resolver. A local target keeps the local fast-path (`db_links`).
2718    async fn classify_cp_link(
2719        &self,
2720        field: &str,
2721        parsed: crate::server::record::ParsedLink,
2722        target_name: &str,
2723        db_links: &mut Vec<(String, String, bool)>,
2724        ext_links: &mut Vec<(String, String, bool)>,
2725        convert_to_ca: &mut Vec<(String, crate::server::record::CaLink)>,
2726    ) {
2727        match parsed {
2728            crate::server::record::ParsedLink::Db(db) => {
2729                let Some(passive_only) = db.policy.cp_passive_only() else {
2730                    return;
2731                };
2732                if self.has_name_no_resolve(&db.record).await {
2733                    db_links.push((db.record, target_name.to_string(), passive_only));
2734                } else {
2735                    // Reconstruct the CA channel name verbatim: `record`
2736                    // for a default-`VAL` link, `record.FIELD` otherwise —
2737                    // the same string the `CA`-modifier path would store
2738                    // in `CaLink::pv`.
2739                    let pv = if db.field == "VAL" {
2740                        db.record.clone()
2741                    } else {
2742                        format!("{}.{}", db.record, db.field)
2743                    };
2744                    ext_links.push((pv.clone(), target_name.to_string(), passive_only));
2745                    convert_to_ca.push((
2746                        field.to_string(),
2747                        crate::server::record::CaLink {
2748                            pv,
2749                            monitor_switch: db.monitor_switch,
2750                            policy: db.policy,
2751                        },
2752                    ));
2753                }
2754            }
2755            crate::server::record::ParsedLink::Ca(ca) => {
2756                if let Some(passive_only) = ca.policy.cp_passive_only() {
2757                    ext_links.push((ca.pv, target_name.to_string(), passive_only));
2758                }
2759            }
2760            _ => {}
2761        }
2762    }
2763
2764    /// Scan all records for CP/CPP input links and register them. Local
2765    /// `Db` links land in the local CP registry; external `Ca` links land
2766    /// in the external CP registry, whose holders are processed by the
2767    /// calink monitor callback.
2768    pub async fn setup_cp_links(&self) {
2769        let names = self.all_record_names().await;
2770        let mut db_links: Vec<(String, String, bool)> = Vec::new();
2771        let mut ext_links: Vec<(String, String, bool)> = Vec::new();
2772
2773        for target_name in &names {
2774            // Enumerate this record's link-bearing fields through the
2775            // single shared owner (`record_link_fields`) so the CA CP/CPP
2776            // setup here and the pvalink install scan can never diverge on
2777            // which fields count as links. `classify_cp_link` keeps only
2778            // the CP/CPP-policy links, routes local `Db` vs external `Ca`,
2779            // and — for a CP/CPP `Db` link to a non-local target — emits a
2780            // `convert_to_ca` entry so the holder's value read is rewired
2781            // to the external resolver (C `dbLink.c:118-130`).
2782            let mut convert_to_ca: Vec<(String, crate::server::record::CaLink)> = Vec::new();
2783            for (field, _raw, parsed) in self.record_link_fields(target_name).await {
2784                self.classify_cp_link(
2785                    &field,
2786                    parsed,
2787                    target_name,
2788                    &mut db_links,
2789                    &mut ext_links,
2790                    &mut convert_to_ca,
2791                )
2792                .await;
2793            }
2794            // Apply the `Db`→`Ca` parse-cache rewrite for non-local CP/CPP
2795            // links so the holder reads its value through the external
2796            // resolver — consistent with the external CP trigger registered
2797            // above (both halves use the same locality decision made in
2798            // `classify_cp_link`). Only the common-field caches are
2799            // rewritten; INPA.. / DOL.. links are re-parsed from their raw
2800            // strings each process cycle and so are not covered here.
2801            if !convert_to_ca.is_empty() {
2802                if let Some(rec_arc) = self.get_record(target_name).await {
2803                    let mut inst = rec_arc.write().await;
2804                    for (field, calink) in convert_to_ca {
2805                        let link = crate::server::record::ParsedLink::Ca(calink);
2806                        match field.as_str() {
2807                            "INP" => inst.parsed_inp = link,
2808                            "OUT" => inst.parsed_out = link,
2809                            "TSEL" => inst.parsed_tsel = link,
2810                            "SDIS" => inst.parsed_sdis = link,
2811                            _ => {}
2812                        }
2813                    }
2814                }
2815            }
2816        }
2817
2818        let db_count = db_links.len();
2819        for (source, target, passive_only) in db_links {
2820            self.register_cp_link(&source, &target, passive_only).await;
2821        }
2822        let ext_count = ext_links.len();
2823        for (external_pv, target, passive_only) in ext_links {
2824            self.register_external_cp_link(&external_pv, &target, passive_only)
2825                .await;
2826        }
2827        if db_count > 0 {
2828            eprintln!("iocInit: {db_count} CP link subscriptions");
2829        }
2830        // Warm external CP/CPP links. A Passive holder of an external CP
2831        // link is never scanned, so its link never opens lazily and the
2832        // calink monitor that drives `dispatch_external_cp_targets` is never
2833        // created (chicken-and-egg). Open each external CP PV now so its
2834        // monitor is live at iocInit — the C `dbCa.c` "add the link at init"
2835        // analogue (`dbCaAddLink`). `resolve_external_pv` routes through the
2836        // registered lset's lazy-open path (the same path a record read
2837        // uses); a no-op when no matching lset is installed (calink off).
2838        if ext_count > 0 {
2839            let ext_pvs = self.external_cp_pv_names().await;
2840            for pv in &ext_pvs {
2841                let _ = self.resolve_external_pv(pv).await;
2842            }
2843            eprintln!(
2844                "iocInit: {ext_count} external CP link subscriptions ({} PVs warmed)",
2845                ext_pvs.len()
2846            );
2847        }
2848    }
2849}
2850
2851#[cfg(test)]
2852mod out_link_put_fail_tests {
2853    use super::{LinkAlarm, OutLinkSrc};
2854    use crate::server::database::PvDatabase;
2855    use crate::server::record::{AlarmSeverity, DbLink, LinkProcessPolicy, MonitorSwitch};
2856    use crate::server::records::calc::CalcRecord;
2857    use crate::types::EpicsValue;
2858    use std::collections::HashSet;
2859
2860    /// C `dbDbPutValue` (dbDbLink.c:382-390) runs `dbPut`, folds the
2861    /// source alarm via `recGblInheritSevrMsg`, then `if (status) return
2862    /// status;` — only a *successful* write reaches the `.PROC`/`PP`
2863    /// `processTarget` branch. A failed OUT-link write must therefore NOT
2864    /// process the target. The failure here is a string that names no choice
2865    /// of a `DBF_MENU` field — C `dbPut` → `putStringMenu` → `S_db_badChoice`.
2866    ///
2867    /// Observable: a passive `calc` target with `CALC = "7"` evaluates to
2868    /// `VAL = 7` on process and stays at its `Default` `VAL = 0` when not
2869    /// processed. The PP OUT link below carries a write that fails, so the
2870    /// code returns before `processTarget` and `VAL` stays `0.0`; processing
2871    /// the target unconditionally would make it `7.0`.
2872    #[tokio::test]
2873    async fn pp_out_link_failed_write_does_not_process_target() {
2874        let db = PvDatabase::new();
2875        db.add_record("TGT", Box::new(CalcRecord::new("7")))
2876            .await
2877            .unwrap();
2878
2879        // Precondition: CALC not yet evaluated, VAL at its Default 0.0.
2880        assert!(
2881            matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 0.0),
2882            "calc VAL must start at its Default 0.0 before any process"
2883        );
2884
2885        let link = DbLink {
2886            record: "TGT".to_string(),
2887            field: "PINI".to_string(),
2888            policy: LinkProcessPolicy::ProcessPassive, // ` PP` token
2889            monitor_switch: MonitorSwitch::NoMaximize,
2890        };
2891        let alarm = LinkAlarm {
2892            stat: 0,
2893            sevr: AlarmSeverity::NoAlarm,
2894            amsg: String::new(),
2895        };
2896        let src = OutLinkSrc {
2897            putf: false,
2898            notify: None,
2899            alarm: &alarm,
2900            field: "OUT",
2901        };
2902        let mut visited = HashSet::new();
2903
2904        db.write_db_link_value(
2905            &link,
2906            EpicsValue::String("NOT_A_MENU_CHOICE".into()),
2907            src,
2908            &mut visited,
2909            0,
2910        )
2911        .await;
2912
2913        // The failed write short-circuits before processTarget, so CALC was
2914        // never evaluated and VAL is still its Default 0.0.
2915        assert!(
2916            matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 0.0),
2917            "a failed OUT-link write must NOT process the PP target \
2918             (VAL must stay 0.0, not become 7.0)"
2919        );
2920    }
2921
2922    /// R6-7 — an empty array into a scalar field is NOT a failed write. C
2923    /// `dbPut` (`dbAccess.c:1370-1372`) writes nothing, raises
2924    /// `LINK_ALARM`/`INVALID_ALARM` on the destination and returns 0, so
2925    /// `dbDbPutValue`'s `if (status) return status;` does not fire and a ` PP`
2926    /// link goes on to process its target.
2927    ///
2928    /// The port used to reject the put, which suppressed both the destination's
2929    /// alarm and its `PP` processing.
2930    #[tokio::test]
2931    async fn pp_out_link_empty_array_alarms_target_and_still_processes_it() {
2932        use crate::server::recgbl::alarm_status;
2933
2934        let db = PvDatabase::new();
2935        db.add_record("ETGT", Box::new(CalcRecord::new("7")))
2936            .await
2937            .unwrap();
2938
2939        let link = DbLink {
2940            record: "ETGT".to_string(),
2941            field: "VAL".to_string(),
2942            policy: LinkProcessPolicy::ProcessPassive, // ` PP` token
2943            monitor_switch: MonitorSwitch::NoMaximize,
2944        };
2945        let alarm = LinkAlarm {
2946            stat: 0,
2947            sevr: AlarmSeverity::NoAlarm,
2948            amsg: String::new(),
2949        };
2950        let src = OutLinkSrc {
2951            putf: false,
2952            notify: None,
2953            alarm: &alarm,
2954            field: "OUT",
2955        };
2956        let mut visited = HashSet::new();
2957
2958        db.write_db_link_value(&link, EpicsValue::DoubleArray(vec![]), src, &mut visited, 0)
2959            .await;
2960
2961        // The put succeeded (status 0), so the PP target processed: CALC = "7".
2962        assert!(
2963            matches!(db.get_pv("ETGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 7.0),
2964            "an empty-array put is accepted by C, so the PP target must process"
2965        );
2966        // …and the destination carries LINK/INVALID, committed by that very
2967        // process cycle's `recGblResetAlarms`.
2968        let inst = db.get_record("ETGT").await.unwrap();
2969        let inst = inst.read().await;
2970        assert_eq!(inst.common.stat, alarm_status::LINK_ALARM);
2971        assert_eq!(inst.common.sevr, AlarmSeverity::Invalid);
2972    }
2973}
2974
2975#[cfg(test)]
2976mod nonlocal_db_link_write_tests {
2977    use super::OutLinkSrc;
2978    use crate::server::database::{LinkPutOp, LinkSet, PvDatabase};
2979    use crate::server::record::{AlarmSeverity, DbLink, LinkProcessPolicy, MonitorSwitch};
2980    use crate::server::records::calc::CalcRecord;
2981    use crate::types::EpicsValue;
2982    use std::collections::HashSet;
2983    use std::sync::{Arc, Mutex};
2984
2985    /// A link set that records every `put_value` it receives so a test
2986    /// can assert a non-local OUT-link write reached the external (CA)
2987    /// put path instead of being dropped by a local `dbPut`.
2988    struct RecordingLset {
2989        puts: Arc<Mutex<Vec<(String, EpicsValue)>>>,
2990    }
2991    #[async_trait::async_trait]
2992    impl LinkSet for RecordingLset {
2993        async fn is_connected(&self, _: &str) -> bool {
2994            true
2995        }
2996        async fn get_value(&self, _: &str) -> Option<EpicsValue> {
2997            None
2998        }
2999        async fn put_value(
3000            &self,
3001            name: &str,
3002            value: EpicsValue,
3003            _op: LinkPutOp,
3004        ) -> Result<(), String> {
3005            self.puts.lock().unwrap().push((name.to_string(), value));
3006            Ok(())
3007        }
3008    }
3009
3010    fn out_src(alarm: &super::LinkAlarm) -> OutLinkSrc<'_> {
3011        OutLinkSrc {
3012            putf: false,
3013            notify: None,
3014            alarm,
3015            field: "OUT",
3016        }
3017    }
3018
3019    fn no_alarm() -> super::LinkAlarm {
3020        super::LinkAlarm {
3021            stat: 0,
3022            sevr: AlarmSeverity::NoAlarm,
3023            amsg: String::new(),
3024        }
3025    }
3026
3027    /// A plain OUT link whose target record is NOT local must write
3028    /// through the external put path — C `dbPutLink` routes a non-local
3029    /// (CA) link's write to `dbCaPutLink`, never a local `dbPut`. The
3030    /// pre-fix code called `put_pv_already_locked` on a name that does
3031    /// not exist locally; the write was silently dropped. The recording
3032    /// lset must now capture the value.
3033    #[tokio::test]
3034    async fn nonlocal_db_out_link_writes_through_external_put() {
3035        let db = PvDatabase::new();
3036        let puts = Arc::new(Mutex::new(Vec::new()));
3037        db.register_link_set("ca", Arc::new(RecordingLset { puts: puts.clone() }))
3038            .await;
3039
3040        // "OTHER:PV" is never added as a local record -> non-local.
3041        let link = DbLink {
3042            record: "OTHER:PV".to_string(),
3043            field: "VAL".to_string(),
3044            policy: LinkProcessPolicy::NoProcess,
3045            monitor_switch: MonitorSwitch::NoMaximize,
3046        };
3047        let alarm = no_alarm();
3048        let mut visited = HashSet::new();
3049        db.write_db_link_value(
3050            &link,
3051            EpicsValue::Double(42.0),
3052            out_src(&alarm),
3053            &mut visited,
3054            0,
3055        )
3056        .await;
3057
3058        let captured = puts.lock().unwrap();
3059        assert_eq!(
3060            captured.len(),
3061            1,
3062            "non-local OUT-link write must reach the external put path exactly once"
3063        );
3064        assert_eq!(captured[0].0, "OTHER:PV");
3065        assert!(matches!(captured[0].1, EpicsValue::Double(v) if v == 42.0));
3066    }
3067
3068    /// A local OUT-link write must NOT divert to the external put path —
3069    /// the locality dispatch only reroutes non-local targets. The local
3070    /// record receives the value; the lset records nothing.
3071    #[tokio::test]
3072    async fn local_db_out_link_writes_local_not_external() {
3073        let db = PvDatabase::new();
3074        let puts = Arc::new(Mutex::new(Vec::new()));
3075        db.register_link_set("ca", Arc::new(RecordingLset { puts: puts.clone() }))
3076            .await;
3077        db.add_record("TGT", Box::new(CalcRecord::new("0")))
3078            .await
3079            .unwrap();
3080
3081        let link = DbLink {
3082            record: "TGT".to_string(),
3083            field: "VAL".to_string(),
3084            policy: LinkProcessPolicy::NoProcess,
3085            monitor_switch: MonitorSwitch::NoMaximize,
3086        };
3087        let alarm = no_alarm();
3088        let mut visited = HashSet::new();
3089        db.write_db_link_value(
3090            &link,
3091            EpicsValue::Double(7.0),
3092            out_src(&alarm),
3093            &mut visited,
3094            0,
3095        )
3096        .await;
3097
3098        assert!(
3099            puts.lock().unwrap().is_empty(),
3100            "a local OUT-link write must not reach the external put path"
3101        );
3102        assert!(
3103            matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 7.0),
3104            "the local target must hold the written value"
3105        );
3106    }
3107
3108    /// A link set that records every `scan_forward` it receives so a test
3109    /// can assert a non-DB FLNK reached the external forward path
3110    /// (C `dbScanFwdLink` → `lset->scanForward`) instead of being dropped
3111    /// by the DB-only `flnk_name` filter. `connected=false` models a
3112    /// disconnected link (pvxs `pvaScanForward`'s `!valid()` gate).
3113    struct ForwardingLset {
3114        forwards: Arc<Mutex<Vec<String>>>,
3115        connected: bool,
3116    }
3117    #[async_trait::async_trait]
3118    impl LinkSet for ForwardingLset {
3119        async fn is_connected(&self, _: &str) -> bool {
3120            self.connected
3121        }
3122        async fn get_value(&self, _: &str) -> Option<EpicsValue> {
3123            None
3124        }
3125        async fn scan_forward(&self, name: &str) -> Result<(), String> {
3126            self.forwards.lock().unwrap().push(name.to_string());
3127            if self.connected {
3128                Ok(())
3129            } else {
3130                Err("Disconn".into())
3131            }
3132        }
3133    }
3134
3135    /// `scan_forward_external_pv` must resolve the scheme and delegate to
3136    /// the registered lset's `scan_forward` — the FWD-link twin of
3137    /// `write_external_pv` for OUT writes (C `dbScanFwdLink` →
3138    /// `lset->scanForward`).
3139    #[tokio::test]
3140    async fn external_forward_link_dispatches_through_scan_forward() {
3141        let db = PvDatabase::new();
3142        let forwards = Arc::new(Mutex::new(Vec::new()));
3143        db.register_link_set(
3144            "pva",
3145            Arc::new(ForwardingLset {
3146                forwards: forwards.clone(),
3147                connected: true,
3148            }),
3149        )
3150        .await;
3151
3152        db.scan_forward_external_pv("pva://OTHER:PROC")
3153            .await
3154            .expect("a connected forward must succeed");
3155
3156        let captured = forwards.lock().unwrap();
3157        assert_eq!(captured.len(), 1);
3158        assert_eq!(captured[0], "OTHER:PROC");
3159    }
3160
3161    /// End-to-end: a record whose FLNK targets an external `pva://` PV
3162    /// must fire the link set's `scan_forward` when it processes — the
3163    /// non-DB FLNK dispatch the DB-only `flnk_name` filter previously
3164    /// dropped. C `recGblFwdLink` runs `dbScanFwdLink` for every FLNK
3165    /// regardless of link kind.
3166    #[tokio::test]
3167    async fn record_processing_fires_external_forward_link() {
3168        let db = PvDatabase::new();
3169        let forwards = Arc::new(Mutex::new(Vec::new()));
3170        db.register_link_set(
3171            "pva",
3172            Arc::new(ForwardingLset {
3173                forwards: forwards.clone(),
3174                connected: true,
3175            }),
3176        )
3177        .await;
3178        db.add_record("SRC", Box::new(CalcRecord::new("0")))
3179            .await
3180            .unwrap();
3181        if let Some(rec) = db.get_record("SRC").await {
3182            let mut inst = rec.write().await;
3183            inst.put_common_field("FLNK", EpicsValue::String("pva://TARGET".into()))
3184                .unwrap();
3185        }
3186
3187        let mut visited = HashSet::new();
3188        db.process_record_with_links("SRC", &mut visited, 0)
3189            .await
3190            .unwrap();
3191
3192        let captured = forwards.lock().unwrap();
3193        assert_eq!(
3194            captured.len(),
3195            1,
3196            "an external pva:// FLNK must fire scan_forward exactly once"
3197        );
3198        assert_eq!(captured[0], "TARGET");
3199    }
3200
3201    /// A disconnected non-retry external FLNK is NOT dropped silently: the
3202    /// lset returns Err and the owning record takes a *pending*
3203    /// LINK/INVALID alarm — pvxs `pvaScanForward`'s
3204    /// `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")`, which
3205    /// writes `nsta`/`nsev`/`namsg` (promoted by the next
3206    /// `recGblResetAlarms`, matching the C late-set inside `recGblFwdLink`).
3207    #[tokio::test]
3208    async fn disconnected_external_forward_link_raises_pending_link_invalid() {
3209        let db = PvDatabase::new();
3210        let forwards = Arc::new(Mutex::new(Vec::new()));
3211        db.register_link_set(
3212            "pva",
3213            Arc::new(ForwardingLset {
3214                forwards: forwards.clone(),
3215                connected: false,
3216            }),
3217        )
3218        .await;
3219        db.add_record("SRC", Box::new(CalcRecord::new("0")))
3220            .await
3221            .unwrap();
3222        if let Some(rec) = db.get_record("SRC").await {
3223            let mut inst = rec.write().await;
3224            inst.put_common_field("FLNK", EpicsValue::String("pva://TARGET".into()))
3225                .unwrap();
3226        }
3227
3228        let mut visited = HashSet::new();
3229        db.process_record_with_links("SRC", &mut visited, 0)
3230            .await
3231            .unwrap();
3232
3233        assert_eq!(
3234            forwards.lock().unwrap().len(),
3235            1,
3236            "scan_forward is still attempted on a disconnected link"
3237        );
3238        let rec = db.get_record("SRC").await.unwrap();
3239        let inst = rec.read().await;
3240        assert_eq!(inst.common.nsev, AlarmSeverity::Invalid);
3241        assert_eq!(
3242            inst.common.nsta,
3243            crate::server::recgbl::alarm_status::LINK_ALARM
3244        );
3245        assert_eq!(inst.common.namsg, "Disconn");
3246    }
3247}
3248
3249#[cfg(test)]
3250mod cp_link_locality_tests {
3251    use crate::server::database::PvDatabase;
3252    use crate::server::record::ParsedLink;
3253    use crate::server::records::ai::AiRecord;
3254
3255    /// A CP/CPP link with no explicit `CA` modifier whose target is NOT a
3256    /// local record must be served as an external (CA) link — both the
3257    /// trigger and the value read. C `dbInitLink` (`dbLink.c:118-130`)
3258    /// makes any `CP`/`CPP` link a CA link, and `dbDbInitLink` keeps it
3259    /// local only when the named record exists in this IOC.
3260    ///
3261    /// Here `INP="OTHER:PV CP"` parses to `ParsedLink::Db` (bare name, no
3262    /// `CA`), but `OTHER:PV` is not local. After `setup_cp_links` the
3263    /// holder's `parsed_inp` must be rewritten to `ParsedLink::Ca` (so the
3264    /// read routes through `resolve_external_pv`) and `OTHER:PV` must be
3265    /// registered as an external CP trigger.
3266    #[tokio::test]
3267    async fn cp_link_to_nonlocal_target_forced_external() {
3268        let db = PvDatabase::new();
3269        db.add_record("HOLDER", Box::new(AiRecord::new(0.0)))
3270            .await
3271            .unwrap();
3272        {
3273            let rec = db.get_record("HOLDER").await.unwrap();
3274            rec.write().await.common.inp = "OTHER:PV CP".to_string();
3275        }
3276
3277        db.setup_cp_links().await;
3278
3279        let inp = db
3280            .get_record("HOLDER")
3281            .await
3282            .unwrap()
3283            .read()
3284            .await
3285            .parsed_inp
3286            .clone();
3287        match inp {
3288            ParsedLink::Ca(ca) => assert_eq!(
3289                ca.pv, "OTHER:PV",
3290                "non-local CP link must carry the verbatim PV name"
3291            ),
3292            other => panic!("non-local CP link must be forced to Ca, got {other:?}"),
3293        }
3294        assert!(
3295            db.external_cp_pv_names()
3296                .await
3297                .contains(&"OTHER:PV".to_string()),
3298            "non-local CP link must be registered as an external CP trigger"
3299        );
3300    }
3301
3302    /// A CP link whose target IS a local record keeps the local fast-path:
3303    /// `setup_cp_links` must NOT rewrite its `parsed_inp` to `Ca` and must
3304    /// NOT register it as an external CP link.
3305    #[tokio::test]
3306    async fn cp_link_to_local_target_stays_db() {
3307        let db = PvDatabase::new();
3308        db.add_record("SRC", Box::new(AiRecord::new(1.0)))
3309            .await
3310            .unwrap();
3311        db.add_record("HOLDER", Box::new(AiRecord::new(0.0)))
3312            .await
3313            .unwrap();
3314        {
3315            let rec = db.get_record("HOLDER").await.unwrap();
3316            let mut inst = rec.write().await;
3317            inst.common.inp = "SRC CP".to_string();
3318            // Seed the parse cache as load would, so the assertion proves
3319            // setup_cp_links leaves a local CP link untouched.
3320            inst.parsed_inp = crate::server::record::parse_link_v2("SRC CP");
3321        }
3322
3323        db.setup_cp_links().await;
3324
3325        let inp = db
3326            .get_record("HOLDER")
3327            .await
3328            .unwrap()
3329            .read()
3330            .await
3331            .parsed_inp
3332            .clone();
3333        assert!(
3334            matches!(inp, ParsedLink::Db(_)),
3335            "local CP link must stay a Db link, got {inp:?}"
3336        );
3337        assert!(
3338            !db.external_cp_pv_names().await.contains(&"SRC".to_string()),
3339            "a local CP target must not be registered as an external CP link"
3340        );
3341    }
3342}
3343
3344#[cfg(test)]
3345mod nonlocal_db_link_read_tests {
3346    use crate::server::database::PvDatabase;
3347    use crate::server::record::{ParsedLink, parse_link_v2};
3348    use crate::types::EpicsValue;
3349    use std::collections::HashSet;
3350    use std::sync::Arc;
3351
3352    /// C `dbInitLink` (`dbLink.c:118-130`) makes a PV link whose target is
3353    /// not a local record a CA link — even with NO `CP`/`CPP`/`CA`
3354    /// modifier (`dbDbInitLink` fails to resolve it locally and falls
3355    /// through to `dbCaAddLinkCallbackOpt`). So a plain `INP="OTHER:PV"`
3356    /// to a non-local target must read the remote value, not return
3357    /// `None`. Pre-fix the `Db` read arm read only the local DB (`get_pv`)
3358    /// and dropped the non-local target.
3359    #[tokio::test]
3360    async fn plain_nonlocal_db_link_reads_via_external_resolver() {
3361        let db = PvDatabase::new();
3362        // Stand-in for the calink/pvalink lset: resolve OTHER:PV remotely.
3363        db.set_external_resolver(Arc::new(|name: &str| {
3364            let hit = name == "OTHER:PV";
3365            Box::pin(async move {
3366                if hit {
3367                    Some(EpicsValue::Double(42.0))
3368                } else {
3369                    None
3370                }
3371            })
3372        }))
3373        .await;
3374
3375        let link = parse_link_v2("OTHER:PV");
3376        assert!(
3377            matches!(link, ParsedLink::Db(_)),
3378            "a bare non-scheme name parses to a Db link, got {link:?}"
3379        );
3380
3381        let mut visited = HashSet::new();
3382        let v = db.read_link_value(&link, &mut visited, 0).await;
3383        assert_eq!(
3384            v,
3385            Some(EpicsValue::Double(42.0)),
3386            "a plain non-local Db link must resolve through the external resolver (C dbCaAddLink fallback)"
3387        );
3388    }
3389
3390    /// A multi-input-style link re-parsed from its raw string each cycle
3391    /// (calc `INPA`..`INPL`, `DOL`) routes its value read through
3392    /// `read_link_with_alarm`; the same locality rule must hold there, so
3393    /// a non-local target reads the remote value and surfaces no
3394    /// local-record alarm.
3395    #[tokio::test]
3396    async fn nonlocal_db_link_value_and_alarm_via_external() {
3397        let db = PvDatabase::new();
3398        db.set_external_resolver(Arc::new(|name: &str| {
3399            let hit = name == "OTHER:PV";
3400            Box::pin(async move {
3401                if hit {
3402                    Some(EpicsValue::Double(5.0))
3403                } else {
3404                    None
3405                }
3406            })
3407        }))
3408        .await;
3409
3410        let link = parse_link_v2("OTHER:PV");
3411        let (fetch, alarm) = db.read_link_with_alarm(&link).await;
3412        assert_eq!(
3413            fetch.value(),
3414            Some(EpicsValue::Double(5.0)),
3415            "non-local Db link value must come from the external resolver"
3416        );
3417        // No lset is registered (only the legacy resolver), so the bare
3418        // external alarm path reports None — never a fabricated local
3419        // record alarm for a record that does not exist in this IOC.
3420        assert!(
3421            alarm.is_none(),
3422            "non-local Db link must not fabricate a local-record alarm, got {alarm:?}"
3423        );
3424    }
3425
3426    /// Owner path: a Db link whose target IS a local record still reads
3427    /// the local database and never consults the external resolver.
3428    #[tokio::test]
3429    async fn local_db_link_reads_local_not_external() {
3430        let db = PvDatabase::new();
3431        db.add_pv("SRC", EpicsValue::Double(7.0)).await.unwrap();
3432        // A resolver returning a DIFFERENT value proves the local read
3433        // wins and the external path is not taken for a local target.
3434        db.set_external_resolver(Arc::new(|_name: &str| {
3435            Box::pin(async { Some(EpicsValue::Double(-1.0)) })
3436        }))
3437        .await;
3438
3439        let link = parse_link_v2("SRC");
3440        let mut visited = HashSet::new();
3441        let v = db.read_link_value(&link, &mut visited, 0).await;
3442        assert_eq!(
3443            v,
3444            Some(EpicsValue::Double(7.0)),
3445            "a local Db link must read the local DB, not the external resolver"
3446        );
3447    }
3448}
3449
3450/// C's link-side metadata fetch — `dbGetGraphicLimits` & siblings
3451/// (`dbLink.c:344-393`) — one case per boundary the C code distinguishes,
3452/// not one per narrative.
3453#[cfg(test)]
3454mod link_metadata_tests {
3455    use crate::server::database::PvDatabase;
3456    use crate::server::record::parse_link_v2;
3457    use crate::server::records::ai::AiRecord;
3458    use crate::server::records::stringout::StringoutRecord;
3459    use crate::server::records::waveform::WaveformRecord;
3460    use crate::types::DbFieldType;
3461    use std::collections::HashSet;
3462
3463    /// BOUNDARY: constant link.
3464    ///
3465    /// `dbConst_lset` leaves all five metadata slots NULL
3466    /// (`dbConstLink.c:234-248`), so `dbGetGraphicLimits`'s
3467    /// `!plset->getGraphicLimits` test returns `S_db_noLSET` and writes
3468    /// NOTHING (`dbLink.c:358-359`).
3469    ///
3470    /// This is the case that decides the oracle's `field(INPA,"5")` records:
3471    /// C's answer is neither a propagated limit nor a DBF-type-range default.
3472    /// The caller keeps its pre-filled buffer.
3473    #[tokio::test]
3474    async fn constant_link_reports_no_metadata() {
3475        let db = PvDatabase::new();
3476        let mut visited = HashSet::new();
3477        let link = parse_link_v2("5");
3478        assert!(
3479            matches!(link, crate::server::record::ParsedLink::Constant(_)),
3480            "fixture must actually be a constant link, got {link:?}"
3481        );
3482        assert_eq!(
3483            db.link_metadata(&link, &mut visited).await,
3484            None,
3485            "a constant link has no metadata lset slots: C returns S_db_noLSET"
3486        );
3487    }
3488
3489    /// BOUNDARY: db link -> field whose record type HAS the slots.
3490    ///
3491    /// `ai` supplies every numeric slot, and `aiRecord.c::get_graphic_double`
3492    /// serves VAL from `prec->hopr`/`prec->lopr`. `dbDbGetGraphicLimits`
3493    /// returns those verbatim (`dbDbLink.c:280-295`).
3494    #[tokio::test]
3495    async fn db_link_to_supported_field_propagates_target_limits() {
3496        let db = PvDatabase::new();
3497        let mut src = AiRecord::new(1.0);
3498        src.hopr = 10.0;
3499        src.lopr = -10.0;
3500        src.egu = "mm".into();
3501        src.prec = 3;
3502        db.add_record("SRC", Box::new(src)).await.unwrap();
3503
3504        let mut visited = HashSet::new();
3505        let meta = db
3506            .link_metadata(&parse_link_v2("SRC"), &mut visited)
3507            .await
3508            .expect("a local db link to an existing field reports metadata");
3509
3510        assert_eq!(meta.graphic_limits, Some((-10.0, 10.0)));
3511        assert_eq!(meta.units.as_deref(), Some("mm"));
3512        assert_eq!(meta.precision, Some(3));
3513    }
3514
3515    /// BOUNDARY: db link -> field whose record type has NO slots at all.
3516    ///
3517    /// `dbGet` does not fail here — it fills a default, turns the option bit
3518    /// off, and returns 0, so `dbDbGet*` reads the default out and reports it.
3519    /// `stringout` `#define`s every `get_*` NULL (`stringoutRecord.c:64-83`).
3520    ///
3521    /// The defaults are NOT uniform, which is the whole point of this case:
3522    /// `get_graphics`/`get_control` `memset` to zero (`dbAccess.c:241-242`,
3523    /// `:281-283`) while `get_alarm` seeds `{epicsNAN×4}` and assigns
3524    /// unconditionally (`dbAccess.c:290,317-330`).
3525    #[tokio::test]
3526    async fn db_link_to_unsupported_field_yields_c_defaults_not_none() {
3527        let db = PvDatabase::new();
3528        db.add_record("STR", Box::new(StringoutRecord::new("hello")))
3529            .await
3530            .unwrap();
3531
3532        let mut visited = HashSet::new();
3533        let meta = db
3534            .link_metadata(&parse_link_v2("STR"), &mut visited)
3535            .await
3536            .expect("dbGet returns 0 even when every option is turned off");
3537
3538        assert_eq!(
3539            meta.graphic_limits,
3540            Some((0.0, 0.0)),
3541            "no get_graphic_double: C memsets the buffer to zero and returns 0"
3542        );
3543        assert_eq!(
3544            meta.control_limits,
3545            Some((0.0, 0.0)),
3546            "no get_control_double: C memsets the buffer to zero and returns 0"
3547        );
3548        let (lolo, low, high, hihi) = meta.alarm_limits.expect("alarm limits are written");
3549        assert!(
3550            lolo.is_nan() && low.is_nan() && high.is_nan() && hihi.is_nan(),
3551            "no get_alarm_double: C's pre-filled epicsNAN survives, NOT zero \
3552             (dbAccess.c:290) — got ({lolo}, {low}, {high}, {hihi})"
3553        );
3554        assert_eq!(meta.precision, Some(0), "no get_precision: C memsets to 0");
3555        assert_eq!(
3556            meta.units.as_deref(),
3557            Some(""),
3558            "no get_units: C memsets the units buffer"
3559        );
3560    }
3561
3562    /// BOUNDARY: db link -> record type that has SOME slots but not
3563    /// `get_alarm_double` (`waveformRecord.c` `#define get_alarm_double NULL`).
3564    ///
3565    /// Pins that the no-support default is decided per slot, not per record:
3566    /// the graphic limits still come from the target's own support while the
3567    /// alarm limits fall back to NaN.
3568    #[tokio::test]
3569    async fn db_link_alarm_default_is_per_slot_not_per_record() {
3570        let db = PvDatabase::new();
3571        db.add_record("WF", Box::new(WaveformRecord::new(8, DbFieldType::Double)))
3572            .await
3573            .unwrap();
3574
3575        let mut visited = HashSet::new();
3576        let meta = db
3577            .link_metadata(&parse_link_v2("WF"), &mut visited)
3578            .await
3579            .expect("waveform reports metadata");
3580
3581        let (lolo, low, high, hihi) = meta.alarm_limits.expect("alarm limits are written");
3582        assert!(
3583            lolo.is_nan() && low.is_nan() && high.is_nan() && hihi.is_nan(),
3584            "waveform NULLs get_alarm_double: NaN, not zero"
3585        );
3586        assert!(
3587            meta.graphic_limits.is_some(),
3588            "waveform keeps get_graphic_double, so that slot is still served"
3589        );
3590    }
3591
3592    /// BOUNDARY: the link points back at a field already on this fetch chain.
3593    ///
3594    /// C's `DBLINK_FLAG_VISITED` guard: a re-entered link leaves `status` at
3595    /// its `S_dbLib_badLink` initialiser and writes nothing
3596    /// (`dbDbLink.c:239-261`) — without it, a record that sources its own
3597    /// metadata from an input link pointing back at itself recurses forever.
3598    #[tokio::test]
3599    async fn revisited_db_link_target_is_refused() {
3600        let db = PvDatabase::new();
3601        db.add_record("SRC", Box::new(AiRecord::new(1.0)))
3602            .await
3603            .unwrap();
3604
3605        let link = parse_link_v2("SRC");
3606        let mut visited = HashSet::new();
3607        assert!(
3608            db.link_metadata(&link, &mut visited).await.is_some(),
3609            "first visit resolves"
3610        );
3611        assert!(
3612            visited.is_empty(),
3613            "the guard must be CLEARED after the fetch (C clears the flag at \
3614             dbDbLink.c:257), or a diamond onto one target would fail"
3615        );
3616
3617        // Simulate being re-entered from inside SRC.VAL's own metadata fetch.
3618        visited.insert("SRC.VAL".to_string());
3619        assert_eq!(
3620            db.link_metadata(&link, &mut visited).await,
3621            None,
3622            "a link already on the chain must write nothing"
3623        );
3624    }
3625
3626    /// A db link naming a field the target record does not have has no
3627    /// `dbAddr` in C — `dbNameToAddr` fails at link-init time, so no lset is
3628    /// installed and `dbGetGraphicLimits` returns `S_db_noLSET`.
3629    #[tokio::test]
3630    async fn db_link_to_missing_field_reports_no_metadata() {
3631        let db = PvDatabase::new();
3632        db.add_record("SRC", Box::new(AiRecord::new(1.0)))
3633            .await
3634            .unwrap();
3635        let mut visited = HashSet::new();
3636        assert_eq!(
3637            db.link_metadata(&parse_link_v2("SRC.NOSUCHFIELD"), &mut visited)
3638                .await,
3639            None,
3640        );
3641    }
3642}