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