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