Skip to main content

epics_base_rs/server/database/
links.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use crate::runtime::sync::RwLock;
5use crate::server::record::{AlarmSeverity, NotifyWaitSet, RecordInstance, ScanType};
6use crate::types::EpicsValue;
7
8use super::processing::join_put_notify;
9use super::{LinkPutOp, PvDatabase, SelmKind, SelmResult, dbr_ushort_cast, select_link_indices_ex};
10
11/// Record-specific input link fields that may carry a CP/CPP modifier:
12/// DOL (ao/bo/longout/mbbo), DOL0-DOLF (seq — 16 groups), DOL1-DOLA
13/// (sseq — legacy 10 groups), NVL (sel), SELL (sseq), SGNL (histogram).
14///
15/// Shared by [`PvDatabase::record_link_fields`] (the single owner of
16/// "which fields on a record are links") and consumed transitively by
17/// both [`PvDatabase::setup_cp_links`] (CA CP/CPP) and the pvalink
18/// install scan (PVA CP/CPP), so the two enumerations cannot diverge.
19pub(crate) const CP_INPUT_LINK_FIELDS: &[&str] = &[
20    "DOL", "DOL0", "DOL1", "DOL2", "DOL3", "DOL4", "DOL5", "DOL6", "DOL7", "DOL8", "DOL9", "DOLA",
21    "DOLB", "DOLC", "DOLD", "DOLE", "DOLF", "NVL", "SELL", "SGNL",
22];
23
24/// Alarm state from a link source, used for MS/NMS propagation.
25///
26/// `amsg` is the alarm-message string — propagated from the source
27/// record's `common.amsg` so a downstream MS link sees the same
28/// human-readable explanation. Empty when the source has no message
29/// or when the link source is not a DB record.
30#[derive(Clone, Debug)]
31pub(crate) struct LinkAlarm {
32    pub stat: u16,
33    pub sevr: AlarmSeverity,
34    pub amsg: String,
35}
36
37/// Apply C `recGblInheritSevrMsg` (recGbl.c:263-281) for one MS-class
38/// link: fold the link source's alarm (`src`) into the destination
39/// record's PENDING alarm (`dest`) per the maximize-severity mode `ms`.
40///
41/// * **NMS** — no propagation.
42/// * **MS**  — raise dest severity to `src.sevr` under `LINK_ALARM`
43///   (NOT the source's stat); no message.
44/// * **MSI** — same as MS, but only when the source is at `INVALID`.
45/// * **MSS** — copy the source's stat + severity + amsg (the only mode
46///   that propagates the message).
47///
48/// Shared by the INPUT-link read path (`processing.rs`, where `dest` is
49/// the record reading its inputs) and the DB OUT-link write path
50/// ([`Database::write_db_link_value`], C `dbDbPutValue` →
51/// `recGblInheritSevrMsg`, dbDbLink.c:382-383, where `dest` is the
52/// OUT-link target). One implementation keeps the two sides from
53/// diverging — an earlier INPUT-side variant wrongly treated MS like
54/// MSS (propagating source stat + amsg through plain MS).
55pub(crate) fn inherit_sevr_msg(
56    dest: &mut crate::server::record::CommonFields,
57    ms: crate::server::record::MonitorSwitch,
58    src: &LinkAlarm,
59) {
60    use crate::server::recgbl::{alarm_status, rec_gbl_set_sevr, rec_gbl_set_sevr_msg};
61    use crate::server::record::{AlarmSeverity, MonitorSwitch};
62    match ms {
63        MonitorSwitch::Maximize => {
64            rec_gbl_set_sevr(dest, alarm_status::LINK_ALARM, src.sevr);
65        }
66        MonitorSwitch::MaximizeIfInvalid => {
67            if src.sevr == AlarmSeverity::Invalid {
68                rec_gbl_set_sevr(dest, alarm_status::LINK_ALARM, src.sevr);
69            }
70        }
71        MonitorSwitch::MaximizeStatus => {
72            rec_gbl_set_sevr_msg(dest, src.stat, src.sevr, src.amsg.clone());
73        }
74        MonitorSwitch::NoMaximize => {} // NMS: do not propagate
75    }
76}
77
78/// The source record's per-cycle state propagated to a DB OUT-link
79/// target, captured once from the source and threaded to every OUT-link
80/// write so all targets in one cycle see the same snapshot:
81///
82/// * `putf` / `notify` — the PUTF bit and put-notify wait-set that C
83///   `processTarget` carries to each target (dbDbLink.c:460-474).
84/// * `alarm` — the committed alarm that C `recGblInheritSevrMsg` folds
85///   into the dest per the link's MS-class switch (dbDbLink.c:382-383).
86///
87/// Bundled so the OUT-link write path threads one snapshot instead of
88/// three positional arguments. The FLNK process-trigger path keeps the
89/// lighter `PutNotifyCtx` (a forward link propagates no value and thus
90/// no alarm).
91#[derive(Clone, Copy)]
92pub(crate) struct OutLinkSrc<'a> {
93    pub putf: bool,
94    pub notify: Option<&'a Arc<NotifyWaitSet>>,
95    pub alarm: &'a LinkAlarm,
96}
97
98/// One `seq` link group — C `linkGrp { dly, dol, dov, lnk }`.
99#[derive(Clone, Debug)]
100pub(crate) struct SeqGroup {
101    /// DOLn input link string (empty when unset).
102    pub dol: String,
103    /// LNKn output link string (empty when unset).
104    pub lnk: String,
105    /// DLYn per-group delay in seconds.
106    pub dly: f64,
107    /// DOn value-storage field (`linkGrp.dov`) — used when DOLn is
108    /// an empty/constant link.
109    pub dov: f64,
110}
111
112/// Typed multi-output payload — replaces the legacy `\0`-packed
113/// `Vec<String>` so a link string containing an embedded NUL can
114/// never mis-split (parity review 04-L3).
115///
116/// `sseq` is NOT a variant here: a `sseq` record drives its per-step
117/// `LNKn` writes itself, in `SseqRecord::process()`, through the async
118/// PACT machine (C `sseqRecord.c::processCallback`) — not via this
119/// all-at-once dispatch.
120pub(crate) enum MultiOut {
121    /// fanout — 16 forward-link strings (LNK0..LNKF).
122    Fanout(Vec<String>),
123    /// dfanout — 16 output-link strings (OUTA..OUTP).
124    Dfanout(Vec<String>),
125    /// seq — 16 link groups (0..F).
126    Seq(Vec<SeqGroup>),
127}
128
129impl MultiOut {
130    /// Number of link slots — the `count` passed to the SELM selector.
131    fn len(&self) -> usize {
132        match self {
133            MultiOut::Fanout(v) => v.len(),
134            MultiOut::Dfanout(v) => v.len(),
135            MultiOut::Seq(v) => v.len(),
136        }
137    }
138}
139
140/// Record types whose multi-output link groups are dispatched by
141/// [`PvDatabase::dispatch_multi_output`].
142///
143/// SINGLE-OWNER INVARIANT — each of these record types' output links
144/// (fanout `LNKn`, dfanout `OUTn`, seq `LNKn`) is dispatched (value
145/// written + target forward-link processed) **exactly once per process
146/// cycle, by `dispatch_multi_output` and by nothing else**.
147///
148/// `dispatch_multi_output` is the sole owner because it is the only
149/// path that performs the full C-record model: SELL→SELN resolution,
150/// SELM/OFFS/SHFT selection, per-group DOLn input fetch, and per-group
151/// DLYn delay.
152///
153/// `sseq` is deliberately NOT listed: its `LNKn` writes are owned by
154/// `SseqRecord::process()` (the async PACT machine, C
155/// `sseqRecord.c::processCallback`), not by this dispatch. `sseq` also
156/// does not implement `Record::multi_output_links`, so the generic
157/// block skips it for that reason — there is no second dispatcher to
158/// gate against.
159///
160/// MUST NOT: the generic `multi_output_links` block in `processing.rs`
161/// (run unconditionally for every record after `dispatch_multi_output`)
162/// must skip any record type listed here. `multi_output_dispatch_owned`
163/// is consulted by that block (see `run_forward_link_tail_with_putf`
164/// §4.6) so a double-dispatch is structurally impossible, not merely
165/// removed at one call site.
166pub(crate) fn multi_output_dispatch_owned(record_type: &str) -> bool {
167    matches!(record_type, "fanout" | "dfanout" | "seq")
168}
169
170impl PvDatabase {
171    /// Read a `Db`-variant link's value honoring C `dbInitLink`'s
172    /// locality rule (`dbLink.c:118-130`): a PV link whose target record
173    /// exists in this IOC reads from the local database; a non-local
174    /// target is a CA link — `dbDbInitLink` fails to resolve it locally
175    /// and falls through to `dbCaAddLinkCallbackOpt`, so its value comes
176    /// from the external resolver. Pre-fix every `Db` arm read only the
177    /// local DB (`get_pv`) and returned `None` for a non-local target, so
178    /// a plain `INP="other:pv"` (no modifier) and a re-parsed multi-input
179    /// (`INPA`..`INPL`) / `DOL` link never read the remote value.
180    ///
181    /// This is the single owner of that rule for the value-read path, so
182    /// it holds uniformly — for every link field and regardless of an
183    /// explicit `CP`/`CPP`/`CA` modifier — not only the
184    /// `INP`/`OUT`/`TSEL`/`SDIS` parse caches the iocInit CP scan
185    /// rewrites (the per-cycle re-parsed links have no cache to rewrite,
186    /// so an init-time conversion can never reach them). The external
187    /// read routes through the lset's lazy-open path: the first read
188    /// opens the CA link and returns `None` until the monitor connects,
189    /// then serves the cached value — exactly C `dbCaGetLink`.
190    async fn read_db_link_value(&self, db: &crate::server::record::DbLink) -> Option<EpicsValue> {
191        self.read_target_value(&db.record, &db.field).await
192    }
193
194    /// Read a `(record, field)` link target's value, dispatching by C
195    /// `dbInitLink` locality (`dbLink.c:118-130`): a target present in
196    /// this IOC reads from the local database (`get_pv`); a non-local
197    /// target is a CA link, resolved through the external path
198    /// (`dbCaGetLink`). The single owner of that locality decision for
199    /// the value-read path — shared by [`Self::read_db_link_value`] (the
200    /// record's own `Db` link) and the lnkCalc input loop
201    /// ([`Self::evaluate_calc_link`]), whose `A..L` inputs are each their
202    /// own `dbInitLink` link and so become CA links when non-local.
203    async fn read_target_value(&self, record: &str, field: &str) -> Option<EpicsValue> {
204        let pv_name = if field == "VAL" {
205            record.to_string()
206        } else {
207            format!("{record}.{field}")
208        };
209        if self.has_name_no_resolve(record).await {
210            self.get_pv(&pv_name).await.ok()
211        } else {
212            self.resolve_external_pv(&pv_name).await
213        }
214    }
215
216    /// Read a value from a parsed link (DB, Constant, or external Ca/Pva).
217    ///
218    /// `visited` / `depth` are the caller's processing-chain state so a
219    /// PP source is processed within the same chain — see
220    /// [`Self::process_passive_db_source`] for why a fresh set / depth 0
221    /// would defeat the cycle guard.
222    pub(crate) async fn read_link_value(
223        &self,
224        link: &crate::server::record::ParsedLink,
225        visited: &mut HashSet<String>,
226        depth: usize,
227    ) -> Option<EpicsValue> {
228        match link {
229            crate::server::record::ParsedLink::None => None,
230            crate::server::record::ParsedLink::Ca(ca) => self.resolve_external_pv(&ca.pv).await,
231            crate::server::record::ParsedLink::Pva(name) => self.resolve_external_pv(name).await,
232            // Resolve through the per-link identity key (not bare `j.pv`)
233            // so two same-PV structured links keep distinct configs —
234            // matches the boundary key the write/scan/alarm paths use via
235            // `external_pv_name` (pvxs per-link `pvaLinkConfig`,
236            // ioc/pvalink.h:65).
237            crate::server::record::ParsedLink::PvaJson(j) => {
238                self.resolve_external_pv(&j.link_identity_key()).await
239            }
240            crate::server::record::ParsedLink::Constant(_) => link.constant_value(),
241            crate::server::record::ParsedLink::Db(db) => {
242                // PP: process source record if Passive before reading.
243                // Threads the caller's `visited`/`depth` so an A↔B PP
244                // cycle terminates at the existing cycle guard instead
245                // of recursing with a fresh set.
246                self.process_passive_db_source(db, visited, depth).await;
247                self.read_db_link_value(db).await
248            }
249            // Hardware links are dispatched by device support directly
250            // — there's no canonical "value" available from a generic
251            // read; return None so the framework treats the link as
252            // unresolvable for value-read purposes.
253            crate::server::record::ParsedLink::Hw(_) => None,
254            // lnkCalc: fetch each input PV, evaluate the expr,
255            // return the result. Timestamp passthrough is handled by
256            // `read_calc_link_with_time` for callers that need it.
257            crate::server::record::ParsedLink::Calc(calc) => self.evaluate_calc_link(calc).await,
258        }
259    }
260
261    /// Read a link's current value WITHOUT processing a Passive source —
262    /// the parity of C `dbGetLink` (`dbLink.c:325` → `dbTryGetLink` →
263    /// `lset->getValue`), which fetches the value for *any* link type
264    /// (DB / CA / PVA / constant / lnkCalc) and never processes the
265    /// target record.
266    ///
267    /// Distinct from [`Self::read_link_value`], which threads
268    /// `visited`/`depth` to PP-process a Passive DB source before reading
269    /// (the INPUT-link path). `dbGetLink` does no such processing, so the
270    /// DB arm here reads with a plain `get_pv` exactly as the pre-fix
271    /// control-link sites did.
272    ///
273    /// Used by the control links that C reads via `dbGetLink` every
274    /// process cycle — `SDIS`→`disa`, `SIML`→`simm`, `SELL`→`seln`, and
275    /// `TSEL`'s `TSE` load. The pre-fix sites open-coded an
276    /// `if let ParsedLink::Db` read, so a control link sourced over
277    /// CA/PVA or given as a constant was silently ignored.
278    pub(crate) async fn read_link_value_no_process(
279        &self,
280        link: &crate::server::record::ParsedLink,
281    ) -> Option<EpicsValue> {
282        match link {
283            crate::server::record::ParsedLink::None => None,
284            crate::server::record::ParsedLink::Ca(ca) => self.resolve_external_pv(&ca.pv).await,
285            crate::server::record::ParsedLink::Pva(name) => self.resolve_external_pv(name).await,
286            // Per-link identity key, as in `read_link_value` above.
287            crate::server::record::ParsedLink::PvaJson(j) => {
288                self.resolve_external_pv(&j.link_identity_key()).await
289            }
290            crate::server::record::ParsedLink::Constant(_) => link.constant_value(),
291            crate::server::record::ParsedLink::Db(db) => self.read_db_link_value(db).await,
292            // Hardware links carry no generic readable value.
293            crate::server::record::ParsedLink::Hw(_) => None,
294            crate::server::record::ParsedLink::Calc(calc) => self.evaluate_calc_link(calc).await,
295        }
296    }
297
298    /// lnkCalc evaluation: fetch each input PV, bind to calc engine
299    /// vars A..L, run `expr`, return the result as `EpicsValue::Double`.
300    /// Returns `None` if any input fetch fails, expr compile fails, or
301    /// eval fails — the caller treats the link as unresolvable.
302    pub async fn evaluate_calc_link(
303        &self,
304        calc: &crate::server::record::CalcLink,
305    ) -> Option<EpicsValue> {
306        use crate::calc::engine::{CALC_NARGS, NumericInputs};
307        // lnkCalc binds inputs to calc engine vars A..L (12). A link
308        // string carrying more than `CALC_NARGS` inputs is malformed —
309        // reject it rather than silently dropping the overflow args
310        // (the pre-fix `.take(12)` masked the misconfiguration).
311        if calc.args.len() > CALC_NARGS {
312            return None;
313        }
314        let mut vars = [0.0f64; CALC_NARGS];
315        for (i, arg) in calc.args.iter().enumerate() {
316            // Each lnkCalc input is its own `dbInitLink` link, so a
317            // non-local input record is a CA link — read it through the
318            // locality owner, not a local-only `get_pv`. `arg` is a bare
319            // record name or `record.FIELD`; split on the last `.`.
320            let (record, field) = match arg.rsplit_once('.') {
321                Some((r, f)) => (r, f),
322                None => (arg.as_str(), "VAL"),
323            };
324            let v = self.read_target_value(record, field).await?;
325            vars[i] = v.to_f64()?;
326        }
327        let compiled = crate::calc::compile(&calc.expr).ok()?;
328        let mut inputs = NumericInputs::with_vars(vars);
329        let result = crate::calc::eval(&compiled, &mut inputs).ok()?;
330        Some(EpicsValue::Double(result))
331    }
332
333    /// lnkCalc evaluation that also returns the timestamp pulled from
334    /// the input named by `time_source` (e.g. `'A'` → first input).
335    /// Returns `(value, Some(time))` when `time_source` is set and
336    /// the referenced input record has a timestamp, `(value, None)`
337    /// otherwise. The caller (link read path) uses `None` to mean
338    /// "consumer keeps its own apply_timestamp time".
339    pub async fn evaluate_calc_link_with_time(
340        &self,
341        calc: &crate::server::record::CalcLink,
342    ) -> Option<(EpicsValue, Option<std::time::SystemTime>)> {
343        let value = self.evaluate_calc_link(calc).await?;
344        let time = match calc.time_source {
345            Some(letter) => {
346                let idx = (letter as u8).saturating_sub(b'A') as usize;
347                let src = calc.args.get(idx)?;
348                // Strip `.FIELD` suffix to land on the record name.
349                let record_name = src.rsplit_once('.').map(|(r, _)| r).unwrap_or(src);
350                if self.has_name_no_resolve(record_name).await {
351                    let rec = self.get_record(record_name).await?;
352                    let inst = rec.read().await;
353                    Some(inst.common.time)
354                } else {
355                    // Non-local time source → CA link; pull the remote
356                    // `.TIME` through the external resolver (C
357                    // `dbGetTimeStamp` on a CA link), same as the
358                    // non-local TSEL `.TIME` adoption.
359                    let (secs, ns, _utag) = self
360                        .external_link_time(&format!("ca://{record_name}"))
361                        .await?;
362                    let secs = secs.max(0) as u64;
363                    let ns = (ns.max(0) as u32).min(999_999_999);
364                    Some(std::time::UNIX_EPOCH + std::time::Duration::new(secs, ns))
365                }
366            }
367            None => None,
368        };
369        Some((value, time))
370    }
371
372    /// Read value + alarm from a DB link. Returns (value, alarm) for MS/NMS propagation.
373    pub(crate) async fn read_link_with_alarm(
374        &self,
375        link: &crate::server::record::ParsedLink,
376    ) -> (Option<EpicsValue>, Option<LinkAlarm>) {
377        match link {
378            crate::server::record::ParsedLink::Db(db) => {
379                let pv_name = if db.field == "VAL" {
380                    db.record.clone()
381                } else {
382                    format!("{}.{}", db.record, db.field)
383                };
384                // C `dbInitLink` locality (`dbLink.c:118-130`): a target
385                // record present in this IOC is a DB link read from the
386                // local database; a non-local target is a CA link, so its
387                // value and raw remote alarm come from the external
388                // resolver — identical to the `Ca`/`Pva` arm below.
389                if !self.has_name_no_resolve(&db.record).await {
390                    return (
391                        self.resolve_external_pv(&pv_name).await,
392                        self.external_link_alarm(&pv_name).await,
393                    );
394                }
395                let value = self.get_pv(&pv_name).await.ok();
396                // Read source record's alarm state — alias-aware
397                // (epics-base PR #336) so a link target spelled with
398                // an alias still propagates MS/NMS alarm correctly.
399                let alarm = if let Some(rec) = self.get_record(&db.record).await {
400                    let inst = rec.read().await;
401                    Some(LinkAlarm {
402                        stat: inst.common.stat,
403                        sevr: inst.common.sevr,
404                        amsg: inst.common.amsg.clone(),
405                    })
406                } else {
407                    None
408                };
409                (value, alarm)
410            }
411            crate::server::record::ParsedLink::Constant(_) => (link.constant_value(), None),
412            // External Pva/Ca link: the value comes from the lset's
413            // cached snapshot, the alarm from the lset's accessors.
414            //
415            // PVA: the `?sevr=` modifier is stripped before epics-base-rs
416            // parses the link, so the lset retains and applies the
417            // `MS`/`NMS`/`MSI` gate itself — a returned `Some(sev)` is
418            // already gated and the caller folds it as `MaximizeStatus`.
419            //
420            // CA: the `MS`/`NMS`/`MSI`/`MSS` modifier is
421            // now carried in the `CaLink`, so the resolver returns the
422            // *raw* remote alarm and record processing applies the gate
423            // using `link.monitor_switch()`. Either way this fn just
424            // reads the raw/gated alarm; the switch pairing happens in
425            // `processing.rs`. Without this, a connected external link
426            // carrying a remote MINOR/MAJOR severity never folded into
427            // the owning record's LINK_ALARM (B2).
428            crate::server::record::ParsedLink::Pva(_)
429            | crate::server::record::ParsedLink::PvaJson(_)
430            | crate::server::record::ParsedLink::Ca(_) => {
431                let name = link
432                    .external_pv_name()
433                    .expect("Ca/Pva/PvaJson link carries a PV name");
434                let value = self.resolve_external_pv(&name).await;
435                let alarm = self.external_link_alarm(&name).await;
436                (value, alarm)
437            }
438            _ => (None, None),
439        }
440    }
441
442    /// Latched upstream timestamp from the lset, when the
443    /// link is configured with `time=true`. The lset gates internally
444    /// (returning `None` for links without the `time` option), so a
445    /// `Some` here is the authoritative remote timestamp the
446    /// processing path should adopt into the owning record's
447    /// `common.time` and `common.utag`. Mirrors pvxs
448    /// `pvalink_lset.cpp:427`.
449    ///
450    /// Returns `(seconds_since_epoch, nanoseconds, userTag)` exactly as
451    /// the lset reports them; the caller folds the time into the
452    /// record's `SystemTime` via `UNIX_EPOCH + Duration::new(...)` and
453    /// adopts the `userTag` into `common.utag`. The `userTag` is the
454    /// remote `timeStamp.userTag` widened without sign extension, or `0`
455    /// when the source carries none.
456    pub(crate) async fn external_link_time(&self, name: &str) -> Option<(i64, i32, u64)> {
457        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
458            ("pva", rest)
459        } else if let Some(rest) = name.strip_prefix("ca://") {
460            ("ca", rest)
461        } else {
462            // Bare name — try every registered lset.
463            let registry = self.inner.link_sets.read().await;
464            for s in registry.schemes() {
465                if let Some(lset) = registry.get(&s) {
466                    if let Some(ts) = lset.time_stamp(name) {
467                        return Some(ts);
468                    }
469                }
470            }
471            return None;
472        };
473        let lset = self.inner.link_sets.read().await.get(scheme)?;
474        lset.time_stamp(body)
475    }
476
477    /// Build a [`LinkAlarm`] from the registered lset's alarm
478    /// accessors for an external (`pva://` / `ca://`) link, or `None`
479    /// when no lset is registered or the lset reports no alarm.
480    ///
481    /// The lset's `alarm_severity` is the gated severity (see
482    /// [`crate::server::database::LinkSet::alarm_severity`]); when it
483    /// is `Some`, the `stat` is `LINK_ALARM` and the message comes
484    /// from `alarm_message`.
485    async fn external_link_alarm(&self, name: &str) -> Option<LinkAlarm> {
486        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
487            ("pva", rest)
488        } else if let Some(rest) = name.strip_prefix("ca://") {
489            ("ca", rest)
490        } else {
491            // Bare name — try every registered lset until one reports
492            // a severity (mirrors `resolve_external_pv`'s bare path).
493            let registry = self.inner.link_sets.read().await;
494            for s in registry.schemes() {
495                if let Some(lset) = registry.get(&s) {
496                    if let Some(sev) = lset.alarm_severity(name) {
497                        return Some(LinkAlarm {
498                            // prefer the remote STAT for MSS;
499                            // fall back to LINK_ALARM when the lset has none.
500                            stat: lset
501                                .alarm_status(name)
502                                .map(|s| s as u16)
503                                .unwrap_or(crate::server::recgbl::alarm_status::LINK_ALARM),
504                            sevr: crate::server::record::AlarmSeverity::from_u16(sev as u16),
505                            amsg: lset.alarm_message(name).unwrap_or_default(),
506                        });
507                    }
508                }
509            }
510            return None;
511        };
512        let lset = self.inner.link_sets.read().await.get(scheme)?;
513        let sev = lset.alarm_severity(body)?;
514        Some(LinkAlarm {
515            // remote STAT for MSS, else LINK_ALARM.
516            stat: lset
517                .alarm_status(body)
518                .map(|s| s as u16)
519                .unwrap_or(crate::server::recgbl::alarm_status::LINK_ALARM),
520            sevr: crate::server::record::AlarmSeverity::from_u16(sev as u16),
521            amsg: lset.alarm_message(body).unwrap_or_default(),
522        })
523    }
524
525    /// Ungated remote alarm snapshot for an external (`pva://` /
526    /// `ca://`) link — the DB-link inspection counterpart of
527    /// [`Self::external_link_alarm`].
528    ///
529    /// Where `external_link_alarm` returns the **gated** maximize-severity
530    /// contribution folded into the owning record's `LINK_ALARM` (pvxs
531    /// `pvaGetValue` applying the `MS`/`NMS`/`MSI` gate,
532    /// `pvalink_lset.cpp:424-431`), this returns the **ungated** remote
533    /// `(severity, status, message)` snapshot pvxs exposes through
534    /// `dbGetAlarm` / `dbGetAlarmMsg` (`pvaGetAlarmMsg`,
535    /// `pvalink_lset.cpp:542-575`). A default `NMS` link reports its
536    /// remote severity here even though it leaves the owning record
537    /// unraised.
538    ///
539    /// `None` when no lset is registered for the scheme, the link is not
540    /// connected, or the lset does not track remote alarms. Scheme
541    /// dispatch mirrors [`Self::external_link_alarm`].
542    pub async fn external_link_alarm_snapshot(
543        &self,
544        name: &str,
545    ) -> Option<crate::server::database::RemoteAlarm> {
546        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
547            ("pva", rest)
548        } else if let Some(rest) = name.strip_prefix("ca://") {
549            ("ca", rest)
550        } else {
551            let registry = self.inner.link_sets.read().await;
552            for s in registry.schemes() {
553                if let Some(lset) = registry.get(&s) {
554                    if let Some(snap) = lset.remote_alarm(name) {
555                        return Some(snap);
556                    }
557                }
558            }
559            return None;
560        };
561        let lset = self.inner.link_sets.read().await.get(scheme)?;
562        lset.remote_alarm(body)
563    }
564
565    /// Remote display / control / valueAlarm metadata for an external
566    /// (`pva://` / `ca://`) link, resolved through the registered
567    /// lset's [`LinkSet::link_metadata`] hook.
568    ///
569    /// This is the DB-link-API entry point that exposes the linked PV
570    /// metadata pvxs's pvalink lset surfaces through its
571    /// `pvaGetDBFtype` / `pvaGetElements` / `pvaGetControlLimits` /
572    /// `pvaGetGraphicLimits` / `pvaGetAlarmLimits` / `pvaGetPrecision`
573    /// / `pvaGetUnits` getters
574    /// (`pvxs/ioc/pvalink_lset.cpp:700`). Scheme dispatch mirrors
575    /// [`Self::external_link_alarm`]: an explicit `pva://` / `ca://`
576    /// prefix selects the lset directly, a bare name tries every
577    /// registered lset until one reports metadata.
578    ///
579    /// `None` when no lset is registered for the scheme or the lset
580    /// has no cached value for the link (not yet connected).
581    pub async fn external_link_metadata(
582        &self,
583        name: &str,
584    ) -> Option<crate::server::database::LinkMetadata> {
585        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
586            ("pva", rest)
587        } else if let Some(rest) = name.strip_prefix("ca://") {
588            ("ca", rest)
589        } else {
590            let registry = self.inner.link_sets.read().await;
591            for s in registry.schemes() {
592                if let Some(lset) = registry.get(&s) {
593                    if let Some(meta) = lset.link_metadata(name) {
594                        return Some(meta);
595                    }
596                }
597            }
598            return None;
599        };
600        let lset = self.inner.link_sets.read().await.get(scheme)?;
601        lset.link_metadata(body)
602    }
603
604    /// C `dbGetLink` PP rule: if a DB input link is `ProcessPassive`
605    /// and its source record is `Passive`-scanned, process the source
606    /// record before its value is read so the reader sees a freshly
607    /// computed value. No-op for non-PP links or non-passive sources.
608    ///
609    /// Shared by `read_link_value_soft` (single-INP path) and the
610    /// multi-input fetch loop (`INPA..INPL` for calc/sel/sub/aSub) so
611    /// both paths get the identical C-correct PP-processing behavior.
612    ///
613    /// The caller's `visited` set and `depth` are threaded through into
614    /// the source's processing cycle — NOT a fresh set / depth 0. This
615    /// is required for the cycle guard to span the PP hop: in C,
616    /// `calcRecord.c::process` sets `prec->pact = TRUE` *before*
617    /// `fetch_values()` (calcRecord.c:119-120), so when a PP input link
618    /// re-enters `dbProcess` on a record already mid-fetch, the
619    /// `if (precord->pact) goto all_done;` guard (dbAccess.c:537-557)
620    /// terminates the cycle after one bounce. The Rust port sets its
621    /// PACT `AtomicBool` only on `AsyncPending` *after* `record.process()`
622    /// returns, so it cannot catch a record mid-link-fetch. Threading
623    /// the caller's `visited` set makes the existing `visited.insert`
624    /// cycle guard (`process_record_with_links_inner`, processing.rs)
625    /// fire instead — an A↔B `PP` cycle bails when the second hop tries
626    /// to re-insert a name already on the chain. The FLNK path threads
627    /// `visited`/`depth` the same way (processing.rs FLNK dispatch).
628    pub(crate) async fn process_passive_db_source(
629        &self,
630        db: &crate::server::record::DbLink,
631        visited: &mut HashSet<String>,
632        depth: usize,
633    ) {
634        if db.policy != crate::server::record::LinkProcessPolicy::ProcessPassive {
635            return;
636        }
637        if let Some(src) = self.get_record(&db.record).await {
638            let is_passive =
639                src.read().await.common.scan == crate::server::record::ScanType::Passive;
640            if is_passive {
641                // recursive INP-link source processing within
642                // one chain — gate held by the foreign entry record.
643                let _ = self
644                    .process_record_with_links_recursive(&db.record, visited, depth + 1)
645                    .await;
646            }
647        }
648    }
649
650    /// Read a value from a parsed link for INP (only reads DB links when soft channel).
651    ///
652    /// `visited` / `depth` are the caller's processing-chain state — a PP
653    /// input link's source is processed *within* that same chain so the
654    /// `visited` cycle guard spans the PP hop (see
655    /// [`Self::process_passive_db_source`]).
656    pub async fn read_link_value_soft(
657        &self,
658        link: &crate::server::record::ParsedLink,
659        is_soft: bool,
660        visited: &mut HashSet<String>,
661        depth: usize,
662    ) -> Option<EpicsValue> {
663        match link {
664            crate::server::record::ParsedLink::Constant(_) => link.constant_value(),
665            crate::server::record::ParsedLink::Db(db) if is_soft => {
666                // PP: process source record if Passive before reading
667                self.process_passive_db_source(db, visited, depth).await;
668                self.read_db_link_value(db).await
669            }
670            crate::server::record::ParsedLink::Ca(_)
671            | crate::server::record::ParsedLink::Pva(_)
672            | crate::server::record::ParsedLink::PvaJson(_)
673                if is_soft =>
674            {
675                let name = link
676                    .external_pv_name()
677                    .expect("Ca/Pva/PvaJson link carries a PV name");
678                self.resolve_external_pv(&name).await
679            }
680            // lnkCalc evaluates regardless of `is_soft` — the input
681            // PVs may themselves be local DB targets (which need the
682            // soft path) or remote CA/PVA, but the calc evaluation
683            // is uniform either way.
684            crate::server::record::ParsedLink::Calc(calc) => self.evaluate_calc_link(calc).await,
685            _ => None,
686        }
687    }
688
689    /// Write a value through a DbLink, optionally processing the target if PP and Passive.
690    ///
691    /// Returns `true` when the write failed — C `dbPutLink` status `!= 0`:
692    /// the local `dbPut` was rejected (conversion error, missing field) or
693    /// the non-local external write returned `Err`. Callers that mirror a
694    /// record's `dbPutLink` status (e.g. dfanout `push_values` raising
695    /// `LINK_ALARM/MAJOR`, dfanoutRecord.c:312) fold this into the source's
696    /// alarm; the fanout/seq dispatch paths ignore it.
697    ///
698    /// `src_putf` carries the source record's `PUTF` bit so the target inherits
699    /// it the same way C `dbDbLink.c::processTarget` propagates it (lines 470-498):
700    ///
701    /// - target not pact: `target.putf = src_putf` (normal propagation),
702    /// - target pact AND `src_putf` AND target not on current process chain:
703    ///   `target.rpro = true`, `target.putf = false` so the in-flight cycle
704    ///   reprocesses on completion attributing the put to the originator,
705    /// - otherwise: no PUTF change (target is either being processed
706    ///   recursively by us, or wasn't triggered by a dbPutField).
707    ///
708    /// Without this, a CA WRITE_NOTIFY landing on an upstream calc/seq/dfanout
709    /// that fanned out via DB OUT links would see `target.putf = 0` on every
710    /// downstream record — breaking dbNotify completion attribution and any
711    /// device-support code that uses PUTF to distinguish operator-driven from
712    /// scan-driven processing.
713    pub(crate) async fn write_db_link_value(
714        &self,
715        link: &crate::server::record::DbLink,
716        value: EpicsValue,
717        src: OutLinkSrc<'_>,
718        visited: &mut HashSet<String>,
719        depth: usize,
720    ) -> bool {
721        let target_name = if link.field == "VAL" {
722            link.record.clone()
723        } else {
724            format!("{}.{}", link.record, link.field)
725        };
726        // C `dbInitLink` locality (`dbLink.c:118-130`): a target record
727        // not present in this IOC is a CA link, so its write is routed
728        // through the external put path (`dbCaPutLink`), not a local
729        // `dbPut`. The alarm-inheritance / PUTF / `processTarget`
730        // machinery below is the local-DB `dbDbPutValue` body
731        // (dbDbLink.c:372-393), which `dbCaPutLink` performs none of —
732        // so a non-local target returns right after the remote write.
733        // OUTPUT-side twin of the `read_db_link_value` locality fallback.
734        if !self.has_name_no_resolve(&link.record).await {
735            let op = Self::external_put_op(src.notify);
736            if let Err(e) = self.write_external_pv(&target_name, value, op).await {
737                eprintln!("OUT-link write to external PV '{target_name}' failed: {e}");
738                return true;
739            }
740            return false;
741        }
742        // an OUT-link write-back is an internal step of the
743        // processing chain that already holds the entry record's
744        // advisory write gate (`dbScanLock` analogue). It must use the
745        // `_already_locked` write so it does not re-acquire a gate: a
746        // self-referencing OUT link (`SELF PP`) would otherwise
747        // dead-lock on the entry record's own non-reentrant gate. C
748        // `dbDbPutValue` writes the OUT-link target under the same
749        // lock set the chain already owns.
750        let put_result = self.put_pv_already_locked(&target_name, value).await;
751
752        // C `dbDbPutValue` (dbDbLink.c:382-383) folds the SOURCE
753        // record's alarm into the destination via `recGblInheritSevrMsg`,
754        // AFTER the `dbPut` and BEFORE `processTarget`. In this port the
755        // source's per-cycle alarm has already been committed by
756        // `rec_gbl_reset_alarms` before the OUT link dispatches, so
757        // `src_alarm` carries the source's *committed* stat/sevr/amsg —
758        // the same values C reads from the still-pending nsta/nsev/namsg
759        // at its (earlier) synchronous `writeValue` point. The inherited
760        // severity lands in the dest's PENDING nsev/nsta(/namsg for MSS);
761        // the dest commits it on its next `rec_gbl_reset_alarms` — its
762        // own process cycle, reached below for a `.PROC`/`PP` link, or a
763        // later independent scan otherwise. NMS (the common case) skips
764        // the dest lookup/lock entirely.
765        if link.monitor_switch != crate::server::record::MonitorSwitch::NoMaximize {
766            if let Some(target_rec) = self.get_record(&link.record).await {
767                let mut tg = target_rec.write().await;
768                inherit_sevr_msg(&mut tg.common, link.monitor_switch, src.alarm);
769            }
770        }
771
772        // C `dbDbPutValue` (dbDbLink.c:384-385) returns the put status
773        // immediately after the alarm inheritance and BEFORE the
774        // `.PROC`/`PP` `processTarget` branch: only a successful write
775        // reaches target processing. A failed OUT-link write (value
776        // conversion error, missing field, record put rejection — e.g.
777        // an empty array written into a scalar field) must therefore NOT
778        // trigger the target's process cycle, which would otherwise run
779        // the target on its stale field value and diverge from C on side
780        // effects, FLNK, alarms, and put-notify completion ordering. The
781        // alarm inheritance above already ran (C folds it regardless of
782        // status), matching the C ordering exactly.
783        if put_result.is_err() {
784            return true;
785        }
786
787        // C `dbDbPutValue` (`dbDbLink.c:387-390`) processes the target
788        // when the destination field is `.PROC` **or** the link carries
789        // `pvlOptPP` (an explicit ` PP` token → `ProcessPassive`). The
790        // `.PROC` arm is independent of the PP flag, so it is checked
791        // here in the write path rather than encoded as a parse-time
792        // policy: a modifier-less link now defaults to `NoProcess`
793        // uniformly (INPUT and OUTPUT alike), and writing a value into a
794        // record's `.PROC` field still forces a process.
795        if link.field == "PROC"
796            || link.policy == crate::server::record::LinkProcessPolicy::ProcessPassive
797        {
798            // Alias-aware lookup: the link's target may be the alias
799            // form. `process_record_with_links` itself also resolves
800            // aliases at entry, so passing `link.record` raw is safe.
801            if let Some(target_rec) = self.get_record(&link.record).await {
802                // Apply C `processTarget` PUTF propagation rules before
803                // dispatching the target's process cycle.
804                let (target_scan, should_process) = {
805                    let mut tg = target_rec.write().await;
806                    let pact = tg.is_processing();
807                    let on_chain = visited.contains(&link.record);
808                    let scan = tg.common.scan;
809                    if !pact {
810                        tg.common.putf = src.putf;
811                        // C `dbNotifyAdd` (dbDbLink.c:460) lives inside
812                        // `processTarget`, which `dbDbPutValue` reaches
813                        // for a `.PROC` write or a `PP` link to a passive
814                        // target (dbDbLink.c:387-389). This Rust port only
815                        // processes the target on the passive branch
816                        // below, so gate the join on the same condition:
817                        // a target that will not be processed must NOT
818                        // join, or it would `enter` the wait-set without
819                        // ever `leave`ing it and hang the completion.
820                        if scan == ScanType::Passive {
821                            join_put_notify(&mut tg, src.notify);
822                        }
823                    } else if src.putf && !on_chain {
824                        tg.common.rpro = true;
825                        tg.common.putf = false;
826                    }
827                    (scan, !pact)
828                };
829                if should_process && target_scan == ScanType::Passive {
830                    // recursive OUT-link target processing within
831                    // one chain — gate held by the foreign entry record.
832                    let _ = self
833                        .process_record_with_links_recursive(&link.record, visited, depth + 1)
834                        .await;
835                }
836            }
837        }
838        // Successful local write (C `dbPutLink` status 0).
839        false
840    }
841
842    /// Write a value to an external (`ca://` / `pva://`) OUT link
843    /// through the registered [`LinkSet`].
844    ///
845    /// This is the OUTPUT-side twin of [`Self::resolve_external_pv`]:
846    /// the input side dispatches a `ParsedLink::Ca`/`Pva` read through
847    /// `lset.get_value`, this dispatches a record's OUT-link write
848    /// through `lset.put_value`. Mirrors C `dbLink.c::dbPutLink`
849    /// (dbLink.c:434-448), which routes every link write — DB or CA —
850    /// through `plink->lset->putValue` and raises a link alarm
851    /// (`setLinkAlarm`) on failure.
852    ///
853    /// `name` may be a fully scheme-prefixed string (`pva://X`,
854    /// `ca://X`) or the bare body (the form stored in
855    /// `ParsedLink::Ca`/`Pva` after `record/link.rs` strips the
856    /// scheme). For a bare name every registered lset is tried in
857    /// turn — the first whose `put_value` succeeds wins.
858    ///
859    /// Returns `Ok(())` on a successful remote write, `Err(reason)`
860    /// when no lset is registered for the scheme or the lset rejects
861    /// the write (the caller folds that into a LINK alarm — it must
862    /// never panic).
863    ///
864    /// `op` carries the delivery semantics the lset must honour:
865    /// [`LinkPutOp::Async`] when the write is part of a put-notify /
866    /// blocking-put chain (mirrors C `dbPutLinkAsync` / pvxs
867    /// `pvaPutValueAsync`), [`LinkPutOp::Plain`] otherwise.
868    pub(crate) async fn write_external_pv(
869        &self,
870        name: &str,
871        value: EpicsValue,
872        op: LinkPutOp,
873    ) -> Result<(), String> {
874        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
875            ("pva", rest)
876        } else if let Some(rest) = name.strip_prefix("ca://") {
877            ("ca", rest)
878        } else {
879            // Bare name — try every registered lset in turn, first
880            // accepting write wins (mirrors `resolve_external_pv`'s
881            // bare-name path).
882            let registry = self.inner.link_sets.read().await;
883            let schemes = registry.schemes();
884            if schemes.is_empty() {
885                return Err(format!("no link set registered for external link '{name}'"));
886            }
887            let mut last_err = String::new();
888            for s in schemes {
889                if let Some(lset) = registry.get(&s) {
890                    match lset.put_value(name, value.clone(), op) {
891                        Ok(()) => {
892                            // Production drain of any retry-queued OUT
893                            // writes on this lset now that a write has
894                            // reached it (the channel may have just
895                            // reconnected). pvxs replays the queued
896                            // put from record processing, not test code
897                            // (`pvalink_channel.cpp:220-263`).
898                            lset.flush_puts();
899                            return Ok(());
900                        }
901                        Err(e) => last_err = e,
902                    }
903                }
904            }
905            return Err(last_err);
906        };
907        let lset = self
908            .inner
909            .link_sets
910            .read()
911            .await
912            .get(scheme)
913            .ok_or_else(|| format!("no '{scheme}' link set registered for '{name}'"))?;
914        let result = lset.put_value(body, value, op);
915        if result.is_ok() {
916            lset.flush_puts();
917        }
918        result
919    }
920
921    /// Fire a forward link (FLNK) whose target is an external
922    /// (`pva://` / `ca://`) PV — the FWD-link counterpart of
923    /// [`Self::write_external_pv`]. Resolves the scheme (or tries every
924    /// registered lset for a bare name, first to accept wins, exactly as
925    /// the OUT-write path does) and delegates to [`LinkSet::scan_forward`].
926    ///
927    /// Mirrors C `dbScanFwdLink` → `plink->lset->scanForward`
928    /// (`dbLink.c:475-480`): the database hands the forward link to the
929    /// link set, which (for pvalink) runs `pvaScanForward`. Returns the
930    /// lset's `Err` unchanged so the caller can raise LINK/INVALID on the
931    /// owning record (pvxs `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM)`).
932    pub(crate) async fn scan_forward_external_pv(&self, name: &str) -> Result<(), String> {
933        let (scheme, body) = if let Some(rest) = name.strip_prefix("pva://") {
934            ("pva", rest)
935        } else if let Some(rest) = name.strip_prefix("ca://") {
936            ("ca", rest)
937        } else {
938            // Bare name — try every registered lset in turn, first
939            // accepting the forward wins (mirrors `write_external_pv`'s
940            // bare-name path so FWD and OUT route a bare target alike).
941            let registry = self.inner.link_sets.read().await;
942            let schemes = registry.schemes();
943            if schemes.is_empty() {
944                return Err(format!("no link set registered for forward link '{name}'"));
945            }
946            let mut last_err = String::new();
947            for s in schemes {
948                if let Some(lset) = registry.get(&s) {
949                    match lset.scan_forward(name) {
950                        Ok(()) => return Ok(()),
951                        Err(e) => last_err = e,
952                    }
953                }
954            }
955            return Err(last_err);
956        };
957        let lset = self
958            .inner
959            .link_sets
960            .read()
961            .await
962            .get(scheme)
963            .ok_or_else(|| format!("no '{scheme}' link set registered for '{name}'"))?;
964        lset.scan_forward(body)
965    }
966
967    /// Map a record's put-completion wait-set to the external-link put
968    /// op. A write that originates inside a put-notify / blocking-put
969    /// chain (the source record carries a completion wait-set) is
970    /// delivered as [`LinkPutOp::Async`] (the pvxs `pvaPutValueAsync` /
971    /// C `dbPutLinkAsync` path); a plain record-processing OUT write is
972    /// [`LinkPutOp::Plain`]. Single owner of the notify→op mapping so
973    /// the external-OUT dispatch sites cannot diverge.
974    fn external_put_op(src_notify: Option<&Arc<NotifyWaitSet>>) -> LinkPutOp {
975        if src_notify.is_some() {
976            LinkPutOp::Async
977        } else {
978            LinkPutOp::Plain
979        }
980    }
981
982    /// Write a value through a parsed OUT link, dispatching DB links
983    /// to [`Self::write_db_link_value`] and external (`ca://`/`pva://`)
984    /// links to [`Self::write_external_pv`].
985    ///
986    /// This is the OUTPUT-side counterpart of [`Self::read_link_value`]'s
987    /// scheme dispatch: the OUT-link write stage in `processing.rs`
988    /// must route a `ParsedLink::Ca`/`Pva` through the link set, not
989    /// only handle `ParsedLink::Db`. An external link with no
990    /// registered lset fails gracefully — the error is logged and the
991    /// record is left to its alarm state, never a panic.
992    ///
993    /// `Constant`/`Hw`/`Calc`/`None` OUT links are not writable
994    /// targets and are silently skipped (C `dbPutLink` returns
995    /// `S_db_noLSET` for a link with no lset — the same no-op).
996    pub(crate) async fn write_out_link_value(
997        &self,
998        link: &crate::server::record::ParsedLink,
999        value: EpicsValue,
1000        src: OutLinkSrc<'_>,
1001        visited: &mut HashSet<String>,
1002        depth: usize,
1003    ) {
1004        match link {
1005            crate::server::record::ParsedLink::Db(db) => {
1006                self.write_db_link_value(db, value, src, visited, depth)
1007                    .await;
1008            }
1009            crate::server::record::ParsedLink::Ca(_)
1010            | crate::server::record::ParsedLink::Pva(_)
1011            | crate::server::record::ParsedLink::PvaJson(_) => {
1012                let name = link
1013                    .external_pv_name()
1014                    .expect("Ca/Pva/PvaJson link carries a PV name");
1015                let op = Self::external_put_op(src.notify);
1016                if let Err(e) = self.write_external_pv(&name, value, op).await {
1017                    eprintln!("OUT-link write to external PV '{name}' failed: {e}");
1018                }
1019            }
1020            // Constant / Hw / Calc / None are not writable OUT-link
1021            // targets — no-op (C `dbPutLink` → `S_db_noLSET`).
1022            _ => {}
1023        }
1024    }
1025
1026    /// Read a record String field, defaulting to empty.
1027    fn field_str(instance: &RecordInstance, field: &str) -> String {
1028        match instance.record.get_field(field) {
1029            Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
1030            _ => String::new(),
1031        }
1032    }
1033
1034    /// Read a record numeric field as `i16`, defaulting to 0.
1035    fn field_i16(instance: &RecordInstance, field: &str) -> i16 {
1036        instance
1037            .record
1038            .get_field(field)
1039            .and_then(|v| v.to_f64())
1040            .unwrap_or(0.0) as i16
1041    }
1042
1043    /// Read a record numeric field as `u16`, defaulting to 0.
1044    ///
1045    /// Used for `DBF_USHORT` fields such as `SELN`: the native unsigned
1046    /// carrier returns its value directly, while any other DBR type is
1047    /// truncated through the `i64` integer view (C `dbPut` cast), so a
1048    /// value ≥ 32768 round-trips instead of saturating at `i16::MAX`.
1049    fn field_u16(instance: &RecordInstance, field: &str) -> u16 {
1050        match instance.record.get_field(field) {
1051            Some(EpicsValue::UShort(v)) => v,
1052            Some(other) => other.as_int_i64().unwrap_or(0) as u16,
1053            None => 0,
1054        }
1055    }
1056
1057    /// Apply a SELM-resolved out-of-range alarm to the record.
1058    ///
1059    /// C raises this alarm inside `process()` (before `recGblResetAlarms`)
1060    /// via `recGblSetSevr(prec, SOFT_ALARM, INVALID_ALARM)`. The Rust
1061    /// multi-output dispatch runs after the record's own alarm reset,
1062    /// so we apply the severity directly to `common.sevr/stat`, refresh
1063    /// the live STAT/SEVR fields, and post the monitor — matching the
1064    /// observable end state (record reads INVALID/SOFT_ALARM, a
1065    /// `DBE_ALARM` subscriber on STAT/SEVR is notified).
1066    async fn apply_selm_alarm(
1067        rec: &Arc<RwLock<RecordInstance>>,
1068        alarm: Option<(u16, AlarmSeverity)>,
1069    ) {
1070        let Some((stat, sevr)) = alarm else {
1071            return;
1072        };
1073        let posted = {
1074            let mut inst = rec.write().await;
1075            // Raise-only, mirroring recGblSetSevr.
1076            if (sevr as u16) > (inst.common.sevr as u16) {
1077                inst.common.sevr = sevr;
1078                inst.common.stat = stat;
1079                true
1080            } else {
1081                false
1082            }
1083        };
1084        if posted {
1085            let inst = rec.read().await;
1086            inst.notify_field("SEVR", crate::server::recgbl::EventMask::ALARM);
1087            inst.notify_field("STAT", crate::server::recgbl::EventMask::VALUE);
1088        }
1089    }
1090
1091    /// Multi-output dispatch for fanout, dfanout, seq record types.
1092    ///
1093    /// The per-record payload is a typed [`MultiOut`] — seq / sseq
1094    /// groups are kept as struct fields, NOT `\0`-packed strings
1095    /// (the pre-fix encoding could mis-split a link string that
1096    /// happened to contain an embedded NUL).
1097    ///
1098    /// `dfanout_pending_sevr` selects the dispatch phase and carries the
1099    /// severity the dfanout `push_values` decision needs:
1100    ///
1101    /// * `Some(nsev)` — the **dfanout pre-commit** phase. C
1102    ///   `dfanoutRecord.c:128-146` runs `push_values` between `checkAlarms`
1103    ///   and `recGblResetAlarms`, gating the push on the *pending* `nsev`
1104    ///   (`if nsev < INVALID push else switch(ivoa)`), and a failed
1105    ///   `dbPutLink` raises `LINK_ALARM/MAJOR` into that same `nsev`. The
1106    ///   caller runs this before the alarm commit and folds the returned
1107    ///   failure flag into the record's `nsev`, so the write alarm lands in
1108    ///   THIS cycle's committed SEVR and its VAL monitor post.
1109    /// * `None` — the **fanout/seq tail** phase (post-commit forward-link
1110    ///   tail). These drive no value and raise no write alarm.
1111    ///
1112    /// A dfanout reached with `None`, or a fanout/seq reached with `Some`,
1113    /// is skipped: each type dispatches in exactly one phase. Returns the
1114    /// single pending alarm `(stat, sevr)` C `push_values` would raise this
1115    /// cycle — `LINK_ALARM/MAJOR` for a failed OUT write or
1116    /// `SOFT_ALARM/INVALID` for a Specified `seln` out of range — for the
1117    /// caller to fold into `nsev` before the alarm commit. Always `None` for
1118    /// fanout/seq (they raise their own range alarm directly, post-commit).
1119    pub(crate) async fn dispatch_multi_output(
1120        &self,
1121        rec: &Arc<RwLock<RecordInstance>>,
1122        dfanout_pending_sevr: Option<AlarmSeverity>,
1123        visited: &mut HashSet<String>,
1124        depth: usize,
1125    ) -> Option<(u16, AlarmSeverity)> {
1126        // Phase gate: dfanout drives its OUT links pre-commit (so a write
1127        // failure folds into the same cycle's SEVR), fanout/seq in the
1128        // forward-link tail. Skip the type that does not belong to the
1129        // calling phase so each dispatches exactly once per cycle.
1130        let is_dfanout = rec.read().await.record.record_type() == "dfanout";
1131        if is_dfanout != dfanout_pending_sevr.is_some() {
1132            return None;
1133        }
1134
1135        // Snapshot the source record's PUTF bit + put-notify wait-set so
1136        // every write_db_link_value call below propagates them to its
1137        // target — C `dbDbLink.c::processTarget` PUTF and `dbNotifyAdd`
1138        // wait-set invariants (see write_db_link_value doc). The
1139        // committed alarm travels the same way for `recGblInheritSevrMsg`
1140        // MS-class propagation into each OUT-link target.
1141        let (src_putf, src_notify, src_alarm) = {
1142            let guard = rec.read().await;
1143            (
1144                guard.common.putf,
1145                guard.notify.clone(),
1146                LinkAlarm {
1147                    stat: guard.common.stat,
1148                    sevr: guard.common.sevr,
1149                    amsg: guard.common.amsg.clone(),
1150                },
1151            )
1152        };
1153        // One snapshot threaded to every OUT-link write below.
1154        let out_src = OutLinkSrc {
1155            putf: src_putf,
1156            notify: src_notify.as_ref(),
1157            alarm: &src_alarm,
1158        };
1159
1160        // Resolve the SELL link into SELN before SELN is read below.
1161        // C `fanoutRecord.c:103` and `dfanoutRecord.c:126` call
1162        // `dbGetLink(&prec->sell, DBR_USHORT, &prec->seln, 0, 0)` at the
1163        // top of `process()`, *before* the SELM switch, so SELN is
1164        // refreshed from SELL every cycle regardless of SELM.
1165        // `seqRecord.c:148` places the identical `dbGetLink` *inside* the
1166        // `else` of `if (prec->selm == seqSELM_All)`, so an All-mode seq
1167        // never reads SELL — SELN stays frozen at its last value and posts
1168        // no spurious change (the post-process `if (seln != oldn)` monitor
1169        // is then a no-op). Match that per-record placement: fanout/dfanout
1170        // always, seq only when SELM != All. Only the `sel` record's
1171        // NVL->SELN binding was previously wired; fanout/dfanout/seq never
1172        // read the SELL link, so a SELL pointing at another record's value
1173        // field never updated SELN — the selection was frozen at whatever
1174        // SELN was initialised to. `sseq` reads its own SELL→SELN in
1175        // `SseqRecord::pre_input_link_actions` (the async machine owns the
1176        // whole cycle), so it is not handled here.
1177        {
1178            let sell = {
1179                let instance = rec.read().await;
1180                match instance.record.record_type() {
1181                    "fanout" | "dfanout" => Some(Self::field_str(&instance, "SELL")),
1182                    // seqSELM_All == 0 (seqRecord.dbd menu(seqSELM) order
1183                    // All/Specified/Mask). C skips the SELL→SELN read in
1184                    // All mode, so the port must too.
1185                    "seq" if Self::field_i16(&instance, "SELM") != 0 => {
1186                        Some(Self::field_str(&instance, "SELL"))
1187                    }
1188                    _ => None,
1189                }
1190            };
1191            if let Some(sell) = sell {
1192                if !sell.is_empty() {
1193                    // C reads SELL via `dbGetLink(&prec->sell, DBR_USHORT,
1194                    // &prec->seln, 0, 0)` for any link type; the pre-fix
1195                    // port only read a `ParsedLink::Db` SELL, so a SELL
1196                    // sourced over CA/PVA or given as a constant never
1197                    // updated SELN.
1198                    let parsed = crate::server::record::parse_link_v2(&sell);
1199                    if let Some(val) = self.read_link_value_no_process(&parsed).await {
1200                        // C reads SELL with `dbGetLink(&prec->sell,
1201                        // DBR_USHORT, &prec->seln, 0, 0)`; the dbConvert GET
1202                        // macro stores `*pdst = (epicsUInt16) *psrc`
1203                        // (`dbConvert.c:63-70`) — a C cast (truncate-then-
1204                        // wrap mod 2^16), NOT a clamp. `SELL=-1` therefore
1205                        // yields `SELN=65535` (Specified → out-of-range
1206                        // INVALID, Mask → all-bits), where the old clamp
1207                        // produced `0` (drove link 0 / empty mask). SELN is
1208                        // a `DBF_USHORT` (u16) field; `select_link_indices_ex`
1209                        // consumes it as `u16`.
1210                        let seln = dbr_ushort_cast(&val);
1211                        let mut instance = rec.write().await;
1212                        let _ = instance.record.put_field("SELN", EpicsValue::UShort(seln));
1213                    }
1214                }
1215            }
1216        }
1217
1218        let dispatch_info: Option<(SelmResult, MultiOut, Option<EpicsValue>)> = {
1219            let instance = rec.read().await;
1220            match instance.record.record_type() {
1221                "fanout" => {
1222                    let selm = Self::field_i16(&instance, "SELM");
1223                    let seln = Self::field_u16(&instance, "SELN");
1224                    let offs = Self::field_i16(&instance, "OFFS");
1225                    let shft = Self::field_i16(&instance, "SHFT");
1226                    // C parity (fanoutRecord.c:39): 16 forward links
1227                    // LNK0..LNKF. LNK0 is the natural first slot.
1228                    let links: Vec<String> = [
1229                        "LNK0", "LNK1", "LNK2", "LNK3", "LNK4", "LNK5", "LNK6", "LNK7", "LNK8",
1230                        "LNK9", "LNKA", "LNKB", "LNKC", "LNKD", "LNKE", "LNKF",
1231                    ]
1232                    .iter()
1233                    .map(|f| Self::field_str(&instance, f))
1234                    .collect();
1235                    // SELM resolution with OFFS/SHFT bias (fanoutRecord.c).
1236                    let sel = select_link_indices_ex(
1237                        SelmKind::FanoutSeq,
1238                        selm,
1239                        seln,
1240                        offs,
1241                        shft,
1242                        links.len(),
1243                    );
1244                    Some((sel, MultiOut::Fanout(links), None))
1245                }
1246                "dfanout" => {
1247                    let selm = Self::field_i16(&instance, "SELM");
1248                    let seln = Self::field_u16(&instance, "SELN");
1249                    // IVOA / IVOV — invalid output handling, mirrors
1250                    // epics-base PR #688. When the record's pending
1251                    // severity is INVALID, IVOA selects: 0 = continue (use
1252                    // VAL as before), 1 = don't drive (suppress all OUT*),
1253                    // 2 = set outputs to IVOV.
1254                    //
1255                    // C `dfanoutRecord.c:128` gates the push on the
1256                    // *pending* `nsev` (`if (prec->nsev < INVALID_ALARM)
1257                    // push_values(); else switch(ivoa)`), evaluated after
1258                    // `checkAlarms` but before `recGblResetAlarms` commits
1259                    // it. This dispatch runs in that same pre-commit window,
1260                    // so the gate reads the caller-supplied pending
1261                    // severity, not the still-stale committed `common.sevr`.
1262                    let push_sevr = dfanout_pending_sevr
1263                        .expect("dfanout dispatch carries the pending severity (phase gate)");
1264                    let raw_val = instance.record.val();
1265                    let val = if push_sevr == crate::server::record::AlarmSeverity::Invalid {
1266                        let ivoa = instance
1267                            .record
1268                            .get_field("IVOA")
1269                            .and_then(|v| {
1270                                if let EpicsValue::Short(s) = v {
1271                                    Some(s)
1272                                } else {
1273                                    None
1274                                }
1275                            })
1276                            .unwrap_or(0);
1277                        match ivoa {
1278                            1 => None, // suppress drive
1279                            2 => instance.record.get_field("IVOV").or(raw_val),
1280                            _ => raw_val, // 0 or unknown — Continue
1281                        }
1282                    } else {
1283                        raw_val
1284                    };
1285                    let links: Vec<String> = [
1286                        "OUTA", "OUTB", "OUTC", "OUTD", "OUTE", "OUTF", "OUTG", "OUTH", "OUTI",
1287                        "OUTJ", "OUTK", "OUTL", "OUTM", "OUTN", "OUTO", "OUTP",
1288                    ]
1289                    .iter()
1290                    .map(|f| Self::field_str(&instance, f))
1291                    .collect();
1292                    // dfanout Specified is 1-based; Mask has no SHFT
1293                    // (dfanoutRecord.c:307-339).
1294                    let sel =
1295                        select_link_indices_ex(SelmKind::Dfanout, selm, seln, 0, 0, links.len());
1296                    Some((sel, MultiOut::Dfanout(links), val))
1297                }
1298                "seq" => {
1299                    let selm = Self::field_i16(&instance, "SELM");
1300                    let seln = Self::field_u16(&instance, "SELN");
1301                    let offs = Self::field_i16(&instance, "OFFS");
1302                    let shft = Self::field_i16(&instance, "SHFT");
1303                    // C parity (seqRecord.c:86): 16 link groups 0..F,
1304                    // each DOLn / DOn (value storage) / DLYn / LNKn.
1305                    let dol_names = [
1306                        "DOL0", "DOL1", "DOL2", "DOL3", "DOL4", "DOL5", "DOL6", "DOL7", "DOL8",
1307                        "DOL9", "DOLA", "DOLB", "DOLC", "DOLD", "DOLE", "DOLF",
1308                    ];
1309                    let lnk_names = [
1310                        "LNK0", "LNK1", "LNK2", "LNK3", "LNK4", "LNK5", "LNK6", "LNK7", "LNK8",
1311                        "LNK9", "LNKA", "LNKB", "LNKC", "LNKD", "LNKE", "LNKF",
1312                    ];
1313                    let dly_names = [
1314                        "DLY0", "DLY1", "DLY2", "DLY3", "DLY4", "DLY5", "DLY6", "DLY7", "DLY8",
1315                        "DLY9", "DLYA", "DLYB", "DLYC", "DLYD", "DLYE", "DLYF",
1316                    ];
1317                    let do_names = [
1318                        "DO0", "DO1", "DO2", "DO3", "DO4", "DO5", "DO6", "DO7", "DO8", "DO9",
1319                        "DOA", "DOB", "DOC", "DOD", "DOE", "DOF",
1320                    ];
1321                    let groups: Vec<SeqGroup> = (0..16)
1322                        .map(|i| SeqGroup {
1323                            dol: Self::field_str(&instance, dol_names[i]),
1324                            lnk: Self::field_str(&instance, lnk_names[i]),
1325                            dly: instance
1326                                .record
1327                                .get_field(dly_names[i])
1328                                .and_then(|v| v.to_f64())
1329                                .unwrap_or(0.0),
1330                            dov: instance
1331                                .record
1332                                .get_field(do_names[i])
1333                                .and_then(|v| v.to_f64())
1334                                .unwrap_or(0.0),
1335                        })
1336                        .collect();
1337                    let sel = select_link_indices_ex(
1338                        SelmKind::FanoutSeq,
1339                        selm,
1340                        seln,
1341                        offs,
1342                        shft,
1343                        groups.len(),
1344                    );
1345                    Some((sel, MultiOut::Seq(groups), None))
1346                }
1347                _ => None,
1348            }
1349        };
1350
1351        let (sel, payload, val) = match dispatch_info {
1352            Some(info) => info,
1353            None => return None,
1354        };
1355        debug_assert!(sel.indices.iter().all(|&i| i < payload.len()));
1356        // Single-owner invariant: every record type that produces a
1357        // `MultiOut` payload here MUST be listed in
1358        // `multi_output_dispatch_owned` so the generic
1359        // `multi_output_links` block in `processing.rs` skips it. If
1360        // this fires, the two lists have diverged and the skipped
1361        // type would be dispatched twice per cycle.
1362        debug_assert!(multi_output_dispatch_owned(
1363            rec.read().await.record.record_type()
1364        ));
1365
1366        // C raises SOFT_ALARM/INVALID_ALARM when SELN/OFFS/SHFT resolve
1367        // out of range (fanoutRecord.c:116, dfanoutRecord.c:317,
1368        // seqRecord.c:157). fanout/seq dispatch in the post-commit tail, so
1369        // they raise it directly into the committed SEVR and post SEVR/STAT
1370        // immediately (apply_selm_alarm). dfanout dispatches PRE-commit, so
1371        // its out-of-range alarm must instead fold into the pending nsev the
1372        // caller commits THIS cycle — captured here and returned below, not
1373        // raised+posted now (a direct SEVR raise would be clobbered by the
1374        // caller's `recGblResetAlarms`).
1375        let dfanout_selm_alarm = sel.alarm;
1376        if !is_dfanout {
1377            Self::apply_selm_alarm(rec, sel.alarm).await;
1378        }
1379        let indices = sel.indices;
1380        // C `dfanoutRecord.c` push_values raises LINK_ALARM/MAJOR per failed
1381        // dbPutLink; accumulated across the selected OUT links below.
1382        let mut link_failed = false;
1383
1384        match payload {
1385            MultiOut::Fanout(links) => {
1386                for idx in indices {
1387                    let link_str = &links[idx];
1388                    if link_str.is_empty() {
1389                        continue;
1390                    }
1391                    let parsed = crate::server::record::parse_link_v2(link_str);
1392                    if let crate::server::record::ParsedLink::Db(ref db) = parsed {
1393                        // C `fanoutRecord.c:110/121/138` dispatches each
1394                        // selected LNKn via `dbScanFwdLink` →
1395                        // `dbDbScanFwdLink` → `dbScanPassive`
1396                        // (`dbDbLink.c:425-432`), which processes the
1397                        // target ONLY when its SCAN is Passive
1398                        // (`if (pto->scan != 0) return 0;`). A LNKn
1399                        // pointing at a Periodic/Event/I/O-Intr record
1400                        // must NOT be re-processed by the fanout — that
1401                        // record runs on its own scan. `dbScanPassive`
1402                        // then calls `processTarget`, which propagates
1403                        // PUTF (and sets RPRO on a busy target) exactly
1404                        // like the explicit FLNK path — so mirror that
1405                        // gate here instead of the previous
1406                        // unconditional `process_record_with_links`.
1407                        if let Some(target_rec) = self.get_record(&db.record).await {
1408                            let (target_scan, should_process) = {
1409                                let mut tg = target_rec.write().await;
1410                                let pact = tg.is_processing();
1411                                let on_chain = visited.contains(&db.record);
1412                                if !pact {
1413                                    tg.common.putf = src_putf;
1414                                } else if src_putf && !on_chain {
1415                                    tg.common.rpro = true;
1416                                    tg.common.putf = false;
1417                                }
1418                                (tg.common.scan, !pact)
1419                            };
1420                            if should_process && target_scan == ScanType::Passive {
1421                                // recursive link-target processing
1422                                // within one chain — gate held by the
1423                                // foreign entry record.
1424                                let _ = self
1425                                    .process_record_with_links_recursive(
1426                                        &db.record,
1427                                        visited,
1428                                        depth + 1,
1429                                    )
1430                                    .await;
1431                            }
1432                        }
1433                    }
1434                }
1435            }
1436            MultiOut::Dfanout(links) => {
1437                if let Some(ref val) = val {
1438                    for idx in indices {
1439                        let link_str = &links[idx];
1440                        if link_str.is_empty() {
1441                            continue;
1442                        }
1443                        // C `dfanoutRecord.c:323` drives each OUTn via
1444                        // `dbPutLink` → `dbDbPutValue`: an `DBF_OUTLINK`
1445                        // target is processed only when the link carries
1446                        // an explicit ` PP` token or the destination is
1447                        // `.PROC` (`dbDbLink.c:387-390`). A modifier-less
1448                        // OUTn is NPP — the value is written but the
1449                        // target is NOT re-processed (otherwise a
1450                        // Soft-Channel ai target's `convert()` would
1451                        // clobber the value just written). The NPP
1452                        // default and the `.PROC`/`PP` processing rule
1453                        // are both honoured by `parse_output_link_v2`
1454                        // (uniform NoProcess default) +
1455                        // `write_db_link_value` (write-path target
1456                        // processing), so no per-call downgrade is needed.
1457                        let parsed = crate::server::record::parse_output_link_v2(link_str);
1458                        match parsed {
1459                            crate::server::record::ParsedLink::Db(ref db) => {
1460                                // C `dfanoutRecord.c:311-312`: a failed
1461                                // dbPutLink raises LINK_ALARM/MAJOR.
1462                                if self
1463                                    .write_db_link_value(db, val.clone(), out_src, visited, depth)
1464                                    .await
1465                                {
1466                                    link_failed = true;
1467                                }
1468                            }
1469                            // External `ca://`/`pva://` OUTn — C
1470                            // `dbPutLink` routes a CA-link write through
1471                            // the link set's `putValue` identically to a
1472                            // DB link (dbLink.c:434-448). PP has no
1473                            // meaning for an external write (the remote
1474                            // record processes on its own IOC), so route
1475                            // straight through the link set.
1476                            crate::server::record::ParsedLink::Ca(_)
1477                            | crate::server::record::ParsedLink::Pva(_)
1478                            | crate::server::record::ParsedLink::PvaJson(_) => {
1479                                let name = parsed
1480                                    .external_pv_name()
1481                                    .expect("Ca/Pva/PvaJson link carries a PV name");
1482                                let op = Self::external_put_op(src_notify.as_ref());
1483                                if let Err(e) = self.write_external_pv(&name, val.clone(), op).await
1484                                {
1485                                    eprintln!(
1486                                        "dfanout OUT-link write to external PV '{name}' failed: {e}"
1487                                    );
1488                                    // C `dfanoutRecord.c:312` routes a CA OUT
1489                                    // link through dbPutLink identically — a
1490                                    // disconnected/rejected remote write
1491                                    // returns nonzero → LINK_ALARM/MAJOR.
1492                                    link_failed = true;
1493                                }
1494                            }
1495                            _ => {}
1496                        }
1497                    }
1498                }
1499            }
1500            MultiOut::Seq(groups) => {
1501                // DOn value-storage field names (`linkGrp.dov`),
1502                // index-aligned with the LNKn/DOLn groups.
1503                const DO_NAMES: [&str; 16] = [
1504                    "DO0", "DO1", "DO2", "DO3", "DO4", "DO5", "DO6", "DO7", "DO8", "DO9", "DOA",
1505                    "DOB", "DOC", "DOD", "DOE", "DOF",
1506                ];
1507                // C `dbLinkIsConstant` is true for empty and numeric-literal
1508                // links; a real link is a DB/CA/PVA PV reference.
1509                let is_real = |s: &str| {
1510                    !s.is_empty()
1511                        && !matches!(
1512                            crate::server::record::parse_link_v2(s),
1513                            crate::server::record::ParsedLink::Constant(_)
1514                        )
1515                };
1516                let rec_name = rec.read().await.name.clone();
1517                for idx in indices {
1518                    let grp = &groups[idx];
1519                    // C `seqRecord.c:182-189`: a group is processed iff its
1520                    // LNKn OR DOLn is a real (non-constant) link. When both
1521                    // are constant/empty the group is skipped entirely — so
1522                    // a DOL-only group (real DOLn, empty LNKn) still reads
1523                    // back DOn and posts it, even though nothing is driven.
1524                    let lnk_real = is_real(&grp.lnk);
1525                    let dol_real = is_real(&grp.dol);
1526                    if !lnk_real && !dol_real {
1527                        continue;
1528                    }
1529                    // Per-group DLYn staggering — C `seqRecord.c`
1530                    // schedules each group after its delay. Groups
1531                    // process sequentially in index order, each after
1532                    // its own delay (callbackRequestDelayed chain).
1533                    if grp.dly > 0.0 {
1534                        tokio::time::sleep(std::time::Duration::from_secs_f64(grp.dly)).await;
1535                    }
1536                    // C `seqRecord.c:259` `dbGetLink(&dol, DBR_DOUBLE,
1537                    // &dov)`: read DOLn into the DOn value field. A
1538                    // constant/empty DOL — or a failed read — leaves DOn at
1539                    // its previous value.
1540                    let new_dov = if dol_real {
1541                        let dol_parsed = crate::server::record::parse_link_v2(&grp.dol);
1542                        self.read_link_value(&dol_parsed, visited, depth)
1543                            .await
1544                            .and_then(|v| v.to_f64())
1545                            .unwrap_or(grp.dov)
1546                    } else {
1547                        grp.dov
1548                    };
1549                    // C `seqRecord.c:264` drives LNKn via `dbPutLink`
1550                    // (`DBR_DOUBLE, &dov`), whose `DBF_OUTLINK` target is
1551                    // processed by `dbDbPutValue` (`dbDbLink.c:388`) only
1552                    // when the link carries an explicit `PP` modifier. A
1553                    // bare `LNKn` is NPP — the value is written but the
1554                    // target is NOT processed. `parse_output_link_v2`
1555                    // applies that NPP default (the dfanout arm above
1556                    // open-codes the same downgrade). LNKn may be a local DB
1557                    // link or an external `ca://`/`pva://` link — C
1558                    // `dbPutLink` routes both through the link set's
1559                    // `putValue` (dbLink.c:434-448). Only a real LNK does
1560                    // anything; a constant LNK is a no-op.
1561                    if lnk_real {
1562                        let lnk_parsed = crate::server::record::parse_output_link_v2(&grp.lnk);
1563                        self.write_out_link_value(
1564                            &lnk_parsed,
1565                            EpicsValue::Double(new_dov),
1566                            out_src,
1567                            visited,
1568                            depth,
1569                        )
1570                        .await;
1571                    }
1572                    // C `seqRecord.c:266-268`: store DOn and post a
1573                    // DBE_VALUE|DBE_LOG monitor only when it changed. The
1574                    // field already holds the old value (snapshot read), so
1575                    // `post_fields` (write + post) is needed only on change.
1576                    if new_dov != grp.dov {
1577                        let _ = self
1578                            .post_fields(
1579                                &rec_name,
1580                                vec![(DO_NAMES[idx].to_string(), EpicsValue::Double(new_dov))],
1581                            )
1582                            .await;
1583                    }
1584                }
1585            }
1586        }
1587
1588        // dfanout (pre-commit phase): return the single pending alarm C
1589        // `push_values` would have raised this cycle for the caller to fold
1590        // into `nsev` before `recGblResetAlarms`. C raises at most one:
1591        // SOFT_ALARM/INVALID for a Specified `seln` out of range (line 317,
1592        // no push) OR LINK_ALARM/MAJOR for a failed dbPutLink (line
1593        // 312/324/333). They are mutually exclusive in C's control flow; if
1594        // both were somehow set, fold the higher severity (recGblSetSevr is
1595        // raise-only). fanout/seq already applied their range alarm directly
1596        // above and drive no value, so they return None.
1597        if is_dfanout {
1598            let link_alarm = if link_failed {
1599                Some((
1600                    crate::server::recgbl::alarm_status::LINK_ALARM,
1601                    AlarmSeverity::Major,
1602                ))
1603            } else {
1604                None
1605            };
1606            return match (dfanout_selm_alarm, link_alarm) {
1607                (Some(a), Some(b)) => Some(if (a.1 as u16) >= (b.1 as u16) { a } else { b }),
1608                (a, b) => a.or(b),
1609            };
1610        }
1611        None
1612    }
1613
1614    /// Post the software event named by an `event` record's `VAL`.
1615    ///
1616    /// Mirrors C `eventRecord.c:120` `postEvent(prec->epvt)` — every
1617    /// `process()` of an event record posts its event, waking the
1618    /// `SCAN="Event"` records whose `EVNT` resolves to that name.
1619    /// No-op for any other record type, or when `VAL` is empty /
1620    /// resolves to event 0 (`eventNameToHandle` returns NULL).
1621    pub(crate) async fn dispatch_event_record(&self, rec: &Arc<RwLock<RecordInstance>>) {
1622        let event_name = {
1623            let instance = rec.read().await;
1624            if instance.record.record_type() != "event" {
1625                return;
1626            }
1627            match instance.record.get_field("VAL") {
1628                Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
1629                _ => return,
1630            }
1631        };
1632        if event_name.trim().is_empty() {
1633            return;
1634        }
1635        // C `postEvent` queues callbacks on the scan ring buffer —
1636        // the event-scanned records run on a separate callback thread,
1637        // NOT recursively inside this process cycle. Spawn the routed
1638        // post so a chain of event records cannot recurse unboundedly
1639        // and the current cycle's FLNK/CP dispatch is not blocked.
1640        let db = self.clone();
1641        crate::runtime::task::spawn(async move {
1642            db.post_event_named(&event_name).await;
1643        });
1644    }
1645
1646    /// Register a CP link: when source_record changes, process target_record.
1647    ///
1648    /// Both names are normalised to canonical form so the cp_links
1649    /// map's key/value always match the canonical record name that
1650    /// `dispatch_cp_targets` uses for lookup. Without this, a user
1651    /// who wrote `INP="ALIAS_NAME CP"` in their .db file would
1652    /// register the CP edge under the alias key and then never see
1653    /// the target processed (the source record's canonical-name
1654    /// dispatch would miss).
1655    /// `passive_only` is `true` for a CPP edge (process the target only when
1656    /// its `SCAN` is Passive) and `false` for CP (always process). When the
1657    /// same source→target edge is registered from both a CP and a CPP link,
1658    /// CP dominates: the merged edge keeps `passive_only == false`, matching
1659    /// C, where an unconditional CP `CA_DBPROCESS` overrides any CPP gate on
1660    /// the same record.
1661    pub async fn register_cp_link(
1662        &self,
1663        source_record: &str,
1664        target_record: &str,
1665        passive_only: bool,
1666    ) {
1667        let source = self
1668            .resolve_alias(source_record)
1669            .await
1670            .unwrap_or_else(|| source_record.to_string());
1671        let target = self
1672            .resolve_alias(target_record)
1673            .await
1674            .unwrap_or_else(|| target_record.to_string());
1675        let mut cp = self.inner.cp_links.write().await;
1676        let targets = cp.entry(source).or_default();
1677        if let Some(existing) = targets.iter_mut().find(|t| t.record == target) {
1678            existing.passive_only = existing.passive_only && passive_only;
1679        } else {
1680            targets.push(super::CpTarget {
1681                record: target,
1682                passive_only,
1683            });
1684        }
1685    }
1686
1687    /// Get target edges to process when `source_record` changes (CP/CPP links).
1688    pub async fn get_cp_targets(&self, source_record: &str) -> Vec<super::CpTarget> {
1689        self.inner
1690            .cp_links
1691            .read()
1692            .await
1693            .get(source_record)
1694            .cloned()
1695            .unwrap_or_default()
1696    }
1697
1698    /// Register an EXTERNAL CP/CPP link: when the remote PV `external_pv`
1699    /// (a cross-IOC CA/PVA source, e.g. `OTHER:PV` from
1700    /// `INP="OTHER:PV CP CA"`) changes, process `target_record`.
1701    ///
1702    /// Twin of [`Self::register_cp_link`] for cross-IOC sources. The key is
1703    /// the **scheme-stripped external PV name** — it is not a local record,
1704    /// so it is NOT alias-resolved. It MUST equal the name the calink/pvalink
1705    /// monitor dispatches under: that monitor is opened through the lset,
1706    /// which strips the `ca://` / `pva://` scheme first, so its `pv_name`
1707    /// (passed to [`Self::dispatch_external_cp_targets`]) is the bare PV.
1708    /// Stripping the same schemes here — the set [`Self::resolve_external_pv`]
1709    /// already knows — guarantees the registry key and the dispatch key can
1710    /// never diverge. The `target_record` (the local holder) IS alias-resolved
1711    /// so the dispatch processes the canonical record. CP dominates CPP on a
1712    /// merged edge, identical to the local path.
1713    pub async fn register_external_cp_link(
1714        &self,
1715        external_pv: &str,
1716        target_record: &str,
1717        passive_only: bool,
1718    ) {
1719        let key = external_pv
1720            .strip_prefix("ca://")
1721            .or_else(|| external_pv.strip_prefix("pva://"))
1722            .unwrap_or(external_pv);
1723        let target = self
1724            .resolve_alias(target_record)
1725            .await
1726            .unwrap_or_else(|| target_record.to_string());
1727        let mut cp = self.inner.external_cp_links.write().await;
1728        let targets = cp.entry(key.to_string()).or_default();
1729        if let Some(existing) = targets.iter_mut().find(|t| t.record == target) {
1730            existing.passive_only = existing.passive_only && passive_only;
1731        } else {
1732            targets.push(super::CpTarget {
1733                record: target,
1734                passive_only,
1735            });
1736        }
1737    }
1738
1739    /// Get holder edges to process when the remote PV `external_pv` changes
1740    /// (external CA/PVA CP/CPP links).
1741    pub async fn get_external_cp_targets(&self, external_pv: &str) -> Vec<super::CpTarget> {
1742        self.inner
1743            .external_cp_links
1744            .read()
1745            .await
1746            .get(external_pv)
1747            .cloned()
1748            .unwrap_or_default()
1749    }
1750
1751    /// Every external PV name that has at least one CP/CPP holder edge.
1752    /// The calink/pvalink integration warms (opens a monitor for) each of
1753    /// these at iocInit so the remote source has a live subscription whose
1754    /// callback can drive [`Self::dispatch_external_cp_targets`] — a Passive
1755    /// CP holder is otherwise never read and its monitor never opens.
1756    pub async fn external_cp_pv_names(&self) -> Vec<String> {
1757        self.inner
1758            .external_cp_links
1759            .read()
1760            .await
1761            .keys()
1762            .cloned()
1763            .collect()
1764    }
1765
1766    /// Classify one parsed CP/CPP link, deciding the local-vs-external
1767    /// routing — this method is the single owner of that decision, so the
1768    /// CP trigger registry and the holder's value-read parse cache stay
1769    /// consistent.
1770    ///
1771    /// A link with no CP/CPP policy (`cp_passive_only() == None`), and
1772    /// every non-`Db`/`Ca` variant, is ignored.
1773    ///
1774    /// `Ca`: an external `ca://OTHER CP` / `OTHER CP CA` holder always
1775    /// drives the cross-IOC path — C `dbCa.c` `eventCallback` adds
1776    /// `CA_DBPROCESS` for every CP link (`dbCa.c:993-994`).
1777    ///
1778    /// `Db`: C `dbInitLink` (`dbLink.c:118-130`) makes any link carrying a
1779    /// `CP`/`CPP`/`CA` option a CA link, and `dbDbInitLink` keeps it local
1780    /// only when the named record exists in this IOC. So a CP/CPP `Db`
1781    /// link whose target is NOT a local record (e.g. `INP="other:pv CP"`,
1782    /// no explicit `CA`) must be served externally — both the calink
1783    /// trigger monitor (`ext_links`) AND the holder's value read. The read
1784    /// half is the `convert_to_ca` entry: it carries the field name and an
1785    /// equivalent [`CaLink`] so the caller rewrites the holder's cached
1786    /// parse from `Db` to `Ca`, routing the read through the external
1787    /// resolver. A local target keeps the local fast-path (`db_links`).
1788    async fn classify_cp_link(
1789        &self,
1790        field: &str,
1791        parsed: crate::server::record::ParsedLink,
1792        target_name: &str,
1793        db_links: &mut Vec<(String, String, bool)>,
1794        ext_links: &mut Vec<(String, String, bool)>,
1795        convert_to_ca: &mut Vec<(String, crate::server::record::CaLink)>,
1796    ) {
1797        match parsed {
1798            crate::server::record::ParsedLink::Db(db) => {
1799                let Some(passive_only) = db.policy.cp_passive_only() else {
1800                    return;
1801                };
1802                if self.has_name_no_resolve(&db.record).await {
1803                    db_links.push((db.record, target_name.to_string(), passive_only));
1804                } else {
1805                    // Reconstruct the CA channel name verbatim: `record`
1806                    // for a default-`VAL` link, `record.FIELD` otherwise —
1807                    // the same string the `CA`-modifier path would store
1808                    // in `CaLink::pv`.
1809                    let pv = if db.field == "VAL" {
1810                        db.record.clone()
1811                    } else {
1812                        format!("{}.{}", db.record, db.field)
1813                    };
1814                    ext_links.push((pv.clone(), target_name.to_string(), passive_only));
1815                    convert_to_ca.push((
1816                        field.to_string(),
1817                        crate::server::record::CaLink {
1818                            pv,
1819                            monitor_switch: db.monitor_switch,
1820                            policy: db.policy,
1821                        },
1822                    ));
1823                }
1824            }
1825            crate::server::record::ParsedLink::Ca(ca) => {
1826                if let Some(passive_only) = ca.policy.cp_passive_only() {
1827                    ext_links.push((ca.pv, target_name.to_string(), passive_only));
1828                }
1829            }
1830            _ => {}
1831        }
1832    }
1833
1834    /// Scan all records for CP/CPP input links and register them. Local
1835    /// `Db` links land in the local CP registry; external `Ca` links land
1836    /// in the external CP registry, whose holders are processed by the
1837    /// calink monitor callback.
1838    pub async fn setup_cp_links(&self) {
1839        let names = self.all_record_names().await;
1840        let mut db_links: Vec<(String, String, bool)> = Vec::new();
1841        let mut ext_links: Vec<(String, String, bool)> = Vec::new();
1842
1843        for target_name in &names {
1844            // Enumerate this record's link-bearing fields through the
1845            // single shared owner (`record_link_fields`) so the CA CP/CPP
1846            // setup here and the pvalink install scan can never diverge on
1847            // which fields count as links. `classify_cp_link` keeps only
1848            // the CP/CPP-policy links, routes local `Db` vs external `Ca`,
1849            // and — for a CP/CPP `Db` link to a non-local target — emits a
1850            // `convert_to_ca` entry so the holder's value read is rewired
1851            // to the external resolver (C `dbLink.c:118-130`).
1852            let mut convert_to_ca: Vec<(String, crate::server::record::CaLink)> = Vec::new();
1853            for (field, _raw, parsed) in self.record_link_fields(target_name).await {
1854                self.classify_cp_link(
1855                    &field,
1856                    parsed,
1857                    target_name,
1858                    &mut db_links,
1859                    &mut ext_links,
1860                    &mut convert_to_ca,
1861                )
1862                .await;
1863            }
1864            // Apply the `Db`→`Ca` parse-cache rewrite for non-local CP/CPP
1865            // links so the holder reads its value through the external
1866            // resolver — consistent with the external CP trigger registered
1867            // above (both halves use the same locality decision made in
1868            // `classify_cp_link`). Only the common-field caches are
1869            // rewritten; INPA.. / DOL.. links are re-parsed from their raw
1870            // strings each process cycle and so are not covered here.
1871            if !convert_to_ca.is_empty() {
1872                if let Some(rec_arc) = self.get_record(target_name).await {
1873                    let mut inst = rec_arc.write().await;
1874                    for (field, calink) in convert_to_ca {
1875                        let link = crate::server::record::ParsedLink::Ca(calink);
1876                        match field.as_str() {
1877                            "INP" => inst.parsed_inp = link,
1878                            "OUT" => inst.parsed_out = link,
1879                            "TSEL" => inst.parsed_tsel = link,
1880                            "SDIS" => inst.parsed_sdis = link,
1881                            _ => {}
1882                        }
1883                    }
1884                }
1885            }
1886        }
1887
1888        let db_count = db_links.len();
1889        for (source, target, passive_only) in db_links {
1890            self.register_cp_link(&source, &target, passive_only).await;
1891        }
1892        let ext_count = ext_links.len();
1893        for (external_pv, target, passive_only) in ext_links {
1894            self.register_external_cp_link(&external_pv, &target, passive_only)
1895                .await;
1896        }
1897        if db_count > 0 {
1898            eprintln!("iocInit: {db_count} CP link subscriptions");
1899        }
1900        // Warm external CP/CPP links. A Passive holder of an external CP
1901        // link is never scanned, so its link never opens lazily and the
1902        // calink monitor that drives `dispatch_external_cp_targets` is never
1903        // created (chicken-and-egg). Open each external CP PV now so its
1904        // monitor is live at iocInit — the C `dbCa.c` "add the link at init"
1905        // analogue (`dbCaAddLink`). `resolve_external_pv` routes through the
1906        // registered lset's lazy-open path (the same path a record read
1907        // uses); a no-op when no matching lset is installed (calink off).
1908        if ext_count > 0 {
1909            let ext_pvs = self.external_cp_pv_names().await;
1910            for pv in &ext_pvs {
1911                let _ = self.resolve_external_pv(pv).await;
1912            }
1913            eprintln!(
1914                "iocInit: {ext_count} external CP link subscriptions ({} PVs warmed)",
1915                ext_pvs.len()
1916            );
1917        }
1918    }
1919}
1920
1921#[cfg(test)]
1922mod out_link_put_fail_tests {
1923    use super::{LinkAlarm, OutLinkSrc};
1924    use crate::server::database::PvDatabase;
1925    use crate::server::record::{AlarmSeverity, DbLink, LinkProcessPolicy, MonitorSwitch};
1926    use crate::server::records::calc::CalcRecord;
1927    use crate::types::EpicsValue;
1928    use std::collections::HashSet;
1929
1930    /// C `dbDbPutValue` (dbDbLink.c:382-390) runs `dbPut`, folds the
1931    /// source alarm via `recGblInheritSevrMsg`, then `if (status) return
1932    /// status;` — only a *successful* write reaches the `.PROC`/`PP`
1933    /// `processTarget` branch. A failed OUT-link write must therefore NOT
1934    /// process the target. Here the write is an empty array into a scalar
1935    /// field, which `put_pv_already_locked` rejects with `InvalidValue`
1936    /// (the C dbPut nRequest=0 empty-array guard, field_io.rs).
1937    ///
1938    /// Observable: a passive `calc` target with `CALC = "7"` evaluates to
1939    /// `VAL = 7` on process and stays at its `Default` `VAL = 0` when not
1940    /// processed. The PP OUT link below carries a value the write
1941    /// rejects, so the fixed code returns before `processTarget` and
1942    /// `VAL` stays `0.0`; the pre-fix code processed the target
1943    /// unconditionally and `VAL` would become `7.0`.
1944    #[tokio::test]
1945    async fn pp_out_link_failed_write_does_not_process_target() {
1946        let db = PvDatabase::new();
1947        db.add_record("TGT", Box::new(CalcRecord::new("7")))
1948            .await
1949            .unwrap();
1950
1951        // Precondition: CALC not yet evaluated, VAL at its Default 0.0.
1952        assert!(
1953            matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 0.0),
1954            "calc VAL must start at its Default 0.0 before any process"
1955        );
1956
1957        let link = DbLink {
1958            record: "TGT".to_string(),
1959            field: "VAL".to_string(),
1960            policy: LinkProcessPolicy::ProcessPassive, // ` PP` token
1961            monitor_switch: MonitorSwitch::NoMaximize,
1962        };
1963        let alarm = LinkAlarm {
1964            stat: 0,
1965            sevr: AlarmSeverity::NoAlarm,
1966            amsg: String::new(),
1967        };
1968        let src = OutLinkSrc {
1969            putf: false,
1970            notify: None,
1971            alarm: &alarm,
1972        };
1973        let mut visited = HashSet::new();
1974
1975        // Empty array into the scalar VAL field: put_pv_already_locked
1976        // returns Err(InvalidValue) per the field_io.rs empty-array guard.
1977        db.write_db_link_value(&link, EpicsValue::DoubleArray(vec![]), src, &mut visited, 0)
1978            .await;
1979
1980        // Fixed: the failed write short-circuits before processTarget,
1981        // so CALC was never evaluated and VAL is still its Default 0.0.
1982        // Pre-fix: the target processed and VAL would be 7.0.
1983        assert!(
1984            matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 0.0),
1985            "a failed OUT-link write must NOT process the PP target \
1986             (VAL must stay 0.0, not become 7.0)"
1987        );
1988    }
1989}
1990
1991#[cfg(test)]
1992mod nonlocal_db_link_write_tests {
1993    use super::OutLinkSrc;
1994    use crate::server::database::{LinkPutOp, LinkSet, PvDatabase};
1995    use crate::server::record::{AlarmSeverity, DbLink, LinkProcessPolicy, MonitorSwitch};
1996    use crate::server::records::calc::CalcRecord;
1997    use crate::types::EpicsValue;
1998    use std::collections::HashSet;
1999    use std::sync::{Arc, Mutex};
2000
2001    /// A link set that records every `put_value` it receives so a test
2002    /// can assert a non-local OUT-link write reached the external (CA)
2003    /// put path instead of being dropped by a local `dbPut`.
2004    struct RecordingLset {
2005        puts: Arc<Mutex<Vec<(String, EpicsValue)>>>,
2006    }
2007    impl LinkSet for RecordingLset {
2008        fn is_connected(&self, _: &str) -> bool {
2009            true
2010        }
2011        fn get_value(&self, _: &str) -> Option<EpicsValue> {
2012            None
2013        }
2014        fn put_value(&self, name: &str, value: EpicsValue, _op: LinkPutOp) -> Result<(), String> {
2015            self.puts.lock().unwrap().push((name.to_string(), value));
2016            Ok(())
2017        }
2018    }
2019
2020    fn out_src(alarm: &super::LinkAlarm) -> OutLinkSrc<'_> {
2021        OutLinkSrc {
2022            putf: false,
2023            notify: None,
2024            alarm,
2025        }
2026    }
2027
2028    fn no_alarm() -> super::LinkAlarm {
2029        super::LinkAlarm {
2030            stat: 0,
2031            sevr: AlarmSeverity::NoAlarm,
2032            amsg: String::new(),
2033        }
2034    }
2035
2036    /// A plain OUT link whose target record is NOT local must write
2037    /// through the external put path — C `dbPutLink` routes a non-local
2038    /// (CA) link's write to `dbCaPutLink`, never a local `dbPut`. The
2039    /// pre-fix code called `put_pv_already_locked` on a name that does
2040    /// not exist locally; the write was silently dropped. The recording
2041    /// lset must now capture the value.
2042    #[tokio::test]
2043    async fn nonlocal_db_out_link_writes_through_external_put() {
2044        let db = PvDatabase::new();
2045        let puts = Arc::new(Mutex::new(Vec::new()));
2046        db.register_link_set("ca", Arc::new(RecordingLset { puts: puts.clone() }))
2047            .await;
2048
2049        // "OTHER:PV" is never added as a local record -> non-local.
2050        let link = DbLink {
2051            record: "OTHER:PV".to_string(),
2052            field: "VAL".to_string(),
2053            policy: LinkProcessPolicy::NoProcess,
2054            monitor_switch: MonitorSwitch::NoMaximize,
2055        };
2056        let alarm = no_alarm();
2057        let mut visited = HashSet::new();
2058        db.write_db_link_value(
2059            &link,
2060            EpicsValue::Double(42.0),
2061            out_src(&alarm),
2062            &mut visited,
2063            0,
2064        )
2065        .await;
2066
2067        let captured = puts.lock().unwrap();
2068        assert_eq!(
2069            captured.len(),
2070            1,
2071            "non-local OUT-link write must reach the external put path exactly once"
2072        );
2073        assert_eq!(captured[0].0, "OTHER:PV");
2074        assert!(matches!(captured[0].1, EpicsValue::Double(v) if v == 42.0));
2075    }
2076
2077    /// A local OUT-link write must NOT divert to the external put path —
2078    /// the locality dispatch only reroutes non-local targets. The local
2079    /// record receives the value; the lset records nothing.
2080    #[tokio::test]
2081    async fn local_db_out_link_writes_local_not_external() {
2082        let db = PvDatabase::new();
2083        let puts = Arc::new(Mutex::new(Vec::new()));
2084        db.register_link_set("ca", Arc::new(RecordingLset { puts: puts.clone() }))
2085            .await;
2086        db.add_record("TGT", Box::new(CalcRecord::new("0")))
2087            .await
2088            .unwrap();
2089
2090        let link = DbLink {
2091            record: "TGT".to_string(),
2092            field: "VAL".to_string(),
2093            policy: LinkProcessPolicy::NoProcess,
2094            monitor_switch: MonitorSwitch::NoMaximize,
2095        };
2096        let alarm = no_alarm();
2097        let mut visited = HashSet::new();
2098        db.write_db_link_value(
2099            &link,
2100            EpicsValue::Double(7.0),
2101            out_src(&alarm),
2102            &mut visited,
2103            0,
2104        )
2105        .await;
2106
2107        assert!(
2108            puts.lock().unwrap().is_empty(),
2109            "a local OUT-link write must not reach the external put path"
2110        );
2111        assert!(
2112            matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 7.0),
2113            "the local target must hold the written value"
2114        );
2115    }
2116
2117    /// A link set that records every `scan_forward` it receives so a test
2118    /// can assert a non-DB FLNK reached the external forward path
2119    /// (C `dbScanFwdLink` → `lset->scanForward`) instead of being dropped
2120    /// by the DB-only `flnk_name` filter. `connected=false` models a
2121    /// disconnected link (pvxs `pvaScanForward`'s `!valid()` gate).
2122    struct ForwardingLset {
2123        forwards: Arc<Mutex<Vec<String>>>,
2124        connected: bool,
2125    }
2126    impl LinkSet for ForwardingLset {
2127        fn is_connected(&self, _: &str) -> bool {
2128            self.connected
2129        }
2130        fn get_value(&self, _: &str) -> Option<EpicsValue> {
2131            None
2132        }
2133        fn scan_forward(&self, name: &str) -> Result<(), String> {
2134            self.forwards.lock().unwrap().push(name.to_string());
2135            if self.connected {
2136                Ok(())
2137            } else {
2138                Err("Disconn".into())
2139            }
2140        }
2141    }
2142
2143    /// `scan_forward_external_pv` must resolve the scheme and delegate to
2144    /// the registered lset's `scan_forward` — the FWD-link twin of
2145    /// `write_external_pv` for OUT writes (C `dbScanFwdLink` →
2146    /// `lset->scanForward`).
2147    #[tokio::test]
2148    async fn external_forward_link_dispatches_through_scan_forward() {
2149        let db = PvDatabase::new();
2150        let forwards = Arc::new(Mutex::new(Vec::new()));
2151        db.register_link_set(
2152            "pva",
2153            Arc::new(ForwardingLset {
2154                forwards: forwards.clone(),
2155                connected: true,
2156            }),
2157        )
2158        .await;
2159
2160        db.scan_forward_external_pv("pva://OTHER:PROC")
2161            .await
2162            .expect("a connected forward must succeed");
2163
2164        let captured = forwards.lock().unwrap();
2165        assert_eq!(captured.len(), 1);
2166        assert_eq!(captured[0], "OTHER:PROC");
2167    }
2168
2169    /// End-to-end: a record whose FLNK targets an external `pva://` PV
2170    /// must fire the link set's `scan_forward` when it processes — the
2171    /// non-DB FLNK dispatch the DB-only `flnk_name` filter previously
2172    /// dropped. C `recGblFwdLink` runs `dbScanFwdLink` for every FLNK
2173    /// regardless of link kind.
2174    #[tokio::test]
2175    async fn record_processing_fires_external_forward_link() {
2176        let db = PvDatabase::new();
2177        let forwards = Arc::new(Mutex::new(Vec::new()));
2178        db.register_link_set(
2179            "pva",
2180            Arc::new(ForwardingLset {
2181                forwards: forwards.clone(),
2182                connected: true,
2183            }),
2184        )
2185        .await;
2186        db.add_record("SRC", Box::new(CalcRecord::new("0")))
2187            .await
2188            .unwrap();
2189        if let Some(rec) = db.get_record("SRC").await {
2190            let mut inst = rec.write().await;
2191            inst.put_common_field("FLNK", EpicsValue::String("pva://TARGET".into()))
2192                .unwrap();
2193        }
2194
2195        let mut visited = HashSet::new();
2196        db.process_record_with_links("SRC", &mut visited, 0)
2197            .await
2198            .unwrap();
2199
2200        let captured = forwards.lock().unwrap();
2201        assert_eq!(
2202            captured.len(),
2203            1,
2204            "an external pva:// FLNK must fire scan_forward exactly once"
2205        );
2206        assert_eq!(captured[0], "TARGET");
2207    }
2208
2209    /// A disconnected non-retry external FLNK is NOT dropped silently: the
2210    /// lset returns Err and the owning record takes a *pending*
2211    /// LINK/INVALID alarm — pvxs `pvaScanForward`'s
2212    /// `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")`, which
2213    /// writes `nsta`/`nsev`/`namsg` (promoted by the next
2214    /// `recGblResetAlarms`, matching the C late-set inside `recGblFwdLink`).
2215    #[tokio::test]
2216    async fn disconnected_external_forward_link_raises_pending_link_invalid() {
2217        let db = PvDatabase::new();
2218        let forwards = Arc::new(Mutex::new(Vec::new()));
2219        db.register_link_set(
2220            "pva",
2221            Arc::new(ForwardingLset {
2222                forwards: forwards.clone(),
2223                connected: false,
2224            }),
2225        )
2226        .await;
2227        db.add_record("SRC", Box::new(CalcRecord::new("0")))
2228            .await
2229            .unwrap();
2230        if let Some(rec) = db.get_record("SRC").await {
2231            let mut inst = rec.write().await;
2232            inst.put_common_field("FLNK", EpicsValue::String("pva://TARGET".into()))
2233                .unwrap();
2234        }
2235
2236        let mut visited = HashSet::new();
2237        db.process_record_with_links("SRC", &mut visited, 0)
2238            .await
2239            .unwrap();
2240
2241        assert_eq!(
2242            forwards.lock().unwrap().len(),
2243            1,
2244            "scan_forward is still attempted on a disconnected link"
2245        );
2246        let rec = db.get_record("SRC").await.unwrap();
2247        let inst = rec.read().await;
2248        assert_eq!(inst.common.nsev, AlarmSeverity::Invalid);
2249        assert_eq!(
2250            inst.common.nsta,
2251            crate::server::recgbl::alarm_status::LINK_ALARM
2252        );
2253        assert_eq!(inst.common.namsg, "Disconn");
2254    }
2255}
2256
2257#[cfg(test)]
2258mod cp_link_locality_tests {
2259    use crate::server::database::PvDatabase;
2260    use crate::server::record::ParsedLink;
2261    use crate::server::records::ai::AiRecord;
2262
2263    /// A CP/CPP link with no explicit `CA` modifier whose target is NOT a
2264    /// local record must be served as an external (CA) link — both the
2265    /// trigger and the value read. C `dbInitLink` (`dbLink.c:118-130`)
2266    /// makes any `CP`/`CPP` link a CA link, and `dbDbInitLink` keeps it
2267    /// local only when the named record exists in this IOC.
2268    ///
2269    /// Here `INP="OTHER:PV CP"` parses to `ParsedLink::Db` (bare name, no
2270    /// `CA`), but `OTHER:PV` is not local. After `setup_cp_links` the
2271    /// holder's `parsed_inp` must be rewritten to `ParsedLink::Ca` (so the
2272    /// read routes through `resolve_external_pv`) and `OTHER:PV` must be
2273    /// registered as an external CP trigger.
2274    #[tokio::test]
2275    async fn cp_link_to_nonlocal_target_forced_external() {
2276        let db = PvDatabase::new();
2277        db.add_record("HOLDER", Box::new(AiRecord::new(0.0)))
2278            .await
2279            .unwrap();
2280        {
2281            let rec = db.get_record("HOLDER").await.unwrap();
2282            rec.write().await.common.inp = "OTHER:PV CP".to_string();
2283        }
2284
2285        db.setup_cp_links().await;
2286
2287        let inp = db
2288            .get_record("HOLDER")
2289            .await
2290            .unwrap()
2291            .read()
2292            .await
2293            .parsed_inp
2294            .clone();
2295        match inp {
2296            ParsedLink::Ca(ca) => assert_eq!(
2297                ca.pv, "OTHER:PV",
2298                "non-local CP link must carry the verbatim PV name"
2299            ),
2300            other => panic!("non-local CP link must be forced to Ca, got {other:?}"),
2301        }
2302        assert!(
2303            db.external_cp_pv_names()
2304                .await
2305                .contains(&"OTHER:PV".to_string()),
2306            "non-local CP link must be registered as an external CP trigger"
2307        );
2308    }
2309
2310    /// A CP link whose target IS a local record keeps the local fast-path:
2311    /// `setup_cp_links` must NOT rewrite its `parsed_inp` to `Ca` and must
2312    /// NOT register it as an external CP link.
2313    #[tokio::test]
2314    async fn cp_link_to_local_target_stays_db() {
2315        let db = PvDatabase::new();
2316        db.add_record("SRC", Box::new(AiRecord::new(1.0)))
2317            .await
2318            .unwrap();
2319        db.add_record("HOLDER", Box::new(AiRecord::new(0.0)))
2320            .await
2321            .unwrap();
2322        {
2323            let rec = db.get_record("HOLDER").await.unwrap();
2324            let mut inst = rec.write().await;
2325            inst.common.inp = "SRC CP".to_string();
2326            // Seed the parse cache as load would, so the assertion proves
2327            // setup_cp_links leaves a local CP link untouched.
2328            inst.parsed_inp = crate::server::record::parse_link_v2("SRC CP");
2329        }
2330
2331        db.setup_cp_links().await;
2332
2333        let inp = db
2334            .get_record("HOLDER")
2335            .await
2336            .unwrap()
2337            .read()
2338            .await
2339            .parsed_inp
2340            .clone();
2341        assert!(
2342            matches!(inp, ParsedLink::Db(_)),
2343            "local CP link must stay a Db link, got {inp:?}"
2344        );
2345        assert!(
2346            !db.external_cp_pv_names().await.contains(&"SRC".to_string()),
2347            "a local CP target must not be registered as an external CP link"
2348        );
2349    }
2350}
2351
2352#[cfg(test)]
2353mod nonlocal_db_link_read_tests {
2354    use crate::server::database::PvDatabase;
2355    use crate::server::record::{ParsedLink, parse_link_v2};
2356    use crate::types::EpicsValue;
2357    use std::collections::HashSet;
2358    use std::sync::Arc;
2359
2360    /// C `dbInitLink` (`dbLink.c:118-130`) makes a PV link whose target is
2361    /// not a local record a CA link — even with NO `CP`/`CPP`/`CA`
2362    /// modifier (`dbDbInitLink` fails to resolve it locally and falls
2363    /// through to `dbCaAddLinkCallbackOpt`). So a plain `INP="OTHER:PV"`
2364    /// to a non-local target must read the remote value, not return
2365    /// `None`. Pre-fix the `Db` read arm read only the local DB (`get_pv`)
2366    /// and dropped the non-local target.
2367    #[tokio::test]
2368    async fn plain_nonlocal_db_link_reads_via_external_resolver() {
2369        let db = PvDatabase::new();
2370        // Stand-in for the calink/pvalink lset: resolve OTHER:PV remotely.
2371        db.set_external_resolver(Arc::new(|name: &str| {
2372            if name == "OTHER:PV" {
2373                Some(EpicsValue::Double(42.0))
2374            } else {
2375                None
2376            }
2377        }))
2378        .await;
2379
2380        let link = parse_link_v2("OTHER:PV");
2381        assert!(
2382            matches!(link, ParsedLink::Db(_)),
2383            "a bare non-scheme name parses to a Db link, got {link:?}"
2384        );
2385
2386        let mut visited = HashSet::new();
2387        let v = db.read_link_value(&link, &mut visited, 0).await;
2388        assert_eq!(
2389            v,
2390            Some(EpicsValue::Double(42.0)),
2391            "a plain non-local Db link must resolve through the external resolver (C dbCaAddLink fallback)"
2392        );
2393    }
2394
2395    /// A multi-input-style link re-parsed from its raw string each cycle
2396    /// (calc `INPA`..`INPL`, `DOL`) routes its value read through
2397    /// `read_link_with_alarm`; the same locality rule must hold there, so
2398    /// a non-local target reads the remote value and surfaces no
2399    /// local-record alarm.
2400    #[tokio::test]
2401    async fn nonlocal_db_link_value_and_alarm_via_external() {
2402        let db = PvDatabase::new();
2403        db.set_external_resolver(Arc::new(|name: &str| {
2404            if name == "OTHER:PV" {
2405                Some(EpicsValue::Double(5.0))
2406            } else {
2407                None
2408            }
2409        }))
2410        .await;
2411
2412        let link = parse_link_v2("OTHER:PV");
2413        let (value, alarm) = db.read_link_with_alarm(&link).await;
2414        assert_eq!(
2415            value,
2416            Some(EpicsValue::Double(5.0)),
2417            "non-local Db link value must come from the external resolver"
2418        );
2419        // No lset is registered (only the legacy resolver), so the bare
2420        // external alarm path reports None — never a fabricated local
2421        // record alarm for a record that does not exist in this IOC.
2422        assert!(
2423            alarm.is_none(),
2424            "non-local Db link must not fabricate a local-record alarm, got {alarm:?}"
2425        );
2426    }
2427
2428    /// Owner path: a Db link whose target IS a local record still reads
2429    /// the local database and never consults the external resolver.
2430    #[tokio::test]
2431    async fn local_db_link_reads_local_not_external() {
2432        let db = PvDatabase::new();
2433        db.add_pv("SRC", EpicsValue::Double(7.0)).await.unwrap();
2434        // A resolver returning a DIFFERENT value proves the local read
2435        // wins and the external path is not taken for a local target.
2436        db.set_external_resolver(Arc::new(|_name: &str| Some(EpicsValue::Double(-1.0))))
2437            .await;
2438
2439        let link = parse_link_v2("SRC");
2440        let mut visited = HashSet::new();
2441        let v = db.read_link_value(&link, &mut visited, 0).await;
2442        assert_eq!(
2443            v,
2444            Some(EpicsValue::Double(7.0)),
2445            "a local Db link must read the local DB, not the external resolver"
2446        );
2447    }
2448}