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