Skip to main content

epics_base_rs/server/database/
links.rs

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