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`, `dfanoutRecord.c:126`,
1162 // `seqRecord.c:152` all call
1163 // `dbGetLink(&prec->sell, DBR_USHORT, &prec->seln, 0, 0)` at
1164 // the top of `process()`, every cycle. Only the `sel` record's
1165 // NVL->SELN binding was previously wired; fanout/dfanout/seq
1166 // never read the SELL link, so a SELL pointing at another
1167 // record's value field never updated SELN — the selection was
1168 // frozen at whatever SELN was initialised to. `sseq` reads its
1169 // own SELL→SELN in `SseqRecord::pre_input_link_actions` (the
1170 // async machine owns the whole cycle), so it is not handled here.
1171 {
1172 let sell = {
1173 let instance = rec.read().await;
1174 match instance.record.record_type() {
1175 "fanout" | "dfanout" | "seq" => Some(Self::field_str(&instance, "SELL")),
1176 _ => None,
1177 }
1178 };
1179 if let Some(sell) = sell {
1180 if !sell.is_empty() {
1181 // C reads SELL via `dbGetLink(&prec->sell, DBR_USHORT,
1182 // &prec->seln, 0, 0)` for any link type; the pre-fix
1183 // port only read a `ParsedLink::Db` SELL, so a SELL
1184 // sourced over CA/PVA or given as a constant never
1185 // updated SELN.
1186 let parsed = crate::server::record::parse_link_v2(&sell);
1187 if let Some(val) = self.read_link_value_no_process(&parsed).await {
1188 // C reads SELL with `dbGetLink(&prec->sell,
1189 // DBR_USHORT, &prec->seln, 0, 0)`; the dbConvert GET
1190 // macro stores `*pdst = (epicsUInt16) *psrc`
1191 // (`dbConvert.c:63-70`) — a C cast (truncate-then-
1192 // wrap mod 2^16), NOT a clamp. `SELL=-1` therefore
1193 // yields `SELN=65535` (Specified → out-of-range
1194 // INVALID, Mask → all-bits), where the old clamp
1195 // produced `0` (drove link 0 / empty mask). SELN is
1196 // a `DBF_USHORT` (u16) field; `select_link_indices_ex`
1197 // consumes it as `u16`.
1198 let seln = dbr_ushort_cast(&val);
1199 let mut instance = rec.write().await;
1200 let _ = instance.record.put_field("SELN", EpicsValue::UShort(seln));
1201 }
1202 }
1203 }
1204 }
1205
1206 let dispatch_info: Option<(SelmResult, MultiOut, Option<EpicsValue>)> = {
1207 let instance = rec.read().await;
1208 match instance.record.record_type() {
1209 "fanout" => {
1210 let selm = Self::field_i16(&instance, "SELM");
1211 let seln = Self::field_u16(&instance, "SELN");
1212 let offs = Self::field_i16(&instance, "OFFS");
1213 let shft = Self::field_i16(&instance, "SHFT");
1214 // C parity (fanoutRecord.c:39): 16 forward links
1215 // LNK0..LNKF. LNK0 is the natural first slot.
1216 let links: Vec<String> = [
1217 "LNK0", "LNK1", "LNK2", "LNK3", "LNK4", "LNK5", "LNK6", "LNK7", "LNK8",
1218 "LNK9", "LNKA", "LNKB", "LNKC", "LNKD", "LNKE", "LNKF",
1219 ]
1220 .iter()
1221 .map(|f| Self::field_str(&instance, f))
1222 .collect();
1223 // SELM resolution with OFFS/SHFT bias (fanoutRecord.c).
1224 let sel = select_link_indices_ex(
1225 SelmKind::FanoutSeq,
1226 selm,
1227 seln,
1228 offs,
1229 shft,
1230 links.len(),
1231 );
1232 Some((sel, MultiOut::Fanout(links), None))
1233 }
1234 "dfanout" => {
1235 let selm = Self::field_i16(&instance, "SELM");
1236 let seln = Self::field_u16(&instance, "SELN");
1237 // IVOA / IVOV — invalid output handling, mirrors
1238 // epics-base PR #688. When the record's pending
1239 // severity is INVALID, IVOA selects: 0 = continue (use
1240 // VAL as before), 1 = don't drive (suppress all OUT*),
1241 // 2 = set outputs to IVOV.
1242 //
1243 // C `dfanoutRecord.c:128` gates the push on the
1244 // *pending* `nsev` (`if (prec->nsev < INVALID_ALARM)
1245 // push_values(); else switch(ivoa)`), evaluated after
1246 // `checkAlarms` but before `recGblResetAlarms` commits
1247 // it. This dispatch runs in that same pre-commit window,
1248 // so the gate reads the caller-supplied pending
1249 // severity, not the still-stale committed `common.sevr`.
1250 let push_sevr = dfanout_pending_sevr
1251 .expect("dfanout dispatch carries the pending severity (phase gate)");
1252 let raw_val = instance.record.val();
1253 let val = if push_sevr == crate::server::record::AlarmSeverity::Invalid {
1254 let ivoa = instance
1255 .record
1256 .get_field("IVOA")
1257 .and_then(|v| {
1258 if let EpicsValue::Short(s) = v {
1259 Some(s)
1260 } else {
1261 None
1262 }
1263 })
1264 .unwrap_or(0);
1265 match ivoa {
1266 1 => None, // suppress drive
1267 2 => instance.record.get_field("IVOV").or(raw_val),
1268 _ => raw_val, // 0 or unknown — Continue
1269 }
1270 } else {
1271 raw_val
1272 };
1273 let links: Vec<String> = [
1274 "OUTA", "OUTB", "OUTC", "OUTD", "OUTE", "OUTF", "OUTG", "OUTH", "OUTI",
1275 "OUTJ", "OUTK", "OUTL", "OUTM", "OUTN", "OUTO", "OUTP",
1276 ]
1277 .iter()
1278 .map(|f| Self::field_str(&instance, f))
1279 .collect();
1280 // dfanout Specified is 1-based; Mask has no SHFT
1281 // (dfanoutRecord.c:307-339).
1282 let sel =
1283 select_link_indices_ex(SelmKind::Dfanout, selm, seln, 0, 0, links.len());
1284 Some((sel, MultiOut::Dfanout(links), val))
1285 }
1286 "seq" => {
1287 let selm = Self::field_i16(&instance, "SELM");
1288 let seln = Self::field_u16(&instance, "SELN");
1289 let offs = Self::field_i16(&instance, "OFFS");
1290 let shft = Self::field_i16(&instance, "SHFT");
1291 // C parity (seqRecord.c:86): 16 link groups 0..F,
1292 // each DOLn / DOn (value storage) / DLYn / LNKn.
1293 let dol_names = [
1294 "DOL0", "DOL1", "DOL2", "DOL3", "DOL4", "DOL5", "DOL6", "DOL7", "DOL8",
1295 "DOL9", "DOLA", "DOLB", "DOLC", "DOLD", "DOLE", "DOLF",
1296 ];
1297 let lnk_names = [
1298 "LNK0", "LNK1", "LNK2", "LNK3", "LNK4", "LNK5", "LNK6", "LNK7", "LNK8",
1299 "LNK9", "LNKA", "LNKB", "LNKC", "LNKD", "LNKE", "LNKF",
1300 ];
1301 let dly_names = [
1302 "DLY0", "DLY1", "DLY2", "DLY3", "DLY4", "DLY5", "DLY6", "DLY7", "DLY8",
1303 "DLY9", "DLYA", "DLYB", "DLYC", "DLYD", "DLYE", "DLYF",
1304 ];
1305 let do_names = [
1306 "DO0", "DO1", "DO2", "DO3", "DO4", "DO5", "DO6", "DO7", "DO8", "DO9",
1307 "DOA", "DOB", "DOC", "DOD", "DOE", "DOF",
1308 ];
1309 let groups: Vec<SeqGroup> = (0..16)
1310 .map(|i| SeqGroup {
1311 dol: Self::field_str(&instance, dol_names[i]),
1312 lnk: Self::field_str(&instance, lnk_names[i]),
1313 dly: instance
1314 .record
1315 .get_field(dly_names[i])
1316 .and_then(|v| v.to_f64())
1317 .unwrap_or(0.0),
1318 dov: instance
1319 .record
1320 .get_field(do_names[i])
1321 .and_then(|v| v.to_f64())
1322 .unwrap_or(0.0),
1323 })
1324 .collect();
1325 let sel = select_link_indices_ex(
1326 SelmKind::FanoutSeq,
1327 selm,
1328 seln,
1329 offs,
1330 shft,
1331 groups.len(),
1332 );
1333 Some((sel, MultiOut::Seq(groups), None))
1334 }
1335 _ => None,
1336 }
1337 };
1338
1339 let (sel, payload, val) = match dispatch_info {
1340 Some(info) => info,
1341 None => return None,
1342 };
1343 debug_assert!(sel.indices.iter().all(|&i| i < payload.len()));
1344 // Single-owner invariant: every record type that produces a
1345 // `MultiOut` payload here MUST be listed in
1346 // `multi_output_dispatch_owned` so the generic
1347 // `multi_output_links` block in `processing.rs` skips it. If
1348 // this fires, the two lists have diverged and the skipped
1349 // type would be dispatched twice per cycle.
1350 debug_assert!(multi_output_dispatch_owned(
1351 rec.read().await.record.record_type()
1352 ));
1353
1354 // C raises SOFT_ALARM/INVALID_ALARM when SELN/OFFS/SHFT resolve
1355 // out of range (fanoutRecord.c:116, dfanoutRecord.c:317,
1356 // seqRecord.c:157). fanout/seq dispatch in the post-commit tail, so
1357 // they raise it directly into the committed SEVR and post SEVR/STAT
1358 // immediately (apply_selm_alarm). dfanout dispatches PRE-commit, so
1359 // its out-of-range alarm must instead fold into the pending nsev the
1360 // caller commits THIS cycle — captured here and returned below, not
1361 // raised+posted now (a direct SEVR raise would be clobbered by the
1362 // caller's `recGblResetAlarms`).
1363 let dfanout_selm_alarm = sel.alarm;
1364 if !is_dfanout {
1365 Self::apply_selm_alarm(rec, sel.alarm).await;
1366 }
1367 let indices = sel.indices;
1368 // C `dfanoutRecord.c` push_values raises LINK_ALARM/MAJOR per failed
1369 // dbPutLink; accumulated across the selected OUT links below.
1370 let mut link_failed = false;
1371
1372 match payload {
1373 MultiOut::Fanout(links) => {
1374 for idx in indices {
1375 let link_str = &links[idx];
1376 if link_str.is_empty() {
1377 continue;
1378 }
1379 let parsed = crate::server::record::parse_link_v2(link_str);
1380 if let crate::server::record::ParsedLink::Db(ref db) = parsed {
1381 // C `fanoutRecord.c:110/121/138` dispatches each
1382 // selected LNKn via `dbScanFwdLink` →
1383 // `dbDbScanFwdLink` → `dbScanPassive`
1384 // (`dbDbLink.c:425-432`), which processes the
1385 // target ONLY when its SCAN is Passive
1386 // (`if (pto->scan != 0) return 0;`). A LNKn
1387 // pointing at a Periodic/Event/I/O-Intr record
1388 // must NOT be re-processed by the fanout — that
1389 // record runs on its own scan. `dbScanPassive`
1390 // then calls `processTarget`, which propagates
1391 // PUTF (and sets RPRO on a busy target) exactly
1392 // like the explicit FLNK path — so mirror that
1393 // gate here instead of the previous
1394 // unconditional `process_record_with_links`.
1395 if let Some(target_rec) = self.get_record(&db.record).await {
1396 let (target_scan, should_process) = {
1397 let mut tg = target_rec.write().await;
1398 let pact = tg.is_processing();
1399 let on_chain = visited.contains(&db.record);
1400 if !pact {
1401 tg.common.putf = src_putf;
1402 } else if src_putf && !on_chain {
1403 tg.common.rpro = true;
1404 tg.common.putf = false;
1405 }
1406 (tg.common.scan, !pact)
1407 };
1408 if should_process && target_scan == ScanType::Passive {
1409 // recursive link-target processing
1410 // within one chain — gate held by the
1411 // foreign entry record.
1412 let _ = self
1413 .process_record_with_links_recursive(
1414 &db.record,
1415 visited,
1416 depth + 1,
1417 )
1418 .await;
1419 }
1420 }
1421 }
1422 }
1423 }
1424 MultiOut::Dfanout(links) => {
1425 if let Some(ref val) = val {
1426 for idx in indices {
1427 let link_str = &links[idx];
1428 if link_str.is_empty() {
1429 continue;
1430 }
1431 // C `dfanoutRecord.c:323` drives each OUTn via
1432 // `dbPutLink` → `dbDbPutValue`: an `DBF_OUTLINK`
1433 // target is processed only when the link carries
1434 // an explicit ` PP` token or the destination is
1435 // `.PROC` (`dbDbLink.c:387-390`). A modifier-less
1436 // OUTn is NPP — the value is written but the
1437 // target is NOT re-processed (otherwise a
1438 // Soft-Channel ai target's `convert()` would
1439 // clobber the value just written). The NPP
1440 // default and the `.PROC`/`PP` processing rule
1441 // are both honoured by `parse_output_link_v2`
1442 // (uniform NoProcess default) +
1443 // `write_db_link_value` (write-path target
1444 // processing), so no per-call downgrade is needed.
1445 let parsed = crate::server::record::parse_output_link_v2(link_str);
1446 match parsed {
1447 crate::server::record::ParsedLink::Db(ref db) => {
1448 // C `dfanoutRecord.c:311-312`: a failed
1449 // dbPutLink raises LINK_ALARM/MAJOR.
1450 if self
1451 .write_db_link_value(db, val.clone(), out_src, visited, depth)
1452 .await
1453 {
1454 link_failed = true;
1455 }
1456 }
1457 // External `ca://`/`pva://` OUTn — C
1458 // `dbPutLink` routes a CA-link write through
1459 // the link set's `putValue` identically to a
1460 // DB link (dbLink.c:434-448). PP has no
1461 // meaning for an external write (the remote
1462 // record processes on its own IOC), so route
1463 // straight through the link set.
1464 crate::server::record::ParsedLink::Ca(_)
1465 | crate::server::record::ParsedLink::Pva(_)
1466 | crate::server::record::ParsedLink::PvaJson(_) => {
1467 let name = parsed
1468 .external_pv_name()
1469 .expect("Ca/Pva/PvaJson link carries a PV name");
1470 let op = Self::external_put_op(src_notify.as_ref());
1471 if let Err(e) = self.write_external_pv(&name, val.clone(), op).await
1472 {
1473 eprintln!(
1474 "dfanout OUT-link write to external PV '{name}' failed: {e}"
1475 );
1476 // C `dfanoutRecord.c:312` routes a CA OUT
1477 // link through dbPutLink identically — a
1478 // disconnected/rejected remote write
1479 // returns nonzero → LINK_ALARM/MAJOR.
1480 link_failed = true;
1481 }
1482 }
1483 _ => {}
1484 }
1485 }
1486 }
1487 }
1488 MultiOut::Seq(groups) => {
1489 // DOn value-storage field names (`linkGrp.dov`),
1490 // index-aligned with the LNKn/DOLn groups.
1491 const DO_NAMES: [&str; 16] = [
1492 "DO0", "DO1", "DO2", "DO3", "DO4", "DO5", "DO6", "DO7", "DO8", "DO9", "DOA",
1493 "DOB", "DOC", "DOD", "DOE", "DOF",
1494 ];
1495 // C `dbLinkIsConstant` is true for empty and numeric-literal
1496 // links; a real link is a DB/CA/PVA PV reference.
1497 let is_real = |s: &str| {
1498 !s.is_empty()
1499 && !matches!(
1500 crate::server::record::parse_link_v2(s),
1501 crate::server::record::ParsedLink::Constant(_)
1502 )
1503 };
1504 let rec_name = rec.read().await.name.clone();
1505 for idx in indices {
1506 let grp = &groups[idx];
1507 // C `seqRecord.c:182-189`: a group is processed iff its
1508 // LNKn OR DOLn is a real (non-constant) link. When both
1509 // are constant/empty the group is skipped entirely — so
1510 // a DOL-only group (real DOLn, empty LNKn) still reads
1511 // back DOn and posts it, even though nothing is driven.
1512 let lnk_real = is_real(&grp.lnk);
1513 let dol_real = is_real(&grp.dol);
1514 if !lnk_real && !dol_real {
1515 continue;
1516 }
1517 // Per-group DLYn staggering — C `seqRecord.c`
1518 // schedules each group after its delay. Groups
1519 // process sequentially in index order, each after
1520 // its own delay (callbackRequestDelayed chain).
1521 if grp.dly > 0.0 {
1522 tokio::time::sleep(std::time::Duration::from_secs_f64(grp.dly)).await;
1523 }
1524 // C `seqRecord.c:259` `dbGetLink(&dol, DBR_DOUBLE,
1525 // &dov)`: read DOLn into the DOn value field. A
1526 // constant/empty DOL — or a failed read — leaves DOn at
1527 // its previous value.
1528 let new_dov = if dol_real {
1529 let dol_parsed = crate::server::record::parse_link_v2(&grp.dol);
1530 self.read_link_value(&dol_parsed, visited, depth)
1531 .await
1532 .and_then(|v| v.to_f64())
1533 .unwrap_or(grp.dov)
1534 } else {
1535 grp.dov
1536 };
1537 // C `seqRecord.c:264` drives LNKn via `dbPutLink`
1538 // (`DBR_DOUBLE, &dov`), whose `DBF_OUTLINK` target is
1539 // processed by `dbDbPutValue` (`dbDbLink.c:388`) only
1540 // when the link carries an explicit `PP` modifier. A
1541 // bare `LNKn` is NPP — the value is written but the
1542 // target is NOT processed. `parse_output_link_v2`
1543 // applies that NPP default (the dfanout arm above
1544 // open-codes the same downgrade). LNKn may be a local DB
1545 // link or an external `ca://`/`pva://` link — C
1546 // `dbPutLink` routes both through the link set's
1547 // `putValue` (dbLink.c:434-448). Only a real LNK does
1548 // anything; a constant LNK is a no-op.
1549 if lnk_real {
1550 let lnk_parsed = crate::server::record::parse_output_link_v2(&grp.lnk);
1551 self.write_out_link_value(
1552 &lnk_parsed,
1553 EpicsValue::Double(new_dov),
1554 out_src,
1555 visited,
1556 depth,
1557 )
1558 .await;
1559 }
1560 // C `seqRecord.c:266-268`: store DOn and post a
1561 // DBE_VALUE|DBE_LOG monitor only when it changed. The
1562 // field already holds the old value (snapshot read), so
1563 // `post_fields` (write + post) is needed only on change.
1564 if new_dov != grp.dov {
1565 let _ = self
1566 .post_fields(
1567 &rec_name,
1568 vec![(DO_NAMES[idx].to_string(), EpicsValue::Double(new_dov))],
1569 )
1570 .await;
1571 }
1572 }
1573 }
1574 }
1575
1576 // dfanout (pre-commit phase): return the single pending alarm C
1577 // `push_values` would have raised this cycle for the caller to fold
1578 // into `nsev` before `recGblResetAlarms`. C raises at most one:
1579 // SOFT_ALARM/INVALID for a Specified `seln` out of range (line 317,
1580 // no push) OR LINK_ALARM/MAJOR for a failed dbPutLink (line
1581 // 312/324/333). They are mutually exclusive in C's control flow; if
1582 // both were somehow set, fold the higher severity (recGblSetSevr is
1583 // raise-only). fanout/seq already applied their range alarm directly
1584 // above and drive no value, so they return None.
1585 if is_dfanout {
1586 let link_alarm = if link_failed {
1587 Some((
1588 crate::server::recgbl::alarm_status::LINK_ALARM,
1589 AlarmSeverity::Major,
1590 ))
1591 } else {
1592 None
1593 };
1594 return match (dfanout_selm_alarm, link_alarm) {
1595 (Some(a), Some(b)) => Some(if (a.1 as u16) >= (b.1 as u16) { a } else { b }),
1596 (a, b) => a.or(b),
1597 };
1598 }
1599 None
1600 }
1601
1602 /// Post the software event named by an `event` record's `VAL`.
1603 ///
1604 /// Mirrors C `eventRecord.c:120` `postEvent(prec->epvt)` — every
1605 /// `process()` of an event record posts its event, waking the
1606 /// `SCAN="Event"` records whose `EVNT` resolves to that name.
1607 /// No-op for any other record type, or when `VAL` is empty /
1608 /// resolves to event 0 (`eventNameToHandle` returns NULL).
1609 pub(crate) async fn dispatch_event_record(&self, rec: &Arc<RwLock<RecordInstance>>) {
1610 let event_name = {
1611 let instance = rec.read().await;
1612 if instance.record.record_type() != "event" {
1613 return;
1614 }
1615 match instance.record.get_field("VAL") {
1616 Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
1617 _ => return,
1618 }
1619 };
1620 if event_name.trim().is_empty() {
1621 return;
1622 }
1623 // C `postEvent` queues callbacks on the scan ring buffer —
1624 // the event-scanned records run on a separate callback thread,
1625 // NOT recursively inside this process cycle. Spawn the routed
1626 // post so a chain of event records cannot recurse unboundedly
1627 // and the current cycle's FLNK/CP dispatch is not blocked.
1628 let db = self.clone();
1629 crate::runtime::task::spawn(async move {
1630 db.post_event_named(&event_name).await;
1631 });
1632 }
1633
1634 /// Register a CP link: when source_record changes, process target_record.
1635 ///
1636 /// Both names are normalised to canonical form so the cp_links
1637 /// map's key/value always match the canonical record name that
1638 /// `dispatch_cp_targets` uses for lookup. Without this, a user
1639 /// who wrote `INP="ALIAS_NAME CP"` in their .db file would
1640 /// register the CP edge under the alias key and then never see
1641 /// the target processed (the source record's canonical-name
1642 /// dispatch would miss).
1643 /// `passive_only` is `true` for a CPP edge (process the target only when
1644 /// its `SCAN` is Passive) and `false` for CP (always process). When the
1645 /// same source→target edge is registered from both a CP and a CPP link,
1646 /// CP dominates: the merged edge keeps `passive_only == false`, matching
1647 /// C, where an unconditional CP `CA_DBPROCESS` overrides any CPP gate on
1648 /// the same record.
1649 pub async fn register_cp_link(
1650 &self,
1651 source_record: &str,
1652 target_record: &str,
1653 passive_only: bool,
1654 ) {
1655 let source = self
1656 .resolve_alias(source_record)
1657 .await
1658 .unwrap_or_else(|| source_record.to_string());
1659 let target = self
1660 .resolve_alias(target_record)
1661 .await
1662 .unwrap_or_else(|| target_record.to_string());
1663 let mut cp = self.inner.cp_links.write().await;
1664 let targets = cp.entry(source).or_default();
1665 if let Some(existing) = targets.iter_mut().find(|t| t.record == target) {
1666 existing.passive_only = existing.passive_only && passive_only;
1667 } else {
1668 targets.push(super::CpTarget {
1669 record: target,
1670 passive_only,
1671 });
1672 }
1673 }
1674
1675 /// Get target edges to process when `source_record` changes (CP/CPP links).
1676 pub async fn get_cp_targets(&self, source_record: &str) -> Vec<super::CpTarget> {
1677 self.inner
1678 .cp_links
1679 .read()
1680 .await
1681 .get(source_record)
1682 .cloned()
1683 .unwrap_or_default()
1684 }
1685
1686 /// Register an EXTERNAL CP/CPP link: when the remote PV `external_pv`
1687 /// (a cross-IOC CA/PVA source, e.g. `OTHER:PV` from
1688 /// `INP="OTHER:PV CP CA"`) changes, process `target_record`.
1689 ///
1690 /// Twin of [`Self::register_cp_link`] for cross-IOC sources. The key is
1691 /// the **scheme-stripped external PV name** — it is not a local record,
1692 /// so it is NOT alias-resolved. It MUST equal the name the calink/pvalink
1693 /// monitor dispatches under: that monitor is opened through the lset,
1694 /// which strips the `ca://` / `pva://` scheme first, so its `pv_name`
1695 /// (passed to [`Self::dispatch_external_cp_targets`]) is the bare PV.
1696 /// Stripping the same schemes here — the set [`Self::resolve_external_pv`]
1697 /// already knows — guarantees the registry key and the dispatch key can
1698 /// never diverge. The `target_record` (the local holder) IS alias-resolved
1699 /// so the dispatch processes the canonical record. CP dominates CPP on a
1700 /// merged edge, identical to the local path.
1701 pub async fn register_external_cp_link(
1702 &self,
1703 external_pv: &str,
1704 target_record: &str,
1705 passive_only: bool,
1706 ) {
1707 let key = external_pv
1708 .strip_prefix("ca://")
1709 .or_else(|| external_pv.strip_prefix("pva://"))
1710 .unwrap_or(external_pv);
1711 let target = self
1712 .resolve_alias(target_record)
1713 .await
1714 .unwrap_or_else(|| target_record.to_string());
1715 let mut cp = self.inner.external_cp_links.write().await;
1716 let targets = cp.entry(key.to_string()).or_default();
1717 if let Some(existing) = targets.iter_mut().find(|t| t.record == target) {
1718 existing.passive_only = existing.passive_only && passive_only;
1719 } else {
1720 targets.push(super::CpTarget {
1721 record: target,
1722 passive_only,
1723 });
1724 }
1725 }
1726
1727 /// Get holder edges to process when the remote PV `external_pv` changes
1728 /// (external CA/PVA CP/CPP links).
1729 pub async fn get_external_cp_targets(&self, external_pv: &str) -> Vec<super::CpTarget> {
1730 self.inner
1731 .external_cp_links
1732 .read()
1733 .await
1734 .get(external_pv)
1735 .cloned()
1736 .unwrap_or_default()
1737 }
1738
1739 /// Every external PV name that has at least one CP/CPP holder edge.
1740 /// The calink/pvalink integration warms (opens a monitor for) each of
1741 /// these at iocInit so the remote source has a live subscription whose
1742 /// callback can drive [`Self::dispatch_external_cp_targets`] — a Passive
1743 /// CP holder is otherwise never read and its monitor never opens.
1744 pub async fn external_cp_pv_names(&self) -> Vec<String> {
1745 self.inner
1746 .external_cp_links
1747 .read()
1748 .await
1749 .keys()
1750 .cloned()
1751 .collect()
1752 }
1753
1754 /// Classify one parsed CP/CPP link, deciding the local-vs-external
1755 /// routing — this method is the single owner of that decision, so the
1756 /// CP trigger registry and the holder's value-read parse cache stay
1757 /// consistent.
1758 ///
1759 /// A link with no CP/CPP policy (`cp_passive_only() == None`), and
1760 /// every non-`Db`/`Ca` variant, is ignored.
1761 ///
1762 /// `Ca`: an external `ca://OTHER CP` / `OTHER CP CA` holder always
1763 /// drives the cross-IOC path — C `dbCa.c` `eventCallback` adds
1764 /// `CA_DBPROCESS` for every CP link (`dbCa.c:993-994`).
1765 ///
1766 /// `Db`: C `dbInitLink` (`dbLink.c:118-130`) makes any link carrying a
1767 /// `CP`/`CPP`/`CA` option a CA link, and `dbDbInitLink` keeps it local
1768 /// only when the named record exists in this IOC. So a CP/CPP `Db`
1769 /// link whose target is NOT a local record (e.g. `INP="other:pv CP"`,
1770 /// no explicit `CA`) must be served externally — both the calink
1771 /// trigger monitor (`ext_links`) AND the holder's value read. The read
1772 /// half is the `convert_to_ca` entry: it carries the field name and an
1773 /// equivalent [`CaLink`] so the caller rewrites the holder's cached
1774 /// parse from `Db` to `Ca`, routing the read through the external
1775 /// resolver. A local target keeps the local fast-path (`db_links`).
1776 async fn classify_cp_link(
1777 &self,
1778 field: &str,
1779 parsed: crate::server::record::ParsedLink,
1780 target_name: &str,
1781 db_links: &mut Vec<(String, String, bool)>,
1782 ext_links: &mut Vec<(String, String, bool)>,
1783 convert_to_ca: &mut Vec<(String, crate::server::record::CaLink)>,
1784 ) {
1785 match parsed {
1786 crate::server::record::ParsedLink::Db(db) => {
1787 let Some(passive_only) = db.policy.cp_passive_only() else {
1788 return;
1789 };
1790 if self.has_name_no_resolve(&db.record).await {
1791 db_links.push((db.record, target_name.to_string(), passive_only));
1792 } else {
1793 // Reconstruct the CA channel name verbatim: `record`
1794 // for a default-`VAL` link, `record.FIELD` otherwise —
1795 // the same string the `CA`-modifier path would store
1796 // in `CaLink::pv`.
1797 let pv = if db.field == "VAL" {
1798 db.record.clone()
1799 } else {
1800 format!("{}.{}", db.record, db.field)
1801 };
1802 ext_links.push((pv.clone(), target_name.to_string(), passive_only));
1803 convert_to_ca.push((
1804 field.to_string(),
1805 crate::server::record::CaLink {
1806 pv,
1807 monitor_switch: db.monitor_switch,
1808 policy: db.policy,
1809 },
1810 ));
1811 }
1812 }
1813 crate::server::record::ParsedLink::Ca(ca) => {
1814 if let Some(passive_only) = ca.policy.cp_passive_only() {
1815 ext_links.push((ca.pv, target_name.to_string(), passive_only));
1816 }
1817 }
1818 _ => {}
1819 }
1820 }
1821
1822 /// Scan all records for CP/CPP input links and register them. Local
1823 /// `Db` links land in the local CP registry; external `Ca` links land
1824 /// in the external CP registry, whose holders are processed by the
1825 /// calink monitor callback.
1826 pub async fn setup_cp_links(&self) {
1827 let names = self.all_record_names().await;
1828 let mut db_links: Vec<(String, String, bool)> = Vec::new();
1829 let mut ext_links: Vec<(String, String, bool)> = Vec::new();
1830
1831 for target_name in &names {
1832 // Enumerate this record's link-bearing fields through the
1833 // single shared owner (`record_link_fields`) so the CA CP/CPP
1834 // setup here and the pvalink install scan can never diverge on
1835 // which fields count as links. `classify_cp_link` keeps only
1836 // the CP/CPP-policy links, routes local `Db` vs external `Ca`,
1837 // and — for a CP/CPP `Db` link to a non-local target — emits a
1838 // `convert_to_ca` entry so the holder's value read is rewired
1839 // to the external resolver (C `dbLink.c:118-130`).
1840 let mut convert_to_ca: Vec<(String, crate::server::record::CaLink)> = Vec::new();
1841 for (field, _raw, parsed) in self.record_link_fields(target_name).await {
1842 self.classify_cp_link(
1843 &field,
1844 parsed,
1845 target_name,
1846 &mut db_links,
1847 &mut ext_links,
1848 &mut convert_to_ca,
1849 )
1850 .await;
1851 }
1852 // Apply the `Db`→`Ca` parse-cache rewrite for non-local CP/CPP
1853 // links so the holder reads its value through the external
1854 // resolver — consistent with the external CP trigger registered
1855 // above (both halves use the same locality decision made in
1856 // `classify_cp_link`). Only the common-field caches are
1857 // rewritten; INPA.. / DOL.. links are re-parsed from their raw
1858 // strings each process cycle and so are not covered here.
1859 if !convert_to_ca.is_empty() {
1860 if let Some(rec_arc) = self.get_record(target_name).await {
1861 let mut inst = rec_arc.write().await;
1862 for (field, calink) in convert_to_ca {
1863 let link = crate::server::record::ParsedLink::Ca(calink);
1864 match field.as_str() {
1865 "INP" => inst.parsed_inp = link,
1866 "OUT" => inst.parsed_out = link,
1867 "TSEL" => inst.parsed_tsel = link,
1868 "SDIS" => inst.parsed_sdis = link,
1869 _ => {}
1870 }
1871 }
1872 }
1873 }
1874 }
1875
1876 let db_count = db_links.len();
1877 for (source, target, passive_only) in db_links {
1878 self.register_cp_link(&source, &target, passive_only).await;
1879 }
1880 let ext_count = ext_links.len();
1881 for (external_pv, target, passive_only) in ext_links {
1882 self.register_external_cp_link(&external_pv, &target, passive_only)
1883 .await;
1884 }
1885 if db_count > 0 {
1886 eprintln!("iocInit: {db_count} CP link subscriptions");
1887 }
1888 // Warm external CP/CPP links. A Passive holder of an external CP
1889 // link is never scanned, so its link never opens lazily and the
1890 // calink monitor that drives `dispatch_external_cp_targets` is never
1891 // created (chicken-and-egg). Open each external CP PV now so its
1892 // monitor is live at iocInit — the C `dbCa.c` "add the link at init"
1893 // analogue (`dbCaAddLink`). `resolve_external_pv` routes through the
1894 // registered lset's lazy-open path (the same path a record read
1895 // uses); a no-op when no matching lset is installed (calink off).
1896 if ext_count > 0 {
1897 let ext_pvs = self.external_cp_pv_names().await;
1898 for pv in &ext_pvs {
1899 let _ = self.resolve_external_pv(pv).await;
1900 }
1901 eprintln!(
1902 "iocInit: {ext_count} external CP link subscriptions ({} PVs warmed)",
1903 ext_pvs.len()
1904 );
1905 }
1906 }
1907}
1908
1909#[cfg(test)]
1910mod out_link_put_fail_tests {
1911 use super::{LinkAlarm, OutLinkSrc};
1912 use crate::server::database::PvDatabase;
1913 use crate::server::record::{AlarmSeverity, DbLink, LinkProcessPolicy, MonitorSwitch};
1914 use crate::server::records::calc::CalcRecord;
1915 use crate::types::EpicsValue;
1916 use std::collections::HashSet;
1917
1918 /// C `dbDbPutValue` (dbDbLink.c:382-390) runs `dbPut`, folds the
1919 /// source alarm via `recGblInheritSevrMsg`, then `if (status) return
1920 /// status;` — only a *successful* write reaches the `.PROC`/`PP`
1921 /// `processTarget` branch. A failed OUT-link write must therefore NOT
1922 /// process the target. Here the write is an empty array into a scalar
1923 /// field, which `put_pv_already_locked` rejects with `InvalidValue`
1924 /// (the C dbPut nRequest=0 empty-array guard, field_io.rs).
1925 ///
1926 /// Observable: a passive `calc` target with `CALC = "7"` evaluates to
1927 /// `VAL = 7` on process and stays at its `Default` `VAL = 0` when not
1928 /// processed. The PP OUT link below carries a value the write
1929 /// rejects, so the fixed code returns before `processTarget` and
1930 /// `VAL` stays `0.0`; the pre-fix code processed the target
1931 /// unconditionally and `VAL` would become `7.0`.
1932 #[tokio::test]
1933 async fn pp_out_link_failed_write_does_not_process_target() {
1934 let db = PvDatabase::new();
1935 db.add_record("TGT", Box::new(CalcRecord::new("7")))
1936 .await
1937 .unwrap();
1938
1939 // Precondition: CALC not yet evaluated, VAL at its Default 0.0.
1940 assert!(
1941 matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 0.0),
1942 "calc VAL must start at its Default 0.0 before any process"
1943 );
1944
1945 let link = DbLink {
1946 record: "TGT".to_string(),
1947 field: "VAL".to_string(),
1948 policy: LinkProcessPolicy::ProcessPassive, // ` PP` token
1949 monitor_switch: MonitorSwitch::NoMaximize,
1950 };
1951 let alarm = LinkAlarm {
1952 stat: 0,
1953 sevr: AlarmSeverity::NoAlarm,
1954 amsg: String::new(),
1955 };
1956 let src = OutLinkSrc {
1957 putf: false,
1958 notify: None,
1959 alarm: &alarm,
1960 };
1961 let mut visited = HashSet::new();
1962
1963 // Empty array into the scalar VAL field: put_pv_already_locked
1964 // returns Err(InvalidValue) per the field_io.rs empty-array guard.
1965 db.write_db_link_value(&link, EpicsValue::DoubleArray(vec![]), src, &mut visited, 0)
1966 .await;
1967
1968 // Fixed: the failed write short-circuits before processTarget,
1969 // so CALC was never evaluated and VAL is still its Default 0.0.
1970 // Pre-fix: the target processed and VAL would be 7.0.
1971 assert!(
1972 matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 0.0),
1973 "a failed OUT-link write must NOT process the PP target \
1974 (VAL must stay 0.0, not become 7.0)"
1975 );
1976 }
1977}
1978
1979#[cfg(test)]
1980mod nonlocal_db_link_write_tests {
1981 use super::OutLinkSrc;
1982 use crate::server::database::{LinkPutOp, LinkSet, PvDatabase};
1983 use crate::server::record::{AlarmSeverity, DbLink, LinkProcessPolicy, MonitorSwitch};
1984 use crate::server::records::calc::CalcRecord;
1985 use crate::types::EpicsValue;
1986 use std::collections::HashSet;
1987 use std::sync::{Arc, Mutex};
1988
1989 /// A link set that records every `put_value` it receives so a test
1990 /// can assert a non-local OUT-link write reached the external (CA)
1991 /// put path instead of being dropped by a local `dbPut`.
1992 struct RecordingLset {
1993 puts: Arc<Mutex<Vec<(String, EpicsValue)>>>,
1994 }
1995 impl LinkSet for RecordingLset {
1996 fn is_connected(&self, _: &str) -> bool {
1997 true
1998 }
1999 fn get_value(&self, _: &str) -> Option<EpicsValue> {
2000 None
2001 }
2002 fn put_value(&self, name: &str, value: EpicsValue, _op: LinkPutOp) -> Result<(), String> {
2003 self.puts.lock().unwrap().push((name.to_string(), value));
2004 Ok(())
2005 }
2006 }
2007
2008 fn out_src(alarm: &super::LinkAlarm) -> OutLinkSrc<'_> {
2009 OutLinkSrc {
2010 putf: false,
2011 notify: None,
2012 alarm,
2013 }
2014 }
2015
2016 fn no_alarm() -> super::LinkAlarm {
2017 super::LinkAlarm {
2018 stat: 0,
2019 sevr: AlarmSeverity::NoAlarm,
2020 amsg: String::new(),
2021 }
2022 }
2023
2024 /// A plain OUT link whose target record is NOT local must write
2025 /// through the external put path — C `dbPutLink` routes a non-local
2026 /// (CA) link's write to `dbCaPutLink`, never a local `dbPut`. The
2027 /// pre-fix code called `put_pv_already_locked` on a name that does
2028 /// not exist locally; the write was silently dropped. The recording
2029 /// lset must now capture the value.
2030 #[tokio::test]
2031 async fn nonlocal_db_out_link_writes_through_external_put() {
2032 let db = PvDatabase::new();
2033 let puts = Arc::new(Mutex::new(Vec::new()));
2034 db.register_link_set("ca", Arc::new(RecordingLset { puts: puts.clone() }))
2035 .await;
2036
2037 // "OTHER:PV" is never added as a local record -> non-local.
2038 let link = DbLink {
2039 record: "OTHER:PV".to_string(),
2040 field: "VAL".to_string(),
2041 policy: LinkProcessPolicy::NoProcess,
2042 monitor_switch: MonitorSwitch::NoMaximize,
2043 };
2044 let alarm = no_alarm();
2045 let mut visited = HashSet::new();
2046 db.write_db_link_value(
2047 &link,
2048 EpicsValue::Double(42.0),
2049 out_src(&alarm),
2050 &mut visited,
2051 0,
2052 )
2053 .await;
2054
2055 let captured = puts.lock().unwrap();
2056 assert_eq!(
2057 captured.len(),
2058 1,
2059 "non-local OUT-link write must reach the external put path exactly once"
2060 );
2061 assert_eq!(captured[0].0, "OTHER:PV");
2062 assert!(matches!(captured[0].1, EpicsValue::Double(v) if v == 42.0));
2063 }
2064
2065 /// A local OUT-link write must NOT divert to the external put path —
2066 /// the locality dispatch only reroutes non-local targets. The local
2067 /// record receives the value; the lset records nothing.
2068 #[tokio::test]
2069 async fn local_db_out_link_writes_local_not_external() {
2070 let db = PvDatabase::new();
2071 let puts = Arc::new(Mutex::new(Vec::new()));
2072 db.register_link_set("ca", Arc::new(RecordingLset { puts: puts.clone() }))
2073 .await;
2074 db.add_record("TGT", Box::new(CalcRecord::new("0")))
2075 .await
2076 .unwrap();
2077
2078 let link = DbLink {
2079 record: "TGT".to_string(),
2080 field: "VAL".to_string(),
2081 policy: LinkProcessPolicy::NoProcess,
2082 monitor_switch: MonitorSwitch::NoMaximize,
2083 };
2084 let alarm = no_alarm();
2085 let mut visited = HashSet::new();
2086 db.write_db_link_value(
2087 &link,
2088 EpicsValue::Double(7.0),
2089 out_src(&alarm),
2090 &mut visited,
2091 0,
2092 )
2093 .await;
2094
2095 assert!(
2096 puts.lock().unwrap().is_empty(),
2097 "a local OUT-link write must not reach the external put path"
2098 );
2099 assert!(
2100 matches!(db.get_pv("TGT.VAL").await.unwrap(), EpicsValue::Double(v) if v == 7.0),
2101 "the local target must hold the written value"
2102 );
2103 }
2104
2105 /// A link set that records every `scan_forward` it receives so a test
2106 /// can assert a non-DB FLNK reached the external forward path
2107 /// (C `dbScanFwdLink` → `lset->scanForward`) instead of being dropped
2108 /// by the DB-only `flnk_name` filter. `connected=false` models a
2109 /// disconnected link (pvxs `pvaScanForward`'s `!valid()` gate).
2110 struct ForwardingLset {
2111 forwards: Arc<Mutex<Vec<String>>>,
2112 connected: bool,
2113 }
2114 impl LinkSet for ForwardingLset {
2115 fn is_connected(&self, _: &str) -> bool {
2116 self.connected
2117 }
2118 fn get_value(&self, _: &str) -> Option<EpicsValue> {
2119 None
2120 }
2121 fn scan_forward(&self, name: &str) -> Result<(), String> {
2122 self.forwards.lock().unwrap().push(name.to_string());
2123 if self.connected {
2124 Ok(())
2125 } else {
2126 Err("Disconn".into())
2127 }
2128 }
2129 }
2130
2131 /// `scan_forward_external_pv` must resolve the scheme and delegate to
2132 /// the registered lset's `scan_forward` — the FWD-link twin of
2133 /// `write_external_pv` for OUT writes (C `dbScanFwdLink` →
2134 /// `lset->scanForward`).
2135 #[tokio::test]
2136 async fn external_forward_link_dispatches_through_scan_forward() {
2137 let db = PvDatabase::new();
2138 let forwards = Arc::new(Mutex::new(Vec::new()));
2139 db.register_link_set(
2140 "pva",
2141 Arc::new(ForwardingLset {
2142 forwards: forwards.clone(),
2143 connected: true,
2144 }),
2145 )
2146 .await;
2147
2148 db.scan_forward_external_pv("pva://OTHER:PROC")
2149 .await
2150 .expect("a connected forward must succeed");
2151
2152 let captured = forwards.lock().unwrap();
2153 assert_eq!(captured.len(), 1);
2154 assert_eq!(captured[0], "OTHER:PROC");
2155 }
2156
2157 /// End-to-end: a record whose FLNK targets an external `pva://` PV
2158 /// must fire the link set's `scan_forward` when it processes — the
2159 /// non-DB FLNK dispatch the DB-only `flnk_name` filter previously
2160 /// dropped. C `recGblFwdLink` runs `dbScanFwdLink` for every FLNK
2161 /// regardless of link kind.
2162 #[tokio::test]
2163 async fn record_processing_fires_external_forward_link() {
2164 let db = PvDatabase::new();
2165 let forwards = Arc::new(Mutex::new(Vec::new()));
2166 db.register_link_set(
2167 "pva",
2168 Arc::new(ForwardingLset {
2169 forwards: forwards.clone(),
2170 connected: true,
2171 }),
2172 )
2173 .await;
2174 db.add_record("SRC", Box::new(CalcRecord::new("0")))
2175 .await
2176 .unwrap();
2177 if let Some(rec) = db.get_record("SRC").await {
2178 let mut inst = rec.write().await;
2179 inst.put_common_field("FLNK", EpicsValue::String("pva://TARGET".into()))
2180 .unwrap();
2181 }
2182
2183 let mut visited = HashSet::new();
2184 db.process_record_with_links("SRC", &mut visited, 0)
2185 .await
2186 .unwrap();
2187
2188 let captured = forwards.lock().unwrap();
2189 assert_eq!(
2190 captured.len(),
2191 1,
2192 "an external pva:// FLNK must fire scan_forward exactly once"
2193 );
2194 assert_eq!(captured[0], "TARGET");
2195 }
2196
2197 /// A disconnected non-retry external FLNK is NOT dropped silently: the
2198 /// lset returns Err and the owning record takes a *pending*
2199 /// LINK/INVALID alarm — pvxs `pvaScanForward`'s
2200 /// `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")`, which
2201 /// writes `nsta`/`nsev`/`namsg` (promoted by the next
2202 /// `recGblResetAlarms`, matching the C late-set inside `recGblFwdLink`).
2203 #[tokio::test]
2204 async fn disconnected_external_forward_link_raises_pending_link_invalid() {
2205 let db = PvDatabase::new();
2206 let forwards = Arc::new(Mutex::new(Vec::new()));
2207 db.register_link_set(
2208 "pva",
2209 Arc::new(ForwardingLset {
2210 forwards: forwards.clone(),
2211 connected: false,
2212 }),
2213 )
2214 .await;
2215 db.add_record("SRC", Box::new(CalcRecord::new("0")))
2216 .await
2217 .unwrap();
2218 if let Some(rec) = db.get_record("SRC").await {
2219 let mut inst = rec.write().await;
2220 inst.put_common_field("FLNK", EpicsValue::String("pva://TARGET".into()))
2221 .unwrap();
2222 }
2223
2224 let mut visited = HashSet::new();
2225 db.process_record_with_links("SRC", &mut visited, 0)
2226 .await
2227 .unwrap();
2228
2229 assert_eq!(
2230 forwards.lock().unwrap().len(),
2231 1,
2232 "scan_forward is still attempted on a disconnected link"
2233 );
2234 let rec = db.get_record("SRC").await.unwrap();
2235 let inst = rec.read().await;
2236 assert_eq!(inst.common.nsev, AlarmSeverity::Invalid);
2237 assert_eq!(
2238 inst.common.nsta,
2239 crate::server::recgbl::alarm_status::LINK_ALARM
2240 );
2241 assert_eq!(inst.common.namsg, "Disconn");
2242 }
2243}
2244
2245#[cfg(test)]
2246mod cp_link_locality_tests {
2247 use crate::server::database::PvDatabase;
2248 use crate::server::record::ParsedLink;
2249 use crate::server::records::ai::AiRecord;
2250
2251 /// A CP/CPP link with no explicit `CA` modifier whose target is NOT a
2252 /// local record must be served as an external (CA) link — both the
2253 /// trigger and the value read. C `dbInitLink` (`dbLink.c:118-130`)
2254 /// makes any `CP`/`CPP` link a CA link, and `dbDbInitLink` keeps it
2255 /// local only when the named record exists in this IOC.
2256 ///
2257 /// Here `INP="OTHER:PV CP"` parses to `ParsedLink::Db` (bare name, no
2258 /// `CA`), but `OTHER:PV` is not local. After `setup_cp_links` the
2259 /// holder's `parsed_inp` must be rewritten to `ParsedLink::Ca` (so the
2260 /// read routes through `resolve_external_pv`) and `OTHER:PV` must be
2261 /// registered as an external CP trigger.
2262 #[tokio::test]
2263 async fn cp_link_to_nonlocal_target_forced_external() {
2264 let db = PvDatabase::new();
2265 db.add_record("HOLDER", Box::new(AiRecord::new(0.0)))
2266 .await
2267 .unwrap();
2268 {
2269 let rec = db.get_record("HOLDER").await.unwrap();
2270 rec.write().await.common.inp = "OTHER:PV CP".to_string();
2271 }
2272
2273 db.setup_cp_links().await;
2274
2275 let inp = db
2276 .get_record("HOLDER")
2277 .await
2278 .unwrap()
2279 .read()
2280 .await
2281 .parsed_inp
2282 .clone();
2283 match inp {
2284 ParsedLink::Ca(ca) => assert_eq!(
2285 ca.pv, "OTHER:PV",
2286 "non-local CP link must carry the verbatim PV name"
2287 ),
2288 other => panic!("non-local CP link must be forced to Ca, got {other:?}"),
2289 }
2290 assert!(
2291 db.external_cp_pv_names()
2292 .await
2293 .contains(&"OTHER:PV".to_string()),
2294 "non-local CP link must be registered as an external CP trigger"
2295 );
2296 }
2297
2298 /// A CP link whose target IS a local record keeps the local fast-path:
2299 /// `setup_cp_links` must NOT rewrite its `parsed_inp` to `Ca` and must
2300 /// NOT register it as an external CP link.
2301 #[tokio::test]
2302 async fn cp_link_to_local_target_stays_db() {
2303 let db = PvDatabase::new();
2304 db.add_record("SRC", Box::new(AiRecord::new(1.0)))
2305 .await
2306 .unwrap();
2307 db.add_record("HOLDER", Box::new(AiRecord::new(0.0)))
2308 .await
2309 .unwrap();
2310 {
2311 let rec = db.get_record("HOLDER").await.unwrap();
2312 let mut inst = rec.write().await;
2313 inst.common.inp = "SRC CP".to_string();
2314 // Seed the parse cache as load would, so the assertion proves
2315 // setup_cp_links leaves a local CP link untouched.
2316 inst.parsed_inp = crate::server::record::parse_link_v2("SRC CP");
2317 }
2318
2319 db.setup_cp_links().await;
2320
2321 let inp = db
2322 .get_record("HOLDER")
2323 .await
2324 .unwrap()
2325 .read()
2326 .await
2327 .parsed_inp
2328 .clone();
2329 assert!(
2330 matches!(inp, ParsedLink::Db(_)),
2331 "local CP link must stay a Db link, got {inp:?}"
2332 );
2333 assert!(
2334 !db.external_cp_pv_names().await.contains(&"SRC".to_string()),
2335 "a local CP target must not be registered as an external CP link"
2336 );
2337 }
2338}
2339
2340#[cfg(test)]
2341mod nonlocal_db_link_read_tests {
2342 use crate::server::database::PvDatabase;
2343 use crate::server::record::{ParsedLink, parse_link_v2};
2344 use crate::types::EpicsValue;
2345 use std::collections::HashSet;
2346 use std::sync::Arc;
2347
2348 /// C `dbInitLink` (`dbLink.c:118-130`) makes a PV link whose target is
2349 /// not a local record a CA link — even with NO `CP`/`CPP`/`CA`
2350 /// modifier (`dbDbInitLink` fails to resolve it locally and falls
2351 /// through to `dbCaAddLinkCallbackOpt`). So a plain `INP="OTHER:PV"`
2352 /// to a non-local target must read the remote value, not return
2353 /// `None`. Pre-fix the `Db` read arm read only the local DB (`get_pv`)
2354 /// and dropped the non-local target.
2355 #[tokio::test]
2356 async fn plain_nonlocal_db_link_reads_via_external_resolver() {
2357 let db = PvDatabase::new();
2358 // Stand-in for the calink/pvalink lset: resolve OTHER:PV remotely.
2359 db.set_external_resolver(Arc::new(|name: &str| {
2360 if name == "OTHER:PV" {
2361 Some(EpicsValue::Double(42.0))
2362 } else {
2363 None
2364 }
2365 }))
2366 .await;
2367
2368 let link = parse_link_v2("OTHER:PV");
2369 assert!(
2370 matches!(link, ParsedLink::Db(_)),
2371 "a bare non-scheme name parses to a Db link, got {link:?}"
2372 );
2373
2374 let mut visited = HashSet::new();
2375 let v = db.read_link_value(&link, &mut visited, 0).await;
2376 assert_eq!(
2377 v,
2378 Some(EpicsValue::Double(42.0)),
2379 "a plain non-local Db link must resolve through the external resolver (C dbCaAddLink fallback)"
2380 );
2381 }
2382
2383 /// A multi-input-style link re-parsed from its raw string each cycle
2384 /// (calc `INPA`..`INPL`, `DOL`) routes its value read through
2385 /// `read_link_with_alarm`; the same locality rule must hold there, so
2386 /// a non-local target reads the remote value and surfaces no
2387 /// local-record alarm.
2388 #[tokio::test]
2389 async fn nonlocal_db_link_value_and_alarm_via_external() {
2390 let db = PvDatabase::new();
2391 db.set_external_resolver(Arc::new(|name: &str| {
2392 if name == "OTHER:PV" {
2393 Some(EpicsValue::Double(5.0))
2394 } else {
2395 None
2396 }
2397 }))
2398 .await;
2399
2400 let link = parse_link_v2("OTHER:PV");
2401 let (value, alarm) = db.read_link_with_alarm(&link).await;
2402 assert_eq!(
2403 value,
2404 Some(EpicsValue::Double(5.0)),
2405 "non-local Db link value must come from the external resolver"
2406 );
2407 // No lset is registered (only the legacy resolver), so the bare
2408 // external alarm path reports None — never a fabricated local
2409 // record alarm for a record that does not exist in this IOC.
2410 assert!(
2411 alarm.is_none(),
2412 "non-local Db link must not fabricate a local-record alarm, got {alarm:?}"
2413 );
2414 }
2415
2416 /// Owner path: a Db link whose target IS a local record still reads
2417 /// the local database and never consults the external resolver.
2418 #[tokio::test]
2419 async fn local_db_link_reads_local_not_external() {
2420 let db = PvDatabase::new();
2421 db.add_pv("SRC", EpicsValue::Double(7.0)).await.unwrap();
2422 // A resolver returning a DIFFERENT value proves the local read
2423 // wins and the external path is not taken for a local target.
2424 db.set_external_resolver(Arc::new(|_name: &str| Some(EpicsValue::Double(-1.0))))
2425 .await;
2426
2427 let link = parse_link_v2("SRC");
2428 let mut visited = HashSet::new();
2429 let v = db.read_link_value(&link, &mut visited, 0).await;
2430 assert_eq!(
2431 v,
2432 Some(EpicsValue::Double(7.0)),
2433 "a local Db link must read the local DB, not the external resolver"
2434 );
2435 }
2436}