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