epics_base_rs/server/record/record_instance.rs
1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::sync::Arc;
4use std::sync::Mutex as StdMutex;
5use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
6
7use crate::error::{CaError, CaResult};
8use crate::server::database::LinkBacking;
9use crate::server::event_queue::{EventReader, EventUser};
10use crate::server::pv::{MonitorEvent, Subscriber};
11use crate::server::recgbl::EventMask;
12use crate::server::snapshot::{
13 ControlInfo, DisplayInfo, EnumInfo, EnumStringForm, PropertySupport,
14};
15use crate::types::c_parse::Converted;
16use crate::types::{DbFieldType, EpicsValue, PvString, c_parse};
17
18use super::alarm::{AlarmLimit, AlarmSeverity, AnalogAlarmConfig};
19use super::common_fields::{BkptFlag, CommonFields};
20use super::link::{
21 ParsedLink, out_link_discards_cp, parse_forward_link_v2, parse_link_v2, parse_output_link_v2,
22};
23use super::menu_choices::MenuBound;
24use super::record_trait::{
25 AuxPostMask, CommonFieldPutResult, FieldDeclaration, FieldDesc, InputLinkRequest, LinkReadAs,
26 ProcessSnapshot, Record, RecordProcessResult, SubroutineFn,
27};
28use super::scan::{ScanType, SimModeScan};
29
30/// C `msstring[4]` (`dbStaticLib.c:61`) — the maximize-severity word
31/// `dbGetString` appends to a link and `dblsr` prints in its own column.
32pub(crate) fn monitor_switch_word(switch_: super::MonitorSwitch) -> &'static str {
33 use super::MonitorSwitch::*;
34 match switch_ {
35 NoMaximize => "NMS",
36 Maximize => "MS",
37 MaximizeIfInvalid => "MSI",
38 MaximizeStatus => "MSS",
39 }
40}
41
42/// C `dbGetString`'s three link branches (`dbStaticLib.c:1906-2050`) — how a
43/// link field READS, as opposed to how it is stored.
44///
45/// C keeps a link field as a parsed `struct link` in record memory and renders
46/// it on every read, so the modifiers a `.db` left out come back as their
47/// defaults: `L:B` in an `INP` reads `L:B NPP NMS`, and `L:B PP MS` in a
48/// `FLNK` reads `L:B`, because `DBF_FWDLINK` carries no process class and no
49/// severity switch. Measured against `softIoc` R7.0.10-146 over CA.
50///
51/// This port stores the text instead, so the rendering happens on the way out.
52/// It is applied once, in [`RecordInstance::resolve_field`], and never in a
53/// printer: the CA server, `dbgf`, `dbpf`'s read-back and `dbpr` all read
54/// through that funnel, and a rule kept in three printers is a rule the fourth
55/// reader does not get.
56///
57/// The target is the slice before the FIRST space rather than a name rebuilt
58/// from the parse, because that is what C stores: `dbParseLink` splits there
59/// and keeps the head verbatim in `pv_link.pvname`. `X.VAL` therefore prints
60/// as `X.VAL`, not as the `X` a round trip through `DbLink::channel_name`
61/// would produce.
62///
63/// Only PV/DB/CA links carry modifiers, so every other link type falls through
64/// to its own text — CONSTANT prints `constantStr` (`:1911-1917`), JSON_LINK
65/// the JSON (`:1927`). Hardware links are the exception that is not a
66/// fall-through: C stores them as numbers and re-renders them per bus
67/// (`:1953-2006`), so this funnel asks [`HwLink::render`](super::HwLink::render)
68/// rather than echoing the field text, and `#C0x10 S-2` reads back as
69/// `#C16 S-2 @`.
70///
71/// Rendering is idempotent under the parser — `L:B NPP NMS` parses to the link
72/// `L:B` does — so a consumer that re-parses what a reader saw gets the link
73/// the store holds.
74pub(crate) fn render_link_field(class: crate::types::DbfLinkClass, raw: &str) -> String {
75 use super::{LinkFieldType, LinkProcessPolicy};
76 use crate::types::DbfLinkClass;
77
78 let text = raw.trim();
79 let ftype = LinkFieldType::for_class(class);
80 // The parse already applied C's per-field-type modifier mask
81 // (`dbStaticLib.c:2380-2391`), so a `DBF_FWDLINK` reaching the arm below
82 // has had everything but `CA` cleared and cannot render a stale ` MS`.
83 let (policy, ms, ca_class) = match super::parse_link_field(text, ftype) {
84 ParsedLink::Db(link) => (link.policy, link.monitor_switch, false),
85 ParsedLink::Ca(link) => (link.policy, link.monitor_switch, true),
86 // The store holds the parsed bus numbers, so the text comes from
87 // them and from nowhere else.
88 ParsedLink::Hw(hw) => return hw.render(),
89 _ => return text.to_string(),
90 };
91 let target = text.split_once(' ').map_or(text, |(head, _)| head);
92
93 // A forward link prints its target and, alone among the modifiers, ` CA`
94 // (`dbStaticLib.c:2034-2044`): no process class and no maximize-severity
95 // switch, which is why C answers a bare `FLNK` with just the record name.
96 if matches!(class, DbfLinkClass::FwdLink) {
97 return if ca_class {
98 format!("{target} CA")
99 } else {
100 target.to_string()
101 };
102 }
103
104 // C's `ppind` chain (`:1938-1943`) tests `PP` before `CA`, so a `ca://`
105 // link that also asked for `PP` renders ` PP`; and a `CP`/`CPP` link that
106 // resolved to a CA channel still renders its own class, because C reads
107 // `pvlMask` and not the type the link ended up with.
108 let pp = if ca_class && policy == LinkProcessPolicy::NoProcess {
109 " CA"
110 } else {
111 match policy {
112 LinkProcessPolicy::NoProcess => " NPP",
113 LinkProcessPolicy::ProcessPassive => " PP",
114 LinkProcessPolicy::ChannelProcess => " CP",
115 LinkProcessPolicy::ChannelProcessPassive => " CPP",
116 }
117 };
118 format!("{target}{pp} {}", monitor_switch_word(ms))
119}
120
121/// Every client-visible `special(SPC_NOMOD)` field of `dbCommon.dbd:13-190`.
122///
123/// These are common fields — no record's `field_list` declares them — so the
124/// declaration names them here. The remaining `SPC_NOMOD` entries in
125/// `dbCommon.dbd` are `DBF_NOACCESS` ([`is_dbcommon_noaccess`]): they have
126/// no field API in this port at all.
127///
128/// TIME is `DBF_NOACCESS` in C, and so it is here — [`FieldDesc::unreadable`]
129/// refuses the read. It is still named here because `SPC_NOMOD` is a fact about
130/// the declaration, not about readability: C's `dbCommon.dbd` marks it
131/// `special(SPC_NOMOD)` and every write path must see that whether or not any
132/// read path ever succeeds.
133///
134/// [`FieldDesc::unreadable`]: super::FieldDesc::unreadable
135///
136/// Read only through [`RecordInstance::is_no_mod`].
137const DBCOMMON_NOMOD: &[&str] = &[
138 "NAME", "STAT", "SEVR", "AMSG", "NSTA", "NSEV", "NAMSG", "ACKS", "ACKT", "LCNT", "PACT",
139 "PUTF", "RPRO", "TIME", "UTAG",
140];
141
142/// Is `field` (already uppercased) a `dbCommon` `DBF_NOACCESS` internal —
143/// a name C resolves but never serves?
144///
145/// These are C-internal pointers with no value API in this port. Their NAMES
146/// still exist to C's resolver: `dbNameToAddr` resolves a `DBF_NOACCESS`
147/// field and the refusal lands at channel *creation*, where `mapDBFToDBR`
148/// yields `DBR_NOACCESS` — measured against `softIocPVX`:
149/// `pvxget ORACLE:AI.MLOK` → `Refused to create Channel`, i.e. the SEARCH
150/// was answered. The search gate (`PvDatabase::has_name_no_resolve`)
151/// consults this — via [`RecordInstance::resolves_noaccess_name`] — so those
152/// names keep answering; every *value* path stays closed to them.
153///
154/// The name list is the generated spec
155/// ([`DB_COMMON_NOACCESS`](super::dbd_generated::DB_COMMON_NOACCESS)) minus
156/// [`DBCOMMON_NOMOD`], and it holds only the rows the generator could state no
157/// width for. `BKPT` and `TIME` are NOT in it: their `extra(...)` names a plain
158/// scalar, so the generator carries the whole descriptor and the search gate is
159/// answered by its `field_desc` arm instead. Both arms answer the SEARCH; which
160/// one does is a property of the declaration, not of this function.
161pub(crate) fn is_dbcommon_noaccess(field: &str) -> bool {
162 super::dbd_generated::DB_COMMON_NOACCESS.contains(&field) && !DBCOMMON_NOMOD.contains(&field)
163}
164
165thread_local! {
166 /// The origin tag applied to every event posted from the current
167 /// thread's synchronous put+process cascade when the poster itself
168 /// passes origin 0. Set only by [`AmbientWriteOriginScope`], read only
169 /// by [`RecordInstance::notify_field_with_origin`]. An in-process
170 /// writer (a ported SNL state machine) uses this so the whole
171 /// synchronous consequence of its put — the direct field post AND the
172 /// process-cycle posts, FLNK cascade included — carries its origin and
173 /// is filtered from its own subscriptions, while posts from work the
174 /// cascade merely *spawned* (a motor poller on another task) stay
175 /// untagged and visible to it.
176 static AMBIENT_WRITE_ORIGIN: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
177}
178
179/// RAII scope for `AMBIENT_WRITE_ORIGIN`. Sound only around code with no
180/// `.await` inside: the tag is thread-local, so crossing an await point
181/// would both leak it to interleaved tasks and lose it on work-stealing.
182/// The put paths that use it (`put_record_field_from_ca_no_notify_with_origin`)
183/// wrap a fully synchronous body.
184pub struct AmbientWriteOriginScope {
185 prev: u64,
186}
187
188/// Enter an ambient-origin scope; the previous value is restored on drop
189/// (scopes nest).
190pub fn ambient_write_origin_scope(origin: u64) -> AmbientWriteOriginScope {
191 let prev = AMBIENT_WRITE_ORIGIN.with(|c| c.replace(origin));
192 AmbientWriteOriginScope { prev }
193}
194
195impl Drop for AmbientWriteOriginScope {
196 fn drop(&mut self) {
197 AMBIENT_WRITE_ORIGIN.with(|c| c.set(self.prev));
198 }
199}
200
201/// The current thread's ambient write origin (0 outside any scope).
202/// `pub(crate)` so the simple-PV posting funnel
203/// (`ProcessVariable::deliver`) applies the same inheritance rule as the
204/// two record funnels in this file.
205pub(crate) fn ambient_write_origin() -> u64 {
206 AMBIENT_WRITE_ORIGIN.with(|c| c.get())
207}
208
209/// Put-notify completion wait-set — the C `dbNotify.c` `processNotify`
210/// waitList analogue (`dbNotifyAdd` / `dbNotifyCompletion`).
211///
212/// A `ca_put_callback` / WRITE_NOTIFY completion must fire only after the
213/// originating (put-target) record AND every record reached through its
214/// FLNK / OUT / process-action dispatch chain (synchronous *or* async)
215/// has finished processing. A single wait-set owns the completion
216/// oneshot; only it fires, and only when the last chain member leaves.
217///
218/// Counting convention: [`Self::new`] arms `pending = 1` for the
219/// originating record (which always joins). Every additional PP target
220/// that will process under the active notify [`Self::enter`]s on join
221/// (C `dbNotifyAdd`), and every record [`Self::leave`]s when its
222/// processing completes (C `dbNotifyCompletion`). The oneshot fires on
223/// the `leave` that drops `pending` to zero.
224///
225/// Membership is BOTH counted and named, because the two questions have
226/// different answers here and C only ever has to ask one of them. C keeps
227/// no count at all — `ellCount(&pnotifyPvt->waitList)` (`dbNotify.c:460`)
228/// IS its count, so its list answers "has the chain settled?" and "which
229/// records must a cancel release?" at once. The port cannot merge them:
230/// `pending` also carries contributions that own no record slot (the
231/// initiator's own hold, and a `dbCaPutLinkCallback` awaiting its network
232/// completion — `links.rs`), which C models as `state` rather than as list
233/// entries. So `pending` answers settlement and the `joined` list answers
234/// cancellation, each with one meaning on every path.
235pub struct NotifyWaitSet {
236 pending: AtomicUsize,
237 /// C `notifyPvt::waitList` (`dbNotify.c:73`) — every record that took
238 /// this set into its `notify` slot, so a cancel can sweep them the way
239 /// `dbNotifyCancel` walks the wait list (`:428-430`).
240 ///
241 /// Append-only, and deliberately so: C deletes a member on completion
242 /// only because `ellCount` is its settlement count, which `pending`
243 /// already is here. Keeping a completed member listed costs a slot
244 /// re-check and cannot lose one, whereas removing on completion would
245 /// make the list the second thing that has to be right for a cancel to
246 /// reach a record.
247 ///
248 /// Names, not handles: the sweep needs the record's write lock anyway,
249 /// and the authority on membership is the record's own slot — a stale
250 /// name simply fails the `Arc::ptr_eq` and is skipped. That makes
251 /// over-listing harmless and under-listing the only failure, which is
252 /// what the append-only rule then makes unrepresentable.
253 ///
254 /// Written in exactly one place, [`RecordInstance::take_notify_slot`],
255 /// which is also the only writer of the slot itself — so "every record
256 /// holding this set is named here" holds by construction.
257 joined: StdMutex<Vec<Box<str>>>,
258 tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
259 /// C `dbChannelRecord(ppn->chan)` — the record the notify was ISSUED
260 /// against, as opposed to the records that joined its chain.
261 ///
262 /// `None` for a set that is not a `processNotify` at all: the
263 /// completion accounting [`PvDatabase::new_put_notify`] arms for a
264 /// downstream link put has no `chan`, so C has no such record and
265 /// `dbNotifyDump` has no block to print for it.
266 ///
267 /// Set at construction and never afterwards. The only mint of an
268 /// entry-bearing set is [`RecordInstance::install_or_queue_notify`],
269 /// which passes its own record, and the other path into the slot
270 /// ([`RecordInstance::join_put_notify`]) clones a set it did not make —
271 /// so "the entry names the record whose slot minted it" holds by
272 /// construction rather than by a check.
273 ///
274 /// It is NOT the membership test. Every member, entry or joined, is named
275 /// in [`Self::joined`]; this only says which member `dbNotifyDump` prints
276 /// a block for (`dbNotify.c:659-660`).
277 ///
278 /// [`PvDatabase::new_put_notify`]: crate::server::database::PvDatabase::new_put_notify
279 entry: Option<Box<str>>,
280}
281
282impl NotifyWaitSet {
283 /// Arm a wait-set whose `tx` fires when the chain settles. `pending`
284 /// starts at 1 for the originating record — its completion `leave`s
285 /// that implicit slot, so a put with no chain targets fires
286 /// immediately on the originating record's own completion.
287 ///
288 /// No entry record: this is the chain-internal set, C's `dbNotifyAdd`
289 /// bookkeeping without a `processNotify` of its own.
290 /// `Self::for_entry_record` is the `dbProcessNotify` arm.
291 pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
292 Arc::new(Self {
293 pending: AtomicUsize::new(1),
294 joined: StdMutex::new(Vec::new()),
295 tx: StdMutex::new(Some(tx)),
296 entry: None,
297 })
298 }
299
300 /// C `dbProcessNotify` (`dbNotify.c:196-270`): the put named `record`, so
301 /// `dbChannelRecord(ppn->chan)` is `record` and that is the one record
302 /// `dbNotifyDump` prints a block for.
303 fn for_entry_record(record: &str, tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
304 Arc::new(Self {
305 pending: AtomicUsize::new(1),
306 joined: StdMutex::new(Vec::new()),
307 tx: StdMutex::new(Some(tx)),
308 entry: Some(record.into()),
309 })
310 }
311
312 /// The record this notify was issued against, or `None` for a set with
313 /// no `processNotify` behind it. See [`Self::entry`].
314 pub(crate) fn entry_record(&self) -> Option<&str> {
315 self.entry.as_deref()
316 }
317
318 /// A PP target joined the chain (C `dbNotifyAdd`). Balanced by exactly
319 /// one [`Self::leave`].
320 pub fn enter(&self) {
321 self.pending.fetch_add(1, Ordering::AcqRel);
322 }
323
324 /// A record finished its contribution (C `dbNotifyCompletion`). Fires
325 /// the completion oneshot on the `leave` that empties the set.
326 pub fn leave(&self) {
327 let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
328 debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
329 if prev == 1 {
330 if let Some(tx) = self.tx.lock().unwrap().take() {
331 let _ = tx.send(());
332 }
333 }
334 }
335
336 /// True once every chain member has left (the completion has fired).
337 /// Used by the put entry to decide synchronous ([`ProcessCompletion::Sync`])
338 /// vs async-pending ([`ProcessCompletion::Async`]) completion.
339 pub fn completed(&self) -> bool {
340 self.pending.load(Ordering::Acquire) == 0
341 }
342
343 /// Record `name` took this set into its `notify` slot — C
344 /// `ellSafeAdd(&pnotifyPvt->waitList, &precord->ppnr->waitNode)`
345 /// (`dbNotify.c:227`/`:258`/`:498`).
346 ///
347 /// Private to this module and called from the one slot writer
348 /// ([`RecordInstance::take_notify_slot`]), so joining the list and taking
349 /// the slot are one act and cannot be done separately.
350 fn record_joined(&self, name: &str) {
351 self.joined.lock().unwrap().push(name.into());
352 }
353
354 /// Every record that has held this set — C's wait list as
355 /// `dbNotifyCancel` enumerates it (`dbNotify.c:428`).
356 ///
357 /// A snapshot, because the sweep must take record write locks and cannot
358 /// hold this mutex while it does. Growth after the snapshot is not a
359 /// missed member: a record can only join a set that is still answerable,
360 /// and this is read only of a set that is not.
361 pub(crate) fn joined_records(&self) -> Vec<Box<str>> {
362 self.joined.lock().unwrap().clone()
363 }
364
365 /// Nobody is left to answer: the completion never fired (the sender is
366 /// still here) and the client that would have received it is gone.
367 ///
368 /// This is the condition C detects at client teardown —
369 /// `rsrvFreePutNotify` sees `pNotify->busy` and calls `dbNotifyCancel`
370 /// (`camessage.c:1630-1638`). A set that already fired holds no sender and
371 /// is NOT unanswerable: it completed.
372 pub(crate) fn is_unanswerable(&self) -> bool {
373 self.tx
374 .lock()
375 .unwrap()
376 .as_ref()
377 .is_some_and(|tx| tx.is_closed())
378 }
379}
380
381/// The completion outcome of an externally-initiated record process cycle —
382/// the value a caller learns after driving the synchronous head of a
383/// `dbPutNotify` / CA `WRITE_NOTIFY`.
384///
385/// This is the contract the **RTEMS CA driver** consumes: the CA thread drives
386/// the synchronous head of a put (C `dbProcessNotify`, `rsrv/camessage.c`
387/// `write_notify_action`) to completion — on RTEMS via `park_on` — then
388/// `match`es this value to decide whether to reply inline or return now and let
389/// background infrastructure deliver the completion later. The caller learns
390/// sync-vs-async as a typed value, not by inferring it from `Option::is_some`.
391///
392/// # C parity (`dbNotify.c`)
393///
394/// The C `processNotify` state machine forks a put-notify exactly here:
395///
396/// * **[`Self::Sync`]** — the record was neither active (`pact`) nor selected
397/// for processing, so `processNotifyCommon` runs `callDone`
398/// (`dbNotify.c:270`), which fires `doneCallback` INLINE on the calling
399/// thread (`dbNotify.c:182`). Our fully-synchronous chain drains the
400/// [`NotifyWaitSet`] before the put entry returns.
401/// * **[`Self::Async`]** — the record was `pact` (`notifyRestartInProgress`,
402/// `dbNotify.c:225-231`) or processed into an async device
403/// (`notifyProcessInProgress`, `dbNotify.c:252-263`). Completion is deferred
404/// to `dbNotifyCompletion` (`dbNotify.c:445-475`), which fires the user
405/// callback via `callbackRequest` (`:466`/`:470`) when the tracked waitList
406/// empties. Our [`NotifyWaitSet::leave`]-to-zero fires the `handle` oneshot
407/// at that same moment.
408///
409/// # Invariant (by construction)
410///
411/// Exactly one of {`Sync` returned, the `Async` handle fires exactly once} per
412/// initiated cycle. The single owner of the fire is [`NotifyWaitSet`]: its
413/// `leave`-to-zero `take`s the oneshot sender and sends once, so the handle can
414/// never fire twice; and `Sync` is returned only when the wait-set already
415/// drained, so no handle is outstanding to fire. There is no parallel
416/// signalling path — the oneshot is the sole completion channel.
417#[derive(Debug)]
418pub enum ProcessCompletion {
419 /// The cycle settled within the calling thread — the caller replies inline.
420 Sync,
421 /// The cycle went async; `handle` fires exactly once when the tracked
422 /// FLNK/OUT chain settles (C `dbNotifyCompletion`).
423 Async(crate::runtime::sync::oneshot::Receiver<()>),
424}
425
426impl ProcessCompletion {
427 /// Build the outcome from the wait-set's internal signal. `None` — the
428 /// wait-set drained synchronously, or the completion receiver lives
429 /// elsewhere (a deferred-restart replay carries only the sender) — is
430 /// [`Self::Sync`]; `Some(rx)` is [`Self::Async`].
431 pub(crate) fn from_signal(rx: Option<crate::runtime::sync::oneshot::Receiver<()>>) -> Self {
432 match rx {
433 Some(rx) => Self::Async(rx),
434 None => Self::Sync,
435 }
436 }
437
438 /// The completion handle if this cycle went async, else `None`. The CA
439 /// `WRITE_NOTIFY` dispatch uses this to choose inline reply (`None`) vs a
440 /// spawned completion task (`Some(rx)`).
441 pub fn into_handle(self) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
442 match self {
443 Self::Sync => None,
444 Self::Async(rx) => Some(rx),
445 }
446 }
447
448 /// True if the cycle went async (a completion handle is outstanding).
449 pub fn is_async(&self) -> bool {
450 matches!(self, Self::Async(_))
451 }
452
453 /// True if the cycle completed synchronously (no handle to await).
454 pub fn is_sync(&self) -> bool {
455 matches!(self, Self::Sync)
456 }
457}
458
459/// A put-notify (`dbPutNotify` — CA WRITE_NOTIFY, `caput -c`) that landed on a
460/// PACT record and was therefore deferred WHOLE.
461///
462/// C `processNotifyCommon` (dbNotify.c:225-231) tests `precord->pact` above
463/// `ppn->putCallback`, so nothing is written and nothing is marked: the record
464/// joins the notify's wait list in state `notifyRestartInProgress`, and when the
465/// async cycle completes the put is replayed against a record that is no longer
466/// active — value written, record processed, callback fired only after THAT
467/// process finishes. So a client's "callback returned" still means "the value I
468/// sent has been processed".
469///
470/// softIoc 7.0.10.1-DEV, `ASY` (calcout, `ODLY=4`, `A=5`), `caput -c ASY.A 7`
471/// issued 1 s into the async cycle:
472///
473/// ```text
474/// t=1s A=5 PACT=1 <- cycle in flight
475/// t=2s A=5 PACT=1 RPRO=0 <- put-notify pending: nothing written
476/// t=4s A=7 PACT=1 <- cycle done; the put is replayed
477/// callback returns at t=6.9s: A=7 VAL=7 <- after the RESTARTED process
478/// ```
479pub struct DeferredNotifyPut {
480 /// The field the client wrote (already upper-cased).
481 pub field: String,
482 /// The value it wrote — held here, unwritten, until the restart.
483 pub value: crate::types::EpicsValue,
484 /// The client's completion channel. The replayed put builds its wait-set
485 /// around this sender, so the callback fires on the restarted process, not
486 /// on the in-flight cycle.
487 pub completion: crate::runtime::sync::oneshot::Sender<()>,
488}
489
490/// One entry of C `precord->ppnr->restartList` — a whole `processNotify`
491/// waiting for the record, not just a put.
492///
493/// C queues the *request* (`ellSafeAdd(&restartList, &ppn->restartNode)`,
494/// dbNotify.c:217) and `restartCheck` re-enters `processNotifyCommon`, which
495/// dispatches on `ppn->requestType`. A queue that could hold only a
496/// field-and-value put left the other request type — C `processGetRequest`,
497/// the port's [`crate::server::database::PvDatabase::process_record_with_notify`]
498/// — with nowhere to wait, so its entry refused instead of queueing.
499pub enum DeferredNotify {
500 /// C `putProcessRequest` / `putProcessGetRequest`: write the field, then
501 /// process; the callback fires on the replayed cycle.
502 Put(DeferredNotifyPut),
503 /// C `processGetRequest`: process the record, write nothing.
504 Process {
505 /// The client's completion channel, armed on the replayed process.
506 completion: crate::runtime::sync::oneshot::Sender<()>,
507 },
508}
509
510/// The PACT→idle transition, as a value.
511///
512/// Carries one bit: whether the record owed a restart at the moment the token
513/// was minted. Queued put-notifies live on the record
514/// (`RecordInstance::notify_restart_list`) from arrival to replay and are
515/// promoted by one owner, `PvDatabase::apply_pact_exit` — so a release path
516/// that forgets its tail delays a restart, it cannot strand one inside a
517/// dropped value.
518///
519/// The bit is a HINT, not a second home for the queue. It is minted under a
520/// record lock the minting site already holds (every constructor is reached
521/// from `&mut self` or an explicit read), which is what lets
522/// `apply_pact_exit` take NO record lock at all — so it is safe to call from
523/// a `Drop`, where a still-live write guard in the same scope would otherwise
524/// deadlock parking_lot. A stale `true` costs one no-op drain: the drain
525/// re-reads the queue under the write lock via
526/// `RecordInstance::take_next_notify_restart` and returns if it is empty.
527///
528/// The token is `#[must_use]` because that tail is where the restart happens:
529/// C `recGbl.c:295` (`if (pdbc->ppn) dbNotifyCompletion(pdbc)`) →
530/// `dbNotifyCompletion` → `restartCheck` (`dbNotify.c:149-170`). Holding it to
531/// the tail rather than promoting at the `pact = FALSE` store is what keeps the
532/// replay behind the rest of the cycle, exactly as C's queued callback is.
533#[must_use = "a PACT release must reach PvDatabase::apply_pact_exit, which is \
534 where a queued put-notify is restarted"]
535pub struct PactExit {
536 restart_pending: bool,
537}
538
539impl PactExit {
540 /// Mint a token from the record's queue state, read under the caller's
541 /// lock.
542 pub(crate) fn new(restart_pending: bool) -> PactExit {
543 PactExit { restart_pending }
544 }
545
546 /// Fold two releases of the same cycle into one token.
547 ///
548 /// A simulated SDLY continuation releases PACT inside `check_simulation_mode`
549 /// and again at the `is_continuation` arm; one restart check covers both, so
550 /// either half owing a restart makes the folded token owe one.
551 pub(crate) fn merge(self, other: PactExit) -> PactExit {
552 PactExit {
553 restart_pending: self.restart_pending || other.restart_pending,
554 }
555 }
556
557 /// Whether the minting site saw a queued notify. See the type docs: this
558 /// is a hint the drain re-validates, never the queue itself.
559 pub(crate) fn restart_pending(&self) -> bool {
560 self.restart_pending
561 }
562}
563
564/// Cached metadata for a record.
565///
566/// Stores the result of `populate_display_info` / `populate_control_info` /
567/// `populate_enum_info` so subsequent `snapshot_for_field` /
568/// `make_monitor_snapshot` calls can skip rebuilding the metadata. The
569/// cache is invalidated whenever a metadata-class field is written
570/// (EGU, PREC, HOPR, LOPR, alarm limits, DRVH/DRVL, state strings).
571///
572/// In a CA-only IOC this is a CPU win; in a hybrid CA + PVA IOC where
573/// every snapshot needs full metadata for NTScalar serialization, the
574/// cache eliminates redundant per-event populate work.
575#[derive(Clone, Default)]
576pub(crate) struct MetadataSnapshot {
577 pub display: Option<DisplayInfo>,
578 pub control: Option<ControlInfo>,
579 pub enums: Option<EnumInfo>,
580 /// `(hihi, high, low, lolo)` from [`RecordInstance::explicit_alarm_limits`]
581 /// — the record's OWN bands, which C `get_alarm_double` reads as struct
582 /// members (`calcRecord.c:205-221`). Record-level, not per-field: the
583 /// function ignores the field and answers from `rtype` alone, so which
584 /// FIELDS receive these is still [`RecordInstance::route_field_metadata`]'s
585 /// decision — only the eight `resolve_field` name lookups behind the
586 /// numbers move here, where an invalidation rather than a snapshot pays
587 /// for them.
588 pub alarm: (f64, f64, f64, f64),
589}
590
591/// Does a write to this field make [`RecordInstance::metadata_cache`] stale?
592///
593/// **Cache bookkeeping only** — NOT the `DBE_PROPERTY` gate, which is the
594/// field's own `prop(YES)` declaration ([`RecordInstance::field_posts_property`]).
595/// The two used to be one hand-written list, so every field this port had to
596/// invalidate on became a property event C does not post, and every `prop(YES)`
597/// field nobody had listed posted nothing. They answer different questions and
598/// the sets genuinely differ in both directions: `busy.ZNAM` is a cache source
599/// that C does not mark `prop(YES)` (busy's `.dbd` declares no `prop` at all),
600/// and `histogram.ULIM` is `prop(YES)` yet feeds only the live-computed
601/// `apply_field_metadata_override`.
602///
603/// The rule: **every field read by `populate_display_info`,
604/// `populate_control_info`, or `populate_enum_info` MUST be in this set** —
605/// otherwise the cache serves stale metadata until some other source field is
606/// written. Field name is expected uppercase.
607///
608/// `DESC` feeds `display.description` but is deliberately absent: its
609/// invalidation is owned by the DESC arm of `put_common_field`, the single
610/// writer of `common.desc`. The `Q:form` info tag (`populate_display_info` ->
611/// `display.form`) is an immutable load-time tag, not a runtime field, so it
612/// needs no invalidation either.
613fn is_metadata_cache_source(name: &str) -> bool {
614 matches!(
615 name,
616 // `populate_display_info` — units/precision/display limits for the
617 // analog, integer, array and motor arms.
618 "EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
619 // `populate_control_info` — the ao/longout/int64out drive limits.
620 | "DRVH" | "DRVL"
621 // `explicit_alarm_limits` — the four bands and the four severities
622 // that gate them. C reads these as struct members on every
623 // `get_alarm_double`, so nothing there has to be invalidated; here
624 // they are cached, and a write to any of the eight is what makes the
625 // cached answer wrong.
626 | "HIHI" | "HIGH" | "LOW" | "LOLO"
627 | "HHSV" | "HSV" | "LSV" | "LLSV"
628 // `populate_enum_info` via `Record::enum_state_strings` — bi/bo/busy
629 // two-state names and the sixteen mbbi/mbbo state strings.
630 | "ZNAM" | "ONAM"
631 | "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
632 | "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
633 )
634}
635
636/// One alarm limit for a DBR_AL_DOUBLE response: the value when its
637/// severity threshold is enabled, `NaN` otherwise. Mirrors C
638/// `get_alarm_double`'s `prec->hhsv ? prec->hihi : epicsNAN` — a NONZERO
639/// test on the raw ordinal, so an out-of-range severity still enables the
640/// limit.
641fn gated(severity: i16, limit: f64) -> f64 {
642 if severity != 0 { limit } else { f64::NAN }
643}
644
645/// Extract the RAW stored ordinal a put lands in a `menu(menuAlarmSevr)`
646/// severity field (`HHSV`/`HSV`/`LSV`/`LLSV`/`UDFS`/`DISS`), WITHOUT clamping
647/// to the 0..=3 valid range.
648///
649/// C's numeric menu put stores whatever `(epicsEnum16)` the value truncates to
650/// (`dbConvert.c::putDoubleEnum` = `*pfield = (epicsEnum16)*psrc`), so
651/// `caput REC.HSV 4` keeps `4` and `caput REC.HSV -1` keeps `65535` — both
652/// wire-visible (served signed as `-1`) and both used verbatim to derive the
653/// alarm. The carrier is `i16` so the 16-bit pattern round-trips; the alarm
654/// meaning is read back with [`AlarmSeverity::from_u16`] and the C nonzero
655/// enable with `!= 0`.
656///
657/// A numeric value has already been wrapped to `epicsEnum16` upstream
658/// (`EpicsValue::convert_to(Enum)`, the one owner of C's double→enum cast); this
659/// only reinterprets its bit pattern. A `String` is a db-load / internal-link
660/// label (a client string put is rejected-or-resolved by `putStringMenu`
661/// upstream), resolved to its ordinal here.
662fn menu_ordinal_raw(value: &EpicsValue) -> i16 {
663 match value {
664 EpicsValue::String(s) => match s.as_str_lossy().as_ref() {
665 "NO_ALARM" => 0,
666 "MINOR" => 1,
667 "MAJOR" => 2,
668 "INVALID" => 3,
669 other => other
670 .parse::<i64>()
671 .ok()
672 .map(|n| n as u16 as i16)
673 .unwrap_or(0),
674 },
675 other => other.to_f64().unwrap_or(0.0) as i64 as u16 as i16,
676 }
677}
678
679/// Coerce a db-loaded `String` for a numeric/menu **common** field to that
680/// field's canonical DBF type before [`RecordInstance::put_common_field`]
681/// dispatches on it.
682///
683/// The db loader applies a record's own fields with the typed
684/// `EpicsValue::parse(desc.dbf_type, value_str)` (`db_loader::apply_fields`),
685/// but a field absent from `field_list` is pushed to the common-field path as
686/// a raw `EpicsValue::String` — it has no `FieldDesc` to parse against. The
687/// numeric common-field arms in `put_common_field` match only their typed
688/// variant, so without this step a `.db` `field(PHAS, "1")`,
689/// `field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`, `field(DISA, "1")`, … is
690/// silently dropped at IOC load. Routing the String through the same
691/// `EpicsValue::parse` the record-field path uses handles the numeric *and*
692/// menu-label forms uniformly, so the arm receives the value it expects.
693///
694/// Only fields whose canonical type is numeric/menu are listed; the
695/// Port of libcom `epicsParseInt32(str, &to, 10, NULL)`
696/// (`libcom/src/misc/epicsStdlib.c:26-53,245-261`), which is how pvxs parses
697/// the `nsec:lsb:` digit count. Returns `None` for every status the C
698/// returns non-zero for:
699///
700/// - `S_stdlib_noConversion` — `strtol` consumed nothing (empty / no digits)
701/// - `S_stdlib_extraneous` — trailing non-space bytes with `units == NULL`
702/// - `S_stdlib_overflow` — outside `epicsInt32`
703///
704/// Leading and trailing whitespace — C `isspace`, vertical tab included — and
705/// a leading `+`/`-` sign are accepted, matching `epicsParseLong`'s skips and
706/// `strtol`.
707fn epics_parse_int32_base10(s: &str) -> Option<i32> {
708 // `while ((c = *str) && isspace(c)) ++str;` then `strtol(str, &endp, 10)`.
709 let body = s.trim_start_matches(crate::runtime::stdlib::c_isspace);
710 let (sign, digits) = match body.strip_prefix(['+', '-']) {
711 Some(rest) if body.starts_with('-') => (-1i64, rest),
712 Some(rest) => (1i64, rest),
713 None => (1i64, body),
714 };
715 let end = digits
716 .find(|c: char| !c.is_ascii_digit())
717 .unwrap_or(digits.len());
718 if end == 0 {
719 return None; // endp == str → S_stdlib_noConversion
720 }
721 // `if (c && !units) return S_stdlib_extraneous;` after skipping trailing
722 // whitespace.
723 if !digits[end..]
724 .trim_start_matches(crate::runtime::stdlib::c_isspace)
725 .is_empty()
726 {
727 return None;
728 }
729 // ERANGE from `strtol`, then the explicit `epicsInt32` range check.
730 let magnitude: i64 = digits[..end].parse().ok()?;
731 i32::try_from(sign * magnitude).ok()
732}
733
734/// The STORED type of a `dbCommon` field — the variant its
735/// [`RecordInstance::put_common_field_bounded`] arm binds, which is not always
736/// the type the `.dbd` DECLARES it as (a `menu()` field is declared `DBF_MENU`
737/// and served `DBR_ENUM`, but held here as its bare index).
738///
739/// String-typed common fields (DESC, ASG, OUT, TSEL, …) have no entry: their
740/// arms take the string verbatim.
741fn stored_common_field_type(name: &str, declared: Option<DbFieldType>) -> Option<DbFieldType> {
742 Some(match name {
743 "SCAN" | "SSCN" | "PINI" => DbFieldType::Enum,
744 "TSE" | "PHAS" | "PRIO" | "DISV" | "DISA" | "DISS" | "LCNT" | "UDFS" | "ACKT" | "ACKS"
745 | "SEVR" | "STAT" | "NSEV" | "NSTA" => DbFieldType::Short,
746 // The analog-alarm limits and the hysteresis margin are the one row
747 // here whose stored type is the DECLARED type, and it varies by record:
748 // `DBF_DOUBLE` on ai/ao/calc/calcout/sub/scalcout, `DBF_LONG` on
749 // longin/longout, `DBF_INT64` on int64in/int64out
750 // (`int64inRecord.dbd.pod:152-208`). Naming `Double` for all of them
751 // discarded the record's own `.dbd` row on both writers — the `.db`
752 // string parse and a runtime `dbPut` — so an `epicsInt64` limit above
753 // 2^53 was rounded before it ever reached storage. Ask the record's
754 // generated field table instead; a caller with no table (a hand-built
755 // test record) keeps the `DBF_DOUBLE` majority.
756 //
757 // A field missing from this table entirely reaches its arm as whatever
758 // variant the caller built, and an arm that binds a typed variant then
759 // drops it: that is how `field(HYST,"2")` silently became 0 on every
760 // record whose hysteresis lives in `common.hyst`.
761 "HIHI" | "HIGH" | "LOW" | "LOLO" | "HYST" => declared.unwrap_or(DbFieldType::Double),
762 // The `DBF_UCHAR` flags. `bool` here, a NUMBER in C.
763 "DISP" | "UDF" | "TPRO" | "RPRO" | "BKPT" | "PROC" => DbFieldType::Char,
764 _ => return None,
765 })
766}
767
768/// **The single owner of "what type does a `dbCommon` field hold"**, run on
769/// EVERY put before the typed arms below see the value — so an arm may bind one
770/// variant and know the put cannot have arrived in another.
771///
772/// This is not a string-parsing convenience. A common field is reached by three
773/// writers with three different ideas of the value's shape: the db loader hands
774/// every field over as a raw `String`; a `dbPut` arrives coerced to the field's
775/// DECLARED type (`DBF_MENU` → `Enum` for PRIO, `DBF_UCHAR` → `Char` for DISP);
776/// an internal link delivers whatever its source stored. Before this ran on the
777/// non-`String` shapes too, each arm's single-variant `if let` was a silent
778/// drop for the other two writers — `caput REC.PRIO HIGH` resolved its label to
779/// `Enum(2)` and then vanished at the arm, leaving PRIO at 0.
780///
781/// An unparseable String is returned as-is so the arm drops it, and a menu
782/// field's bad label FAILS the put (`S_db_badChoice`) rather than landing as
783/// index 0.
784fn coerce_common_field(
785 name: &str,
786 value: EpicsValue,
787 bound: MenuBound,
788 declared: Option<DbFieldType>,
789) -> CaResult<Converted> {
790 let Some(dbf) = stored_common_field_type(name, declared) else {
791 return Ok(Converted::Stored(value));
792 };
793 let EpicsValue::String(s) = &value else {
794 // Already typed: project onto the stored type through the one
795 // value-coercion owner. `convert_to` short-circuits a value that is
796 // already `dbf`, so the common case costs nothing.
797 return Ok(Converted::Stored(value.convert_to(dbf)));
798 };
799 let text = s.as_str_lossy();
800 // A `DBF_MENU` common field resolves its label against THAT field's own
801 // menu through the one converter every menu-field string put uses
802 // (C `dbConvert.c::putStringMenu`: exact label, else an index below
803 // `nChoice`, else `S_db_badChoice`) — the same rule the record-specific
804 // menu fields follow in `coerce_write_value`. The failure PROPAGATES: the
805 // field-blind `EpicsValue::parse` fallback below must never see a menu
806 // field, or `caput REC.PRIO Bogus` lands as index 0 instead of failing.
807 //
808 // SCAN/SSCN/PINI are menu fields like any other and go through the same
809 // converter. They used to each carry a hand-written `from_str` that drifted
810 // from C: `ScanType::from_str` case-folded and invented `"0.5 second"`
811 // aliases for menuScan's `".5 second"` (and mapped any out-of-range index
812 // to Passive), `SimModeScan::from_str` took any u16, `PiniMode::from_str`
813 // trimmed. C has ONE converter and it does none of that.
814 if let Some(choices) = super::menu_choices::shared_menu_choices(name) {
815 return super::menu_choices::resolve_menu_field_string_bounded(
816 name, choices, dbf, &text, bound,
817 )
818 .map(Converted::Stored);
819 }
820 // Numeric (non-menu) common field: C's `dbPut` runs the string through the
821 // SAME `epicsParse*` (`dbConvert.c` `putString*`) the record data fields use,
822 // and a non-zero status REFUSES the whole put (`dbAccess.c:1362`, mapped to
823 // `ECA_PUTFAIL`). Route it through the single owner of that conversion —
824 // [`c_parse::put_string`] — instead of the field-blind `EpicsValue::parse`,
825 // which wrapped (`256 as u8 == 0`) and swallowed the error (`Err(_) =>
826 // Ok(value)`), so `caput REC.PROC 256` and `caput REC.PROC notanumber` were
827 // accepted where C rejects them.
828 //
829 // Key the parse on the field's C-DECLARED width, not its stored variant: the
830 // `DBF_UCHAR` flags (DISP/UDF/TPRO/RPRO/BKPT/PROC, `dbCommon.dbd`) are held in
831 // the signed `Char` variant here but C parses them with `epicsParseUInt8`, so
832 // `caput REC.PROC 255` and `caput REC.PROC -1` (→255) are accepted and only
833 // `256`+/non-numeric refused. `put_string` returns the value in the declared
834 // variant; project it back onto the stored variant through the one
835 // value-coercion owner (byte-identity for `UChar`→`Char`).
836 let declared = match dbf {
837 DbFieldType::Char => DbFieldType::UChar,
838 other => other,
839 };
840 let Some(target) = c_parse::NumericField::of(declared) else {
841 // Unreachable: every numeric `stored_common_field_type` (Char/Short/
842 // Double, all with a numeric row) reaches here; the `Enum` menu types
843 // returned above. Keep the pre-parse value rather than panic.
844 return Ok(Converted::Stored(value));
845 };
846 // Hand the string over UNTRIMMED. C tests `*from == 0` on the raw bytes
847 // (`dbFastLinkConv.c:147`), so `" "` is not the empty string to it and
848 // falls through to `epicsParse*`, which refuses it; trimming first turned
849 // it into the accepted empty case. Nothing else was riding on the trim —
850 // `scan_int`/`strtod` skip leading `isspace` themselves, and `epicsParse*`
851 // is called with a non-NULL `units` pointer, so trailing text is legal.
852 Ok(match c_parse::put_string(name, target, &text)? {
853 c_parse::Converted::Stored(parsed) => Converted::Stored(parsed.convert_to(dbf)),
854 c_parse::Converted::Unchanged => Converted::Unchanged,
855 })
856}
857
858/// The alarm-acknowledge request types C's `dbPut` dispatches on
859/// (`dbAccess.c:1331-1335`): `DBR_PUT_ACKT` and `DBR_PUT_ACKS`.
860///
861/// Acknowledgement is a *request type*, not a field write — the two handlers
862/// run above the `SPC_NOMOD` gate that refuses every ordinary put to ACKT/ACKS.
863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
864pub enum AlarmAck {
865 /// `DBR_PUT_ACKT` → `putAckt`: set transient-alarm acknowledgement.
866 Transient,
867 /// `DBR_PUT_ACKS` → `putAcks`: acknowledge an alarm of this severity.
868 Severity,
869}
870
871pub(crate) enum ForwardTarget {
872 /// Nothing to forward: the record vetoed its own FLNK this cycle
873 /// ([`Record::should_fire_forward_link`]), or `FLNK` is unset / constant /
874 /// hardware — every kind C's `dbScanFwdLink` walks past.
875 None,
876 /// A local database record, C `dbScanFwdLink` → `dbScanPassive` →
877 /// `processTarget`, with what that call carries: C `processTarget`
878 /// (dbDbLink.c:460-474) hands each target `psrc->putf` and `psrc->ppn`
879 /// as a unit — the PUTF bit and the put-notify wait-set always travel
880 /// together. Resolved here, under the guard that resolved the target, so
881 /// a cycle with no DB forward link reads neither.
882 Db {
883 name: String,
884 putf: bool,
885 notify: Option<Arc<crate::server::record::NotifyWaitSet>>,
886 },
887 /// An external `pva://` / `ca://` PV, C `dbScanFwdLink` → the link set's
888 /// `scanForward` (pvxs `pvaScanForward`) — a process-only trigger.
889 External(String),
890}
891impl ProcessPlan {
892 /// The answers, asked of the record type once. Pure in the record's
893 /// type: every input is a `Record` method or a table keyed on its name,
894 /// so the plan is the same whenever it is built.
895 pub(crate) fn of(record: &dyn Record) -> Self {
896 let rtype = record.record_type();
897 let declares_simulation = field_desc_of(record, "SIMM").is_some();
898 ProcessPlan {
899 dset_can_refuse: crate::server::recgbl::dev_sup_process_refusal(rtype).is_some(),
900 simulation: declares_simulation,
901 sel_nvl: rtype == "sel",
902 string_input: !record.string_input_links().is_empty(),
903 multi_output_dispatch: crate::server::database::multi_output_dispatch_owned(rtype),
904 reads_sell: crate::server::database::reads_sell(rtype),
905 posts_software_event: crate::server::database::posts_software_event(rtype),
906 resolves_subroutine_from_link: crate::server::database::resolves_subroutine_from_link(
907 rtype,
908 ),
909 // Asked of the record itself, not of a table keyed on `rtype`: the
910 // type that implements the step is the one that answers whether it
911 // has one, so the two cannot drift into different files.
912 substitutes_input_stage_when_simulating: record.simulation_substitutes_input_stage(),
913 redecides_after_output: record.redecides_after_output(),
914 input_fetch_policy: record.input_fetch_policy(),
915 constants_deliver_at_process: record.constant_inputs_deliver_at_process(),
916 multi_input_is_db_get_link: record.multi_input_fetch_is_db_get_link(),
917 multi_inputs_read_native: record.input_link_answers_fixed_at_type()
918 && record.multi_input_links().iter().all(|(lf, _)| {
919 record.input_link_request(lf) == InputLinkRequest::As(LinkReadAs::Native)
920 && !record.input_link_failure_is_inert(lf)
921 }),
922 dispatches_generic_multi_output: !crate::server::database::multi_output_dispatch_owned(
923 rtype,
924 ) && record.declares_multi_output_links(),
925 narrows_input_links: record.narrows_input_links(),
926 output_stage: record.can_device_write()
927 || field_desc_of(record, "OUT").is_some()
928 || field_desc_of(record, "OEVT").is_some()
929 || crate::server::database::multi_output_dispatch_owned(rtype)
930 || record.declares_multi_output_links()
931 || declares_simulation,
932 fetches_dol_closed_loop: record.fetches_dol_closed_loop(),
933 soft_channel_skips_convert: record.soft_channel_skips_convert(),
934 skips_timestamp_when_undefined: record.skips_timestamp_when_undefined(),
935 restamps_time_after_completion: record.restamps_time_after_completion(),
936 clears_udf: record.clears_udf(),
937 }
938 }
939}
940
941/// C's `if (prec->udf) recGblSetSevr(prec, UDF_ALARM, prec->udfs);` line, as
942/// one record type writes it — whether the type has the line at all, whether
943/// it tests `udf == TRUE` instead of truthiness, the severity it substitutes
944/// for `UDFS`, and the message it attaches. Four `&dyn Record` calls, once per
945/// cycle per record, for four answers the type fixed at construction.
946#[derive(Clone, Copy, Debug)]
947pub(crate) struct UdfAlarm {
948 pub(crate) exact_one: bool,
949 pub(crate) severity: Option<crate::server::record::AlarmSeverity>,
950 pub(crate) message: &'static str,
951}
952
953/// What a record's TYPE can reach in a process cycle.
954///
955/// C enters a record through one `rset->process` pointer and runs only the
956/// lines that type has. This port runs one generic body for every type, so a
957/// `calc` cycle otherwise takes a record lock to ask whether device support
958/// may refuse it, another for the simulation block, another for `sel`'s NVL
959/// and another for the string-input fetch — four locks and four dynamic calls
960/// to learn four answers that were fixed when the type was compiled. They are
961/// settled once, in [`RecordCell::new`], and live on the cell rather than
962/// under its lock: a cycle borrows them ([`RecordCell::process_plan`])
963/// instead of copying them out of the guarded instance, which the body did
964/// field by field — one load and one spill per flag — on every cycle.
965#[derive(Clone, Copy, Debug, Default)]
966pub(crate) struct ProcessPlan {
967 /// This type's C `process()` opens with a dset test that can refuse the
968 /// cycle (`dev_sup_process_refusal`). `false` for `calc`, `sub`, `fanout`
969 /// and `calcout`, whose C `process()` has no such line.
970 pub(crate) dset_can_refuse: bool,
971 /// This type's dbd declares SIMM, so it has a C `readValue`/`writeValue`
972 /// simulation block at all.
973 pub(crate) simulation: bool,
974 /// This type is `sel`, the only one with an NVL link feeding SELN.
975 pub(crate) sel_nvl: bool,
976 /// This type declares at least one string-input link.
977 pub(crate) string_input: bool,
978 /// This type's `fanout`/`dfanout`/`seq` link array is driven by
979 /// `PvDatabase::dispatch_multi_output`. Gates all three of its call sites
980 /// — the pre-commit value phase and both forward-link tails.
981 pub(crate) multi_output_dispatch: bool,
982 /// This type reads `SELL` into `SELN` at some phase of the cycle.
983 pub(crate) reads_sell: bool,
984 /// This type is `event`, the only one that posts a named software event
985 /// from the forward-link tail.
986 pub(crate) posts_software_event: bool,
987 /// This type is `aSub`, the only one that can re-read its subroutine name
988 /// from a link mid-cycle.
989 pub(crate) resolves_subroutine_from_link: bool,
990 /// This type's simulation block replaces the input stage rather than
991 /// running alongside it — `swait` alone. `false` makes the whole
992 /// `set_simulation_active` step disappear, lock included, rather than
993 /// taking the record's write lock to ask and be told no.
994 pub(crate) substitutes_input_stage_when_simulating: bool,
995 /// This type has a `conditional_write` epilogue — see
996 /// [`Record::redecides_after_output`]. `false` makes the post-output write
997 /// lock disappear.
998 pub(crate) redecides_after_output: bool,
999 /// What a failed multi-input read means to this type — see
1000 /// [`Record::input_fetch_policy`].
1001 pub(crate) input_fetch_policy: crate::server::record::InputFetchPolicy,
1002 /// `printf` alone re-runs `recGblInitConstantLink` on every process, so
1003 /// its constant inputs deliver every cycle — see
1004 /// [`Record::constant_inputs_deliver_at_process`].
1005 pub(crate) constants_deliver_at_process: bool,
1006 /// Whether a failed multi-input read owes C's `setLinkAlarm` — see
1007 /// [`Record::multi_input_fetch_is_db_get_link`].
1008 pub(crate) multi_input_is_db_get_link: bool,
1009 /// Every declared multi-input link reads [`LinkReadAs::Native`] and none
1010 /// declares its failure inert — the framework default, which every base
1011 /// numeric type gives. The fetch loop then carries the answer instead of
1012 /// asking [`Record::input_link_request`] and
1013 /// [`Record::input_link_failure_is_inert`] through the vtable per link,
1014 /// per cycle. Only settled here for a type whose answers are fixed at the
1015 /// type ([`Record::input_link_answers_fixed_at_type`]).
1016 pub(crate) multi_inputs_read_native: bool,
1017 /// This type declares generic multi-output pairs
1018 /// ([`Record::multi_output_links`]) for the pre-commit output stage to
1019 /// drive — scalcout, acalcout, aSub, epid. `false` makes
1020 /// `dispatch_multi_output_values` and the record lock it takes disappear,
1021 /// rather than acquiring the lock to re-derive the answer from the
1022 /// record's type name every cycle.
1023 ///
1024 /// Disjoint from [`ProcessPlan::multi_output_dispatch`] by construction:
1025 /// the `fanout`/`dfanout`/`seq` arrays are driven by
1026 /// `dispatch_multi_output`, and `multi_output_dispatch_owned` excludes
1027 /// them from the generic block, so no type can answer `true` to both.
1028 pub(crate) dispatches_generic_multi_output: bool,
1029 /// This type can narrow the cycle's input list — see
1030 /// [`Record::narrows_input_links`]. `false` makes the guard that asks
1031 /// disappear.
1032 pub(crate) narrows_input_links: bool,
1033 /// This type has an output stage — a device it can write, an `OUT` link,
1034 /// an `OEVT` event, multi-output links, or a simulation block — so a cycle
1035 /// composes and drives outputs between `checkAlarms` and the alarm commit.
1036 /// `false` is that whole stage absent from the type's C `process()`
1037 /// (`calcRecord.c` has no such lines): the cycle skips it, and with it the
1038 /// guard boundary the stage's link writes would need.
1039 pub(crate) output_stage: bool,
1040 /// Five per-type answers the cycle asked the record for through the
1041 /// vtable every process; each is a literal in every implementation, so
1042 /// they are read once here. See the `Record` method of the same name.
1043 pub(crate) fetches_dol_closed_loop: bool,
1044 pub(crate) soft_channel_skips_convert: bool,
1045 pub(crate) skips_timestamp_when_undefined: bool,
1046 pub(crate) restamps_time_after_completion: bool,
1047 pub(crate) clears_udf: bool,
1048}
1049
1050/// The allocation every holder of a record shares — the port's `precord`.
1051///
1052/// The record's data is guarded by its **lock set** and nothing else — C's
1053/// `dbScanLock` (`dbLock.c:184-213`), the one mutex every record a DB link
1054/// reaches shares. [`Self::read`] and [`Self::write`] take that set
1055/// (recursively, as C's `epicsMutex` is) and hand out a borrow of the data;
1056/// a thread already inside the set — the process path, which took it at
1057/// entry — pays a thread-key compare and a counter, no atomic
1058/// read-modify-write. The per-record `RwLock` this replaced was a second
1059/// lock over the same data: every field access on the process path took it
1060/// again under the set that already excluded every other writer.
1061///
1062/// What lives HERE rather than in [`RecordInstance`] is decided by one rule:
1063/// **a fact a caller needs before it holds the lock cannot live behind the
1064/// lock.** C has exactly one such fact — `precord->lset`, read off
1065/// `dbCommon` with no lock at all to learn which mutex to take.
1066///
1067/// Same-thread aliasing is what the `RwLock` used to refuse by deadlocking
1068/// (a `write()` under a live guard of the same record never returned). The
1069/// set recurses, so that refusal is `borrow`'s now, and it panics —
1070/// on the thread that did it, naming the record — instead of parking it.
1071pub struct RecordCell {
1072 /// C `dbCommon::lset` (`dbLockPvt.h:52`). Born pointing at the bootstrap
1073 /// set — C's null `lset` before `dbLockInitRecords`, made lockable — and
1074 /// moved onto a set of its own by the registry, which adopts this very
1075 /// cell for the record's name so that the record and the registry can
1076 /// never hold two answers to "which set is this record in". A lock-set
1077 /// MERGE moves the set the cell points at, never the cell.
1078 lock_record: std::sync::Arc<crate::server::database::LockRecord>,
1079 /// `RefCell`-style borrow state of `data`: `0` free, `n > 0` that many
1080 /// shared borrows, `-1` one exclusive borrow. Touched only by the thread
1081 /// holding the record's lock set, which is why plain loads and stores
1082 /// (no read-modify-write) are enough — the atomic is for `Sync`, not for
1083 /// contention, and there is none.
1084 borrow: std::sync::atomic::AtomicIsize,
1085 /// The record's `BKPT` byte, shared with `data.common.bkpt` — reachable
1086 /// here without the lock set, as C reads `precord->bkpt` (see
1087 /// [`BkptFlag`]).
1088 bkpt: BkptFlag,
1089 /// Which published `cp_links` map `PvDatabase::sources_cp_edges` was resolved
1090 /// from, or 0 before it ever was.
1091 ///
1092 /// The question the cache answers — "does any CP/CPP edge name this
1093 /// record as its source?" — is asked once per process cycle by
1094 /// `PvDatabase::dispatch_cp_targets`, and the registry that holds the
1095 /// answer is keyed by record NAME. Asking it directly costs a hash of the
1096 /// name and a map probe every cycle to hear `false`, which is what every
1097 /// record with no CP holder hears forever. The registry moves its
1098 /// revision on every edit, so a record that has heard the answer once may
1099 /// keep it until it does.
1100 cp_edges_revision: std::sync::atomic::AtomicU64,
1101 /// The cached answer, valid only while [`Self::cp_edges_revision`] still
1102 /// matches the registry's.
1103 cp_edges: std::sync::atomic::AtomicBool,
1104 /// C `dbCommon::rdes` — the record type's description, reachable without
1105 /// a borrow of the record because a cell's type never changes. What a
1106 /// link target's field address ([`FieldAddr`]) is resolved against.
1107 rdes: RecordDesc,
1108 /// [`Record::field_slot`] for each entry of `rdes.fields`, asked once
1109 /// here so a link resolving its target's address needs no lock.
1110 slots: Box<[Option<super::record_trait::FieldSlot>]>,
1111 /// [`Record::field_slot`] of each [`Record::multi_input_links`] value
1112 /// field, by the link's index — C's `&prec->a + i`, the address the
1113 /// fetch stores through ([`Record::put_slot_f64`]) when the type hands
1114 /// one out.
1115 multi_input_val_slots: Box<[Option<super::record_trait::FieldSlot>]>,
1116 /// The type-static answers a process cycle tests — see [`ProcessPlan`].
1117 /// Fixed by the type, like `rdes`, so it is reachable without a borrow
1118 /// of the record.
1119 plan: ProcessPlan,
1120 data: LockSetGuarded<RecordInstance>,
1121}
1122
1123/// A record type's declaration, C's `dbRecordType` behind `precord->rdes`:
1124/// the two static tables a field name is resolved against.
1125#[derive(Clone, Copy)]
1126pub(crate) struct RecordDesc {
1127 pub(crate) record_type: &'static str,
1128 pub(crate) fields: &'static [FieldDesc],
1129}
1130
1131impl RecordDesc {
1132 pub(crate) fn of<R: Record + ?Sized>(record: &R) -> Self {
1133 RecordDesc {
1134 record_type: record.record_type(),
1135 fields: record.field_list(),
1136 }
1137 }
1138}
1139
1140/// C's `dbAddr` field half — `pfldDes`, plus whether the name is one the
1141/// type DECLARES (an undeclared one falls to the attribute table, C
1142/// `dbNameToAddr`'s `dbGetAttributePart` fallthrough, `dbAccess.c:667-675`).
1143/// Settled once per link target and read through every cycle after, as C
1144/// resolves a link's `dbAddr` once in `dbDbInitLink`.
1145#[derive(Clone, Copy)]
1146pub(crate) struct FieldAddr {
1147 pub(crate) declared: bool,
1148 pub(crate) desc: Option<&'static FieldDesc>,
1149 /// The record's own handle for the field ([`Record::field_slot`]), when
1150 /// the type has one and the address was resolved with the record in
1151 /// hand ([`FieldAddr::resolve_in`]). A read at a slot goes to the typed
1152 /// accessor and skips the by-name chain.
1153 pub(crate) slot: Option<super::record_trait::FieldSlot>,
1154}
1155
1156impl FieldAddr {
1157 /// `field` is upper-case already.
1158 pub(crate) fn resolve(rdes: &RecordDesc, field: &str) -> Self {
1159 FieldAddr {
1160 declared: declares_field(rdes.record_type, field),
1161 desc: field_desc_in(rdes.fields, field),
1162 slot: None,
1163 }
1164 }
1165
1166 /// [`Self::resolve`] with the record's slot for the field — for an
1167 /// address that is kept and read through. Takes no lock: the slots were
1168 /// settled with the cell ([`RecordCell::field_slot`]), so a record may
1169 /// resolve a link to itself while it is held for writing.
1170 pub(crate) fn resolve_in(rec: &RecordCell, field: &str) -> Self {
1171 let mut addr = Self::resolve(rec.rdes(), field);
1172 let slot = rec.field_slot(field);
1173 debug_assert!(
1174 slot.is_none() || (addr.declared && addr.desc.is_some_and(|d| !d.unreadable())),
1175 "{}.{field}: a field slot must name a declared, readable field",
1176 rec.rdes().record_type
1177 );
1178 addr.slot = slot;
1179 addr
1180 }
1181}
1182
1183/// Whether `record_type`'s `.dbd` declares `field` — C `dbFindField` on the
1184/// type's declared list, which `pvNameLookup` (`dbChannel.c:311-329`) asks
1185/// before it falls through to the attribute table.
1186pub(crate) fn declares_field(record_type: &str, field: &str) -> bool {
1187 super::dbd_generated::record_declaration_order(record_type)
1188 .is_some_and(|names| names.contains(&field))
1189}
1190
1191/// The record's data, guarded by its lock set. `Sync` on exactly the bound
1192/// `RwLock<T>` demanded for it — the lock set excludes every other thread
1193/// while a borrow is out, the same guarantee the `RwLock` gave.
1194struct LockSetGuarded<T: Send + Sync>(std::cell::UnsafeCell<T>);
1195
1196// SAFETY: a `&T` or `&mut T` is only ever produced by `RecordCell::read` /
1197// `RecordCell::write`, on a thread that holds the record's lock set, and
1198// lives no longer than that hold (`RecordRef` / `RecordMut` carry the set
1199// guard). Two threads therefore never observe the cell at once, and the
1200// same-thread borrow rules are enforced by `RecordCell::borrow`.
1201unsafe impl<T: Send + Sync> Sync for LockSetGuarded<T> {}
1202
1203impl RecordCell {
1204 pub(crate) fn new(instance: RecordInstance) -> Self {
1205 Self {
1206 lock_record: crate::server::database::LockRecord::bootstrap(),
1207 borrow: std::sync::atomic::AtomicIsize::new(0),
1208 bkpt: instance.common.bkpt.share(),
1209 cp_edges_revision: std::sync::atomic::AtomicU64::new(0),
1210 cp_edges: std::sync::atomic::AtomicBool::new(false),
1211 rdes: RecordDesc::of(&*instance.record),
1212 slots: instance
1213 .record
1214 .field_list()
1215 .iter()
1216 .map(|f| instance.record.field_slot(f.name))
1217 .collect(),
1218 multi_input_val_slots: instance
1219 .record
1220 .multi_input_links()
1221 .iter()
1222 .map(|(_, vf)| instance.record.field_slot(vf))
1223 .collect(),
1224 plan: ProcessPlan::of(&*instance.record),
1225 data: LockSetGuarded(std::cell::UnsafeCell::new(instance)),
1226 }
1227 }
1228
1229 /// The type-static answers this cycle may test instead of re-asking the
1230 /// record under a lock.
1231 pub(crate) fn process_plan(&self) -> &ProcessPlan {
1232 &self.plan
1233 }
1234
1235 /// The record type's declaration — C `precord->rdes`.
1236 pub(crate) fn rdes(&self) -> &RecordDesc {
1237 &self.rdes
1238 }
1239
1240 /// [`Record::field_slot`] for `field` (upper-case), answered from the
1241 /// table settled at construction: a slot names a field of the type's
1242 /// own list, and `dbCommon` fields have none.
1243 /// The slot the fetch stores multi-input link `index`'s value through,
1244 /// when the type hands one out.
1245 #[inline(always)]
1246 pub(crate) fn multi_input_val_slot(
1247 &self,
1248 index: usize,
1249 ) -> Option<super::record_trait::FieldSlot> {
1250 self.multi_input_val_slots[index]
1251 }
1252
1253 pub(crate) fn field_slot(&self, field: &str) -> Option<super::record_trait::FieldSlot> {
1254 self.rdes
1255 .fields
1256 .iter()
1257 .position(|f| f.name.eq_ignore_ascii_case(field))
1258 .and_then(|i| self.slots[i])
1259 }
1260
1261 /// The record's `BKPT` byte, without taking its lock set.
1262 pub(crate) fn bkpt(&self) -> &BkptFlag {
1263 &self.bkpt
1264 }
1265
1266 /// Shared access to the record — C `dbScanLock` followed by reading the
1267 /// fields. Blocks while another thread holds the record's lock set;
1268 /// recurses on the thread that holds it.
1269 ///
1270 /// # Panics
1271 ///
1272 /// If this thread holds a [`RecordMut`] of the same record: the borrow
1273 /// the caller asked for cannot coexist with it, and the `RwLock` this
1274 /// replaced would have parked the thread forever here.
1275 pub fn read(&self) -> RecordRef<'_> {
1276 use std::sync::atomic::Ordering;
1277 let set = self.lock_record.acquire();
1278 let borrow = self.borrow.load(Ordering::Relaxed);
1279 assert!(
1280 borrow >= 0,
1281 "record data read while this thread holds it for writing"
1282 );
1283 self.borrow.store(borrow + 1, Ordering::Relaxed);
1284 RecordRef {
1285 cell: self,
1286 _set: Some(set),
1287 }
1288 }
1289
1290 /// [`Self::read`] for a thread that holds `held`: when this record is in
1291 /// that set the set is held already, and only the borrow count is
1292 /// touched — no thread key, no depth counter. Every DB link target is in
1293 /// its reader's set (the merge, see `record_lock.rs`), so this is the
1294 /// per-link read of a process cycle; a record in another set takes
1295 /// [`Self::read`]'s path. Sound by Rule R: a held set keeps its records,
1296 /// so the test cannot go stale while `held` lives, and the borrow is
1297 /// bound to `held` so it cannot outlive the hold.
1298 ///
1299 /// # Panics
1300 ///
1301 /// As [`Self::read`].
1302 #[inline]
1303 pub(crate) fn read_in<'a>(
1304 &'a self,
1305 held: &'a crate::server::database::SetGuard,
1306 ) -> RecordRef<'a> {
1307 use std::sync::atomic::Ordering;
1308 if !held.holds(&self.lock_record) {
1309 return self.read();
1310 }
1311 let borrow = self.borrow.load(Ordering::Relaxed);
1312 assert!(
1313 borrow >= 0,
1314 "record data read while this thread holds it for writing"
1315 );
1316 self.borrow.store(borrow + 1, Ordering::Relaxed);
1317 RecordRef {
1318 cell: self,
1319 _set: None,
1320 }
1321 }
1322
1323 /// Exclusive access to the record — C `dbScanLock` followed by writing
1324 /// the fields. Blocks and recurses as [`Self::read`] does.
1325 ///
1326 /// # Panics
1327 ///
1328 /// If this thread holds any guard of the same record, for the reason
1329 /// given on [`Self::read`].
1330 pub fn write(&self) -> RecordMut<'_> {
1331 use std::sync::atomic::Ordering;
1332 let set = self.lock_record.acquire();
1333 assert!(
1334 self.borrow.load(Ordering::Relaxed) == 0,
1335 "record data written while this thread still holds a guard of it"
1336 );
1337 self.borrow.store(-1, Ordering::Relaxed);
1338 RecordMut {
1339 cell: self,
1340 _set: set,
1341 }
1342 }
1343
1344 /// The answer this record cached for `registry_revision`, or `None` when
1345 /// it has not heard one for that revision.
1346 ///
1347 /// Only `PvDatabase::sources_cp_edges` reads or writes the pair, so the
1348 /// two atomics move as one: the flag is stored first and the revision
1349 /// second, which is the order that makes a matching revision proof that
1350 /// the flag beside it belongs to that revision.
1351 pub(crate) fn cached_cp_edges(&self, registry_revision: u64) -> Option<bool> {
1352 use std::sync::atomic::Ordering;
1353 (self.cp_edges_revision.load(Ordering::Acquire) == registry_revision)
1354 .then(|| self.cp_edges.load(Ordering::Relaxed))
1355 }
1356
1357 /// File `present` as this record's answer for `registry_revision`.
1358 pub(crate) fn cache_cp_edges(&self, registry_revision: u64, present: bool) {
1359 use std::sync::atomic::Ordering;
1360 self.cp_edges.store(present, Ordering::Relaxed);
1361 self.cp_edges_revision
1362 .store(registry_revision, Ordering::Release);
1363 }
1364
1365 /// The lock-set cell — C `precord->lset`.
1366 pub(crate) fn lock_record(&self) -> &std::sync::Arc<crate::server::database::LockRecord> {
1367 &self.lock_record
1368 }
1369}
1370
1371/// A shared borrow of a record, holding its lock set — what
1372/// [`RecordCell::read`] hands out. `!Send`, as the set guard inside it is:
1373/// a lock set is released by the thread that took it.
1374#[must_use = "the record's lock set is released as soon as the guard is dropped"]
1375pub struct RecordRef<'a> {
1376 cell: &'a RecordCell,
1377 /// `None` for a [`RecordCell::read_in`] borrow, which rides on the set
1378 /// guard of its `'a` rather than taking one.
1379 _set: Option<crate::server::database::SetGuard>,
1380}
1381
1382impl RecordRef<'_> {
1383 /// Whether the borrow rode a set guard the caller held rather than
1384 /// taking one — [`RecordCell::read_in`]'s same-set case.
1385 #[cfg(test)]
1386 pub(crate) fn rides_held_set(&self) -> bool {
1387 self._set.is_none()
1388 }
1389}
1390
1391impl std::ops::Deref for RecordRef<'_> {
1392 type Target = RecordInstance;
1393
1394 fn deref(&self) -> &RecordInstance {
1395 // SAFETY: this thread holds the record's lock set (`_set`) and the
1396 // borrow count admitted a shared borrow, so no `&mut` exists.
1397 unsafe { &*self.cell.data.0.get() }
1398 }
1399}
1400
1401impl Drop for RecordRef<'_> {
1402 fn drop(&mut self) {
1403 use std::sync::atomic::Ordering;
1404 // Runs before `_set` is released (fields drop after this body), so
1405 // the count is never touched by a thread outside the set.
1406 let borrow = self.cell.borrow.load(Ordering::Relaxed);
1407 self.cell.borrow.store(borrow - 1, Ordering::Relaxed);
1408 }
1409}
1410
1411/// An exclusive borrow of a record, holding its lock set — what
1412/// [`RecordCell::write`] hands out.
1413#[must_use = "the record's lock set is released as soon as the guard is dropped"]
1414pub struct RecordMut<'a> {
1415 cell: &'a RecordCell,
1416 _set: crate::server::database::SetGuard,
1417}
1418
1419impl std::ops::Deref for RecordMut<'_> {
1420 type Target = RecordInstance;
1421
1422 fn deref(&self) -> &RecordInstance {
1423 // SAFETY: as `RecordRef`, and the borrow count admitted the one
1424 // exclusive borrow, so this is the only reference.
1425 unsafe { &*self.cell.data.0.get() }
1426 }
1427}
1428
1429impl std::ops::DerefMut for RecordMut<'_> {
1430 fn deref_mut(&mut self) -> &mut RecordInstance {
1431 // SAFETY: as `deref`, through the unique borrow of the guard.
1432 unsafe { &mut *self.cell.data.0.get() }
1433 }
1434}
1435
1436impl RecordMut<'_> {
1437 /// The instance and the set guard it is held under, apart — for a
1438 /// caller that writes the record while it reads another record of the
1439 /// same set through [`RecordCell::read_in`].
1440 #[inline]
1441 pub(crate) fn split(&mut self) -> (&mut RecordInstance, &crate::server::database::SetGuard) {
1442 // SAFETY: as `deref_mut`; the set guard is a separate field.
1443 (unsafe { &mut *self.cell.data.0.get() }, &self._set)
1444 }
1445}
1446
1447impl Drop for RecordMut<'_> {
1448 fn drop(&mut self) {
1449 self.cell
1450 .borrow
1451 .store(0, std::sync::atomic::Ordering::Relaxed);
1452 }
1453}
1454
1455/// One entry of [`RecordInstance::parsed_inputs`]: a link text the record
1456/// held at some read, its parse, and the local record the parse addressed.
1457pub(crate) struct ParsedInputLink {
1458 text: String,
1459 /// The [`Record::input_links_generation`] the record answered when
1460 /// `text` was last read off it, for a type that answers one: while the
1461 /// record still answers it, `text` IS the field's text and the cycle
1462 /// need not read the field to know.
1463 generation: Option<u64>,
1464 parsed: Arc<ParsedLink>,
1465 /// C's `dbAddr` from `dbDbInitLink` (`dbDbLink.c:88-111`): the target
1466 /// record, resolved once and read through until something can have
1467 /// changed the answer. C re-resolves only when the link text is put; the
1468 /// port also re-resolves when a name map changed, because records and
1469 /// aliases can come and go after `iocInit` here.
1470 target: Option<LinkTargetHandle>,
1471}
1472
1473/// A resolved link target and the name-map revision it was resolved under.
1474///
1475/// Holds the target's `Arc` — C's `dbAddr` holds the record pointer — so the
1476/// cycle's read is a load, not a `Weak` upgrade. What keeps that from
1477/// leaking: a record leaves the name map only through
1478/// [`RecordInstance::destroy`], which drops the handles the record holds,
1479/// and the map's revision moves with it so every handle that named the
1480/// record is re-resolved on its holder's next read; the database's own drop
1481/// destroys every record still in the map, which breaks the `A -> B -> A`
1482/// cycles two such handles make.
1483struct LinkTargetHandle {
1484 target: ResolvedTarget,
1485 revision: u64,
1486 /// Settled with the target, since every input is the parse's or the
1487 /// target's: whether the reader's own hold reads it, and how.
1488 native: Option<NativeRead>,
1489}
1490
1491/// How the held native fetch reads a link it may read — C's link flags and
1492/// `dbAddr` as the fetch consumes them, decided once per resolution rather
1493/// than per cycle.
1494#[derive(Clone, Copy)]
1495pub(crate) struct NativeRead {
1496 /// The field is not `VAL`, so a simple PV may shadow its spelling and
1497 /// the directory is asked first.
1498 pub(crate) shadowed: bool,
1499 /// `MS` / `MSS` / `MSI`: the target's alarm is inherited.
1500 pub(crate) inherits: bool,
1501 /// The reader's own slot for the link's value field
1502 /// ([`RecordCell::multi_input_val_slot`]) — C's `&prec->a + i`, the
1503 /// address the read is stored through, when the type hands one out.
1504 pub(crate) val_slot: Option<super::record_trait::FieldSlot>,
1505}
1506
1507impl NativeRead {
1508 /// The read the reader's hold makes of `parsed` at `target`, or `None`
1509 /// when it is not this frame's: a read of the reader's own field, a `PP`
1510 /// source (processed with the guard released), a filtered channel, or
1511 /// not a local record read at all.
1512 fn of(
1513 parsed: &ParsedLink,
1514 target: &ResolvedTarget,
1515 reader: &Arc<RecordCell>,
1516 slot: usize,
1517 ) -> Option<Self> {
1518 use crate::server::record::{LinkProcessPolicy, MonitorSwitch};
1519 let ParsedLink::Db(db) = parsed else {
1520 return None;
1521 };
1522 let channel = db.target();
1523 if Arc::ptr_eq(&target.rec, reader)
1524 || db.policy == LinkProcessPolicy::ProcessPassive
1525 || channel.json_suffix.is_some()
1526 {
1527 return None;
1528 }
1529 Some(NativeRead {
1530 shadowed: &*channel.field != "VAL",
1531 inherits: db.monitor_switch != MonitorSwitch::NoMaximize,
1532 val_slot: reader.multi_input_val_slot(slot),
1533 })
1534 }
1535}
1536
1537impl ParsedInputLink {
1538 /// The entry of `cache` for `slot`, holding the parse of the text `record`
1539 /// has in link `slot` of `declared` (its [`Record::multi_input_links`])
1540 /// now; `None` when the link is unset. The list rather than the name,
1541 /// so the frame carries an index and reads the name only when it reads
1542 /// the text.
1543 ///
1544 /// The single writer of [`RecordInstance::parsed_inputs`]: the record's
1545 /// current text is read first and the cached entry is reused only when
1546 /// its text is that text byte for byte, so the parse runs once per
1547 /// distinct text a slot has held and the invariant on the field's doc
1548 /// needs no other site to hold. Takes the record's parts rather than the
1549 /// record so the fetch stage can hold the entry while it writes the
1550 /// record's fields. `always`: the held and the general fetch both call
1551 /// it, and the held one is the loop's common frame.
1552 ///
1553 /// `generation` is [`Record::input_links_generation`] as the caller read
1554 /// it under the hold it calls this under — never carried across a
1555 /// release, since a put in the gap moves it. An entry stamped with that
1556 /// generation was compared byte for byte at it, and the record's one
1557 /// counting writer has not run since — so the text is not read at all.
1558 #[inline(always)]
1559 pub(crate) fn validated<'c>(
1560 cache: &'c mut [Option<ParsedInputLink>],
1561 record: &dyn Record,
1562 slot: usize,
1563 declared: &'static [(&'static str, &'static str)],
1564 generation: Option<u64>,
1565 ) -> Option<&'c mut ParsedInputLink> {
1566 let cell = cache.get_mut(slot)?;
1567 if generation.is_some()
1568 && let Some(entry) = cell.as_ref()
1569 && entry.generation == generation
1570 {
1571 debug_assert!(
1572 record.link_text_ref(declared[slot].0) == Some(entry.text.as_str()),
1573 "{}: the link text changed under generation {generation:?} — \
1574 a writer of the text does not move input_links_generation",
1575 declared[slot].0
1576 );
1577 return cell.as_mut();
1578 }
1579 let link_field = declared[slot].0;
1580 let owned;
1581 let text: &str = match record.link_text_ref(link_field) {
1582 Some("") => return None,
1583 Some(text) => text,
1584 None => {
1585 owned = link_text_of(record, link_field)?;
1586 &owned
1587 }
1588 };
1589 if cell.as_ref().is_none_or(|entry| entry.text != text) {
1590 *cell = Some(Self::fresh(text));
1591 }
1592 let entry = cell.as_mut()?;
1593 entry.generation = generation;
1594 Some(entry)
1595 }
1596
1597 /// A slot's entry for a text it has not held: the parse, with no target
1598 /// resolved yet. Its own frame so the cycle's byte-for-byte reuse test
1599 /// does not carry the parser's.
1600 fn fresh(text: &str) -> ParsedInputLink {
1601 ParsedInputLink {
1602 text: text.to_owned(),
1603 generation: None,
1604 parsed: Arc::new(parse_link_v2(text)),
1605 target: None,
1606 }
1607 }
1608
1609 /// The parse, shared: the cycle hands it to reads that run after the
1610 /// record lock is released.
1611 pub(crate) fn parsed(&self) -> &Arc<ParsedLink> {
1612 &self.parsed
1613 }
1614
1615 /// The handle at the name maps' current revision: reused while the maps
1616 /// are at the revision it was resolved under, re-resolved otherwise. A
1617 /// link that resolves to no local record is asked again on every read,
1618 /// as it was before the cache. `reader` is the record holding the entry
1619 /// and `slot` the entry's index in its list, for [`NativeRead::of`].
1620 ///
1621 /// Over the entry's parts, so a caller can hold the parse beside the
1622 /// handle it returns.
1623 #[inline(always)]
1624 fn resolve<'h>(
1625 handle: &'h mut Option<LinkTargetHandle>,
1626 parsed: &ParsedLink,
1627 names: &dyn LinkTargetResolver,
1628 reader: &Arc<RecordCell>,
1629 slot: usize,
1630 ) -> Option<&'h LinkTargetHandle> {
1631 let revision = names.name_revision();
1632 if handle
1633 .as_ref()
1634 .is_none_or(|handle| handle.revision != revision)
1635 {
1636 *handle = names.local_target(parsed).map(|target| {
1637 let native = NativeRead::of(parsed, &target, reader, slot);
1638 LinkTargetHandle {
1639 target,
1640 revision,
1641 native,
1642 }
1643 });
1644 }
1645 handle.as_ref()
1646 }
1647
1648 /// The parse, with C's `dbAddr` for it — the local record and field the
1649 /// link addresses, or `None` for a link that is not a local record read.
1650 /// The two come out together because both borrow the entry and the
1651 /// cycle wants both of every link.
1652 #[inline(always)]
1653 pub(crate) fn target(
1654 &mut self,
1655 names: &dyn LinkTargetResolver,
1656 reader: &Arc<RecordCell>,
1657 slot: usize,
1658 ) -> (&ParsedLink, Option<&ResolvedTarget>) {
1659 let target = Self::resolve(&mut self.target, &self.parsed, names, reader, slot)
1660 .map(|handle| &handle.target);
1661 (&self.parsed, target)
1662 }
1663
1664 /// The link as the reader's own hold reads it — see [`NativeRead::of`]
1665 /// — or `None` for a link that is not that frame's.
1666 #[inline(always)]
1667 pub(crate) fn native_read(
1668 &mut self,
1669 names: &dyn LinkTargetResolver,
1670 reader: &Arc<RecordCell>,
1671 slot: usize,
1672 ) -> Option<(&crate::server::record::DbLink, &ResolvedTarget, NativeRead)> {
1673 let handle = Self::resolve(&mut self.target, &self.parsed, names, reader, slot)?;
1674 let native = handle.native?;
1675 let ParsedLink::Db(db) = &*self.parsed else {
1676 return None;
1677 };
1678 Some((db, &handle.target, native))
1679 }
1680
1681 /// Drop the target handle, leaving the parse. What
1682 /// [`RecordInstance::destroy`] does to every entry, so a record that has
1683 /// left the database holds no other record.
1684 fn release_target(&mut self) {
1685 self.target = None;
1686 }
1687
1688 /// Whether the handle names `cell`.
1689 fn targets(&self, cell: &Arc<RecordCell>) -> bool {
1690 self.target
1691 .as_ref()
1692 .is_some_and(|handle| Arc::ptr_eq(&handle.target.rec, cell))
1693 }
1694}
1695
1696/// The text of link field `field` of `record`, `None` when the link is unset
1697/// — [`RecordInstance::link_text`] over the record alone.
1698fn link_text_of(record: &dyn Record, field: &str) -> Option<String> {
1699 if let Some(text) = record.link_text_ref(field) {
1700 return (!text.is_empty()).then(|| text.to_owned());
1701 }
1702 match record.get_field(field)? {
1703 EpicsValue::String(text) if !text.is_empty() => Some(text.as_str_lossy().into_owned()),
1704 _ => None,
1705 }
1706}
1707
1708/// A link's local target with its field resolved — C's `dbAddr`.
1709#[derive(Clone)]
1710pub(crate) struct ResolvedTarget {
1711 pub(crate) rec: Arc<RecordCell>,
1712 pub(crate) field: FieldAddr,
1713}
1714
1715/// Where a DB link's local target comes from — `PvDatabase` in production.
1716pub(crate) trait LinkTargetResolver {
1717 /// The revision of the name maps (records and aliases). Loaded BEFORE
1718 /// the maps are read, so a handle stamped with it can never be one a
1719 /// later mutation produced under an earlier number.
1720 fn name_revision(&self) -> u64;
1721 /// The local record a `Db` link addresses, with its field resolved, or
1722 /// `None` for a link that is not a local record read (a constant, an
1723 /// external PV, a simple PV).
1724 fn local_target(&self, link: &ParsedLink) -> Option<ResolvedTarget>;
1725}
1726
1727/// A type-erased record instance stored in the database.
1728pub struct RecordInstance {
1729 pub name: String,
1730 pub record: Box<dyn Record>,
1731 pub common: CommonFields,
1732 pub subscribers: HashMap<String, Vec<Subscriber>>,
1733 /// Terminal destruction marker, the [`crate::server::pv::ProcessVariable`]
1734 /// flag's counterpart for a record-backed channel. Set once by
1735 /// [`Self::destroy`], whose only caller is
1736 /// [`crate::server::database::PvDatabase::remove_record`], so *removed
1737 /// from the database* and *destroyed* are one event for both target
1738 /// kinds and a server can sweep them with one uniform test.
1739 destroyed: bool,
1740 // Link parse cache
1741 pub parsed_inp: ParsedLink,
1742 /// The parsed form of each [`Record::multi_input_links`] slot the record
1743 /// has wired, validated against the text the record holds NOW on every
1744 /// read — see [`ParsedInputLink::validated`], the one writer. The record's
1745 /// put paths need no hook: an entry whose text is not the record's text
1746 /// is re-parsed at the next read, so `parsed == parse_link_v2(&text)`
1747 /// holds for every entry by construction. C parses a link once at
1748 /// `dbInitLink`; the port parsed every set `INPA`..`INPL` on every cycle.
1749 pub(crate) parsed_inputs: Vec<Option<ParsedInputLink>>,
1750 pub parsed_out: ParsedLink,
1751 pub parsed_flnk: ParsedLink,
1752 pub parsed_sdis: ParsedLink,
1753 pub parsed_tsel: ParsedLink,
1754 // Device support
1755 pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
1756 // Subroutine (for sub records)
1757 pub subroutine: Option<Arc<SubroutineFn>>,
1758 /// The by-name function registry this record's PENDING `init_record` pass
1759 /// 1 will resolve INAM/SNAM against — C `registryFunctionFind`, reading a
1760 /// process-global table from inside `init_record`.
1761 ///
1762 /// One meaning on every path: armed by the creation sink immediately
1763 /// before [`Self::run_init_passes`], consumed (and cleared) by the pass
1764 /// itself. It exists because the lookup's failure is an EARLY RETURN — the
1765 /// init tail must not run past it — and only the init owner can honour
1766 /// that, while only the database holds the registry. Resolving from
1767 /// outside the passes, as both builders used to, put the lookup after the
1768 /// tail it is supposed to skip.
1769 init_subroutines: Option<Arc<HashMap<String, Arc<SubroutineFn>>>>,
1770 /// PACT (C `precord->pact`) — the re-entrancy guard, and the record's
1771 /// "busy" state for every put that lands on it.
1772 ///
1773 /// PRIVATE by construction: entered through [`RecordInstance::enter_pact`]
1774 /// and released ONLY through [`RecordInstance::leave_pact`], which hands
1775 /// back the [`PactExit`] that routes the release to the cycle tail where
1776 /// queued put-notifies are restarted. A `pact.store(false)` open-coded at
1777 /// a release site is what skipped that tail on the ODLY/SDLY paths; it is
1778 /// no longer expressible.
1779 pact: AtomicBool,
1780 // Put-notify wait-set this record currently belongs to (C
1781 // `precord->ppn`). Set when the record joins an active put-notify
1782 // (originating put target, or a FLNK/OUT PP target via `dbNotifyAdd`);
1783 // taken + `leave`d when the record's processing completes. `None`
1784 // outside any put-notify. See [`NotifyWaitSet`].
1785 // Private to the crate: the two writers ([`RecordInstance::
1786 // install_or_queue_notify`] and [`RecordInstance::join_put_notify`]) are
1787 // the slot's only assignment sites, and a `pub` field made a third one
1788 // constructible from outside. Read it with [`RecordInstance::has_notify`].
1789 pub(crate) notify: Option<Arc<NotifyWaitSet>>,
1790 /// C `precord->ppnr->restartList` — put-notifies waiting to take this
1791 /// record, oldest first.
1792 ///
1793 /// `processNotifyCommon` (dbNotify.c:213-219, 225-231) tests both
1794 /// "another processNotify owns the record" and `precord->pact` ABOVE
1795 /// `putCallback`, so a `dbPutNotify` onto a busy record writes nothing:
1796 /// no value, no RPRO. The whole put — value, process, callback — is
1797 /// deferred and restarted later. C queues them with `ellSafeAdd` and
1798 /// promotes one per completion (`restartCheck`, dbNotify.c:149-170); this
1799 /// is that list, and modelling it as a list rather than one slot is what
1800 /// stops the second concurrent `caput -c` being refused with an
1801 /// `ECA_PUTCBINPROG` C never sends.
1802 ///
1803 /// PRIVATE. Appended only by [`Self::queue_notify_put`], drained only by
1804 /// [`Self::take_next_notify_restart`], which pops only onto a record no
1805 /// put-notify owns.
1806 notify_restart_list: std::collections::VecDeque<DeferredNotify>,
1807 /// The value of each subscribed field as ALREADY PUBLISHED to that
1808 /// field's `DBE_VALUE`/`DBE_LOG` subscribers. The generic
1809 /// change-detection loop in every snapshot builder posts a field only
1810 /// when its current value differs from this — so this map is what
1811 /// C's per-record `*_lst` / MARK state is to `monitor()`.
1812 ///
1813 /// # Invariant (CONTRACT)
1814 ///
1815 /// A field's value MUST NOT be published twice by the framework.
1816 /// Concretely: every value-class post (a `db_post_events` carrying
1817 /// `DBE_VALUE` and/or `DBE_LOG`) MUST advance this map for the field it
1818 /// posts, whether or not that field has a subscriber (C's `monitor()`
1819 /// state advances on a post to an empty `mlis`, and a later subscriber
1820 /// inherits it); an alarm-only / property-only post MUST NOT (those classes do
1821 /// not deliver the value to a `DBE_VALUE`/`DBE_LOG` subscriber, so the
1822 /// change is still owed to them).
1823 ///
1824 /// In C, `dbPut` (dbAccess.c:1407-1414) is the record's ONLY post for a
1825 /// put: `db_post_events(precord, pfieldsave, DBE_VALUE|DBE_LOG)`. No
1826 /// record's `monitor()` re-posts that field — it posts a closed set and
1827 /// compares against its own `*_lst` fields. A framework that posts on the
1828 /// put and then change-detects the same field on the next process cycle
1829 /// sends an event C never sends.
1830 ///
1831 /// # Owner
1832 ///
1833 /// [`RecordInstance::record_value_post`] is the SINGLE writer. The field
1834 /// is private so no path outside this module can advance (or fail to
1835 /// advance) it: the snapshot builders read it through
1836 /// [`RecordInstance::posted_value`] and every poster —
1837 /// [`RecordInstance::notify_field_with_origin`] included — advances it
1838 /// through the owner.
1839 last_posted: HashMap<String, EpicsValue>,
1840 /// The live store for a field the record's `.dbd` DECLARES but the record
1841 /// struct has no `put_field` arm / no storage for — the WRITE analog of the
1842 /// read-side [`Self::declared_default`] fallback.
1843 ///
1844 /// C makes every `.dbd` field not just readable but WRITABLE: `dbPutField`
1845 /// resolves the field from its `dbFldDes` and `dbPut` writes the incoming
1846 /// value into record memory, whether or not any record code ever reads it
1847 /// back — a `caput dfanout.HOPR 10` sticks even though `dfanoutRecord.c`
1848 /// never touches HOPR. A Rust record models only the fields it has
1849 /// behaviour for, so a field it declares but never stores had nowhere for a
1850 /// put to land: [`Self::put_common_field`]'s catch-all reported
1851 /// `S_dbLib_fieldNotFound` and the client's put was refused, while a READ of
1852 /// the same field succeeded through `declared_default`. This map is that
1853 /// missing storage — one uniform mechanism for the whole family, not a
1854 /// per-field struct member on each record type.
1855 ///
1856 /// Keyed by upper-case field name, holding the value already coerced to the
1857 /// field's C-declared DBF type (the same projection `declared_default` and
1858 /// the read path serve). [`Self::resolve_field`] reads it BEFORE
1859 /// `declared_default`, so a read reflects a prior write and an untouched
1860 /// field still reads its `.dbd` initial. Empty for a record whose declared
1861 /// fields are all modeled.
1862 declared_overrides: HashMap<String, EpicsValue>,
1863 /// This record's OWN link fields whose target supplies some field's
1864 /// units/precision/graphic/alarm — the distinct answers of
1865 /// [`Record::link_backed_metadata_field`] over the record's declared
1866 /// field list, collected ONCE here.
1867 ///
1868 /// Derived, never declared a second time: the record type states the
1869 /// mapping in one place and this is the reverse index of that one
1870 /// statement, so the two cannot drift the way the central
1871 /// `match rtype` list they replace drifted away from `aSub`.
1872 link_backed_metadata_links: Vec<String>,
1873 /// What this record's TYPE can reach in a process cycle, settled once at
1874 /// construction. See [`ProcessPlan`].
1875 /// C's `UDF_ALARM` guard for this type, or `None` for a type whose C
1876 /// support has no such line (`swait`, `waveform` proper). See
1877 /// [`UdfAlarm`]. Read by [`Self::evaluate_alarms`] under the lock, which
1878 /// is why it lives here and not on the cell's [`ProcessPlan`].
1879 udf_alarm: Option<UdfAlarm>,
1880 /// What this record's TYPE answers to `monitor()`'s type-static
1881 /// questions, settled once at construction. See [`MonitorPlan`].
1882 monitor_plan: MonitorPlan,
1883 /// Where each of those links sits in [`Record::multi_input_links`] — the
1884 /// list a process cycle reads once at its top. Both lists are properties
1885 /// of the record TYPE, so the mapping is fixed here and cannot drift;
1886 /// `None` marks a metadata link the multi-input fetch does not cover,
1887 /// which the cycle then reads for itself. Taken once because the
1888 /// alternative is searching the pre-read list by name, per link, per pass.
1889 link_backed_metadata_input_slot: Vec<Option<usize>>,
1890 /// Does this record type's `.dbd` declare a simulation block — i.e. the
1891 /// SIMM field C's `readValue`/`writeValue` dispatch on?
1892 ///
1893 /// `dbCommon` declares none of SIMM/SIML/SIOL/SIMS/SDLY, so the answer is
1894 /// a property of the record TYPE, and for 18 of the 41 types this port
1895 /// carries (calc, calcout, sub, aSub, sel, seq, fanout, compress,
1896 /// subArray, ...) it is `false`: their C record support has no
1897 /// `readValue`/`writeValue` at all. Taken once here because the process
1898 /// cycle asks it on every pass, where resolving SIMM by name costs a
1899 /// scan of the record's declared field list and `dbCommon`'s.
1900 declares_simulation: bool,
1901 /// Set by `check_deadband_ext` for waveform/aai/aao when their
1902 /// content hash changed this cycle (C `monitor()` On Change mode,
1903 /// waveformRecord.c:310-319). The snapshot builders read it to post
1904 /// `HASH` with a literal `DBE_VALUE` event, independent of the VAL
1905 /// post mask. False for every record without the MPST/APST/HASH
1906 /// mechanism.
1907 pub(crate) array_hash_changed: bool,
1908 /// One-shot "skip the registered subroutine this cycle" signal for aSub
1909 /// `LFLG=READ`. The async processing path resolves the `SUBL` link before
1910 /// taking this lock; when the resolved name is bad (C `fetch_values` ->
1911 /// `S_db_BadSub`) or the link read failed, C `process` runs `do_sub` only
1912 /// on `!status`, so the subroutine is skipped. Set by the resolution
1913 /// apply, consumed (and cleared) by [`Self::run_registered_subroutine`];
1914 /// `false` for every record without a pending bad re-resolution.
1915 pub(crate) suppress_subroutine_run: bool,
1916 /// Generation counter for ReprocessAfter timer cancellation.
1917 /// Bumped each process cycle. Spawned timers check this to avoid
1918 /// stale re-processes from accumulated timers.
1919 pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
1920 /// Generation counter for the monitor watchdog
1921 /// ([`Record::watchdog_interval`] / [`Record::watchdog_fire`]), bumped by
1922 /// each `PvDatabase::arm_watchdog` so a re-arm supersedes the tick already
1923 /// in flight — C `callbackRequestDelayed` replacing an outstanding delayed
1924 /// callback. Deliberately NOT `reprocess_generation`: C's histogram wdog is
1925 /// its own `epicsCallback`, independent of the record's SDLY/async
1926 /// re-entry, so an SDLY defer must not cancel the watchdog nor vice versa.
1927 pub watchdog_generation: Arc<std::sync::atomic::AtomicU64>,
1928 /// Per-record info tags from `info("key", "value")` directives in
1929 /// the .db file (epics-base info(...) grammar). Consumers include
1930 /// asyn (`asyn:READBACK`), record-as-PV bridge tags
1931 /// (`Q:group`, `Q:form`), and IOC-specific extensions. Empty for
1932 /// records loaded without info(...) clauses.
1933 pub info: HashMap<String, String>,
1934 /// Cached metadata (display/control/enums) — `None` means stale or
1935 /// not yet built. Populated lazily by `snapshot_for_field` /
1936 /// `make_monitor_snapshot` and invalidated by `invalidate_metadata_cache`
1937 /// whenever a metadata-class field (EGU/PREC/HOPR/LOPR/limit/state)
1938 /// is written.
1939 ///
1940 /// Wrapped in `std::sync::Mutex` for interior mutability — the
1941 /// containing `RecordInstance` is shared via `Arc<RwLock<...>>` from
1942 /// `PvDatabase`, and snapshot construction holds a read lock; the
1943 /// inner Mutex lets us still mutate the cache from a `&self` method.
1944 ///
1945 /// # Cache invariant (CONTRACT)
1946 ///
1947 /// The cache is **only correct under the following contract**: every
1948 /// code path that mutates a cache-source field (the set defined in
1949 /// the file-private [`is_metadata_cache_source`] predicate) MUST call
1950 /// [`RecordInstance::notify_field_written`] (or
1951 /// [`RecordInstance::invalidate_metadata_cache`] directly) afterward.
1952 ///
1953 /// All current write paths in `field_io.rs` already do this. If you
1954 /// add a new code path that:
1955 ///
1956 /// - calls `instance.record.put_field(...)` directly, OR
1957 /// - mutates record fields from inside `Record::process()`,
1958 /// `Record::on_put`, or `Record::special` and that mutation could
1959 /// touch a cache-source field, OR
1960 /// - lets a `Box<dyn Record>` implementation expose its own
1961 /// mutation methods that change cache-source fields,
1962 ///
1963 /// then call `instance.notify_field_written(field_name)` to keep the
1964 /// cache consistent. Forgetting will produce a stale snapshot —
1965 /// monitors will continue to see the old EGU/PREC/limits until the
1966 /// next legitimate cache-source write triggers invalidation.
1967 ///
1968 /// # Symmetric note for `populate_*` extensions
1969 ///
1970 /// If a future change adds a new field to `populate_display_info`,
1971 /// `populate_control_info`, or `populate_enum_info`, the new source
1972 /// field name MUST also be added to [`is_metadata_cache_source`] so
1973 /// writes to it invalidate the cache — unless, like DESC
1974 /// (`display.description`), its write owner invalidates directly (see
1975 /// the DESC arm of `put_common_field`). This set says nothing about
1976 /// `DBE_PROPERTY`, which the field's own `prop(YES)` declaration
1977 /// decides ([`RecordInstance::field_posts_property`]). (The `Q:form`
1978 /// -> `display.form` mapping is exempt: it reads an immutable
1979 /// load-time info tag, not a runtime field.)
1980 pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
1981}
1982
1983/// The cycle status [`RecordInstance::run_registered_subroutine`] reports when
1984/// `do_sub` was skipped — C's `fetch_values` failure / `S_db_BadSub` path, which
1985/// leaves `process`'s `status` non-zero (aSubRecord.c:216-224).
1986const SUBROUTINE_STATUS_SKIPPED: i64 = -1;
1987/// Which C `do_sub` a record type owns. Only `subRecord.c` and `aSubRecord.c`
1988/// define one; every other record type reaches
1989/// [`RecordInstance::run_registered_subroutine`] with no subroutine bound for
1990/// the trivial reason that it never had one, and must not be handed `do_sub`'s
1991/// bad-sub verdict. Resolving the kind once also keeps the two record types'
1992/// three points of divergence (empty-SNAM exemption, bad-sub status, UDF
1993/// clear) reading off one decision instead of three `record_type()` compares.
1994#[derive(Clone, Copy, PartialEq, Eq)]
1995enum SubroutineKind {
1996 /// `subRecord.c::do_sub` — VAL is the subroutine's computed value.
1997 Sub,
1998 /// `aSubRecord.c::do_sub` — VAL is the returned status.
1999 ASub,
2000}
2001
2002impl SubroutineKind {
2003 fn of(record_type: &str) -> Option<Self> {
2004 match record_type {
2005 "sub" => Some(Self::Sub),
2006 "aSub" => Some(Self::ASub),
2007 _ => None,
2008 }
2009 }
2010}
2011
2012/// C `S_db_BadSub` — `(M_dbAccess | 35)` with `M_dbAccess = 511 << 16`
2013/// (`dbAccessDefs.h:189`, `errMdef.h:39`), i.e. 33488931. aSub's `do_sub`
2014/// returns it verbatim for an unregistered SNAM and `process` publishes it as
2015/// VAL, so the number is observable on the wire and cannot be a private
2016/// sentinel.
2017const S_DB_BAD_SUB: i64 = (511 << 16) | 35;
2018/// The bound subroutine returned `Err` — no C counterpart (a C subroutine
2019/// returns a `long`), and a failed cycle either way.
2020const SUBROUTINE_STATUS_ERROR: i64 = -3;
2021
2022/// What a record TYPE answers to the type-static questions C `monitor()`
2023/// asks about it — the deadband field and its mask narrowings, the value
2024/// gate, and whether the record posts its value at all. Every answer is a
2025/// property of the type, so it is read once at construction rather than
2026/// through the `dyn Record` vtable on every cycle. The per-cycle questions
2027/// (`monitor_value_changed`, `process_posted_fields`, the `take_*` one-shot
2028/// masks) stay dynamic.
2029#[derive(Clone, Copy)]
2030pub(crate) struct MonitorPlan {
2031 /// [`Record::process_posts_value_monitor`].
2032 pub(crate) posts_value_monitor: bool,
2033 /// [`Record::uses_monitor_deadband`].
2034 pub(crate) uses_deadband: bool,
2035 /// [`Record::monitor_deadband_field`].
2036 pub(crate) deadband_field: &'static str,
2037 /// The deadband field is in [`Record::value_only_change_fields`] or
2038 /// [`Record::fields_posted_with_monitor_mask`]: C posts it without
2039 /// `DBE_LOG`.
2040 pub(crate) log_suppressed: bool,
2041 /// [`Record::fields_posted_with_value_mask`].
2042 pub(crate) value_masked: &'static [(&'static str, crate::server::record::ValuePostGate)],
2043 /// The change-detected aux fields' mask resolver.
2044 pub(crate) aux_post: AuxPostMask,
2045}
2046
2047impl MonitorPlan {
2048 pub(crate) fn of(record: &dyn Record) -> Self {
2049 let deadband_field = record.monitor_deadband_field();
2050 Self {
2051 posts_value_monitor: record.process_posts_value_monitor(),
2052 uses_deadband: record.uses_monitor_deadband(),
2053 deadband_field,
2054 log_suppressed: record.value_only_change_fields().contains(&deadband_field)
2055 || record
2056 .fields_posted_with_monitor_mask()
2057 .contains(&deadband_field),
2058 value_masked: record.fields_posted_with_value_mask(),
2059 aux_post: AuxPostMask::of(record),
2060 }
2061 }
2062}
2063
2064/// What one `monitor()` hands its publisher: the value snapshot (only the
2065/// fields somebody subscribes to carry a value) and the `recGblResetAlarms`
2066/// posts with their per-field C masks.
2067pub(crate) struct MonitorOutcome {
2068 pub(crate) snapshot: ProcessSnapshot,
2069 pub(crate) alarm_posts: crate::server::database::AlarmPosts,
2070}
2071
2072/// C `monitor()`'s post of the deadband field, as assembled by the single owner
2073/// [`RecordInstance::deadband_post`].
2074pub(crate) struct DeadbandPost {
2075 /// C's `monitor_mask` for this cycle. Also the mask the
2076 /// [`Record::fields_posted_with_value_mask`] secondaries ride: C posts them
2077 /// from INSIDE the `if (monitor_mask)` guard, with the same mask.
2078 pub mask: EventMask,
2079 /// The deadband field's own post — `(field, value)`. `None` when no class
2080 /// fired (C's `if (monitor_mask)` skips the post) or the field does not
2081 /// resolve. The name is the one [`Record::monitor_deadband_field`] already
2082 /// hands out as a `&'static str`; owning a copy of it charged a malloc per
2083 /// cycle for a string that is in the binary.
2084 pub field: Option<(&'static str, EpicsValue)>,
2085}
2086
2087/// A value's `DBR_STRING` form, for a source whose field metadata is NOT
2088/// reachable (an external CA/PVA link, a constant, an lnkCalc result) — the
2089/// fallback half of [`RecordInstance::field_as_dbr_string`], which is also the
2090/// whole rule once the local choice table has had its say.
2091///
2092/// A pvalink NTEnum still resolves its label here: the carrier brings its own
2093/// `choices` (pvxs `pvxs/ioc/pvalink_lset.cpp:344-356` — a `DBR_STRING` target copies
2094/// `choices[index]`). A bare `Enum` index from a link whose labels the port
2095/// cannot reach falls back to its decimal form, like the CA `*_STRING` encoder.
2096/// **The** declaration lookup: a field's `dbFldDes`, in C's terms.
2097///
2098/// The record type's declaration is [`FieldDeclaration::field_list`] — the
2099/// generated `.dbd` table where one exists and the hand-written table where it
2100/// does not, never both. `dbCommon` is asked last, so a record-specific field
2101/// shadows the common one.
2102///
2103/// A free function, not just a [`RecordInstance`] method, because the sites that
2104/// need the declaration do not all hold an instance — a constant-link seed, a
2105/// link write, the db loader all have `&dyn Record`.
2106///
2107/// `None` for a field with no declaration at all: a virtual field (`RTYP`,
2108/// `TIME`, ...), which C answers from dbStaticLib rather than from a `dbFldDes`.
2109pub(crate) fn field_desc_of<R: Record + ?Sized>(
2110 record: &R,
2111 field: &str,
2112) -> Option<&'static FieldDesc> {
2113 field_desc_in(record.field_list(), field)
2114}
2115
2116/// [`field_desc_of`] against a type's table — C `dbFindFieldPart`: the
2117/// record type's own `.dbd` table, then `dbCommon`.
2118pub(crate) fn field_desc_in(
2119 fields: &'static [FieldDesc],
2120 field: &str,
2121) -> Option<&'static FieldDesc> {
2122 let named = |t: &'static [FieldDesc]| t.iter().find(|f| f.name.eq_ignore_ascii_case(field));
2123 named(fields).or_else(|| named(super::dbd_generated::DB_COMMON_FIELDS))
2124}
2125
2126/// **The single owner of "which choice list does this field resolve against"**,
2127/// asked by BOTH sides of a menu field:
2128///
2129/// * the READ side ([`RecordInstance::enum_string_form_for`]) — C `getMenuString`,
2130/// which renders the stored index as its choice;
2131/// * the WRITE side ([`crate::server::record::coerce_put_value`]) — C
2132/// `putStringMenu`, which resolves an incoming label to that same index.
2133///
2134/// They MUST see the same list or the field is not round-trippable. The write
2135/// side used to ask only [`Record::menu_field_choices`] (the record's hand
2136/// table), so an `aSub`'s `caput FTA LONG` found no menu, fell through to the
2137/// numeric parse and landed as index 0 — while the read side, on the `.dbd`
2138/// menu, rendered index 0 as `STRING`.
2139///
2140/// Order is C's: the field's own `menu()` from the declaration, then the
2141/// record's hand table where the `.dbd` does not reach (the downstream crates'
2142/// record types), then the `dbCommon` menus.
2143///
2144/// The last step — [`shared_menu_choices`](super::menu_choices::shared_menu_choices)
2145/// — is a heuristic keyed on the field NAME (`OSV`, `SIMS`, `HHSV`, …), so it is
2146/// consulted ONLY when the field's declaration does not already pin a non-menu
2147/// type: a menu is served as `DBR_ENUM`, so a field DECLARED `DBF_STRING` is
2148/// never a menu. Without this gate scalcout's string `OSV` ("Output string
2149/// value") matched the name-based `menuAlarmSevr` entry a same-named bi/bo
2150/// severity field owns, and `caput SCALCOUT.OSV <string>` was rejected with
2151/// `S_db_badChoice` where C accepts the string.
2152pub(crate) fn menu_choices_of<R: Record + ?Sized>(
2153 record: &R,
2154 field: &str,
2155) -> Option<&'static [&'static str]> {
2156 // `DTYP` is `DBF_DEVICE`: its choices are the record type's DEVICE menu
2157 // (C `dbDeviceMenu`, built from the `device()` declarations), which is
2158 // per-record-type and so cannot live in the shared `dbCommon` FieldDesc.
2159 if field.eq_ignore_ascii_case("DTYP") {
2160 return super::dbd_generated::device_menu(record.record_type());
2161 }
2162 let desc = field_desc_of(record, field);
2163 desc.and_then(|f| f.menu)
2164 .or_else(|| record.menu_field_choices(field))
2165 .or_else(|| {
2166 // Name-based fallback — but the declared type wins: a `DBF_STRING`
2167 // field is not a menu even when a same-named field elsewhere is.
2168 if desc.is_some_and(|f| f.dbf_type == DbFieldType::String) {
2169 None
2170 } else {
2171 super::menu_choices::shared_menu_choices(field)
2172 }
2173 })
2174}
2175
2176/// The `DBF_*` type `field` is SERVED as, for a caller that holds only a
2177/// `&dyn Record` — the free-function form of
2178/// [`RecordInstance::declared_field_type`], and the ONLY way any site outside
2179/// the instance may turn a [`FieldDesc`] into a type.
2180///
2181/// A [`FieldDesc::runtime_typed`] field (`waveform.VAL` typed by `FTVL`, an
2182/// `aSub`'s `A`..`U` typed by `FTA`..`FTU`) has NO type in its declaration: C's
2183/// `cvt_dbaddr` overwrites `paddr->field_type` from record state, so the `.dbd`
2184/// entry is a placeholder and the value the record stores is the answer. Every
2185/// caller falls back to that value, which is why this returns `None` rather than
2186/// the placeholder — handing out `DBF_DOUBLE` for a `FTVL=CHAR` waveform is how
2187/// a string written down an output link became `0.0`.
2188pub(crate) fn declared_field_type_of<R: Record + ?Sized>(
2189 record: &R,
2190 field: &str,
2191) -> Option<DbFieldType> {
2192 let desc = field_desc_of(record, field)?;
2193 (!desc.runtime_typed).then_some(desc.dbf_type)
2194}
2195
2196pub(crate) fn value_as_dbr_string(value: &EpicsValue) -> Option<PvString> {
2197 match value {
2198 EpicsValue::String(s) => Some(s.clone()),
2199 EpicsValue::Enum(v) => Some(PvString::from(v.to_string())),
2200 EpicsValue::EnumWithChoices { index, choices } => Some(
2201 choices
2202 .get(*index as usize)
2203 .cloned()
2204 .unwrap_or_else(|| PvString::from(index.to_string())),
2205 ),
2206 other => match other.clone().convert_to(DbFieldType::String) {
2207 EpicsValue::String(s) => Some(s),
2208 _ => None,
2209 },
2210 }
2211}
2212
2213/// The `dbCommon` link fields, each with the C link-field type its text is
2214/// parsed under (`dbStaticLib.c:2380-2391`): `INP`/`TSEL`/`SDIS` are
2215/// `DBF_INLINK`, `OUT` is `DBF_OUTLINK`, `FLNK` is `DBF_FWDLINK`.
2216///
2217/// These five — and only these five — have a parse cache on
2218/// [`RecordInstance`], which is what lets a one-shot init decision (C
2219/// `dbInitLink` setting `DBLINK_FLAG_INITIALIZED`) be committed for them.
2220/// The list has one owner because it is read from three places that must not
2221/// drift: the per-field parse in `put_common_field`, the database's
2222/// `record_link_fields` enumeration, and the `initialize_link_locality`
2223/// commit. `FLNK` missing from just one of them is exactly how an external
2224/// forward link went un-opened at init.
2225pub const COMMON_LINK_FIELDS: [(&str, super::link::LinkFieldType); 5] = [
2226 ("INP", super::link::LinkFieldType::In),
2227 ("OUT", super::link::LinkFieldType::Out),
2228 ("TSEL", super::link::LinkFieldType::In),
2229 ("SDIS", super::link::LinkFieldType::In),
2230 ("FLNK", super::link::LinkFieldType::Fwd),
2231];
2232
2233impl RecordInstance {
2234 pub fn new(name: String, record: impl Record) -> Self {
2235 Self::new_boxed(name, Box::new(record))
2236 }
2237
2238 /// The raw text of one `COMMON_LINK_FIELDS` entry, or `None` for any
2239 /// other field name.
2240 pub fn common_link_text(&self, field: &str) -> Option<&str> {
2241 Some(match field {
2242 "INP" => self.common.inp.as_str(),
2243 "OUT" => self.common.out.as_str(),
2244 "TSEL" => self.common.tsel.as_str(),
2245 "SDIS" => self.common.sdis.as_str(),
2246 "FLNK" => self.common.flnk.as_str(),
2247 _ => return None,
2248 })
2249 }
2250
2251 /// The parse cache of one `COMMON_LINK_FIELDS` entry, or `None` for any
2252 /// other field name. The only mutable handle on the cache outside
2253 /// `put_common_field`, so the iocInit locality commit cannot reach a slot
2254 /// that has no matching raw text.
2255 pub fn common_link_cache_mut(&mut self, field: &str) -> Option<&mut ParsedLink> {
2256 Some(match field {
2257 "INP" => &mut self.parsed_inp,
2258 "OUT" => &mut self.parsed_out,
2259 "TSEL" => &mut self.parsed_tsel,
2260 "SDIS" => &mut self.parsed_sdis,
2261 "FLNK" => &mut self.parsed_flnk,
2262 _ => return None,
2263 })
2264 }
2265
2266 /// The link fields whose target metadata this record's rset serves — the
2267 /// work list [`PvDatabase::resolve_link_backed_metadata`] resolves for a
2268 /// batch post, and the set `Self::link_backed_metadata_field_of` answers
2269 /// one field out of.
2270 ///
2271 /// [`PvDatabase::resolve_link_backed_metadata`]: crate::server::database::PvDatabase
2272 pub fn link_backed_metadata_links(&self) -> &[String] {
2273 &self.link_backed_metadata_links
2274 }
2275
2276 /// Where each [`Self::link_backed_metadata_links`] entry sits in the
2277 /// record's multi-input link list, in that same order. See the field.
2278 pub(crate) fn link_backed_metadata_input_slots(&self) -> &[Option<usize>] {
2279 &self.link_backed_metadata_input_slot
2280 }
2281
2282 /// C `monitor()` and the `recGblResetAlarms` it opens with, for every
2283 /// path that ends a process cycle. The single owner of the cycle's
2284 /// monitor STATE — alarm commit, MLST/ALST, the previous-value store, the
2285 /// record's one-shot post masks — and of the posts it hands the
2286 /// publisher. The state half runs whether or not anyone subscribes;
2287 /// the per-field payload is built by [`Self::collect_subscriber_posts`]
2288 /// only for subscribed fields, decided under the guard the caller
2289 /// delivers with, so no post is decided on one lock and delivered on
2290 /// another.
2291 pub(crate) fn monitor_cycle(&mut self) -> MonitorOutcome {
2292 let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut self.common);
2293 // C `recGblResetAlarms` returns `val_mask = DBE_ALARM`
2294 // (recGbl.c:194/203/212) when the severity/status OR the alarm
2295 // message moved — every monitored-value post this cycle carries
2296 // DBE_ALARM so a `DBE_ALARM`-only subscriber sees the value at the
2297 // moment the alarm changed.
2298 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
2299 EventMask::ALARM
2300 } else {
2301 EventMask::NONE
2302 };
2303
2304 let (include_val, include_archive) = self.value_include_classes();
2305 // The deadband-tracked field posts with the classes that actually
2306 // fired: MDEL crossing → DBE_VALUE, ADEL crossing → DBE_LOG, alarm
2307 // movement → DBE_ALARM — and nothing else (C `monitor()` per-field
2308 // masks: motorRecord.cc:3476-3507 RBV, aiRecord.c VAL). A record
2309 // like motor deadbands its readback; its VAL then routes through
2310 // the change-detected loop in `collect_subscriber_posts`.
2311 let deadband = self.deadband_post(alarm_bits, include_val, include_archive);
2312 let mut snapshot = ProcessSnapshot::new();
2313 if let Some((field, value)) = deadband.field {
2314 snapshot.push((field.into(), value, deadband.mask));
2315 }
2316 self.collect_subscriber_posts(&mut snapshot, deadband.mask, alarm_bits, include_val);
2317 // C waveform/aai/aao `monitor()` posts HASH with a literal
2318 // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
2319 // 317-319), independent of the VAL post mask. `array_hash_changed`
2320 // was set by `check_deadband_ext` this cycle.
2321 if self.array_hash_changed {
2322 if let Some(h) = self.resolve_field("HASH") {
2323 snapshot.push(("HASH".into(), h, EventMask::VALUE));
2324 }
2325 }
2326 // NO `.UDF` post. C `monitor()` never posts UDF, and neither does
2327 // `recGblResetAlarms` (recGbl.c:202-222 posts SEVR/STAT/AMSG/ACKS
2328 // only). UDF reaches a `.UDF` subscriber only through the generic
2329 // put path (C `dbPut` posts the field it wrote, dbAccess.c:1411-1413).
2330 let alarm_posts = crate::server::database::alarm_field_posts(&self.common, &alarm_result);
2331 MonitorOutcome {
2332 snapshot,
2333 alarm_posts,
2334 }
2335 }
2336
2337 /// This cycle's forward link, resolved from the two things C's
2338 /// `dbScanFwdLink` reads: the record's own veto
2339 /// ([`Record::should_fire_forward_link`]) and `FLNK`'s parsed kind.
2340 ///
2341 /// The single owner of that question. The DB half and the external half
2342 /// used to be derived at separate points — the DB name under the monitor
2343 /// segment's guard, the external PV by `dispatch_external_forward_link`
2344 /// re-taking the record's read lock in the tail — so every record in the
2345 /// database paid a second acquisition and a second `should_fire_forward_link`
2346 /// call once a cycle, and the two derivations could disagree about a
2347 /// record whose veto changed in between.
2348 pub(crate) fn forward_target(&self) -> ForwardTarget {
2349 if !self.record.should_fire_forward_link() {
2350 return ForwardTarget::None;
2351 }
2352 match &self.parsed_flnk {
2353 ParsedLink::Db(l) => ForwardTarget::Db {
2354 name: l.target().record.clone(),
2355 putf: self.common.putf,
2356 notify: self.notify.clone(),
2357 },
2358 ParsedLink::Pva(_) | ParsedLink::PvaJson(_) | ParsedLink::Ca(_) => self
2359 .parsed_flnk
2360 .external_pv_name()
2361 .map_or(ForwardTarget::None, |s| {
2362 ForwardTarget::External(s.to_string())
2363 }),
2364 // Constant / Hw / Calc / None carry no forward action.
2365 _ => ForwardTarget::None,
2366 }
2367 }
2368
2369 /// Does this record's `.dbd` declare the simulation block — the gate C's
2370 /// `readValue`/`writeValue` exist behind. See the field.
2371 pub(crate) fn declares_simulation(&self) -> bool {
2372 self.declares_simulation
2373 }
2374
2375 /// The parse of multi-input link `slot` for a reader that holds the
2376 /// record shared: the cached one when its text is the record's current
2377 /// text, a fresh parse otherwise — never written back, since the fetch
2378 /// stage that runs under the exclusive guard validates the entry itself.
2379 pub(crate) fn cached_multi_input(
2380 &self,
2381 slot: usize,
2382 link_field: &str,
2383 ) -> Option<Arc<ParsedLink>> {
2384 let owned;
2385 let text: &str = match self.record.link_text_ref(link_field) {
2386 Some("") => return None,
2387 Some(text) => text,
2388 None => {
2389 owned = self.link_text(link_field)?;
2390 &owned
2391 }
2392 };
2393 match self.parsed_inputs.get(slot) {
2394 Some(Some(entry)) if entry.text == text => Some(entry.parsed.clone()),
2395 _ => Some(Arc::new(parse_link_v2(text))),
2396 }
2397 }
2398
2399 /// The text of a link field, `None` when the link is unset.
2400 ///
2401 /// A declared link is unset far more often than set — a `calc` declares 21
2402 /// inputs and a stock database wires none of them — and the process cycle
2403 /// asks every declared link on every pass, so the unset answer is the one
2404 /// that has to be cheap. Returning `Option` rather than an empty `String`
2405 /// is what makes it so at the call sites: there is no empty text to test,
2406 /// so nothing the caller wants only for a set link — a cloned field name,
2407 /// a parse, a `Vec` entry — can be built before the answer is known. Each
2408 /// of the six link reads this replaced did build something first and throw
2409 /// it away.
2410 #[inline]
2411 pub(crate) fn link_text(&self, field: &str) -> Option<String> {
2412 link_text_of(&*self.record, field)
2413 }
2414
2415 /// Whether link field `field` holds a text — [`Self::link_text`] without
2416 /// the copy.
2417 pub(crate) fn link_is_set(&self, field: &str) -> bool {
2418 match self.record.link_text_ref(field) {
2419 Some(text) => !text.is_empty(),
2420 None => self.link_text(field).is_some(),
2421 }
2422 }
2423
2424 pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
2425 let rtype = record.record_type();
2426 // The reverse index of `Record::link_backed_metadata_field`, built once
2427 // from the record's own declaration so no second list can go stale.
2428 // Empty for every record type that answers `None` — which is all but
2429 // calc, calcout, sub, seq and aSub.
2430 let link_backed_metadata_links: Vec<String> = {
2431 use crate::server::record::FieldDeclaration;
2432 let mut links: Vec<String> = record
2433 .field_list()
2434 .iter()
2435 .filter_map(|d| record.link_backed_metadata_field(d.name))
2436 .collect();
2437 links.sort_unstable();
2438 links.dedup();
2439 links
2440 };
2441 // See the field's own doc: fixed by the record TYPE, both sides of it.
2442 let link_backed_metadata_input_slot: Vec<Option<usize>> = link_backed_metadata_links
2443 .iter()
2444 .map(|lf| {
2445 record
2446 .multi_input_links()
2447 .iter()
2448 .position(|(mf, _)| mf == lf)
2449 })
2450 .collect();
2451 // The gate on the whole simulation block — see the field's own doc.
2452 let declares_simulation = field_desc_of(record.as_ref(), "SIMM").is_some();
2453 let udf_alarm = record.raises_udf_alarm().then(|| UdfAlarm {
2454 exact_one: record.udf_alarm_on_exact_one(),
2455 severity: record.udf_alarm_severity(),
2456 message: record.udf_alarm_message(),
2457 });
2458 let monitor_plan = MonitorPlan::of(record.as_ref());
2459 let analog_alarm = match rtype {
2460 // C parity: every record type whose dbd carries
2461 // HIHI/HIGH/LOW/LOLO/HHSV/HSV/LSV/LLSV gets an analog-alarm
2462 // config slot. Previously calc / calcout were missing —
2463 // their put_field for those fields silently no-op'd
2464 // because `self.common.analog_alarm` was None at the
2465 // mutation site. Confirmed via
2466 // calcRecord.dbd.pod:716-744 (HIHI..LLSV) and
2467 // calcoutRecord.dbd.pod:1103+ (same). `sub` carries the same
2468 // HIHI/HIGH/LOLO/LOW + HHSV/HSV/LSV/LLSV set
2469 // (subRecord.dbd.pod:569-642) and runs the analog `checkAlarms`.
2470 // `scalcout` declares the identical set (`sCalcoutRecord.dbd:479-531`
2471 // HIHI/LOLO/HIGH/LOW/HHSV/LLSV/HSV/LSV/HYST + `:858` LALM) and its
2472 // `checkAlarms` (`sCalcoutRecord.c:699-752`) is the same ladder, run
2473 // BEFORE the OOPT switch (`:374`) precisely so a limit excursion can
2474 // drive IVOA. Without the slot the record had no alarm surface at
2475 // all: `caput scalc.HIHI 5` was a `FieldNotFound` and a scalcout
2476 // could never go MINOR/MAJOR on its own result.
2477 //
2478 // **This match is the single owner of "which records have the analog
2479 // ladder"** — `evaluate_alarms` runs it off the slot's presence, so a
2480 // record added here gets the ladder and one absent cannot.
2481 "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout"
2482 | "sub" | "scalcout" => Some(AnalogAlarmConfig::default()),
2483 _ => None,
2484 };
2485 let mut common = CommonFields::default();
2486 common.analog_alarm = analog_alarm;
2487 let multi_input_slots = record.multi_input_links().len();
2488
2489 Self {
2490 destroyed: false,
2491 name,
2492 record,
2493 common,
2494 subscribers: HashMap::new(),
2495 parsed_inp: ParsedLink::None,
2496 parsed_out: ParsedLink::None,
2497 parsed_flnk: ParsedLink::None,
2498 parsed_sdis: ParsedLink::None,
2499 parsed_tsel: ParsedLink::None,
2500 parsed_inputs: (0..multi_input_slots).map(|_| None).collect(),
2501 device: None,
2502 subroutine: None,
2503 init_subroutines: None,
2504 pact: AtomicBool::new(false),
2505 notify: None,
2506 notify_restart_list: std::collections::VecDeque::new(),
2507 last_posted: HashMap::new(),
2508 declared_overrides: HashMap::new(),
2509 link_backed_metadata_links,
2510 link_backed_metadata_input_slot,
2511 declares_simulation,
2512 udf_alarm,
2513 monitor_plan,
2514 array_hash_changed: false,
2515 suppress_subroutine_run: false,
2516 reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2517 watchdog_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2518 info: HashMap::new(),
2519 metadata_cache: StdMutex::new(None),
2520 }
2521 }
2522
2523 /// **The owner of a record's init passes** — C `iocInit.c::doInitRecord0`
2524 /// (`:508-536`) and `doInitRecord1`. Nothing else may call
2525 /// `Record::init_record`.
2526 ///
2527 /// C runs a prologue on EVERY record before pass 0, and it is the reason
2528 /// this is one function instead of two `init_record` calls at each caller:
2529 ///
2530 /// ```c
2531 /// /* Reset the process active field */
2532 /// precord->pact = FALSE;
2533 ///
2534 /// /* Initial UDF severity */
2535 /// if (precord->udf && precord->stat == UDF_ALARM)
2536 /// precord->sevr = precord->udfs;
2537 /// ```
2538 ///
2539 /// A record is born `udf = 1`, `stat = UDF_ALARM` (dbCommon.dbd
2540 /// `initial("UDF")`), `udfs = INVALID` — so after `iocInit` a record that
2541 /// has NEVER processed advertises `STAT=UDF SEVR=INVALID`, not
2542 /// `NO_ALARM`. That is what makes an `MS` consumer inherit
2543 /// `LINK`/`INVALID` from a not-yet-processed source, the IOC-startup
2544 /// ordering case MS exists for (softIoc-verified). A record whose
2545 /// `init_record` or device support defines the value clears UDF and the
2546 /// severity goes away on its first process.
2547 ///
2548 /// `name` is used only for the init-failure diagnostics C sends to errlog.
2549 ///
2550 /// Crate-private on purpose: the passes must run against the record's FINAL
2551 /// loaded field set (the initial UDF severity is a function of UDF/STAT/
2552 /// UDFS, and a `.db` `field(VAL,…)` clears UDF at load — C
2553 /// `dbStaticLib.c:2653-2661`). The one caller is the creation sink,
2554 /// [`crate::server::database::PvDatabase::add_loaded_record`], which takes
2555 /// the load and the record together so no path can init a half-loaded
2556 /// record.
2557 /// Hand the record the function registry its pending init pass 1 resolves
2558 /// INAM/SNAM against. The creation sink's line; see [`Self::
2559 /// init_subroutines`].
2560 pub(crate) fn arm_init_subroutines(
2561 &mut self,
2562 registry: Arc<HashMap<String, Arc<SubroutineFn>>>,
2563 ) {
2564 self.init_subroutines = Some(registry);
2565 }
2566
2567 /// C's `if (!pdset) { recGblRecordError(S_dev_noDSET, prec, "init_record");
2568 /// return S_dev_noDSET; }` — whether this record's `init_record` gets past
2569 /// its own first statement.
2570 ///
2571 /// Three terms, each one C's:
2572 /// * [`crate::server::recgbl::dev_sup_refusal`] IS the set of record types
2573 /// whose `init_record` opens with that test — the same table that
2574 /// supplies the message, so the refusal and the return can never
2575 /// disagree about which types make it.
2576 /// * a soft DTYP (`""`, `Soft Channel`, `Raw Soft Channel`, `Async Soft
2577 /// Channel`) resolves to a dset C always links, so the test passes.
2578 /// `""` counts because C's DTYP index 0 is the record type's FIRST
2579 /// `device()` line, which for every soft record type is the soft
2580 /// channel.
2581 /// * anything else needs a device the resolver produced. `None` after the
2582 /// creation sink has run its bind is C's `pdevSup == NULL`.
2583 pub(crate) fn init_record_reaches_body(&self) -> bool {
2584 self.device.is_some()
2585 || self.common.dtyp.is_soft()
2586 || crate::server::recgbl::dev_sup_refusal(self.record.record_type()).is_none()
2587 }
2588
2589 /// C `registryFunctionFind` inside `init_record` pass 1, for the record
2590 /// types that make it. Returns whether the pass reached its tail.
2591 ///
2592 /// Unarmed (`init_subroutines` is `None`) means no registry was handed
2593 /// over — an iocsh `dbLoadRecords` merge re-running the passes on a record
2594 /// that already resolved. Nothing to look up again, and C's tail is
2595 /// reached.
2596 fn resolve_init_subroutine(&mut self, name: &str) -> bool {
2597 match self.init_subroutines.take() {
2598 Some(registry) => crate::server::ioc_app::wire_subroutine(self, name, ®istry),
2599 None => true,
2600 }
2601 }
2602
2603 pub(crate) fn run_init_passes(&mut self, name: &str) {
2604 // C's `precord->pact = FALSE` — a record cannot be mid-process at init,
2605 // so this release provably frees nothing: no client put has run, so the
2606 // restart list is empty.
2607 debug_assert!(
2608 self.notify_restart_list.is_empty(),
2609 "a record cannot hold a queued put-notify at init"
2610 );
2611 let _ = self.leave_pact();
2612 if self.common.udf != 0
2613 && self.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM
2614 {
2615 self.common.sevr = AlarmSeverity::from_u16(self.common.udfs as u16);
2616 }
2617 // C `<rec>Record.c::init_record`'s FIRST statement, for the 26 record
2618 // types that make it: `if (!pdset) { recGblRecordError(S_dev_noDSET,
2619 // …); return S_dev_noDSET; }` (`aiRecord.c:105-110`,
2620 // `aoRecord.c:107-110`, …). Everything below it — `ao`'s `prec->init =
2621 // TRUE`, `ai`'s and `sub`'s MLST/ALST/LALM seed, `mbbo`'s SDEF fold —
2622 // is unreachable for a record whose dset is NULL, and running the
2623 // passes anyway is what left those cells set where softIoc reads 0.
2624 //
2625 // It is the OWNER's test, not each record's, for one structural
2626 // reason: `Record::init_record` is on the record BODY, which cannot
2627 // see `RecordInstance::device`. Asking every record type to re-derive
2628 // the answer is the per-cell patch this replaces.
2629 if !self.init_record_reaches_body() {
2630 return;
2631 }
2632 if let Err(e) = self.record.init_record(0) {
2633 eprintln!("init_record(0) failed for {name}: {e}");
2634 }
2635 // C `iocInit.c::doResolveLinks` (`:545-570`), the pass BETWEEN the two
2636 // `init_record` calls: for each device link the record type declares,
2637 // `pdsxt->add_record(precord)` and then `dbInitLink` for that same
2638 // link. This is that call, and it keeps C's position — before the link
2639 // it complains about is initialised, after pass 0 has run.
2640 crate::server::builtin_devices::soft_callback::add_record(self, name);
2641 if let Err(e) = self.record.init_record(1) {
2642 eprintln!("init_record(1) failed for {name}: {e}");
2643 }
2644 // C `pdset->common.init_record(prec)` — the driver's own init, which
2645 // every record type calls from INSIDE `init_record` once the dset test
2646 // above has passed (`aiRecord.c:115-124`, `aoRecord.c:121-133`). It
2647 // ran after both passes and after the constant-link seed while device
2648 // support was attached by a whole-database pass; it is here now
2649 // because the dset is bound before the passes. Residual difference
2650 // from C, not closed by this move: C calls it BEFORE the record's own
2651 // tail (`prec->mlst = prec->val`) and the port's tail is inside
2652 // `init_record(1)`, so a driver `init` that defines VAL is still not
2653 // reflected in the trackers seeded a line earlier.
2654 crate::server::device_support::init_device_support(self);
2655 // C `subRecord.c:107-129` / `aSubRecord.c:139-160` — the INAM call and
2656 // the SNAM lookup, also inside pass 1. `false` is C's early return
2657 // (`S_db_BadSub`, or the empty-SNAM `pact = TRUE; return 0`), so the
2658 // tail below is skipped exactly where C skips it.
2659 if !self.resolve_init_subroutine(name) {
2660 return;
2661 }
2662 // The UDF tail of pass 1. `init_record` cannot reach UDF (a common
2663 // field), so the record types whose C `init_record` ends in
2664 // `prec->udf = FALSE` — histogram's `clear_histogram`, aao's constant
2665 // DOL, mbboDirect's B0..B1F fold (epics-base dabcf89) — deliver it
2666 // through this hook instead. It lives HERE, inside the init owner,
2667 // because it is part of the same C pass: a creation path that ran the
2668 // passes but skipped the tail (iocsh `dbLoadRecords` did) left those
2669 // records UDF=1 where C has UDF=0.
2670 // The `post_init_finalize_undef` hook is a cross-crate record-trait API
2671 // over a `bool` (histogram/aao/mbboDirect implement it); bridge the raw
2672 // `u8` carrier through it here at the single init owner.
2673 let mut udf = self.common.udf != 0;
2674 if let Err(e) = self.record.post_init_finalize_undef(&mut udf) {
2675 eprintln!("post_init_finalize_undef failed for {name}: {e}");
2676 }
2677 self.common.udf = udf as u8;
2678 // C `init_record` that ends in `prec->udf = 0; recGblResetAlarms(prec)`
2679 // — the asyn record, defined and no-alarm the moment it loads. The born
2680 // `UDF`/`INVALID` (and the UDF-severity derivation above) are overwritten
2681 // here: at init `nsta`/`nsev` are 0, so `rec_gbl_reset_alarms` transfers
2682 // `STAT`/`SEVR` to `NO_ALARM`. Runs after `post_init_finalize_undef` so
2683 // it is the final word on this record's initial alarm state.
2684 if self.record.init_resets_alarms() {
2685 self.common.udf = 0;
2686 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut self.common);
2687 }
2688 // C `init_record` can park a record it cannot process with `prec->pact
2689 // = TRUE`. The only such line in base is `subRecord.c:119-123`, and it
2690 // sits WITH the empty-SNAM report and the return, so the resolution
2691 // step above performs it: a record whose INAM missed returned before
2692 // that line and is not parked however empty its SNAM is (softIoc reads
2693 // `PACT: 0`). This asks the record type for the same transition on the
2694 // paths that reach the tail — an iocsh `dbLoadRecords` merge re-running
2695 // the passes with no registry to resolve against. It is after the
2696 // passes so the `leave_pact()` above cannot undo it, and the park is
2697 // not permanent: a put to a `pact_park_fields()` field re-asks, the way
2698 // C's `special()` does.
2699 if self.record.parks_pact() {
2700 self.enter_pact();
2701 }
2702 }
2703
2704 /// SINGLE OWNER of the DTYP -> soft-output-dset mapping. The dset table
2705 /// decides what a soft OUT-link write carries; no caller may re-derive it.
2706 ///
2707 /// C ships two soft output dsets per output record type and DTYP picks one:
2708 /// `devXxxSoft.c::write_xxx` puts VAL/OVAL on the OUT link, while
2709 /// `devXxxSoftRaw.c::write_xxx` puts the RAW word — `dbPutLink(&prec->out,
2710 /// DBR_LONG, &prec->rval, 1)` (`devAoSoftRaw.c:44`, `devBoSoftRaw.c:65`) or
2711 /// `data = prec->rval & prec->mask` (`devMbboSoftRaw.c:71-75`,
2712 /// `devMbboDirectSoftRaw.c:71-75`).
2713 ///
2714 /// `Record::raw_soft_output_value` IS the SoftRaw column of that table:
2715 /// `Some` exactly for the record types C ships a SoftRaw dset for. A record
2716 /// type C has no SoftRaw dset for keeps the plain soft-channel value —
2717 /// `DTYP="Raw Soft Channel"` on a `longout` is a `.db` error C rejects at
2718 /// init ("no device support"), and the port's lenient reading of it (the
2719 /// same one [`crate::server::device_support::is_soft_dtyp`] already applies
2720 /// on the input side) must not turn the write into a silent no-op.
2721 ///
2722 /// `None` means DTYP names device support that owns the write — real
2723 /// hardware. "Async Soft Channel" is NOT that: C's
2724 /// `devXxxSoftCallback.c::write_xxx` puts the same VAL/OVAL the plain soft
2725 /// dset puts, only through `dbPutLinkAsync` (`devAoSoftCallback.c:49`,
2726 /// `devLoSoftCallback.c:49`), and falls back to a synchronous `dbPutLink`
2727 /// when the link has no LSET. Returning `None` for it made every
2728 /// `DTYP("Async Soft Channel")` output record write nothing at all —
2729 /// measured on `pva2pva/testApp/testpvalink.db:30-35`, whose `longout`
2730 /// drives a pva OUT link that never fired.
2731 pub fn soft_output_value(&self) -> Option<Option<EpicsValue>> {
2732 use crate::server::device_support::SoftDtyp;
2733 match self.common.dtyp.soft()? {
2734 SoftDtyp::Raw => Some(
2735 self.record
2736 .raw_soft_output_value()
2737 .or_else(|| self.record.output_link_value()),
2738 ),
2739 SoftDtyp::Plain | SoftDtyp::Async => Some(self.record.output_link_value()),
2740 }
2741 }
2742
2743 /// Set a single `info("key", "value")` tag on this record. Last
2744 /// write wins. Used by the .db loader (`info(...)` directive) and
2745 /// `dbpf`-style tools.
2746 pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
2747 self.info.insert(key.into(), value.into());
2748 }
2749
2750 /// Look up a single info tag. Returns `None` when the record has
2751 /// no tag with that key.
2752 pub fn get_info(&self, key: &str) -> Option<&str> {
2753 self.info.get(key).map(|s| s.as_str())
2754 }
2755
2756 /// The value of `field` already published to its `DBE_VALUE`/`DBE_LOG`
2757 /// subscribers, or `None` when the framework has never published one.
2758 /// The read side of the `last_posted` contract — see the field's docs.
2759 pub(crate) fn posted_value(&self, field: &str) -> Option<&EpicsValue> {
2760 self.last_posted.get(field)
2761 }
2762
2763 /// SINGLE OWNER of `last_posted`: record that `value` has been published
2764 /// to `field`'s `DBE_VALUE`/`DBE_LOG` subscribers, so no later cycle
2765 /// change-detects and re-publishes it.
2766 ///
2767 /// Every value-class post — the snapshot builders' change-detected posts,
2768 /// the intermediate async-notify posts, and the put-time
2769 /// [`Self::notify_field_with_origin`] post that C makes from `dbPut`
2770 /// (dbAccess.c:1414) — routes through here. Alarm-only / property-only
2771 /// posts MUST NOT call it: they deliver nothing to a value-class
2772 /// subscriber, so the value is still owed.
2773 pub(crate) fn record_value_post(&mut self, field: &str, value: EpicsValue) {
2774 if let Some(slot) = self.last_posted.get_mut(field) {
2775 *slot = value;
2776 } else {
2777 self.last_posted.insert(field.to_string(), value);
2778 }
2779 }
2780
2781 /// Invalidate the metadata cache. Called after writing any
2782 /// metadata-class field (EGU, PREC, HOPR/LOPR, alarm limits,
2783 /// DRVH/DRVL, enum strings). The next snapshot will rebuild the
2784 /// cache from the new values.
2785 pub fn invalidate_metadata_cache(&self) {
2786 if let Ok(mut guard) = self.metadata_cache.lock() {
2787 *guard = None;
2788 }
2789 }
2790
2791 /// **The** `DBE_PROPERTY` gate: C `dbAccess.c:1330`
2792 /// `paddr->pfldDes->prop`, read from the field's own declaration.
2793 ///
2794 /// C never consults a list of field names — it asks the `.dbd`, per record
2795 /// type, which is why `histogram.ULIM` is a property and `bi.ZSV` (declared
2796 /// `pp(TRUE)`, no `prop`) is not, and why `bi.ZNAM` is one while
2797 /// `busy.ZNAM` is not. Asking [`Self::field_desc`] gives the port the same
2798 /// per-type answer from the same generated `.dbd` tables.
2799 ///
2800 /// A field with no declaration at all — a virtual field (`RTYP`, `TIME`) —
2801 /// has no `dbFldDes` in C either, so it is not property-class.
2802 pub(crate) fn field_posts_property(&self, field: &str) -> bool {
2803 self.field_desc(field).is_some_and(|d| d.prop)
2804 }
2805
2806 /// Hook called by the database after a field is written. If the field is a
2807 /// metadata-cache source, the cache is invalidated so the next snapshot
2808 /// picks up the new value. Posts nothing — a caller that also owes the
2809 /// `DBE_PROPERTY` event uses [`Self::notify_field_written_if_changed`].
2810 ///
2811 /// Field name is automatically uppercased.
2812 pub fn notify_field_written(&self, field: &str) {
2813 let upper = field.to_ascii_uppercase();
2814 if is_metadata_cache_source(&upper) {
2815 self.invalidate_metadata_cache();
2816 }
2817 }
2818
2819 /// Like [`Self::notify_field_written`], plus the `DBE_PROPERTY` post C
2820 /// makes from `dbPut` — and both are skipped when the put did not actually
2821 /// change the field's value. Mirrors epics-base `faac1df1`: property events
2822 /// fire only on real changes, not on idempotent writes (the C path compares
2823 /// `paddr->pfield` against the converted payload before setting the
2824 /// `propertyUpdate` flag).
2825 ///
2826 /// The two effects have independent gates. Invalidation follows
2827 /// `is_metadata_cache_source` (what this port's cache reads); the post
2828 /// follows `Self::field_posts_property` (what the `.dbd` declares). A
2829 /// field can be either without being both.
2830 ///
2831 /// `prev` is the value captured BEFORE the put. Callers that don't need the
2832 /// change-detection (e.g. internal writers that know the field is neither)
2833 /// can keep using [`Self::notify_field_written`].
2834 ///
2835 /// `backing` is what the sweep needs and could not have: the post below
2836 /// names EVERY subscribed field, so it reaches a link-backed one whenever a
2837 /// client is monitoring it, and this method runs under the record's own
2838 /// write lock where the target's lock cannot be taken. The put path that
2839 /// calls it has already resolved one at its no-lock point.
2840 pub fn notify_field_written_if_changed(
2841 &mut self,
2842 field: &str,
2843 prev: Option<&EpicsValue>,
2844 backing: LinkBacking<'_>,
2845 ) {
2846 let upper = field.to_ascii_uppercase();
2847 let cache_source = is_metadata_cache_source(&upper);
2848 let posts_property = self.field_posts_property(&upper);
2849 if !cache_source && !posts_property {
2850 return;
2851 }
2852 // The SAME reader the put's pre-value was captured with
2853 // (`field_io.rs`'s three `dbPut` bodies). `Record::get_field` alone
2854 // sees only the record type's own struct, and this port keeps on
2855 // `CommonFields` a good deal of storage C keeps per record type — the
2856 // whole analog-alarm ladder among it — so a `caput HIHI` on a calc
2857 // compared `None` to `None`, reported "unchanged", and posted neither
2858 // the `DBE_PROPERTY` C sends for a `prop(YES)` field nor the cache
2859 // invalidation the metadata it feeds depends on.
2860 let now = self.resolve_field_stored(&upper);
2861 if prev == now.as_ref() {
2862 return;
2863 }
2864 if cache_source {
2865 self.invalidate_metadata_cache();
2866 }
2867 if posts_property {
2868 // mirror C dbAccess.c:1395-1396 — the gate `if (propertyUpdate &&
2869 // !status)` and the `db_post_events(precord, NULL, DBE_PROPERTY)` it
2870 // guards. The NULL field pointer is what makes it record-wide.
2871 // Collect keys first to avoid a re-entrant immutable borrow on subscribers.
2872 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
2873 for f in fields {
2874 self.notify_field_with_origin(
2875 &f,
2876 crate::server::recgbl::EventMask::PROPERTY,
2877 0,
2878 backing,
2879 );
2880 }
2881 }
2882 }
2883
2884 /// Returns the cached MetadataSnapshot, building and storing it on
2885 /// the first call (or after invalidation). Used by both
2886 /// `snapshot_for_field` and `make_monitor_snapshot` so the populate
2887 /// cost is paid at most once per metadata-stable interval.
2888 fn cached_metadata(&self) -> MetadataSnapshot {
2889 // Fast path: cache hit
2890 if let Ok(guard) = self.metadata_cache.lock()
2891 && let Some(cached) = guard.as_ref()
2892 {
2893 return cached.clone();
2894 }
2895
2896 // Cache miss: build a fresh metadata snapshot
2897 let mut tmp = super::super::snapshot::Snapshot::new(
2898 EpicsValue::Double(0.0),
2899 0,
2900 0,
2901 std::time::SystemTime::UNIX_EPOCH,
2902 );
2903 self.populate_display_info(&mut tmp);
2904 self.populate_control_info(&mut tmp);
2905 self.populate_enum_info(&mut tmp);
2906
2907 let meta = MetadataSnapshot {
2908 display: tmp.display,
2909 control: tmp.control,
2910 enums: tmp.enums,
2911 alarm: self.explicit_alarm_limits(self.record.record_type()),
2912 };
2913
2914 // Store back; ignore poisoning (cache is best-effort).
2915 if let Ok(mut guard) = self.metadata_cache.lock() {
2916 *guard = Some(meta.clone());
2917 }
2918 meta
2919 }
2920
2921 /// C `dbChannelSpecial(chan) == SPC_NOMOD` — **the single owner of the
2922 /// no-modify declaration**, for every consumer that needs to know whether a
2923 /// field can be written.
2924 ///
2925 /// C declares it once, in the `.dbd`, and reads it in two unrelated places:
2926 ///
2927 /// * `dbPut` (`dbAccess.c:123-126`, via `dbPutSpecial(paddr, 0)`) refuses
2928 /// the write — the port's `check_no_mod` gate;
2929 /// * `rsrvCheckPut` (`rsrv/camessage.c:2540-2551`) — `if
2930 /// (dbChannelSpecial(pciu->dbch) == SPC_NOMOD) return 0;` — which feeds
2931 /// the CA `ACCESS_RIGHTS` write bit (`camessage.c:1154-1156`) as well as
2932 /// both put paths, so a client sees `Access: read, no write` and never
2933 /// sends the doomed write.
2934 ///
2935 /// Only the first consumer existed in the port, so every dbCommon NOMOD
2936 /// field advertised WRITE on the wire (`caput N1.SEVR 2` was refused
2937 /// server-side, after the client had already sent it, with an async
2938 /// exception instead of C's clean client-side "Write access denied").
2939 ///
2940 /// Three sources, one answer:
2941 ///
2942 /// 1. the dbCommon `SPC_NOMOD` set below — common fields, so no record's
2943 /// `field_list` declares them;
2944 /// 2. the record type's **declaration**, resolved by `Self::field_desc` —
2945 /// the vendored `.dbd` whenever one exists, and only for a record type
2946 /// that has no `.dbd` at all (`motor`, `optics`, `scaler`, `std`) the
2947 /// record's own hand-written table, which for those Tier 3 types
2948 /// genuinely *is* their declaration;
2949 /// 3. [`Record::field_no_mod`] — an SPC_NOMOD a record's `cvt_dbaddr`
2950 /// raises from its own state (compress VAL under BALG=LIFO,
2951 /// `compressRecord.c:404-405`), which a static `FieldDesc` cannot
2952 /// express.
2953 ///
2954 /// `field` may be any case.
2955 pub fn is_no_mod(&self, field: &str) -> bool {
2956 if DBCOMMON_NOMOD.iter().any(|f| f.eq_ignore_ascii_case(field)) {
2957 return true;
2958 }
2959 if self.field_desc(field).is_some_and(|f| f.read_only) {
2960 return true;
2961 }
2962 self.record.field_no_mod(field)
2963 }
2964
2965 /// Check if the record is currently processing (PACT equivalent).
2966 pub fn is_processing(&self) -> bool {
2967 self.pact.load(std::sync::atomic::Ordering::Acquire)
2968 }
2969
2970 /// C `prec->pact = TRUE` — the record goes busy for an async device
2971 /// round-trip, an SDLY simulation defer, or an ODLY reprocess window.
2972 pub fn enter_pact(&self) {
2973 self.pact.store(true, std::sync::atomic::Ordering::Release);
2974 }
2975
2976 /// C `prec->pact = FALSE` — the ONLY release of PACT.
2977 ///
2978 /// The returned [`PactExit`] carries the release's debt to the cycle tail,
2979 /// where a queued put-notify is restarted — the omission the open-coded
2980 /// `processing.store(false)` at the ODLY continuation and the three SIM/SDLY
2981 /// releases made.
2982 ///
2983 /// `#[must_use]` does NOT enforce that debt and never did: the lint fires on
2984 /// an unused *expression*, so a site that binds the token with `let` and then
2985 /// leaves by `?` or an early `return` warns about nothing. The enforcement is
2986 /// `processing::CycleEndGuard`, whose `Drop` pays the tail for every exit
2987 /// that did not.
2988 pub fn leave_pact(&mut self) -> PactExit {
2989 self.pact.store(false, std::sync::atomic::Ordering::Release);
2990 PactExit::new(self.notify_restart_pending())
2991 }
2992
2993 /// The cycle-tail token for a record this cycle did NOT release PACT on.
2994 ///
2995 /// Still consults the queue: a notify parked behind an in-flight wait-set
2996 /// on an idle record is freed by the wait-set completion, and the tail is
2997 /// what promotes it.
2998 pub fn pact_exit_without_release(&self) -> PactExit {
2999 PactExit::new(self.notify_restart_pending())
3000 }
3001
3002 /// C `processNotifyCommon`'s two defer tests (dbNotify.c:213, 225), as one
3003 /// question: may a NEWLY ARRIVING put-notify take this record now?
3004 ///
3005 /// `true` for an in-flight wait-set (`precord->ppn`), for PACT, and for a
3006 /// non-empty restart list — the last so a notify arriving in the window
3007 /// between a completion and the restart check cannot jump the queue.
3008 ///
3009 /// A RESTARTED put is not asked this: it is already the record's owner (C
3010 /// `precord->ppn == ppn`, state `notifyRestartCallbackRequested`, which
3011 /// dbNotify.c:213 exempts by name) and only PACT can stop it — see
3012 /// `Self::requeue_notify_put`.
3013 pub fn notify_put_is_owned(&self) -> bool {
3014 self.notify.is_some() || self.is_processing() || !self.notify_restart_list.is_empty()
3015 }
3016
3017 /// C `processNotifyCommon`'s FIRST defer test alone (dbNotify.c:213):
3018 /// another `processNotify` owns this record, or one is already queued
3019 /// behind it. [`Self::notify_put_is_owned`] folds in the PACT arm
3020 /// (`:225`) as well.
3021 ///
3022 /// A DBF link-field put waits on ownership but NOT on PACT. A bare `sub`
3023 /// with an empty `SNAM` parks PACT=TRUE forever (subRecord.c:119-122), so
3024 /// a link put that waited on the PACT arm there would never be written and
3025 /// `caput <sub>.INPA 0` would read back empty. Ownership carries no such
3026 /// trap: the restart check drains the queue at every cycle end.
3027 pub fn notify_put_has_owner(&self) -> bool {
3028 self.notify.is_some() || !self.notify_restart_list.is_empty()
3029 }
3030
3031 /// C `ellSafeAdd(&precord->ppnr->restartList, &ppn->restartNode)` — the
3032 /// arriving put-notify joins the back of the queue, unwritten.
3033 ///
3034 /// Infallible: C has no "refuse" arm here, and a refusal loses the client's
3035 /// write. Call only under [`Self::notify_put_is_owned`].
3036 /// Take this record's put-notify slot, or queue behind whoever holds it.
3037 ///
3038 /// C `processNotifyCommon` (dbNotify.c:211-231) has exactly two outcomes
3039 /// and no third: the record is free and the notify takes it, or it is
3040 /// owned and the notify joins `precord->ppnr->restartList`. There is no
3041 /// refusal arm — `ECA_PUTCBINPROG` has one sender in all of base, the
3042 /// 60-second put-callback timeout in `write_notify_action`
3043 /// (`rsrv/camessage.c:1701` at R7.0.10).
3044 ///
3045 /// `None` means queued, and the caller MUST NOT process: the replay
3046 /// drives the record and fires the callback, so processing here would
3047 /// run the cycle twice for one client request.
3048 ///
3049 /// Ownership alone decides — NOT [`Self::notify_put_has_owner`]. A
3050 /// non-empty restart list stops a *fresh* arrival at the entry gate, but a
3051 /// replay reaching here has already been popped off that list and must
3052 /// take the slot with its successors still queued behind it, exactly as
3053 /// C `restartCheck` (dbNotify.c:158-168) assigns `precord->ppn = pfirst`
3054 /// while leaving the rest of `restartList` in place.
3055 pub fn install_or_queue_notify(
3056 &mut self,
3057 completion: crate::runtime::sync::oneshot::Sender<()>,
3058 ) -> Option<Arc<NotifyWaitSet>> {
3059 if self.notify.is_some() {
3060 self.queue_notify_put(DeferredNotify::Process { completion });
3061 return None;
3062 }
3063 let notify = NotifyWaitSet::for_entry_record(&self.name, completion);
3064 self.take_notify_slot(notify.clone());
3065 Some(notify)
3066 }
3067
3068 /// Take `ws` into this record's put-notify slot.
3069 ///
3070 /// **The only writer of [`Self::notify`] that installs a set** — the other
3071 /// two ([`Self::abandon_put_notify`], [`Self::release_notify`]) only clear
3072 /// it. Everything that makes a record a member of a wait-set happens here,
3073 /// so C's `precord->ppn = ppn` and its `ellSafeAdd(&waitList, ...)`
3074 /// (`dbNotify.c:226-227`, `:257-258`, `:497-498`) stay the single act they
3075 /// are in C. Split across two call sites, they were what let a member exist
3076 /// that no cancel could name.
3077 fn take_notify_slot(&mut self, ws: Arc<NotifyWaitSet>) {
3078 debug_assert!(
3079 self.notify.is_none(),
3080 "the slot must be tested free in the same critical section"
3081 );
3082 ws.record_joined(&self.name);
3083 self.notify = Some(ws);
3084 }
3085
3086 /// C `dbNotifyAdd` (dbNotify.c:477-501): a link target joins the wait-set
3087 /// of the put-notify driving the chain, so the initiator's completion
3088 /// waits for this record's cycle too.
3089 ///
3090 /// One of the two callers of `take_notify_slot`, the sole writer; the
3091 /// other is [`Self::install_or_queue_notify`]. All three live here so
3092 /// the slot has no assignment site outside this module — an open-coded one
3093 /// elsewhere is how a wait-set came to be installed without the record's
3094 /// write gate.
3095 ///
3096 /// A record already carrying a wait-set keeps it (C's `if (!pto->ppn …)`
3097 /// at `:492`), so this never displaces a live one, and the `enter` is
3098 /// paired with the `leave` the target's own cycle tail performs.
3099 pub fn join_put_notify(&mut self, src: Option<&Arc<NotifyWaitSet>>) {
3100 if self.notify.is_some() {
3101 return;
3102 }
3103 if let Some(ws) = src {
3104 ws.enter();
3105 self.take_notify_slot(ws.clone());
3106 }
3107 }
3108
3109 /// Give up a claim on the slot without ever having processed under it.
3110 ///
3111 /// NOT a completion. `complete_put_notify` (`processing.rs:449`, C
3112 /// `dbNotifyCompletion`) `leave`s the wait-set because the record
3113 /// contributed a cycle to it; an abandoned claim contributed nothing, so
3114 /// the set is dropped whole. Its `pending` never reaches zero, and the
3115 /// client's receiver wakes on the dropped sender — the same release C
3116 /// gives a `dbNotifyCancel`.
3117 ///
3118 /// The caller must be the claim's owner. Nothing else can have cleared or
3119 /// replaced the slot in between: [`Self::install_or_queue_notify`] and
3120 /// [`Self::join_put_notify`] both refuse an occupied slot, and
3121 /// [`Self::take_next_notify_restart`] will not pop while it is occupied,
3122 /// so the assertion below states an invariant rather than guarding a
3123 /// race.
3124 pub(crate) fn abandon_put_notify(&mut self, claimed: &Arc<NotifyWaitSet>) {
3125 let taken = self.notify.take();
3126 debug_assert!(
3127 taken.as_ref().is_some_and(|ws| Arc::ptr_eq(ws, claimed)),
3128 "only the claim owner may clear the put-notify slot"
3129 );
3130 }
3131
3132 /// The set in this record's slot if it can never be answered — the test
3133 /// half of C `dbNotifyCancel` (`dbNotify.c:385-430`), reached from
3134 /// `rsrvFreePutNotify` (`camessage.c:1630-1638`) when a client is torn down
3135 /// with its put-callback still busy.
3136 ///
3137 /// # Invariant (CONTRACT)
3138 ///
3139 /// A record's put-notify slot MUST NOT stay occupied by a notify nobody
3140 /// can be answered from. Without the release such a record is wedged for
3141 /// good: every later put-notify queues on `notify_restart_list` behind a
3142 /// completion that can never arrive and writes nothing — C
3143 /// `processNotifyCommon` tests ownership ABOVE `putCallback` — so the next
3144 /// client hangs too, and the one after it.
3145 ///
3146 /// This is what makes honouring a record type's forward-link gate safe at
3147 /// all. `busy` left at VAL=1 withholds the `ca_put_callback` exactly as C
3148 /// does (`busyRecord.c:271`); the client then gives up and exits, and that
3149 /// exit is the teardown modelled here.
3150 ///
3151 /// Two triggers reach it, and both are needed. The CA server calls it at
3152 /// client teardown, which is C's own moment
3153 /// (`rsrvFreePutNotify`, `camessage.c:1630-1638`), and it also runs at
3154 /// every ownership test, which catches a set whose client died on a
3155 /// transport the teardown hook does not cover. Only the first closes the
3156 /// case where a SECOND client is already queued on a record's restart list
3157 /// when the first dies: nothing then arrives to test ownership, and C's
3158 /// `restartCheck` would have handed that record to the queued client.
3159 ///
3160 /// The set names its own members ([`NotifyWaitSet::joined_records`]), so
3161 /// the sweep reaches a chain target exactly as C's `notifyProcessInProgress`
3162 /// arm does (`dbNotify.c:428-430`) rather than stopping at the entry.
3163 /// That distinction is not cosmetic: a chain target is the likelier victim,
3164 /// because the record whose cycle never ends is precisely the one this
3165 /// exists for — a `busy` left at VAL=1 withholds `recGblFwdLink` by
3166 /// contract, so its slot would otherwise be held by a dead set forever.
3167 ///
3168 /// See [`PvDatabase::cancel_unanswerable_notify`], the owner that runs it
3169 /// across the whole set.
3170 ///
3171 /// [`PvDatabase::cancel_unanswerable_notify`]: crate::server::database::PvDatabase::cancel_unanswerable_notify
3172 pub(crate) fn unanswerable_notify(&self) -> Option<Arc<NotifyWaitSet>> {
3173 self.notify
3174 .as_ref()
3175 .filter(|ws| ws.is_unanswerable())
3176 .cloned()
3177 }
3178
3179 /// Drop this record's claim on `dead` — C `restartCheck`'s
3180 /// `precord->ppn = 0` (`dbNotify.c:157`) as `dbNotifyCancel` reaches it.
3181 ///
3182 /// The record's own slot is the authority on membership, so this is
3183 /// `Arc::ptr_eq`-gated: a name the sweep carries for a record that has
3184 /// since completed and taken a different notify releases nothing.
3185 ///
3186 /// Returns whether the slot was freed, so the caller can promote the
3187 /// restart-list head (C `restartCheck`) exactly as a completion would.
3188 #[must_use = "a freed slot owes the restart list a drain"]
3189 pub(crate) fn release_notify(&mut self, dead: &Arc<NotifyWaitSet>) -> bool {
3190 if self.notify.as_ref().is_some_and(|ws| Arc::ptr_eq(ws, dead)) {
3191 self.notify = None;
3192 return true;
3193 }
3194 false
3195 }
3196
3197 /// Whether a put-notify owns this record — C `precord->ppn != NULL`.
3198 ///
3199 /// The public read of the slot. The wait-set itself stays crate-private so
3200 /// no caller outside this crate can `enter`/`leave` a set it does not own,
3201 /// which is the accounting [`NotifyWaitSet`] exists to keep.
3202 pub fn has_notify(&self) -> bool {
3203 self.notify.is_some()
3204 }
3205
3206 pub fn queue_notify_put(&mut self, put: DeferredNotify) {
3207 debug_assert!(
3208 self.notify_put_is_owned(),
3209 "a put-notify is queued only when the record is owned; otherwise it \
3210 takes the record directly"
3211 );
3212 self.notify_restart_list.push_back(put);
3213 }
3214
3215 /// C `processNotifyCommon`'s `precord->pact` arm reached by a RESTARTED
3216 /// notify (dbNotify.c:225-231): it stays `precord->ppn` and waits for the
3217 /// next completion, so it does NOT fall in behind puts that arrived after
3218 /// it. Back to the head.
3219 ///
3220 /// The only way a promotion can find the record busy is a scan that took
3221 /// PACT between the pop and the replay; the record's advisory write gate,
3222 /// held across both, keeps every other put out of that window.
3223 pub(crate) fn requeue_notify_put(&mut self, put: DeferredNotify) {
3224 debug_assert!(
3225 self.is_processing(),
3226 "a promoted put-notify returns to the head only because the record \
3227 went PACT under it"
3228 );
3229 self.notify_restart_list.push_front(put);
3230 }
3231
3232 /// C `restartCheck` (dbNotify.c:149-170) — promote the queue head once the
3233 /// record is free, or leave it queued for the next completion.
3234 ///
3235 /// **The only drain.** The freedom test lives here rather than at the call
3236 /// site, so promoting onto a record that is still PACT or still carries a
3237 /// wait-set is not expressible.
3238 pub(crate) fn take_next_notify_restart(&mut self) -> Option<DeferredNotify> {
3239 if self.notify.is_some() || self.is_processing() {
3240 return None;
3241 }
3242 self.notify_restart_list.pop_front()
3243 }
3244
3245 /// Does this record owe anyone a restart? The cheap read that keeps the
3246 /// per-cycle restart check off the spawn path when nothing is queued.
3247 pub(crate) fn notify_restart_pending(&self) -> bool {
3248 !self.notify_restart_list.is_empty()
3249 }
3250
3251 /// How many put-notifies are queued behind whoever owns this record — C
3252 /// `ellCount(&precord->ppnr->restartList)`.
3253 ///
3254 /// A count and not a bool because `dbNotifyDump` prints one line per
3255 /// queued entry (`dbNotify.c:678-685`); [`Self::notify_restart_pending`]
3256 /// answers the cheaper question the restart check asks. Read-only: the
3257 /// queue's only drain is still [`Self::take_next_notify_restart`].
3258 pub(crate) fn notify_restart_len(&self) -> usize {
3259 self.notify_restart_list.len()
3260 }
3261
3262 /// Unified field resolution: record fields → common fields → virtual
3263 /// fields — and, for a link field, C `dbGet`'s rendering of it.
3264 ///
3265 /// This is the port's `dbGet` (`dbAccess.c:625-961`): the read every
3266 /// external reader arrives at, whether it came from
3267 /// [`PvDatabase::get_pv`](crate::server::database::PvDatabase::get_pv)
3268 /// on behalf of a CA client, from `dbgf`, or from `dbpr`. C's `dbGet`
3269 /// sends `DBF_INLINK`/`DBF_OUTLINK`/`DBF_FWDLINK` to `getLinkValue`
3270 /// (`:944-947`), which renders the link with `dbGetString` (`:850-856`),
3271 /// so applying that here is what makes every reader agree without any of
3272 /// them knowing the rule.
3273 ///
3274 /// The STORE is still the text — `Record::get_field` — and that is what
3275 /// the link layer parses. The two are not the same value and do not share
3276 /// a name: C likewise reads `precord->inp` directly when it wants the
3277 /// link and `dbGet` when it wants what a client would see.
3278 pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
3279 self.resolve_field_upper(&name.to_ascii_uppercase())
3280 }
3281
3282 /// [`Self::resolve_field`] for a name the caller has already normalised
3283 /// — a parsed link's target field, a channel name's field — so the link
3284 /// read path does not allocate an upper-cased copy of a name that is
3285 /// upper-case by construction. `name` must already be upper-case.
3286 pub fn resolve_field_upper(&self, name: &str) -> Option<EpicsValue> {
3287 debug_assert!(
3288 !name.bytes().any(|b| b.is_ascii_lowercase()),
3289 "resolve_field_upper takes a normalised name, got {name:?}"
3290 );
3291 self.resolve_field_upper_at(name, self.field_desc(name))
3292 }
3293
3294 /// [`Self::resolve_field_upper`] with the field's declaration in hand:
3295 /// `desc` is [`Self::field_desc`]'s answer for `name`, which a link
3296 /// target settles once ([`FieldAddr`]) instead of scanning per read.
3297 #[inline]
3298 pub(crate) fn resolve_field_upper_at(
3299 &self,
3300 name: &str,
3301 desc: Option<&'static FieldDesc>,
3302 ) -> Option<EpicsValue> {
3303 let value = self.resolve_field_stored_at(name, desc)?;
3304 Some(self.as_a_reader_sees(name, value))
3305 }
3306
3307 /// [`Self::resolve_field`] without the reader's view — what the field
3308 /// HOLDS, which for a link field is the text C's `dbParseLink` takes
3309 /// (`dbStaticLib.c:2246`) rather than what `dbGetString` renders
3310 /// (`:1906-2050`).
3311 ///
3312 /// `dbpr` needs both of the same field, and in C they come from one
3313 /// address: it prints the link's resolved TYPE in front of the rendered
3314 /// text (`dbTest.c:1205-1224`). Splitting the accessor chain here keeps
3315 /// that one address — a second walk to find the stored text would be a
3316 /// second answer to "which field is this", and the round before this one
3317 /// is what happens when those two disagree.
3318 ///
3319 /// `name` must already be upper-case.
3320 pub fn resolve_field_stored(&self, name: &str) -> Option<EpicsValue> {
3321 self.resolve_field_stored_at(name, self.field_desc(name))
3322 }
3323
3324 /// [`Self::resolve_field_stored`] with `desc` = [`Self::field_desc`]`(name)`.
3325 #[inline]
3326 fn resolve_field_stored_at(
3327 &self,
3328 name: &str,
3329 desc: Option<&'static FieldDesc>,
3330 ) -> Option<EpicsValue> {
3331 let value = match desc {
3332 // C `dbGet`'s validity gate (`dbAccess.c:667-675`): the NAME
3333 // resolves — `dbNameToAddr` finds every field the `.dbd` declares
3334 // — and the READ is what fails, with `S_db_badDbrtype`. The two
3335 // outcomes leave by different doors one level up, in
3336 // `PvDatabase::get_pv`, which turns a declared-but-unresolved
3337 // field into `CaError::BadDbrType` and an undeclared one into
3338 // `ChannelNotFound`.
3339 //
3340 // The gate goes HERE, in front of the accessor chain, rather than
3341 // in whichever accessor would otherwise answer: `declared_default`
3342 // synthesises the declared type's zero for any field with no
3343 // stored value, so leaving the row to reach it served `REC.TIME`
3344 // as `UChar(0)` — a value C has no way to produce.
3345 Some(desc) if desc.unreadable() => return None,
3346 // C `dbFindFieldPart` — the record type's own `.dbd` table, then
3347 // `dbCommon`. Every accessor below reads STORAGE, and this port
3348 // keeps a good deal of storage on `CommonFields` that C keeps per
3349 // record type (`INP`, `OUT`, `SSCN`, the analog-alarm ladder), so
3350 // without the declaration in front of them a `calc` answered
3351 // `.OUT` with an empty string where C answers `PV 'C:GOOD.OUT'
3352 // not found`. The declaration is the namespace, not the storage.
3353 //
3354 // The record's own answer is returned as it lands rather than
3355 // threaded through the fallbacks' `or_else` chain, which moved
3356 // the 32-byte value once per link in the chain on the way out.
3357 Some(_) => match self.record.get_field(name) {
3358 Some(value) => value,
3359 None => self
3360 .get_common_field(name)
3361 .or_else(|| self.get_virtual_field(name))
3362 .or_else(|| self.declared_overrides.get(name).cloned())
3363 .or_else(|| self.declared_default(name))?,
3364 },
3365 // C `dbNameToAddr` falls through to `dbGetAttributePart` on
3366 // `S_dbLib_fieldNotFound` (`dbAccess.c:672-675`), which is how
3367 // `RTYP` — declared by no record type — reads as the type name.
3368 // `VERS` and any `dbPutAttribute` name need the database's
3369 // attribute map and are answered a level up, in `get_pv`.
3370 None => self.get_virtual_field(name)?,
3371 };
3372 Some(value)
3373 }
3374
3375 /// C `dbGet`'s link arm applied to one resolved field: a link field reads
3376 /// as [`render_link_field`], everything else as itself.
3377 ///
3378 /// The class lookup is behind the string test because only a string-valued
3379 /// field can be a link, and the numeric fields a processing cycle reads
3380 /// (`HASH`, `SIMM`, `SDLY`) must not pay for a declaration scan.
3381 #[inline]
3382 fn as_a_reader_sees(&self, upper_field: &str, value: EpicsValue) -> EpicsValue {
3383 let EpicsValue::String(ref text) = value else {
3384 return value;
3385 };
3386 let Some(class) = crate::types::dbf_link_class(self.record.record_type(), upper_field)
3387 else {
3388 return value;
3389 };
3390 EpicsValue::String(
3391 render_link_field(class, text.as_str_lossy().as_ref())
3392 .as_str()
3393 .into(),
3394 )
3395 }
3396
3397 /// The value a field that is DECLARED by the `.dbd` but has no live store
3398 /// on this record serves: its `initial(...)`, or a type-zero.
3399 ///
3400 /// C makes *every* `.dbd` field addressable — `dbNameToAddr` resolves the
3401 /// field from its `dbFldDes` and `dbGet` reads it out of record memory,
3402 /// which the dbd loader seeded with `initial()` (or left zero). A Rust
3403 /// record implements only the fields it has behaviour for, so a field it
3404 /// declares but never touches — `aSub.OVAL`, `sub.LA`, `sel.HOPR` — had no
3405 /// channel at all: [`Self::resolve_field`]'s three accessors all returned
3406 /// `None` and CA create-channel answered `S_dbLib_recNotFound`.
3407 ///
3408 /// The declared table is the contract for *which* fields exist; this is the
3409 /// last resort for the *value* of one with no runtime accessor, and it is
3410 /// exactly what an unprocessed C record on an empty `.db` returns —
3411 /// [`apply_dbd_initials`](crate::server::db_loader) seeds the same
3412 /// `initial()` into the fields the record *does* store, from the same
3413 /// generated table, so the two paths agree by construction.
3414 fn declared_default(&self, name: &str) -> Option<EpicsValue> {
3415 let desc = self.field_desc(name)?;
3416 // A `runtime_typed` field (`VAL`/`BG`, re-typed from `FTVL`/`SDEF`) is
3417 // record-owned by definition and its placeholder `dbf_type` is not what
3418 // it serves; never synthesise one here — the record itself answers it.
3419 if desc.runtime_typed {
3420 return None;
3421 }
3422 let initial = desc.initial.unwrap_or("");
3423 if let Some(choices) = desc
3424 .menu
3425 .or_else(|| self.record.menu_field_choices(name))
3426 .or_else(|| super::shared_menu_choices(name))
3427 {
3428 // A menu field with no `initial(...)` is index 0, exactly as an
3429 // empty numeric field is 0 below.
3430 if initial.is_empty() {
3431 return Some(EpicsValue::Enum(0));
3432 }
3433 return super::resolve_menu_field_string_db_load(name, choices, desc.dbf_type, initial)
3434 .ok();
3435 }
3436 // `parse` maps an empty string to the declared type's zero, so this one
3437 // call serves both `initial(...)` and no-initial fields.
3438 EpicsValue::parse_bytes(desc.dbf_type, initial.as_bytes()).ok()
3439 }
3440
3441 /// Resolve a field for EPICS `$` long-string (character-array) access.
3442 ///
3443 /// The `$` channel-name modifier (C `dbChannel.c:486-505`) re-views a
3444 /// field as a `DBR_CHAR` array: a `DBF_STRING` field becomes a char
3445 /// array of `field_size` elements, a link field a char array of
3446 /// `PVLINK_STRINGSZ`, and every other field type is rejected with
3447 /// `S_dbLib_fieldNotFound`. pvxs serves that char view as a
3448 /// `form = "String"` long-string `NTScalar` — it reads the `DBR_CHAR`
3449 /// bytes and NUL-terminates them back into a string
3450 /// (`ioc/iocsource.cpp:133-136`, `ioc/channel.cpp:62-74`).
3451 ///
3452 /// Both `DBF_STRING` fields and link fields resolve to an
3453 /// [`EpicsValue::String`] in this database (a link resolves to its
3454 /// textual form, see [`Self::get_common_field`]), so a field is
3455 /// `$`-eligible exactly when it resolves to a string value. Returns
3456 /// that string value for an eligible field, or `None` for a field the
3457 /// `$` modifier cannot view as a char array (the
3458 /// `S_dbLib_fieldNotFound` case) — the single owner of the
3459 /// dbChannel `$`-eligibility rule for the channel-resolution layer.
3460 pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
3461 match self.resolve_field(name)? {
3462 v @ EpicsValue::String(_) => Some(v),
3463 _ => None,
3464 }
3465 }
3466
3467 /// Choice table for a field served as `DBR_ENUM` from a `DBF_MENU`:
3468 /// the record's own record-specific menu
3469 /// ([`Record::menu_field_choices`]),
3470 /// else a shared menu keyed by field name
3471 /// ([`shared_menu_choices`](super::menu_choices::shared_menu_choices)).
3472 /// The choices a `menu()` field serves as its `DBR_ENUM` labels.
3473 ///
3474 /// The `.dbd` declaration is the first and best answer: a generated
3475 /// [`FieldDesc`] carries the field's own `menu(...)` choices, which is what
3476 /// C's `dbGetFieldIndex` -> `pamapdbfType` -> menu lookup resolves. The two
3477 /// hand-maintained fallbacks below are for record types still on a
3478 /// hand-written table; they go away with the last of them.
3479 ///
3480 /// `shared_menu_choices` in particular keys on the field NAME alone, across
3481 /// every record type — which is only correct while no two record types give
3482 /// the same field name different menus. Asking the field's own descriptor
3483 /// first removes that assumption.
3484 fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
3485 menu_choices_of(self.record.as_ref(), field)
3486 }
3487
3488 /// The choices this record's `DTYP` selects among — C's `dbDeviceMenu` for
3489 /// the record type, in `.dbd` declaration order.
3490 ///
3491 /// C's DTYP field IS the index into this list, and an unset DTYP is index 0
3492 /// — which is why a bare `record(ai,"X"){}` serves `Soft Channel` and a
3493 /// `record(calc,"X"){}`, whose record type declares no device support at
3494 /// all, serves the empty string.
3495 ///
3496 /// The port stores the device NAME rather than the index, because the name
3497 /// is what the device-support registry dispatches on, and a name registered
3498 /// at runtime by a downstream crate (`asynInt32`) has no `device()` line in
3499 /// any vendored `.dbd`. Such a name is appended as its own slot, so the
3500 /// index and the string still name the SAME device support: there is no
3501 /// value of DTYP that renders as a device this record is not bound to.
3502 /// `None` when the record type declares NO device support at all — C's
3503 /// `dbDeviceMenu *pdevs = paddr->pfldDes->ftPvt; if (!pdevs) goto nostrs;`
3504 /// (`dbAccess.c:176-179`), which clears `DBR_ENUM_STRS` so the client is
3505 /// sent no choice list at all.
3506 ///
3507 /// C keeps that case DISTINCT from a device menu that exists but is empty,
3508 /// and says so at `dbAccess.c:205`: *"indicate option data not available.
3509 /// distinct from no_str==0"*. An empty-but-present menu is still marked,
3510 /// with `no_str = 0`; a missing menu is not marked. Returning `Vec` here
3511 /// and defaulting the missing menu to `[]` collapsed the two, so a
3512 /// `record(calc,"X"){}` — whose record type has no `device()` line — served
3513 /// `value.choices = {0}[]` where QSRV2 omits the leaf entirely.
3514 pub(crate) fn device_choices(&self) -> Option<Vec<PvString>> {
3515 let record_type = self.record.record_type();
3516 // Base's build-time menu (`epics-base-rs/dbd`), then the menus a
3517 // downstream crate whose device support has no vendored `device()` line
3518 // registered at runtime (asyn's `asynInt32`, `asynFloat64`, ...). C's
3519 // `dbDeviceMenu` is the concatenation of every `device()` the loaded
3520 // `.dbd` set declares, in load order — base first, then asyn — so the
3521 // merge appends the contributed choices AFTER the declared ones.
3522 // The C None-vs-empty distinction (`dbAccess.c:176-179` vs `:205`): the
3523 // menu is present iff the loaded `.dbd` set declares ANY `device()` for
3524 // this type. A type base declares none for but asyn does (structurally
3525 // possible, though none of asyn's are such) is therefore present, not
3526 // None; a type neither declares for (calc) stays None.
3527 if super::dbd_generated::device_menu(record_type).is_none()
3528 && super::contributed_device_menu(record_type).is_empty()
3529 {
3530 return None;
3531 }
3532 // `merged_device_menu` = declared + contributed, the SAME source the
3533 // CA-put validation (`coerce_put_value`'s DTYP branch) resolves against,
3534 // so a client can put exactly the DTYP names it can read here.
3535 let mut names: Vec<PvString> = super::merged_device_menu(record_type)
3536 .into_iter()
3537 .map(PvString::from)
3538 .collect();
3539 let dtyp = self.common.dtyp.as_str();
3540 if !dtyp.is_empty() && !names.iter().any(|n| n.as_str_lossy() == dtyp) {
3541 names.push(PvString::from(dtyp));
3542 }
3543 Some(names)
3544 }
3545
3546 /// C `dbPutFieldLink`'s link-type gate (`dbAccess.c:1125-1137`): a link
3547 /// written at RUNTIME is held to the same `dbCanSetLink` rule as one written
3548 /// by the `.db`, against the device support the record's CURRENT `DTYP`
3549 /// binds. Same rule, same owner — [`super::check_link_assignment`]; only the
3550 /// DTYP it is asked about differs (the record's, not the `.db` text's).
3551 ///
3552 /// [`MenuBound::DbLoad`] is exempt, and that is not a hole: on the db-load
3553 /// path C does not check a link as each field is parsed either. It checks
3554 /// once, at `iocInit`, over the record as loaded (`dbStaticLib.c:2178-2231`)
3555 /// — which is why `field(INP,…)` may precede `field(DTYP,…)` in a `.db` and
3556 /// still bind. [`PvDatabase::db_init_record_links`] is that pass, and it
3557 /// reads the record's DTYP off the record itself, so it does not depend on
3558 /// the order the `.db` happened to spell its fields in. Gating here as well
3559 /// would re-introduce exactly that order dependence.
3560 ///
3561 /// [`PvDatabase::db_init_record_links`]: crate::server::database::PvDatabase
3562 fn check_link_assignment(
3563 &self,
3564 upper_field: &str,
3565 text: &str,
3566 bound: MenuBound,
3567 ) -> CaResult<()> {
3568 if matches!(bound, MenuBound::DbLoad) {
3569 return Ok(());
3570 }
3571 super::check_link_assignment(
3572 self.record.record_type(),
3573 Some(self.common.dtyp.as_str()),
3574 upper_field,
3575 text,
3576 )
3577 }
3578
3579 /// The value of the `DTYP` field: the index of the bound device support in
3580 /// [`Self::device_choices`]. An unset DTYP is index 0, exactly as in C.
3581 pub(crate) fn dtyp_index(&self) -> u16 {
3582 let dtyp = self.common.dtyp.as_str();
3583 if dtyp.is_empty() {
3584 return 0;
3585 }
3586 // A record type with no device menu has no slot for any DTYP, so the
3587 // index stays 0 — the same answer the old `unwrap_or(&[])` gave.
3588 self.device_choices()
3589 .unwrap_or_default()
3590 .iter()
3591 .position(|c| c.as_str_lossy() == dtyp)
3592 .unwrap_or(0) as u16
3593 }
3594
3595 /// **The** owner of "what string does this enum-valued field render as" —
3596 /// C's `[DBF_*][DBR_STRING]` conversion row, chosen by the field's DBF
3597 /// class. Every path that renders an enum as a string goes through here:
3598 /// the CA/PVA encoders (via [`EnumInfo::string_form`](crate::server::snapshot::EnumInfo::string_form) on the
3599 /// snapshot this builds) and the db-link read
3600 /// ([`Self::field_as_dbr_string`]). There is exactly one such table per
3601 /// field, and no path may reconstruct a second one.
3602 ///
3603 /// C's dispatch, and this function's, in the same order:
3604 ///
3605 /// * `DBF_MENU` / `DBF_DEVICE` -> `getMenuString` / `getDeviceString`, the
3606 /// field's own choice list. Asked FIRST, because a menu field on a record
3607 /// whose `VAL` is an enum (`bo.OMSL`) must render its menu's choices, not
3608 /// the record's `ZNAM`/`ONAM`.
3609 /// * `DBF_ENUM` `VAL` -> `getEnumString` -> the record's `get_enum_str`
3610 /// rset ([`Record::enum_string_form`]).
3611 ///
3612 /// `None` when the field has neither — C answers `S_db_noRSET`, an error;
3613 /// the port renders empty.
3614 ///
3615 /// Each class brings its own out-of-range rule with it (see
3616 /// [`EnumOverflow`](crate::server::snapshot::EnumOverflow)); the index is
3617 /// rendered as a number for a `DBF_MENU` and ONLY for a `DBF_MENU`.
3618 pub(crate) fn enum_string_form_for(&self, field: &str) -> Option<EnumStringForm> {
3619 if field.eq_ignore_ascii_case("DTYP") {
3620 // `None` propagates C's `goto nostrs` (`dbAccess.c:178`): a record
3621 // type with no `device()` declaration supplies no choice list, so
3622 // the leaf is omitted rather than marked empty.
3623 return self.device_choices().map(EnumStringForm::device);
3624 }
3625 if let Some(choices) = self.menu_choices_for(field) {
3626 return Some(EnumStringForm::menu(
3627 choices.iter().map(|c| PvString::from(*c)),
3628 ));
3629 }
3630 if field.eq_ignore_ascii_case("VAL") {
3631 return self.record.enum_string_form();
3632 }
3633 None
3634 }
3635
3636 /// Is `field` one of the DBF classes C's soft device support writes as
3637 /// `DBR_STRING`?
3638 ///
3639 /// `devsCalcoutSoft.c:128-130` (and its async twin, :83-85) switches the
3640 /// scalcout OUT put on the TARGET field's DBF type and sends `OSV` — the
3641 /// string result — for seven of them:
3642 ///
3643 /// ```c
3644 /// case DBF_STRING: case DBF_ENUM: case DBF_MENU: case DBF_DEVICE:
3645 /// case DBF_INLINK: case DBF_OUTLINK: case DBF_FWDLINK:
3646 /// status = dbPutLink(&pscalcout->out, DBR_STRING, &pscalcout->osv, 1);
3647 /// ```
3648 ///
3649 /// [`DbFieldType`] is the port's DBR *wire* type and cannot express
3650 /// `DBF_MENU` / `DBF_DEVICE` — C's DBF class is not the DBR type. The
3651 /// classification therefore lives here, with the record's field metadata,
3652 /// where each class is already known:
3653 ///
3654 /// * `DBF_STRING` and the three link classes — the port stores links and
3655 /// `DTYP` (C's only `DBF_DEVICE` field) as strings;
3656 /// * `DBF_ENUM` — an enum-typed field;
3657 /// * `DBF_MENU` — a menu-index field, i.e. one this record resolves choice
3658 /// labels for ([`Self::menu_choices_for`]): `PRIO`, `STAT`, `SEVR`,
3659 /// `DISS`, `ACKT`, `SCAN`, `IVOA`, `OMSL`, … The index is stored as a
3660 /// short, so a same-named field that is NOT a menu index (scalcout's
3661 /// string `OSV` shares a name with the alarm-severity menu) is
3662 /// classified by its own type, not by the name collision.
3663 ///
3664 /// Everything else (`DBF_DOUBLE`, `DBF_LONG`, `DBF_CHAR`, …) falls to the
3665 /// device support's `default:` arm.
3666 ///
3667 /// The question is about the target field's DECLARED class, so it is asked
3668 /// of the declaration ([`Self::declared_field_type`]) and not of the
3669 /// variant the record stores: C's `switch` is on `dbAddr.field_type`, which
3670 /// `dbNameToAddr` took from the `dbFldDes`. `DBF_MENU` and `DBF_DEVICE` both
3671 /// map to `DbFieldType::Enum` in the generated tables (`mapDBFToDBR`), and
3672 /// the three link classes to `DbFieldType::String`, so the seven C arms are
3673 /// exactly these two.
3674 pub(crate) fn field_puts_as_string(&self, field: &str) -> bool {
3675 let Some(declared) = self.declared_field_type(field) else {
3676 return false;
3677 };
3678 matches!(declared, DbFieldType::String | DbFieldType::Enum)
3679 }
3680
3681 /// The field's value as C `dbGetLink(plink, DBR_STRING, ...)` delivers it —
3682 /// the SOURCE side of an input link read with
3683 /// [`LinkReadAs::String`].
3684 ///
3685 /// C converts at the source, through `dbConvert.c`'s
3686 /// `[field_type][DBR_STRING]` table: a `DBF_ENUM` field goes through
3687 /// `getEnumString` → the record's `get_enum_str` (mbbi's `ZRST`.., bi's
3688 /// `ZNAM`/`ONAM`) and a `DBF_MENU` field through `getMenuString` → the
3689 /// menu's choice string, i.e. the state LABEL in both cases, never the
3690 /// index. Only the record holds those tables, so the render lives here with
3691 /// the field metadata — the link-read owner has an index and nothing to
3692 /// resolve it with.
3693 ///
3694 /// The render goes through [`Self::enum_string_form_for`], the same owner
3695 /// the CA/PVA encoders use, so a link read and a `caget -t` of one field can
3696 /// never disagree about its string.
3697 pub(crate) fn field_as_dbr_string(&self, field: &str) -> Option<PvString> {
3698 let value = self.resolve_field(field)?;
3699 // A `DBF_ENUM` index, and a `DBF_MENU` index (stored as a short),
3700 // render through the field's string source. A short field that is
3701 // neither has no source and stays the plain number C converts it to.
3702 let idx = match value {
3703 EpicsValue::Enum(v) => Some(v),
3704 EpicsValue::Short(v) => u16::try_from(v).ok(),
3705 _ => None,
3706 };
3707 if let Some(idx) = idx
3708 && let Some(form) = self.enum_string_form_for(field)
3709 {
3710 return Some(form.render(idx));
3711 }
3712 value_as_dbr_string(&value)
3713 }
3714
3715 /// The field's declaration — its `dbFldDes`, in C's terms.
3716 ///
3717 /// The `.dbd` is the declaration, so the table generated FROM the `.dbd`
3718 /// ([`dbd_generated::record_fields`](super::dbd_generated::record_fields))
3719 /// is asked first, for every record type that has one. A record's own
3720 /// `Record::field_list` is a
3721 /// hand-written stand-in for that table, and it is consulted only for a
3722 /// record type the `.dbd` does not cover (`subArray`, and the record types
3723 /// the downstream crates add). It cannot be the primary answer: several of
3724 /// those tables are *derived from the record's Rust storage types* — the
3725 /// `#[derive(EpicsRecord)]` records type `longin.ADEL` `DBF_DOUBLE`
3726 /// because the struct member is an `f64`, where the `.dbd` says
3727 /// `DBF_LONG` — and reading the type off the storage is the whole defect
3728 /// this owner exists to close.
3729 ///
3730 /// `dbCommon` last, matching the order [`Self::resolve_field`] reads the
3731 /// value in, so a record-specific field always shadows the common one in
3732 /// both halves.
3733 ///
3734 /// `None` for a field with no declaration at all: a virtual field
3735 /// (`RTYP`, `TIME`, ...), which C answers from dbStaticLib rather than
3736 /// from a `dbFldDes`.
3737 pub(crate) fn field_desc(&self, field: &str) -> Option<&'static FieldDesc> {
3738 field_desc_of(self.record.as_ref(), field)
3739 }
3740
3741 /// Is `field` (already uppercased) a `DBF_NOACCESS` internal name —
3742 /// record-own (`BPTR`, `RPVT`, ...) or `dbCommon` (`MLOK`, `RSET`, ...)?
3743 ///
3744 /// C's `dbNameToAddr` resolves such a name, so a SEARCH for it is
3745 /// answered and the refusal lands at channel creation (`mapDBFToDBR` →
3746 /// `DBR_NOACCESS`). The search gate (`PvDatabase::has_name_no_resolve`)
3747 /// asks this so the port answers the same way; every value path stays
3748 /// closed to these names.
3749 pub(crate) fn resolves_noaccess_name(&self, field: &str) -> bool {
3750 is_dbcommon_noaccess(field) || self.record.noaccess_names().contains(&field)
3751 }
3752
3753 /// The `DBF_*` type `field` is SERVED as — the single source of truth for
3754 /// the type on the wire, on every delivery path.
3755 ///
3756 /// This is the field's DECLARED type ([`FieldDesc::dbf_type`], from the
3757 /// `.dbd`), not the type of whatever variant the record happens to store.
3758 /// C resolves a channel's `field_type` from the `dbFldDes` at
3759 /// name-resolution time (`dbChannelCreate` -> `dbNameToAddr`,
3760 /// `dbAccess.c:184-205`) and every later `dbGet`/`db_post_events` converts
3761 /// the stored bytes to it — the storage is private to the record, the
3762 /// declaration is the contract.
3763 ///
3764 /// Two answers are NOT the declaration:
3765 ///
3766 /// * a [`FieldDesc::runtime_typed`] field — C's `cvt_dbaddr` overwrites
3767 /// `paddr->field_type` from record state (`FTVL`, `FTA`, `SDEF`), and
3768 /// this port's `cvt_dbaddr` is the variant the record stores;
3769 /// * a field with no `FieldDesc` at all (a virtual field).
3770 ///
3771 /// In both cases the value's own type is the answer, so this returns
3772 /// `None` and [`Self::project_to_declared_type`] leaves the value alone.
3773 pub fn declared_field_type(&self, field: &str) -> Option<DbFieldType> {
3774 declared_field_type_of(self.record.as_ref(), field)
3775 }
3776
3777 /// Project a field's stored value onto its declared type
3778 /// ([`Self::declared_field_type`]) — the single owner of "what type this
3779 /// field goes on the wire as", run by the CA create-channel path
3780 /// ([`Self::client_field_value`]), the GET path
3781 /// ([`Self::snapshot_for_field`]) and the MONITOR path
3782 /// ([`Self::make_monitor_snapshot`]), so all three announce and serve the
3783 /// same type.
3784 ///
3785 /// The projection is [`EpicsValue::convert_to`], the one value-coercion
3786 /// owner — the same routine `dbGet` converts through. Never re-derive a
3787 /// conversion here: C picks its routine from BOTH the source and the
3788 /// destination type, and only `convert_to` knows that table.
3789 ///
3790 /// Idempotent: a value already of its declared type is short-circuited by
3791 /// `convert_to`, and re-projecting a projected value is a no-op. That is
3792 /// what lets the CA path derive the native type from the value it is about
3793 /// to serve.
3794 pub fn project_to_declared_type(&self, field: &str, value: EpicsValue) -> EpicsValue {
3795 match self.declared_field_type(field) {
3796 Some(declared) => value.convert_to(declared),
3797 None => value,
3798 }
3799 }
3800
3801 /// The client-facing value of `field`: the resolved value projected onto
3802 /// the field's declared type ([`Self::project_to_declared_type`]), so a
3803 /// native type derived from the value — which is what the CA
3804 /// create-channel path does — is the DECLARED type, and matches the
3805 /// GET/MONITOR data byte for byte.
3806 pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
3807 let value = self.resolve_field(field)?;
3808 Some(self.project_to_declared_type(field, value))
3809 }
3810
3811 /// Attach a `DBF_MENU` field's `menu()` choice labels to a built snapshot,
3812 /// so the CA/PVA enum encoders present `"NO CONVERSION"` rather than `0`.
3813 ///
3814 /// The VALUE half of the `DBF_MENU` -> `DBR_ENUM` mapping is not here: the
3815 /// `.dbd` declares a menu field `DBF_MENU`, the generator types that
3816 /// `DbFieldType::Enum` (`mapDBFToDBR`), and
3817 /// [`Self::project_to_declared_type`] — which every delivery path runs —
3818 /// makes the served value an [`EpicsValue::Enum`] on that declaration
3819 /// alone. So the label table is all that is left to attach, and it is
3820 /// attached exactly when the served value came out an enum. A same-named
3821 /// field that is NOT a menu index (`scalcout.OSV`, declared `DBF_STRING`,
3822 /// shares a name with the alarm-severity menu) is served as its own
3823 /// declared string and gets no choice table.
3824 fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
3825 if !matches!(snap.value, EpicsValue::Enum(_)) {
3826 return;
3827 }
3828 // `VAL` is the one field whose two rset slots differ: C's
3829 // `get_enum_strs` (the `DBR_GR_ENUM` labels) is TRIMMED to `no_str`
3830 // while `get_enum_str` (the DBR_STRING form) indexes the untrimmed
3831 // state array. `populate_enum_info` owns that pair; every OTHER
3832 // enum-valued field is a menu or a device, whose one choice list
3833 // answers both (C `getMenuString`/`getDeviceString` index the same
3834 // `papChoiceValue` the GR_ENUM reply carries).
3835 if field.eq_ignore_ascii_case("VAL") {
3836 return;
3837 }
3838 let Some(form) = self.enum_string_form_for(field) else {
3839 return;
3840 };
3841 snap.enums = Some(super::super::snapshot::EnumInfo::with_string_form(
3842 form.slots.clone(),
3843 form,
3844 ));
3845 }
3846
3847 /// Build a Snapshot with full metadata for the given field — for a field
3848 /// **no link backs**.
3849 ///
3850 /// A link-backed field answers `None` here on purpose. Its metadata has to
3851 /// be resolved from the target record, which needs a
3852 /// [`PvDatabase`](crate::server::database::PvDatabase) and, because the
3853 /// port has one lock per record instead of C's per-lock-set recursive
3854 /// mutex, has to happen with no record lock held. That is
3855 /// [`PvDatabase::channel_snapshot_for_field`](crate::server::database::PvDatabase::channel_snapshot_for_field),
3856 /// and it is the only entry point that can serve one. Answering `None`
3857 /// rather than a seeded snapshot is what makes a caller that reached for
3858 /// the wrong door serve nothing instead of something stale.
3859 pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
3860 if self.link_backed_metadata_field_of(field).is_some() {
3861 return None;
3862 }
3863 self.snapshot_for_field_with(field, LinkBacking::none())
3864 }
3865
3866 /// [`Self::snapshot_for_field`] with the link metadata the caller resolved
3867 /// for this build. `PvDatabase` is the intended caller; see [`LinkBacking`].
3868 pub fn snapshot_for_field_with(
3869 &self,
3870 field: &str,
3871 backing: LinkBacking<'_>,
3872 ) -> Option<super::super::snapshot::Snapshot> {
3873 // The GET path serves the field at its DECLARED type, the same type
3874 // the CA create-channel path announced from `client_field_value` and
3875 // the same one the monitor path posts.
3876 let value = self.client_field_value(field)?;
3877 Some(self.finish_field_snapshot(field, value, backing))
3878 }
3879
3880 /// Which of this record's own link fields, if any, supplies `field`'s
3881 /// metadata — C's `get_linkNumber` question, asked before any lock is
3882 /// dropped so `PvDatabase` knows whether it has to resolve at all.
3883 pub(crate) fn link_backed_metadata_field_of(&self, field: &str) -> Option<String> {
3884 self.record
3885 .link_backed_metadata_field(&field.to_ascii_uppercase())
3886 }
3887
3888 /// The value a channel bound to `field` serves, through the `$` view
3889 /// the channel was bound with.
3890 ///
3891 /// `dbChannelCreate` decides the view ONCE, at bind time
3892 /// (`dbChannel.c:486-505`), and every delivery path then reads through
3893 /// the `dbChannel` it produced; this is that single read. Callers must
3894 /// not re-derive it: resolving the bare field name answers "yes" for
3895 /// `VAL` whatever its type, so a path that does drops the eligibility
3896 /// half of the view entirely and admits `REC.VAL$` on a `DBF_DOUBLE`.
3897 ///
3898 /// `None` is `S_dbLib_fieldNotFound`: the record has no such field, or
3899 /// `$` was applied to a field that cannot be re-viewed as a character
3900 /// array (see [`Self::resolve_string_view_field`]).
3901 pub fn channel_field_value(&self, field: &str, string_view: bool) -> Option<EpicsValue> {
3902 if string_view {
3903 self.resolve_string_view_field(field)
3904 } else {
3905 self.client_field_value(field)
3906 }
3907 }
3908
3909 /// [`Self::snapshot_for_field_with`] through the same `$` view as
3910 /// [`Self::channel_field_value`] — the metadata is the field's either
3911 /// way, only the value is re-viewed.
3912 ///
3913 /// This is the `_with` variant deliberately: the view decides the VALUE,
3914 /// `backing` decides the METADATA, and the two are independent. A caller
3915 /// that has resolved a [`LinkBacking`] passes it straight through, so a
3916 /// link-backed `$` member keeps its target's units/precision.
3917 pub fn channel_snapshot_for_field(
3918 &self,
3919 field: &str,
3920 string_view: bool,
3921 backing: LinkBacking<'_>,
3922 ) -> Option<super::super::snapshot::Snapshot> {
3923 let value = self.channel_field_value(field, string_view)?;
3924 Some(self.finish_field_snapshot(field, value, backing))
3925 }
3926
3927 /// The one finishing pipeline behind both `Snapshot` producers
3928 /// ([`Self::snapshot_for_field`] for GET, [`Self::make_monitor_snapshot`]
3929 /// for updates). Every step that shapes a served snapshot — alarm/utag
3930 /// carry, the metadata cache, per-field routing and RSET overrides, menu
3931 /// enums, property support, the `Q:time:tag` nsec split — runs here, so
3932 /// the two paths cannot drift apart. Upstream pvxs PR #189 is exactly
3933 /// that drift: its subscription callback served unmasked nanoseconds
3934 /// while its GET path applied the nsec mask.
3935 fn finish_field_snapshot(
3936 &self,
3937 field: &str,
3938 value: EpicsValue,
3939 backing: LinkBacking<'_>,
3940 ) -> super::super::snapshot::Snapshot {
3941 let mut snap = super::super::snapshot::Snapshot::new(
3942 value,
3943 self.common.stat,
3944 self.common.sevr as u16,
3945 self.common.time,
3946 );
3947 // Default the served `timeStamp.userTag` to the record's `utag`,
3948 // mirroring pvxs `iocsource.cpp:245` (`auto utag = meta.utag;`).
3949 // The 64-bit `epicsUTag` narrows to the int32 NT wire field by
3950 // truncating to the low 32 bits — pvxs assigns the same uint64
3951 // straight into the `Int32` `timeStamp.userTag`. The `Q:time:tag`
3952 // nsec-LSB split below overrides this when configured, matching
3953 // pvxs `if(info.nsecMask) utag = meta.time.nsec & info.nsecMask;`
3954 // (:246-247 — the test and its assignment).
3955 snap.user_tag = self.common.utag as i32;
3956 // Carry the record's committed alarm message (`common.amsg`) so a
3957 // PVA read serves `alarm.message` from the record's own amsg
3958 // (pvxs `iocsource.cpp:230-236` prefers `meta.amsg`) rather than a
3959 // string re-synthesized from the condition code. Empty for records
3960 // that raise no message (C's plain `recGblSetSevr` clears namsg).
3961 snap.alarm.amsg = self.common.amsg.as_str().to_owned();
3962
3963 // Pull display/control/enums from the metadata cache (build on
3964 // first call, hit thereafter until invalidated by a metadata-class
3965 // field write).
3966 let meta = self.cached_metadata();
3967 let explicit_alarm = meta.alarm;
3968 snap.display = meta.display;
3969 snap.control = meta.control;
3970 snap.enums = meta.enums;
3971
3972 // The cache above is the record's VAL metadata. C routes PER FIELD, so
3973 // a non-VAL-class field does NOT get VAL's limits — see
3974 // [`Self::route_field_metadata`], which owns that decision.
3975 self.route_field_metadata(field, backing, explicit_alarm, &mut snap);
3976
3977 // Per-field RSET metadata (C get_units/get_precision/
3978 // get_graphic_double/get_control_double/get_alarm_double key on
3979 // dbGetFieldIndex) patches the record-level cache for this field.
3980 self.apply_field_metadata_override(field, &mut snap);
3981
3982 // DBF_MENU field (a shared menu such as `SCAN`/`OMSL`/`HHSV`/... or
3983 // a record-specific menu such as `sel.SELM`): carry the menu index
3984 // as DBR_ENUM and attach its `menu()` choice labels. See
3985 // `attach_menu_enum`. This overrides any record VAL enum table
3986 // copied from the metadata cache above, because a menu field
3987 // carries its own menu's choices, not the record's VAL state
3988 // strings.
3989 self.attach_menu_enum(field, &mut snap);
3990
3991 // The metadata VALUES and the mask that says which of them this
3992 // channel actually supplies are assigned by the same owner, from the
3993 // settled value, so they cannot disagree.
3994 self.assign_property_support(field, &mut snap);
3995
3996 // apply `info(Q:time:tag, "nsec:lsb:N")` — pvxs
3997 // `iocsource.cpp:239-248` publishes `nanoseconds & ~nsecMask` and
3998 // moves `nanoseconds & nsecMask` into `timeStamp.userTag`. The
3999 // split is applied to both `snap.timestamp` and `snap.user_tag` so
4000 // downstream encoders (NTScalar `timeStamp`, QSRV groups) all see
4001 // the same shape. A zero mask (tag absent or unparseable) is a
4002 // no-op inside the helper, exactly as pvxs's `if(info.nsecMask)`
4003 // gate is.
4004 crate::server::snapshot::apply_nsec_mask(&mut snap, self.qtime_nsec_mask());
4005
4006 snap
4007 }
4008
4009 /// Resolve `info(Q:time:tag)` to pvxs's `MappingInfo::nsecMask`.
4010 /// Returns 0 (the "no split" mask) when the tag is absent or does not
4011 /// parse — pvxs leaves `nsecMask` at its 0 initialiser in that case.
4012 ///
4013 /// pvxs `ioc/typeutils.cpp:79-88`:
4014 ///
4015 /// ```c
4016 /// if(auto val = ent.info("Q:time:tag")) {
4017 /// epicsInt32 dig = 0;
4018 /// if(strncmp(val, "nsec:lsb:", 9)==0 && !epicsParseInt32(&val[9], &dig, 10, nullptr)) {
4019 /// nsecMask = (uint64_t(1u)<<dig)-1u;
4020 /// }
4021 /// }
4022 /// ```
4023 ///
4024 /// The prefix test is a byte-exact `strncmp` — no case folding and no
4025 /// whitespace tolerance, so `NSEC:LSB:4` and `nsec: lsb: 4` do NOT
4026 /// match and leave the timestamp alone. There is no bounds clamp
4027 /// either: any `dig` `epicsParseInt32` accepts is shifted verbatim, so
4028 /// `nsec:lsb:31` yields the `0x7FFF_FFFF` mask pvxs actually serves.
4029 fn qtime_nsec_mask(&self) -> u64 {
4030 let Some(rest) = self
4031 .get_info("Q:time:tag")
4032 .and_then(|v| v.strip_prefix("nsec:lsb:"))
4033 else {
4034 return 0;
4035 };
4036 let Some(dig) = epics_parse_int32_base10(rest) else {
4037 return 0;
4038 };
4039 // C shifts `uint64_t(1u)` by an `epicsInt32`. A `dig` outside
4040 // `0..=63` is UB in C++; every ISA EPICS builds on (x86-64 `shlq`,
4041 // aarch64 `lsl`) takes the shift count modulo 64, which is what
4042 // `wrapping_shl` does — so `nsec:lsb:64` disables the split
4043 // (mask 0) and a negative `dig` shifts by `dig & 63`, the same
4044 // masks pvxs produces on those hosts.
4045 1u64.wrapping_shl(dig as u32) - 1
4046 }
4047
4048 /// Populate DisplayInfo from record fields if applicable.
4049 /// Resolve the `Q:form` info-tag value to a `display.form` menu index.
4050 ///
4051 /// pvxs publishes the fixed seven-entry form menu
4052 /// (Default/String/Binary/Decimal/Hex/Exponential/Engineering) for every
4053 /// numeric value and, for the VAL field only, sets `display.form.index`
4054 /// to the slot whose name equals the field's `Q:form` info tag
4055 /// (`iocsource.cpp:42-62`, case-sensitive). Unset or unrecognised ->
4056 /// `None` (form stays 0 = Default), exactly as pvxs leaves the index
4057 /// untouched on no match.
4058 fn q_form_index(&self) -> Option<i16> {
4059 const FORM_NAMES: [&str; 7] = [
4060 "Default",
4061 "String",
4062 "Binary",
4063 "Decimal",
4064 "Hex",
4065 "Exponential",
4066 "Engineering",
4067 ];
4068 let tag = self.info.get("Q:form")?;
4069 FORM_NAMES
4070 .iter()
4071 .position(|name| name == tag)
4072 .map(|i| i as i16)
4073 }
4074
4075 /// Stamp a built snapshot with the property mask THIS channel supplies —
4076 /// [`Record::property_support`] narrowed to the addressed field by C's
4077 /// second gate ([`PropertySupport::narrowed_to_field`]). Called by both
4078 /// snapshot builders once the value has settled (after
4079 /// [`Self::attach_menu_enum`] promoted a `DBF_MENU` field to its
4080 /// `DBR_ENUM` form), so the mask is read off the same value the client
4081 /// receives and no consumer has to re-derive either gate.
4082 fn assign_property_support(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
4083 snap.properties = self.record.property_support().narrowed_to_field(
4084 snap.value.db_field_type(),
4085 self.menu_choices_for(field).is_some(),
4086 );
4087 }
4088
4089 /// The property mask a channel on `field` supplies, without building a
4090 /// snapshot — what a PVA server needs to decide which NT leaves it may
4091 /// MARK for a channel it has not read yet (QSRV resolves a group's member
4092 /// masks once, at monitor start, rather than per event).
4093 ///
4094 /// Same two gates, same owner as `Self::assign_property_support`: an
4095 /// unknown field supplies nothing.
4096 pub fn property_support_for_field(&self, field: &str) -> PropertySupport {
4097 let Some(value) = self.client_field_value(field) else {
4098 return PropertySupport::NONE;
4099 };
4100 self.record.property_support().narrowed_to_field(
4101 value.db_field_type(),
4102 self.menu_choices_for(field).is_some(),
4103 )
4104 }
4105
4106 /// The record-level display metadata cache: units, precision and display
4107 /// limits as C's `get_units` / `get_precision` / `get_graphic_double`
4108 /// answer them for the fields their rset lists.
4109 ///
4110 /// Driven by [`Record::property_support`], not by a `match` on the record
4111 /// type. Those were two independent tables answering the same question,
4112 /// and nothing kept them in step: `default_property_support` declares
4113 /// units and precision for twenty-five record types where the arm list
4114 /// here covered nine, so the other sixteen declared the leaf and served
4115 /// `""` / `0` — `sel`, `sub`, `dfanout`, `subArray`, `scalcout`,
4116 /// `acalcout`, `epid`, `scaler`, `swait`, `sseq`, `seq`, `mca`,
4117 /// `histogram`, `transform`, `throttle` and `asyn`. Deriving the cache
4118 /// from the declaration makes "declares the slot" and "supplies the slot"
4119 /// one fact rather than two that can disagree.
4120 ///
4121 /// Precision matters well beyond `caget -d`: it is also what the
4122 /// DBF_DOUBLE to DBR_STRING conversion renders with, in C
4123 /// (`dbConvert.c:783-786` calls `prset->get_precision` with no field-type
4124 /// gate) and here (`codec.rs::convert_value_to_dbr_string`), so a missing
4125 /// slot changed the digits of a plain `caget`.
4126 ///
4127 /// The sources are the same in every C rset that supplies them — `EGU` for
4128 /// `get_units` and `PREC` for `get_precision` — so only the graphic pair
4129 /// needs a per-type table (`graphic_limit_fields`). Per-FIELD departures
4130 /// from the record's own values stay where C puts them, in that record's
4131 /// [`Record::field_metadata_override`], which is applied after this and
4132 /// wins.
4133 fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
4134 let slots = self.record.property_support();
4135 if slots.units || slots.precision || slots.graphic_double {
4136 let (upper, lower) = if slots.graphic_double {
4137 let (hi, lo) = super::record_trait::graphic_limit_fields(self.record.record_type());
4138 (self.metadata_limit(hi), self.metadata_limit(lo))
4139 } else {
4140 (0.0, 0.0)
4141 };
4142 snap.display = Some(super::super::snapshot::DisplayInfo {
4143 units: if slots.units {
4144 self.metadata_units()
4145 } else {
4146 Default::default()
4147 },
4148 precision: if slots.precision {
4149 self.metadata_limit("PREC") as i16
4150 } else {
4151 0
4152 },
4153 upper_disp_limit: upper,
4154 lower_disp_limit: lower,
4155 ..Default::default()
4156 });
4157 }
4158 // Apply the `Q:form` display-format hint. The block above builds
4159 // `snap.display` for every record type that supplies at least one
4160 // display slot — the same set for which pvxs emits
4161 // `display.form.choices`. This cache is record-level (it is the VAL
4162 // field's metadata); the VAL-only rule pvxs applies to
4163 // `display.form.index` (`iocsource.cpp:53`) is enforced per served
4164 // field in `apply_field_metadata_override`.
4165 if let Some(display) = snap.display.as_mut() {
4166 if let Some(form) = self.q_form_index() {
4167 display.form = form;
4168 }
4169 }
4170 // `display.description` from dbCommon DESC — pvxs QSRV fills it
4171 // on every metadata populate (iocsource.cpp:306-310), for every
4172 // record type including those with no other display source. The
4173 // qsrv builders always emit the leaf (defaulting a `None`
4174 // display), so creating the DisplayInfo here changes leaf
4175 // values, never the wire shape. Cache freshness is owned by the
4176 // DESC arm of `put_common_field`, which invalidates without
4177 // posting DBE_PROPERTY (epics-base#785 / UI-106).
4178 snap.display
4179 .get_or_insert_with(Default::default)
4180 .description = self.common.desc.clone();
4181 }
4182
4183 /// The record-level control-limit cache — what C's `get_control_double`
4184 /// answers for the fields its rset lists.
4185 ///
4186 /// Gated on the declared slot for the same reason as
4187 /// [`Self::populate_display_info`], and with the same effect: the arm list
4188 /// this replaces covered thirteen record types where
4189 /// `default_property_support` declares `control_double` for twenty-three,
4190 /// so `sel`, `sub`, `dfanout`, `subArray`, `histogram`, `scalcout`,
4191 /// `acalcout` and `epid` served 0/0 on `VAL` — a channel whose C rset
4192 /// answers the record's own operator range.
4193 ///
4194 /// Which of the record's fields that range comes from is the one thing
4195 /// that varies by type, and it lives in `control_limit_source`.
4196 fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
4197 use super::record_trait::ControlLimitSource;
4198
4199 if !self.record.property_support().control_double {
4200 return;
4201 }
4202 let (upper, lower) =
4203 match super::record_trait::control_limit_source(self.record.record_type()) {
4204 ControlLimitSource::Drive => {
4205 (self.metadata_limit("DRVH"), self.metadata_limit("DRVL"))
4206 }
4207 ControlLimitSource::DriveWhenSet => {
4208 let (drvh, drvl) = (self.metadata_limit("DRVH"), self.metadata_limit("DRVL"));
4209 if drvh > drvl {
4210 (drvh, drvl)
4211 } else {
4212 (self.metadata_limit("HOPR"), self.metadata_limit("LOPR"))
4213 }
4214 }
4215 ControlLimitSource::SoftLimits => {
4216 (self.metadata_limit("HLM"), self.metadata_limit("LLM"))
4217 }
4218 ControlLimitSource::Operator => {
4219 (self.metadata_limit("HOPR"), self.metadata_limit("LOPR"))
4220 }
4221 };
4222 snap.control = Some(super::super::snapshot::ControlInfo {
4223 upper_ctrl_limit: upper,
4224 lower_ctrl_limit: lower,
4225 });
4226 }
4227
4228 /// A numeric metadata field (`PREC`, `HOPR`, `DRVH`, ...) as C reads it —
4229 /// straight out of record memory, which for C means every field the `.dbd`
4230 /// declares.
4231 ///
4232 /// Through [`Self::resolve_field`], NOT `Record::get_field`, and that is
4233 /// the whole reason this cache used to read zero for the types it now
4234 /// serves: a Rust record implements only the fields it has behaviour for,
4235 /// so `sel`, `sub`, `dfanout` and their siblings model no `PREC`/`HOPR`
4236 /// cell at all and `get_field` answers `None` for them. Their `.db` values
4237 /// live in `declared_overrides`, and their unset defaults in the `.dbd`
4238 /// `initial()`, both of which only `resolve_field` reaches.
4239 fn metadata_limit(&self, field: &str) -> f64 {
4240 self.resolve_field(field)
4241 .and_then(|v| v.to_f64())
4242 .unwrap_or(0.0)
4243 }
4244
4245 /// `EGU`, the source every ported C `get_units` copies from. Empty for a
4246 /// record type whose `.dbd` declares no `EGU` — `seq` and `histogram` have
4247 /// none, and C writes nothing into the `dbAccess.c:378` seed for either.
4248 fn metadata_units(&self) -> crate::types::PvString {
4249 match self.resolve_field("EGU") {
4250 Some(EpicsValue::String(s)) => s,
4251 _ => Default::default(),
4252 }
4253 }
4254
4255 /// Whether C's `get_units` copies the record's own `EGU` into `field`.
4256 ///
4257 /// The fourth membership question, and the one the port never asked. Units
4258 /// had no per-field step at all: the record-level cache was the entire
4259 /// answer, so every field of a type that supplies the slot was served
4260 /// `EGU` — including the fields whose C rset tests first and writes
4261 /// nothing, leaving the `dbAccess.c:378` empty seed. Measured shape:
4262 /// `caget -d DBR_GR_DOUBLE AI.SMOO` served `EGU` where `aiRecord.c:223-226`
4263 /// deliberately skips the three raw-conversion fields.
4264 ///
4265 /// * `ai`/`ao` (`aiRecord.c:217-232`, `aoRecord.c:284-298`) — a DBF_DOUBLE
4266 /// field other than the raw-conversion ones, which carry no engineering
4267 /// units.
4268 /// * `calc`/`calcout`/`sub`/`sel`/`dfanout` (`calcRecord.c:169-182`,
4269 /// `calcoutRecord.c:425-444`, `subRecord.c:206-219`,
4270 /// `selRecord.c:136-143`, `dfanoutRecord.c:155-163`) — any DBF_DOUBLE
4271 /// field.
4272 /// * `longin`/`longout` (`longinRecord.c:183-191`) test DBF_LONG and the
4273 /// int64 pair (`int64inRecord.c:179-187`) DBF_INT64: the record's own VAL
4274 /// type, not DOUBLE.
4275 /// * `compress` (`compressRecord.c:449-458`) widens the DBF_DOUBLE test
4276 /// with `VAL`, whose served type comes from the record rather than the
4277 /// dbd.
4278 /// * the array types (`waveformRecord.c:220-233`, `aaiRecord.c`,
4279 /// `aaoRecord.c`, `subArrayRecord.c:202-215`) name `VAL`, `HOPR` and
4280 /// `LOPR`, and drop `VAL` when `FTVL` makes it strings or enums.
4281 /// * `histogram`, `seq`, `bo`, `table` and `aSub` never write `EGU` at all;
4282 /// each answers a literal or a link for a named set and nothing
4283 /// elsewhere, and the literals come from
4284 /// [`Record::field_metadata_override`].
4285 ///
4286 /// Every other ported type copies `EGU` with no test whatever
4287 /// (`sCalcoutRecord.c:603-609`, `aCalcoutRecord.c:743-749`,
4288 /// `epidRecord.c:217-223`, `mcaRecord.c:884-890`, `motorRecord.cc`'s
4289 /// `default:` arm).
4290 fn units_from_egu(&self, rtype: &str, field: &str) -> bool {
4291 use crate::types::DbFieldType as T;
4292 let f = field.to_ascii_uppercase();
4293 // The link arm is NOT here: `route_field_metadata` asks
4294 // [`Record::link_backed_metadata_field`] first and only falls through
4295 // to this EGU question for a field no link backs. This function
4296 // answers C's `else strncpy(units, prec->egu, ...)` branch alone.
4297 let own = |t: T| self.static_field_type(&f) == Some(t);
4298 match rtype {
4299 "ai" => own(T::Double) && !matches!(f.as_str(), "ASLO" | "AOFF" | "SMOO"),
4300 "ao" => own(T::Double) && !matches!(f.as_str(), "ASLO" | "AOFF"),
4301 "calc" | "calcout" | "sub" | "sel" | "dfanout" => own(T::Double),
4302 "longin" | "longout" => own(T::Long),
4303 "int64in" | "int64out" => own(T::Int64),
4304 "compress" => own(T::Double) || f == "VAL",
4305 "waveform" | "aai" | "aao" | "subArray" => {
4306 matches!(f.as_str(), "HOPR" | "LOPR")
4307 || (f == "VAL" && !self.ftvl_is_string_or_enum())
4308 }
4309 "histogram" | "seq" | "bo" | "table" | "aSub" => false,
4310 _ => true,
4311 }
4312 }
4313
4314 /// `FTVL` names `DBF_STRING` or `DBF_ENUM` — the two element types for
4315 /// which the array rsets break out of the `VAL` case before copying `EGU`.
4316 /// `menuFtype` is declared in `DBF_` code order, so `0` is STRING and `11`
4317 /// is ENUM.
4318 fn ftvl_is_string_or_enum(&self) -> bool {
4319 matches!(
4320 self.resolve_field("FTVL").and_then(|v| v.to_f64()),
4321 Some(x) if x == 0.0 || x == 11.0
4322 )
4323 }
4324
4325 /// Populate EnumInfo — C rset `get_enum_strs`.
4326 ///
4327 /// The table comes from [`Record::enum_state_strings`], the SAME slot the
4328 /// string-put converter (`dbConvert.c::putStringEnum`) resolves against, so
4329 /// the choice list a client reads and the names it may write are one table
4330 /// by construction. It arrives already trimmed to C's `no_str` (bi/bo/busy
4331 /// drop an empty ONAM behind a set ZNAM — `boRecord.c:342-352`; mbbi/mbbo
4332 /// cut at the last non-empty state — `mbbiRecord.c:262-269`).
4333 ///
4334 /// The `DBR_STRING` half of the same channel is the record's OTHER rset slot
4335 /// (`get_enum_str`), which is not this trimmed list — see
4336 /// [`EnumStringForm`]. A record that has no such slot (every record but
4337 /// bi/bo/busy/mbbi/mbbo, including the downstream crates' own enum records)
4338 /// renders from its label list, which is what C's absent slot amounts to.
4339 fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
4340 if let Some(strings) = self.record.enum_state_strings() {
4341 snap.enums = Some(match self.record.enum_string_form() {
4342 Some(form) => super::super::snapshot::EnumInfo::with_string_form(strings, form),
4343 None => super::super::snapshot::EnumInfo::new(strings),
4344 });
4345 }
4346 }
4347
4348 /// Get a common field value.
4349 pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
4350 match name {
4351 "SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
4352 "STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
4353 "NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
4354 "NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
4355 // epics-base PR #568 / #566 — alarm message string.
4356 "AMSG" => Some(EpicsValue::String(self.common.amsg.as_str().into())),
4357 "NAMSG" => Some(EpicsValue::String(self.common.namsg.as_str().into())),
4358 "ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
4359 // `ACKT` and `PINI` are `DBF_MENU` (`menuYesNo` /`menuPini`,
4360 // `dbCommon.dbd.pod:335,169`), not `DBF_UCHAR`: they carry a menu
4361 // index, which `promote_menu_value` lifts to `DBR_ENUM` with the
4362 // menu's choice strings. Storing them as `Short` is what makes
4363 // them eligible for that promotion — see `promote_menu_value`.
4364 "ACKT" => Some(EpicsValue::Short(if self.common.ackt { 1 } else { 0 })),
4365 // DBF_UCHAR: served as UChar (declared type) so the raw put byte
4366 // round-trips to the wire — see the DISP/TPRO comment above.
4367 "UDF" => Some(EpicsValue::UChar(self.common.udf)),
4368 "UDFS" => Some(EpicsValue::Short(self.common.udfs)),
4369 "SCAN" => Some(EpicsValue::Enum(self.common.scan.to_u16())),
4370 "SSCN" => Some(EpicsValue::Enum(self.common.sscn.to_u16())),
4371 // `OLDSIMM` is `DBF_MENU`/`menu(menuSimm)`, stored as the menu index
4372 // and promoted to `DBR_ENUM` with the NO/YES/RAW labels by
4373 // `promote_menu_value` (shared registry — the saved copy is ALWAYS
4374 // menuSimm, unlike the live SIMM). Written only by the simulation
4375 // owner (`rec_gbl_save_simm`); `special(SPC_NOMOD)` for clients.
4376 "OLDSIMM" => Some(EpicsValue::Short(self.common.oldsimm)),
4377 "PINI" => Some(EpicsValue::Short(self.common.pini)),
4378 // DISP/TPRO/RPRO/UDF are `DBF_UCHAR` in `dbCommon.dbd`. Serve them
4379 // as `UChar` — their DECLARED type — so `project_to_declared_type`
4380 // is identity and the raw put byte reaches the wire untouched (C
4381 // stores the byte and `caget` renders `DBR_CHAR` signed: 255 → -1).
4382 // Serving `Char` here instead routed the value through the lossy
4383 // `Char → UChar` projection (signed −1 clamped to 0), so a
4384 // `caput DISP 255` read back as 0 rather than C's -1. `BKPT` is
4385 // `DBF_NOACCESS`: no `FieldDesc`, no projection, served `Char`.
4386 "TPRO" => Some(EpicsValue::UChar(self.common.tpro)),
4387 "BKPT" => Some(EpicsValue::Char(self.common.bkpt.get())),
4388 "FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
4389 // A record type whose C `.dbd` has no INP has no `.INP` channel
4390 // either — C's dbChannel resolution is the dbd, so `dbgf HI.INP` on
4391 // a histogram answers "PV 'HI.INP' not found". The port keeps INP on
4392 // `CommonFields` for every record, so `declares_inp_link()` is what
4393 // stands in for the dbd, and it must gate the read side as well as
4394 // the write side (`put_common_field`) — otherwise the field is
4395 // unloadable and unwritable yet still resolves as a channel.
4396 "INP" if self.record.declares_inp_link() => {
4397 Some(EpicsValue::String(self.common.inp.clone().into()))
4398 }
4399 "OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
4400 // C's DTYP is `DBF_DEVICE`: an epicsEnum16 index into the record
4401 // type's device menu, NOT the name. The name is what this port
4402 // stores and dispatches on; the index is what the wire carries.
4403 "DTYP" => Some(EpicsValue::Enum(self.dtyp_index())),
4404 "TSE" => Some(EpicsValue::Short(self.common.tse)),
4405 "TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
4406 // C `UTAG` is DBF_UINT64 — exposed natively as the unsigned
4407 // 64-bit value variant so values above i64::MAX round-trip.
4408 "UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
4409 "ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
4410 "ASL" => Some(EpicsValue::Char(self.common.asl)),
4411 "DESC" => Some(EpicsValue::String(self.common.desc.clone())),
4412 "PHAS" => Some(EpicsValue::Short(self.common.phas)),
4413 "EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
4414 "PRIO" => Some(EpicsValue::Short(self.common.prio)),
4415 "DISV" => Some(EpicsValue::Short(self.common.disv)),
4416 "DISA" => Some(EpicsValue::Short(self.common.disa)),
4417 "SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
4418 "DISS" => Some(EpicsValue::Short(self.common.diss)),
4419 "HYST" => Some(EpicsValue::Double(self.common.hyst)),
4420 "LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
4421 "DISP" => Some(EpicsValue::UChar(self.common.disp)),
4422 "PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
4423 "RPRO" => Some(EpicsValue::UChar(self.common.rpro)),
4424 "PACT" => Some(EpicsValue::Char(if self.is_processing() { 1 } else { 0 })),
4425 // C `dbCommon.dbd`: `field(PROC,DBF_UCHAR)` — the raw put byte is
4426 // retained in `prec->proc` and served back SIGNED as `DBR_CHAR`
4427 // (`caput PROC 255` → `caget` = -1), exactly like DISP/RPRO. The
4428 // `pp(TRUE)` force-process is orthogonal: writing PROC still
4429 // reprocesses the record (put-path intercept), but the byte sticks.
4430 "PROC" => Some(EpicsValue::UChar(self.common.proc_field)),
4431 // Analog alarm fields
4432 "HIHI" => self
4433 .common
4434 .analog_alarm
4435 .as_ref()
4436 .map(|a| a.hihi.to_epics_value()),
4437 "HIGH" => self
4438 .common
4439 .analog_alarm
4440 .as_ref()
4441 .map(|a| a.high.to_epics_value()),
4442 "LOW" => self
4443 .common
4444 .analog_alarm
4445 .as_ref()
4446 .map(|a| a.low.to_epics_value()),
4447 "LOLO" => self
4448 .common
4449 .analog_alarm
4450 .as_ref()
4451 .map(|a| a.lolo.to_epics_value()),
4452 "HHSV" => self
4453 .common
4454 .analog_alarm
4455 .as_ref()
4456 .map(|a| EpicsValue::Short(a.hhsv)),
4457 "HSV" => self
4458 .common
4459 .analog_alarm
4460 .as_ref()
4461 .map(|a| EpicsValue::Short(a.hsv)),
4462 "LSV" => self
4463 .common
4464 .analog_alarm
4465 .as_ref()
4466 .map(|a| EpicsValue::Short(a.lsv)),
4467 "LLSV" => self
4468 .common
4469 .analog_alarm
4470 .as_ref()
4471 .map(|a| EpicsValue::Short(a.llsv)),
4472 // swait OUTN is aliased to common.out
4473 "OUTN" => {
4474 if self.record.record_type() == "swait" {
4475 Some(EpicsValue::String(self.common.out.clone().into()))
4476 } else {
4477 None
4478 }
4479 }
4480 _ => None,
4481 }
4482 }
4483
4484 /// `true` when the record type declares `name` in its own `field_list`,
4485 /// i.e. the record stores the field itself and owns whatever behaviour
4486 /// hangs off it.
4487 ///
4488 /// This separates the two meanings a link field carries. `common.inp` /
4489 /// `common.out` is the link *text* — always the value the `.db` file
4490 /// wrote, for every record type, because C device support reads
4491 /// `prec->inp` / `prec->out` at `init_record` no matter which layer owns
4492 /// the field ([`crate::server::db_loader::apply_fields`] keeps it
4493 /// populated). `parsed_inp` / `parsed_out` is the *framework's* dispatch
4494 /// of that link, and is armed only for a record type that does NOT
4495 /// declare the field: a record that declares it drives the link itself
4496 /// (`multi_output_links` for `acalcout`/`scalcout`, device support for
4497 /// `motorRecord`/`scalerRecord`, or its own `process`). Arming the
4498 /// framework path for those too would write the link twice per cycle.
4499 fn record_declares_field(&self, name: &str) -> bool {
4500 self.record.implements_field(name)
4501 }
4502
4503 /// Set a common field value from a runtime `dbPut` (CA/PVA/`dbpf`/link).
4504 /// Returns what scan index changes are needed.
4505 ///
4506 /// A `DBF_MENU` common field's string is converted by C's runtime
4507 /// converter, `dbConvert.c::putStringMenu` — see `MenuBound::DbPut`.
4508 pub fn put_common_field(
4509 &mut self,
4510 name: &str,
4511 value: EpicsValue,
4512 ) -> CaResult<CommonFieldPutResult> {
4513 self.put_common_field_bounded(name, value, MenuBound::DbPut)
4514 }
4515
4516 /// **The single owner of a record's SCAN transition** — C `dbPutField` on
4517 /// SCAN, which is `scanDelete(precord)` … `scanAdd(precord)`
4518 /// (`dbAccess.c::dbPutSpecial` SPC_SCAN, dbScan.c:236-248).
4519 ///
4520 /// Two callers reach it, and they are the two C sites that move a record
4521 /// between scan lists: a `SCAN` put ([`Self::put_common_field`]) and the
4522 /// simulation-mode scan swap (`recGblCheckSimm`, recGbl.c:427-437, which
4523 /// calls exactly the same `scanDelete`/`scanAdd` pair). Returns the delta
4524 /// for the scan-index owner (`PvDatabase::update_scan_index`) to apply once
4525 /// the record lock is down; [`CommonFieldPutResult::NoChange`] when the scan
4526 /// did not move.
4527 pub fn set_scan(&mut self, new_scan: ScanType) -> CommonFieldPutResult {
4528 let old_scan = self.common.scan;
4529 self.common.scan = new_scan;
4530 if old_scan == new_scan {
4531 return CommonFieldPutResult::NoChange;
4532 }
4533 // C `scanDelete`/`scanAdd` call the record's device support
4534 // `get_ioint_info(1)` / `get_ioint_info(0)`. Only a change of I/O Intr
4535 // *membership* reaches those; a Passive→"1 second" move calls neither.
4536 let was_io_intr = old_scan == ScanType::IoIntr;
4537 let is_io_intr = new_scan == ScanType::IoIntr;
4538 if was_io_intr != is_io_intr {
4539 self.record.set_io_intr_scan(is_io_intr);
4540 }
4541 CommonFieldPutResult::ScanChanged {
4542 old_scan,
4543 new_scan,
4544 phas: self.common.phas,
4545 }
4546 }
4547
4548 /// C `recGblSaveSimm` (`recGbl.c:421-425`) — latch the CURRENT simulation
4549 /// mode into OLDSIMM:
4550 ///
4551 /// ```c
4552 /// void recGblSaveSimm(const epicsEnum16 sscn,
4553 /// epicsEnum16 *poldsimm, const epicsEnum16 simm) {
4554 /// if (sscn == USHRT_MAX) return;
4555 /// *poldsimm = simm;
4556 /// }
4557 /// ```
4558 ///
4559 /// **The only writer of `CommonFields::oldsimm`.** Must run BEFORE the SIMM
4560 /// value moves — C calls it from `special(SPC_MOD)` pass 0 (before the put)
4561 /// and from `recGblGetSimm`/`recGblInitSimm` before the SIML read. The
4562 /// `sscn == 65535` guard is C's: with SSCN unset there is no scan to swap
4563 /// to, so the latch is not even taken (and [`Self::rec_gbl_check_simm`]
4564 /// bails on the same test, so the stale OLDSIMM is never read).
4565 ///
4566 /// A record type with no SSCN/OLDSIMM in its C dbd (`busy`, `swait`) passes
4567 /// neither pointer to any recGbl helper: no-op here.
4568 pub fn rec_gbl_save_simm(&mut self) {
4569 if !self.record.uses_recgbl_simm_helpers() {
4570 return;
4571 }
4572 // C `recGblSaveSimm`: `if (*psscn == USHRT_MAX) return;` — the literal
4573 // sentinel, not "any index outside the menu".
4574 if self.common.sscn.is_unset() {
4575 return;
4576 }
4577 if let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") {
4578 self.common.oldsimm = simm;
4579 }
4580 }
4581
4582 /// C `recGblCheckSimm` (`recGbl.c:427-437`) — on a SIMM transition, swap the
4583 /// record's SCAN with SSCN:
4584 ///
4585 /// ```c
4586 /// void recGblCheckSimm(struct dbCommon *pcommon, epicsEnum16 *psscn,
4587 /// const epicsEnum16 oldsimm, const epicsEnum16 simm) {
4588 /// if (*psscn == USHRT_MAX) return;
4589 /// if (simm != oldsimm) {
4590 /// epicsUInt16 scan = pcommon->scan;
4591 /// scanDelete(pcommon);
4592 /// pcommon->scan = *psscn;
4593 /// scanAdd(pcommon);
4594 /// *psscn = scan;
4595 /// }
4596 /// }
4597 /// ```
4598 ///
4599 /// This is what makes SSCN mean anything at all: a record configured
4600 /// `field(SCAN,"1 second") field(SSCN,"Passive")` stops periodic scanning
4601 /// the moment SIMM leaves NO, and resumes it when SIMM goes back — with the
4602 /// two fields having traded places each time. Both are a genuine swap, not
4603 /// an assignment: SSCN ends up holding the scan the record just left.
4604 ///
4605 /// **The only writer of the SIMM-driven SCAN/SSCN swap.** The scan-list
4606 /// move itself goes through the single SCAN owner [`Self::set_scan`], whose
4607 /// [`CommonFieldPutResult`] the caller hands to
4608 /// `PvDatabase::update_scan_index` once the record lock is down. Runs AFTER
4609 /// the SIMM value moved — C `special(SPC_MOD)` pass 1, and the tail of
4610 /// `recGblGetSimm`/`recGblInitSimm`.
4611 pub fn rec_gbl_check_simm(&mut self) -> CommonFieldPutResult {
4612 if !self.record.uses_recgbl_simm_helpers() {
4613 return CommonFieldPutResult::NoChange;
4614 }
4615 let Some(sim_scan) = self.common.sscn.scan() else {
4616 // `*psscn == USHRT_MAX` — SSCN unset, no swap. An SSCN that is
4617 // merely ILLEGAL still swaps: C assigns it into SCAN and `scanAdd`
4618 // then declines to scan the record.
4619 return CommonFieldPutResult::NoChange;
4620 };
4621 let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") else {
4622 return CommonFieldPutResult::NoChange;
4623 };
4624 if simm == self.common.oldsimm {
4625 return CommonFieldPutResult::NoChange;
4626 }
4627 let previous_scan = self.common.scan;
4628 let result = self.set_scan(sim_scan);
4629 self.common.sscn = SimModeScan::from_scan(previous_scan);
4630 result
4631 }
4632
4633 /// C `dbAccess.c::putAckt` (`:1285-1300`) — the **only** writer of ACKT.
4634 ///
4635 /// Reached from `dbPut` for a `DBR_PUT_ACKT` request *type*
4636 /// (`dbAccess.c:1331-1332`), ABOVE the `SPC_NOMOD` gate that refuses every
4637 /// ordinary put to the field. Posts exactly what C posts: the ACKT change,
4638 /// the ACKS it may lower, and the record-wide `DBE_ALARM` — and only when
4639 /// `ackt` actually changed (C returns 0 early otherwise).
4640 pub fn put_ackt(&mut self, value: u16, backing: LinkBacking<'_>) {
4641 let new_ackt = value != 0;
4642 if new_ackt == self.common.ackt {
4643 return;
4644 }
4645 use crate::server::recgbl::EventMask;
4646 let ack_mask = EventMask::VALUE | EventMask::ALARM;
4647 self.common.ackt = new_ackt;
4648 self.cleanup_subscribers();
4649 self.notify_field_backed("ACKT", ack_mask, backing);
4650 // C `:1294-1297`: turning transient acknowledgement off lowers a
4651 // sticky ACKS down to the current SEVR — an alarm that has already
4652 // cleared must not keep a higher unacknowledged severity.
4653 if !new_ackt && self.common.acks > self.common.sevr {
4654 self.common.acks = self.common.sevr;
4655 self.notify_field_backed("ACKS", ack_mask, backing);
4656 }
4657 self.notify_record_alarm(backing);
4658 }
4659
4660 /// C `dbAccess.c::putAcks` (`:1302-1315`) — the **only** runtime writer of
4661 /// ACKS. Reached from `dbPut` for a `DBR_PUT_ACKS` request type, ABOVE the
4662 /// `SPC_NOMOD` gate.
4663 ///
4664 /// The acknowledged severity is compared against the STORED unacknowledged
4665 /// severity `acks`, not the current `sevr`: an operator acknowledging at
4666 /// the severity that was latched into ACKS clears it even after `sevr` has
4667 /// since dropped. A too-low acknowledgement changes nothing and posts
4668 /// nothing; an acknowledgement of an already-clear ACKS still posts, which
4669 /// is C's literal `if (*psev >= precord->acks)` (0 >= 0 holds).
4670 pub fn put_acks(&mut self, value: u16, backing: LinkBacking<'_>) {
4671 let sev = AlarmSeverity::from_u16(value);
4672 if sev < self.common.acks {
4673 return;
4674 }
4675 use crate::server::recgbl::EventMask;
4676 self.common.acks = AlarmSeverity::NoAlarm;
4677 self.cleanup_subscribers();
4678 self.notify_field_backed("ACKS", EventMask::VALUE | EventMask::ALARM, backing);
4679 self.notify_record_alarm(backing);
4680 }
4681
4682 /// Set a common field value from the `.db` loader, which in C is a
4683 /// different converter with a different out-of-menu bound
4684 /// (`dbStaticRun.c::dbPutStringNum`; see `MenuBound::DbLoad`). It is what
4685 /// lets `field(SSCN,"65535")` — the menuScan "use SCAN" sentinel, out of
4686 /// the menu's 0-9 range — load, while `caput REC.SSCN 65535` is refused at
4687 /// runtime exactly as C refuses it.
4688 pub fn put_common_field_db_load(
4689 &mut self,
4690 name: &str,
4691 value: EpicsValue,
4692 ) -> CaResult<CommonFieldPutResult> {
4693 self.put_common_field_bounded(name, value, MenuBound::DbLoad)
4694 }
4695
4696 fn put_common_field_bounded(
4697 &mut self,
4698 name: &str,
4699 value: EpicsValue,
4700 bound: MenuBound,
4701 ) -> CaResult<CommonFieldPutResult> {
4702 let name = name.to_ascii_uppercase();
4703 self.record.validate_put(&name, &value)?;
4704 self.record.special(&name, false)?;
4705 // The db loader hands every common field to this path as a raw
4706 // `EpicsValue::String` (no per-field `FieldDesc` to parse against).
4707 // Coerce it to the field's canonical numeric/menu type up front so the
4708 // typed arms below apply a `field(PHAS, "1")` / `field(PRIO, "HIGH")`
4709 // directive instead of silently dropping it at IOC load. String-typed
4710 // and already-typed values pass through unchanged.
4711 let declared = declared_field_type_of(self.record.as_ref(), &name);
4712 let value = match coerce_common_field(&name, value, bound, declared)? {
4713 Converted::Stored(v) => v,
4714 // C's converter returned success without storing (`cvt_st_ul`'s
4715 // skipped store): the field keeps its old value, so no arm below
4716 // runs and no SCAN/PHAS transition happened.
4717 Converted::Unchanged => return Ok(CommonFieldPutResult::NoChange),
4718 };
4719 // C `dbPutString`/`dbPutField` route every link field's text through
4720 // `dbParseLink`, whose brace arm hands it to `dbJLinkParse`
4721 // (`dbStaticLib.c:2280-2286`); an unusable JSON link is
4722 // `S_dbLib_badField` and the field never takes the value. This is the
4723 // one funnel every common link field crosses — SDIS, TSEL, FLNK, DOL,
4724 // SIML, SIOL, INP, OUT — on both the db-load and the runtime put path,
4725 // so the rule holds without being restated per field.
4726 if let EpicsValue::String(ref s) = value {
4727 let text = s.as_str_lossy();
4728 if text.trim_start().starts_with('{')
4729 && crate::types::dbf_link_class(self.record.record_type(), &name).is_some()
4730 {
4731 super::check_json_link_text(&text)?;
4732 }
4733 }
4734 match name.as_str() {
4735 // `special(SPC_NOMOD)` — C `dbPutSpecial` refuses the put with
4736 // `S_db_noMod` (dbAccess.c:123-127). OLDSIMM is written only by the
4737 // simulation-mode owner (`rec_gbl_save_simm`).
4738 "OLDSIMM" => return Err(CaError::ReadOnlyField(name)),
4739 "SEVR" => {
4740 if let EpicsValue::Short(v) = value {
4741 self.common.sevr = AlarmSeverity::from_u16(v as u16);
4742 }
4743 }
4744 "STAT" => {
4745 if let EpicsValue::Short(v) = value {
4746 self.common.stat = v as u16;
4747 }
4748 }
4749 "NSEV" => {
4750 if let EpicsValue::Short(v) = value {
4751 self.common.nsev = AlarmSeverity::from_u16(v as u16);
4752 }
4753 }
4754 "NSTA" => {
4755 if let EpicsValue::Short(v) = value {
4756 self.common.nsta = v as u16;
4757 }
4758 }
4759 "AMSG" => {
4760 if let EpicsValue::String(s) = value {
4761 self.common.amsg.set(&s.as_str_lossy());
4762 }
4763 }
4764 "NAMSG" => {
4765 if let EpicsValue::String(s) = value {
4766 self.common.namsg.set(&s.as_str_lossy());
4767 }
4768 }
4769 // ACKS/ACKT carry NO acknowledgement semantics here. They are
4770 // `special(SPC_NOMOD)` in `dbCommon.dbd:150-159`, so no runtime put
4771 // reaches this arm — the gate refuses it. C's acknowledgement is
4772 // driven by the DBR *request type* (`DBR_PUT_ACKS`/`ACKT`), which
4773 // `dbPut` intercepts ABOVE the SPC_NOMOD gate and hands to
4774 // [`Self::put_acks`] / [`Self::put_ackt`]. What is left here is the
4775 // `dbLoadRecords` / `dbStaticLib` load path (`field(ACKT,"YES")`),
4776 // which stores the value verbatim — C `dbPutString` never crosses
4777 // `dbPut`.
4778 "ACKS" => {
4779 if let EpicsValue::Short(v) = value {
4780 self.common.acks = AlarmSeverity::from_u16(v as u16);
4781 }
4782 }
4783 "ACKT" => match value {
4784 EpicsValue::Char(v) => self.common.ackt = v != 0,
4785 EpicsValue::Short(v) => self.common.ackt = v != 0,
4786 _ => return Ok(CommonFieldPutResult::NoChange),
4787 },
4788 "UDF" => {
4789 // Store the raw put byte (C keeps the epicsUInt8 verbatim); a
4790 // record that re-derives UDF on process overwrites it, one that
4791 // sources nothing this cycle keeps it (put-defect cluster #3).
4792 // The calc family reads UDF straight from `common` too
4793 // (`clears_udf() == false`), so the stored byte stands.
4794 if let EpicsValue::Char(v) = value {
4795 self.common.udf = v;
4796 }
4797 }
4798 "UDFS" => {
4799 self.common.udfs = menu_ordinal_raw(&value);
4800 }
4801 // The `String` form never reaches these three menu arms:
4802 // `coerce_common_field` has already run it through the one
4803 // menu converter, which either produced an `Enum` index or failed
4804 // the put with `S_db_badChoice`.
4805 "SCAN" => {
4806 let new_scan = match &value {
4807 EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
4808 EpicsValue::Enum(v) => ScanType::from_u16(*v),
4809 _ => return Ok(CommonFieldPutResult::NoChange),
4810 };
4811 let result = self.set_scan(new_scan);
4812 if !matches!(result, CommonFieldPutResult::NoChange) {
4813 self.record.on_put(&name);
4814 self.record.special(&name, true)?;
4815 return Ok(result);
4816 }
4817 }
4818 "SSCN" => {
4819 let new_sscn = match &value {
4820 EpicsValue::Short(v) => SimModeScan::from_u16(*v as u16),
4821 EpicsValue::Enum(v) => SimModeScan::from_u16(*v),
4822 _ => return Ok(CommonFieldPutResult::NoChange),
4823 };
4824 self.common.sscn = new_sscn;
4825 }
4826 // `PINI` is `menu(menuPini)` — the six choices NO/YES/RUN/RUNNING/
4827 // PAUSE/PAUSED (`menuPini.dbd.pod:59-65`). Resolved exactly like
4828 // `SCAN`: a menu label or a bare index, never a truthiness test.
4829 // The pre-fix `bool` arm collapsed `RUN` (index 2) to `false`, so
4830 // `caput REC.PINI RUN` *disabled* PINI instead of selecting the
4831 // iocRun pass.
4832 "PINI" => {
4833 // Store the RAW ordinal (see [`CommonFields::pini`]): C's numeric
4834 // menu put keeps `(epicsEnum16)`, so an out-of-range `caput
4835 // REC.PINI 6` / `-1` round-trips and simply matches no lifecycle
4836 // pass in `doRecordPini`. A `String` label is already resolved to
4837 // `Enum` by `coerce_common_field` (menuPini via `putStringMenu`).
4838 self.common.pini = match &value {
4839 EpicsValue::Short(v) => *v,
4840 EpicsValue::Char(v) => *v as i16,
4841 EpicsValue::Enum(v) => *v as i16,
4842 _ => return Ok(CommonFieldPutResult::NoChange),
4843 };
4844 }
4845 "TPRO" => {
4846 if let EpicsValue::Char(v) = value {
4847 self.common.tpro = v;
4848 }
4849 }
4850 "BKPT" => {
4851 if let EpicsValue::Char(v) = value {
4852 self.common.bkpt.set(v);
4853 }
4854 }
4855 "FLNK" => {
4856 if let EpicsValue::String(s) = value {
4857 self.common.flnk = s.as_str_lossy().into_owned();
4858 self.parsed_flnk = parse_forward_link_v2(&self.common.flnk);
4859 }
4860 }
4861 "INP" => {
4862 // A record type whose C `.dbd` has no INP must refuse it, the
4863 // way C's dbd does ("field not found" at load, record inert) —
4864 // `histogram`'s input link is SVL, not INP
4865 // (histogramRecord.dbd.pod:212). Without this the port accepts a
4866 // `field(INP,...)` no C IOC can load.
4867 if !self.record.declares_inp_link() {
4868 return Err(CaError::FieldNotFound("INP".to_string()));
4869 }
4870 if let EpicsValue::String(s) = value {
4871 self.check_link_assignment("INP", &s.as_str_lossy(), bound)?;
4872 self.common.inp = s.as_str_lossy().into_owned();
4873 if !self.record_declares_field("INP") {
4874 self.parsed_inp = parse_link_v2(&self.common.inp);
4875 }
4876 }
4877 }
4878 "OUT" => {
4879 if let EpicsValue::String(s) = value {
4880 let s = s.as_str_lossy();
4881 self.check_link_assignment("OUT", &s, bound)?;
4882 // C `dbParseLink` (dbStaticLib.c:2382-2386) discards a
4883 // CP/CPP modifier on a DBF_OUTLINK and warns once, naming
4884 // the holder record, its field and the target. The discard
4885 // itself is owned by `parse_output_link_v2` below; only the
4886 // diagnostic lives here, where the record name exists and
4887 // the link text is being (re)loaded rather than re-parsed
4888 // per process cycle.
4889 if out_link_discards_cp(&s) {
4890 tracing::warn!(
4891 target: "epics_base_rs::record",
4892 record = %self.name,
4893 field = "OUT",
4894 link = %s,
4895 "Discarding CP/CPP modifier in CA output link"
4896 );
4897 }
4898 self.common.out = s.into_owned();
4899 // C `dbDbPutValue` (dbDbLink.c:386-389): an OUT
4900 // link processes its target only on an explicit
4901 // ` PP` token (or a `.PROC` destination). A bare
4902 // OUT link is NPP — `parse_output_link_v2`
4903 // downgrades the modifier-less `ProcessPassive`
4904 // default that `parse_link_v2` would otherwise
4905 // apply.
4906 if !self.record_declares_field("OUT") {
4907 self.parsed_out = parse_output_link_v2(&self.common.out);
4908 }
4909 // C `longoutRecord.c::special` (PR #6c573b4 part 2)
4910 // and similar OOCH-style hooks need `after=true`
4911 // to fire after the link has actually moved. The
4912 // earlier `validate_put` + `special(name, false)`
4913 // pair only covered the before-side.
4914 self.record.special(&name, true)?;
4915 }
4916 }
4917 // Two shapes reach DTYP and both name a device support:
4918 //
4919 // * the `.db` loader hands over the NAME verbatim, and it may be a
4920 // name registered at runtime by a downstream crate ("asynInt32")
4921 // that no vendored `.dbd` declares — C would reject that at load,
4922 // the port's registry accepts it (Tier 3);
4923 // * a `dbPut` arrives as the menu INDEX, because `DBF_DEVICE` is
4924 // served as `DBR_ENUM` and `coerce_put_value` already resolved an
4925 // incoming label through the device menu (C `putStringMenu`,
4926 // which fails `S_db_badChoice` on a name the menu does not have).
4927 //
4928 // The index is meaningful against the MERGED device menu (static
4929 // `device()` declarations + runtime-contributed device support) —
4930 // the exact list `coerce_put_value` bounded it by. Resolving it
4931 // against the static-only menu here would drop every contributed
4932 // name (asyn's "asynInt32", scaler-rs's "Asyn Scaler") back to
4933 // NoChange, leaving DTYP unset after a valid put.
4934 "DTYP" => match value {
4935 EpicsValue::String(s) => self.common.dtyp = s.as_str_lossy().into_owned().into(),
4936 EpicsValue::Enum(i) => {
4937 let merged = super::merged_device_menu(self.record.record_type());
4938 match merged.get(i as usize) {
4939 Some(name) => self.common.dtyp = (*name).into(),
4940 None => return Ok(CommonFieldPutResult::NoChange),
4941 }
4942 }
4943 _ => return Ok(CommonFieldPutResult::NoChange),
4944 },
4945 "TSE" => {
4946 if let EpicsValue::Short(v) = value {
4947 self.common.tse = v;
4948 }
4949 }
4950 "TSEL" => {
4951 if let EpicsValue::String(s) = value {
4952 self.common.tsel = s.as_str_lossy().into_owned();
4953 self.parsed_tsel = parse_link_v2(&self.common.tsel);
4954 }
4955 }
4956 "UTAG" => {
4957 // C UTAG is DBF_UINT64 — accept any integer-shaped value and
4958 // store the unsigned 64-bit tag. The db loader feeds every
4959 // common field as EpicsValue::String, so parse field(UTAG, "N")
4960 // rather than dropping it silently at IOC load; a CA write to
4961 // this u64 field crosses as DBR_DOUBLE (CA has no uint64 wire
4962 // type), so accept Double too.
4963 match value {
4964 EpicsValue::UInt64(v) => self.common.utag = v,
4965 EpicsValue::Int64(v) => self.common.utag = v as u64,
4966 EpicsValue::Long(v) => self.common.utag = v as u64,
4967 EpicsValue::Short(v) => self.common.utag = v as u64,
4968 EpicsValue::Enum(v) => self.common.utag = v as u64,
4969 EpicsValue::Char(v) => self.common.utag = v as u64,
4970 EpicsValue::Double(v) => self.common.utag = v as u64,
4971 EpicsValue::String(s) => {
4972 if let Ok(EpicsValue::UInt64(v)) =
4973 EpicsValue::parse(DbFieldType::UInt64, s.as_str_lossy().trim())
4974 {
4975 self.common.utag = v;
4976 }
4977 }
4978 _ => {}
4979 }
4980 }
4981 "ASG" => {
4982 if let EpicsValue::String(s) = value {
4983 self.common.asg = s.as_str_lossy().into_owned();
4984 }
4985 }
4986 "ASL" => {
4987 // C dbCommon.ASL is `epicsUInt32` in the .dbd but
4988 // only ever 0 or 1; accept Char / Short / Long for
4989 // the common put paths and clamp to {0, 1}.
4990 // db_loader feeds every common field as
4991 // `EpicsValue::String`; also accept that so a
4992 // `.db` `field(ASL, "1")` directive isn't silently
4993 // ignored at IOC load.
4994 let n: i64 = match value {
4995 EpicsValue::Char(v) => v as i64,
4996 EpicsValue::Short(v) => v as i64,
4997 EpicsValue::Long(v) => v as i64,
4998 EpicsValue::Int64(v) => v,
4999 EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
5000 _ => return Ok(CommonFieldPutResult::NoChange),
5001 };
5002 self.common.asl = if n != 0 { 1 } else { 0 };
5003 }
5004 "DESC" => {
5005 if let EpicsValue::String(s) = value {
5006 // DBF_STRING data field — store the bytes verbatim so a
5007 // non-UTF-8 DESC round-trips unchanged.
5008 if self.common.desc != s {
5009 self.common.desc = s;
5010 // DESC feeds `display.description` (a metadata-cache
5011 // source) but is not property-class — C never marks
5012 // it prop(YES) (epics-base#785) — so refresh the
5013 // cache here at the write owner without posting
5014 // DBE_PROPERTY: the pvxs behavior (fresh on the next
5015 // metadata build, no event).
5016 self.invalidate_metadata_cache();
5017 }
5018 }
5019 }
5020 "PHAS" => {
5021 if let EpicsValue::Short(v) = value {
5022 let old_phas = self.common.phas;
5023 self.common.phas = v;
5024 // Only a record that IS in a scan list can be re-sorted
5025 // within one; the same gate the index owner applies.
5026 if old_phas != v && self.common.scan.scan_list().is_some() {
5027 let scan = self.common.scan;
5028 self.record.on_put(&name);
5029 self.record.special(&name, true)?;
5030 return Ok(CommonFieldPutResult::PhasChanged {
5031 scan,
5032 old_phas,
5033 new_phas: v,
5034 });
5035 }
5036 }
5037 }
5038 "EVNT" => {
5039 // C `EVNT` is DBF_STRING (event name). Accept a
5040 // string directly; accept a numeric value too for
5041 // backward compatibility (numeric events / a calc
5042 // record driving EVNT) by formatting it as a string.
5043 match value {
5044 EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
5045 EpicsValue::Short(v) => self.common.evnt = v.to_string(),
5046 EpicsValue::Long(v) => self.common.evnt = v.to_string(),
5047 EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
5048 EpicsValue::Double(v) => {
5049 // Match C `eventNameToHandle`: a double with
5050 // an integer part is treated as that integer.
5051 self.common.evnt = (v as i64).to_string();
5052 }
5053 _ => {}
5054 }
5055 }
5056 "PRIO" => {
5057 if let EpicsValue::Short(v) = value {
5058 self.common.prio = v;
5059 }
5060 }
5061 "DISV" => {
5062 if let EpicsValue::Short(v) = value {
5063 self.common.disv = v;
5064 }
5065 }
5066 "DISA" => {
5067 if let EpicsValue::Short(v) = value {
5068 self.common.disa = v;
5069 }
5070 }
5071 "SDIS" => {
5072 if let EpicsValue::String(s) = value {
5073 self.common.sdis = s.as_str_lossy().into_owned();
5074 self.parsed_sdis = parse_link_v2(&self.common.sdis);
5075 }
5076 }
5077 "DISS" => {
5078 self.common.diss = menu_ordinal_raw(&value);
5079 }
5080 "HYST" => {
5081 if let Some(v) = value.to_f64() {
5082 self.common.hyst = v;
5083 }
5084 }
5085 "LCNT" => {
5086 if let EpicsValue::Short(v) = value {
5087 self.common.lcnt = v;
5088 }
5089 }
5090 "DISP" => {
5091 if let EpicsValue::Char(v) = value {
5092 self.common.disp = v;
5093 }
5094 }
5095 "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
5096 "RPRO" => {
5097 if let EpicsValue::Char(v) = value {
5098 self.common.rpro = v;
5099 }
5100 }
5101 "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
5102 // C `dbPut` stores the raw byte in `prec->proc` (retained across
5103 // processing — C never resets it); `coerce_common_field` has
5104 // already projected the put onto `DBF_UCHAR` (→ `Char`). The
5105 // `pp(TRUE)` reprocess is driven separately by the put-path
5106 // force-process intercept, so this arm ONLY records the byte.
5107 "PROC" => {
5108 if let EpicsValue::Char(v) = value {
5109 self.common.proc_field = v;
5110 }
5111 }
5112 // Analog alarm limits. The DB-load String was already coerced to
5113 // the field's DECLARED `.dbd` type by `coerce_common_field` — the
5114 // one owner of "what type does this common field hold" — so every
5115 // writer (`.db` load, `caput`, a link) lands here with a numeric
5116 // value in the record's own alarm domain, `epicsInt64` included.
5117 "HIHI" => {
5118 if let (Some(v), Some(a)) = (
5119 AlarmLimit::from_stored(&value),
5120 self.common.analog_alarm.as_mut(),
5121 ) {
5122 a.hihi = v;
5123 }
5124 }
5125 "HIGH" => {
5126 if let (Some(v), Some(a)) = (
5127 AlarmLimit::from_stored(&value),
5128 self.common.analog_alarm.as_mut(),
5129 ) {
5130 a.high = v;
5131 }
5132 }
5133 "LOW" => {
5134 if let (Some(v), Some(a)) = (
5135 AlarmLimit::from_stored(&value),
5136 self.common.analog_alarm.as_mut(),
5137 ) {
5138 a.low = v;
5139 }
5140 }
5141 "LOLO" => {
5142 if let (Some(v), Some(a)) = (
5143 AlarmLimit::from_stored(&value),
5144 self.common.analog_alarm.as_mut(),
5145 ) {
5146 a.lolo = v;
5147 }
5148 }
5149 "HHSV" => {
5150 if let Some(a) = &mut self.common.analog_alarm {
5151 a.hhsv = menu_ordinal_raw(&value);
5152 }
5153 }
5154 "HSV" => {
5155 if let Some(a) = &mut self.common.analog_alarm {
5156 a.hsv = menu_ordinal_raw(&value);
5157 }
5158 }
5159 "LSV" => {
5160 if let Some(a) = &mut self.common.analog_alarm {
5161 a.lsv = menu_ordinal_raw(&value);
5162 }
5163 }
5164 "LLSV" => {
5165 if let Some(a) = &mut self.common.analog_alarm {
5166 a.llsv = menu_ordinal_raw(&value);
5167 }
5168 }
5169 // swait-specific: OUTN is the output link name for swait records.
5170 // Mirrors to common.out so the processing framework dispatches it.
5171 "OUTN" => {
5172 if self.record.record_type() != "swait" {
5173 // No OUTN field on any other record type — the same
5174 // `S_dbLib_fieldNotFound` the catch-all below reports.
5175 return Err(self.unknown_field_error(name));
5176 }
5177 if let EpicsValue::String(s) = value {
5178 self.common.out = s.as_str_lossy().into_owned();
5179 // Bare OUT link is NPP — see the "OUT" arm.
5180 self.parsed_out = parse_output_link_v2(&self.common.out);
5181 }
5182 }
5183 // C `dbNameToAddr` (dbAccess.c:660-676) resolves the field part
5184 // with `dbFindFieldPart`, then falls back to `dbGetAttributePart`.
5185 // A name that is neither a record field, nor a dbCommon field, nor
5186 // an attribute resolves to nothing (`S_dbLib_fieldNotFound`), so
5187 // `dbPutField` is never reached and the caller reports the error —
5188 // `dbpf` prints "PV '%s' not found" and returns -1 (dbTest.c:787-795).
5189 // Returning success here made a put to a misspelled field a silent
5190 // no-op.
5191 //
5192 // But a field the record's `.dbd` DECLARES and no arm above stored
5193 // is NOT unknown: C `dbPut` writes it into record memory even when
5194 // no record code reads it back (`caput dfanout.HOPR 10`). Land it in
5195 // the per-instance declared-override store — the write analog of
5196 // `declared_default` — so the put is accepted and a later read
5197 // reflects it. `put_declared_override` still returns
5198 // `unknown_field_error` for a name with no `dbFldDes`, so a
5199 // misspelled field is refused exactly as before.
5200 _ => return self.put_declared_override(&name, value, bound),
5201 }
5202 self.record.on_put(&name);
5203 // C `dbPut` (dbAccess.c:1399-1405) returns the after-put
5204 // `dbPutSpecial(paddr, 1)` status to the caller — the stored value
5205 // stays, but the monitor post and the process are skipped and the
5206 // client sees the failure. Never drop it.
5207 self.record.special(&name, true)?;
5208 Ok(CommonFieldPutResult::NoChange)
5209 }
5210
5211 /// The error C reports for a write to a field name that
5212 /// [`Self::put_common_field`] does not own.
5213 ///
5214 /// Two C outcomes, split by whether the name resolves at all:
5215 ///
5216 /// - A record *attribute* (`NAME`, `RTYP`) resolves — `dbGetAttributePart`
5217 /// succeeds — but the write is refused: `NAME` is `special(SPC_NOMOD)`
5218 /// (dbCommon.dbd:13-17) so `dbPutSpecial` pass 0 returns `S_db_noMod`
5219 /// (dbAccess.c:123-124), and an attribute address carries
5220 /// `special == SPC_ATTRIBUTE`, which `dbPutField` rejects with the same
5221 /// `S_db_noMod` (dbAccess.c:1252-1253).
5222 /// - Anything else does not resolve: `S_dbLib_fieldNotFound`.
5223 fn unknown_field_error(&self, name: String) -> CaError {
5224 if self.get_virtual_field(&name).is_some() {
5225 CaError::ReadOnlyField(name)
5226 } else {
5227 CaError::FieldNotFound(name)
5228 }
5229 }
5230
5231 /// Store a put to a field the record's `.dbd` DECLARES but the record
5232 /// models no storage for — the WRITE owner of [`Self::declared_overrides`]
5233 /// and the write analog of [`Self::declared_default`].
5234 ///
5235 /// Reached only from [`Self::put_common_field_bounded`]'s catch-all, i.e.
5236 /// after both `Record::put_field` (returned `FieldNotFound`) and every
5237 /// `dbCommon` arm above have declined the field. Three gates, mirroring
5238 /// C `dbNameToAddr`/`dbPut`:
5239 ///
5240 /// * NO `dbFldDes` (`field_desc` is `None`) — the name is not a field of
5241 /// this record type at all. C resolves nothing and `dbPutField` reports
5242 /// `S_dbLib_fieldNotFound`; return [`Self::unknown_field_error`] (which
5243 /// also renders `NAME`/`RTYP` as the read-only attributes they are).
5244 /// * `special(SPC_NOMOD)` — a declared field that is immutable
5245 /// ([`Self::is_no_mod`]: the `.dbd` `read_only`/attribute bit or the
5246 /// record's runtime `field_no_mod`). C refuses the put with `S_db_noMod`;
5247 /// the runtime dispatch already gates this via `field_io::check_no_mod`,
5248 /// but the db-load path does not, so enforce it here too — never store an
5249 /// SPC_NOMOD field in the override map.
5250 /// * [`FieldDesc::runtime_typed`] — a field whose served type C's
5251 /// `cvt_dbaddr` re-derives from record state (`waveform.VAL` from `FTVL`,
5252 /// `aSub.A` from `FTA`). Such a field is record-owned by definition, so
5253 /// its `put_field` should have taken the put; if it somehow reached here
5254 /// the override store must not shadow it (`declared_default` skips it for
5255 /// the same reason). Treat as not-found rather than store a value under
5256 /// the wrong type.
5257 /// * PARTIALLY modeled — `Record::get_field` serves the field but no
5258 /// `put_field` arm accepts it (`calcout.PVAL` → `self.pval`). The record
5259 /// owns the read path, so the write belongs in its own `put_field`, not a
5260 /// shadow cell; refuse here rather than store a value `resolve_field`
5261 /// would never reach. See the inline note on the `get_field` guard.
5262 ///
5263 /// Otherwise coerce the incoming value to the field's C-declared DBF type
5264 /// through the one write-side value-coercion owner
5265 /// ([`coerce_put_value`](crate::server::record::coerce_put_value)) — so a
5266 /// `.db`/`caput` string parses with C's range rules (`caput REC.PREC 99999`
5267 /// into a `DBF_SHORT` is refused, not wrapped) and a menu label resolves
5268 /// against the field's own choices — and store it. Returns
5269 /// [`CommonFieldPutResult::NoChange`]: there is no scan/phas/alarm side
5270 /// effect for a metadata field with no record behaviour, and the caller's
5271 /// value-field monitor post reads the stored value back through
5272 /// [`Self::resolve_field`].
5273 fn put_declared_override(
5274 &mut self,
5275 name: &str,
5276 value: EpicsValue,
5277 bound: MenuBound,
5278 ) -> CaResult<CommonFieldPutResult> {
5279 let Some(desc) = self.field_desc(name) else {
5280 return Err(self.unknown_field_error(name.to_string()));
5281 };
5282 if desc.runtime_typed {
5283 return Err(self.unknown_field_error(name.to_string()));
5284 }
5285 if matches!(bound, MenuBound::DbPut) && self.is_no_mod(name) {
5286 // C `dbPutSpecial` pass 0 refuses SPC_NOMOD with `S_db_noMod`
5287 // (dbAccess.c:123-127) — and `dbPutSpecial` is reached only from
5288 // `dbPutField`/`dbPut`, the RUNTIME path. `dbLoadRecords` writes
5289 // through dbStatic's `dbPutString` (dbStaticLib.c:2570), which
5290 // consults `special` for `SPC_CALC` alone; SPC_NOMOD appears in
5291 // that layer only as a filter on `dbLexRoutines.c:1285`'s
5292 // misspelled-field guesser, never as a refusal of a field the
5293 // `.db` names outright. Refusing both paths dropped every
5294 // `field(<SPC_NOMOD>,…)` directive with a stderr line —
5295 // `mca`'s SIOL/SIML, `sub`'s LA..LU, `sel`'s LA..NLST,
5296 // `scalcout`'s PA..MLST, `asyn`'s AINP/NORD/ERRS and `swait`'s
5297 // VERS — so a simulated `mca` could not be given a SIOL at all.
5298 return Err(CaError::ReadOnlyField(name.to_string()));
5299 }
5300 // The override is the WRITABLE TWIN of `declared_default`, and
5301 // `declared_default` is `resolve_field`'s fallback ONLY when the record
5302 // itself serves nothing (`Record::get_field` is `None`). If the record
5303 // DOES serve this field (`get_field` is `Some`), it is not unmodeled —
5304 // it is PARTIALLY modeled: a getter into record memory (e.g.
5305 // `calcout.PVAL` → `self.pval`, which `process()` also writes) but no
5306 // matching `put_field` arm. Storing here would place the value in a
5307 // second cell that `resolve_field` never reaches (`get_field` shadows
5308 // the override) and that no `process()` keeps in step — a silent write
5309 // loss. Such a field's put belongs in the record's OWN `put_field`
5310 // (a per-record setter, a distinct change); refuse it here rather than
5311 // half-accept it, so `resolve_field` stays single-valued. A field the
5312 // record does not serve at all falls through to be stored.
5313 if self.record.get_field(name).is_some() {
5314 return Err(self.unknown_field_error(name.to_string()));
5315 }
5316 let target = desc.dbf_type;
5317 match crate::server::record::coerce_put_value(self.record.as_ref(), name, target, value)? {
5318 Converted::Stored(coerced) => {
5319 self.declared_overrides
5320 .insert(name.to_ascii_uppercase(), coerced);
5321 }
5322 // Nothing stored: the override keeps whatever it held.
5323 Converted::Unchanged => {}
5324 }
5325 Ok(CommonFieldPutResult::NoChange)
5326 }
5327
5328 /// Get virtual fields (NAME, RTYP).
5329 pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
5330 match name {
5331 "NAME" => Some(EpicsValue::String(self.name.clone().into())),
5332 "RTYP" => Some(EpicsValue::String(
5333 self.record.record_type().to_string().into(),
5334 )),
5335 _ => None,
5336 }
5337 }
5338
5339 /// Evaluate alarms based on record type and current value.
5340 /// Uses rec_gbl_set_sevr to accumulate into nsta/nsev.
5341 ///
5342 /// CALC_ALARM is NOT raised here. C raises it inside the record's own
5343 /// `process()` (`calcRecord.c:121-123`, `calcoutRecord.c:238-241`,
5344 /// `sCalcoutRecord.c:357-363`, `aCalcoutRecord.c:304-305`,
5345 /// `swaitRecord.c:409-410`), and in the port [`Record::check_alarms`] — which
5346 /// runs immediately before this — is that owner. It used to be raised here
5347 /// instead, keyed on a hardcoded `rtype` list plus a `CALC_ALARM` pseudo-field
5348 /// no DBD declares; swait is what that construction cost: it carried the flag
5349 /// but was not on the list, so a failed `calcPerform` alarmed nowhere.
5350 pub fn evaluate_alarms(&mut self) {
5351 use crate::server::recgbl;
5352
5353 // Check UDF first — but only for record types whose C support carries
5354 // the `if (prec->udf) recGblSetSevr(..., UDF_ALARM, ...)` guard. C has
5355 // no central UDF alarm; see `Record::raises_udf_alarm`.
5356 debug_assert!(
5357 self.udf_alarm.is_some() == self.record.raises_udf_alarm(),
5358 "a record that changes its UDF_ALARM guard after construction \
5359 cannot be served from ProcessPlan"
5360 );
5361 if let Some(udf) = self.udf_alarm {
5362 recgbl::rec_gbl_check_udf(&mut self.common, udf.exact_one, udf.severity, udf.message);
5363 }
5364
5365 // The analog-alarm SLOT is the enumeration — a record has the ladder iff
5366 // `new_boxed` gave it a config, which is the one place the C `.dbd`
5367 // survey lives. A second `match rtype` here was the same list written
5368 // twice, and the two could disagree: scalcout was in neither, so its ten
5369 // C alarm fields could not even be put.
5370 //
5371 // bi / bo / busy / mbbi / mbbo STATE+COS (and mbbo SOFT) alarm evaluation
5372 // lives in each record's `Record::check_alarms` hook (C `checkAlarms`);
5373 // those records carry no analog config, so they never reach here and
5374 // cannot double-raise.
5375 if let Some(alarm_cfg) = self.common.analog_alarm {
5376 // VAL goes down in the variant the record stores it in, not
5377 // flattened to `f64`: it is what picks the ladder's comparison
5378 // domain, and `Int64(v) as f64` had already rounded the value
5379 // before the first comparison ran.
5380 let Some(input) = self.record.analog_alarm_input() else {
5381 return;
5382 };
5383 self.evaluate_analog_alarm(input, &alarm_cfg);
5384 }
5385 }
5386
5387 fn evaluate_analog_alarm(&mut self, input: super::AnalogAlarmInput, cfg: &AnalogAlarmConfig) {
5388 use crate::server::recgbl::{self, alarm_status};
5389
5390 // C `checkAlarms` returns immediately on a UDF cycle: it raises
5391 // `UDF_ALARM`/`UDFS` (already done by `rec_gbl_check_udf` in
5392 // `evaluate_alarms`), zeroes `AFVL` on the AFTC-capable records, and
5393 // returns BEFORE the range check — so `LALM` is left untouched and
5394 // `AFVL` is not filtered this cycle. The identical guard appears in
5395 // every record that shares this arm (`aiRecord.c:319-323`,
5396 // `aoRecord.c:383-386`, `longinRecord.c:274-278`,
5397 // `longoutRecord.c:317-320`, `int64inRecord.c:267-271`,
5398 // `int64outRecord.c:298-301`, `calcRecord.c:300-304`,
5399 // `calcoutRecord.c:563-566`). AFTC-capable records (ai/longin/
5400 // int64in/calc) carry `AFVL` and zero it (`prec->afvl = 0`); the
5401 // out records (ao/longout/int64out/calcout) have no `AFVL` and just
5402 // return. Running the range check here would drift `LALM` to `val`
5403 // (NaN on an undefined cycle) and filter `AFVL` — both observable.
5404 if self.common.udf != 0 {
5405 if let Some((_, afvl)) = self.record.alarm_filter_cells() {
5406 if afvl != 0.0 {
5407 self.record.store_alarm_filter_value(0.0);
5408 }
5409 }
5410 return;
5411 }
5412
5413 // One rule for every ladder input: a record that DECLARES the field
5414 // owns it, because `Record::put_field` absorbs the client's put before
5415 // `put_common_field` ever runs, and only an undeclared field falls
5416 // through to `CommonFields`. MDEL/ADEL/MLST/ALST in
5417 // `check_monitor_deadbands` already read this way; HYST did not, so
5418 // `int64in`/`int64out`'s `pub hyst` swallowed every put while the
5419 // hysteresis compared against a permanent 0.0 — with `caget .HYST`
5420 // reading the value back, which is what made it silent.
5421 //
5422 // `common.hyst` stays an `f64` and stays exact: after the limits moved
5423 // to the declared type its only remaining readers are the
5424 // `DBF_DOUBLE` records and longin/longout, and every `epicsInt32` is
5425 // an `f64` exactly.
5426 let super::AnalogAlarmInput { val, hyst, lalm } = input;
5427
5428 // C-style per-level hysteresis: alarm fires if val passes the level,
5429 // OR if we were already at that alarm level (lalm == alev) and val
5430 // hasn't retreated past the hysteresis margin.
5431 //
5432 // `alarm_range` is the C-style integer level: 1=Lolo, 2=Low,
5433 // 3=Normal, 4=High, 5=Hihi. Required for the calc-record AFTC
5434 // filter (`calcRecord.c::checkAlarms:339-381`) which filters
5435 // on the range level (not on severity) and re-maps back.
5436 // C's `checkAlarms` enables each level with a NONZERO test on the raw
5437 // severity ordinal (`if (prec->hhsv && …)`) and passes that raw ordinal
5438 // to `recGblSetSevr`; `recGblResetAlarms` then clamps the resulting
5439 // *severity* to `INVALID_ALARM` while the *status* keeps the level. So an
5440 // out-of-range selector (`HHSV = 4`) still fires HIHI and lands
5441 // SEVR=INVALID/STAT=HIHI — reproduced by testing `!= 0` and mapping the
5442 // ordinal through [`AlarmSeverity::from_u16`] (which clamps `>= 3` to
5443 // `Invalid`).
5444 let sevs = [cfg.hhsv, cfg.llsv, cfg.hsv, cfg.lsv];
5445 let mut alarm_range = match val {
5446 // The `DBF_LONG`/`DBF_INT64` records: a limit, a hysteresis and a
5447 // LALM all land here as the exact `epicsInt64` C compares.
5448 AlarmLimit::Long(_) | AlarmLimit::Int64(_) => {
5449 let v = val.as_i128();
5450 super::alarm::analog_alarm_range(
5451 v,
5452 hyst.map_or(self.common.hyst as i128, AlarmLimit::as_i128),
5453 lalm.map_or(v, AlarmLimit::as_i128),
5454 [
5455 cfg.hihi.as_i128(),
5456 cfg.lolo.as_i128(),
5457 cfg.high.as_i128(),
5458 cfg.low.as_i128(),
5459 ],
5460 sevs,
5461 )
5462 }
5463 AlarmLimit::Double(v) => super::alarm::analog_alarm_range(
5464 v,
5465 hyst.map_or(self.common.hyst, AlarmLimit::as_f64),
5466 lalm.map_or(v, AlarmLimit::as_f64),
5467 [
5468 cfg.hihi.as_f64(),
5469 cfg.lolo.as_f64(),
5470 cfg.high.as_f64(),
5471 cfg.low.as_f64(),
5472 ],
5473 sevs,
5474 ),
5475 };
5476
5477 // C `range_stat[]` (`int64inRecord.c:250-253`) plus the severity and
5478 // `alev` each range selects. ONE table, because C reaches the same
5479 // mapping twice — once out of the ladder and once out of the AFTC
5480 // filter's `switch (alarmRange)` (`:326-346`).
5481 let resolve = |range: u16| -> (AlarmSeverity, u16, Option<AlarmLimit>) {
5482 match range {
5483 5 => (
5484 AlarmSeverity::from_u16(cfg.hhsv as u16),
5485 alarm_status::HIHI_ALARM,
5486 Some(cfg.hihi),
5487 ),
5488 4 => (
5489 AlarmSeverity::from_u16(cfg.hsv as u16),
5490 alarm_status::HIGH_ALARM,
5491 Some(cfg.high),
5492 ),
5493 2 => (
5494 AlarmSeverity::from_u16(cfg.lsv as u16),
5495 alarm_status::LOW_ALARM,
5496 Some(cfg.low),
5497 ),
5498 1 => (
5499 AlarmSeverity::from_u16(cfg.llsv as u16),
5500 alarm_status::LOLO_ALARM,
5501 Some(cfg.lolo),
5502 ),
5503 _ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, None),
5504 }
5505 };
5506
5507 // C parity: the alarm-range AFTC low-pass filter
5508 // (`{ai,longin,int64in,calc}Record.c::checkAlarms`) smooths the
5509 // integer `alarmRange` and re-maps. Only records that carry the
5510 // AFTC/AFVL fields run it — `ao`/`longout`/`int64out`/`calcout`
5511 // have no AFTC field (confirmed via the respective `.dbd.pod`),
5512 // so they are excluded.
5513 if let Some((aftc, afvl)) = self.record.alarm_filter_cells() {
5514 if aftc > 0.0 {
5515 let now = crate::runtime::general_time::get_current();
5516 let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
5517 alarm_range,
5518 aftc,
5519 afvl,
5520 self.common.time,
5521 now,
5522 );
5523 self.record.store_alarm_filter_value(new_afvl);
5524 // C re-maps through the SAME `switch (alarmRange)` the ladder
5525 // fell out of, so the filter changes only the range and
5526 // `resolve` below answers for both.
5527 alarm_range = filtered_range;
5528 } else {
5529 // aftc <= 0 disables the filter. C `checkAlarms`
5530 // (e.g. aiRecord.c:356,401) initialises the local
5531 // `afvl = 0` and unconditionally stores `prec->afvl =
5532 // afvl` at the end, so a disabled filter drives AFVL to
5533 // 0. Mirror that here so a stale accumulator from a prior
5534 // `aftc > 0` run cannot mis-seed the filter if AFTC is
5535 // re-enabled later.
5536 if afvl != 0.0 {
5537 self.record.store_alarm_filter_value(0.0);
5538 }
5539 }
5540 }
5541 let (new_sevr, new_stat, alev) = resolve(alarm_range);
5542
5543 if new_sevr != AlarmSeverity::NoAlarm {
5544 // C `aiRecord.c:405-406` — the latch is armed to the THRESHOLD, and
5545 // only when `recGblSetSevr` returns TRUE. A level that fires while a
5546 // higher-or-equal severity is already pending (an MS input link, a
5547 // SIMM alarm, a device INVALID) raises nothing, so C leaves LALM
5548 // where it was; arming it there would let the next cycle's
5549 // `lalm == alev && val >= alev - hyst` clause hold an alarm C has
5550 // already cleared.
5551 if recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr) {
5552 self.record.store_analog_lalm(alev.unwrap_or(val));
5553 }
5554 } else {
5555 // No alarm condition: reset LALM to current value. C `aiRecord.c:409`
5556 // does this unconditionally — only the alarm arm is gated.
5557 self.record.store_analog_lalm(val);
5558 }
5559 }
5560
5561 /// Invoke the registered subroutine (`sub`/`aSub` `SNAM`) if one is
5562 /// bound, before the record's `process()` body runs.
5563 ///
5564 /// C `subRecord.c::do_sub` / `aSubRecord.c::do_sub` call the named
5565 /// subroutine on EVERY `process()`. The function registry lives on the
5566 /// framework (`RecordInstance::subroutine`), not on the record, so the
5567 /// record's own `process()` is a no-op for these two types and the
5568 /// framework must drive the call. This is the SINGLE owner of that call
5569 /// for every dispatch path: the main engine
5570 /// (`process_record_with_links_inner`, the SCAN / event / CA-put-to-PP /
5571 /// FLNK path) and the by-name `process_local` (`db.process_record`,
5572 /// QSRV group / foreign-call path) both route through here, so a
5573 /// `sub`/`aSub` runs identically regardless of how it is processed.
5574 /// Previously only `process_local` invoked the subroutine, so on the
5575 /// main engine path `VAL`/`VALA..VALU`/`OUTA..OUTU` never updated.
5576 /// The cycle's status is delivered to the record on EVERY exit path — see
5577 /// [`Record::set_subroutine_status`], which aSub's OUT-link gate reads. The
5578 /// delivery is factored out of the body below so a future early return
5579 /// cannot skip it: the body returns the status, this wrapper publishes it.
5580 pub(crate) fn run_registered_subroutine(&mut self) -> CaResult<()> {
5581 let outcome = self.run_subroutine_body();
5582 // A subroutine that errored out has no C counterpart (a C subroutine
5583 // returns a `long`); it is a failed cycle, so it takes the non-zero
5584 // arm — no outputs.
5585 let status = *outcome.as_ref().unwrap_or(&SUBROUTINE_STATUS_ERROR);
5586 self.record.set_subroutine_status(status);
5587 outcome.map(|_| ())
5588 }
5589
5590 /// Returns C `process`'s `status` for this cycle: 0 only when `do_sub` ran
5591 /// and returned 0.
5592 ///
5593 /// This is C `process`'s
5594 /// `if (!status) { status = do_sub(prec); prec->val = status; }`
5595 /// (aSubRecord.c:216-224, subRecord.c:142-147). The VAL publish is HERE
5596 /// rather than inside [`Self::do_sub`] precisely because C puts it here:
5597 /// every `do_sub` exit — empty SNAM, unregistered SNAM, the subroutine's
5598 /// own return — publishes its status as aSub's VAL from this one site, and
5599 /// only the pre-`do_sub` skip (a failed `fetch_values`) leaves VAL alone.
5600 fn run_subroutine_body(&mut self) -> CaResult<i64> {
5601 // aSub `LFLG=READ`: a `SUBL` re-resolution that found a bad/unregistered
5602 // name (C `fetch_values` -> `S_db_BadSub`) or failed to read the link
5603 // signals "skip do_sub this cycle" — C `process` runs `do_sub` only on
5604 // `!status`. The framework's failed input-link fetch arms the same flag.
5605 // One-shot: taken (cleared) whether or not a subroutine is set, so it
5606 // never leaks into the next cycle. The single consumer of the flag,
5607 // shared by every process path.
5608 if std::mem::take(&mut self.suppress_subroutine_run) {
5609 return Ok(SUBROUTINE_STATUS_SKIPPED);
5610 }
5611
5612 // Every record type reaches this call on the process path, but only
5613 // `sub` and `aSub` have a `do_sub` in their rset at all. For all the
5614 // others "no subroutine is bound" is their permanent normal state, not
5615 // an unresolved SNAM, so they must not take `do_sub`'s bad-sub exit.
5616 let Some(kind) = SubroutineKind::of(self.record.record_type()) else {
5617 return Ok(SUBROUTINE_STATUS_SKIPPED);
5618 };
5619
5620 let status = self.do_sub(kind)?;
5621
5622 // aSub publishes the status as VAL (C `aSubRecord.c:224`
5623 // `prec->val = status`). The subroutine's computed outputs live in
5624 // VALA..VALU, so VAL is the return code and overwrites whatever the
5625 // closure may have written to VAL. `sub` does NOT do this — its VAL
5626 // is the value the subroutine computed. aSub VAL is DBF_LONG
5627 // (epicsInt32); the status is a C `long` truncated into it.
5628 if kind == SubroutineKind::ASub {
5629 let _ = self
5630 .record
5631 .put_field("VAL", EpicsValue::Long(status as i32));
5632 }
5633 Ok(status)
5634 }
5635
5636 /// C `do_sub` — `aSubRecord.c:454-473` and `subRecord.c:420-437`, which
5637 /// differ in exactly two places and agree everywhere else:
5638 ///
5639 /// * aSub short-circuits an EMPTY SNAM to `return 0` BEFORE the null-pointer
5640 /// check (`if (prec->snam[0] == 0) return 0;`), so a bare
5641 /// `record(aSub,"X"){}` is a no-op that completes with status 0, not a
5642 /// bad-sub. `sub` has no such branch — it cannot reach here with an empty
5643 /// SNAM because `init_record` parks PACT
5644 /// (`Record::init_record_parks_pact`, subRecord.c:119-123).
5645 /// * an unresolved subroutine raises `BAD_SUB_ALARM` at `INVALID_ALARM` in
5646 /// both, but aSub returns `S_db_BadSub` (which `run_subroutine_body`
5647 /// publishes as VAL and aSub's OUT gate reads as "push nothing") while
5648 /// `sub` returns 0.
5649 ///
5650 /// The raise is per-cycle, not a one-shot init diagnostic: C `iocInit`
5651 /// discards `init_record`'s status (iocInit.c:569-570), so the record loads
5652 /// and scans and every process cycle re-raises BAD_SUB/INVALID.
5653 fn do_sub(&mut self, kind: SubroutineKind) -> CaResult<i64> {
5654 use crate::server::recgbl::{self, alarm_status};
5655
5656 // Clone the Arc so the borrow on `self.subroutine` is released
5657 // before we mutate `self.record` / `self.common` below.
5658 let Some(sub_fn) = self.subroutine.clone() else {
5659 let snam_empty = matches!(
5660 self.record.get_field("SNAM"),
5661 Some(EpicsValue::String(s)) if s.is_empty()
5662 );
5663 if kind == SubroutineKind::ASub && snam_empty {
5664 return Ok(0);
5665 }
5666 recgbl::rec_gbl_set_sevr(
5667 &mut self.common,
5668 alarm_status::BAD_SUB_ALARM,
5669 AlarmSeverity::Invalid,
5670 );
5671 return Ok(match kind {
5672 SubroutineKind::ASub => S_DB_BAD_SUB,
5673 SubroutineKind::Sub => 0,
5674 });
5675 };
5676 // C `do_sub` returns the subroutine's `long` status.
5677 let status = sub_fn(&mut *self.record)?;
5678
5679 // A negative status raises SOFT_ALARM at the record's BRSV severity
5680 // (C `do_sub`: `if (status < 0) recGblSetSevr(SOFT_ALARM,
5681 // prec->brsv)`). It accumulates into nsta/nsev for this cycle's
5682 // recGblResetAlarms commit and runs before checkAlarms, so a higher
5683 // analog severity (e.g. the shared analog-alarm owner) still wins via
5684 // the raise-only rule. BRSV defaults to NO_ALARM, under which
5685 // recGblSetSevr is a no-op.
5686 if status < 0 {
5687 let brsv = self
5688 .record
5689 .get_field("BRSV")
5690 .and_then(|v| v.to_f64())
5691 .map(|f| AlarmSeverity::from_u16(f as u16))
5692 .unwrap_or(AlarmSeverity::NoAlarm);
5693 recgbl::rec_gbl_set_sevr(&mut self.common, alarm_status::SOFT_ALARM, brsv);
5694 } else {
5695 // C `do_sub`'s `else` arm — the ONE place either flavour writes UDF,
5696 // reached only where the subroutine actually ran and returned `>= 0`.
5697 // aSub takes `prec->udf = FALSE` (`aSubRecord.c:470`); `sub` takes
5698 // `prec->udf = isnan(prec->val)` (`subRecord.c:434`).
5699 //
5700 // `sub`'s derive is the same expression the framework's per-cycle
5701 // blanket computes, made HERE instead of there so that the cycles
5702 // which run no subroutine — the unresolved-SNAM `BAD_SUB_ALARM`
5703 // return above, a failed `fetch_values` (`suppress_subroutine_run`),
5704 // a negative status — leave UDF at its previous value, exactly as C
5705 // does. Both flavours therefore opt out of the blanket
5706 // (`Record::clears_udf` == false).
5707 self.common.udf = match kind {
5708 SubroutineKind::ASub => 0,
5709 SubroutineKind::Sub => self.record.value_is_undefined() as u8,
5710 };
5711 }
5712 Ok(status)
5713 }
5714
5715 /// The single owner of a process cycle's SUBSCRIBER posts — C `monitor()`'s
5716 /// "post every subscribed field this cycle touched" loop.
5717 ///
5718 /// Every processing path (`process_record_with_links_inner`, the deferred
5719 /// async-completion path, the simulation path, and [`Self::process_local`])
5720 /// calls this; none of them may reimplement the rules, because a rule that
5721 /// holds on one path and not another is a monitor that fires on a scan cycle
5722 /// but not on an async completion. The per-field mask resolvers
5723 /// ([`AuxPostMask`], [`crate::server::record::value_gate`]) were already
5724 /// single-owned for the same reason — this is the loop around them.
5725 ///
5726 /// It also UPDATES `last_posted` for every post the cycle makes — including
5727 /// a post to a field nobody subscribes to, which it does not return — and
5728 /// it TAKES the
5729 /// record's per-cycle post mask ([`Record::take_cycle_posted_fields`]), so
5730 /// it must run exactly once per cycle.
5731 ///
5732 /// The rules, in order:
5733 ///
5734 /// * The deadband field (default VAL), the
5735 /// [`recgbl::RECGBL_POSTED_ALARM_FIELDS`](crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS)
5736 /// (SEVR/STAT/AMSG/ACKS) and UDF are emitted by the caller with their own
5737 /// C masks and are skipped here.
5738 /// * [`Record::event_posted_fields`] post from their own event path
5739 /// (waveform HASH) — never from change detection.
5740 /// * [`Record::process_posted_fields`], when declared, is the closed set of
5741 /// fields a process cycle may post at all.
5742 /// * A secondary value field ([`Record::fields_posted_with_value_mask`])
5743 /// carries VAL's monitor mask, gated per its [`ValuePostGate`](super::ValuePostGate).
5744 /// * A CHANGED field carries [`AuxPostMask::mask_for`] — unless it is a
5745 /// [`Record::fields_posted_only_when_marked`] field, which C never
5746 /// change-detects (aCalcout AA..LL) and which therefore posts from its
5747 /// mark alone.
5748 /// * An UNCHANGED field posts only if the record marked it this cycle:
5749 /// statically ([`Record::force_posted_fields`]), per-cycle
5750 /// ([`Record::take_cycle_posted_fields`]), on the alarm transition
5751 /// ([`Record::alarm_cycle_monitored_fields`]), or in the DBE_LOG sweep
5752 /// ([`Record::log_swept_fields`]).
5753 pub(crate) fn collect_subscriber_posts(
5754 &mut self,
5755 snapshot: &mut ProcessSnapshot,
5756 deadband_mask: EventMask,
5757 alarm_bits: EventMask,
5758 include_val: bool,
5759 ) {
5760 use crate::server::record::{CyclePostMask, ValuePostGate, value_gate};
5761
5762 // TAKE — this also clears the state it answers from (C's
5763 // `pcalc->newm = 0`), which is why this loop may run only once per cycle.
5764 let mut cycle_posted = self.record.take_cycle_posted_fields();
5765 // The record-lifetime sibling: C's `firstCalcPosted == 0` term, which
5766 // iocInit's per-cycle drain must not be able to eat. Merged here so
5767 // both reach the same branch with the same mask mapping.
5768 let first_cycle = self.record.take_first_monitor_cycle();
5769 if !first_cycle.is_empty() {
5770 cycle_posted.extend(first_cycle);
5771 }
5772 let MonitorPlan {
5773 deadband_field,
5774 value_masked,
5775 aux_post,
5776 ..
5777 } = self.monitor_plan;
5778 // C `if (prec->omod) monitor_mask |= (DBE_VALUE|DBE_LOG)` — the guard
5779 // `OnChangeForced` fields sit behind, which the record may open on a
5780 // cycle where VAL's own mask is shut. TAKEn, like `cycle_posted`, so
5781 // this loop may run only once per cycle.
5782 let secondary_guard = deadband_mask | self.record.take_secondary_value_mask();
5783
5784 // C aoRecord.c:536-549: the secondary block runs once per cycle, from
5785 // inside `if (monitor_mask)`, and each field's own `oraw != rval` test
5786 // is welded to the `oraw = rval` that follows its `db_post_events`.
5787 // Decided by the record's own old copy, not this walk's `last_posted`
5788 // change detection, and taken whether or not anyone is subscribed —
5789 // so the bookkeeping must not depend on who is watching. Decided
5790 // HERE, before the walk reads any field, so the record's old copies
5791 // are already advanced by the time the walk looks at the record;
5792 // the posts themselves land after the walk, in the order C emits
5793 // them. Which fields fired is kept as a bit per `value_masked` index
5794 // rather than a list, so the cycle allocates nothing for it.
5795 let forced_mask = (!secondary_guard.is_empty())
5796 .then_some(secondary_guard | EventMask::VALUE | EventMask::LOG);
5797 let mut forced_fired: u64 = 0;
5798 if forced_mask.is_some() {
5799 debug_assert!(
5800 value_masked.len() <= u64::BITS as usize,
5801 "fields_posted_with_value_mask is wider than the fired bitmask"
5802 );
5803 for (index, (name, gate)) in value_masked.iter().enumerate() {
5804 if *gate == ValuePostGate::OnChangeForced
5805 && self.record.take_secondary_value_change(name)
5806 {
5807 forced_fired |= 1 << index;
5808 }
5809 }
5810 }
5811 // Nothing is subscribed, nothing has ever been published and no
5812 // forced post fired, so the walk below has no field to reach and
5813 // nothing to advance. The record's declared post sets are the walk's
5814 // inputs alone, so asking for them, six more trips through the
5815 // vtable, is asked only here. Everything a cycle owes whether or not
5816 // anyone is watching — the TAKEs above and the record's own old-copy
5817 // advance — has already run.
5818 if self.subscribers.is_empty() && self.last_posted.is_empty() && forced_fired == 0 {
5819 return;
5820 }
5821 // Every post this walk adds sits at or past this index, so the
5822 // published-state advance at the tail covers exactly those.
5823 let first_post = snapshot.len();
5824
5825 // C's default for a change-detected auxiliary post:
5826 // `monitor_mask | DBE_VALUE | DBE_LOG` (calcRecord.c:420, subRecord.c:400;
5827 // motor `DBE_VAL_LOG` for marked fields, motorRecord.cc:3522-3645).
5828 let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
5829 let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
5830 &[]
5831 } else {
5832 self.record.alarm_cycle_monitored_fields()
5833 };
5834 let force_fields = self.record.force_posted_fields();
5835 let log_swept = self.record.log_swept_fields();
5836 // C change-detects nothing about these fields; only the record's own
5837 // per-cycle mark may post them (aCalcout AA..LL — no PAA..PLL previous
5838 // copy exists to compare against).
5839 let marked_only = self.record.fields_posted_only_when_marked();
5840 let event_posted = self.record.event_posted_fields();
5841 let process_posted = self.record.process_posted_fields();
5842
5843 // The walk covers every field whose published value is tracked — each
5844 // subscribed field, and each field a post has already published — not
5845 // only the fields someone watches now. C `monitor()` decides from the
5846 // record's own state and `db_post_events` with no subscriber leaves
5847 // that state advanced, so what the record has published must not
5848 // depend on who is watching: a move finished with no `.DMOV` monitor
5849 // left `last_posted` at the move-start 0 while the field read 1, and
5850 // the next subscriber's move-start 0 compared equal and was dropped.
5851 // A field with no entry has never been published; `add_subscriber`
5852 // seeds it from the value the subscriber is handed.
5853 let tracked = self.subscribers.keys().chain(
5854 self.last_posted
5855 .keys()
5856 .filter(|field| !self.subscribers.contains_key(*field)),
5857 );
5858 for field in tracked {
5859 if field == deadband_field
5860 // SEVR/STAT/AMSG/ACKS are posted by `recGblResetAlarms` itself,
5861 // each with its own C mask (recGbl.c:202-222) — the caller emits
5862 // them from `alarm_field_posts`. A second, change-detected copy
5863 // here would double-post with a mask C never uses for them
5864 // (`alarm_bits | DBE_VALUE | DBE_LOG` instead of C's DBE_VALUE
5865 // on ACKS). UDF is excluded for the opposite reason: NO C
5866 // `monitor()` posts it at all, so a processing cycle that
5867 // redefines VAL must emit no `.UDF` event (a caput to `.UDF`
5868 // still posts, through the generic put path).
5869 || crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS.contains(&field.as_str())
5870 || field == "UDF"
5871 || event_posted.contains(&field.as_str())
5872 || !process_posted.is_none_or(|allowed| allowed.contains(&field.as_str()))
5873 {
5874 continue;
5875 }
5876 let Some(val) = self.resolve_field(field) else {
5877 continue;
5878 };
5879 let changed = match self.posted_value(field) {
5880 Some(prev) => prev != &val,
5881 None => true,
5882 };
5883 if let Some(gate) = value_gate(value_masked, field) {
5884 // C posts this secondary value field with VAL's own monitor_mask,
5885 // from inside the guard that decides whether VAL posts at all —
5886 // never a forced DBE_VALUE|DBE_LOG. `ValuePostGate` says whether C
5887 // also re-tests the field's own value inside that guard (ai RVAL,
5888 // aiRecord.c:462) or posts it whenever the guard fires (timestamp
5889 // RVAL, timestampRecord.c:160).
5890 let post = match gate {
5891 ValuePostGate::OnChange => changed && !deadband_mask.is_empty(),
5892 ValuePostGate::WithValue => include_val,
5893 // Decided once per cycle in `forced_fired` above, against
5894 // the record's own old copy — never here, where the answer
5895 // would depend on this loop's `last_posted` cache and on
5896 // the field having a subscriber.
5897 ValuePostGate::OnChangeForced => false,
5898 };
5899 if post {
5900 snapshot.push((field.clone().into(), val.clone(), deadband_mask));
5901 }
5902 } else if changed && !marked_only.contains(&field.as_str()) {
5903 snapshot.push((
5904 field.clone().into(),
5905 val.clone(),
5906 aux_post.mask_for(field, alarm_bits, deadband_mask),
5907 ));
5908 } else if force_fields.contains(&field.as_str()) {
5909 // C `monitor()` posts a statically re-marked field with
5910 // `monitor_mask | DBE_VAL_LOG` even when unchanged.
5911 snapshot.push((field.clone().into(), val.clone(), aux_mask));
5912 } else if cycle_posted.iter().any(|(name, _)| *name == field) {
5913 // One event per MARK, each with the mask of the C call site that
5914 // made it (`CyclePostMask`) — a field marked twice (aCalcout's
5915 // AMASK `afterCalc` post AND its NEWM `monitor()` post) is posted
5916 // twice, exactly as C posts it from both loops.
5917 for (_, cycle_mask) in cycle_posted.iter().filter(|(name, _)| *name == field) {
5918 let mask = match cycle_mask {
5919 CyclePostMask::Value => EventMask::VALUE,
5920 CyclePostMask::ValueLog => EventMask::VALUE | EventMask::LOG,
5921 CyclePostMask::MonitorValueLog => aux_mask,
5922 };
5923 snapshot.push((field.clone().into(), val.clone(), mask));
5924 }
5925 } else if alarm_fanout.contains(&field.as_str()) {
5926 // C motor `monitor()` (motorRecord.cc:3456-3646) posts every listed
5927 // field once `monitor_mask != 0`, so a DBE_ALARM-only subscriber
5928 // observes the alarm moment on any of them.
5929 snapshot.push((field.clone().into(), val.clone(), alarm_bits));
5930 }
5931 // C `scalerRecord.c::monitor():757-773` posts EVERY S1..Snch with a
5932 // literal DBE_LOG on every cycle it runs (it runs when `ss == IDLE`,
5933 // scalerRecord.c:510). That sweep is INDEPENDENT of the change post,
5934 // not an alternative to it: on the count-completion cycle `ss` is
5935 // IDLE and `updateCounts()` has ALREADY posted each changed Sn with
5936 // DBE_VALUE (:582), so C emits two events for that field in that one
5937 // cycle — DBE_VALUE, then DBE_LOG. Making this an `else if` on
5938 // `changed` dropped the DBE_LOG half exactly when it matters: a
5939 // DBE_LOG-only archiver would never receive the final counts.
5940 //
5941 // The sweep carries the ALARM-transition bits too. DEVIATION from C,
5942 // deliberate — CBUG-B19. C's `monitor()` opens with
5943 // `monitor_mask = recGblResetAlarms(pscal); monitor_mask |=
5944 // (DBE_VALUE|DBE_LOG);` and then posts with a LITERAL `DBE_LOG`
5945 // (scalerRecord.c:764-771) — `monitor_mask` is assigned, OR-ed, and
5946 // never read. Those two lines are dead, and their only plausible use
5947 // was as the third `db_post_events` argument.
5948 // `recGblResetAlarms` returns the alarm-transition mask that every
5949 // other record ORs into its value posts, so discarding it drops the
5950 // alarm bit: a client subscribed to `Sn` with DBE_ALARM receives
5951 // NOTHING on an alarm-severity transition of the record.
5952 //
5953 // The DBE_VALUE half of C's dead `|=` is deliberately NOT
5954 // resurrected: this sweep is unconditional, so adding VALUE would
5955 // fire a value event at every VALUE subscriber on every idle scan,
5956 // changed or not — that would be a new defect, not a fix. The value
5957 // path is separately served by the change post (C's `updateCounts()`
5958 // DBE_VALUE at `:582`).
5959 if log_swept.contains(&field.as_str()) {
5960 snapshot.push((field.clone().into(), val, EventMask::LOG | alarm_bits));
5961 }
5962 }
5963 // A guarded secondary post lands whether or not the field has a
5964 // subscriber, exactly as C calls `db_post_events` (aoRecord.c:541);
5965 // the ones nobody watches are dropped below, after the published
5966 // values have advanced.
5967 if let Some(forced_mask) = forced_mask {
5968 for (index, (name, _)) in value_masked.iter().enumerate() {
5969 if forced_fired & (1 << index) == 0 {
5970 continue;
5971 }
5972 if let Some(val) = self.resolve_field(name) {
5973 snapshot.push((Cow::Borrowed(name), val, forced_mask));
5974 }
5975 }
5976 }
5977 // Every post the cycle made is published whether or not anyone
5978 // watches the field: advance the published values first, then hand on
5979 // only the posts a subscriber receives. C's `db_post_events` with no
5980 // subscriber delivers nothing, and still leaves the record's state
5981 // advanced. `snapshot` is the caller's, not a field of `self`, so the
5982 // walk's pushes and this advance do not contend for the record.
5983 for (field, val, _) in snapshot.iter().skip(first_post) {
5984 self.record_value_post(field, val.clone());
5985 }
5986 let mut index = 0;
5987 snapshot.retain(|(field, _, _)| {
5988 let walked = index >= first_post;
5989 index += 1;
5990 !walked
5991 || self
5992 .subscribers
5993 .get(field.as_ref())
5994 .is_some_and(|subs| !subs.is_empty())
5995 });
5996 }
5997
5998 /// The posts an `AsyncPendingNotify` pass publishes — the single owner both
5999 /// dispatch paths call (`processing.rs`'s engine and [`Self::process_local`]),
6000 /// so a mid-async post cannot obey one rule on one path and another rule on
6001 /// the other.
6002 ///
6003 /// Each post carries `DBE_VALUE|DBE_LOG`: C motor's mid-move
6004 /// `db_post_events` calls use `DBE_VAL_LOG` (motorRecord.cc:2606 DMOV, and
6005 /// every other `do_work` post), and no alarm transition ran on this pending
6006 /// pass.
6007 ///
6008 /// The deadband field is NOT change-detected against `last_posted` here.
6009 /// Whether it posts, with which mask, and where MLST/ALST land belong to
6010 /// [`Self::value_include_classes`] and [`Self::deadband_post`] on this pass
6011 /// as on every other — C motor `monitor()` runs on the move-start pass too
6012 /// (motorRecord.cc:1507) and posts RBV only on an MDEL/ADEL crossing,
6013 /// moving `mlst` to RBV (motorRecord.cc:3468-3507). `deadband_post`
6014 /// deliberately does not advance `last_posted`: MLST/ALST are where that
6015 /// field's published value lives, so change-detecting it here compared
6016 /// against a cache nothing maintains and re-posted the PREVIOUS readback at
6017 /// every move start.
6018 pub(crate) fn collect_notify_posts(
6019 &mut self,
6020 fields: Vec<(String, EpicsValue)>,
6021 ) -> ProcessSnapshot {
6022 let deadband_field = self.record.monitor_deadband_field();
6023 let mut posts = ProcessSnapshot::new();
6024 for (name, val) in fields {
6025 if name == deadband_field {
6026 // The record's own value, not the notify's copy of it: C posts
6027 // the field itself (`db_post_events(pmr, &pmr->rbv, ...)`).
6028 let (include_val, include_archive) = self.value_include_classes();
6029 // No alarm bits: `recGblResetAlarms` has not run on this pending
6030 // pass, so the post carries only the classes MDEL/ADEL fired.
6031 let deadband = self.deadband_post(EventMask::NONE, include_val, include_archive);
6032 if let Some((field, value)) = deadband.field {
6033 posts.push((field.into(), value, deadband.mask));
6034 }
6035 continue;
6036 }
6037 if self.posted_value(&name).is_none_or(|prev| prev != &val) {
6038 self.record_value_post(&name, val.clone());
6039 posts.push((name.into(), val, EventMask::VALUE | EventMask::LOG));
6040 }
6041 }
6042 posts
6043 }
6044
6045 /// Basic process: process record, evaluate alarms, timestamp, build snapshot.
6046 /// This does NOT handle links — see process_with_context in database.rs.
6047 ///
6048 /// Returns the value/log snapshot plus a list of alarm-field posts
6049 /// (`SEVR`/`STAT`/`AMSG`/`ACKS`) with their individual C event masks.
6050 /// `SEVR` is posted `DBE_VALUE` only; `STAT`/`AMSG` carry `DBE_ALARM`
6051 /// (sevr/amsg change) and/or `DBE_VALUE` (stat change). The caller
6052 /// must fire these via `notify_field` so a `DBE_VALUE`-only `.SEVR`
6053 /// subscriber is not missed on an alarm-only change and a
6054 /// `DBE_ALARM`-only subscriber is not wrongly notified — C parity
6055 /// with `recGblResetAlarms` (recGbl.c:202-222), matching the
6056 /// `processing.rs` link path.
6057 pub fn process_local(
6058 &mut self,
6059 ) -> CaResult<(
6060 ProcessSnapshot,
6061 Vec<(&'static str, crate::server::recgbl::EventMask)>,
6062 )> {
6063 use crate::server::recgbl::{self, EventMask};
6064 const LCNT_ALARM_THRESHOLD: i16 = 10;
6065
6066 if self.pact.swap(true, std::sync::atomic::Ordering::AcqRel) {
6067 // C `dbProcess` PACT-active guard (dbAccess.c:544-557):
6068 //
6069 // if ((precord->stat == SCAN_ALARM) ||
6070 // (precord->lcnt++ < MAX_LOCK) ||
6071 // (precord->sevr >= INVALID_ALARM)) goto all_done;
6072 // recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM,
6073 // "Async in progress");
6074 //
6075 // The alarm fires EXACTLY ONCE — on the attempt whose
6076 // pre-increment lcnt equals MAX_LOCK — and is then blocked
6077 // by the stat == SCAN_ALARM / sevr >= INVALID bails, the
6078 // same shape as the link path
6079 // (`process_record_with_links_inner`). The pre-fix guard
6080 // here used post-increment `lcnt >= threshold` with no
6081 // already-raised bail, so every reentrant attempt past the
6082 // threshold re-posted the unchanged SEVR/STAT/VAL (and the
6083 // first fire came one attempt early); it also wrote
6084 // sevr/stat directly, skipping `recGblSetSevrMsg` +
6085 // `recGblResetAlarms` — losing the "Async in progress"
6086 // AMSG and the acks bookkeeping the reset performs.
6087 let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
6088 let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
6089 let lcnt_before = self.common.lcnt;
6090 self.common.lcnt = lcnt_before.saturating_add(1);
6091 if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
6092 return Ok((ProcessSnapshot::new(), Vec::new()));
6093 }
6094 recgbl::rec_gbl_set_sevr_msg(
6095 &mut self.common,
6096 recgbl::alarm_status::SCAN_ALARM,
6097 AlarmSeverity::Invalid,
6098 "Async in progress",
6099 );
6100 let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
6101 // Per-field C masks (recGbl.c:202-222): this guard only
6102 // runs on a fresh SCAN_ALARM/INVALID raise, so sevr AND
6103 // stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post
6104 // the shared `stat_mask` = DBE_ALARM|DBE_VALUE, VAL posts
6105 // DBE_VALUE|DBE_LOG plus `val_mask` = DBE_ALARM.
6106 let stat_mask = EventMask::ALARM | EventMask::VALUE;
6107 let mut changed_fields = crate::server::record::ProcessSnapshot::new();
6108 if let Some(val) = self.record.val() {
6109 changed_fields.push((
6110 "VAL".into(),
6111 val,
6112 EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
6113 ));
6114 }
6115 changed_fields.push((
6116 "SEVR".into(),
6117 EpicsValue::Short(self.common.sevr as i16),
6118 EventMask::VALUE,
6119 ));
6120 changed_fields.push((
6121 "STAT".into(),
6122 EpicsValue::Short(self.common.stat as i16),
6123 stat_mask,
6124 ));
6125 // AMSG carries "Async in progress" alongside the STAT
6126 // transition (C recGbl.c posts STAT and AMSG together
6127 // when any alarm field moved).
6128 changed_fields.push((
6129 "AMSG".into(),
6130 EpicsValue::String(self.common.amsg.as_str().into()),
6131 stat_mask,
6132 ));
6133 return Ok((changed_fields, Vec::new()));
6134 }
6135 self.common.lcnt = 0;
6136 // RAII guard that resets `self.pact` to false on drop — both for the
6137 // normal exit path and for any `?` early return. The guard holds a raw
6138 // pointer rather than a reference because we still need `self` mutably
6139 // while the guard is alive (the record body below mutates other `self`
6140 // fields).
6141 //
6142 // This is the one PACT release that does not go through `leave_pact`,
6143 // and it provably owes no restart: `process_local` holds `&mut self` for
6144 // the whole PACT window, and a put-notify is queued only through
6145 // `queue_notify_put`, which needs that same `&mut`. So nothing can join
6146 // the restart list inside the window, and the `swap(true)` above proved
6147 // the record was idle on entry.
6148 debug_assert!(
6149 self.notify_restart_list.is_empty(),
6150 "a queued put-notify implies the record was owned, which the swap \
6151 above proved it was not"
6152 );
6153 struct ProcessGuard(*const AtomicBool);
6154 // SAFETY: AtomicBool is Sync; raw pointers don't auto-derive
6155 // Send. We hand-roll Send because the ptr targets a field of
6156 // `self`, which the caller already proves can be borrowed
6157 // through this code path. The pointer is only ever read for an
6158 // atomic store, never written, dereferenced for raw access, or
6159 // escaped from this scope.
6160 unsafe impl Send for ProcessGuard {}
6161 impl Drop for ProcessGuard {
6162 fn drop(&mut self) {
6163 // SAFETY: `self.0` was constructed from
6164 // `&self.pact as *const AtomicBool` below, where
6165 // `self` is the live RecordInstance whose lifetime
6166 // strictly outlives `_guard`. RecordInstance is
6167 // !Unpin-equivalent in practice (we never move it
6168 // while held in the database's `Arc<RwLock<_>>`), so
6169 // the pointer remains valid until Drop runs.
6170 unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
6171 }
6172 }
6173 let _guard = ProcessGuard(&self.pact as *const AtomicBool);
6174
6175 // Call subroutine if registered (for sub/aSub records). Single owner
6176 // shared with the main engine path — see `run_registered_subroutine`.
6177 self.run_registered_subroutine()?;
6178 // Soft-Channel input records must skip the RVAL->VAL convert
6179 // (C `devAiSoft.c` `read_ai` returns 2 = "don't convert" for
6180 // every Soft-Channel input record, incl. one with a constant /
6181 // unset INP). Without this, `process_local` on a soft input
6182 // with a preset VAL — e.g. NaN — would run `convert()` and
6183 // clobber it, after which the UDF check below would see a
6184 // defined value and wrongly clear UDF. The
6185 // `processing.rs` link path already does this; `process_local`
6186 // is the separate foreign-call path (`db.process_record`) and
6187 // needs the same skip. `SoftDtyp::Raw` is excluded below and still
6188 // runs convert.
6189 //
6190 // Gated on `soft_channel_skips_convert()` — identical to the
6191 // `processing.rs` link path — so this only suppresses the
6192 // `RVAL → VAL` convert step. `set_device_did_compute` is an
6193 // overloaded hook: `ai/bi/mbbi/mbbi_direct` read it as
6194 // "skip convert" (override true), but `epid` reads it as
6195 // "skip the whole built-in PID compute" (keeps default false).
6196 // Without this gate, a Soft-Channel `epid` driven through
6197 // `process_local` (`db.process_record`, e.g. QSRV group proc
6198 // members) would skip `do_pid()` entirely — the regression
6199 // d1032fe5 fixed on the `processing.rs` path only.
6200 {
6201 // The same "does the input dset return 2" question the
6202 // `processing.rs` link path asks — Plain and Async, not Raw.
6203 let is_soft = matches!(
6204 self.common.dtyp.soft(),
6205 Some(
6206 crate::server::device_support::SoftDtyp::Plain
6207 | crate::server::device_support::SoftDtyp::Async
6208 )
6209 );
6210 let is_output = self.record.can_device_write();
6211 if is_soft && !is_output && self.record.soft_channel_skips_convert() {
6212 self.record.set_device_did_compute(true);
6213 }
6214 }
6215 // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so the
6216 // record's process() can see it — same as the processing.rs link
6217 // path. `process_local` is the foreign-call path
6218 // (`db.process_record`); without this a record driven through it
6219 // (e.g. QSRV group-process members) would not see UDF/TSE.
6220 {
6221 let ctx = self.common.process_context();
6222 self.record.set_process_context(&ctx);
6223 }
6224 let outcome = self.record.process()?;
6225 let process_result = outcome.result;
6226 // Note: process_local() does not execute ProcessActions — those are
6227 // handled by the full process_record_with_links() path in processing.rs.
6228 //
6229 // It must still apply `post_write_fields`. There are no link writes
6230 // here for them to be ordered against, so the ordering rule is
6231 // satisfied trivially; what is NOT optional is applying them at all —
6232 // a record that hands its completion-flag clear to the framework
6233 // (sseq's `busy`, scaler's `cnt`) would otherwise stay busy forever on
6234 // this path. Same store-then-`DBE_VALUE`-post as
6235 // `PvDatabase::publish_post_write_fields`.
6236 for (field, value) in outcome.post_write_fields {
6237 if self.record.put_field_internal(&field, value).is_ok() {
6238 self.notify_field_written(&field);
6239 self.notify_field(&field, crate::server::recgbl::EventMask::VALUE);
6240 }
6241 }
6242
6243 // If the record reports it modified a metadata-class field during
6244 // process(), invalidate the metadata cache so the next snapshot
6245 // rebuilds from the new values. Default impl returns false, so
6246 // most records pay zero cost here.
6247 if self.record.took_metadata_change() {
6248 self.invalidate_metadata_cache();
6249 // mirror C db_post_events(precord, NULL, DBE_PROPERTY) after record processing.
6250 // `none()` and not a parameter, alone among the `DBE_PROPERTY`
6251 // sweeps: this function is the link-LESS process path by its own
6252 // contract above, and every production process cycle goes through
6253 // `PvDatabase::process_record_with_links`, which resolves. The
6254 // claim is therefore "no caller of `process_local` has a
6255 // link-backed subscriber", and a caller that acquires one must
6256 // move to the link path rather than pass a backing here.
6257 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
6258 for f in fields {
6259 self.notify_field_with_origin(
6260 &f,
6261 crate::server::recgbl::EventMask::PROPERTY,
6262 0,
6263 LinkBacking::none(),
6264 );
6265 }
6266 }
6267
6268 if process_result == RecordProcessResult::AsyncPending {
6269 // Async: PACT stays set, no further processing this cycle
6270 // Don't clear processing flag (guard won't run — we leak it intentionally)
6271 std::mem::forget(_guard);
6272 return Ok((ProcessSnapshot::new(), Vec::new()));
6273 }
6274 if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
6275 // Intermediate notification (e.g. DMOV=0 at move start).
6276 // Unlike AsyncPending, we DO release the processing flag so
6277 // subsequent I/O Intr cycles can continue processing normally.
6278 self.common.time = crate::runtime::general_time::get_current();
6279 // The pass's posts, through the owner this path shares with the
6280 // engine (`Self::collect_notify_posts`).
6281 let changed_fields = self.collect_notify_posts(fields);
6282 // _guard drops here, clearing the processing flag
6283 return Ok((changed_fields, Vec::new()));
6284 }
6285 if process_result == RecordProcessResult::CompleteNoEmit {
6286 // The record accumulated this cycle without emitting (compress
6287 // `status == 1`). C `compressRecord.c:365` runs the completion
6288 // epilogue (udf clear, timestamp, monitor, FLNK) only on an emit
6289 // cycle (`if (status != 1)`), so a non-emitting cycle must publish
6290 // nothing — skip the epilogue and return an empty snapshot, exactly
6291 // as the production engine path does in `processing.rs`. This keeps
6292 // the emit-gate uniform across both process-dispatch paths so the
6293 // invariant holds by construction, not by "process_local never
6294 // produces it". CompleteNoEmit is synchronous (PACT already
6295 // cleared); the `_guard` drops here, clearing the processing flag.
6296 return Ok((ProcessSnapshot::new(), Vec::new()));
6297 }
6298
6299 // `CompleteDeferOutput` (swait ODLY delay-start) is NOT special-cased
6300 // here: it deliberately shares the Complete value-side snapshot builder
6301 // below. C `swaitRecord.c::process` posts the value side (`monitor()`,
6302 // line 475) on the delaying cycle, so building the snapshot now is the
6303 // correct, parity-matching behavior — unlike `CompleteNoEmit` above,
6304 // whose fall-through would wrongly emit. The variant's *other* halves —
6305 // holding PACT across the delay and deferring OUT/OEVT/FLNK to the
6306 // `ReprocessAfter` continuation — are the engine path's responsibility
6307 // (`processing.rs::process_record_with_links_inner`); `process_local` is
6308 // a body-only test helper that dispatches no FLNK/output and no
6309 // `ProcessAction`, and no test drives a swait ODLY record through it. So
6310 // the invariant still holds by construction across both dispatch paths:
6311 // both publish the value side here, both leave the output side to the
6312 // engine.
6313
6314 // UDF update before alarm evaluation — C parity (see
6315 // `processing.rs`). A NaN / undefined value keeps UDF true so
6316 // `recGblCheckUDF` raises UDF_ALARM this cycle instead of the
6317 // record reporting a stale/garbage value with no alarm.
6318 if self.record.clears_udf() {
6319 self.common.udf = self.record.value_is_undefined() as u8;
6320 }
6321 // Per-record alarm hook (C `checkAlarms()`).
6322 self.record.check_alarms(&mut self.common);
6323
6324 // Evaluate alarms (accumulates into nsta/nsev)
6325 self.evaluate_alarms();
6326
6327 self.common.time = crate::runtime::general_time::get_current();
6328 // UDF already updated above — do not clear unconditionally.
6329
6330 let MonitorOutcome {
6331 snapshot,
6332 alarm_posts,
6333 } = self.monitor_cycle();
6334
6335 Ok((snapshot, alarm_posts.to_vec()))
6336 }
6337
6338 /// Check MDEL/ADEL deadbands for VAL monitor/archive filtering.
6339 /// Returns `(monitor_trigger, archive_trigger)`.
6340 ///
6341 /// Updates `MLST`/`ALST` (record-owned) and the `CommonFields`
6342 /// `mlst/alst` shadow when a trigger fires. Records without
6343 /// MDEL/ADEL (e.g. motor) default to deadband=0 (any actual
6344 /// change triggers).
6345 ///
6346 /// Delegates the comparison to the free function [`check_deadband`]
6347 /// below, which ports C `recGblCheckDeadband` (recGbl.c:345-370).
6348 /// `None` there is "this record type carries no MLST/ALST cell and
6349 /// nothing has been posted yet", the only state C does not have.
6350 /// The single owner of the deadband field's monitor post — C `monitor()`'s
6351 /// `db_post_events(&prec->val, monitor_mask)`, the one post every record
6352 /// makes for the value it deadbands.
6353 ///
6354 /// [`Self::check_deadband_ext`] decides WHETHER the MDEL/ADEL classes fired;
6355 /// this decides what the resulting post looks like, and it is the only place
6356 /// that assembles that mask. The three `processing.rs` snapshot builders and
6357 /// the `notify_monitors` path all route through here, so a record's mask rule
6358 /// cannot hold on one processing path and not another.
6359 ///
6360 /// Two record hooks strip C's `DBE_LOG` from the post:
6361 ///
6362 /// * [`Record::value_only_change_fields`] — C posts a literal `DBE_VALUE`
6363 /// (scaler VAL, scalerRecord.c:478).
6364 /// * [`Record::fields_posted_with_monitor_mask`] — C posts
6365 /// `monitor_mask | DBE_VALUE` (event VAL, eventRecord.c:163). `monitor_mask`
6366 /// there is `recGblResetAlarms`'s return, i.e. the alarm bits alone, so the
6367 /// post carries `DBE_VALUE` (+ `DBE_ALARM` when the alarm moved) and never
6368 /// the archive `DBE_LOG` — an event's VAL reaches a `DBE_LOG` archiver on
6369 /// no cycle at all.
6370 ///
6371 /// [`DeadbandPost::field`] is `None` when no class fired, i.e. when C's
6372 /// `if (monitor_mask)` guard would skip the post.
6373 /// C `monitor()`'s VALUE / LOG gate for the primary-value post —
6374 /// `(include_val, include_archive)`, the single owner every processing path
6375 /// feeds into [`Self::deadband_post`] and [`Self::collect_subscriber_posts`].
6376 /// Keeping it in one place is what stops the rule from holding on the
6377 /// synchronous path but not the async-continuation / put-notify paths.
6378 pub(crate) fn value_include_classes(&mut self) -> (bool, bool) {
6379 // fanout/seq "trigger" records post VAL only with the alarm events
6380 // `recGblResetAlarms` returns, never DBE_VALUE/DBE_LOG — see
6381 // `Record::process_posts_value_monitor`. The alarm bits still reach VAL
6382 // via `deadband_post`'s `alarm_bits`, so an alarm transition still posts
6383 // it; only the value/archive classes are suppressed.
6384 if !self.monitor_plan.posts_value_monitor {
6385 return (false, false);
6386 }
6387 match self.record.monitor_value_changed() {
6388 // lsi/lso post VALUE|LOG only when the string actually changed (C
6389 // `lsiRecord.c`/`lsoRecord.c` monitor: `len != olen || memcmp(oval,
6390 // val, len)`); they have no MDEL/ADEL deadband to express that, so
6391 // the gate is explicit. The MPST/APST `menuPost` "Always" override
6392 // OR-adds DBE_VALUE / DBE_LOG even on an unchanged cycle (C monitor:
6393 // `if (mpst == menuPost_Always) events |= DBE_VALUE; if (apst ==
6394 // menuPost_Always) events |= DBE_LOG;`).
6395 Some(changed) => {
6396 let (val_always, archive_always) = self.record.monitor_always_post();
6397 (changed || val_always, changed || archive_always)
6398 }
6399 None => {
6400 if self.monitor_plan.uses_deadband {
6401 self.check_deadband_ext()
6402 } else {
6403 // Binary records (bi/bo/busy/mbbi/mbbo): always post monitors
6404 (true, true)
6405 }
6406 }
6407 }
6408 }
6409
6410 pub(crate) fn deadband_post(
6411 &self,
6412 alarm_bits: EventMask,
6413 include_val: bool,
6414 include_archive: bool,
6415 ) -> DeadbandPost {
6416 let MonitorPlan {
6417 deadband_field: field,
6418 log_suppressed,
6419 ..
6420 } = self.monitor_plan;
6421
6422 let mut mask = alarm_bits;
6423 if include_val {
6424 mask |= EventMask::VALUE;
6425 }
6426 if include_archive && !log_suppressed {
6427 mask |= EventMask::LOG;
6428 }
6429
6430 // The closed set applies to THIS post too. `process_posted_fields` is
6431 // "the CLOSED set of fields a process cycle of this record may post" —
6432 // and the deadband post is a post. A record whose C `monitor()` never
6433 // names the deadband field must not have one invented for it: transform
6434 // `monitor()` (transformRecord.c:786-809) walks A..P and posts no VAL
6435 // at all — VAL is an inert dummy (`:422`) — so an alarm cycle, whose
6436 // `alarm_bits` alone make `mask` non-empty, was firing a `.VAL` monitor
6437 // C never sends. Gating here rather than at each builder keeps the
6438 // single owner of the deadband post the single enforcer of the set.
6439 let in_closed_set = self
6440 .record
6441 .process_posted_fields()
6442 .is_none_or(|allowed| allowed.contains(&field));
6443
6444 let value = if mask.is_empty() || !in_closed_set {
6445 None
6446 } else if field == "VAL" {
6447 self.record.val()
6448 } else {
6449 self.resolve_field(field)
6450 };
6451 DeadbandPost {
6452 mask,
6453 field: value.map(|v| (field, v)),
6454 }
6455 }
6456
6457 pub fn check_deadband_ext(&mut self) -> (bool, bool) {
6458 // C waveform/aai/aao `monitor()` (waveformRecord.c:291-326) replaces
6459 // the analog MDEL/ADEL deadband with the MPST/APST "Always vs On
6460 // Change" mechanism: the record hashes its array content and posts
6461 // `DBE_VALUE`/`DBE_LOG` either always or only when the hash changed,
6462 // and posts `HASH` (`DBE_VALUE`) on a hash change. The record owns
6463 // the hash compute + `HASH` update; `array_hash_changed` carries the
6464 // event to the snapshot builders, which post `HASH` (the field is
6465 // excluded from the generic change-detection loop via
6466 // `event_posted_fields`).
6467 if let Some(post) = self.record.array_monitor_post() {
6468 self.array_hash_changed = post.hash_changed;
6469 return (post.post_value, post.post_archive);
6470 }
6471 self.array_hash_changed = false;
6472
6473 // The deadband is evaluated against `monitor_deadband_value()`,
6474 // not `val()` directly: a record whose monitored quantity is
6475 // not its primary value (e.g. the motor record, VAL=setpoint /
6476 // RBV=readback — C `monitor()` deadbands RBV) overrides that
6477 // hook. Default is `val()`, so other records are unaffected.
6478 let Some(val) = self.record.monitor_deadband_value() else {
6479 return (true, true);
6480 };
6481
6482 // The four cells in one ask — see `Record::monitor_deadband_cells`.
6483 // `None` for MLST/ALST survives to `check_deadband` as the "nothing
6484 // posted yet" state: record types that carry no MLST/ALST cell (sel,
6485 // scalcout) have nowhere to hold a last-posted value, and fall back to
6486 // the `CommonFields` shadow.
6487 let cells = self.record.monitor_deadband_cells();
6488 let mdel = cells.mdel.unwrap_or(0.0);
6489 let adel = cells.adel.unwrap_or(0.0);
6490 let mlst = cells.mlst.or(self.common.mlst);
6491 let alst = cells.alst.or(self.common.alst);
6492
6493 let monitor_trigger = check_deadband(val, mlst, mdel);
6494 let archive_trigger = check_deadband(val, alst, adel);
6495
6496 if monitor_trigger || archive_trigger {
6497 self.record
6498 .store_monitor_last_posted(val, monitor_trigger, archive_trigger);
6499 }
6500 if archive_trigger {
6501 self.common.alst = Some(val);
6502 }
6503 if monitor_trigger {
6504 self.common.mlst = Some(val);
6505 }
6506
6507 (monitor_trigger, archive_trigger)
6508 }
6509
6510 /// Build a Snapshot for a given value, populated with the record's display
6511 /// metadata and the link metadata the poster resolved for this batch. Uses
6512 /// the metadata cache so the populate cost is paid at most once per
6513 /// metadata-stable interval (cf. `cached_metadata`).
6514 ///
6515 /// There is deliberately no `backing`-less form. One existed, defaulting to
6516 /// [`LinkBacking::none`], and it made "nothing was resolved" the thing a
6517 /// caller says by saying nothing — which is how the `DBE_PROPERTY` sweep
6518 /// came to post `CALC.A` with the calc's own precision (see
6519 /// `link_backed_metadata_is_read_live.rs`). A caller with nothing to
6520 /// resolve still writes `LinkBacking::none()`, and then it is a claim a
6521 /// reviewer can see and check.
6522 ///
6523 /// The monitor path reaches the same one consumer the GET path does
6524 /// (`finish_field_snapshot` -> `route_field_metadata`), so it carried the
6525 /// same defect: measured on the wire, a `camonitor -s` on a `calc`'s `A`
6526 /// after `caput TARGET.PREC 4` with the source never processed printed
6527 /// `5.0` where C printed `5.0000`. The resolve cannot happen here — the
6528 /// post runs with the record's own lock held — so the caller that owns the
6529 /// process/put cycle resolves it at a point where no lock is held and
6530 /// hands it in.
6531 ///
6532 /// Returns `None` when the backing DECLINED and this field is link-backed.
6533 /// The poster's gate ("nobody is subscribed, so skip the resolve") is asked
6534 /// under a read lock the walk then releases, and a subscriber can attach
6535 /// before the post takes the write lock. That one event would otherwise go
6536 /// out carrying the record's own seed where the link's metadata belongs.
6537 /// C closes the same window by lock discipline: `db_add_event` takes
6538 /// `dbScanLock` on the record's lock set (`dbEvent.c:730`), which
6539 /// `dbProcess` holds for the whole cycle, so a monitor attaching mid-cycle
6540 /// joins after it and receives events from the NEXT process, never that
6541 /// one. Refusing here reproduces that: the late subscriber's first event
6542 /// comes from the next cycle, and its initial value came from the
6543 /// create-channel GET, which resolves on the ungated door.
6544 pub fn make_monitor_snapshot(
6545 &self,
6546 field: &str,
6547 value: EpicsValue,
6548 backing: LinkBacking<'_>,
6549 ) -> Option<super::super::snapshot::Snapshot> {
6550 // A monitor update is posted from the record's own change-detection
6551 // loop, which hands over the STORED variant. Project it onto the
6552 // field's declared type here, at the same owner the GET path and the
6553 // CA create-channel path use, or a client that was told `DBR_ENUM` at
6554 // create time would be posted a `DBR_SHORT` update.
6555 // The poster's obligation, checked rather than trusted: a link-backed
6556 // field carries its target's units/precision/limits, and only a caller
6557 // holding no record lock can resolve them. A `LinkBacking::none()`
6558 // here would silently serve the slot's C seed instead — the X2 defect
6559 // in its new clothes. Debug-only because it is a property of the call
6560 // graph, not of the data: every path either resolves or provably
6561 // posts no link-backed field, and the suite is what proves it.
6562 debug_assert!(
6563 !backing.is_unresolved()
6564 || self
6565 .record
6566 .link_backed_metadata_field(&field.to_ascii_uppercase())
6567 .is_none(),
6568 "{}: monitor post of link-backed field {field} with nothing resolved \
6569 — the poster must call PvDatabase::resolve_link_backed_metadata \
6570 at a point where it holds no record lock",
6571 self.name
6572 );
6573 // The window above. Cheap enum test first — a declined backing only
6574 // ever comes from the poster door, so every other caller pays one
6575 // discriminant compare and never the field lookup.
6576 if backing.is_declined()
6577 && self
6578 .record
6579 .link_backed_metadata_field(&field.to_ascii_uppercase())
6580 .is_some()
6581 {
6582 return None;
6583 }
6584 let value = self.project_to_declared_type(field, value);
6585 Some(self.finish_field_snapshot(field, value, backing))
6586 }
6587
6588 /// Apply a record's per-field metadata override (C RSET
6589 /// `get_units`/`get_precision`/`get_graphic_double`/
6590 /// `get_control_double`/`get_alarm_double`, all keyed by field)
6591 /// over the cached record-level metadata. Shared by the GET and
6592 /// monitor snapshot builders. Computed live on every call — never
6593 /// cached — so overrides derived from fields outside the
6594 /// [`is_metadata_cache_source`] set cannot go stale.
6595 ///
6596 /// This is also where the record-level `Q:form` info tag is narrowed to
6597 /// the served field: QSRV assigns `display.form.index` only when the
6598 /// channel addresses the VAL field (`IOCSource::initialize` gates it on
6599 /// `dbIsValueField(dbChannelFldDes(chan))`, `iocsource.cpp:53`; the form
6600 /// *menu*, `form.choices`, is published for every field). The metadata
6601 /// cache is per-record, so a channel on `REC.RVAL` of a record carrying
6602 /// `info(Q:form, "Hex")` used to report Hex where pvxs reports Default.
6603 /// Both `Snapshot` producers (`snapshot_for_field` for GET,
6604 /// `make_monitor_snapshot` for updates) run this one owner, so
6605 /// `DisplayInfo::form` means exactly one thing on every path: the form
6606 /// index that applies to THIS field.
6607 fn apply_field_metadata_override(
6608 &self,
6609 field: &str,
6610 snap: &mut super::super::snapshot::Snapshot,
6611 ) {
6612 if let Some(display) = snap.display.as_mut()
6613 && !crate::server::database::is_value_field(field)
6614 {
6615 display.form = 0;
6616 }
6617 let Some(ov) = self.record.field_metadata_override(field) else {
6618 return;
6619 };
6620 if ov.units.is_some()
6621 || ov.precision.is_some()
6622 || ov.disp_limits.is_some()
6623 || ov.alarm_limits.is_some()
6624 {
6625 let d = snap.display.get_or_insert_with(Default::default);
6626 if let Some(units) = ov.units {
6627 d.units = units;
6628 }
6629 if let Some(precision) = ov.precision {
6630 d.precision = precision;
6631 }
6632 if let Some((upper, lower)) = ov.disp_limits {
6633 d.upper_disp_limit = upper;
6634 d.lower_disp_limit = lower;
6635 }
6636 if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
6637 d.upper_alarm_limit = hihi;
6638 d.upper_warning_limit = high;
6639 d.lower_warning_limit = low;
6640 d.lower_alarm_limit = lolo;
6641 }
6642 }
6643 if let Some((upper, lower)) = ov.ctrl_limits {
6644 let c = snap.control.get_or_insert_with(Default::default);
6645 c.upper_ctrl_limit = upper;
6646 c.lower_ctrl_limit = lower;
6647 }
6648 }
6649
6650 /// C's rset metadata slots route **per field**, on `dbGetFieldIndex`. The
6651 /// port's metadata cache is the record's VAL metadata, and serving it to
6652 /// every field is what made a non-VAL field report VAL's limits.
6653 ///
6654 /// Every base record's `get_control_double` / `get_alarm_double` has the
6655 /// same two-arm shape: a listed set of field indices that take the
6656 /// record's own limits, and a `default:` arm that hands the field to
6657 /// `recGblGetControlDouble` / `recGblGetAlarmDouble` — the field TYPE's
6658 /// numeric range, and four NaN. This routes the `default:` arm; a listed
6659 /// field keeps the cache, which already holds exactly the record's own
6660 /// limits (and already distinguishes `ao`'s DRVH/DRVL from `ai`'s
6661 /// HOPR/LOPR).
6662 ///
6663 /// The three slots' listed sets are **different**, and each has its own
6664 /// owner here: [`Self::control_explicit_field`],
6665 /// [`Self::graphic_explicit_field`] and [`Self::alarm_explicit_field`].
6666 /// They are separate switches over separate field lists in C, so the
6667 /// membership question is asked once per slot, never once for both — and
6668 /// each list varies by record TYPE, so it is asked once per type too.
6669 ///
6670 /// Measured on a real `softIocPVX` against `record(calc,"X"){}`:
6671 /// `.PHAS` (DBF_SHORT, unlisted) serves control ±32767 — the SHRT range —
6672 /// while `.VAL` and `.HIHI` (both listed) serve 0/0 from HOPR/LOPR.
6673 ///
6674 /// Display (graphic) limits differ from control in one way: the `default:`
6675 /// arm of `get_graphic_double` tries a LINK first (`calcRecord.c`
6676 /// `get_linkNumber` → `dbGetGraphicLimits`) and only falls to `recGbl` for
6677 /// a field that backs no link. A constant (unset) link has no metadata
6678 /// getters, so the `dbAccess.c:216` 0/0 seed stands — measured: `CALC.A`
6679 /// serves display 0/0 but control ±1e300. [`Record::link_backed_metadata_field`]
6680 /// carries that per-record C knowledge.
6681 ///
6682 /// Units and precision are routed here too, and they are NOT the
6683 /// `get_*_double` shape: neither has a `recGbl` range arm, so a field the
6684 /// type's switch does not name keeps `dbAccess.c`'s memset — empty units,
6685 /// and the precision seed. They were built into the record-level cache
6686 /// until `subArray`/`sel`/`sub`/`dfanout` were measured serving `""`/`0`
6687 /// against C's EGU/PREC, which is the same dual meaning the alarm leaves
6688 /// had: whether a field carried the record's own value depended on a
6689 /// `match rtype` in a different function.
6690 ///
6691 /// The last arm is not the same for every record type — a slot can also
6692 /// fall through WITHOUT delegating, keeping the seed. That fact is one bit
6693 /// per record type, read from its C source: `control_default_arm`.
6694 fn route_field_metadata(
6695 &self,
6696 field: &str,
6697 backing: LinkBacking<'_>,
6698 explicit_alarm: (f64, f64, f64, f64),
6699 snap: &mut super::super::snapshot::Snapshot,
6700 ) {
6701 // The rset slots this record type actually supplies. A NULL slot makes
6702 // `dbAccess.c` clear the option bit, so the leaf is never served and
6703 // there is nothing to route — minting a value here would put a
6704 // fabricated number into the struct while `Snapshot::properties` says
6705 // the slot is absent, and the two wires read different halves of that
6706 // disagreement: CA goes through the mask-gated accessors
6707 // (`codec.rs::get_limits` calls `graphic_limits()`/`alarm_limits()`/
6708 // `control_limits()`, each `then_some`-gated), PVA reads the struct
6709 // straight (`native_source.rs:226`) under its own leaf mask.
6710 let slots = self.record.property_support();
6711 let rtype = self.record.record_type();
6712 let f = field.to_ascii_uppercase();
6713
6714 // C's `get_linkNumber` question, asked ONCE per snapshot: which of this
6715 // record's own link fields, if any, supplies this field's metadata.
6716 // Four of the six slots consult it — `aSubRecord.c:306-404` is the
6717 // complete specimen — and control does not, because
6718 // `dbGetControlLimits` has no caller anywhere in base.
6719 let link_backed = self.record.link_backed_metadata_field(&f);
6720 // Resolved for THIS build and handed in — see [`LinkBacking`]. Reading
6721 // a value the record had stored is what made a `caput SRC.EGU` invisible
6722 // to a passive source's clients until the source next processed.
6723 let link_meta = link_backed.as_ref().and_then(|lf| backing.metadata(lf));
6724
6725 // C `get_units`'s "no case" arm — the field the rset tests for and
6726 // declines to write, leaving `dbAccess.c:378`'s zeroed buffer. The
6727 // record-level cache holds `EGU` for every type that supplies the
6728 // slot, so this is the step that takes it back off the fields C never
6729 // gives it to. See [`Self::units_from_egu`].
6730 if slots.units {
6731 if link_backed.is_some() {
6732 // C's link arm — `dbGetUnits(&prec->inpa + n, ...)`, which
6733 // writes only what the TARGET record supplies. A constant or
6734 // unresolved link supplies nothing and `dbAccess.c:378`'s
6735 // zeroed buffer stands.
6736 snap.display.get_or_insert_with(Default::default).units = link_meta
6737 .and_then(|m| m.units.as_deref())
6738 .map(crate::types::PvString::from)
6739 .unwrap_or_default();
6740 } else if !self.units_from_egu(rtype, &f) {
6741 snap.display.get_or_insert_with(Default::default).units = Default::default();
6742 }
6743 }
6744
6745 // C `get_precision`'s link arm, which the port had no arm for at all.
6746 // All five link-routing types seed `*pprecision = prec->prec` — the
6747 // record's own PREC, already in the metadata cache — and overwrite it
6748 // only when `dbGetPrecision` on the backing link SUCCEEDS
6749 // (`calcRecord.c:184-203`, `aSubRecord.c:323-348`). So an unresolved or
6750 // constant link means "leave the cache alone", which is the `None`
6751 // arm here.
6752 if slots.precision {
6753 let link_precision = link_meta.and_then(|m| m.precision);
6754 if link_backed.is_some()
6755 && let Some(precision) = link_precision
6756 {
6757 snap.display.get_or_insert_with(Default::default).precision = precision;
6758 }
6759 // C `get_precision`'s SHARED TAIL — `recGblGetPrec`
6760 // (`recGbl.c:119-144`), which every one of these bodies hands the
6761 // fields it did not name. For a field that can carry a precision
6762 // (`dbAccess.c:388-389` gates `DBR_PRECISION` on
6763 // `DBF_FLOAT`/`DBF_DOUBLE`) the tail does one thing: clamp a PREC
6764 // outside `0..=15` to 15. Applied here rather than per record so
6765 // "does this field take the tail" has one owner —
6766 // [`Self::precision_explicit_field`] — instead of thirty
6767 // `field_metadata_override`s that each have to remember it.
6768 //
6769 // Gated on the field being float or double so the tail runs
6770 // exactly where C's does: `dbAccess.c:388-389` refuses to call
6771 // `get_precision` at all for any other type, which is what keeps
6772 // `recGblGetPrec`'s integer arm (`*precision = 0`) unobservable —
6773 // it must stay unobservable here too.
6774 let field_type = self.static_field_type(field);
6775 if matches!(
6776 field_type,
6777 Some(crate::types::DbFieldType::Float | crate::types::DbFieldType::Double)
6778 ) && !Self::precision_explicit_field(
6779 rtype,
6780 &f,
6781 link_backed.is_some(),
6782 link_precision.is_some(),
6783 ) {
6784 let d = snap.display.get_or_insert_with(Default::default);
6785 d.precision = crate::server::recgbl::rec_gbl_get_prec(field_type, d.precision);
6786 }
6787 }
6788
6789 // C `get_control_double`'s last arm. No base record routes control
6790 // through a link: `dbGetControlLimits` has zero callers in all of
6791 // base, so unlike display this arm needs no link branch.
6792 if slots.control_double && !Self::control_explicit_field(rtype, field) {
6793 let (upper, lower) =
6794 match super::record_trait::control_default_arm(self.record.record_type()) {
6795 // `recGblGetControlDouble` → `getMaxRangeValues(field_type)`.
6796 // A type with no case in C's switch (STRING/MENU/DEVICE/links)
6797 // is written by nothing, leaving the `dbAccess.c:256` seed —
6798 // which is 0/0, exactly what `unwrap_or` supplies.
6799 super::record_trait::RsetDefaultArm::RecGblRange => {
6800 self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
6801 }
6802 // The slot exists but writes nothing here, so the same
6803 // `dbAccess.c:256` seed stands. Modelled as a value rather
6804 // than as `None`: C's option bit is ON (the slot is supplied
6805 // and returned 0), so the leaf IS served — carrying the seed.
6806 super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
6807 };
6808 snap.control = Some(super::super::snapshot::ControlInfo {
6809 upper_ctrl_limit: upper,
6810 lower_ctrl_limit: lower,
6811 });
6812 }
6813
6814 // C `get_graphic_double`'s last arm. Unlike control this one has a LINK
6815 // branch ahead of the recGbl call, so the three answers are: keep the
6816 // cache (listed on HOPR/LOPR), the link's limits, or the default arm.
6817 if slots.graphic_double && !Self::graphic_explicit_field(rtype, field) {
6818 let (upper, lower) = if link_backed.is_some() {
6819 // `dbGetGraphicLimits` on the backing link. A CONSTANT link has
6820 // no metadata getters and an unresolved one has nothing cached,
6821 // so in both cases the `dbAccess.c:216` 0/0 seed stands.
6822 link_meta
6823 .and_then(|m| m.graphic_limits)
6824 .map(|(lower, upper)| (upper, lower))
6825 .unwrap_or((0.0, 0.0))
6826 } else {
6827 match super::record_trait::graphic_default_arm(rtype) {
6828 super::record_trait::RsetDefaultArm::RecGblRange => {
6829 self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
6830 }
6831 super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
6832 }
6833 };
6834 let d = snap.display.get_or_insert_with(Default::default);
6835 d.upper_disp_limit = upper;
6836 d.lower_disp_limit = lower;
6837 }
6838
6839 // C `get_alarm_double`, BOTH arms. This branch owns the four limits
6840 // outright: `slots.alarm_double` is exactly the condition under which
6841 // `getProperties` assigns the four `valueAlarm.*Limit` leaves, so
6842 // whenever the leaves are served this assigns them.
6843 //
6844 // The explicit arm used to be left to the record-level metadata cache
6845 // (`populate_display_info`), whose `match rtype` covered only some of
6846 // the types that supply the slot. A type it missed reached the wire
6847 // with `snap.display == None` and the four leaves kept the NT's
6848 // structural 0 — measured: DFANOUT.VAL, SEL.VAL and SUB.VAL served 0
6849 // where C serves NaN. That made "which limits does VAL carry" depend on
6850 // a match arm existing somewhere else, which is the dual meaning this
6851 // single owner removes.
6852 if slots.alarm_double {
6853 let (hihi, high, low, lolo) = if Self::alarm_explicit_field(rtype, field) {
6854 explicit_alarm
6855 } else if link_backed.is_some() {
6856 // `dbGetAlarmLimits` on the backing link; `dbAccess.c:294`'s
6857 // four NaN stand when it supplies nothing.
6858 link_meta
6859 .and_then(|m| m.alarm_limits)
6860 .map(|(lolo, low, high, hihi)| (hihi, high, low, lolo))
6861 .unwrap_or_else(crate::server::recgbl::rec_gbl_get_alarm_double)
6862 } else {
6863 crate::server::recgbl::rec_gbl_get_alarm_double()
6864 };
6865 // The four alarm limits live on DisplayInfo because that mirrors
6866 // C's `dbr_gr_double` packing, which the CA encoder depends on.
6867 // Minting it here is safe: every other DisplayInfo field defaults
6868 // to the same value the `None` path already served.
6869 let d = snap.display.get_or_insert_with(Default::default);
6870 d.upper_alarm_limit = hihi;
6871 d.upper_warning_limit = high;
6872 d.lower_warning_limit = low;
6873 d.lower_alarm_limit = lolo;
6874 }
6875 }
6876
6877 /// The four limits C's `get_alarm_double` serves for the fields its rset
6878 /// lists — [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields).
6879 ///
6880 /// Read through [`Self::resolve_field`], the same unified accessor C's
6881 /// `prec->hihi` is. The port stores the eight alarm fields in one of two
6882 /// disjoint homes — `common.analog_alarm` for the types with the analog
6883 /// ladder (`ai`/`ao`/`calc`/…), the record's own struct for the types
6884 /// without it (`dfanout`/`sel`) — and `resolve_field` spans both. Reading
6885 /// the ladder slot directly instead would answer NaN for every `dfanout`
6886 /// and `sel` no matter how its HIHI/HHSV were set, because those two types
6887 /// have no slot at all.
6888 fn explicit_alarm_limits(&self, rtype: &str) -> (f64, f64, f64, f64) {
6889 let limit = |name: &str| {
6890 self.resolve_field(name)
6891 .and_then(|v| v.to_f64())
6892 .unwrap_or(0.0)
6893 };
6894 // The raw stored ordinal, NOT clamped to 0..=3: C tests `prec->hhsv`
6895 // for NONZERO, so an out-of-range severity still enables its limit.
6896 let severity = |name: &str| {
6897 self.resolve_field(name)
6898 .and_then(|v| v.to_f64())
6899 .unwrap_or(0.0) as i16
6900 };
6901 match super::record_trait::alarm_val_arm(rtype) {
6902 super::record_trait::AlarmValArm::Unconditional => {
6903 (limit("HIHI"), limit("HIGH"), limit("LOW"), limit("LOLO"))
6904 }
6905 super::record_trait::AlarmValArm::Gated => (
6906 gated(severity("HHSV"), limit("HIHI")),
6907 gated(severity("HSV"), limit("HIGH")),
6908 gated(severity("LSV"), limit("LOW")),
6909 gated(severity("LLSV"), limit("LOLO")),
6910 ),
6911 }
6912 }
6913
6914 /// The fields C's **`get_control_double`** answers with the record's own
6915 /// cached limits, rather than letting them fall to the `default:` arm.
6916 ///
6917 /// "VAL plus the seven alarm bands" is one type's list, not the shared
6918 /// one: it holds for `ai` (`aiRecord.c:267-288`), `ao`, `calc`, `calcout`,
6919 /// `longin`, `longout`, `int64in`, `int64out` and `sub`
6920 /// (`subRecord.c:272-292`) — the `_` arm — and for no other type. Every
6921 /// list below is transcribed from that type's own rset:
6922 ///
6923 /// * `aSub` (`aSubRecord.c:372-376`) is a bare `recGblGetControlDouble`:
6924 /// it lists NOTHING, VAL included.
6925 /// * `seq` (`seqRecord.c:342-353`) lists only DLYn and `bo`
6926 /// (`boRecord.c:310-318`) only HIGH — and both answer a LITERAL rather
6927 /// than the cache, so they come from
6928 /// [`Record::field_metadata_override`] (which runs after this routing
6929 /// and wins over the `default:` arm). Nothing of these two types keeps
6930 /// the cache, VAL included.
6931 /// * `dfanout` (`dfanoutRecord.c:197-213`) lists VAL and the three
6932 /// latches but NOT the four bands.
6933 /// * `sel` (`selRecord.c:203-235`) lists the eight plus `A`..`L` /
6934 /// `LA`..`LL`; `acalcout`/`scalcout` (`aCalcoutRecord.c:793-822`,
6935 /// `sCalcoutRecord.c:653-682`) list VAL and the four bands but NOT the
6936 /// latches, plus `A`..`L` / `PA`..`PL`.
6937 /// * `epid` (`epidRecord.c:263-287`) lists VAL, the four bands and CVAL on
6938 /// HOPR/LOPR; `motor` (`motorRecord.cc:3263-3308`) lists VAL and RBV on
6939 /// HLM/LLM.
6940 /// * the array types (`waveformRecord.c:268-289`, `aaiRecord.c:287-304`,
6941 /// `aaoRecord.c:292-309`, `compressRecord.c:487-502`,
6942 /// `histogramRecord.c:458-475`, `subArrayRecord.c:258-287`) list VAL
6943 /// alone on the cache — their other listed fields answer computed spans,
6944 /// so those too come from [`Record::field_metadata_override`].
6945 ///
6946 /// Fields whose listed case answers something OTHER than the record's
6947 /// cached limits are deliberately absent — `motor`'s DVAL/DRBV (DHLM/DLLM)
6948 /// and `epid`'s OVAL/P/I/D (DRVH/DRVL) have no override yet and so still
6949 /// take the `default:` arm.
6950 ///
6951 /// **Not** the other two slots' lists — see [`Self::alarm_explicit_field`]
6952 /// (smaller) and [`Self::graphic_explicit_field`] (larger, and cut short
6953 /// for different types). C's three rset arms are separate switches over
6954 /// separate field lists, so one shared predicate could only ever be right
6955 /// for one of them.
6956 fn control_explicit_field(rtype: &str, field: &str) -> bool {
6957 // The types that list nothing the cache can answer, VAL included.
6958 //
6959 // `tableRecord.c:795-810` and `mcaRecord.c:929-943` are the same
6960 // shape as `aSub`: a small named set (table's six user coordinates
6961 // `AX`..`Z`, mca's dead `BPTR` arm) and `recGblGetControlDouble` for
6962 // everything else — VAL and the alarm bands included. Both named sets
6963 // answer a literal rather than the record's HOPR/LOPR, so they come
6964 // from [`Record::field_metadata_override`] and nothing here keeps the
6965 // cache.
6966 if matches!(rtype, "aSub" | "seq" | "bo" | "table" | "mca") {
6967 return false;
6968 }
6969 if crate::server::database::is_value_field(field) {
6970 return true;
6971 }
6972 let f = field.to_ascii_uppercase();
6973 let bands: &[&str] = match rtype {
6974 "dfanout" => &["LALM", "ALST", "MLST"],
6975 "acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
6976 "waveform" | "aai" | "aao" | "compress" | "histogram" | "subArray" | "motor" => &[],
6977 _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
6978 };
6979 if bands.contains(&f.as_str()) {
6980 return true;
6981 }
6982 match rtype {
6983 // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
6984 "sel" => Self::calc_arg_field(&f, 12),
6985 "acalcout" | "scalcout" => {
6986 Self::calc_arg_field(&f, 12)
6987 || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
6988 }
6989 "epid" => f == "CVAL",
6990 "motor" => f == "RBV",
6991 _ => false,
6992 }
6993 }
6994
6995 /// The fields C's **`get_alarm_double`** lists explicitly — **VAL alone**,
6996 /// not the eight [`Self::control_explicit_field`] lists.
6997 ///
6998 /// Transcribed from every rset in base that supplies the slot. Most are a
6999 /// bare `if (dbGetFieldIndex(paddr) == indexof(VAL))` with every other
7000 /// field falling to `recGblGetAlarmDouble` (`recGbl.c:155-162`, four NaN):
7001 /// `aiRecord.c:294`, `aoRecord.c:368`, `dfanoutRecord.c:218`,
7002 /// `int64inRecord.c:239`, `int64outRecord.c:283`, `longinRecord.c:244`,
7003 /// `longoutRecord.c:300`, `selRecord.c:241`. Three do NOT have that shape
7004 /// and reach the same NaN for a band field the long way —
7005 /// `calcRecord.c:257-280`, `calcoutRecord.c:532-555` and
7006 /// `subRecord.c:294-317` hoist the index into a `fieldIndex` local, test
7007 /// VAL, and otherwise try `get_linkNumber` first, so only a field that is
7008 /// neither VAL nor an `INPx` slot falls through to `recGblGetAlarmDouble`
7009 /// (`subRecord.c:313-314`); an `INPx` field takes that LINK's alarm limits
7010 /// through `dbGetAlarmLimits`, not the four NaN.
7011 ///
7012 /// So `.HIHI` serves VAL's *control* limits but NOT VAL's *alarm* limits —
7013 /// the band fields' four alarm limits are the recGbl NaN. Routing both
7014 /// slots off one VAL-class predicate is what put the record's own
7015 /// valueAlarm limits on all eight.
7016 ///
7017 /// Which fields each type lists — and the fact that some list none, and
7018 /// that `motor` lists two — is one per-type table,
7019 /// [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields);
7020 /// what that listed arm ANSWERS is its twin,
7021 /// [`alarm_val_arm`](super::record_trait::alarm_val_arm). Keeping the two
7022 /// questions in one place is what lets this predicate stay a pure
7023 /// membership test.
7024 fn alarm_explicit_field(rtype: &str, field: &str) -> bool {
7025 super::record_trait::alarm_explicit_fields(rtype)
7026 .iter()
7027 .any(|f| field.eq_ignore_ascii_case(f))
7028 }
7029
7030 /// `A`..`A+n-1` (a single letter) or `LA`..`LA+n-1` — C's calc-family
7031 /// argument fields, addressed by index range rather than by name.
7032 ///
7033 /// `calcRecord.c:161-167` / `calcoutRecord.c:417-423` test
7034 /// `idx >= indexof(A) && idx < indexof(A) + CALCPERFORM_NARGS`, and the dbd
7035 /// declares those `CALCPERFORM_NARGS` fields contiguously as the single
7036 /// letters `A`..`U` (`postfix.h:29` = 21, `calcRecord.dbd.pod:801-985`), so
7037 /// the index range and the letter range are the same set.
7038 fn calc_arg_field(field: &str, nargs: u8) -> bool {
7039 let last = b'A' + nargs - 1;
7040 match field.as_bytes() {
7041 [c] => c.is_ascii_uppercase() && *c <= last,
7042 [b'L', c] => c.is_ascii_uppercase() && *c <= last,
7043 _ => false,
7044 }
7045 }
7046
7047 /// The fields C's **`get_graphic_double`** answers with the record's own
7048 /// `HOPR`/`LOPR` — which is exactly what the VAL metadata cache already
7049 /// holds, so routing must leave them on it.
7050 ///
7051 /// The third membership question, and a third distinct set: the alarm arm
7052 /// lists VAL alone and the control arm lists the eight, but graphic lists
7053 /// the eight PLUS a per-type tail, and two types cut it short.
7054 ///
7055 /// * base analog (`aiRecord.c:244-265`, `aoRecord.c:316-339`,
7056 /// `calcRecord.c:187-212`, `calcoutRecord.c:452-484`,
7057 /// `subRecord.c:242-270`, `selRecord.c:181-201`,
7058 /// `dfanoutRecord.c:181-195`, `longinRecord.c:190-204`,
7059 /// `longoutRecord.c`, `int64inRecord.c:196-210`, `int64outRecord.c`):
7060 /// the eight.
7061 /// * `acalcout`/`scalcout` (`aCalcoutRecord.c:1046`, `sCalcoutRecord.c:906`)
7062 /// list only VAL/HIHI/HIGH/LOW/LOLO — NOT LALM/ALST/MLST — plus the
7063 /// `A`..`L` and `PA`..`PL` ranges.
7064 /// * `sel` (`selRecord.c:193-196`) also lists `A`..`L` / `LA`..`LL`, via a
7065 /// GCC case range. It has no link arm at all, so its args are HOPR/LOPR
7066 /// where calc's identically-named ones are link-backed.
7067 /// * the SVAL family (`aiRecord.c:253`, `longinRecord.c`,
7068 /// `int64inRecord.c:205`), `ao`'s `OVAL`/`PVAL`/`IVOV`
7069 /// (`aoRecord.c:322-338`), and `compress`'s `IHIL`/`ILIL`
7070 /// (`compressRecord.c:474-476`).
7071 ///
7072 /// Fields whose graphic case answers something OTHER than HOPR/LOPR are
7073 /// NOT here — they cannot keep the cache and are supplied by
7074 /// [`Record::field_metadata_override`] instead (`histogram` WDTH,
7075 /// `subArray`/`waveform`/`aai`/`aao` index fields, `seq` DLYn,
7076 /// `calcout` ODLY).
7077 fn graphic_explicit_field(rtype: &str, field: &str) -> bool {
7078 // The two types that do not list VAL. Neither switch is keyed on
7079 // VAL at all: `seqRecord.c:282-297` keys on `index - indexof(DLY0)`,
7080 // so every field BELOW DLY0 — VAL included — reaches
7081 // `recGblGetGraphicDouble`; `aSubRecord.c:350-368` keys on the link
7082 // number, and VAL is neither an inlink nor an outlink, so it falls out
7083 // having written nothing (the `graphic_default_arm` Seed).
7084 //
7085 // Measured: `SEQ.VAL` served display 0/0 — the empty VAL cache — where
7086 // C serves the DBF_LONG range.
7087 //
7088 // `tableRecord.c:778-792` and `mcaRecord.c:910-927` key on a named set
7089 // too — table's `AX`..`Z` window, mca's `DTIM`/`IDTIM` percent scale
7090 // and dead `BPTR` arm — and hand every other field, VAL included, to
7091 // `recGblGetGraphicDouble`. Both named sets answer literals through
7092 // [`Record::field_metadata_override`], so neither type keeps the cache.
7093 if matches!(rtype, "seq" | "aSub" | "table" | "mca") {
7094 return false;
7095 }
7096 if crate::server::database::is_value_field(field) {
7097 return true;
7098 }
7099 let f = field.to_ascii_uppercase();
7100 let bands: &[&str] = match rtype {
7101 "acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
7102 // `swaitRecord.c:597-606` is a bare `pfield == &pwait->val` test,
7103 // so its `ALST`/`MLST` take `recGblGetGraphicDouble` — and swait
7104 // has no HIHI/HIGH/LOW/LOLO to ask about.
7105 "swait" => &[],
7106 _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
7107 };
7108 if bands.contains(&f.as_str()) {
7109 return true;
7110 }
7111 match rtype {
7112 "ai" | "longin" | "int64in" => f == "SVAL",
7113 "ao" => matches!(f.as_str(), "OVAL" | "PVAL" | "IVOV"),
7114 "compress" => matches!(f.as_str(), "IHIL" | "ILIL"),
7115 // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
7116 "sel" => Self::calc_arg_field(&f, 12),
7117 // A..L and PA..PL, both to HOPR/LOPR.
7118 "acalcout" | "scalcout" => {
7119 Self::calc_arg_field(&f, 12)
7120 || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
7121 }
7122 // `epidRecord.c:238-248` names CVAL alongside VAL and the four
7123 // bands — the same list its `get_control_double` (`:263-273`) has
7124 // and this predicate did not.
7125 "epid" => f == "CVAL",
7126 _ => false,
7127 }
7128 }
7129
7130 /// The fields C's **`get_precision`** answers without reaching
7131 /// `recGblGetPrec` — the fourth membership question, and a fourth distinct
7132 /// set.
7133 ///
7134 /// Every `get_precision` in base and in the ported modules is the same
7135 /// two-part body: name some fields and answer them outright, hand the rest
7136 /// to `recGblGetPrec` (`recGbl.c:119-144`). For a field that can carry a
7137 /// precision at all — `dbAccess.c:388-389` gates `DBR_PRECISION` on
7138 /// `DBF_FLOAT`/`DBF_DOUBLE` — that shared tail does exactly one thing:
7139 /// clamp an out-of-range `PREC` to 15. So this predicate is what decides
7140 /// whether `caput REC.PREC 20` reaches a client as 20 or as 15, and the
7141 /// two answers differ per FIELD within one record: `ai.VAL` returns before
7142 /// the tail and serves 20, `ai.HOPR` falls into it and serves 15
7143 /// (`aiRecord.c:234-242`).
7144 ///
7145 /// The literal arms (`bo.HIGH`, `seq.DLYn`, `calcout.ODLY`,
7146 /// `histogram.SDEL`, `motor.VERS`, …) need no entry: they are
7147 /// [`Record::field_metadata_override`]s, which run after this and win, and
7148 /// every literal C uses is already inside `0..=15`.
7149 ///
7150 /// `link_supplied` is `dbGetPrecision`'s status on the backing link, and
7151 /// only `seq` reads it — see the `link_backed` arm.
7152 fn precision_explicit_field(
7153 rtype: &str,
7154 field: &str,
7155 link_backed: bool,
7156 link_supplied: bool,
7157 ) -> bool {
7158 match rtype {
7159 // No `recGblGetPrec` in the body at all: every field keeps PREC,
7160 // `ODLY` its literal 3 (`swaitRecord.c:583-595`).
7161 "swait" => return true,
7162 // `if (fieldIndex == VERS) 2; else if (fieldIndex >= VAL) prec;
7163 // else recGblGetPrec(...) /* Field is in dbCommon */`
7164 // (`transformRecord.c:752-767`, `scalerRecord.c:728-741`,
7165 // `tableRecord.c:814-828`). Only fields BELOW `VAL` — dbCommon —
7166 // reach the tail, and dbCommon declares no `DBF_FLOAT`/`DBF_DOUBLE`
7167 // field, so nothing that can be served ever gets there.
7168 "transform" | "scaler" | "table" => return true,
7169 // The same split inverted: `if (pfield < &pR->val) return 0;` then
7170 // `recGblGetPrec` (`sseqRecord.c:810-822`). Here it is the RECORD's
7171 // own fields — every `DLYn`, i.e. everything that can be served —
7172 // that reaches the tail, and the exempt half is the dbCommon one
7173 // that cannot.
7174 "sseq" => return false,
7175 // Falls through on every field, `DLY` included: it takes `DPREC`
7176 // instead of `PREC` and is clamped anyway
7177 // (`throttleRecord.c:451-464`).
7178 "throttle" => return false,
7179 _ => {}
7180 }
7181 if link_backed {
7182 // `if (linkNumber >= 0) { if (dbGetPrecision(...) == 0) *p = ...; }
7183 // else recGblGetPrec(...)` — the link arm returns whether or not
7184 // the link answered (`calcRecord.c:194-201`,
7185 // `calcoutRecord.c:461-468`, `subRecord.c:231-238`,
7186 // `aSubRecord.c:330-346`).
7187 //
7188 // `seq` is the exception, and it is why this takes a second
7189 // argument: its `case 2:` returns ONLY when `dbGetPrecision`
7190 // succeeded, and a `DOn` over a constant `DOLn` falls out of the
7191 // switch into the shared tail (`seqRecord.c:310-317`).
7192 return if rtype == "seq" { link_supplied } else { true };
7193 }
7194 if crate::server::database::is_value_field(field) {
7195 // `*precision = prec->prec; if (VAL) return 0;` — the common
7196 // shape (`aiRecord.c:238-239`, `aaiRecord.c:262-264`,
7197 // `aaoRecord.c:267-269`, `aoRecord.c:304-312`,
7198 // `calcRecord.c:190-192`, `calcoutRecord.c:457-459`,
7199 // `compressRecord.c:464-466`, `dfanoutRecord.c:169-171`,
7200 // `selRecord.c:152-155`, `subArrayRecord.c:221-223`,
7201 // `subRecord.c:227-229`, `waveformRecord.c:239-241`,
7202 // `sCalcoutRecord.c:616-618`, `aCalcoutRecord.c:756-758`,
7203 // `epidRecord.c:230-233`).
7204 //
7205 // The five that do NOT name VAL: `aSub` keys on the link number
7206 // only and VAL is neither an inlink nor an outlink
7207 // (`aSubRecord.c:330-346`); `mca` names `BPTR` and the four
7208 // calibration fields (`mcaRecord.c:898-905`); `seq` keys on
7209 // `index - indexof(DLY0)`, leaving VAL below the switch
7210 // (`seqRecord.c:305-317`); `histogram`'s switch has no VAL case
7211 // (`histogramRecord.c:423-436`); `motor` reaches the tail from
7212 // `default:` (`motorRecord.cc:3319-3335`).
7213 return !matches!(rtype, "aSub" | "mca" | "seq" | "histogram" | "motor");
7214 }
7215 let f = field.to_ascii_uppercase();
7216 match rtype {
7217 // `case VAL: case OVAL: case PVAL: break;` (`aoRecord.c:305-312`).
7218 "ao" => matches!(f.as_str(), "OVAL" | "PVAL"),
7219 // `if (fieldIndex == VAL || fieldIndex == CVAL) return 0;`
7220 // (`epidRecord.c:231-232`).
7221 "epid" => f == "CVAL",
7222 // The five cases that answer `prec->prec`
7223 // (`histogramRecord.c:424-430`). `SDEL` is the sixth case and a
7224 // literal, so the override covers it. Note that histogram's tail
7225 // gets an UNSEEDED `precision` — the record never assigns
7226 // `prec->prec` before the switch — so C answers `dbAccess.c:387`'s
7227 // zeroed buffer there, not a clamped PREC; `SDLY` is histogram's
7228 // only such field and carries its own `Some(0)` override.
7229 "histogram" => matches!(f.as_str(), "ULIM" | "LLIM" | "SGNL" | "SVAL" | "WDTH"),
7230 // `BPTR` returns, and the four calibration fields answer a literal
7231 // 6 (`mcaRecord.c:898-905`).
7232 "mca" => matches!(f.as_str(), "BPTR" | "CALO" | "CALS" | "CALQ" | "TTH"),
7233 // `case RRBV: case RMP: case REP: *precision = 0; break;` and
7234 // `case VERS: *precision = 2; break;` — both `break` past the
7235 // switch to the bare `return`, never to `recGblGetPrec`
7236 // (`motorRecord.cc:3322-3330`).
7237 "motor" => matches!(f.as_str(), "RRBV" | "RMP" | "REP" | "VERS"),
7238 // `sel` is deliberately absent: its `A`..`L` / `LA`..`LL` loop
7239 // compares `paddr->pfield` against `&pvalue` and `&plvalue` — the
7240 // addresses of the two LOCAL pointers, not the fields they walk
7241 // (`selRecord.c:159-160`) — so the test never matches and every
7242 // `sel` argument reaches `recGblGetPrec`. Transcribed as C
7243 // behaves, not as it reads.
7244 _ => false,
7245 }
7246 }
7247
7248 /// The field's type as the **dbd declares it**, which is the only type
7249 /// `recGblGetPrec` / `getMaxRangeValues` ever see.
7250 ///
7251 /// C reads `pdbFldDes->field_type` (`recGbl.c:127`, `:151`, `:169`) — the
7252 /// STATIC descriptor — so a `cvt_dbaddr` retype (the port's
7253 /// `runtime_typed`, DBF_NOACCESS in the dbd) never reaches the switch and
7254 /// the switch has no case for it. `None` reproduces that: no case, no
7255 /// write.
7256 fn static_field_type(&self, field: &str) -> Option<crate::types::DbFieldType> {
7257 let desc = self.field_desc(field)?;
7258 (!desc.runtime_typed).then_some(desc.dbf_type)
7259 }
7260
7261 /// `recGblGetGraphicDouble` / `recGblGetControlDouble` for `field` — the
7262 /// same `getMaxRangeValues` table both C entry points share
7263 /// (`recGbl.c:146-171`). `None` where C's switch has no case (STRING,
7264 /// MENU, DEVICE, NOACCESS, links), which writes nothing.
7265 ///
7266 /// `declared_dbf` is C's `pdbFldDes->field_type` verbatim. Deciding
7267 /// menu-ness from `desc.menu` instead asked whether the field carries its
7268 /// own inline choice list, which `SCAN` and `DTYP` do not — their choices
7269 /// come from the scan table and the device registry — so both reported a
7270 /// `DBF_USHORT` range of 65535/0 to `gft` where C reports 0/0.
7271 fn rec_gbl_range_for(&self, field: &str) -> Option<(f64, f64)> {
7272 let desc = self.field_desc(field)?;
7273 crate::server::recgbl::rec_gbl_get_graphic_double(desc.declared_dbf)
7274 }
7275
7276 /// Notify subscribers from a snapshot (call outside lock).
7277 /// Each entry carries its own posting mask: only subscribers whose
7278 /// mask intersects that field's mask are notified, and the delivered
7279 /// [`MonitorEvent`] reports that intersection — C
7280 /// `db_post_events(prec, &field, mask)` per-field granularity, then
7281 /// `pLog->mask = caEventMask & pevent->select` per subscriber.
7282 ///
7283 /// `backing` is the link metadata the process cycle resolved for this
7284 /// batch, at its own no-lock-held point. See [`Self::make_monitor_snapshot`]
7285 /// for why it has no default.
7286 pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot, backing: LinkBacking<'_>) {
7287 use crate::server::database::filters::FilteredMonitorEvent;
7288
7289 // Nothing is subscribed to any field, so no post below can land; the
7290 // per-field lookup is a hash of the field name per post per cycle to
7291 // find that out. Same gate as `collect_subscriber_posts`.
7292 if self.subscribers.is_empty() {
7293 return;
7294 }
7295
7296 // Same ambient-origin inheritance as `notify_field_with_origin`:
7297 // a process cycle driven by an in-process writer's put tags its
7298 // posts with the writer's origin, so the writer's own filtered
7299 // subscriptions do not hear its cascade. 0 outside any scope.
7300 let origin = ambient_write_origin();
7301
7302 for (field, value, posting_mask) in snapshot.iter() {
7303 let posting_mask = *posting_mask;
7304 if let Some(subs) = self.subscribers.get(field.as_ref()) {
7305 // Build a full snapshot once per field (with display
7306 // metadata) and hand every subscriber a reference to that one
7307 // snapshot — C posts the fixed-size `db_field_log` and reads
7308 // the wide value by reference at delivery (`camessage.c:516`),
7309 // so a per-subscriber deep copy of an array value is a port
7310 // deviation, not parity.
7311 let Some(snap) = self.make_monitor_snapshot(field, value.clone(), backing) else {
7312 continue;
7313 };
7314 let mon_snap = Arc::new(snap);
7315 for sub in subs {
7316 // Paused subscriber (`db_event_disable`): suppress at
7317 // the source — no delivery, no coalesce.
7318 if !sub.active {
7319 continue;
7320 }
7321 // Gate and narrow in one step through
7322 // `Subscriber::delivered_mask`, which owns C's
7323 // twice-used `caEventMask & pevent->select`. An empty
7324 // posting mask means nothing changed and ands to zero,
7325 // so it skips there rather than needing a check here.
7326 if let Some(mask) = sub.delivered_mask(posting_mask) {
7327 let event = MonitorEvent {
7328 snapshot: mon_snap.clone(),
7329 origin,
7330 mask,
7331 };
7332 // Server-side filter chain (3.15.7). Empty chain
7333 // is identity, so no behaviour change for the
7334 // common no-filter case.
7335 let filtered = if sub.filters.is_empty() {
7336 Some(event)
7337 } else {
7338 sub.filters
7339 .apply(FilteredMonitorEvent::new(event))
7340 .map(|fe| fe.event)
7341 };
7342 let Some(event) = filtered else {
7343 continue;
7344 };
7345 // C `db_queue_event_log`: append, or replace this
7346 // monitor's last queued entry in place when the queue
7347 // is in flow control or nearly full. The queue owns
7348 // that decision and counts the displaced value.
7349 sub.post(event);
7350 }
7351 }
7352 }
7353 }
7354 }
7355
7356 /// Notify subscribers of a specific field, filtering by event mask.
7357 ///
7358 /// The last wrapper that still answers for its callers: `none()` here is a
7359 /// claim that no caller of this function names a link-backed field, and it
7360 /// is made once for 25 production call sites rather than at each of them.
7361 /// [`Self::notify_field_backed`] is the form for a caller that cannot make
7362 /// that claim.
7363 pub fn notify_field(&mut self, field: &str, mask: crate::server::recgbl::EventMask) {
7364 self.notify_field_with_origin(field, mask, 0, LinkBacking::none());
7365 }
7366
7367 /// [`Self::notify_field`] for a poster that may name a link-backed field
7368 /// and has resolved its backing.
7369 pub fn notify_field_backed(
7370 &mut self,
7371 field: &str,
7372 mask: crate::server::recgbl::EventMask,
7373 backing: LinkBacking<'_>,
7374 ) {
7375 self.notify_field_with_origin(field, mask, 0, backing);
7376 }
7377
7378 /// C `db_post_events(precord, NULL, DBE_ALARM)`: post a record-wide
7379 /// alarm event. Delivers to every subscriber on any field whose mask
7380 /// includes DBE_ALARM, each carrying its own monitored field's current
7381 /// value (the per-field `notify_field` already filters by mask
7382 /// intersection). Used by the alarm-acknowledge (ACKT/ACKS) put path so
7383 /// an alarm-mask monitor on any field observes the acknowledgement.
7384 pub fn notify_record_alarm(&mut self, backing: LinkBacking<'_>) {
7385 // Every subscribed field, so a client monitoring a link-backed one
7386 // (`CALC.A`) is in the set — this poster takes a backing for that
7387 // reason and not because the alarm itself is link-backed.
7388 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
7389 for field in fields {
7390 self.notify_field_backed(&field, crate::server::recgbl::EventMask::ALARM, backing);
7391 }
7392 }
7393
7394 /// Notify subscribers with an origin tag for self-write filtering.
7395 ///
7396 /// This is C `db_post_events(precord, pfield, mask)` for one field, and —
7397 /// per the `last_posted` contract — the poster that advances the
7398 /// already-published value when `mask` carries a value class. Taking
7399 /// `&mut self` is what makes that unbypassable: there is no way to publish
7400 /// a field's value through the framework without the change detector
7401 /// learning that it was published.
7402 ///
7403 /// `backing` is the link metadata the put path resolved for this post, at
7404 /// its own no-lock-held point. See [`Self::make_monitor_snapshot`] for why
7405 /// it has no default.
7406 pub fn notify_field_with_origin(
7407 &mut self,
7408 field: &str,
7409 mask: crate::server::recgbl::EventMask,
7410 origin: u64,
7411 backing: LinkBacking<'_>,
7412 ) {
7413 use crate::server::database::filters::FilteredMonitorEvent;
7414 // A poster that carries no origin of its own inherits the ambient
7415 // one (0 outside any scope): this is how every post inside an
7416 // SNL writer's synchronous put+process cascade gets the writer's
7417 // tag without threading a parameter through the whole processing
7418 // machinery. An explicit origin always wins.
7419 let origin = if origin != 0 {
7420 origin
7421 } else {
7422 ambient_write_origin()
7423 };
7424 // A value-class post publishes the field to its DBE_VALUE/DBE_LOG
7425 // subscribers, exactly as C's `dbPut` does for the put field
7426 // (dbAccess.c:1414) — record it so the next process cycle's
7427 // change-detection loop does not publish the same value a second
7428 // time. An alarm-only / property-only post publishes no value, so it
7429 // leaves the map alone.
7430 let publishes_value = mask.intersects(
7431 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
7432 );
7433 let subs = self.subscribers.get(field).filter(|subs| !subs.is_empty());
7434 if subs.is_none() && !publishes_value {
7435 return;
7436 }
7437 let Some(value) = self.resolve_field(field) else {
7438 return;
7439 };
7440 // With no subscriber the post is value-class (the return above), and
7441 // the value goes straight to the owner.
7442 let Some(subs) = subs else {
7443 self.record_value_post(field, value);
7444 return;
7445 };
7446 let posted = publishes_value.then(|| value.clone());
7447 // A refused post publishes nothing, so it must not advance
7448 // `last_posted` either, or the next cycle's change detector would
7449 // treat the value as already delivered and the subscriber would never
7450 // see it: `posted` dies with this return.
7451 let Some(snap) = self.make_monitor_snapshot(field, value, backing) else {
7452 return;
7453 };
7454 {
7455 let mon_snap = Arc::new(snap);
7456 for sub in subs {
7457 // Paused subscriber (`db_event_disable`): suppress at
7458 // the source — no delivery, no coalesce.
7459 if !sub.active {
7460 continue;
7461 }
7462 // Same single owner as the snapshot path: gate and
7463 // narrow are one operation (C `dbEvent.c:896-900`).
7464 if let Some(mask) = sub.delivered_mask(mask) {
7465 let event = MonitorEvent {
7466 snapshot: mon_snap.clone(),
7467 origin,
7468 mask,
7469 };
7470 // Server-side filter chain (3.15.7). Empty
7471 // chain (the default for every subscriber
7472 // until a `.{filter:opts}` PV-name suffix
7473 // parser wires one in) is the identity, so
7474 // existing subscribers see no behaviour
7475 // change. A filter returning `None` silences
7476 // this event for this subscriber only.
7477 let filtered = if sub.filters.is_empty() {
7478 Some(event)
7479 } else {
7480 sub.filters
7481 .apply(FilteredMonitorEvent::new(event))
7482 .map(|fe| fe.event)
7483 };
7484 let Some(event) = filtered else {
7485 continue;
7486 };
7487 // Same single post owner as the snapshot path.
7488 sub.post(event);
7489 }
7490 }
7491 }
7492 // The value is now published: hand it to the `last_posted` owner so
7493 // the change detector does not publish it again. Delivery to any
7494 // individual subscriber may have been filtered out, and the field may
7495 // have no subscriber at all, exactly as C's `db_post_events` may find
7496 // an empty `mlis` — C still leaves `monitor()`'s `*_lst` state
7497 // advanced by the cycle that ran, so the post, not the delivery, is
7498 // what counts.
7499 if let Some(value) = posted {
7500 self.record_value_post(field, value);
7501 }
7502 }
7503
7504 /// Add a subscriber for a specific field. Returns `None` when the
7505 /// per-field subscriber cap (`EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`)
7506 /// is reached. the parallel cap on `ProcessVariable`
7507 /// defends against a misbehaving client opening many
7508 /// MONITOR ops against one shared PV; the same defence is needed
7509 /// for record fields, which the CA server's
7510 /// `ChannelTarget::RecordField` path lands on.
7511 pub fn add_subscriber(
7512 &mut self,
7513 field: &str,
7514 sid: u32,
7515 data_type: DbFieldType,
7516 mask: u16,
7517 ) -> Option<EventReader> {
7518 self.add_subscriber_on(&EventUser::new(), field, sid, data_type, mask)
7519 }
7520
7521 /// Add a field subscriber whose events queue on `user`'s event queue —
7522 /// C `db_add_event` with the circuit's `event_user` as context. Every
7523 /// subscription on one CA circuit shares that queue and therefore its
7524 /// `nDuplicates`, so a duplicate queued for one of them releases the
7525 /// EVENTS_OFF drain for all of them (`dbEvent.c:947`). In-process consumers
7526 /// use [`Self::add_subscriber`], which gives each its own `event_user`.
7527 pub fn add_subscriber_on(
7528 &mut self,
7529 user: &EventUser,
7530 field: &str,
7531 sid: u32,
7532 data_type: DbFieldType,
7533 mask: u16,
7534 ) -> Option<EventReader> {
7535 let cap = crate::server::pv::max_subscribers_per_pv();
7536 // A destroyed record takes no new monitor, so `destroyed => no
7537 // subscribers` survives a CREATE_CHAN + EVENT_ADD that races the
7538 // removal. Both are `&mut self`, so there is no window between them.
7539 if self.destroyed {
7540 return None;
7541 }
7542 let field_str = field.to_string();
7543 let bucket = self.subscribers.entry(field_str.clone()).or_default();
7544 // Reap rows whose consumer is gone before
7545 // counting against the cap. A record field whose value
7546 // never changes (e.g. a quasi-static catalog field) never
7547 // triggers `notify_field_with_origin`'s retain-filter, so
7548 // a long-lived subscribe-disconnect storm could pin the
7549 // bucket at `cap` worth of dead rows and lock out
7550 // genuine new subscribers.
7551 bucket.retain(|s| !s.is_closed());
7552 if bucket.len() >= cap {
7553 tracing::warn!(
7554 record = %self.name,
7555 field = %field_str,
7556 live = bucket.len(),
7557 cap,
7558 "record field subscriber cap reached, refusing add_subscriber"
7559 );
7560 return None;
7561 }
7562 let (sink, reader) = crate::server::event_queue::attach(user, sid);
7563 bucket.push(Subscriber {
7564 sid,
7565 data_type,
7566 mask,
7567 sink,
7568 filters: crate::server::database::filters::FilterChain::new(),
7569 active: true,
7570 });
7571 // A field with no `last_posted` entry has never been published: seed it
7572 // with the value the client is handed in the EVENT_ADD response, so the
7573 // first process cycle does not treat it as changed. An existing entry
7574 // is left alone — every post advances it whether or not anyone was
7575 // subscribed, so it is what was last published, and a change since
7576 // then is still owed to the field's other subscribers.
7577 if !self.last_posted.contains_key(&field_str) {
7578 if let Some(val) = self.resolve_field(&field_str) {
7579 self.last_posted.insert(field_str, val);
7580 }
7581 }
7582 Some(reader)
7583 }
7584
7585 /// Attach a filter to the most recently added subscriber for
7586 /// `field`. Returns `false` when no subscriber exists yet on that
7587 /// field (call `add_subscriber` first). The CA / PVA channel-name
7588 /// parsers will use this once `.{filter:opts}` syntax is wired.
7589 /// Tests can also use it directly to compose filter chains.
7590 pub fn attach_filter_to_last_subscriber(
7591 &mut self,
7592 field: &str,
7593 filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
7594 ) -> bool {
7595 if let Some(bucket) = self.subscribers.get_mut(field) {
7596 if let Some(sub) = bucket.last_mut() {
7597 sub.filters.push(filter);
7598 return true;
7599 }
7600 }
7601 false
7602 }
7603
7604 /// Remove a subscriber by subscription ID from all fields.
7605 pub fn remove_subscriber(&mut self, sid: u32) {
7606 for subs in self.subscribers.values_mut() {
7607 subs.retain(|s| s.sid != sid);
7608 }
7609 }
7610
7611 /// Destroy this record: drop every field monitor and refuse every future
7612 /// one, and drop every link target handle it holds. The record-backed
7613 /// half of the rule [`crate::server::pv::ProcessVariable::destroy`]
7614 /// states for simple PVs, so one sweep in a server closes both kinds of
7615 /// channel; and the one place a record lets go of the records its links
7616 /// resolved to, so a destroyed record keeps no other alive. Returns
7617 /// `true` for the call that performed the transition.
7618 pub(crate) fn destroy(&mut self) -> bool {
7619 let first = !self.destroyed;
7620 self.destroyed = true;
7621 self.subscribers.clear();
7622 for entry in self.parsed_inputs.iter_mut().flatten() {
7623 entry.release_target();
7624 }
7625 first
7626 }
7627
7628 /// Drop every link target handle that names `cell` — the other half of
7629 /// the rule [`Self::destroy`] keeps: a handle never outlives its
7630 /// target's map entry. The remover calls it on every record left in the
7631 /// map once the entry is gone, so a record that never processes again
7632 /// does not keep the removed one alive.
7633 pub(crate) fn release_link_targets_to(&mut self, cell: &Arc<RecordCell>) {
7634 for entry in self.parsed_inputs.iter_mut().flatten() {
7635 if entry.targets(cell) {
7636 entry.release_target();
7637 }
7638 }
7639 }
7640
7641 /// Whether `Self::destroy` has run.
7642 pub fn is_destroyed(&self) -> bool {
7643 self.destroyed
7644 }
7645
7646 /// Pause / resume one subscriber's event flow at the source
7647 /// (`db_event_disable` / `db_event_enable`). `active == false`
7648 /// suppresses every subsequent post to this subscriber, so the record stops
7649 /// doing per-event work for it. Entries already queued stay queued and are
7650 /// still delivered, exactly as in C: `db_event_disable` only unlinks the
7651 /// subscription from the record's monitor list (`dbEvent.c:524-535`) and
7652 /// never reaches into the event queue. No-op if no subscriber has this
7653 /// `sid`. The caller holds the record write lock, so this is exclusive with
7654 /// the read-locked post paths that consult `Subscriber::active`.
7655 pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
7656 for subs in self.subscribers.values_mut() {
7657 for sub in subs.iter_mut() {
7658 if sub.sid == sid {
7659 sub.active = active;
7660 }
7661 }
7662 }
7663 }
7664
7665 /// Clean up subscriber rows whose consumer is gone.
7666 pub fn cleanup_subscribers(&mut self) {
7667 for subs in self.subscribers.values_mut() {
7668 subs.retain(|s| !s.is_closed());
7669 }
7670 }
7671}
7672
7673/// C `recGblCheckDeadband` (recGbl.c:345-370), spelled as C spells it:
7674///
7675/// ```c
7676/// double delta = 0;
7677/// if (finite(newval) && finite(*poldval)) {
7678/// delta = *poldval - newval;
7679/// if (delta < 0.0) delta = -delta;
7680/// }
7681/// else if (!isnan(newval) != !isnan(*poldval) ||
7682/// !isinf(newval) != !isinf(*poldval)) delta = epicsINF;
7683/// else if (isinf(newval) && newval != *poldval) delta = epicsINF;
7684/// if (delta > deadband) { *monitor_mask |= add_mask; *poldval = newval; }
7685/// ```
7686///
7687/// The `delta = 0` initialiser is load-bearing: the pairs no branch matches
7688/// — both NaN, or two same-signed infinities — reach `0 > deadband`, so they
7689/// fire only for a negative deadband and otherwise leave `*poldval` alone.
7690/// That is why the whole rule has to stay a single `delta > deadband` rather
7691/// than a chain of early returns, and why NaN cannot be read as a marker
7692/// here: C compares a NaN `*poldval` against a NaN `newval` by these rules
7693/// and finds them unchanged. `dbnd.c parse_ok` relies on exactly that when it
7694/// seeds its own `last` to `epicsNAN`.
7695///
7696/// `oldval` is `None` for the record types that carry no MLST/ALST cell to
7697/// hold a last-posted value; nothing was posted, so the first post is
7698/// unconditional. C has no such state — its MLST is a plain double the record
7699/// type initialises (0 for `calc` and `sel`, `prec->val` for `ai`,
7700/// aiRecord.c:129-130).
7701pub(crate) fn check_deadband(newval: f64, oldval: Option<f64>, deadband: f64) -> bool {
7702 let Some(oldval) = oldval else {
7703 return true;
7704 };
7705 let delta = if newval.is_finite() && oldval.is_finite() {
7706 (oldval - newval).abs()
7707 } else if newval.is_nan() != oldval.is_nan() || newval.is_infinite() != oldval.is_infinite() {
7708 // One is NaN or +/-inf and the other is not.
7709 f64::INFINITY
7710 } else if newval.is_infinite() && newval != oldval {
7711 // One is +inf, the other -inf.
7712 f64::INFINITY
7713 } else {
7714 0.0
7715 };
7716 delta > deadband
7717}
7718
7719#[cfg(test)]
7720mod device_menu_marking_tests {
7721 use super::*;
7722 use crate::server::records::ai::AiRecord;
7723 use crate::server::records::calc::CalcRecord;
7724 use crate::server::records::mbbo::MbboRecord;
7725
7726 /// C `dbAccess.c:176-179`: a `DBF_DEVICE` field whose record type declares
7727 /// no device support has `pfldDes->ftPvt == NULL` and takes `goto nostrs`,
7728 /// which clears `DBR_ENUM_STRS` — the client is sent NO choice list.
7729 ///
7730 /// `calc` declares no `device()` line, so QSRV2 omits `value.choices` on
7731 /// `CALC.DTYP`. The port used to default the missing menu to `[]` and mark
7732 /// an empty list instead.
7733 #[test]
7734 fn dtyp_of_a_record_type_with_no_device_support_supplies_no_choices() {
7735 let inst = RecordInstance::new("X".into(), CalcRecord::default());
7736 assert!(
7737 super::super::dbd_generated::device_menu("calc").is_none(),
7738 "precondition: calc declares no device() line (C ftPvt == NULL)"
7739 );
7740 assert!(
7741 inst.device_choices().is_none(),
7742 "a record type with no device menu must report None, not an empty list"
7743 );
7744 assert!(
7745 inst.enum_string_form_for("DTYP").is_none(),
7746 "DTYP must supply no enum-string form, so no `value.choices` is marked"
7747 );
7748 }
7749
7750 /// The other side of C's `dbAccess.c:205` comment — *"indicate option data
7751 /// not available. distinct from no_str==0"*. `ai` DOES declare device
7752 /// support, so its menu exists and its choices are served.
7753 #[test]
7754 fn dtyp_of_a_record_type_with_device_support_supplies_its_choices() {
7755 let inst = RecordInstance::new("X".into(), AiRecord::default());
7756 let choices = inst
7757 .device_choices()
7758 .expect("ai declares device() lines, so its menu exists");
7759 assert!(
7760 choices.iter().any(|c| c.as_str_lossy() == "Soft Channel"),
7761 "ai's device menu must carry its declared choices, got {choices:?}"
7762 );
7763 assert!(inst.enum_string_form_for("DTYP").is_some());
7764 }
7765
7766 /// An unset `DTYP` is index 0 on both sides of the distinction — a record
7767 /// type with no device menu has no slot for any DTYP, so the index stays 0
7768 /// rather than panicking or shifting.
7769 #[test]
7770 fn dtyp_index_is_zero_when_the_record_type_has_no_device_menu() {
7771 let inst = RecordInstance::new("X".into(), CalcRecord::default());
7772 assert_eq!(inst.dtyp_index(), 0);
7773 }
7774
7775 /// A downstream crate's registered device menu (asyn's) is merged AFTER the
7776 /// base-declared choices, matching a C fat softIoc that loaded `asyn.dbd`:
7777 /// `mbbo.DTYP` = the three base soft entries then `asynInt32`,
7778 /// `asynUInt32Digital`, in that order. `dtyp_index` reads the merged list,
7779 /// so an `mbbo` bound to `asynInt32` reports index 3 — the wire value C
7780 /// serves — instead of the appended-as-own-slot index the port gave before
7781 /// the menu was known.
7782 #[test]
7783 fn a_registered_device_menu_merges_after_the_base_declared_choices() {
7784 // The list asyn's generated `dbd_generated::DEVICE_MENU_MBBO` carries.
7785 static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
7786 super::super::register_device_menu("mbbo", ASYN_MBBO);
7787
7788 let mut inst = RecordInstance::new("X".into(), MbboRecord::default());
7789 let merged: Vec<String> = inst
7790 .device_choices()
7791 .expect("mbbo declares device() lines")
7792 .iter()
7793 .map(|c| c.as_str_lossy().into_owned())
7794 .collect();
7795 assert_eq!(
7796 merged,
7797 vec![
7798 "Soft Channel",
7799 "Raw Soft Channel",
7800 "Async Soft Channel",
7801 "asynInt32",
7802 "asynUInt32Digital",
7803 ],
7804 "base-declared choices first, asyn-contributed appended in asyn.dbd order"
7805 );
7806
7807 inst.common.dtyp = "asynInt32".into();
7808 assert_eq!(
7809 inst.dtyp_index(),
7810 3,
7811 "an asyn DTYP indexes into the merged menu, not an appended own slot"
7812 );
7813 }
7814
7815 /// The None-vs-empty contract survives the merge: a record type neither
7816 /// base nor any downstream crate contributes a `device()` for (calc) stays
7817 /// `None`, never `Some([])`, even after asyn menus are registered in this
7818 /// process.
7819 #[test]
7820 fn calc_stays_none_after_asyn_menus_are_registered() {
7821 static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
7822 super::super::register_device_menu("mbbo", ASYN_MBBO);
7823
7824 let inst = RecordInstance::new("X".into(), CalcRecord::default());
7825 assert!(
7826 inst.device_choices().is_none(),
7827 "calc declares no device() and gets no contribution — still None"
7828 );
7829 }
7830}
7831
7832#[cfg(test)]
7833mod property_support_owner_tests {
7834 use crate::server::record::record_trait::default_property_support;
7835 use crate::server::snapshot::PropertySupport as P;
7836
7837 /// `sseqRecord.c:124-144` — the rset table NULLs every property slot
7838 /// except `get_precision`:
7839 ///
7840 /// ```c
7841 /// NULL, /* get_units */
7842 /// get_precision, /* get_precision */
7843 /// NULL, /* get_enum_str */
7844 /// NULL, /* get_enum_strs */
7845 /// NULL, /* put_enum_str */
7846 /// NULL, /* get_graphic_double */
7847 /// NULL, /* get_control_double */
7848 /// NULL /* get_alarm_double */
7849 /// ```
7850 ///
7851 /// `sseq` was previously grouped with the full-numeric synApps types, so
7852 /// the port marked six leaves per field that QSRV2 omits entirely.
7853 #[test]
7854 fn sseq_supplies_only_precision() {
7855 assert_eq!(
7856 default_property_support("sseq"),
7857 P {
7858 precision: true,
7859 ..P::NONE
7860 }
7861 );
7862 }
7863
7864 /// A record type the table does not name keeps the permissive
7865 /// `NUMERIC` default rather than silently losing metadata. This is the
7866 /// arm `asyn` used to land on — and why marking had to become a trait
7867 /// method: asyn-rs cannot add a row here.
7868 #[test]
7869 fn an_untranscribed_record_type_keeps_the_permissive_default() {
7870 assert_eq!(default_property_support("no-such-record-type"), P::NUMERIC);
7871 }
7872}
7873
7874#[cfg(test)]
7875mod metadata_cache_tests {
7876 use super::*;
7877 use crate::server::records::ai::AiRecord;
7878
7879 /// Helper: build an AiRecord wrapped in a RecordInstance with EGU/PREC/HOPR/LOPR set.
7880 fn ai_instance() -> RecordInstance {
7881 let mut rec = AiRecord::default();
7882 let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
7883 let _ = rec.put_field("PREC", EpicsValue::Short(2));
7884 let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
7885 let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
7886 let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
7887 RecordInstance::new("TEMP".to_string(), rec)
7888 }
7889
7890 /// a record-field monitor whose event queue has run short of room
7891 /// replaces its last queued entry in place (C `db_queue_event_log`,
7892 /// `dbEvent.c:812-827`), and the displaced value — which the consumer never
7893 /// observed — must be counted in the shared `dropped_monitor_events()`
7894 /// counter (C `nreplace`), the same accounting a `ProcessVariable` post
7895 /// uses. Before the fix the record-field path overwrote its coalesce slot
7896 /// without counting, hiding slow-consumer loss on the path most CA/PVA
7897 /// database monitors use. The counter is process-global, so the assertion is
7898 /// a strict monotonic increase (robust under parallel tests); the
7899 /// revert-verify runs this test in isolation.
7900 #[test]
7901 fn bfr10_record_field_overflow_counts_dropped_event() {
7902 use crate::server::event_queue::{event_que_size, events_per_que};
7903 use crate::server::pv::dropped_monitor_events;
7904 use crate::server::recgbl::EventMask;
7905 let mut inst = ai_instance();
7906 // Keep the reader alive and do NOT drain, so the ring fills to the
7907 // replace threshold and later posts displace the tail entry.
7908 let _reader = inst
7909 .add_subscriber(
7910 "VAL",
7911 1,
7912 crate::types::DbFieldType::Double,
7913 EventMask::VALUE.bits(),
7914 )
7915 .expect("subscriber added");
7916 let before = dropped_monitor_events();
7917 let posts = event_que_size() - events_per_que() + 10;
7918 for _ in 0..posts {
7919 inst.notify_field_with_origin("VAL", EventMask::VALUE, 0, LinkBacking::none());
7920 }
7921 let after = dropped_monitor_events();
7922 assert!(
7923 after > before,
7924 "a post that replaces an unobserved queued entry must record a \
7925 dropped monitor event (before={before}, after={after})"
7926 );
7927 }
7928
7929 #[test]
7930 fn metadata_cache_source_set_check() {
7931 // Every field `populate_display_info` / `populate_control_info` /
7932 // `populate_enum_info` reads.
7933 assert!(is_metadata_cache_source("EGU"));
7934 assert!(is_metadata_cache_source("PREC"));
7935 assert!(is_metadata_cache_source("HOPR"));
7936 assert!(is_metadata_cache_source("LOPR"));
7937 assert!(is_metadata_cache_source("DRVH"));
7938 assert!(is_metadata_cache_source("ZNAM"));
7939 assert!(is_metadata_cache_source("ZRST"));
7940 assert!(is_metadata_cache_source("FFST"));
7941
7942 // Every cell `explicit_alarm_limits` reads, now that the cache holds
7943 // its answer: the four bands and the four severities that gate them.
7944 assert!(is_metadata_cache_source("HIHI"));
7945 assert!(is_metadata_cache_source("HIGH"));
7946 assert!(is_metadata_cache_source("LOW"));
7947 assert!(is_metadata_cache_source("LOLO"));
7948 assert!(is_metadata_cache_source("HHSV"));
7949 assert!(is_metadata_cache_source("HSV"));
7950 assert!(is_metadata_cache_source("LSV"));
7951 assert!(is_metadata_cache_source("LLSV"));
7952
7953 assert!(!is_metadata_cache_source("VAL"));
7954 assert!(!is_metadata_cache_source("DESC"));
7955 assert!(!is_metadata_cache_source("SCAN"));
7956 assert!(!is_metadata_cache_source("PHAS"));
7957 }
7958
7959 #[test]
7960 fn cache_starts_empty_then_populates_on_first_snapshot() {
7961 let inst = ai_instance();
7962
7963 // Cache starts empty
7964 assert!(inst.metadata_cache.lock().unwrap().is_none());
7965
7966 // First snapshot triggers populate + cache store
7967 let snap = inst.snapshot_for_field("VAL").unwrap();
7968 let display = snap.display.expect("ai snapshot must have display");
7969 assert_eq!(display.units, "degC");
7970 assert_eq!(display.precision, 2);
7971 assert_eq!(display.upper_disp_limit, 100.0);
7972 assert_eq!(display.lower_disp_limit, 0.0);
7973
7974 // Cache is now populated
7975 assert!(inst.metadata_cache.lock().unwrap().is_some());
7976 }
7977
7978 #[test]
7979 fn q_form_info_tag_sets_display_form_index() {
7980 // pvxs maps the `Q:form` info tag to `display.form.index` for the
7981 // VAL field (iocsource.cpp:42-62). "Hex" is slot 4 of the
7982 // seven-entry menu (Default/String/Binary/Decimal/Hex/...).
7983 let mut inst = ai_instance();
7984 inst.set_info("Q:form", "Hex");
7985 let snap = inst.snapshot_for_field("VAL").unwrap();
7986 let display = snap.display.expect("ai snapshot must have display");
7987 assert_eq!(display.form, 4, "Q:form=Hex -> display.form index 4");
7988 }
7989
7990 /// R16-31: `Q:form` is a record-level info tag, but QSRV assigns
7991 /// `display.form.index` only when the channel addresses the VAL field
7992 /// (`if(dbIsValueField(dbChannelFldDes(chan)))`, `iocsource.cpp:53`). A
7993 /// snapshot of any other field of the same record reports the default
7994 /// form, on both the GET and the monitor producer.
7995 #[test]
7996 fn q_form_applies_to_the_val_field_only() {
7997 let mut inst = ai_instance();
7998 inst.set_info("Q:form", "Hex");
7999
8000 let val = inst.snapshot_for_field("VAL").unwrap();
8001 assert_eq!(val.display.expect("ai display").form, 4);
8002
8003 for non_val in ["RVAL", "SEVR", "HOPR"] {
8004 let Some(snap) = inst.snapshot_for_field(non_val) else {
8005 panic!("ai.{non_val} must resolve");
8006 };
8007 assert_eq!(
8008 snap.display.expect("ai display").form,
8009 0,
8010 "Q:form must not reach ai.{non_val} — pvxs applies it to VAL only"
8011 );
8012 }
8013
8014 // The monitor producer shares the same per-field owner.
8015 let update = inst
8016 .make_monitor_snapshot("RVAL", EpicsValue::Long(7), LinkBacking::none())
8017 .expect("nothing was declined: the backing is unresolved, not declined");
8018 assert_eq!(
8019 update.display.expect("ai display").form,
8020 0,
8021 "a monitor update on a non-VAL field carries the default form too"
8022 );
8023 let update = inst
8024 .make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none())
8025 .expect("nothing was declined: the backing is unresolved, not declined");
8026 assert_eq!(update.display.expect("ai display").form, 4);
8027 }
8028
8029 #[test]
8030 fn q_form_absent_or_unknown_leaves_form_default() {
8031 // No `Q:form` tag -> form stays 0 (Default).
8032 let inst = ai_instance();
8033 let snap = inst.snapshot_for_field("VAL").unwrap();
8034 assert_eq!(snap.display.expect("ai display").form, 0);
8035
8036 // Unrecognised tag -> pvxs leaves the index untouched (0).
8037 let mut inst2 = ai_instance();
8038 inst2.set_info("Q:form", "Nonsense");
8039 let snap2 = inst2.snapshot_for_field("VAL").unwrap();
8040 assert_eq!(snap2.display.expect("ai display").form, 0);
8041 }
8042
8043 /// `info(Q:time:tag)` resolves to pvxs's `nsecMask`
8044 /// (`ioc/typeutils.cpp:79-88`). The prefix test there is a byte-exact
8045 /// `strncmp("nsec:lsb:", 9)` and the digit count is fed straight to
8046 /// `(uint64_t(1u)<<dig)-1u` — no case folding, no whitespace tolerance
8047 /// around the prefix, and no bounds clamp. Each boundary gets a case.
8048 #[test]
8049 fn qtime_nsec_mask_matches_pvxs_updatensecmask() {
8050 let cases: &[(&str, u64)] = &[
8051 // parses: `epicsParseInt32` skips whitespace around the digits
8052 // and accepts a sign.
8053 ("nsec:lsb:20", (1 << 20) - 1),
8054 ("nsec:lsb:1", 1),
8055 ("nsec:lsb: 4 ", 0xF),
8056 ("nsec:lsb:+4", 0xF),
8057 // no clamp: 31 is the mask pvxs actually serves (the old Rust
8058 // `(1..=30)` guard dropped it), and 0 is pvxs's "off" mask.
8059 ("nsec:lsb:31", 0x7FFF_FFFF),
8060 ("nsec:lsb:0", 0),
8061 // `strncmp` is byte-exact: case-folded or whitespace-split
8062 // prefixes do not match, so pvxs leaves `nsecMask` at 0.
8063 ("NSEC:LSB:4", 0),
8064 ("Nsec:Lsb:4", 0),
8065 ("nsec: lsb: 4", 0),
8066 (" nsec:lsb:4", 0),
8067 // `epicsParseInt32` failures: no conversion, extraneous trailing
8068 // bytes, overflow past epicsInt32.
8069 ("nsec:lsb:", 0),
8070 ("nsec:lsb:abc", 0),
8071 ("nsec:lsb:4x", 0),
8072 ("nsec:lsb:4 5", 0),
8073 ("nsec:lsb:99999999999999999999", 0),
8074 ("nsec:lsb:2147483648", 0),
8075 ];
8076 for (tag, want) in cases {
8077 let mut inst = ai_instance();
8078 inst.set_info("Q:time:tag", *tag);
8079 assert_eq!(
8080 inst.qtime_nsec_mask(),
8081 *want,
8082 "info(Q:time:tag, {tag:?}) must resolve to nsecMask {want:#x}"
8083 );
8084 }
8085 // Tag absent entirely → pvxs never enters the `if(auto val = ...)`
8086 // body and `nsecMask` stays 0.
8087 assert_eq!(ai_instance().qtime_nsec_mask(), 0);
8088 }
8089
8090 /// End-to-end on the snapshot: `nsec:lsb:31` publishes
8091 /// `nanoseconds & ~mask` (0, since nanoseconds < 1e9 < 2^31) and
8092 /// `userTag = nanoseconds & mask` (pvxs `iocsource.cpp:239-248`). The
8093 /// old `(1..=30)` clamp served the raw nanoseconds and the record's
8094 /// utag instead.
8095 #[test]
8096 fn qtime_nsec_lsb_31_is_served_not_ignored() {
8097 use std::time::{Duration, SystemTime};
8098 let mut inst = ai_instance();
8099 // 123_456_700, not …789: Windows `SystemTime` is a FILETIME with 100 ns
8100 // resolution, so a sub-100 ns literal is truncated on readback and the
8101 // assertion below would see …700. Any value < 2^31 exercises the
8102 // nsec:lsb:31 mask identically, so pin one that survives the round trip.
8103 inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
8104 inst.common.utag = 5;
8105 inst.set_info("Q:time:tag", "nsec:lsb:31");
8106
8107 let snap = inst.snapshot_for_field("VAL").unwrap();
8108 assert_eq!(snap.user_tag, 123_456_700);
8109 assert_eq!(snap.timestamp.subsec_nanos(), 0);
8110 assert_eq!(snap.timestamp.unix_secs(), 42);
8111 }
8112
8113 /// The monitor path applies the same `Q:time:tag` nsec split as GET.
8114 /// Pre-fix, `make_monitor_snapshot` skipped `apply_nsec_mask`, so a
8115 /// monitor update on a `nsec:lsb:N` record posted the raw nanoseconds
8116 /// and the record utag while a GET of the same channel served the
8117 /// split — upstream pvxs PR #189 is the same defect in its
8118 /// `subscriptionCallback`.
8119 #[test]
8120 fn qtime_nsec_mask_applies_on_the_monitor_path() {
8121 use std::time::{Duration, SystemTime};
8122 let mut inst = ai_instance();
8123 // 100 ns-multiple so the subsec_nanos assertion holds on Windows too;
8124 // see qtime_nsec_lsb_31_is_served_not_ignored for the FILETIME reason.
8125 inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
8126 inst.common.utag = 5;
8127 inst.set_info("Q:time:tag", "nsec:lsb:31");
8128
8129 let mon = inst
8130 .make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none())
8131 .expect("nothing was declined: the backing is unresolved, not declined");
8132 assert_eq!(mon.user_tag, 123_456_700);
8133 assert_eq!(mon.timestamp.subsec_nanos(), 0);
8134 assert_eq!(mon.timestamp.unix_secs(), 42);
8135 }
8136
8137 /// The mirror boundary: a tag pvxs's `strncmp` rejects must leave the
8138 /// timestamp and the record's own utag alone. The old case-insensitive
8139 /// split matched `NSEC:LSB:4` and masked the wire timestamp pvxs serves
8140 /// unmasked.
8141 #[test]
8142 fn qtime_uppercase_tag_leaves_timestamp_untouched() {
8143 use std::time::{Duration, SystemTime};
8144 let mut inst = ai_instance();
8145 // 100 ns-multiple so the subsec_nanos assertion holds on Windows too;
8146 // see qtime_nsec_lsb_31_is_served_not_ignored for the FILETIME reason.
8147 inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
8148 inst.common.utag = 5;
8149 inst.set_info("Q:time:tag", "NSEC:LSB:4");
8150
8151 let snap = inst.snapshot_for_field("VAL").unwrap();
8152 assert_eq!(
8153 snap.user_tag, 5,
8154 "record utag must survive a non-matching tag"
8155 );
8156 assert_eq!(snap.timestamp.subsec_nanos(), 123_456_700);
8157 }
8158
8159 /// the served `timeStamp.userTag` defaults to the record's `utag`
8160 /// (pvxs `iocsource.cpp:245`), on both the GET (`snapshot_for_field`)
8161 /// and MONITOR (`make_monitor_snapshot`) paths. Pre-fix both hard-set
8162 /// it to 0, dropping the record's tag. A bit-31 utag also pins the
8163 /// `u64 -> i32` narrowing: the low 32 bits' pattern is preserved
8164 /// (no clamp), matching pvxs assigning `epicsUTag` into the `Int32`
8165 /// wire field.
8166 #[test]
8167 fn snapshot_serves_record_utag_as_timestamp_usertag() {
8168 let mut inst = ai_instance();
8169 // no `info(Q:time:tag, ...)` on this record, so the nsec-LSB
8170 // override never fires and the utag default is what is served.
8171 inst.common.utag = 0x9000_0000;
8172 let want = 0x9000_0000u32 as i32;
8173
8174 let get = inst.snapshot_for_field("VAL").unwrap();
8175 assert_eq!(
8176 get.user_tag, want,
8177 "GET path must serve the record's utag as timeStamp.userTag"
8178 );
8179
8180 let mon = inst
8181 .make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none())
8182 .expect("nothing was declined: the backing is unresolved, not declined");
8183 assert_eq!(
8184 mon.user_tag, want,
8185 "MONITOR path must carry the record's utag too"
8186 );
8187 }
8188
8189 #[test]
8190 fn cache_hit_returns_same_metadata() {
8191 let inst = ai_instance();
8192
8193 // Prime the cache
8194 let snap1 = inst.snapshot_for_field("VAL").unwrap();
8195 let display1 = snap1.display.unwrap();
8196
8197 // Subsequent snapshots return the same cached metadata
8198 let snap2 = inst.snapshot_for_field("VAL").unwrap();
8199 let display2 = snap2.display.unwrap();
8200
8201 assert_eq!(display1.units, display2.units);
8202 assert_eq!(display1.precision, display2.precision);
8203 assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
8204 assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
8205 }
8206
8207 #[test]
8208 fn invalidate_clears_cache() {
8209 let inst = ai_instance();
8210 let _ = inst.snapshot_for_field("VAL");
8211 assert!(inst.metadata_cache.lock().unwrap().is_some());
8212
8213 inst.invalidate_metadata_cache();
8214 assert!(inst.metadata_cache.lock().unwrap().is_none());
8215 }
8216
8217 #[test]
8218 fn notify_field_written_invalidates_for_metadata_field() {
8219 let inst = ai_instance();
8220 let _ = inst.snapshot_for_field("VAL");
8221 assert!(inst.metadata_cache.lock().unwrap().is_some());
8222
8223 // Writing a metadata field should invalidate
8224 inst.notify_field_written("EGU");
8225 assert!(inst.metadata_cache.lock().unwrap().is_none());
8226 }
8227
8228 #[test]
8229 fn notify_field_written_skips_non_metadata_field() {
8230 let inst = ai_instance();
8231 let _ = inst.snapshot_for_field("VAL");
8232 assert!(inst.metadata_cache.lock().unwrap().is_some());
8233
8234 // Writing a value field should NOT invalidate the cache
8235 inst.notify_field_written("VAL");
8236 assert!(inst.metadata_cache.lock().unwrap().is_some());
8237
8238 // DESC is not property-class either — its cache invalidation
8239 // is owned by the DESC arm of `put_common_field`, not by this
8240 // notify path (UI-106).
8241 inst.notify_field_written("DESC");
8242 assert!(inst.metadata_cache.lock().unwrap().is_some());
8243 }
8244
8245 #[test]
8246 fn notify_field_written_is_case_insensitive() {
8247 let inst = ai_instance();
8248 let _ = inst.snapshot_for_field("VAL");
8249 assert!(inst.metadata_cache.lock().unwrap().is_some());
8250
8251 // Lowercase metadata field name should still trigger invalidation
8252 inst.notify_field_written("egu");
8253 assert!(inst.metadata_cache.lock().unwrap().is_none());
8254 }
8255
8256 /// epics-base faac1df1 — `notify_field_written_if_changed` must
8257 /// SKIP the cache invalidation when the metadata field's value
8258 /// didn't actually change. Otherwise a stream of idempotent puts
8259 /// from a CSS panel binds DBE_PROPERTY subscribers to bogus
8260 /// "property changed" events on every cycle.
8261 #[test]
8262 fn notify_field_written_if_changed_skips_when_unchanged() {
8263 let mut inst = ai_instance();
8264 let _ = inst.snapshot_for_field("VAL");
8265 assert!(inst.metadata_cache.lock().unwrap().is_some());
8266
8267 // Capture prev, do a no-op put, then notify — cache must remain.
8268 let prev = inst.record.get_field("EGU");
8269 let _ = inst.record.put_field("EGU", prev.clone().unwrap());
8270 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
8271 assert!(
8272 inst.metadata_cache.lock().unwrap().is_some(),
8273 "no-op put must not invalidate the metadata cache"
8274 );
8275 }
8276
8277 /// And when the value DID change, the cache must invalidate.
8278 #[test]
8279 fn notify_field_written_if_changed_invalidates_on_real_change() {
8280 let mut inst = ai_instance();
8281 let _ = inst.snapshot_for_field("VAL");
8282 assert!(inst.metadata_cache.lock().unwrap().is_some());
8283
8284 let prev = inst.record.get_field("EGU");
8285 let _ = inst
8286 .record
8287 .put_field("EGU", EpicsValue::String("kPa".into()));
8288 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
8289 assert!(
8290 inst.metadata_cache.lock().unwrap().is_none(),
8291 "real metadata change must invalidate cache"
8292 );
8293 }
8294
8295 /// UI-106 / epics-base#785 — DESC feeds `display.description`
8296 /// (pvxs fills it on every metadata populate, iocsource.cpp:306-310),
8297 /// and a changed DESC refreshes the cache at its write owner so the
8298 /// next snapshot serves the new text.
8299 #[test]
8300 fn desc_reaches_display_description_and_a_write_refreshes_it() {
8301 let mut inst = ai_instance();
8302 inst.put_common_field("DESC", EpicsValue::String("before".into()))
8303 .unwrap();
8304 let snap = inst.snapshot_for_field("VAL").unwrap();
8305 assert_eq!(
8306 snap.display.as_ref().unwrap().description.as_str_lossy(),
8307 "before"
8308 );
8309 inst.put_common_field("DESC", EpicsValue::String("after".into()))
8310 .unwrap();
8311 assert!(
8312 inst.metadata_cache.lock().unwrap().is_none(),
8313 "a changed DESC must invalidate the metadata cache"
8314 );
8315 let snap = inst.snapshot_for_field("VAL").unwrap();
8316 assert_eq!(
8317 snap.display.as_ref().unwrap().description.as_str_lossy(),
8318 "after"
8319 );
8320 }
8321
8322 /// …and an idempotent DESC put must NOT invalidate — same
8323 /// discipline as faac1df1 for the property-class fields.
8324 #[test]
8325 fn an_idempotent_desc_put_keeps_the_cache() {
8326 let mut inst = ai_instance();
8327 inst.put_common_field("DESC", EpicsValue::String("same".into()))
8328 .unwrap();
8329 let _ = inst.snapshot_for_field("VAL");
8330 assert!(inst.metadata_cache.lock().unwrap().is_some());
8331 inst.put_common_field("DESC", EpicsValue::String("same".into()))
8332 .unwrap();
8333 assert!(
8334 inst.metadata_cache.lock().unwrap().is_some(),
8335 "an unchanged DESC must not invalidate the metadata cache"
8336 );
8337 }
8338
8339 /// Non-metadata fields don't carry property semantics — the
8340 /// `if_changed` variant must never invalidate for them, matching
8341 /// the existing `notify_field_written` short-circuit.
8342 #[test]
8343 fn notify_field_written_if_changed_skips_non_metadata_field() {
8344 let mut inst = ai_instance();
8345 let _ = inst.snapshot_for_field("VAL");
8346 assert!(inst.metadata_cache.lock().unwrap().is_some());
8347 // VAL is neither a cache source nor `prop(YES)` — must be skipped
8348 // even with a changed value.
8349 inst.notify_field_written_if_changed("VAL", None, LinkBacking::none());
8350 assert!(inst.metadata_cache.lock().unwrap().is_some());
8351 }
8352
8353 #[test]
8354 fn cache_picks_up_new_value_after_invalidation() {
8355 let mut inst = ai_instance();
8356
8357 // First snapshot: degC
8358 let snap1 = inst.snapshot_for_field("VAL").unwrap();
8359 assert_eq!(snap1.display.unwrap().units, "degC");
8360
8361 // Mutate EGU and invalidate
8362 let _ = inst
8363 .record
8364 .put_field("EGU", EpicsValue::String("mV".into()));
8365 inst.notify_field_written("EGU");
8366
8367 // Second snapshot: mV (rebuilt)
8368 let snap2 = inst.snapshot_for_field("VAL").unwrap();
8369 assert_eq!(snap2.display.unwrap().units, "mV");
8370 }
8371
8372 /// R19-41: every snapshot carries the mask of which properties the
8373 /// channel SUPPLIES — C's `rset` slots (`dbAccess.c:336-427` clears the
8374 /// option bit of each NULL slot) narrowed to the addressed field. One
8375 /// case per gate boundary; the three record types are the ones measured
8376 /// against pvxs, which marks none of these leaves.
8377 #[test]
8378 fn property_support_masks_what_the_record_type_does_not_supply() {
8379 use crate::server::records::longout::LongoutRecord;
8380 use crate::server::records::stringout::StringoutRecord;
8381 use crate::server::records::waveform::WaveformRecord;
8382
8383 // ai VAL (DBF_DOUBLE): every numeric slot, no enum strings.
8384 let ai = ai_instance();
8385 let p = ai.snapshot_for_field("VAL").unwrap().properties;
8386 assert_eq!(p, PropertySupport::NUMERIC);
8387 assert_eq!(
8388 ai.snapshot_for_field("VAL").unwrap().precision(),
8389 Some(2),
8390 "an ai supplies get_precision and VAL is DBF_DOUBLE"
8391 );
8392
8393 // ai RVAL (DBF_LONG): the SAME rset, but C keeps DBR_PRECISION only
8394 // for DBF_FLOAT/DBF_DOUBLE (`dbAccess.c:386-395`).
8395 let rval = ai.snapshot_for_field("RVAL").unwrap();
8396 assert!(
8397 !rval.properties.precision && rval.precision().is_none(),
8398 "a non-float field supplies no precision even when the rset does"
8399 );
8400 assert!(
8401 rval.properties.units,
8402 "the other slots are unaffected by the field's type"
8403 );
8404
8405 // longout: `#define get_precision NULL`.
8406 let lo = RecordInstance::new("LO".to_string(), LongoutRecord::default());
8407 let lo = lo.snapshot_for_field("VAL").unwrap();
8408 assert!(!lo.properties.precision && lo.precision().is_none());
8409 assert!(lo.properties.units && lo.properties.graphic_double);
8410
8411 // stringout: no property slot at all.
8412 let so = RecordInstance::new("SO".to_string(), StringoutRecord::default());
8413 let so = so.snapshot_for_field("VAL").unwrap();
8414 assert_eq!(so.properties, PropertySupport::NONE);
8415 assert!(so.units().is_none(), "a stringout supplies no EGU");
8416
8417 // waveform: `#define get_alarm_double NULL`.
8418 let wf = RecordInstance::new("WF".to_string(), WaveformRecord::default());
8419 let wf = wf.snapshot_for_field("VAL").unwrap();
8420 assert!(
8421 !wf.properties.alarm_double && wf.alarm_limits().is_none(),
8422 "a waveform supplies no alarm limits — a GUI must not draw bands at zero"
8423 );
8424 assert!(wf.properties.units && wf.properties.graphic_double);
8425 }
8426
8427 #[test]
8428 fn make_monitor_snapshot_uses_cache() {
8429 let inst = ai_instance();
8430 assert!(inst.metadata_cache.lock().unwrap().is_none());
8431
8432 // make_monitor_snapshot should also populate the cache
8433 let snap = inst
8434 .make_monitor_snapshot("VAL", EpicsValue::Double(42.0), LinkBacking::none())
8435 .expect("nothing was declined: the backing is unresolved, not declined");
8436 assert!(snap.display.is_some());
8437 assert!(inst.metadata_cache.lock().unwrap().is_some());
8438
8439 // Subsequent call hits cache
8440 let snap2 = inst
8441 .make_monitor_snapshot("VAL", EpicsValue::Double(43.0), LinkBacking::none())
8442 .expect("nothing was declined: the backing is unresolved, not declined");
8443 let d1 = snap.display.unwrap();
8444 let d2 = snap2.display.unwrap();
8445 assert_eq!(d1.units, d2.units);
8446 assert_eq!(d1.precision, d2.precision);
8447 }
8448
8449 /// Stub record with a per-field metadata override on SPD only —
8450 /// models a C RSET whose get_units/get_graphic_double key on
8451 /// dbGetFieldIndex (e.g. motorRecord.cc:3156-3361).
8452 static PER_FIELD_META_FIELDS: &[crate::server::record::FieldDesc] = &[
8453 crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
8454 crate::server::record::FieldDesc::new("SPD", crate::types::DbFieldType::Double, false),
8455 crate::server::record::FieldDesc::new("EGU", crate::types::DbFieldType::String, false),
8456 crate::server::record::FieldDesc::new("PREC", crate::types::DbFieldType::Short, false),
8457 crate::server::record::FieldDesc::new("HOPR", crate::types::DbFieldType::Double, false),
8458 crate::server::record::FieldDesc::new("LOPR", crate::types::DbFieldType::Double, false),
8459 ];
8460
8461 struct PerFieldMetaRecord;
8462
8463 impl Record for PerFieldMetaRecord {
8464 /// Its own type, not `ai`: the fixture serves `SPD`, which no `ai`
8465 /// declares, and a field is readable only where the record type
8466 /// declares it (`resolve_field`). Record-level metadata still
8467 /// populates, because that comes from EGU/PREC/HOPR/LOPR below.
8468 fn record_type(&self) -> &'static str {
8469 "per_field_meta"
8470 }
8471 fn get_field(&self, name: &str) -> Option<EpicsValue> {
8472 match name {
8473 "VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
8474 "EGU" => Some(EpicsValue::String("mm".into())),
8475 "PREC" => Some(EpicsValue::Short(3)),
8476 "HOPR" => Some(EpicsValue::Double(100.0)),
8477 "LOPR" => Some(EpicsValue::Double(-100.0)),
8478 _ => None,
8479 }
8480 }
8481 fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
8482 Err(CaError::FieldNotFound(name.to_string()))
8483 }
8484 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8485 PER_FIELD_META_FIELDS
8486 }
8487 fn field_metadata_override(
8488 &self,
8489 field: &str,
8490 ) -> Option<crate::server::record::FieldMetadataOverride> {
8491 if field != "SPD" {
8492 return None;
8493 }
8494 Some(crate::server::record::FieldMetadataOverride {
8495 units: Some("mm/sec".into()),
8496 precision: Some(1),
8497 disp_limits: Some((5.0, 0.5)),
8498 ctrl_limits: Some((4.0, 1.0)),
8499 alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
8500 })
8501 }
8502 }
8503
8504 #[test]
8505 fn field_metadata_override_applies_on_get_and_monitor_paths() {
8506 let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
8507
8508 // VAL: no override — record-level metadata serves it.
8509 let snap = inst.snapshot_for_field("VAL").unwrap();
8510 let d = snap.display.unwrap();
8511 assert_eq!(d.units, "mm");
8512 assert_eq!(d.precision, 3);
8513 assert_eq!(d.upper_disp_limit, 100.0);
8514
8515 // SPD via the GET path: every member patched over the cache.
8516 let snap = inst.snapshot_for_field("SPD").unwrap();
8517 let d = snap.display.unwrap();
8518 assert_eq!(d.units, "mm/sec");
8519 assert_eq!(d.precision, 1);
8520 assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
8521 assert_eq!(
8522 (
8523 d.upper_alarm_limit,
8524 d.upper_warning_limit,
8525 d.lower_warning_limit,
8526 d.lower_alarm_limit
8527 ),
8528 (9.0, 8.0, -8.0, -9.0)
8529 );
8530 let c = snap.control.unwrap();
8531 assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
8532
8533 // SPD via the monitor path: identical override.
8534 let snap = inst
8535 .make_monitor_snapshot("SPD", EpicsValue::Double(2.0), LinkBacking::none())
8536 .expect("nothing was declined: the backing is unresolved, not declined");
8537 let d = snap.display.unwrap();
8538 assert_eq!(d.units, "mm/sec");
8539 assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
8540 let c = snap.control.unwrap();
8541 assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
8542 }
8543
8544 /// Stub modelling the motor monitor() shape (C motorRecord.cc:
8545 /// 3468-3507): VAL is a setpoint, the MDEL/ADEL deadband tracks
8546 /// the RBV readback, which advances on every process.
8547 static READBACK_DEADBAND_FIELDS: &[crate::server::record::FieldDesc] = &[
8548 crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
8549 crate::server::record::FieldDesc::new("RBV", crate::types::DbFieldType::Double, false),
8550 crate::server::record::FieldDesc::new("MDEL", crate::types::DbFieldType::Double, false),
8551 crate::server::record::FieldDesc::new("ADEL", crate::types::DbFieldType::Double, false),
8552 ];
8553
8554 struct ReadbackDeadbandRecord {
8555 val: f64,
8556 rbv: f64,
8557 deadband: f64,
8558 }
8559
8560 impl Record for ReadbackDeadbandRecord {
8561 /// `RBV` is a motor field, not an `ai` one, and only a record type
8562 /// that declares a field can serve it.
8563 fn record_type(&self) -> &'static str {
8564 "readback_deadband"
8565 }
8566 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
8567 self.rbv += 30.0;
8568 Ok(crate::server::record::ProcessOutcome::complete())
8569 }
8570 fn get_field(&self, name: &str) -> Option<EpicsValue> {
8571 match name {
8572 "VAL" => Some(EpicsValue::Double(self.val)),
8573 "RBV" => Some(EpicsValue::Double(self.rbv)),
8574 "MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
8575 _ => None,
8576 }
8577 }
8578 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
8579 match (name, value) {
8580 ("VAL", EpicsValue::Double(v)) => {
8581 self.val = v;
8582 Ok(())
8583 }
8584 ("MDEL", EpicsValue::Double(v)) => {
8585 self.deadband = v;
8586 Ok(())
8587 }
8588 _ => Err(CaError::FieldNotFound(name.to_string())),
8589 }
8590 }
8591 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8592 READBACK_DEADBAND_FIELDS
8593 }
8594 fn monitor_deadband_value(&self) -> Option<f64> {
8595 Some(self.rbv)
8596 }
8597 fn monitor_deadband_field(&self) -> &'static str {
8598 "RBV"
8599 }
8600 }
8601
8602 /// C motor monitor() parity: MDEL/ADEL throttle the deadband
8603 /// field's (RBV) delivery; VAL posts only when the setpoint
8604 /// actually changed — not on every readback poll.
8605 #[test]
8606 fn deadband_field_routes_readback_and_val_posts_only_on_change() {
8607 use crate::server::recgbl::EventMask;
8608 let mut inst = RecordInstance::new(
8609 "RDB".to_string(),
8610 ReadbackDeadbandRecord {
8611 val: 5.0,
8612 rbv: 0.0,
8613 deadband: 10.0,
8614 },
8615 );
8616 let _val_rx = inst
8617 .add_subscriber(
8618 "VAL",
8619 1,
8620 crate::types::DbFieldType::Double,
8621 EventMask::VALUE.bits(),
8622 )
8623 .expect("VAL subscriber");
8624 let _rbv_rx = inst
8625 .add_subscriber(
8626 "RBV",
8627 2,
8628 crate::types::DbFieldType::Double,
8629 EventMask::VALUE.bits(),
8630 )
8631 .expect("RBV subscriber");
8632 let names =
8633 |snap: &ProcessSnapshot| snap.iter().map(|(n, _, _)| n.clone()).collect::<Vec<_>>();
8634
8635 // Cycle 1 (first publish): RBV fires via the deadband trigger
8636 // (MLST starts at the NaN never-posted sentinel). VAL must NOT
8637 // post: `add_subscriber` seeded `last_posted` with the current
8638 // value (the initial value already went out with EVENT_ADD), and
8639 // C monitor() posts VAL only when MARKED(M_VAL) — nothing marked
8640 // it.
8641 let (snap, _) = inst.process_local().unwrap();
8642 let n = names(&snap);
8643 assert!(n.contains(&std::borrow::Cow::Borrowed("RBV")), "{n:?}");
8644 assert!(
8645 !n.contains(&std::borrow::Cow::Borrowed("VAL")),
8646 "VAL unchanged since subscribe must not post: {n:?}"
8647 );
8648
8649 // Cycle 2: RBV moved past MDEL, VAL unchanged → RBV posted,
8650 // VAL not re-posted.
8651 let (snap, _) = inst.process_local().unwrap();
8652 let n = names(&snap);
8653 assert!(
8654 n.contains(&std::borrow::Cow::Borrowed("RBV")),
8655 "RBV crossed MDEL: {n:?}"
8656 );
8657 assert!(
8658 !n.contains(&std::borrow::Cow::Borrowed("VAL")),
8659 "unchanged VAL must not post: {n:?}"
8660 );
8661
8662 // Cycle 3: widen the deadband — RBV moves within it → throttled.
8663 let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
8664 let (snap, _) = inst.process_local().unwrap();
8665 let n = names(&snap);
8666 assert!(
8667 !n.contains(&std::borrow::Cow::Borrowed("RBV")),
8668 "MDEL must throttle RBV: {n:?}"
8669 );
8670
8671 // Cycle 4: setpoint moves while RBV stays inside the deadband →
8672 // VAL posts via change detection, RBV stays throttled.
8673 let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
8674 let (snap, _) = inst.process_local().unwrap();
8675 let n = names(&snap);
8676 assert!(
8677 n.contains(&std::borrow::Cow::Borrowed("VAL")),
8678 "changed VAL must post: {n:?}"
8679 );
8680 assert!(
8681 !n.contains(&std::borrow::Cow::Borrowed("RBV")),
8682 "MDEL must throttle RBV: {n:?}"
8683 );
8684 }
8685
8686 /// A subroutine-less aSub (empty SNAM — the record the PVA monitor
8687 /// oracle drives as `ORACLE:MONSCAN:ASUB`) mirrors C `do_sub`
8688 /// (aSubRecord.c:459-465): an empty SNAM returns 0 BEFORE the bad-sub
8689 /// check, and C `process` (`:224`) runs `prec->val = status = 0` every
8690 /// cycle. So a periodic scan forces VAL back to 0, and C `monitor()`
8691 /// (`:414`, `val != oval`) posts nothing — the driven `dbPut`s are the
8692 /// only VAL events.
8693 ///
8694 /// Before the fix the port's "no bound subroutine" branch returned
8695 /// `S_db_BadSub` and never wrote VAL, so a scanned aSub kept VAL at the
8696 /// last client put and the deadband gate re-posted it on every scan (the
8697 /// oracle's 7 updates where C posts 4). This pins both halves: `process`
8698 /// resets VAL to 0, and a scan of the reset value posts nothing.
8699 #[test]
8700 fn subroutineless_asub_process_resets_val_and_stops_scan_overposting() {
8701 use crate::server::recgbl::EventMask;
8702 use crate::server::records::asub_record::ASubRecord;
8703
8704 let mut inst = RecordInstance::new("ASUB".to_string(), ASubRecord::default());
8705 // The default record: no subroutine bound, SNAM empty.
8706 assert!(inst.subroutine.is_none());
8707 let _val_rx = inst
8708 .add_subscriber(
8709 "VAL",
8710 1,
8711 crate::types::DbFieldType::Long,
8712 EventMask::VALUE.bits(),
8713 )
8714 .expect("VAL subscriber");
8715 let posts_val = |snap: &ProcessSnapshot| snap.iter().any(|(n, _, _)| n == "VAL");
8716
8717 // A settling scan of the unchanged record: C `do_sub` returns 0 and
8718 // `process` leaves VAL at 0 (already 0), settling the monitor gate.
8719 let _ = inst.process_local().unwrap();
8720 assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
8721 // status 0 -> C `if (!status)` drives every OUT link (aSub's
8722 // `multi_output_links` gate reads the cycle status); a bad-sub status
8723 // would suppress all 21.
8724 assert_eq!(
8725 inst.record.multi_output_links().len(),
8726 21,
8727 "empty-SNAM do_sub status must be 0, not S_db_BadSub"
8728 );
8729
8730 // A client caput lands on VAL (DBF_LONG, not process-passive: it posts
8731 // but does not itself process, leaving VAL non-zero — exactly how the
8732 // oracle drives the scanned reproducer between scans).
8733 inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
8734
8735 // The periodic scan processes. C forces VAL back to 0 and posts
8736 // nothing (val == oval == 0). Before the fix VAL stayed 7 and the scan
8737 // re-posted it.
8738 let (snap, _) = inst.process_local().unwrap();
8739 assert_eq!(
8740 inst.record.get_field("VAL"),
8741 Some(EpicsValue::Long(0)),
8742 "a scan must reset VAL to the do_sub status (0)"
8743 );
8744 assert!(
8745 !posts_val(&snap),
8746 "a scan that resets VAL to 0 must not re-post it"
8747 );
8748
8749 // A second driven put + scan: the monitor marker stays at 0, so no
8750 // scan ever re-posts the reset value.
8751 inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
8752 let (snap, _) = inst.process_local().unwrap();
8753 assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
8754 assert!(
8755 !posts_val(&snap),
8756 "repeated scans must not re-post the reset VAL"
8757 );
8758 }
8759
8760 /// Record that names DIFF in `force_posted_fields` (the motor's C
8761 /// `process_motor_info` unconditional `MARK(M_DIFF)`) while keeping
8762 /// every value constant — a settled axis parked at a fixed non-zero
8763 /// following error. VAL is a control: not force-listed, so it must
8764 /// fall back to change-detection.
8765 static FORCE_POST_FIELDS: &[crate::server::record::FieldDesc] = &[
8766 crate::server::record::FieldDesc::new("DIFF", crate::types::DbFieldType::Double, false),
8767 crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
8768 ];
8769
8770 struct ForcePostRecord {
8771 diff: f64,
8772 val: f64,
8773 }
8774
8775 impl Record for ForcePostRecord {
8776 fn record_type(&self) -> &'static str {
8777 "force_post"
8778 }
8779 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
8780 // Values never change — the readback already matches; only the
8781 // unconditional MARK should keep DIFF flowing.
8782 Ok(crate::server::record::ProcessOutcome::complete())
8783 }
8784 fn get_field(&self, name: &str) -> Option<EpicsValue> {
8785 match name {
8786 "DIFF" => Some(EpicsValue::Double(self.diff)),
8787 "VAL" => Some(EpicsValue::Double(self.val)),
8788 _ => None,
8789 }
8790 }
8791 fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
8792 Err(CaError::FieldNotFound(name.to_string()))
8793 }
8794 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8795 FORCE_POST_FIELDS
8796 }
8797 fn force_posted_fields(&self) -> &'static [&'static str] {
8798 &["DIFF"]
8799 }
8800 }
8801
8802 /// C motorRecord parity: `process_motor_info` MARKs M_DIFF/M_RDIF every
8803 /// CALLBACK_DATA pass and `monitor()` posts them with `DBE_VAL_LOG`
8804 /// regardless of change, so a force-posted field re-posts on an
8805 /// otherwise-idle cycle while an unchanged non-force field does not.
8806 #[test]
8807 fn force_posted_field_reposts_unchanged_value_each_cycle() {
8808 use crate::server::recgbl::EventMask;
8809 let mut inst = RecordInstance::new(
8810 "FP".to_string(),
8811 ForcePostRecord {
8812 diff: 2.5,
8813 val: 1.0,
8814 },
8815 );
8816 let _diff_rx = inst
8817 .add_subscriber(
8818 "DIFF",
8819 1,
8820 crate::types::DbFieldType::Double,
8821 EventMask::VALUE.bits(),
8822 )
8823 .expect("DIFF subscriber");
8824 let _val_rx = inst
8825 .add_subscriber(
8826 "VAL",
8827 2,
8828 crate::types::DbFieldType::Double,
8829 EventMask::VALUE.bits(),
8830 )
8831 .expect("VAL subscriber");
8832 let names =
8833 |snap: &ProcessSnapshot| snap.iter().map(|(n, _, _)| n.clone()).collect::<Vec<_>>();
8834
8835 // Cycle 1 (first publish): both DIFF and VAL post — last_posted is
8836 // empty so change-detection treats every subscribed field as new.
8837 let (snap1, _) = inst.process_local().unwrap();
8838 assert!(
8839 names(&snap1).contains(&std::borrow::Cow::Borrowed("DIFF")),
8840 "DIFF posts on first publish: {:?}",
8841 names(&snap1)
8842 );
8843
8844 // Cycle 2: nothing changed. VAL (not force-listed) must NOT re-post;
8845 // DIFF (force-listed) MUST re-post — the C unconditional MARK +
8846 // DBE_VAL_LOG. This is the divergence MOT-1 closes.
8847 let (snap2, _) = inst.process_local().unwrap();
8848 assert!(
8849 names(&snap2).contains(&std::borrow::Cow::Borrowed("DIFF")),
8850 "force-posted DIFF must re-post when unchanged: {:?}",
8851 names(&snap2)
8852 );
8853 assert!(
8854 !names(&snap2).contains(&std::borrow::Cow::Borrowed("VAL")),
8855 "an unchanged non-force field must not re-post: {:?}",
8856 names(&snap2)
8857 );
8858 // The forced re-post carries DBE_VALUE|DBE_LOG (no alarm bits this
8859 // cycle), matching C `monitor_mask | DBE_VAL_LOG` with monitor_mask=0.
8860 let diff_mask = snap2
8861 .iter()
8862 .find(|(n, _, _)| n == "DIFF")
8863 .map(|(_, _, m)| *m)
8864 .expect("DIFF post present");
8865 assert_eq!(
8866 diff_mask.bits(),
8867 (EventMask::VALUE | EventMask::LOG).bits(),
8868 "forced re-post mask is DBE_VAL_LOG"
8869 );
8870 }
8871
8872 /// Record that names S1 in `log_swept_fields` (the scaler's idle
8873 /// `monitor()` DBE_LOG sweep) while keeping every value constant. S2
8874 /// is a control: subscribed but NOT swept, so an unchanged S2 must
8875 /// not re-post. Neither field is the primary `VAL`, so the default
8876 /// deadband field resolves to nothing and does not confound the test.
8877 static LOG_SWEEP_FIELDS: &[crate::server::record::FieldDesc] = &[
8878 crate::server::record::FieldDesc::new("S1", crate::types::DbFieldType::Long, false),
8879 crate::server::record::FieldDesc::new("S2", crate::types::DbFieldType::Long, false),
8880 ];
8881
8882 struct LogSweepRecord {
8883 s1: i32,
8884 s2: i32,
8885 }
8886
8887 impl Record for LogSweepRecord {
8888 fn record_type(&self) -> &'static str {
8889 "scaler"
8890 }
8891 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
8892 // Counts never change — only the unconditional idle LOG sweep
8893 // should keep S1 flowing to a DBE_LOG (archiver) subscriber.
8894 Ok(crate::server::record::ProcessOutcome::complete())
8895 }
8896 fn get_field(&self, name: &str) -> Option<EpicsValue> {
8897 match name {
8898 "S1" => Some(EpicsValue::Long(self.s1)),
8899 "S2" => Some(EpicsValue::Long(self.s2)),
8900 _ => None,
8901 }
8902 }
8903 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
8904 match (name, value) {
8905 ("S1", EpicsValue::Long(v)) => {
8906 self.s1 = v;
8907 Ok(())
8908 }
8909 ("S2", EpicsValue::Long(v)) => {
8910 self.s2 = v;
8911 Ok(())
8912 }
8913 _ => Err(CaError::FieldNotFound(name.to_string())),
8914 }
8915 }
8916 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8917 LOG_SWEEP_FIELDS
8918 }
8919 fn log_swept_fields(&self) -> &'static [&'static str] {
8920 &["S1"]
8921 }
8922 }
8923
8924 /// C `scalerRecord.c::monitor():757-773` sweeps each active channel with a
8925 /// literal `DBE_LOG` on every cycle it runs, unconditionally — the sweep is
8926 /// INDEPENDENT of the change post, not an alternative to it (R12-62). So an
8927 /// UNCHANGED swept field posts `DBE_LOG` only, and a CHANGED swept field
8928 /// posts TWICE on that one cycle: once by change-detection, and once by the
8929 /// sweep with `DBE_LOG`. (In C's scaler those two are `updateCounts()`'s
8930 /// `DBE_VALUE` at `:582` and `monitor()`'s `DBE_LOG` at `:771`.) A
8931 /// non-swept field never re-posts when unchanged. `add_subscriber` seeds
8932 /// `last_posted` with the current value (the initial value goes out via
8933 /// EVENT_ADD), so a freshly subscribed unchanged field already takes the
8934 /// sweep path on cycle 1.
8935 #[test]
8936 fn log_swept_field_reposts_unchanged_with_log_mask_only() {
8937 use crate::server::recgbl::EventMask;
8938 let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
8939 let _s1_rx = inst
8940 .add_subscriber(
8941 "S1",
8942 1,
8943 crate::types::DbFieldType::Long,
8944 EventMask::LOG.bits(),
8945 )
8946 .expect("S1 subscriber");
8947 let _s2_rx = inst
8948 .add_subscriber(
8949 "S2",
8950 2,
8951 crate::types::DbFieldType::Long,
8952 EventMask::VALUE.bits(),
8953 )
8954 .expect("S2 subscriber");
8955 let names =
8956 |snap: &ProcessSnapshot| snap.iter().map(|(n, _, _)| n.clone()).collect::<Vec<_>>();
8957 let count_of =
8958 |snap: &ProcessSnapshot, f: &str| snap.iter().filter(|(n, _, _)| n == f).count();
8959 let mask_of = |snap: &ProcessSnapshot, f: &str| {
8960 snap.iter().find(|(n, _, _)| n == f).map(|(_, _, m)| *m)
8961 };
8962
8963 // Cycle 1: nothing changed since subscribe. S1 (swept) re-posts
8964 // with DBE_LOG ONLY; S2 (not swept) must NOT re-post.
8965 let (snap1, _) = inst.process_local().unwrap();
8966 assert!(
8967 names(&snap1).contains(&std::borrow::Cow::Borrowed("S1")),
8968 "log-swept S1 must re-post when unchanged: {:?}",
8969 names(&snap1)
8970 );
8971 assert!(
8972 !names(&snap1).contains(&std::borrow::Cow::Borrowed("S2")),
8973 "unchanged non-swept S2 must not re-post: {:?}",
8974 names(&snap1)
8975 );
8976 // DBE_LOG, plus the DBE_ALARM of this cycle's transition: a record
8977 // starts UDF/INVALID and its first process clears that, so cycle 1 IS an
8978 // alarm transition (CBUG-B19 — C's sweep drops the alarm bit; this
8979 // assertion used to require a bare DBE_LOG). No DBE_VALUE either way:
8980 // the counts have not moved.
8981 assert_eq!(
8982 mask_of(&snap1, "S1").unwrap().bits(),
8983 (EventMask::LOG | EventMask::ALARM).bits(),
8984 "idle sweep posts DBE_LOG + the alarm transition, never DBE_VALUE"
8985 );
8986
8987 // Cycle 2: S1's count changed. Change-detection delivers it, and the
8988 // sweep delivers it AGAIN with DBE_LOG — the two C `db_post_events`
8989 // calls of the count-completion cycle.
8990 inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
8991 let (snap2, _) = inst.process_local().unwrap();
8992 assert_eq!(
8993 count_of(&snap2, "S1"),
8994 2,
8995 "a changed swept field posts twice — change post + independent \
8996 DBE_LOG sweep: {:?}",
8997 snap2.iter().collect::<Vec<_>>()
8998 );
8999 let s1_masks: Vec<u16> = snap2
9000 .iter()
9001 .filter(|(n, _, _)| n == "S1")
9002 .map(|(_, _, m)| m.bits())
9003 .collect();
9004 assert_eq!(
9005 s1_masks,
9006 vec![
9007 (EventMask::VALUE | EventMask::LOG).bits(),
9008 EventMask::LOG.bits()
9009 ],
9010 "change post first (VALUE|LOG here — this stub is not a \
9011 value_only_change_fields record), then the sweep's literal DBE_LOG"
9012 );
9013
9014 // Cycle 3: unchanged again — back to the DBE_LOG-only sweep.
9015 let (snap3, _) = inst.process_local().unwrap();
9016 assert_eq!(
9017 mask_of(&snap3, "S1").unwrap().bits(),
9018 EventMask::LOG.bits(),
9019 "unchanged-again S1 returns to the DBE_LOG-only sweep"
9020 );
9021 }
9022
9023 /// A log-swept record that can raise an alarm on demand — the scaler's
9024 /// `do_alarm()` (scalerRecord.c:745-755) in miniature.
9025 static ALARMING_LOG_SWEEP_FIELDS: &[crate::server::record::FieldDesc] =
9026 &[crate::server::record::FieldDesc::new(
9027 "S1",
9028 crate::types::DbFieldType::Long,
9029 false,
9030 )];
9031
9032 struct AlarmingLogSweepRecord {
9033 s1: i32,
9034 alarm: bool,
9035 }
9036
9037 impl Record for AlarmingLogSweepRecord {
9038 fn record_type(&self) -> &'static str {
9039 "scaler"
9040 }
9041 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
9042 Ok(crate::server::record::ProcessOutcome::complete())
9043 }
9044 /// This fixture drives its alarm purely through `check_alarms`
9045 /// (`self.alarm`), so it must NOT also raise the central UDF alarm —
9046 /// otherwise the born `udf = 1` pins severity at INVALID every cycle
9047 /// and there is never a real NO_ALARM → INVALID transition to test.
9048 /// (Before `rec_gbl_check_udf` stopped fabricating a UDF message, the
9049 /// ALARM bit this test asserts came from that fabricated amsg
9050 /// flipping to "" — an artifact, not the severity transition the
9051 /// test name and comments describe.) With no UDF alarm, cycle 1
9052 /// genuinely clears the born UDF/INVALID to NO_ALARM, and the
9053 /// `self.alarm` cycle is a true severity transition.
9054 fn raises_udf_alarm(&self) -> bool {
9055 false
9056 }
9057 fn check_alarms(&mut self, common: &mut crate::server::record::CommonFields) {
9058 if self.alarm {
9059 crate::server::recgbl::rec_gbl_set_sevr(
9060 common,
9061 crate::server::recgbl::alarm_status::UDF_ALARM,
9062 crate::server::record::AlarmSeverity::Invalid,
9063 );
9064 }
9065 }
9066 fn get_field(&self, name: &str) -> Option<EpicsValue> {
9067 match name {
9068 "S1" => Some(EpicsValue::Long(self.s1)),
9069 _ => None,
9070 }
9071 }
9072 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
9073 match (name, value) {
9074 ("S1", EpicsValue::Long(v)) => {
9075 self.s1 = v;
9076 Ok(())
9077 }
9078 _ => Err(CaError::FieldNotFound(name.to_string())),
9079 }
9080 }
9081 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
9082 ALARMING_LOG_SWEEP_FIELDS
9083 }
9084 fn log_swept_fields(&self) -> &'static [&'static str] {
9085 &["S1"]
9086 }
9087 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
9088 Some(self)
9089 }
9090 }
9091
9092 /// CBUG-B19 — the sweep post carries the alarm-transition bits.
9093 ///
9094 /// DEVIATION from C, deliberate. C's scaler `monitor()` computes
9095 /// `monitor_mask = recGblResetAlarms(pscal)` (scalerRecord.c:764), ORs
9096 /// `DBE_VALUE|DBE_LOG` into it (`:766`), and then posts every `Sn` with a
9097 /// LITERAL `DBE_LOG` (`:771`) — `monitor_mask` is assigned, OR-ed, and never
9098 /// read. The alarm bit that `recGblResetAlarms` returns is exactly what every
9099 /// other record ORs into its value posts, so C drops it: a client subscribed
9100 /// to `Sn` with DBE_ALARM receives NOTHING on a severity transition.
9101 ///
9102 /// The DBE_VALUE half of C's dead `|=` is deliberately not resurrected — the
9103 /// sweep is unconditional, so a VALUE bit here would fire a value event on
9104 /// every idle scan whether or not the counts moved. The first assertion pins
9105 /// that.
9106 #[test]
9107 fn b19_log_swept_field_carries_the_alarm_transition_bits() {
9108 use crate::server::recgbl::EventMask;
9109 let mut inst = RecordInstance::new(
9110 "SW".to_string(),
9111 AlarmingLogSweepRecord {
9112 s1: 7,
9113 alarm: false,
9114 },
9115 );
9116 let _s1_rx = inst
9117 .add_subscriber(
9118 "S1",
9119 1,
9120 crate::types::DbFieldType::Long,
9121 (EventMask::LOG | EventMask::ALARM).bits(),
9122 )
9123 .expect("S1 subscriber");
9124 let mask_of = |snap: &ProcessSnapshot, f: &str| {
9125 snap.iter().find(|(n, _, _)| n == f).map(|(_, _, m)| *m)
9126 };
9127
9128 // Cycle 1 clears the record's initial UDF/INVALID alarm, which is itself
9129 // a transition; cycle 2 is the quiet baseline. The sweep is then DBE_LOG
9130 // alone — in particular NOT DBE_VALUE, since the counts have not moved.
9131 let _ = inst.process_local().unwrap();
9132 let (snap1, _) = inst.process_local().unwrap();
9133 assert_eq!(
9134 mask_of(&snap1, "S1").unwrap().bits(),
9135 EventMask::LOG.bits(),
9136 "no alarm transition → the sweep is DBE_LOG only"
9137 );
9138
9139 // The alarm fires: severity moves NO_ALARM → INVALID, so this cycle's
9140 // posts carry DBE_ALARM. C posts DBE_LOG here and the alarm subscriber
9141 // learns nothing.
9142 if let Some(r) = inst
9143 .record
9144 .as_any_mut()
9145 .and_then(|a| a.downcast_mut::<AlarmingLogSweepRecord>())
9146 {
9147 r.alarm = true;
9148 }
9149 let (snap2, _) = inst.process_local().unwrap();
9150 assert_eq!(
9151 mask_of(&snap2, "S1").unwrap().bits(),
9152 (EventMask::LOG | EventMask::ALARM).bits(),
9153 "the severity transition must reach the swept field (C drops it)"
9154 );
9155
9156 // Severity stays INVALID: no transition, so no alarm bit — the sweep is
9157 // DBE_LOG again.
9158 let (snap3, _) = inst.process_local().unwrap();
9159 assert_eq!(
9160 mask_of(&snap3, "S1").unwrap().bits(),
9161 EventMask::LOG.bits(),
9162 "a steady severity is not a transition"
9163 );
9164 }
9165
9166 /// Stub record that simulates a record whose process() mutates an
9167 /// internal metadata field. Used to verify that the
9168 /// `Record::took_metadata_change()` hook actually triggers cache
9169 /// invalidation in `process_local()`.
9170 struct MutatingMetaRecord {
9171 val: f64,
9172 egu: String,
9173 took_change: bool,
9174 }
9175
9176 impl Record for MutatingMetaRecord {
9177 fn record_type(&self) -> &'static str {
9178 "ai" // pretend to be ai so populate_display_info populates EGU
9179 }
9180 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
9181 // Simulate dynamic metadata change inside processing
9182 self.egu = "kV".into();
9183 self.took_change = true;
9184 Ok(crate::server::record::ProcessOutcome::complete())
9185 }
9186 fn get_field(&self, name: &str) -> Option<EpicsValue> {
9187 match name {
9188 "VAL" => Some(EpicsValue::Double(self.val)),
9189 "EGU" => Some(EpicsValue::String(self.egu.clone().into())),
9190 "PREC" => Some(EpicsValue::Short(0)),
9191 "HOPR" => Some(EpicsValue::Double(0.0)),
9192 "LOPR" => Some(EpicsValue::Double(0.0)),
9193 _ => None,
9194 }
9195 }
9196 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
9197 match (name, value) {
9198 ("VAL", EpicsValue::Double(v)) => {
9199 self.val = v;
9200 Ok(())
9201 }
9202 ("EGU", EpicsValue::String(s)) => {
9203 self.egu = s.as_str_lossy().into_owned();
9204 Ok(())
9205 }
9206 _ => Err(CaError::FieldNotFound(name.to_string())),
9207 }
9208 }
9209 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
9210 &[]
9211 }
9212 fn took_metadata_change(&mut self) -> bool {
9213 let was = self.took_change;
9214 self.took_change = false; // reset after reporting
9215 was
9216 }
9217 }
9218
9219 #[test]
9220 fn process_local_invalidates_cache_on_took_metadata_change() {
9221 let mut inst = RecordInstance::new(
9222 "MUT".to_string(),
9223 MutatingMetaRecord {
9224 val: 1.0,
9225 egu: "V".to_string(),
9226 took_change: false,
9227 },
9228 );
9229
9230 // Build the cache once with the original EGU
9231 let snap1 = inst.snapshot_for_field("VAL").unwrap();
9232 assert_eq!(snap1.display.unwrap().units, "V");
9233 assert!(inst.metadata_cache.lock().unwrap().is_some());
9234
9235 // Run process_local — the stub record sets took_change inside process()
9236 let _ = inst.process_local();
9237
9238 // Cache should now be invalidated (took_metadata_change returned true)
9239 assert!(
9240 inst.metadata_cache.lock().unwrap().is_none(),
9241 "process_local should invalidate cache when took_metadata_change is true"
9242 );
9243
9244 // Next snapshot picks up the new EGU
9245 let snap2 = inst.snapshot_for_field("VAL").unwrap();
9246 assert_eq!(snap2.display.unwrap().units, "kV");
9247 }
9248
9249 /// Stub record that does NOT mutate metadata fields. Verifies the
9250 /// default `took_metadata_change` returns false and the cache stays.
9251 struct StableMetaRecord {
9252 val: f64,
9253 }
9254 impl Record for StableMetaRecord {
9255 fn record_type(&self) -> &'static str {
9256 "ai"
9257 }
9258 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
9259 self.val += 1.0;
9260 Ok(crate::server::record::ProcessOutcome::complete())
9261 }
9262 fn get_field(&self, name: &str) -> Option<EpicsValue> {
9263 match name {
9264 "VAL" => Some(EpicsValue::Double(self.val)),
9265 "EGU" => Some(EpicsValue::String("V".into())),
9266 "PREC" => Some(EpicsValue::Short(0)),
9267 "HOPR" => Some(EpicsValue::Double(0.0)),
9268 "LOPR" => Some(EpicsValue::Double(0.0)),
9269 _ => None,
9270 }
9271 }
9272 fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
9273 Ok(())
9274 }
9275 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
9276 &[]
9277 }
9278 // took_metadata_change uses default impl (returns false)
9279 }
9280
9281 #[test]
9282 fn process_local_keeps_cache_when_no_metadata_change() {
9283 let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
9284
9285 let _ = inst.snapshot_for_field("VAL");
9286 assert!(inst.metadata_cache.lock().unwrap().is_some());
9287
9288 // Run process_local several times — cache should remain intact
9289 let _ = inst.process_local();
9290 assert!(inst.metadata_cache.lock().unwrap().is_some());
9291 let _ = inst.process_local();
9292 assert!(inst.metadata_cache.lock().unwrap().is_some());
9293 let _ = inst.process_local();
9294 assert!(inst.metadata_cache.lock().unwrap().is_some());
9295 }
9296
9297 // ── Regression: DBE_PROPERTY event delivery boundaries ──────────────
9298
9299 /// Subscribe `VAL` for PROPERTY, put `field`, run the post gate, and report
9300 /// whether an event was delivered. `put` must differ from the field's
9301 /// current value or the change-detection suppresses the post either way.
9302 fn property_event_on_put<R: Record>(rec: R, field: &str, put: EpicsValue) -> bool {
9303 use crate::server::recgbl::EventMask;
9304 let mut inst = RecordInstance::new("PROPGATE".to_string(), rec);
9305 let mut rx = inst
9306 .add_subscriber(
9307 "VAL",
9308 1,
9309 crate::types::DbFieldType::Double,
9310 EventMask::PROPERTY.bits(),
9311 )
9312 .expect("subscriber added");
9313 let prev = inst.record.get_field(field);
9314 assert_ne!(prev.as_ref(), Some(&put), "{field}: put must be a change");
9315 inst.record.put_field(field, put).expect("put accepted");
9316 inst.notify_field_written_if_changed(field, prev.as_ref(), LinkBacking::none());
9317 rx.try_recv().is_ok()
9318 }
9319
9320 /// Boundary: `prop(YES)`. `histogramRecord.dbd.pod` declares ULIM
9321 /// `special(SPC_RESET)` + `prop(YES)`, so C's `dbPut` sets
9322 /// `propertyUpdate` (dbAccess.c:1330) and posts DBE_PROPERTY. ULIM is
9323 /// nobody's cache source — it reaches the wire through the live
9324 /// `apply_field_metadata_override` — so a set keyed on cache sources
9325 /// cannot answer this, which is why the gate reads the declaration.
9326 #[test]
9327 fn prop_yes_field_posts_property_event() {
9328 use crate::server::records::histogram::HistogramRecord;
9329 assert!(
9330 property_event_on_put(
9331 HistogramRecord::default(),
9332 "ULIM",
9333 EpicsValue::Double(100.0)
9334 ),
9335 "histogram.ULIM is prop(YES) — a changed put must post DBE_PROPERTY"
9336 );
9337 }
9338
9339 /// Boundary: `pp(TRUE)` without `prop`. `biRecord.dbd.pod` declares ZSV
9340 /// `pp(TRUE)`, `menu(menuAlarmSevr)` and no `prop`, so C's
9341 /// `paddr->pfldDes->prop` is 0 and no property event is posted.
9342 #[test]
9343 fn pp_true_without_prop_posts_no_property_event() {
9344 use crate::server::records::bi::BiRecord;
9345 assert!(
9346 !property_event_on_put(BiRecord::default(), "ZSV", EpicsValue::Short(2)),
9347 "bi.ZSV is pp(TRUE) but not prop(YES) — it must post no DBE_PROPERTY"
9348 );
9349 }
9350
9351 /// Boundary 1: metadata field written with a CHANGED value, subscriber
9352 /// mask includes PROPERTY → subscriber receives an event.
9353 /// Mirrors C dbAccess.c:1395-1396 `if (propertyUpdate && !status)`
9354 /// `db_post_events(precord,NULL,DBE_PROPERTY)`.
9355 #[test]
9356 fn r47_property_event_delivered_on_changed_metadata() {
9357 use crate::server::recgbl::EventMask;
9358 let mut inst = ai_instance();
9359 let mut rx = inst
9360 .add_subscriber(
9361 "VAL",
9362 1,
9363 crate::types::DbFieldType::Double,
9364 EventMask::PROPERTY.bits(),
9365 )
9366 .expect("subscriber added");
9367
9368 let prev = inst.record.get_field("EGU"); // "degC"
9369 let _ = inst
9370 .record
9371 .put_field("EGU", EpicsValue::String("kPa".into()));
9372 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
9373
9374 assert!(
9375 rx.try_recv().is_ok(),
9376 "PROPERTY subscriber must receive event when metadata field changes"
9377 );
9378 }
9379
9380 /// Boundary 2: same metadata field written with the SAME value → NO event.
9381 /// Matches C suppression at dbAccess.c:1379-1383 and the `prev != now` gate.
9382 #[test]
9383 fn r47_no_event_on_unchanged_metadata() {
9384 use crate::server::recgbl::EventMask;
9385 let mut inst = ai_instance();
9386 let mut rx = inst
9387 .add_subscriber(
9388 "VAL",
9389 1,
9390 crate::types::DbFieldType::Double,
9391 EventMask::PROPERTY.bits(),
9392 )
9393 .expect("subscriber added");
9394
9395 let prev = inst.record.get_field("EGU"); // "degC"
9396 // Write the same value — no change
9397 let _ = inst.record.put_field("EGU", prev.clone().unwrap());
9398 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
9399
9400 assert!(
9401 rx.try_recv().is_err(),
9402 "PROPERTY subscriber must NOT receive event when metadata value is unchanged"
9403 );
9404 }
9405
9406 /// Boundary 3: VALUE-only subscriber (no PROPERTY bit) receives NO event
9407 /// from a metadata write, even when the field value changed.
9408 #[test]
9409 fn r47_value_only_subscriber_no_event_on_metadata_write() {
9410 use crate::server::recgbl::EventMask;
9411 let mut inst = ai_instance();
9412 let mut rx = inst
9413 .add_subscriber(
9414 "VAL",
9415 1,
9416 crate::types::DbFieldType::Double,
9417 EventMask::VALUE.bits(),
9418 )
9419 .expect("subscriber added");
9420
9421 let prev = inst.record.get_field("EGU"); // "degC"
9422 let _ = inst
9423 .record
9424 .put_field("EGU", EpicsValue::String("kPa".into()));
9425 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
9426
9427 assert!(
9428 rx.try_recv().is_err(),
9429 "VALUE-only subscriber must NOT receive event from a metadata write"
9430 );
9431 }
9432
9433 /// Boundary 4 (took_metadata_change path): PROPERTY subscriber receives
9434 /// event after process_local() when the record reports a metadata change.
9435 #[test]
9436 fn r47_process_local_property_event_on_took_metadata_change() {
9437 use crate::server::recgbl::EventMask;
9438 let mut inst = RecordInstance::new(
9439 "MUT2".to_string(),
9440 MutatingMetaRecord {
9441 val: 1.0,
9442 egu: "V".to_string(),
9443 took_change: false,
9444 },
9445 );
9446 let mut rx = inst
9447 .add_subscriber(
9448 "VAL",
9449 1,
9450 crate::types::DbFieldType::Double,
9451 EventMask::PROPERTY.bits(),
9452 )
9453 .expect("subscriber added");
9454
9455 // process() sets took_change = true and updates egu to "kV"
9456 let _ = inst.process_local();
9457
9458 assert!(
9459 rx.try_recv().is_ok(),
9460 "PROPERTY subscriber must receive event after process_local reports took_metadata_change"
9461 );
9462 }
9463}
9464
9465#[cfg(test)]
9466mod aftc_filter_tests {
9467 //! Tests for the shared AFTC alarm-range filter
9468 //! (`records::alarm_filter::aftc_filter`) as driven by
9469 //! `evaluate_analog_alarm`. Pure-function tests: no record instance
9470 //! needed — the filter is a stateless transform of (raw_alarm, aftc,
9471 //! afvl_in, t_last, t_now). Algorithm provenance: 2009 EPICS
9472 //! Codeathon (epics-base `824d37811`), C `aiRecord.c:355-401`.
9473
9474 use crate::server::records::alarm_filter::aftc_filter;
9475 use std::time::{Duration, SystemTime};
9476
9477 fn at(secs: f64) -> SystemTime {
9478 SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
9479 }
9480
9481 #[test]
9482 fn disabled_when_aftc_le_zero() {
9483 // aftc=0 means filter disabled — pass-through.
9484 let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
9485 assert_eq!(out, 2);
9486 assert_eq!(afvl, 0.0);
9487 }
9488
9489 #[test]
9490 fn initial_sample_seeds_state_unchanged_alarm() {
9491 // afvl=0 means first sample after enable — alarm passes through
9492 // and accumulator seeds with the raw severity.
9493 let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
9494 assert_eq!(out, 2);
9495 assert_eq!(afvl, 2.0);
9496 }
9497
9498 #[test]
9499 fn raises_alarm_only_after_full_time_constant() {
9500 // Single-step heuristic: with `aftc = 3s` and `dt = 0.1s`, alpha
9501 // ≈ 0.967, so a one-shot raw_alarm=2 against afvl=0.0 should not
9502 // produce alarm=2 yet — the filter must hold off until the
9503 // accumulator crosses the threshold.
9504 // Seed with afvl=0.01 (tiny prior, simulating "almost no alarm
9505 // yet"); the filter must keep alarm at 0 after one short tick.
9506 let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
9507 assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
9508 assert!(afvl > 0.0 && afvl < 2.0);
9509 }
9510
9511 #[test]
9512 fn dt_zero_is_no_op() {
9513 // Two evaluations at the same instant produce no filter advance.
9514 let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
9515 assert_eq!(out, 1); // floor(|1.5|) = 1
9516 assert_eq!(afvl, 1.5);
9517 }
9518
9519 #[test]
9520 fn long_steady_state_converges_to_alarm() {
9521 // After many steps with raw_alarm=2 and dt much smaller than aftc,
9522 // the accumulator must converge towards 2.
9523 let aftc = 1.0;
9524 let mut afvl = 0.0;
9525 let mut last = at(0.0);
9526 let mut alarm = 0;
9527 for i in 1..=100 {
9528 let now = at(i as f64 * 0.05);
9529 let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
9530 alarm = out;
9531 afvl = new_afvl;
9532 last = now;
9533 }
9534 assert_eq!(
9535 alarm, 2,
9536 "after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
9537 );
9538 assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
9539 }
9540}
9541
9542#[cfg(test)]
9543mod check_deadband_tests {
9544 use super::check_deadband;
9545
9546 const NAN: f64 = f64::NAN;
9547 const INF: f64 = f64::INFINITY;
9548
9549 /// C's own test for this function, transcribed:
9550 /// `modules/database/test/ioc/db/recGblCheckDeadbandTest.c` runs all 19
9551 /// (oldval, newval) pairs it can build from {below-band, above-band,
9552 /// unchanged, -0.0, NaN, +inf, -inf} against deadbands -1, 0 and 1.5, and
9553 /// carries the expected mask for each of the 57 cells. Transcribing the
9554 /// table rather than writing cases per story is what keeps the pairs no C
9555 /// branch matches — `(NaN, NaN)` and same-signed infinity — from being
9556 /// dropped, since they are exactly the ones a reader is tempted to fold
9557 /// into "not comparable, so post".
9558 #[test]
9559 fn matches_the_c_recgblcheckdeadband_truth_table() {
9560 // t_SetValues: [oldval, newval]
9561 let pairs: [(f64, f64); 19] = [
9562 (1.0, 2.0),
9563 (0.0, 2.0),
9564 (0.0, 0.0),
9565 (-0.0, 0.0),
9566 (1.0, NAN),
9567 (1.0, INF),
9568 (1.0, -INF),
9569 (NAN, 1.0),
9570 (NAN, NAN),
9571 (NAN, INF),
9572 (NAN, -INF),
9573 (INF, 1.0),
9574 (INF, NAN),
9575 (INF, INF),
9576 (INF, -INF),
9577 (-INF, 1.0),
9578 (-INF, NAN),
9579 (-INF, INF),
9580 (-INF, -INF),
9581 ];
9582 // t_ExpectedUpdates, one row per deadband in t_Deadband.
9583 let expected: [(f64, [bool; 19]); 3] = [
9584 (
9585 -1.0,
9586 [
9587 true, true, true, true, true, true, true, true, true, true, true, true, true,
9588 true, true, true, true, true, true,
9589 ],
9590 ),
9591 (
9592 0.0,
9593 [
9594 true, true, false, false, true, true, true, true, false, true, true, true,
9595 true, false, true, true, true, true, false,
9596 ],
9597 ),
9598 (
9599 1.5,
9600 [
9601 false, true, false, false, true, true, true, true, false, true, true, true,
9602 true, false, true, true, true, true, false,
9603 ],
9604 ),
9605 ];
9606
9607 for (deadband, row) in expected {
9608 for (i, ((oldval, newval), want)) in pairs.iter().zip(row).enumerate() {
9609 assert_eq!(
9610 check_deadband(*newval, Some(*oldval), deadband),
9611 want,
9612 "C pattern {i}: deadband={deadband} oldval={oldval} newval={newval}"
9613 );
9614 }
9615 }
9616 }
9617
9618 /// The port-only state: a record type with no MLST/ALST cell has posted
9619 /// nothing, so the first comparison has no baseline and must fire whatever
9620 /// the value and the deadband are — including the value C's table says
9621 /// would not fire against an equal baseline.
9622 #[test]
9623 fn never_posted_fires_regardless_of_value_or_deadband() {
9624 for value in [0.0, 1.0, NAN, INF, -INF] {
9625 for deadband in [-1.0, 0.0, 1.5] {
9626 assert!(
9627 check_deadband(value, None, deadband),
9628 "never-posted must fire: value={value} deadband={deadband}"
9629 );
9630 }
9631 }
9632 }
9633}
9634
9635#[cfg(test)]
9636mod common_field_dbload_tests {
9637 use super::*;
9638 use crate::server::records::ai::AiRecord;
9639
9640 /// The db loader feeds every common field to `put_common_field` as an
9641 /// `EpicsValue::String`. Each numeric/menu common field directive must
9642 /// take effect at load — both the integer form (`field(PHAS, "1")`) and
9643 /// the menu-label form (`field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`) —
9644 /// rather than being silently dropped because the arm matched only its
9645 /// typed variant. One assertion per affected common-field arm.
9646 #[test]
9647 fn db_loaded_string_common_fields_take_effect() {
9648 let mut inst = RecordInstance::new("REC".to_string(), AiRecord::default());
9649 let put = |inst: &mut RecordInstance, f: &str, v: &str| {
9650 inst.put_common_field_db_load(f, EpicsValue::String(v.into()))
9651 .unwrap_or_else(|e| panic!("put_common_field_db_load({f}, {v:?}) failed: {e}"));
9652 };
9653
9654 // Integer-valued directives.
9655 put(&mut inst, "PHAS", "1");
9656 assert_eq!(inst.common.phas, 1, "field(PHAS, \"1\")");
9657 put(&mut inst, "TSE", "-2");
9658 assert_eq!(inst.common.tse, -2, "field(TSE, \"-2\")");
9659 put(&mut inst, "DISV", "1");
9660 assert_eq!(inst.common.disv, 1, "field(DISV, \"1\")");
9661 put(&mut inst, "DISA", "1");
9662 assert_eq!(inst.common.disa, 1, "field(DISA, \"1\")");
9663 put(&mut inst, "LCNT", "3");
9664 assert_eq!(inst.common.lcnt, 3, "field(LCNT, \"3\")");
9665 put(&mut inst, "DISP", "1");
9666 assert!(inst.common.disp != 0, "field(DISP, \"1\")");
9667 put(&mut inst, "UDF", "0");
9668 assert!(inst.common.udf == 0, "field(UDF, \"0\")");
9669
9670 // Menu-label directives (resolved via the one menu converter).
9671 put(&mut inst, "PRIO", "HIGH");
9672 assert_eq!(inst.common.prio, 2, "field(PRIO, \"HIGH\")");
9673 put(&mut inst, "DISS", "MAJOR");
9674 assert_eq!(
9675 inst.common.diss,
9676 AlarmSeverity::Major as i16,
9677 "field(DISS, \"MAJOR\")"
9678 );
9679 put(&mut inst, "UDFS", "NO_ALARM");
9680 assert_eq!(
9681 inst.common.udfs,
9682 AlarmSeverity::NoAlarm as i16,
9683 "field(UDFS, \"NO_ALARM\")"
9684 );
9685 put(&mut inst, "ACKT", "NO");
9686 assert!(!inst.common.ackt, "field(ACKT, \"NO\")");
9687
9688 // Numeric form of a menu field still works (field(PRIO, "0")).
9689 put(&mut inst, "PRIO", "0");
9690 assert_eq!(inst.common.prio, 0, "field(PRIO, \"0\")");
9691
9692 // A String-typed common field is untouched by the coercion.
9693 put(&mut inst, "DESC", "a description");
9694 assert_eq!(inst.common.desc.as_str_lossy().as_ref(), "a description");
9695 }
9696}
9697
9698#[cfg(test)]
9699mod declared_override_tests {
9700 use super::*;
9701 use crate::server::records::dfanout::DfanoutRecord;
9702
9703 /// A field `dfanout`'s `.dbd` DECLARES (HOPR/LOPR/PREC/EGU) but the
9704 /// `DfanoutRecord` struct models no storage for: a put must be ACCEPTED and
9705 /// stored (C `dbPut` writes it into record memory), and a later
9706 /// `resolve_field` must serve the written value — not the `.dbd` initial.
9707 #[test]
9708 fn declared_but_unmodeled_field_put_is_stored_and_served() {
9709 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
9710
9711 // Untouched: reads its declared default (initial / type-zero), NOT an
9712 // error, and the override store is empty.
9713 assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(0.0)));
9714 assert!(inst.declared_overrides.is_empty());
9715
9716 // DBF_DOUBLE, DBF_SHORT and DBF_STRING declared metadata fields all
9717 // land, coerced to the declared type.
9718 inst.put_common_field("HOPR", EpicsValue::String("10".into()))
9719 .expect("caput dfanout.HOPR 10 must be accepted");
9720 inst.put_common_field("PREC", EpicsValue::String("3".into()))
9721 .expect("caput dfanout.PREC 3 must be accepted");
9722 inst.put_common_field("EGU", EpicsValue::String("volts".into()))
9723 .expect("caput dfanout.EGU volts must be accepted");
9724
9725 assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(10.0)));
9726 assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
9727 assert_eq!(
9728 inst.resolve_field("EGU"),
9729 Some(EpicsValue::String("volts".into()))
9730 );
9731 // Case-insensitive key: the lower-case read reaches the same slot.
9732 assert_eq!(inst.resolve_field("hopr"), Some(EpicsValue::Double(10.0)));
9733 }
9734
9735 /// The declared type's C range rules apply through the write-side coercion
9736 /// owner: `caput dfanout.PREC 99999` into a `DBF_SHORT` is REFUSED (C
9737 /// `epicsParseInt16` overflow → `S_db_badField`), and the field keeps its
9738 /// prior value — never wraps to a garbage `Short`.
9739 #[test]
9740 fn declared_override_honors_declared_type_range() {
9741 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
9742 inst.put_common_field("PREC", EpicsValue::String("3".into()))
9743 .expect("in-range PREC accepted");
9744 assert!(
9745 inst.put_common_field("PREC", EpicsValue::String("99999".into()))
9746 .is_err(),
9747 "PREC 99999 overflows DBF_SHORT and must be refused"
9748 );
9749 assert!(
9750 inst.put_common_field("PREC", EpicsValue::String("abc".into()))
9751 .is_err(),
9752 "non-numeric PREC must be refused"
9753 );
9754 // The refused puts left the accepted value intact.
9755 assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
9756 }
9757
9758 /// An UNDECLARED field name is still `FieldNotFound` — the override store
9759 /// captures only fields with a real `dbFldDes`, so a misspelled field is
9760 /// refused exactly as C's `dbNameToAddr` refuses it.
9761 #[test]
9762 fn undeclared_field_is_still_not_found() {
9763 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
9764 assert!(matches!(
9765 inst.put_common_field("XYZZY", EpicsValue::String("1".into())),
9766 Err(CaError::FieldNotFound(_))
9767 ));
9768 assert!(inst.declared_overrides.is_empty());
9769 }
9770
9771 /// A PARTIALLY modeled field — one the record SERVES via `get_field` but
9772 /// has no `put_field` arm for (`calcout.PVAL` → `self.pval`) — must NOT
9773 /// land in the override map: doing so would place the value where
9774 /// `resolve_field` (which reads `get_field` first) never sees it, a silent
9775 /// write loss. The override is only for fields the record serves nothing
9776 /// for; a partially modeled field's put is the record's own concern.
9777 #[test]
9778 fn partially_modeled_field_is_not_captured_by_override() {
9779 use crate::server::records::calcout::CalcoutRecord;
9780 let mut inst = RecordInstance::new("CO".to_string(), CalcoutRecord::default());
9781 // PVAL is served by the record (its own storage), so it is not stored
9782 // in the override map; the map stays empty and no ghost cell shadows
9783 // the record's read.
9784 let _ = inst.put_common_field("PVAL", EpicsValue::String("1".into()));
9785 assert!(
9786 inst.declared_overrides.is_empty(),
9787 "a field the record serves via get_field must not enter the override map"
9788 );
9789 }
9790}
9791
9792#[cfg(test)]
9793mod pact_exit_tests {
9794 use super::*;
9795 use crate::server::records::ai::AiRecord;
9796
9797 fn instance() -> RecordInstance {
9798 RecordInstance::new("PACT:REC".to_string(), AiRecord::new(0.0))
9799 }
9800
9801 /// The two boundary values of the bit `leave_pact` mints. It is minted
9802 /// under the `&mut self` the caller already holds, which is what lets
9803 /// `PvDatabase::apply_pact_exit` take no record lock and so be safe to
9804 /// call from a `Drop` that still has a `rec.write()` alive in scope.
9805 #[test]
9806 fn leave_pact_reports_an_empty_restart_queue_as_nothing_to_do() {
9807 let mut inst = instance();
9808 inst.enter_pact();
9809 assert!(!inst.leave_pact().restart_pending());
9810 }
9811
9812 #[test]
9813 fn leave_pact_reports_a_queued_notify_so_the_tail_drains_it() {
9814 let mut inst = instance();
9815 inst.enter_pact();
9816 let (tx, _rx) = crate::runtime::sync::oneshot::channel();
9817 inst.queue_notify_put(DeferredNotify::Process { completion: tx });
9818 assert!(inst.leave_pact().restart_pending());
9819 }
9820}
9821
9822#[cfg(test)]
9823mod declaration_gate_tests {
9824 use super::*;
9825 use crate::server::records::{bi::BiRecord, calc::CalcRecord, histogram::HistogramRecord};
9826
9827 fn inst(name: &str, record: Box<dyn Record>) -> RecordInstance {
9828 RecordInstance::new_boxed(name.to_string(), record)
9829 }
9830
9831 /// The boundary is DECLARED / NOT DECLARED, one case each way per
9832 /// storage that this port keeps for every record but C keeps per record
9833 /// type. Every expectation measured on `softIoc` R7.0.10-146 with
9834 /// `record(calc,"C:GOOD")`, `record(bi,"B:ONE")`,
9835 /// `record(histogram,"H:ONE")`:
9836 ///
9837 /// ```text
9838 /// dbgf C:GOOD.OUT PV 'C:GOOD.OUT' not found
9839 /// dbgf C:GOOD.INP PV 'C:GOOD.INP' not found
9840 /// dbgf C:GOOD.SSCN PV 'C:GOOD.SSCN' not found
9841 /// dbgf C:GOOD.OLDSIMM PV 'C:GOOD.OLDSIMM' not found
9842 /// dbgf C:GOOD.NOSUCH PV 'C:GOOD.NOSUCH' not found
9843 /// dbgf C:GOOD.RTYP DBF_STRING: "calc"
9844 /// dbgf C:GOOD.NAME DBF_STRING: "C:GOOD"
9845 /// dbgf B:ONE.INP DBF_STRING: ""
9846 /// dbgf B:ONE.OUT PV 'B:ONE.OUT' not found
9847 /// dbgf B:ONE.HIHI PV 'B:ONE.HIHI' not found
9848 /// dbgf H:ONE.INP PV 'H:ONE.INP' not found
9849 /// ```
9850 #[test]
9851 fn a_field_resolves_exactly_where_the_record_type_declares_it() {
9852 let calc = inst("C:GOOD", Box::new(CalcRecord::default()));
9853 for undeclared in ["OUT", "INP", "SSCN", "OLDSIMM", "NOSUCH"] {
9854 assert_eq!(calc.resolve_field(undeclared), None, "calc.{undeclared}");
9855 }
9856 // Undeclared, but C's `dbNameToAddr` falls through to the record
9857 // type's attributes for it.
9858 assert_eq!(
9859 calc.resolve_field("RTYP"),
9860 Some(EpicsValue::String("calc".into()))
9861 );
9862 // Declared by dbCommon, so it stays readable.
9863 assert_eq!(
9864 calc.resolve_field("NAME"),
9865 Some(EpicsValue::String("C:GOOD".into()))
9866 );
9867 assert!(calc.resolve_field("CALC").is_some());
9868
9869 // The same storage, on a record type that DOES declare INP and does
9870 // not declare OUT or the analog-alarm ladder.
9871 let bi = inst("B:ONE", Box::new(BiRecord::default()));
9872 assert_eq!(bi.resolve_field("INP"), Some(EpicsValue::String("".into())));
9873 assert_eq!(bi.resolve_field("OUT"), None);
9874 assert_eq!(bi.resolve_field("HIHI"), None);
9875
9876 // `histogramRecord.dbd` declares SVL, not INP — the case
9877 // `Record::declares_inp_link` was written for, now answered by the
9878 // declaration itself.
9879 let histogram = inst("H:ONE", Box::new(HistogramRecord::default()));
9880 assert_eq!(histogram.resolve_field("INP"), None);
9881 assert!(histogram.resolve_field("SVL").is_some());
9882 }
9883
9884 /// The channel-existence side must agree with the read side, or a client
9885 /// gets a SEARCH answered and a CREATE refused (or worse, the reverse).
9886 /// `resolve_string_view_field` is the `$` long-string route to the same
9887 /// funnel.
9888 #[test]
9889 fn the_long_string_view_is_gated_by_the_same_declaration() {
9890 let calc = inst("C:GOOD", Box::new(CalcRecord::default()));
9891 assert_eq!(calc.resolve_string_view_field("OUT"), None);
9892 assert!(calc.resolve_string_view_field("CALC").is_some());
9893 }
9894
9895 /// `declares_simulation` replaced `resolve_field("SIMM").is_some()` as the
9896 /// gate on `PvDatabase::check_simulation_mode`, so it must answer the same
9897 /// thing for every record type the port carries — a type that gained the
9898 /// simulation block while the flag said otherwise would silently stop
9899 /// simulating, and one that lost it would resolve SIML/SIOL by name on
9900 /// every process cycle again.
9901 #[test]
9902 fn the_simulation_gate_agrees_with_resolving_simm_on_every_record_type() {
9903 use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
9904 let mut declared = 0usize;
9905 for rtype in RECORD_TYPE_ORDER {
9906 let Ok(record) = crate::server::db_loader::create_record(rtype) else {
9907 continue;
9908 };
9909 let instance = inst(&format!("SIM:{rtype}"), record);
9910 assert_eq!(
9911 instance.declares_simulation(),
9912 instance.resolve_field("SIMM").is_some(),
9913 "{rtype}"
9914 );
9915 declared += usize::from(instance.declares_simulation());
9916 }
9917 // Both arms have to be populated or the equality above is vacuous.
9918 assert!(declared > 0, "no record type declared a simulation block");
9919 assert!(
9920 declared < RECORD_TYPE_ORDER.len(),
9921 "every record type declared one"
9922 );
9923 }
9924
9925 /// The two arms by name, so the gate's meaning is readable without
9926 /// running the sweep above: `ai` is `readValue`-bearing, `calc` has no
9927 /// `readValue` in C at all.
9928 /// The slot index is what lets the cycle take a pre-read link's text
9929 /// without searching for it by name, so it has to name the same link the
9930 /// search would have found — on every record type, not just the calc
9931 /// class it was measured on.
9932 #[test]
9933 fn the_metadata_link_slots_name_the_same_links_a_search_would_find() {
9934 use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
9935 let mut with_slots = 0usize;
9936 for rtype in RECORD_TYPE_ORDER {
9937 let Ok(record) = crate::server::db_loader::create_record(rtype) else {
9938 continue;
9939 };
9940 let instance = inst(&format!("S:{rtype}"), record);
9941 let links = instance.link_backed_metadata_links();
9942 let slots = instance.link_backed_metadata_input_slots();
9943 assert_eq!(links.len(), slots.len(), "{rtype}");
9944 for (lf, slot) in links.iter().zip(slots) {
9945 let multi = instance.record.multi_input_links();
9946 assert_eq!(
9947 *slot,
9948 multi.iter().position(|(mf, _)| mf == lf),
9949 "{rtype}.{lf}"
9950 );
9951 if let Some(i) = *slot {
9952 assert_eq!(multi[i].0, lf.as_str(), "{rtype}.{lf}");
9953 with_slots += 1;
9954 }
9955 }
9956 }
9957 assert!(
9958 with_slots > 0,
9959 "no record type maps a metadata link onto its multi-input list"
9960 );
9961 }
9962
9963 /// `link_text` replaced a materialise-then-test shape at six link-read
9964 /// sites, so what it owes them is that boundary: a link that reads empty
9965 /// is `None`, and every other answer is the text itself.
9966 #[test]
9967 fn link_text_answers_none_exactly_where_a_link_reads_empty() {
9968 use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
9969 for rtype in RECORD_TYPE_ORDER {
9970 let Ok(record) = crate::server::db_loader::create_record(rtype) else {
9971 continue;
9972 };
9973 let instance = inst(&format!("L:{rtype}"), record);
9974 let declared: Vec<&'static str> = instance
9975 .record
9976 .multi_input_links()
9977 .iter()
9978 .chain(instance.record.string_input_links())
9979 .map(|(lf, _)| *lf)
9980 .collect();
9981 for lf in declared {
9982 let materialised = match instance.record.get_field(lf) {
9983 Some(EpicsValue::String(text)) => text.as_str_lossy().into_owned(),
9984 _ => String::new(),
9985 };
9986 assert_eq!(
9987 instance.link_text(lf),
9988 (!materialised.is_empty()).then_some(materialised),
9989 "{rtype}.{lf}"
9990 );
9991 }
9992 }
9993 }
9994
9995 /// A record that lends a link text must lend the SAME text its
9996 /// `get_field` materialises — the two paths are one answer, and a slot
9997 /// mapping that drifts by one would otherwise hand the cycle a
9998 /// neighbouring link's target.
9999 #[test]
10000 fn a_lent_link_text_is_the_one_get_field_materialises() {
10001 use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
10002 for rtype in RECORD_TYPE_ORDER {
10003 let Ok(mut record) = crate::server::db_loader::create_record(rtype) else {
10004 continue;
10005 };
10006 let declared: Vec<&'static str> = record
10007 .multi_input_links()
10008 .iter()
10009 .chain(record.string_input_links())
10010 .map(|(lf, _)| *lf)
10011 .collect();
10012 // Distinct per slot, so a mapping off by one cannot agree.
10013 for (slot, lf) in declared.iter().enumerate() {
10014 let text = format!("SRC:{rtype}:{slot}.VAL CP");
10015 let _ = record.put_field(lf, EpicsValue::String(text.as_str().into()));
10016 }
10017 for lf in &declared {
10018 let Some(lent) = record.link_text_ref(lf) else {
10019 continue;
10020 };
10021 let materialised = match record.get_field(lf) {
10022 Some(EpicsValue::String(text)) => text.as_str_lossy().into_owned(),
10023 _ => String::new(),
10024 };
10025 assert_eq!(
10026 lent, materialised,
10027 "{rtype}.{lf} lent a text its get_field does not hold"
10028 );
10029 }
10030 }
10031 }
10032
10033 #[test]
10034 fn link_text_reads_a_wired_link_and_refuses_what_is_not_one() {
10035 let mut instance = inst("C:GOOD", Box::new(CalcRecord::default()));
10036 assert_eq!(instance.link_text("INPA"), None, "an unwired INPA");
10037 instance
10038 .record
10039 .put_field("INPA", EpicsValue::String("SRC:ONE.VAL CP".into()))
10040 .expect("INPA takes a link string");
10041 assert_eq!(
10042 instance.link_text("INPA").as_deref(),
10043 Some("SRC:ONE.VAL CP")
10044 );
10045 // A numeric field is not a link, and neither is one the type does not
10046 // declare: both have to read as "no link", not as an empty one.
10047 assert_eq!(instance.link_text("A"), None);
10048 assert_eq!(instance.link_text("NOSUCH"), None);
10049 }
10050
10051 #[test]
10052 fn a_record_type_without_readvalue_declares_no_simulation_block() {
10053 assert!(!inst("C:GOOD", Box::new(CalcRecord::default())).declares_simulation());
10054 assert!(inst("B:ONE", Box::new(BiRecord::default())).declares_simulation());
10055 }
10056}
10057
10058#[cfg(test)]
10059mod link_field_rendering_tests {
10060 use super::render_link_field;
10061
10062 /// One case per boundary of C's `dbGetString` link switch
10063 /// (`dbStaticLib.c:1906-2050`), not one per scenario: the modifier chain has
10064 /// a defaulted arm, and the three field types mask it differently, so the
10065 /// cases that matter are the mask edges rather than a walk of realistic
10066 /// links.
10067 #[test]
10068 fn a_link_field_renders_with_cs_parsed_modifiers() {
10069 use crate::types::DbfLinkClass::{FwdLink, InLink, OutLink};
10070 for (class, text, want) in [
10071 // An input link's absent modifiers are C's defaults, not absences.
10072 (InLink, "L:B", "L:B NPP NMS"),
10073 (InLink, "L:B MS", "L:B NPP MS"),
10074 (InLink, "L:B PP MS", "L:B PP MS"),
10075 (InLink, "L:B MSI", "L:B NPP MSI"),
10076 (InLink, "L:B MSS", "L:B NPP MSS"),
10077 // The process class is one assignment down C's chain, so ` CA`
10078 // appears only when no PP/CP/CPP won it.
10079 (InLink, "L:B CA", "L:B CA NMS"),
10080 (InLink, "L:B CP", "L:B CP NMS"),
10081 (InLink, "L:B CPP", "L:B CPP NMS"),
10082 (InLink, "L:B CP CA", "L:B CA NMS"),
10083 (InLink, "L:B CP NPP", "L:B NPP NMS"),
10084 // The target is the slice before the first space, verbatim: a
10085 // `.FIELD` survives where a rebuild through `channel_name` would
10086 // drop an explicit `.VAL`.
10087 (InLink, "L:B.SEVR MS", "L:B.SEVR NPP MS"),
10088 (InLink, "L:B.VAL", "L:B.VAL NPP NMS"),
10089 // `DBF_OUTLINK` masks CP/CPP off before the render sees it.
10090 (OutLink, "L:B", "L:B NPP NMS"),
10091 (OutLink, "L:B CPP MS", "L:B NPP MS"),
10092 // `DBF_FWDLINK` keeps only CA and prints no severity switch at all.
10093 (FwdLink, "L:B", "L:B"),
10094 (FwdLink, "L:B CA", "L:B CA"),
10095 (FwdLink, "L:B PP MS", "L:B"),
10096 // Everything that is not a PV link is its own text.
10097 (InLink, "12.5", "12.5"),
10098 (InLink, "", ""),
10099 (InLink, "[1, 2, 3]", "[1, 2, 3]"),
10100 (InLink, "@dev p1 p2", "@dev p1 p2"),
10101 (InLink, "{\"const\":1}", "{\"const\":1}"),
10102 ] {
10103 assert_eq!(render_link_field(class, text), want, "{class:?} {text:?}");
10104 }
10105 }
10106}
10107
10108#[cfg(test)]
10109mod unanswerable_notify_tests {
10110 use super::*;
10111 use crate::server::records::calc::CalcRecord;
10112
10113 fn rec(name: &str) -> RecordInstance {
10114 RecordInstance::new(name.into(), CalcRecord::default())
10115 }
10116
10117 /// The ordinary case: the client is still waiting, so the slot is its own.
10118 /// C only reaches `dbNotifyCancel` from a teardown.
10119 #[test]
10120 fn a_waiting_client_keeps_the_slot() {
10121 let mut r = rec("A");
10122 let (tx, _rx) = crate::runtime::sync::oneshot::channel();
10123 r.install_or_queue_notify(tx).expect("slot was free");
10124 assert!(r.unanswerable_notify().is_none());
10125 assert!(r.has_notify(), "a live put-callback must not be cancelled");
10126 }
10127
10128 /// C `rsrvFreePutNotify` (`camessage.c:1630-1638`): the client went away
10129 /// with its put-callback still busy, so the notify leaves the record.
10130 #[test]
10131 fn a_departed_client_releases_the_slot() {
10132 let mut r = rec("A");
10133 let (tx, rx) = crate::runtime::sync::oneshot::channel();
10134 r.install_or_queue_notify(tx).expect("slot was free");
10135 drop(rx);
10136 let dead = r.unanswerable_notify().expect("nobody can answer it");
10137 assert!(r.release_notify(&dead));
10138 assert!(
10139 !r.has_notify(),
10140 "the record must be free for the next put-notify"
10141 );
10142 }
10143
10144 /// A notify that COMPLETED is not unanswerable — it answered. The sender is
10145 /// spent, so a receiver dropped afterwards says nothing about the slot.
10146 #[test]
10147 fn a_completed_notify_is_not_cancelled() {
10148 let mut r = rec("A");
10149 let (tx, rx) = crate::runtime::sync::oneshot::channel();
10150 let set = r.install_or_queue_notify(tx).expect("slot was free");
10151 set.leave();
10152 drop(rx);
10153 assert!(r.unanswerable_notify().is_none());
10154 }
10155
10156 /// The set names every record that holds it, entry and `dbNotifyAdd`
10157 /// target alike — C `pnotifyPvt->waitList` (`dbNotify.c:227`/`:498`), which
10158 /// `dbNotifyCancel` walks at `:428`.
10159 #[test]
10160 fn the_set_names_the_entry_and_every_chain_target() {
10161 let mut entry = rec("A");
10162 let mut target = rec("B");
10163 let (tx, _rx) = crate::runtime::sync::oneshot::channel();
10164 let set = entry.install_or_queue_notify(tx).expect("slot was free");
10165 target.join_put_notify(Some(&set));
10166 let members: Vec<String> = set
10167 .joined_records()
10168 .into_iter()
10169 .map(|n| n.to_string())
10170 .collect();
10171 assert_eq!(members, vec!["A".to_string(), "B".to_string()]);
10172 }
10173
10174 /// A chain target releases too. It holds the same wait-set through
10175 /// `dbNotifyAdd`, and the record whose cycle never ends is exactly the one
10176 /// that keeps it — a `busy` FLNK target left at VAL=1 declines its own
10177 /// `recGblFwdLink` by contract, so nothing else would ever free it. C
10178 /// empties the whole wait list (`dbNotify.c:428-430`) before it looks at
10179 /// the entry at all.
10180 #[test]
10181 fn a_chain_target_holding_the_same_set_releases_too() {
10182 let mut entry = rec("A");
10183 let mut target = rec("B");
10184 let (tx, rx) = crate::runtime::sync::oneshot::channel();
10185 let set = entry.install_or_queue_notify(tx).expect("slot was free");
10186 target.join_put_notify(Some(&set));
10187 drop(rx);
10188 let dead = target.unanswerable_notify().expect("nobody can answer it");
10189 assert!(Arc::ptr_eq(&dead, &set));
10190 assert!(target.release_notify(&dead));
10191 assert!(!target.has_notify(), "B must be free for the next put");
10192 assert!(entry.release_notify(&dead));
10193 assert!(!entry.has_notify());
10194 }
10195
10196 /// The record's own slot is the authority, not the name the sweep carries:
10197 /// a member that has since completed and taken a LIVE notify keeps it.
10198 /// C re-tests `precord->ppn` for the same reason (`restartCheck`'s
10199 /// `assert(precord->ppn)`, `dbNotify.c:154`).
10200 #[test]
10201 fn a_member_that_moved_on_to_a_live_notify_is_left_alone() {
10202 let mut entry = rec("A");
10203 let mut target = rec("B");
10204 let (tx, rx) = crate::runtime::sync::oneshot::channel();
10205 let dead = entry.install_or_queue_notify(tx).expect("slot was free");
10206 target.join_put_notify(Some(&dead));
10207 drop(rx);
10208
10209 // B finished its contribution and a fresh client took it.
10210 assert!(target.release_notify(&dead));
10211 let (tx2, _rx2) = crate::runtime::sync::oneshot::channel();
10212 target.install_or_queue_notify(tx2).expect("slot was free");
10213
10214 assert!(
10215 !target.release_notify(&dead),
10216 "the stale name must not evict the live notify"
10217 );
10218 assert!(target.has_notify());
10219 }
10220}
10221
10222#[cfg(test)]
10223mod forced_secondary_post_tests {
10224 use super::*;
10225 use crate::server::recgbl::EventMask;
10226 use crate::server::records::ao::AoRecord;
10227 use crate::types::DbFieldType;
10228
10229 /// An ao whose output moved this cycle (`omod`) with `oraw != rval`,
10230 /// the state C's `aoRecord.c:541` posts RVAL from.
10231 fn ao_with_moved_rval(rval: i32) -> RecordInstance {
10232 let mut rec = AoRecord::default();
10233 rec.rval = rval;
10234 rec.omod = true;
10235 RecordInstance::new("AO:FORCED".to_string(), rec)
10236 }
10237
10238 /// One guarded monitor cycle; the fields it posted, in order.
10239 fn guarded_cycle(inst: &mut RecordInstance) -> Vec<String> {
10240 let mut snapshot = ProcessSnapshot::new();
10241 inst.collect_subscriber_posts(&mut snapshot, EventMask::VALUE, EventMask::NONE, true);
10242 snapshot
10243 .iter()
10244 .map(|(field, _, _)| field.to_string())
10245 .collect()
10246 }
10247
10248 /// Boundary `oraw != rval` with a subscriber: RVAL posts once and the
10249 /// old copy advances; boundary `oraw == rval` on the next guarded
10250 /// cycle: nothing.
10251 #[test]
10252 fn a_changed_rval_posts_once_then_not_again() {
10253 let mut inst = ao_with_moved_rval(7);
10254 let _rx = inst
10255 .add_subscriber("RVAL", 1, DbFieldType::Long, EventMask::VALUE.bits())
10256 .expect("RVAL subscriber");
10257 let posted = guarded_cycle(&mut inst);
10258 assert_eq!(
10259 posted.iter().filter(|f| *f == "RVAL").count(),
10260 1,
10261 "{posted:?}"
10262 );
10263 assert_eq!(inst.record.get_field("ORAW"), Some(EpicsValue::Long(7)));
10264 let posted = guarded_cycle(&mut inst);
10265 assert!(!posted.iter().any(|f| f == "RVAL"), "{posted:?}");
10266 }
10267
10268 /// Boundary no subscriber: the old copy still advances, and nothing is
10269 /// posted — the bookkeeping must not depend on who is watching.
10270 #[test]
10271 fn a_changed_rval_advances_oraw_with_no_subscriber() {
10272 let mut inst = ao_with_moved_rval(7);
10273 assert!(guarded_cycle(&mut inst).is_empty());
10274 assert_eq!(inst.record.get_field("ORAW"), Some(EpicsValue::Long(7)));
10275 }
10276}