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