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