Skip to main content

epics_base_rs/server/database/
links.rs

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