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