epics_base_rs/server/record/record_instance.rs
1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::Mutex as StdMutex;
4use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5
6use crate::error::{CaError, CaResult};
7use crate::server::database::LinkBacking;
8use crate::server::event_queue::{EventReader, EventUser};
9use crate::server::pv::{MonitorEvent, Subscriber};
10use crate::server::recgbl::EventMask;
11use crate::server::snapshot::{
12 ControlInfo, DisplayInfo, EnumInfo, EnumStringForm, PropertySupport,
13};
14use crate::types::c_parse::Converted;
15use crate::types::{DbFieldType, EpicsValue, PvString, c_parse};
16
17use super::alarm::{AlarmLimit, AlarmSeverity, AnalogAlarmConfig};
18use super::common_fields::CommonFields;
19use super::link::{
20 ParsedLink, out_link_discards_cp, parse_forward_link_v2, parse_link_v2, parse_output_link_v2,
21};
22use super::menu_choices::MenuBound;
23use super::record_trait::{
24 AuxPostMask, CommonFieldPutResult, FieldDeclaration, FieldDesc, ProcessSnapshot, Record,
25 RecordProcessResult, SubroutineFn,
26};
27use super::scan::{ScanType, SimModeScan};
28
29/// C `msstring[4]` (`dbStaticLib.c:61`) — the maximize-severity word
30/// `dbGetString` appends to a link and `dblsr` prints in its own column.
31pub(crate) fn monitor_switch_word(switch_: super::MonitorSwitch) -> &'static str {
32 use super::MonitorSwitch::*;
33 match switch_ {
34 NoMaximize => "NMS",
35 Maximize => "MS",
36 MaximizeIfInvalid => "MSI",
37 MaximizeStatus => "MSS",
38 }
39}
40
41/// C `dbGetString`'s three link branches (`dbStaticLib.c:1906-2050`) — how a
42/// link field READS, as opposed to how it is stored.
43///
44/// C keeps a link field as a parsed `struct link` in record memory and renders
45/// it on every read, so the modifiers a `.db` left out come back as their
46/// defaults: `L:B` in an `INP` reads `L:B NPP NMS`, and `L:B PP MS` in a
47/// `FLNK` reads `L:B`, because `DBF_FWDLINK` carries no process class and no
48/// severity switch. Measured against `softIoc` R7.0.10-146 over CA.
49///
50/// This port stores the text instead, so the rendering happens on the way out.
51/// It is applied once, in [`RecordInstance::resolve_field`], and never in a
52/// printer: the CA server, `dbgf`, `dbpf`'s read-back and `dbpr` all read
53/// through that funnel, and a rule kept in three printers is a rule the fourth
54/// reader does not get.
55///
56/// The target is the slice before the FIRST space rather than a name rebuilt
57/// from the parse, because that is what C stores: `dbParseLink` splits there
58/// and keeps the head verbatim in `pv_link.pvname`. `X.VAL` therefore prints
59/// as `X.VAL`, not as the `X` a round trip through `DbLink::channel_name`
60/// would produce.
61///
62/// Only PV/DB/CA links carry modifiers, so every other link type falls through
63/// to its own text — CONSTANT prints `constantStr` (`:1911-1917`), JSON_LINK
64/// the JSON (`:1927`). Hardware links are the exception that is not a
65/// fall-through: C stores them as numbers and re-renders them per bus
66/// (`:1953-2006`), so this funnel asks [`HwLink::render`](super::HwLink::render)
67/// rather than echoing the field text, and `#C0x10 S-2` reads back as
68/// `#C16 S-2 @`.
69///
70/// Rendering is idempotent under the parser — `L:B NPP NMS` parses to the link
71/// `L:B` does — so a consumer that re-parses what a reader saw gets the link
72/// the store holds.
73pub(crate) fn render_link_field(class: crate::types::DbfLinkClass, raw: &str) -> String {
74 use super::{LinkFieldType, LinkProcessPolicy};
75 use crate::types::DbfLinkClass;
76
77 let text = raw.trim();
78 let ftype = LinkFieldType::for_class(class);
79 // The parse already applied C's per-field-type modifier mask
80 // (`dbStaticLib.c:2380-2391`), so a `DBF_FWDLINK` reaching the arm below
81 // has had everything but `CA` cleared and cannot render a stale ` MS`.
82 let (policy, ms, ca_class) = match super::parse_link_field(text, ftype) {
83 ParsedLink::Db(link) => (link.policy, link.monitor_switch, false),
84 ParsedLink::Ca(link) => (link.policy, link.monitor_switch, true),
85 // The store holds the parsed bus numbers, so the text comes from
86 // them and from nowhere else.
87 ParsedLink::Hw(hw) => return hw.render(),
88 _ => return text.to_string(),
89 };
90 let target = text.split_once(' ').map_or(text, |(head, _)| head);
91
92 // A forward link prints its target and, alone among the modifiers, ` CA`
93 // (`dbStaticLib.c:2034-2044`): no process class and no maximize-severity
94 // switch, which is why C answers a bare `FLNK` with just the record name.
95 if matches!(class, DbfLinkClass::FwdLink) {
96 return if ca_class {
97 format!("{target} CA")
98 } else {
99 target.to_string()
100 };
101 }
102
103 // C's `ppind` chain (`:1938-1943`) tests `PP` before `CA`, so a `ca://`
104 // link that also asked for `PP` renders ` PP`; and a `CP`/`CPP` link that
105 // resolved to a CA channel still renders its own class, because C reads
106 // `pvlMask` and not the type the link ended up with.
107 let pp = if ca_class && policy == LinkProcessPolicy::NoProcess {
108 " CA"
109 } else {
110 match policy {
111 LinkProcessPolicy::NoProcess => " NPP",
112 LinkProcessPolicy::ProcessPassive => " PP",
113 LinkProcessPolicy::ChannelProcess => " CP",
114 LinkProcessPolicy::ChannelProcessPassive => " CPP",
115 }
116 };
117 format!("{target}{pp} {}", monitor_switch_word(ms))
118}
119
120/// Every client-visible `special(SPC_NOMOD)` field of `dbCommon.dbd:13-190`.
121///
122/// These are common fields — no record's `field_list` declares them — so the
123/// declaration names them here. The remaining `SPC_NOMOD` entries in
124/// `dbCommon.dbd` are `DBF_NOACCESS` ([`is_dbcommon_noaccess`]): they have
125/// no field API in this port at all.
126///
127/// TIME is `DBF_NOACCESS` in C, and so it is here — [`FieldDesc::unreadable`]
128/// refuses the read. It is still named here because `SPC_NOMOD` is a fact about
129/// the declaration, not about readability: C's `dbCommon.dbd` marks it
130/// `special(SPC_NOMOD)` and every write path must see that whether or not any
131/// read path ever succeeds.
132///
133/// [`FieldDesc::unreadable`]: super::FieldDesc::unreadable
134///
135/// Read only through [`RecordInstance::is_no_mod`].
136const DBCOMMON_NOMOD: &[&str] = &[
137 "NAME", "STAT", "SEVR", "AMSG", "NSTA", "NSEV", "NAMSG", "ACKS", "ACKT", "LCNT", "PACT",
138 "PUTF", "RPRO", "TIME", "UTAG",
139];
140
141/// Is `field` (already uppercased) a `dbCommon` `DBF_NOACCESS` internal —
142/// a name C resolves but never serves?
143///
144/// These are C-internal pointers with no value API in this port. Their NAMES
145/// still exist to C's resolver: `dbNameToAddr` resolves a `DBF_NOACCESS`
146/// field and the refusal lands at channel *creation*, where `mapDBFToDBR`
147/// yields `DBR_NOACCESS` — measured against `softIocPVX`:
148/// `pvxget ORACLE:AI.MLOK` → `Refused to create Channel`, i.e. the SEARCH
149/// was answered. The search gate (`PvDatabase::has_name_no_resolve`)
150/// consults this — via [`RecordInstance::resolves_noaccess_name`] — so those
151/// names keep answering; every *value* path stays closed to them.
152///
153/// The name list is the generated spec
154/// ([`DB_COMMON_NOACCESS`](super::dbd_generated::DB_COMMON_NOACCESS)) minus
155/// [`DBCOMMON_NOMOD`], and it holds only the rows the generator could state no
156/// width for. `BKPT` and `TIME` are NOT in it: their `extra(...)` names a plain
157/// scalar, so the generator carries the whole descriptor and the search gate is
158/// answered by its `field_desc` arm instead. Both arms answer the SEARCH; which
159/// one does is a property of the declaration, not of this function.
160pub(crate) fn is_dbcommon_noaccess(field: &str) -> bool {
161 super::dbd_generated::DB_COMMON_NOACCESS.contains(&field) && !DBCOMMON_NOMOD.contains(&field)
162}
163
164thread_local! {
165 /// The origin tag applied to every event posted from the current
166 /// thread's synchronous put+process cascade when the poster itself
167 /// passes origin 0. Set only by [`AmbientWriteOriginScope`], read only
168 /// by [`RecordInstance::notify_field_with_origin`]. An in-process
169 /// writer (a ported SNL state machine) uses this so the whole
170 /// synchronous consequence of its put — the direct field post AND the
171 /// process-cycle posts, FLNK cascade included — carries its origin and
172 /// is filtered from its own subscriptions, while posts from work the
173 /// cascade merely *spawned* (a motor poller on another task) stay
174 /// untagged and visible to it.
175 static AMBIENT_WRITE_ORIGIN: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
176}
177
178/// RAII scope for `AMBIENT_WRITE_ORIGIN`. Sound only around code with no
179/// `.await` inside: the tag is thread-local, so crossing an await point
180/// would both leak it to interleaved tasks and lose it on work-stealing.
181/// The put paths that use it (`put_record_field_from_ca_no_notify_with_origin`)
182/// wrap a fully synchronous body.
183pub struct AmbientWriteOriginScope {
184 prev: u64,
185}
186
187/// Enter an ambient-origin scope; the previous value is restored on drop
188/// (scopes nest).
189pub fn ambient_write_origin_scope(origin: u64) -> AmbientWriteOriginScope {
190 let prev = AMBIENT_WRITE_ORIGIN.with(|c| c.replace(origin));
191 AmbientWriteOriginScope { prev }
192}
193
194impl Drop for AmbientWriteOriginScope {
195 fn drop(&mut self) {
196 AMBIENT_WRITE_ORIGIN.with(|c| c.set(self.prev));
197 }
198}
199
200/// The current thread's ambient write origin (0 outside any scope).
201/// `pub(crate)` so the simple-PV posting funnel
202/// (`ProcessVariable::deliver`) applies the same inheritance rule as the
203/// two record funnels in this file.
204pub(crate) fn ambient_write_origin() -> u64 {
205 AMBIENT_WRITE_ORIGIN.with(|c| c.get())
206}
207
208/// Put-notify completion wait-set — the C `dbNotify.c` `processNotify`
209/// waitList analogue (`dbNotifyAdd` / `dbNotifyCompletion`).
210///
211/// A `ca_put_callback` / WRITE_NOTIFY completion must fire only after the
212/// originating (put-target) record AND every record reached through its
213/// FLNK / OUT / process-action dispatch chain (synchronous *or* async)
214/// has finished processing. A single wait-set owns the completion
215/// oneshot; only it fires, and only when the last chain member leaves.
216///
217/// Counting convention: [`Self::new`] arms `pending = 1` for the
218/// originating record (which always joins). Every additional PP target
219/// that will process under the active notify [`Self::enter`]s on join
220/// (C `dbNotifyAdd`), and every record [`Self::leave`]s when its
221/// processing completes (C `dbNotifyCompletion`). The oneshot fires on
222/// the `leave` that drops `pending` to zero.
223///
224/// Membership is BOTH counted and named, because the two questions have
225/// different answers here and C only ever has to ask one of them. C keeps
226/// no count at all — `ellCount(&pnotifyPvt->waitList)` (`dbNotify.c:460`)
227/// IS its count, so its list answers "has the chain settled?" and "which
228/// records must a cancel release?" at once. The port cannot merge them:
229/// `pending` also carries contributions that own no record slot (the
230/// initiator's own hold, and a `dbCaPutLinkCallback` awaiting its network
231/// completion — `links.rs`), which C models as `state` rather than as list
232/// entries. So `pending` answers settlement and the `joined` list answers
233/// cancellation, each with one meaning on every path.
234pub struct NotifyWaitSet {
235 pending: AtomicUsize,
236 /// C `notifyPvt::waitList` (`dbNotify.c:73`) — every record that took
237 /// this set into its `notify` slot, so a cancel can sweep them the way
238 /// `dbNotifyCancel` walks the wait list (`:428-430`).
239 ///
240 /// Append-only, and deliberately so: C deletes a member on completion
241 /// only because `ellCount` is its settlement count, which `pending`
242 /// already is here. Keeping a completed member listed costs a slot
243 /// re-check and cannot lose one, whereas removing on completion would
244 /// make the list the second thing that has to be right for a cancel to
245 /// reach a record.
246 ///
247 /// Names, not handles: the sweep needs the record's write lock anyway,
248 /// and the authority on membership is the record's own slot — a stale
249 /// name simply fails the `Arc::ptr_eq` and is skipped. That makes
250 /// over-listing harmless and under-listing the only failure, which is
251 /// what the append-only rule then makes unrepresentable.
252 ///
253 /// Written in exactly one place, [`RecordInstance::take_notify_slot`],
254 /// which is also the only writer of the slot itself — so "every record
255 /// holding this set is named here" holds by construction.
256 joined: StdMutex<Vec<Box<str>>>,
257 tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
258 /// C `dbChannelRecord(ppn->chan)` — the record the notify was ISSUED
259 /// against, as opposed to the records that joined its chain.
260 ///
261 /// `None` for a set that is not a `processNotify` at all: the
262 /// completion accounting [`PvDatabase::new_put_notify`] arms for a
263 /// downstream link put has no `chan`, so C has no such record and
264 /// `dbNotifyDump` has no block to print for it.
265 ///
266 /// Set at construction and never afterwards. The only mint of an
267 /// entry-bearing set is [`RecordInstance::install_or_queue_notify`],
268 /// which passes its own record, and the other path into the slot
269 /// ([`RecordInstance::join_put_notify`]) clones a set it did not make —
270 /// so "the entry names the record whose slot minted it" holds by
271 /// construction rather than by a check.
272 ///
273 /// It is NOT the membership test. Every member, entry or joined, is named
274 /// in [`Self::joined`]; this only says which member `dbNotifyDump` prints
275 /// a block for (`dbNotify.c:659-660`).
276 ///
277 /// [`PvDatabase::new_put_notify`]: crate::server::database::PvDatabase::new_put_notify
278 entry: Option<Box<str>>,
279}
280
281impl NotifyWaitSet {
282 /// Arm a wait-set whose `tx` fires when the chain settles. `pending`
283 /// starts at 1 for the originating record — its completion `leave`s
284 /// that implicit slot, so a put with no chain targets fires
285 /// immediately on the originating record's own completion.
286 ///
287 /// No entry record: this is the chain-internal set, C's `dbNotifyAdd`
288 /// bookkeeping without a `processNotify` of its own.
289 /// `Self::for_entry_record` is the `dbProcessNotify` arm.
290 pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
291 Arc::new(Self {
292 pending: AtomicUsize::new(1),
293 joined: StdMutex::new(Vec::new()),
294 tx: StdMutex::new(Some(tx)),
295 entry: None,
296 })
297 }
298
299 /// C `dbProcessNotify` (`dbNotify.c:196-270`): the put named `record`, so
300 /// `dbChannelRecord(ppn->chan)` is `record` and that is the one record
301 /// `dbNotifyDump` prints a block for.
302 fn for_entry_record(record: &str, tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
303 Arc::new(Self {
304 pending: AtomicUsize::new(1),
305 joined: StdMutex::new(Vec::new()),
306 tx: StdMutex::new(Some(tx)),
307 entry: Some(record.into()),
308 })
309 }
310
311 /// The record this notify was issued against, or `None` for a set with
312 /// no `processNotify` behind it. See [`Self::entry`].
313 pub(crate) fn entry_record(&self) -> Option<&str> {
314 self.entry.as_deref()
315 }
316
317 /// A PP target joined the chain (C `dbNotifyAdd`). Balanced by exactly
318 /// one [`Self::leave`].
319 pub fn enter(&self) {
320 self.pending.fetch_add(1, Ordering::AcqRel);
321 }
322
323 /// A record finished its contribution (C `dbNotifyCompletion`). Fires
324 /// the completion oneshot on the `leave` that empties the set.
325 pub fn leave(&self) {
326 let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
327 debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
328 if prev == 1 {
329 if let Some(tx) = self.tx.lock().unwrap().take() {
330 let _ = tx.send(());
331 }
332 }
333 }
334
335 /// True once every chain member has left (the completion has fired).
336 /// Used by the put entry to decide synchronous ([`ProcessCompletion::Sync`])
337 /// vs async-pending ([`ProcessCompletion::Async`]) completion.
338 pub fn completed(&self) -> bool {
339 self.pending.load(Ordering::Acquire) == 0
340 }
341
342 /// Record `name` took this set into its `notify` slot — C
343 /// `ellSafeAdd(&pnotifyPvt->waitList, &precord->ppnr->waitNode)`
344 /// (`dbNotify.c:227`/`:258`/`:498`).
345 ///
346 /// Private to this module and called from the one slot writer
347 /// ([`RecordInstance::take_notify_slot`]), so joining the list and taking
348 /// the slot are one act and cannot be done separately.
349 fn record_joined(&self, name: &str) {
350 self.joined.lock().unwrap().push(name.into());
351 }
352
353 /// Every record that has held this set — C's wait list as
354 /// `dbNotifyCancel` enumerates it (`dbNotify.c:428`).
355 ///
356 /// A snapshot, because the sweep must take record write locks and cannot
357 /// hold this mutex while it does. Growth after the snapshot is not a
358 /// missed member: a record can only join a set that is still answerable,
359 /// and this is read only of a set that is not.
360 pub(crate) fn joined_records(&self) -> Vec<Box<str>> {
361 self.joined.lock().unwrap().clone()
362 }
363
364 /// Nobody is left to answer: the completion never fired (the sender is
365 /// still here) and the client that would have received it is gone.
366 ///
367 /// This is the condition C detects at client teardown —
368 /// `rsrvFreePutNotify` sees `pNotify->busy` and calls `dbNotifyCancel`
369 /// (`camessage.c:1630-1638`). A set that already fired holds no sender and
370 /// is NOT unanswerable: it completed.
371 pub(crate) fn is_unanswerable(&self) -> bool {
372 self.tx
373 .lock()
374 .unwrap()
375 .as_ref()
376 .is_some_and(|tx| tx.is_closed())
377 }
378}
379
380/// The completion outcome of an externally-initiated record process cycle —
381/// the value a caller learns after driving the synchronous head of a
382/// `dbPutNotify` / CA `WRITE_NOTIFY`.
383///
384/// This is the contract the **RTEMS CA driver** consumes: the CA thread drives
385/// the synchronous head of a put (C `dbProcessNotify`, `rsrv/camessage.c`
386/// `write_notify_action`) to completion — on RTEMS via `park_on` — then
387/// `match`es this value to decide whether to reply inline or return now and let
388/// background infrastructure deliver the completion later. The caller learns
389/// sync-vs-async as a typed value, not by inferring it from `Option::is_some`.
390///
391/// # C parity (`dbNotify.c`)
392///
393/// The C `processNotify` state machine forks a put-notify exactly here:
394///
395/// * **[`Self::Sync`]** — the record was neither active (`pact`) nor selected
396/// for processing, so `processNotifyCommon` runs `callDone`
397/// (`dbNotify.c:270`), which fires `doneCallback` INLINE on the calling
398/// thread (`dbNotify.c:182`). Our fully-synchronous chain drains the
399/// [`NotifyWaitSet`] before the put entry returns.
400/// * **[`Self::Async`]** — the record was `pact` (`notifyRestartInProgress`,
401/// `dbNotify.c:225-231`) or processed into an async device
402/// (`notifyProcessInProgress`, `dbNotify.c:252-263`). Completion is deferred
403/// to `dbNotifyCompletion` (`dbNotify.c:445-475`), which fires the user
404/// callback via `callbackRequest` (`:466`/`:470`) when the tracked waitList
405/// empties. Our [`NotifyWaitSet::leave`]-to-zero fires the `handle` oneshot
406/// at that same moment.
407///
408/// # Invariant (by construction)
409///
410/// Exactly one of {`Sync` returned, the `Async` handle fires exactly once} per
411/// initiated cycle. The single owner of the fire is [`NotifyWaitSet`]: its
412/// `leave`-to-zero `take`s the oneshot sender and sends once, so the handle can
413/// never fire twice; and `Sync` is returned only when the wait-set already
414/// drained, so no handle is outstanding to fire. There is no parallel
415/// signalling path — the oneshot is the sole completion channel.
416#[derive(Debug)]
417pub enum ProcessCompletion {
418 /// The cycle settled within the calling thread — the caller replies inline.
419 Sync,
420 /// The cycle went async; `handle` fires exactly once when the tracked
421 /// FLNK/OUT chain settles (C `dbNotifyCompletion`).
422 Async(crate::runtime::sync::oneshot::Receiver<()>),
423}
424
425impl ProcessCompletion {
426 /// Build the outcome from the wait-set's internal signal. `None` — the
427 /// wait-set drained synchronously, or the completion receiver lives
428 /// elsewhere (a deferred-restart replay carries only the sender) — is
429 /// [`Self::Sync`]; `Some(rx)` is [`Self::Async`].
430 pub(crate) fn from_signal(rx: Option<crate::runtime::sync::oneshot::Receiver<()>>) -> Self {
431 match rx {
432 Some(rx) => Self::Async(rx),
433 None => Self::Sync,
434 }
435 }
436
437 /// The completion handle if this cycle went async, else `None`. The CA
438 /// `WRITE_NOTIFY` dispatch uses this to choose inline reply (`None`) vs a
439 /// spawned completion task (`Some(rx)`).
440 pub fn into_handle(self) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
441 match self {
442 Self::Sync => None,
443 Self::Async(rx) => Some(rx),
444 }
445 }
446
447 /// True if the cycle went async (a completion handle is outstanding).
448 pub fn is_async(&self) -> bool {
449 matches!(self, Self::Async(_))
450 }
451
452 /// True if the cycle completed synchronously (no handle to await).
453 pub fn is_sync(&self) -> bool {
454 matches!(self, Self::Sync)
455 }
456}
457
458/// A put-notify (`dbPutNotify` — CA WRITE_NOTIFY, `caput -c`) that landed on a
459/// PACT record and was therefore deferred WHOLE.
460///
461/// C `processNotifyCommon` (dbNotify.c:225-231) tests `precord->pact` above
462/// `ppn->putCallback`, so nothing is written and nothing is marked: the record
463/// joins the notify's wait list in state `notifyRestartInProgress`, and when the
464/// async cycle completes the put is replayed against a record that is no longer
465/// active — value written, record processed, callback fired only after THAT
466/// process finishes. So a client's "callback returned" still means "the value I
467/// sent has been processed".
468///
469/// softIoc 7.0.10.1-DEV, `ASY` (calcout, `ODLY=4`, `A=5`), `caput -c ASY.A 7`
470/// issued 1 s into the async cycle:
471///
472/// ```text
473/// t=1s A=5 PACT=1 <- cycle in flight
474/// t=2s A=5 PACT=1 RPRO=0 <- put-notify pending: nothing written
475/// t=4s A=7 PACT=1 <- cycle done; the put is replayed
476/// callback returns at t=6.9s: A=7 VAL=7 <- after the RESTARTED process
477/// ```
478pub struct DeferredNotifyPut {
479 /// The field the client wrote (already upper-cased).
480 pub field: String,
481 /// The value it wrote — held here, unwritten, until the restart.
482 pub value: crate::types::EpicsValue,
483 /// The client's completion channel. The replayed put builds its wait-set
484 /// around this sender, so the callback fires on the restarted process, not
485 /// on the in-flight cycle.
486 pub completion: crate::runtime::sync::oneshot::Sender<()>,
487}
488
489/// One entry of C `precord->ppnr->restartList` — a whole `processNotify`
490/// waiting for the record, not just a put.
491///
492/// C queues the *request* (`ellSafeAdd(&restartList, &ppn->restartNode)`,
493/// dbNotify.c:217) and `restartCheck` re-enters `processNotifyCommon`, which
494/// dispatches on `ppn->requestType`. A queue that could hold only a
495/// field-and-value put left the other request type — C `processGetRequest`,
496/// the port's [`crate::server::database::PvDatabase::process_record_with_notify`]
497/// — with nowhere to wait, so its entry refused instead of queueing.
498pub enum DeferredNotify {
499 /// C `putProcessRequest` / `putProcessGetRequest`: write the field, then
500 /// process; the callback fires on the replayed cycle.
501 Put(DeferredNotifyPut),
502 /// C `processGetRequest`: process the record, write nothing.
503 Process {
504 /// The client's completion channel, armed on the replayed process.
505 completion: crate::runtime::sync::oneshot::Sender<()>,
506 },
507}
508
509/// The PACT→idle transition, as a value.
510///
511/// Carries one bit: whether the record owed a restart at the moment the token
512/// was minted. Queued put-notifies live on the record
513/// (`RecordInstance::notify_restart_list`) from arrival to replay and are
514/// promoted by one owner, `PvDatabase::apply_pact_exit` — so a release path
515/// that forgets its tail delays a restart, it cannot strand one inside a
516/// dropped value.
517///
518/// The bit is a HINT, not a second home for the queue. It is minted under a
519/// record lock the minting site already holds (every constructor is reached
520/// from `&mut self` or an explicit read), which is what lets
521/// `apply_pact_exit` take NO record lock at all — so it is safe to call from
522/// a `Drop`, where a still-live write guard in the same scope would otherwise
523/// deadlock parking_lot. A stale `true` costs one no-op drain: the drain
524/// re-reads the queue under the write lock via
525/// `RecordInstance::take_next_notify_restart` and returns if it is empty.
526///
527/// The token is `#[must_use]` because that tail is where the restart happens:
528/// C `recGbl.c:295` (`if (pdbc->ppn) dbNotifyCompletion(pdbc)`) →
529/// `dbNotifyCompletion` → `restartCheck` (`dbNotify.c:149-170`). Holding it to
530/// the tail rather than promoting at the `pact = FALSE` store is what keeps the
531/// replay behind the rest of the cycle, exactly as C's queued callback is.
532#[must_use = "a PACT release must reach PvDatabase::apply_pact_exit, which is \
533 where a queued put-notify is restarted"]
534pub struct PactExit {
535 restart_pending: bool,
536}
537
538impl PactExit {
539 /// Mint a token from the record's queue state, read under the caller's
540 /// lock.
541 pub(crate) fn new(restart_pending: bool) -> PactExit {
542 PactExit { restart_pending }
543 }
544
545 /// Fold two releases of the same cycle into one token.
546 ///
547 /// A simulated SDLY continuation releases PACT inside `check_simulation_mode`
548 /// and again at the `is_continuation` arm; one restart check covers both, so
549 /// either half owing a restart makes the folded token owe one.
550 pub(crate) fn merge(self, other: PactExit) -> PactExit {
551 PactExit {
552 restart_pending: self.restart_pending || other.restart_pending,
553 }
554 }
555
556 /// Whether the minting site saw a queued notify. See the type docs: this
557 /// is a hint the drain re-validates, never the queue itself.
558 pub(crate) fn restart_pending(&self) -> bool {
559 self.restart_pending
560 }
561}
562
563/// Cached metadata for a record.
564///
565/// Stores the result of `populate_display_info` / `populate_control_info` /
566/// `populate_enum_info` so subsequent `snapshot_for_field` /
567/// `make_monitor_snapshot` calls can skip rebuilding the metadata. The
568/// cache is invalidated whenever a metadata-class field is written
569/// (EGU, PREC, HOPR, LOPR, alarm limits, DRVH/DRVL, state strings).
570///
571/// In a CA-only IOC this is a CPU win; in a hybrid CA + PVA IOC where
572/// every snapshot needs full metadata for NTScalar serialization, the
573/// cache eliminates redundant per-event populate work.
574#[derive(Clone, Default)]
575pub(crate) struct MetadataSnapshot {
576 pub display: Option<DisplayInfo>,
577 pub control: Option<ControlInfo>,
578 pub enums: Option<EnumInfo>,
579}
580
581/// Does a write to this field make [`RecordInstance::metadata_cache`] stale?
582///
583/// **Cache bookkeeping only** — NOT the `DBE_PROPERTY` gate, which is the
584/// field's own `prop(YES)` declaration ([`RecordInstance::field_posts_property`]).
585/// The two used to be one hand-written list, so every field this port had to
586/// invalidate on became a property event C does not post, and every `prop(YES)`
587/// field nobody had listed posted nothing. They answer different questions and
588/// the sets genuinely differ in both directions: `busy.ZNAM` is a cache source
589/// that C does not mark `prop(YES)` (busy's `.dbd` declares no `prop` at all),
590/// and `histogram.ULIM` is `prop(YES)` yet feeds only the live-computed
591/// `apply_field_metadata_override`.
592///
593/// The rule: **every field read by `populate_display_info`,
594/// `populate_control_info`, or `populate_enum_info` MUST be in this set** —
595/// otherwise the cache serves stale metadata until some other source field is
596/// written. Field name is expected uppercase.
597///
598/// `DESC` feeds `display.description` but is deliberately absent: its
599/// invalidation is owned by the DESC arm of `put_common_field`, the single
600/// writer of `common.desc`. The `Q:form` info tag (`populate_display_info` ->
601/// `display.form`) is an immutable load-time tag, not a runtime field, so it
602/// needs no invalidation either.
603fn is_metadata_cache_source(name: &str) -> bool {
604 matches!(
605 name,
606 // `populate_display_info` — units/precision/display limits for the
607 // analog, integer, array and motor arms.
608 "EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
609 // `populate_control_info` — the ao/longout/int64out drive limits.
610 | "DRVH" | "DRVL"
611 // `populate_enum_info` via `Record::enum_state_strings` — bi/bo/busy
612 // two-state names and the sixteen mbbi/mbbo state strings.
613 | "ZNAM" | "ONAM"
614 | "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
615 | "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
616 )
617}
618
619/// One alarm limit for a DBR_AL_DOUBLE response: the value when its
620/// severity threshold is enabled, `NaN` otherwise. Mirrors C
621/// `get_alarm_double`'s `prec->hhsv ? prec->hihi : epicsNAN` — a NONZERO
622/// test on the raw ordinal, so an out-of-range severity still enables the
623/// limit.
624fn gated(severity: i16, limit: f64) -> f64 {
625 if severity != 0 { limit } else { f64::NAN }
626}
627
628/// Extract the RAW stored ordinal a put lands in a `menu(menuAlarmSevr)`
629/// severity field (`HHSV`/`HSV`/`LSV`/`LLSV`/`UDFS`/`DISS`), WITHOUT clamping
630/// to the 0..=3 valid range.
631///
632/// C's numeric menu put stores whatever `(epicsEnum16)` the value truncates to
633/// (`dbConvert.c::putDoubleEnum` = `*pfield = (epicsEnum16)*psrc`), so
634/// `caput REC.HSV 4` keeps `4` and `caput REC.HSV -1` keeps `65535` — both
635/// wire-visible (served signed as `-1`) and both used verbatim to derive the
636/// alarm. The carrier is `i16` so the 16-bit pattern round-trips; the alarm
637/// meaning is read back with [`AlarmSeverity::from_u16`] and the C nonzero
638/// enable with `!= 0`.
639///
640/// A numeric value has already been wrapped to `epicsEnum16` upstream
641/// (`EpicsValue::convert_to(Enum)`, the one owner of C's double→enum cast); this
642/// only reinterprets its bit pattern. A `String` is a db-load / internal-link
643/// label (a client string put is rejected-or-resolved by `putStringMenu`
644/// upstream), resolved to its ordinal here.
645fn menu_ordinal_raw(value: &EpicsValue) -> i16 {
646 match value {
647 EpicsValue::String(s) => match s.as_str_lossy().as_ref() {
648 "NO_ALARM" => 0,
649 "MINOR" => 1,
650 "MAJOR" => 2,
651 "INVALID" => 3,
652 other => other
653 .parse::<i64>()
654 .ok()
655 .map(|n| n as u16 as i16)
656 .unwrap_or(0),
657 },
658 other => other.to_f64().unwrap_or(0.0) as i64 as u16 as i16,
659 }
660}
661
662/// Coerce a db-loaded `String` for a numeric/menu **common** field to that
663/// field's canonical DBF type before [`RecordInstance::put_common_field`]
664/// dispatches on it.
665///
666/// The db loader applies a record's own fields with the typed
667/// `EpicsValue::parse(desc.dbf_type, value_str)` (`db_loader::apply_fields`),
668/// but a field absent from `field_list` is pushed to the common-field path as
669/// a raw `EpicsValue::String` — it has no `FieldDesc` to parse against. The
670/// numeric common-field arms in `put_common_field` match only their typed
671/// variant, so without this step a `.db` `field(PHAS, "1")`,
672/// `field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`, `field(DISA, "1")`, … is
673/// silently dropped at IOC load. Routing the String through the same
674/// `EpicsValue::parse` the record-field path uses handles the numeric *and*
675/// menu-label forms uniformly, so the arm receives the value it expects.
676///
677/// Only fields whose canonical type is numeric/menu are listed; the
678/// Port of libcom `epicsParseInt32(str, &to, 10, NULL)`
679/// (`libcom/src/misc/epicsStdlib.c:26-53,245-261`), which is how pvxs parses
680/// the `nsec:lsb:` digit count. Returns `None` for every status the C
681/// returns non-zero for:
682///
683/// - `S_stdlib_noConversion` — `strtol` consumed nothing (empty / no digits)
684/// - `S_stdlib_extraneous` — trailing non-space bytes with `units == NULL`
685/// - `S_stdlib_overflow` — outside `epicsInt32`
686///
687/// Leading and trailing whitespace — C `isspace`, vertical tab included — and
688/// a leading `+`/`-` sign are accepted, matching `epicsParseLong`'s skips and
689/// `strtol`.
690fn epics_parse_int32_base10(s: &str) -> Option<i32> {
691 // `while ((c = *str) && isspace(c)) ++str;` then `strtol(str, &endp, 10)`.
692 let body = s.trim_start_matches(crate::runtime::stdlib::c_isspace);
693 let (sign, digits) = match body.strip_prefix(['+', '-']) {
694 Some(rest) if body.starts_with('-') => (-1i64, rest),
695 Some(rest) => (1i64, rest),
696 None => (1i64, body),
697 };
698 let end = digits
699 .find(|c: char| !c.is_ascii_digit())
700 .unwrap_or(digits.len());
701 if end == 0 {
702 return None; // endp == str → S_stdlib_noConversion
703 }
704 // `if (c && !units) return S_stdlib_extraneous;` after skipping trailing
705 // whitespace.
706 if !digits[end..]
707 .trim_start_matches(crate::runtime::stdlib::c_isspace)
708 .is_empty()
709 {
710 return None;
711 }
712 // ERANGE from `strtol`, then the explicit `epicsInt32` range check.
713 let magnitude: i64 = digits[..end].parse().ok()?;
714 i32::try_from(sign * magnitude).ok()
715}
716
717/// The STORED type of a `dbCommon` field — the variant its
718/// [`RecordInstance::put_common_field_bounded`] arm binds, which is not always
719/// the type the `.dbd` DECLARES it as (a `menu()` field is declared `DBF_MENU`
720/// and served `DBR_ENUM`, but held here as its bare index).
721///
722/// String-typed common fields (DESC, ASG, OUT, TSEL, …) have no entry: their
723/// arms take the string verbatim.
724fn stored_common_field_type(name: &str, declared: Option<DbFieldType>) -> Option<DbFieldType> {
725 Some(match name {
726 "SCAN" | "SSCN" | "PINI" => DbFieldType::Enum,
727 "TSE" | "PHAS" | "PRIO" | "DISV" | "DISA" | "DISS" | "LCNT" | "UDFS" | "ACKT" | "ACKS"
728 | "SEVR" | "STAT" | "NSEV" | "NSTA" => DbFieldType::Short,
729 // The analog-alarm limits and the hysteresis margin are the one row
730 // here whose stored type is the DECLARED type, and it varies by record:
731 // `DBF_DOUBLE` on ai/ao/calc/calcout/sub/scalcout, `DBF_LONG` on
732 // longin/longout, `DBF_INT64` on int64in/int64out
733 // (`int64inRecord.dbd.pod:152-208`). Naming `Double` for all of them
734 // discarded the record's own `.dbd` row on both writers — the `.db`
735 // string parse and a runtime `dbPut` — so an `epicsInt64` limit above
736 // 2^53 was rounded before it ever reached storage. Ask the record's
737 // generated field table instead; a caller with no table (a hand-built
738 // test record) keeps the `DBF_DOUBLE` majority.
739 //
740 // A field missing from this table entirely reaches its arm as whatever
741 // variant the caller built, and an arm that binds a typed variant then
742 // drops it: that is how `field(HYST,"2")` silently became 0 on every
743 // record whose hysteresis lives in `common.hyst`.
744 "HIHI" | "HIGH" | "LOW" | "LOLO" | "HYST" => declared.unwrap_or(DbFieldType::Double),
745 // The `DBF_UCHAR` flags. `bool` here, a NUMBER in C.
746 "DISP" | "UDF" | "TPRO" | "RPRO" | "BKPT" | "PROC" => DbFieldType::Char,
747 _ => return None,
748 })
749}
750
751/// **The single owner of "what type does a `dbCommon` field hold"**, run on
752/// EVERY put before the typed arms below see the value — so an arm may bind one
753/// variant and know the put cannot have arrived in another.
754///
755/// This is not a string-parsing convenience. A common field is reached by three
756/// writers with three different ideas of the value's shape: the db loader hands
757/// every field over as a raw `String`; a `dbPut` arrives coerced to the field's
758/// DECLARED type (`DBF_MENU` → `Enum` for PRIO, `DBF_UCHAR` → `Char` for DISP);
759/// an internal link delivers whatever its source stored. Before this ran on the
760/// non-`String` shapes too, each arm's single-variant `if let` was a silent
761/// drop for the other two writers — `caput REC.PRIO HIGH` resolved its label to
762/// `Enum(2)` and then vanished at the arm, leaving PRIO at 0.
763///
764/// An unparseable String is returned as-is so the arm drops it, and a menu
765/// field's bad label FAILS the put (`S_db_badChoice`) rather than landing as
766/// index 0.
767fn coerce_common_field(
768 name: &str,
769 value: EpicsValue,
770 bound: MenuBound,
771 declared: Option<DbFieldType>,
772) -> CaResult<Converted> {
773 let Some(dbf) = stored_common_field_type(name, declared) else {
774 return Ok(Converted::Stored(value));
775 };
776 let EpicsValue::String(s) = &value else {
777 // Already typed: project onto the stored type through the one
778 // value-coercion owner. `convert_to` short-circuits a value that is
779 // already `dbf`, so the common case costs nothing.
780 return Ok(Converted::Stored(value.convert_to(dbf)));
781 };
782 let text = s.as_str_lossy();
783 // A `DBF_MENU` common field resolves its label against THAT field's own
784 // menu through the one converter every menu-field string put uses
785 // (C `dbConvert.c::putStringMenu`: exact label, else an index below
786 // `nChoice`, else `S_db_badChoice`) — the same rule the record-specific
787 // menu fields follow in `coerce_write_value`. The failure PROPAGATES: the
788 // field-blind `EpicsValue::parse` fallback below must never see a menu
789 // field, or `caput REC.PRIO Bogus` lands as index 0 instead of failing.
790 //
791 // SCAN/SSCN/PINI are menu fields like any other and go through the same
792 // converter. They used to each carry a hand-written `from_str` that drifted
793 // from C: `ScanType::from_str` case-folded and invented `"0.5 second"`
794 // aliases for menuScan's `".5 second"` (and mapped any out-of-range index
795 // to Passive), `SimModeScan::from_str` took any u16, `PiniMode::from_str`
796 // trimmed. C has ONE converter and it does none of that.
797 if let Some(choices) = super::menu_choices::shared_menu_choices(name) {
798 return super::menu_choices::resolve_menu_field_string_bounded(
799 name, choices, dbf, &text, bound,
800 )
801 .map(Converted::Stored);
802 }
803 // Numeric (non-menu) common field: C's `dbPut` runs the string through the
804 // SAME `epicsParse*` (`dbConvert.c` `putString*`) the record data fields use,
805 // and a non-zero status REFUSES the whole put (`dbAccess.c:1362`, mapped to
806 // `ECA_PUTFAIL`). Route it through the single owner of that conversion —
807 // [`c_parse::put_string`] — instead of the field-blind `EpicsValue::parse`,
808 // which wrapped (`256 as u8 == 0`) and swallowed the error (`Err(_) =>
809 // Ok(value)`), so `caput REC.PROC 256` and `caput REC.PROC notanumber` were
810 // accepted where C rejects them.
811 //
812 // Key the parse on the field's C-DECLARED width, not its stored variant: the
813 // `DBF_UCHAR` flags (DISP/UDF/TPRO/RPRO/BKPT/PROC, `dbCommon.dbd`) are held in
814 // the signed `Char` variant here but C parses them with `epicsParseUInt8`, so
815 // `caput REC.PROC 255` and `caput REC.PROC -1` (→255) are accepted and only
816 // `256`+/non-numeric refused. `put_string` returns the value in the declared
817 // variant; project it back onto the stored variant through the one
818 // value-coercion owner (byte-identity for `UChar`→`Char`).
819 let declared = match dbf {
820 DbFieldType::Char => DbFieldType::UChar,
821 other => other,
822 };
823 let Some(target) = c_parse::NumericField::of(declared) else {
824 // Unreachable: every numeric `stored_common_field_type` (Char/Short/
825 // Double, all with a numeric row) reaches here; the `Enum` menu types
826 // returned above. Keep the pre-parse value rather than panic.
827 return Ok(Converted::Stored(value));
828 };
829 // Hand the string over UNTRIMMED. C tests `*from == 0` on the raw bytes
830 // (`dbFastLinkConv.c:147`), so `" "` is not the empty string to it and
831 // falls through to `epicsParse*`, which refuses it; trimming first turned
832 // it into the accepted empty case. Nothing else was riding on the trim —
833 // `scan_int`/`strtod` skip leading `isspace` themselves, and `epicsParse*`
834 // is called with a non-NULL `units` pointer, so trailing text is legal.
835 Ok(match c_parse::put_string(name, target, &text)? {
836 c_parse::Converted::Stored(parsed) => Converted::Stored(parsed.convert_to(dbf)),
837 c_parse::Converted::Unchanged => Converted::Unchanged,
838 })
839}
840
841/// The alarm-acknowledge request types C's `dbPut` dispatches on
842/// (`dbAccess.c:1331-1335`): `DBR_PUT_ACKT` and `DBR_PUT_ACKS`.
843///
844/// Acknowledgement is a *request type*, not a field write — the two handlers
845/// run above the `SPC_NOMOD` gate that refuses every ordinary put to ACKT/ACKS.
846#[derive(Debug, Clone, Copy, PartialEq, Eq)]
847pub enum AlarmAck {
848 /// `DBR_PUT_ACKT` → `putAckt`: set transient-alarm acknowledgement.
849 Transient,
850 /// `DBR_PUT_ACKS` → `putAcks`: acknowledge an alarm of this severity.
851 Severity,
852}
853
854/// A type-erased record instance stored in the database.
855pub struct RecordInstance {
856 pub name: String,
857 pub record: Box<dyn Record>,
858 pub common: CommonFields,
859 pub subscribers: HashMap<String, Vec<Subscriber>>,
860 /// Terminal destruction marker, the [`crate::server::pv::ProcessVariable`]
861 /// flag's counterpart for a record-backed channel. Set once by
862 /// [`Self::destroy`], whose only caller is
863 /// [`crate::server::database::PvDatabase::remove_record`], so *removed
864 /// from the database* and *destroyed* are one event for both target
865 /// kinds and a server can sweep them with one uniform test.
866 destroyed: bool,
867 // Link parse cache
868 pub parsed_inp: ParsedLink,
869 pub parsed_out: ParsedLink,
870 pub parsed_flnk: ParsedLink,
871 pub parsed_sdis: ParsedLink,
872 pub parsed_tsel: ParsedLink,
873 // Device support
874 pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
875 // Subroutine (for sub records)
876 pub subroutine: Option<Arc<SubroutineFn>>,
877 /// The by-name function registry this record's PENDING `init_record` pass
878 /// 1 will resolve INAM/SNAM against — C `registryFunctionFind`, reading a
879 /// process-global table from inside `init_record`.
880 ///
881 /// One meaning on every path: armed by the creation sink immediately
882 /// before [`Self::run_init_passes`], consumed (and cleared) by the pass
883 /// itself. It exists because the lookup's failure is an EARLY RETURN — the
884 /// init tail must not run past it — and only the init owner can honour
885 /// that, while only the database holds the registry. Resolving from
886 /// outside the passes, as both builders used to, put the lookup after the
887 /// tail it is supposed to skip.
888 init_subroutines: Option<Arc<HashMap<String, Arc<SubroutineFn>>>>,
889 /// PACT (C `precord->pact`) — the re-entrancy guard, and the record's
890 /// "busy" state for every put that lands on it.
891 ///
892 /// PRIVATE by construction: entered through [`RecordInstance::enter_pact`]
893 /// and released ONLY through [`RecordInstance::leave_pact`], which hands
894 /// back the [`PactExit`] that routes the release to the cycle tail where
895 /// queued put-notifies are restarted. A `pact.store(false)` open-coded at
896 /// a release site is what skipped that tail on the ODLY/SDLY paths; it is
897 /// no longer expressible.
898 pact: AtomicBool,
899 // Put-notify wait-set this record currently belongs to (C
900 // `precord->ppn`). Set when the record joins an active put-notify
901 // (originating put target, or a FLNK/OUT PP target via `dbNotifyAdd`);
902 // taken + `leave`d when the record's processing completes. `None`
903 // outside any put-notify. See [`NotifyWaitSet`].
904 // Private to the crate: the two writers ([`RecordInstance::
905 // install_or_queue_notify`] and [`RecordInstance::join_put_notify`]) are
906 // the slot's only assignment sites, and a `pub` field made a third one
907 // constructible from outside. Read it with [`RecordInstance::has_notify`].
908 pub(crate) notify: Option<Arc<NotifyWaitSet>>,
909 /// C `precord->ppnr->restartList` — put-notifies waiting to take this
910 /// record, oldest first.
911 ///
912 /// `processNotifyCommon` (dbNotify.c:213-219, 225-231) tests both
913 /// "another processNotify owns the record" and `precord->pact` ABOVE
914 /// `putCallback`, so a `dbPutNotify` onto a busy record writes nothing:
915 /// no value, no RPRO. The whole put — value, process, callback — is
916 /// deferred and restarted later. C queues them with `ellSafeAdd` and
917 /// promotes one per completion (`restartCheck`, dbNotify.c:149-170); this
918 /// is that list, and modelling it as a list rather than one slot is what
919 /// stops the second concurrent `caput -c` being refused with an
920 /// `ECA_PUTCBINPROG` C never sends.
921 ///
922 /// PRIVATE. Appended only by [`Self::queue_notify_put`], drained only by
923 /// [`Self::take_next_notify_restart`], which pops only onto a record no
924 /// put-notify owns.
925 notify_restart_list: std::collections::VecDeque<DeferredNotify>,
926 /// The value of each subscribed field as ALREADY PUBLISHED to that
927 /// field's `DBE_VALUE`/`DBE_LOG` subscribers. The generic
928 /// change-detection loop in every snapshot builder posts a field only
929 /// when its current value differs from this — so this map is what
930 /// C's per-record `*_lst` / MARK state is to `monitor()`.
931 ///
932 /// # Invariant (CONTRACT)
933 ///
934 /// A field's value MUST NOT be published twice by the framework.
935 /// Concretely: every value-class post (a `db_post_events` carrying
936 /// `DBE_VALUE` and/or `DBE_LOG`) MUST advance this map for the field it
937 /// posts; an alarm-only / property-only post MUST NOT (those classes do
938 /// not deliver the value to a `DBE_VALUE`/`DBE_LOG` subscriber, so the
939 /// change is still owed to them).
940 ///
941 /// In C, `dbPut` (dbAccess.c:1407-1414) is the record's ONLY post for a
942 /// put: `db_post_events(precord, pfieldsave, DBE_VALUE|DBE_LOG)`. No
943 /// record's `monitor()` re-posts that field — it posts a closed set and
944 /// compares against its own `*_lst` fields. A framework that posts on the
945 /// put and then change-detects the same field on the next process cycle
946 /// sends an event C never sends.
947 ///
948 /// # Owner
949 ///
950 /// [`RecordInstance::record_value_post`] is the SINGLE writer. The field
951 /// is private so no path outside this module can advance (or fail to
952 /// advance) it: the snapshot builders read it through
953 /// [`RecordInstance::posted_value`] and every poster —
954 /// [`RecordInstance::notify_field_with_origin`] included — advances it
955 /// through the owner.
956 last_posted: HashMap<String, EpicsValue>,
957 /// The live store for a field the record's `.dbd` DECLARES but the record
958 /// struct has no `put_field` arm / no storage for — the WRITE analog of the
959 /// read-side [`Self::declared_default`] fallback.
960 ///
961 /// C makes every `.dbd` field not just readable but WRITABLE: `dbPutField`
962 /// resolves the field from its `dbFldDes` and `dbPut` writes the incoming
963 /// value into record memory, whether or not any record code ever reads it
964 /// back — a `caput dfanout.HOPR 10` sticks even though `dfanoutRecord.c`
965 /// never touches HOPR. A Rust record models only the fields it has
966 /// behaviour for, so a field it declares but never stores had nowhere for a
967 /// put to land: [`Self::put_common_field`]'s catch-all reported
968 /// `S_dbLib_fieldNotFound` and the client's put was refused, while a READ of
969 /// the same field succeeded through `declared_default`. This map is that
970 /// missing storage — one uniform mechanism for the whole family, not a
971 /// per-field struct member on each record type.
972 ///
973 /// Keyed by upper-case field name, holding the value already coerced to the
974 /// field's C-declared DBF type (the same projection `declared_default` and
975 /// the read path serve). [`Self::resolve_field`] reads it BEFORE
976 /// `declared_default`, so a read reflects a prior write and an untouched
977 /// field still reads its `.dbd` initial. Empty for a record whose declared
978 /// fields are all modeled.
979 declared_overrides: HashMap<String, EpicsValue>,
980 /// This record's OWN link fields whose target supplies some field's
981 /// units/precision/graphic/alarm — the distinct answers of
982 /// [`Record::link_backed_metadata_field`] over the record's declared
983 /// field list, collected ONCE here.
984 ///
985 /// Derived, never declared a second time: the record type states the
986 /// mapping in one place and this is the reverse index of that one
987 /// statement, so the two cannot drift the way the central
988 /// `match rtype` list they replace drifted away from `aSub`.
989 link_backed_metadata_links: Vec<String>,
990 /// Set by `check_deadband_ext` for waveform/aai/aao when their
991 /// content hash changed this cycle (C `monitor()` On Change mode,
992 /// waveformRecord.c:310-319). The snapshot builders read it to post
993 /// `HASH` with a literal `DBE_VALUE` event, independent of the VAL
994 /// post mask. False for every record without the MPST/APST/HASH
995 /// mechanism.
996 pub(crate) array_hash_changed: bool,
997 /// One-shot "skip the registered subroutine this cycle" signal for aSub
998 /// `LFLG=READ`. The async processing path resolves the `SUBL` link before
999 /// taking this lock; when the resolved name is bad (C `fetch_values` ->
1000 /// `S_db_BadSub`) or the link read failed, C `process` runs `do_sub` only
1001 /// on `!status`, so the subroutine is skipped. Set by the resolution
1002 /// apply, consumed (and cleared) by [`Self::run_registered_subroutine`];
1003 /// `false` for every record without a pending bad re-resolution.
1004 pub(crate) suppress_subroutine_run: bool,
1005 /// Generation counter for ReprocessAfter timer cancellation.
1006 /// Bumped each process cycle. Spawned timers check this to avoid
1007 /// stale re-processes from accumulated timers.
1008 pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
1009 /// Generation counter for the monitor watchdog
1010 /// ([`Record::watchdog_interval`] / [`Record::watchdog_fire`]), bumped by
1011 /// each `PvDatabase::arm_watchdog` so a re-arm supersedes the tick already
1012 /// in flight — C `callbackRequestDelayed` replacing an outstanding delayed
1013 /// callback. Deliberately NOT `reprocess_generation`: C's histogram wdog is
1014 /// its own `epicsCallback`, independent of the record's SDLY/async
1015 /// re-entry, so an SDLY defer must not cancel the watchdog nor vice versa.
1016 pub watchdog_generation: Arc<std::sync::atomic::AtomicU64>,
1017 /// Per-record info tags from `info("key", "value")` directives in
1018 /// the .db file (epics-base info(...) grammar). Consumers include
1019 /// asyn (`asyn:READBACK`), record-as-PV bridge tags
1020 /// (`Q:group`, `Q:form`), and IOC-specific extensions. Empty for
1021 /// records loaded without info(...) clauses.
1022 pub info: HashMap<String, String>,
1023 /// Cached metadata (display/control/enums) — `None` means stale or
1024 /// not yet built. Populated lazily by `snapshot_for_field` /
1025 /// `make_monitor_snapshot` and invalidated by `invalidate_metadata_cache`
1026 /// whenever a metadata-class field (EGU/PREC/HOPR/LOPR/limit/state)
1027 /// is written.
1028 ///
1029 /// Wrapped in `std::sync::Mutex` for interior mutability — the
1030 /// containing `RecordInstance` is shared via `Arc<RwLock<...>>` from
1031 /// `PvDatabase`, and snapshot construction holds a read lock; the
1032 /// inner Mutex lets us still mutate the cache from a `&self` method.
1033 ///
1034 /// # Cache invariant (CONTRACT)
1035 ///
1036 /// The cache is **only correct under the following contract**: every
1037 /// code path that mutates a cache-source field (the set defined in
1038 /// the file-private [`is_metadata_cache_source`] predicate) MUST call
1039 /// [`RecordInstance::notify_field_written`] (or
1040 /// [`RecordInstance::invalidate_metadata_cache`] directly) afterward.
1041 ///
1042 /// All current write paths in `field_io.rs` already do this. If you
1043 /// add a new code path that:
1044 ///
1045 /// - calls `instance.record.put_field(...)` directly, OR
1046 /// - mutates record fields from inside `Record::process()`,
1047 /// `Record::on_put`, or `Record::special` and that mutation could
1048 /// touch a cache-source field, OR
1049 /// - lets a `Box<dyn Record>` implementation expose its own
1050 /// mutation methods that change cache-source fields,
1051 ///
1052 /// then call `instance.notify_field_written(field_name)` to keep the
1053 /// cache consistent. Forgetting will produce a stale snapshot —
1054 /// monitors will continue to see the old EGU/PREC/limits until the
1055 /// next legitimate cache-source write triggers invalidation.
1056 ///
1057 /// # Symmetric note for `populate_*` extensions
1058 ///
1059 /// If a future change adds a new field to `populate_display_info`,
1060 /// `populate_control_info`, or `populate_enum_info`, the new source
1061 /// field name MUST also be added to [`is_metadata_cache_source`] so
1062 /// writes to it invalidate the cache — unless, like DESC
1063 /// (`display.description`), its write owner invalidates directly (see
1064 /// the DESC arm of `put_common_field`). This set says nothing about
1065 /// `DBE_PROPERTY`, which the field's own `prop(YES)` declaration
1066 /// decides ([`RecordInstance::field_posts_property`]). (The `Q:form`
1067 /// -> `display.form` mapping is exempt: it reads an immutable
1068 /// load-time info tag, not a runtime field.)
1069 pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
1070}
1071
1072/// The cycle status [`RecordInstance::run_registered_subroutine`] reports when
1073/// `do_sub` was skipped — C's `fetch_values` failure / `S_db_BadSub` path, which
1074/// leaves `process`'s `status` non-zero (aSubRecord.c:216-224).
1075const SUBROUTINE_STATUS_SKIPPED: i64 = -1;
1076/// Which C `do_sub` a record type owns. Only `subRecord.c` and `aSubRecord.c`
1077/// define one; every other record type reaches
1078/// [`RecordInstance::run_registered_subroutine`] with no subroutine bound for
1079/// the trivial reason that it never had one, and must not be handed `do_sub`'s
1080/// bad-sub verdict. Resolving the kind once also keeps the two record types'
1081/// three points of divergence (empty-SNAM exemption, bad-sub status, UDF
1082/// clear) reading off one decision instead of three `record_type()` compares.
1083#[derive(Clone, Copy, PartialEq, Eq)]
1084enum SubroutineKind {
1085 /// `subRecord.c::do_sub` — VAL is the subroutine's computed value.
1086 Sub,
1087 /// `aSubRecord.c::do_sub` — VAL is the returned status.
1088 ASub,
1089}
1090
1091impl SubroutineKind {
1092 fn of(record_type: &str) -> Option<Self> {
1093 match record_type {
1094 "sub" => Some(Self::Sub),
1095 "aSub" => Some(Self::ASub),
1096 _ => None,
1097 }
1098 }
1099}
1100
1101/// C `S_db_BadSub` — `(M_dbAccess | 35)` with `M_dbAccess = 511 << 16`
1102/// (`dbAccessDefs.h:189`, `errMdef.h:39`), i.e. 33488931. aSub's `do_sub`
1103/// returns it verbatim for an unregistered SNAM and `process` publishes it as
1104/// VAL, so the number is observable on the wire and cannot be a private
1105/// sentinel.
1106const S_DB_BAD_SUB: i64 = (511 << 16) | 35;
1107/// The bound subroutine returned `Err` — no C counterpart (a C subroutine
1108/// returns a `long`), and a failed cycle either way.
1109const SUBROUTINE_STATUS_ERROR: i64 = -3;
1110
1111/// C `monitor()`'s post of the deadband field, as assembled by the single owner
1112/// [`RecordInstance::deadband_post`].
1113pub(crate) struct DeadbandPost {
1114 /// C's `monitor_mask` for this cycle. Also the mask the
1115 /// [`Record::fields_posted_with_value_mask`] secondaries ride: C posts them
1116 /// from INSIDE the `if (monitor_mask)` guard, with the same mask.
1117 pub mask: EventMask,
1118 /// The deadband field's own post — `(field, value)`. `None` when no class
1119 /// fired (C's `if (monitor_mask)` skips the post) or the field does not
1120 /// resolve.
1121 pub field: Option<(String, EpicsValue)>,
1122}
1123
1124/// A value's `DBR_STRING` form, for a source whose field metadata is NOT
1125/// reachable (an external CA/PVA link, a constant, an lnkCalc result) — the
1126/// fallback half of [`RecordInstance::field_as_dbr_string`], which is also the
1127/// whole rule once the local choice table has had its say.
1128///
1129/// A pvalink NTEnum still resolves its label here: the carrier brings its own
1130/// `choices` (pvxs `pvxs/ioc/pvalink_lset.cpp:344-356` — a `DBR_STRING` target copies
1131/// `choices[index]`). A bare `Enum` index from a link whose labels the port
1132/// cannot reach falls back to its decimal form, like the CA `*_STRING` encoder.
1133/// **The** declaration lookup: a field's `dbFldDes`, in C's terms.
1134///
1135/// The record type's declaration is [`FieldDeclaration::field_list`] — the
1136/// generated `.dbd` table where one exists and the hand-written table where it
1137/// does not, never both. `dbCommon` is asked last, so a record-specific field
1138/// shadows the common one.
1139///
1140/// A free function, not just a [`RecordInstance`] method, because the sites that
1141/// need the declaration do not all hold an instance — a constant-link seed, a
1142/// link write, the db loader all have `&dyn Record`.
1143///
1144/// `None` for a field with no declaration at all: a virtual field (`RTYP`,
1145/// `TIME`, ...), which C answers from dbStaticLib rather than from a `dbFldDes`.
1146pub(crate) fn field_desc_of<R: Record + ?Sized>(
1147 record: &R,
1148 field: &str,
1149) -> Option<&'static FieldDesc> {
1150 let named = |t: &'static [FieldDesc]| t.iter().find(|f| f.name.eq_ignore_ascii_case(field));
1151 named(record.field_list()).or_else(|| named(super::dbd_generated::DB_COMMON_FIELDS))
1152}
1153
1154/// **The single owner of "which choice list does this field resolve against"**,
1155/// asked by BOTH sides of a menu field:
1156///
1157/// * the READ side ([`RecordInstance::enum_string_form_for`]) — C `getMenuString`,
1158/// which renders the stored index as its choice;
1159/// * the WRITE side ([`crate::server::record::coerce_put_value`]) — C
1160/// `putStringMenu`, which resolves an incoming label to that same index.
1161///
1162/// They MUST see the same list or the field is not round-trippable. The write
1163/// side used to ask only [`Record::menu_field_choices`] (the record's hand
1164/// table), so an `aSub`'s `caput FTA LONG` found no menu, fell through to the
1165/// numeric parse and landed as index 0 — while the read side, on the `.dbd`
1166/// menu, rendered index 0 as `STRING`.
1167///
1168/// Order is C's: the field's own `menu()` from the declaration, then the
1169/// record's hand table where the `.dbd` does not reach (the downstream crates'
1170/// record types), then the `dbCommon` menus.
1171///
1172/// The last step — [`shared_menu_choices`](super::menu_choices::shared_menu_choices)
1173/// — is a heuristic keyed on the field NAME (`OSV`, `SIMS`, `HHSV`, …), so it is
1174/// consulted ONLY when the field's declaration does not already pin a non-menu
1175/// type: a menu is served as `DBR_ENUM`, so a field DECLARED `DBF_STRING` is
1176/// never a menu. Without this gate scalcout's string `OSV` ("Output string
1177/// value") matched the name-based `menuAlarmSevr` entry a same-named bi/bo
1178/// severity field owns, and `caput SCALCOUT.OSV <string>` was rejected with
1179/// `S_db_badChoice` where C accepts the string.
1180pub(crate) fn menu_choices_of<R: Record + ?Sized>(
1181 record: &R,
1182 field: &str,
1183) -> Option<&'static [&'static str]> {
1184 // `DTYP` is `DBF_DEVICE`: its choices are the record type's DEVICE menu
1185 // (C `dbDeviceMenu`, built from the `device()` declarations), which is
1186 // per-record-type and so cannot live in the shared `dbCommon` FieldDesc.
1187 if field.eq_ignore_ascii_case("DTYP") {
1188 return super::dbd_generated::device_menu(record.record_type());
1189 }
1190 let desc = field_desc_of(record, field);
1191 desc.and_then(|f| f.menu)
1192 .or_else(|| record.menu_field_choices(field))
1193 .or_else(|| {
1194 // Name-based fallback — but the declared type wins: a `DBF_STRING`
1195 // field is not a menu even when a same-named field elsewhere is.
1196 if desc.is_some_and(|f| f.dbf_type == DbFieldType::String) {
1197 None
1198 } else {
1199 super::menu_choices::shared_menu_choices(field)
1200 }
1201 })
1202}
1203
1204/// The `DBF_*` type `field` is SERVED as, for a caller that holds only a
1205/// `&dyn Record` — the free-function form of
1206/// [`RecordInstance::declared_field_type`], and the ONLY way any site outside
1207/// the instance may turn a [`FieldDesc`] into a type.
1208///
1209/// A [`FieldDesc::runtime_typed`] field (`waveform.VAL` typed by `FTVL`, an
1210/// `aSub`'s `A`..`U` typed by `FTA`..`FTU`) has NO type in its declaration: C's
1211/// `cvt_dbaddr` overwrites `paddr->field_type` from record state, so the `.dbd`
1212/// entry is a placeholder and the value the record stores is the answer. Every
1213/// caller falls back to that value, which is why this returns `None` rather than
1214/// the placeholder — handing out `DBF_DOUBLE` for a `FTVL=CHAR` waveform is how
1215/// a string written down an output link became `0.0`.
1216pub(crate) fn declared_field_type_of<R: Record + ?Sized>(
1217 record: &R,
1218 field: &str,
1219) -> Option<DbFieldType> {
1220 let desc = field_desc_of(record, field)?;
1221 (!desc.runtime_typed).then_some(desc.dbf_type)
1222}
1223
1224pub(crate) fn value_as_dbr_string(value: &EpicsValue) -> Option<PvString> {
1225 match value {
1226 EpicsValue::String(s) => Some(s.clone()),
1227 EpicsValue::Enum(v) => Some(PvString::from(v.to_string())),
1228 EpicsValue::EnumWithChoices { index, choices } => Some(
1229 choices
1230 .get(*index as usize)
1231 .cloned()
1232 .unwrap_or_else(|| PvString::from(index.to_string())),
1233 ),
1234 other => match other.clone().convert_to(DbFieldType::String) {
1235 EpicsValue::String(s) => Some(s),
1236 _ => None,
1237 },
1238 }
1239}
1240
1241/// The `dbCommon` link fields, each with the C link-field type its text is
1242/// parsed under (`dbStaticLib.c:2380-2391`): `INP`/`TSEL`/`SDIS` are
1243/// `DBF_INLINK`, `OUT` is `DBF_OUTLINK`, `FLNK` is `DBF_FWDLINK`.
1244///
1245/// These five — and only these five — have a parse cache on
1246/// [`RecordInstance`], which is what lets a one-shot init decision (C
1247/// `dbInitLink` setting `DBLINK_FLAG_INITIALIZED`) be committed for them.
1248/// The list has one owner because it is read from three places that must not
1249/// drift: the per-field parse in `put_common_field`, the database's
1250/// `record_link_fields` enumeration, and the `initialize_link_locality`
1251/// commit. `FLNK` missing from just one of them is exactly how an external
1252/// forward link went un-opened at init.
1253pub const COMMON_LINK_FIELDS: [(&str, super::link::LinkFieldType); 5] = [
1254 ("INP", super::link::LinkFieldType::In),
1255 ("OUT", super::link::LinkFieldType::Out),
1256 ("TSEL", super::link::LinkFieldType::In),
1257 ("SDIS", super::link::LinkFieldType::In),
1258 ("FLNK", super::link::LinkFieldType::Fwd),
1259];
1260
1261impl RecordInstance {
1262 pub fn new(name: String, record: impl Record) -> Self {
1263 Self::new_boxed(name, Box::new(record))
1264 }
1265
1266 /// The raw text of one `COMMON_LINK_FIELDS` entry, or `None` for any
1267 /// other field name.
1268 pub fn common_link_text(&self, field: &str) -> Option<&str> {
1269 Some(match field {
1270 "INP" => self.common.inp.as_str(),
1271 "OUT" => self.common.out.as_str(),
1272 "TSEL" => self.common.tsel.as_str(),
1273 "SDIS" => self.common.sdis.as_str(),
1274 "FLNK" => self.common.flnk.as_str(),
1275 _ => return None,
1276 })
1277 }
1278
1279 /// The parse cache of one `COMMON_LINK_FIELDS` entry, or `None` for any
1280 /// other field name. The only mutable handle on the cache outside
1281 /// `put_common_field`, so the iocInit locality commit cannot reach a slot
1282 /// that has no matching raw text.
1283 pub fn common_link_cache_mut(&mut self, field: &str) -> Option<&mut ParsedLink> {
1284 Some(match field {
1285 "INP" => &mut self.parsed_inp,
1286 "OUT" => &mut self.parsed_out,
1287 "TSEL" => &mut self.parsed_tsel,
1288 "SDIS" => &mut self.parsed_sdis,
1289 "FLNK" => &mut self.parsed_flnk,
1290 _ => return None,
1291 })
1292 }
1293
1294 /// The link fields whose target metadata this record's rset serves — the
1295 /// work list [`PvDatabase::resolve_link_backed_metadata`] resolves for a
1296 /// batch post, and the set `Self::link_backed_metadata_field_of` answers
1297 /// one field out of.
1298 ///
1299 /// [`PvDatabase::resolve_link_backed_metadata`]: crate::server::database::PvDatabase
1300 pub fn link_backed_metadata_links(&self) -> &[String] {
1301 &self.link_backed_metadata_links
1302 }
1303
1304 pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
1305 let rtype = record.record_type();
1306 // The reverse index of `Record::link_backed_metadata_field`, built once
1307 // from the record's own declaration so no second list can go stale.
1308 // Empty for every record type that answers `None` — which is all but
1309 // calc, calcout, sub, seq and aSub.
1310 let link_backed_metadata_links: Vec<String> = {
1311 use crate::server::record::FieldDeclaration;
1312 let mut links: Vec<String> = record
1313 .field_list()
1314 .iter()
1315 .filter_map(|d| record.link_backed_metadata_field(d.name))
1316 .collect();
1317 links.sort_unstable();
1318 links.dedup();
1319 links
1320 };
1321 let analog_alarm = match rtype {
1322 // C parity: every record type whose dbd carries
1323 // HIHI/HIGH/LOW/LOLO/HHSV/HSV/LSV/LLSV gets an analog-alarm
1324 // config slot. Previously calc / calcout were missing —
1325 // their put_field for those fields silently no-op'd
1326 // because `self.common.analog_alarm` was None at the
1327 // mutation site. Confirmed via
1328 // calcRecord.dbd.pod:716-744 (HIHI..LLSV) and
1329 // calcoutRecord.dbd.pod:1103+ (same). `sub` carries the same
1330 // HIHI/HIGH/LOLO/LOW + HHSV/HSV/LSV/LLSV set
1331 // (subRecord.dbd.pod:569-642) and runs the analog `checkAlarms`.
1332 // `scalcout` declares the identical set (`sCalcoutRecord.dbd:479-531`
1333 // HIHI/LOLO/HIGH/LOW/HHSV/LLSV/HSV/LSV/HYST + `:858` LALM) and its
1334 // `checkAlarms` (`sCalcoutRecord.c:699-752`) is the same ladder, run
1335 // BEFORE the OOPT switch (`:374`) precisely so a limit excursion can
1336 // drive IVOA. Without the slot the record had no alarm surface at
1337 // all: `caput scalc.HIHI 5` was a `FieldNotFound` and a scalcout
1338 // could never go MINOR/MAJOR on its own result.
1339 //
1340 // **This match is the single owner of "which records have the analog
1341 // ladder"** — `evaluate_alarms` runs it off the slot's presence, so a
1342 // record added here gets the ladder and one absent cannot.
1343 "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout"
1344 | "sub" | "scalcout" => Some(AnalogAlarmConfig::default()),
1345 _ => None,
1346 };
1347 let mut common = CommonFields::default();
1348 common.analog_alarm = analog_alarm;
1349
1350 Self {
1351 destroyed: false,
1352 name,
1353 record,
1354 common,
1355 subscribers: HashMap::new(),
1356 parsed_inp: ParsedLink::None,
1357 parsed_out: ParsedLink::None,
1358 parsed_flnk: ParsedLink::None,
1359 parsed_sdis: ParsedLink::None,
1360 parsed_tsel: ParsedLink::None,
1361 device: None,
1362 subroutine: None,
1363 init_subroutines: None,
1364 pact: AtomicBool::new(false),
1365 notify: None,
1366 notify_restart_list: std::collections::VecDeque::new(),
1367 last_posted: HashMap::new(),
1368 declared_overrides: HashMap::new(),
1369 link_backed_metadata_links,
1370 array_hash_changed: false,
1371 suppress_subroutine_run: false,
1372 reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1373 watchdog_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1374 info: HashMap::new(),
1375 metadata_cache: StdMutex::new(None),
1376 }
1377 }
1378
1379 /// **The owner of a record's init passes** — C `iocInit.c::doInitRecord0`
1380 /// (`:508-536`) and `doInitRecord1`. Nothing else may call
1381 /// `Record::init_record`.
1382 ///
1383 /// C runs a prologue on EVERY record before pass 0, and it is the reason
1384 /// this is one function instead of two `init_record` calls at each caller:
1385 ///
1386 /// ```c
1387 /// /* Reset the process active field */
1388 /// precord->pact = FALSE;
1389 ///
1390 /// /* Initial UDF severity */
1391 /// if (precord->udf && precord->stat == UDF_ALARM)
1392 /// precord->sevr = precord->udfs;
1393 /// ```
1394 ///
1395 /// A record is born `udf = 1`, `stat = UDF_ALARM` (dbCommon.dbd
1396 /// `initial("UDF")`), `udfs = INVALID` — so after `iocInit` a record that
1397 /// has NEVER processed advertises `STAT=UDF SEVR=INVALID`, not
1398 /// `NO_ALARM`. That is what makes an `MS` consumer inherit
1399 /// `LINK`/`INVALID` from a not-yet-processed source, the IOC-startup
1400 /// ordering case MS exists for (softIoc-verified). A record whose
1401 /// `init_record` or device support defines the value clears UDF and the
1402 /// severity goes away on its first process.
1403 ///
1404 /// `name` is used only for the init-failure diagnostics C sends to errlog.
1405 ///
1406 /// Crate-private on purpose: the passes must run against the record's FINAL
1407 /// loaded field set (the initial UDF severity is a function of UDF/STAT/
1408 /// UDFS, and a `.db` `field(VAL,…)` clears UDF at load — C
1409 /// `dbStaticLib.c:2653-2661`). The one caller is the creation sink,
1410 /// [`crate::server::database::PvDatabase::add_loaded_record`], which takes
1411 /// the load and the record together so no path can init a half-loaded
1412 /// record.
1413 /// Hand the record the function registry its pending init pass 1 resolves
1414 /// INAM/SNAM against. The creation sink's line; see [`Self::
1415 /// init_subroutines`].
1416 pub(crate) fn arm_init_subroutines(
1417 &mut self,
1418 registry: Arc<HashMap<String, Arc<SubroutineFn>>>,
1419 ) {
1420 self.init_subroutines = Some(registry);
1421 }
1422
1423 /// C's `if (!pdset) { recGblRecordError(S_dev_noDSET, prec, "init_record");
1424 /// return S_dev_noDSET; }` — whether this record's `init_record` gets past
1425 /// its own first statement.
1426 ///
1427 /// Three terms, each one C's:
1428 /// * [`crate::server::recgbl::dev_sup_refusal`] IS the set of record types
1429 /// whose `init_record` opens with that test — the same table that
1430 /// supplies the message, so the refusal and the return can never
1431 /// disagree about which types make it.
1432 /// * a soft DTYP (`""`, `Soft Channel`, `Raw Soft Channel`, `Async Soft
1433 /// Channel`) resolves to a dset C always links, so the test passes.
1434 /// `""` counts because C's DTYP index 0 is the record type's FIRST
1435 /// `device()` line, which for every soft record type is the soft
1436 /// channel.
1437 /// * anything else needs a device the resolver produced. `None` after the
1438 /// creation sink has run its bind is C's `pdevSup == NULL`.
1439 pub(crate) fn init_record_reaches_body(&self) -> bool {
1440 self.device.is_some()
1441 || crate::server::device_support::is_soft_dtyp(&self.common.dtyp)
1442 || crate::server::recgbl::dev_sup_refusal(self.record.record_type()).is_none()
1443 }
1444
1445 /// C `registryFunctionFind` inside `init_record` pass 1, for the record
1446 /// types that make it. Returns whether the pass reached its tail.
1447 ///
1448 /// Unarmed (`init_subroutines` is `None`) means no registry was handed
1449 /// over — an iocsh `dbLoadRecords` merge re-running the passes on a record
1450 /// that already resolved. Nothing to look up again, and C's tail is
1451 /// reached.
1452 fn resolve_init_subroutine(&mut self, name: &str) -> bool {
1453 match self.init_subroutines.take() {
1454 Some(registry) => crate::server::ioc_app::wire_subroutine(self, name, ®istry),
1455 None => true,
1456 }
1457 }
1458
1459 pub(crate) fn run_init_passes(&mut self, name: &str) {
1460 // C's `precord->pact = FALSE` — a record cannot be mid-process at init,
1461 // so this release provably frees nothing: no client put has run, so the
1462 // restart list is empty.
1463 debug_assert!(
1464 self.notify_restart_list.is_empty(),
1465 "a record cannot hold a queued put-notify at init"
1466 );
1467 let _ = self.leave_pact();
1468 if self.common.udf != 0
1469 && self.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM
1470 {
1471 self.common.sevr = AlarmSeverity::from_u16(self.common.udfs as u16);
1472 }
1473 // C `<rec>Record.c::init_record`'s FIRST statement, for the 26 record
1474 // types that make it: `if (!pdset) { recGblRecordError(S_dev_noDSET,
1475 // …); return S_dev_noDSET; }` (`aiRecord.c:105-110`,
1476 // `aoRecord.c:107-110`, …). Everything below it — `ao`'s `prec->init =
1477 // TRUE`, `ai`'s and `sub`'s MLST/ALST/LALM seed, `mbbo`'s SDEF fold —
1478 // is unreachable for a record whose dset is NULL, and running the
1479 // passes anyway is what left those cells set where softIoc reads 0.
1480 //
1481 // It is the OWNER's test, not each record's, for one structural
1482 // reason: `Record::init_record` is on the record BODY, which cannot
1483 // see `RecordInstance::device`. Asking every record type to re-derive
1484 // the answer is the per-cell patch this replaces.
1485 if !self.init_record_reaches_body() {
1486 return;
1487 }
1488 if let Err(e) = self.record.init_record(0) {
1489 eprintln!("init_record(0) failed for {name}: {e}");
1490 }
1491 // C `iocInit.c::doResolveLinks` (`:545-570`), the pass BETWEEN the two
1492 // `init_record` calls: for each device link the record type declares,
1493 // `pdsxt->add_record(precord)` and then `dbInitLink` for that same
1494 // link. This is that call, and it keeps C's position — before the link
1495 // it complains about is initialised, after pass 0 has run.
1496 crate::server::builtin_devices::soft_callback::add_record(self, name);
1497 if let Err(e) = self.record.init_record(1) {
1498 eprintln!("init_record(1) failed for {name}: {e}");
1499 }
1500 // C `pdset->common.init_record(prec)` — the driver's own init, which
1501 // every record type calls from INSIDE `init_record` once the dset test
1502 // above has passed (`aiRecord.c:115-124`, `aoRecord.c:121-133`). It
1503 // ran after both passes and after the constant-link seed while device
1504 // support was attached by a whole-database pass; it is here now
1505 // because the dset is bound before the passes. Residual difference
1506 // from C, not closed by this move: C calls it BEFORE the record's own
1507 // tail (`prec->mlst = prec->val`) and the port's tail is inside
1508 // `init_record(1)`, so a driver `init` that defines VAL is still not
1509 // reflected in the trackers seeded a line earlier.
1510 crate::server::device_support::init_device_support(self);
1511 // C `subRecord.c:107-129` / `aSubRecord.c:139-160` — the INAM call and
1512 // the SNAM lookup, also inside pass 1. `false` is C's early return
1513 // (`S_db_BadSub`, or the empty-SNAM `pact = TRUE; return 0`), so the
1514 // tail below is skipped exactly where C skips it.
1515 if !self.resolve_init_subroutine(name) {
1516 return;
1517 }
1518 // The UDF tail of pass 1. `init_record` cannot reach UDF (a common
1519 // field), so the record types whose C `init_record` ends in
1520 // `prec->udf = FALSE` — histogram's `clear_histogram`, aao's constant
1521 // DOL, mbboDirect's B0..B1F fold (epics-base dabcf89) — deliver it
1522 // through this hook instead. It lives HERE, inside the init owner,
1523 // because it is part of the same C pass: a creation path that ran the
1524 // passes but skipped the tail (iocsh `dbLoadRecords` did) left those
1525 // records UDF=1 where C has UDF=0.
1526 // The `post_init_finalize_undef` hook is a cross-crate record-trait API
1527 // over a `bool` (histogram/aao/mbboDirect implement it); bridge the raw
1528 // `u8` carrier through it here at the single init owner.
1529 let mut udf = self.common.udf != 0;
1530 if let Err(e) = self.record.post_init_finalize_undef(&mut udf) {
1531 eprintln!("post_init_finalize_undef failed for {name}: {e}");
1532 }
1533 self.common.udf = udf as u8;
1534 // C `init_record` that ends in `prec->udf = 0; recGblResetAlarms(prec)`
1535 // — the asyn record, defined and no-alarm the moment it loads. The born
1536 // `UDF`/`INVALID` (and the UDF-severity derivation above) are overwritten
1537 // here: at init `nsta`/`nsev` are 0, so `rec_gbl_reset_alarms` transfers
1538 // `STAT`/`SEVR` to `NO_ALARM`. Runs after `post_init_finalize_undef` so
1539 // it is the final word on this record's initial alarm state.
1540 if self.record.init_resets_alarms() {
1541 self.common.udf = 0;
1542 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut self.common);
1543 }
1544 // C `init_record` can park a record it cannot process with `prec->pact
1545 // = TRUE`. The only such line in base is `subRecord.c:119-123`, and it
1546 // sits WITH the empty-SNAM report and the return, so the resolution
1547 // step above performs it: a record whose INAM missed returned before
1548 // that line and is not parked however empty its SNAM is (softIoc reads
1549 // `PACT: 0`). This asks the record type for the same transition on the
1550 // paths that reach the tail — an iocsh `dbLoadRecords` merge re-running
1551 // the passes with no registry to resolve against. It is after the
1552 // passes so the `leave_pact()` above cannot undo it, and the park is
1553 // not permanent: a put to a `pact_park_fields()` field re-asks, the way
1554 // C's `special()` does.
1555 if self.record.parks_pact() {
1556 self.enter_pact();
1557 }
1558 }
1559
1560 /// SINGLE OWNER of the DTYP -> soft-output-dset mapping. The dset table
1561 /// decides what a soft OUT-link write carries; no caller may re-derive it.
1562 ///
1563 /// C ships two soft output dsets per output record type and DTYP picks one:
1564 /// `devXxxSoft.c::write_xxx` puts VAL/OVAL on the OUT link, while
1565 /// `devXxxSoftRaw.c::write_xxx` puts the RAW word — `dbPutLink(&prec->out,
1566 /// DBR_LONG, &prec->rval, 1)` (`devAoSoftRaw.c:44`, `devBoSoftRaw.c:65`) or
1567 /// `data = prec->rval & prec->mask` (`devMbboSoftRaw.c:71-75`,
1568 /// `devMbboDirectSoftRaw.c:71-75`).
1569 ///
1570 /// `Record::raw_soft_output_value` IS the SoftRaw column of that table:
1571 /// `Some` exactly for the record types C ships a SoftRaw dset for. A record
1572 /// type C has no SoftRaw dset for keeps the plain soft-channel value —
1573 /// `DTYP="Raw Soft Channel"` on a `longout` is a `.db` error C rejects at
1574 /// init ("no device support"), and the port's lenient reading of it (the
1575 /// same one [`crate::server::device_support::is_soft_dtyp`] already applies
1576 /// on the input side) must not turn the write into a silent no-op.
1577 ///
1578 /// `None` means DTYP names device support that owns the write — real
1579 /// hardware. "Async Soft Channel" is NOT that: C's
1580 /// `devXxxSoftCallback.c::write_xxx` puts the same VAL/OVAL the plain soft
1581 /// dset puts, only through `dbPutLinkAsync` (`devAoSoftCallback.c:49`,
1582 /// `devLoSoftCallback.c:49`), and falls back to a synchronous `dbPutLink`
1583 /// when the link has no LSET. Returning `None` for it made every
1584 /// `DTYP("Async Soft Channel")` output record write nothing at all —
1585 /// measured on `pva2pva/testApp/testpvalink.db:30-35`, whose `longout`
1586 /// drives a pva OUT link that never fired.
1587 pub fn soft_output_value(&self) -> Option<Option<EpicsValue>> {
1588 use crate::server::device_support::{SoftDtyp, classify_soft};
1589 match classify_soft(&self.common.dtyp)? {
1590 SoftDtyp::Raw => Some(
1591 self.record
1592 .raw_soft_output_value()
1593 .or_else(|| self.record.output_link_value()),
1594 ),
1595 SoftDtyp::Plain | SoftDtyp::Async => Some(self.record.output_link_value()),
1596 }
1597 }
1598
1599 /// Set a single `info("key", "value")` tag on this record. Last
1600 /// write wins. Used by the .db loader (`info(...)` directive) and
1601 /// `dbpf`-style tools.
1602 pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
1603 self.info.insert(key.into(), value.into());
1604 }
1605
1606 /// Look up a single info tag. Returns `None` when the record has
1607 /// no tag with that key.
1608 pub fn get_info(&self, key: &str) -> Option<&str> {
1609 self.info.get(key).map(|s| s.as_str())
1610 }
1611
1612 /// The value of `field` already published to its `DBE_VALUE`/`DBE_LOG`
1613 /// subscribers, or `None` when the framework has never published one.
1614 /// The read side of the `last_posted` contract — see the field's docs.
1615 pub(crate) fn posted_value(&self, field: &str) -> Option<&EpicsValue> {
1616 self.last_posted.get(field)
1617 }
1618
1619 /// SINGLE OWNER of `last_posted`: record that `value` has been published
1620 /// to `field`'s `DBE_VALUE`/`DBE_LOG` subscribers, so no later cycle
1621 /// change-detects and re-publishes it.
1622 ///
1623 /// Every value-class post — the snapshot builders' change-detected posts,
1624 /// the intermediate async-notify posts, and the put-time
1625 /// [`Self::notify_field_with_origin`] post that C makes from `dbPut`
1626 /// (dbAccess.c:1414) — routes through here. Alarm-only / property-only
1627 /// posts MUST NOT call it: they deliver nothing to a value-class
1628 /// subscriber, so the value is still owed.
1629 pub(crate) fn record_value_post(&mut self, field: &str, value: EpicsValue) {
1630 if let Some(slot) = self.last_posted.get_mut(field) {
1631 *slot = value;
1632 } else {
1633 self.last_posted.insert(field.to_string(), value);
1634 }
1635 }
1636
1637 /// Invalidate the metadata cache. Called after writing any
1638 /// metadata-class field (EGU, PREC, HOPR/LOPR, alarm limits,
1639 /// DRVH/DRVL, enum strings). The next snapshot will rebuild the
1640 /// cache from the new values.
1641 pub fn invalidate_metadata_cache(&self) {
1642 if let Ok(mut guard) = self.metadata_cache.lock() {
1643 *guard = None;
1644 }
1645 }
1646
1647 /// **The** `DBE_PROPERTY` gate: C `dbAccess.c:1330`
1648 /// `paddr->pfldDes->prop`, read from the field's own declaration.
1649 ///
1650 /// C never consults a list of field names — it asks the `.dbd`, per record
1651 /// type, which is why `histogram.ULIM` is a property and `bi.ZSV` (declared
1652 /// `pp(TRUE)`, no `prop`) is not, and why `bi.ZNAM` is one while
1653 /// `busy.ZNAM` is not. Asking [`Self::field_desc`] gives the port the same
1654 /// per-type answer from the same generated `.dbd` tables.
1655 ///
1656 /// A field with no declaration at all — a virtual field (`RTYP`, `TIME`) —
1657 /// has no `dbFldDes` in C either, so it is not property-class.
1658 pub(crate) fn field_posts_property(&self, field: &str) -> bool {
1659 self.field_desc(field).is_some_and(|d| d.prop)
1660 }
1661
1662 /// Hook called by the database after a field is written. If the field is a
1663 /// metadata-cache source, the cache is invalidated so the next snapshot
1664 /// picks up the new value. Posts nothing — a caller that also owes the
1665 /// `DBE_PROPERTY` event uses [`Self::notify_field_written_if_changed`].
1666 ///
1667 /// Field name is automatically uppercased.
1668 pub fn notify_field_written(&self, field: &str) {
1669 let upper = field.to_ascii_uppercase();
1670 if is_metadata_cache_source(&upper) {
1671 self.invalidate_metadata_cache();
1672 }
1673 }
1674
1675 /// Like [`Self::notify_field_written`], plus the `DBE_PROPERTY` post C
1676 /// makes from `dbPut` — and both are skipped when the put did not actually
1677 /// change the field's value. Mirrors epics-base `faac1df1`: property events
1678 /// fire only on real changes, not on idempotent writes (the C path compares
1679 /// `paddr->pfield` against the converted payload before setting the
1680 /// `propertyUpdate` flag).
1681 ///
1682 /// The two effects have independent gates. Invalidation follows
1683 /// `is_metadata_cache_source` (what this port's cache reads); the post
1684 /// follows `Self::field_posts_property` (what the `.dbd` declares). A
1685 /// field can be either without being both.
1686 ///
1687 /// `prev` is the value captured BEFORE the put. Callers that don't need the
1688 /// change-detection (e.g. internal writers that know the field is neither)
1689 /// can keep using [`Self::notify_field_written`].
1690 ///
1691 /// `backing` is what the sweep needs and could not have: the post below
1692 /// names EVERY subscribed field, so it reaches a link-backed one whenever a
1693 /// client is monitoring it, and this method runs under the record's own
1694 /// write lock where the target's lock cannot be taken. The put path that
1695 /// calls it has already resolved one at its no-lock point.
1696 pub fn notify_field_written_if_changed(
1697 &mut self,
1698 field: &str,
1699 prev: Option<&EpicsValue>,
1700 backing: LinkBacking<'_>,
1701 ) {
1702 let upper = field.to_ascii_uppercase();
1703 let cache_source = is_metadata_cache_source(&upper);
1704 let posts_property = self.field_posts_property(&upper);
1705 if !cache_source && !posts_property {
1706 return;
1707 }
1708 let now = self.record.get_field(&upper);
1709 if prev == now.as_ref() {
1710 return;
1711 }
1712 if cache_source {
1713 self.invalidate_metadata_cache();
1714 }
1715 if posts_property {
1716 // mirror C dbAccess.c:1395-1396 — the gate `if (propertyUpdate &&
1717 // !status)` and the `db_post_events(precord, NULL, DBE_PROPERTY)` it
1718 // guards. The NULL field pointer is what makes it record-wide.
1719 // Collect keys first to avoid a re-entrant immutable borrow on subscribers.
1720 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
1721 for f in fields {
1722 self.notify_field_with_origin(
1723 &f,
1724 crate::server::recgbl::EventMask::PROPERTY,
1725 0,
1726 backing,
1727 );
1728 }
1729 }
1730 }
1731
1732 /// Returns the cached MetadataSnapshot, building and storing it on
1733 /// the first call (or after invalidation). Used by both
1734 /// `snapshot_for_field` and `make_monitor_snapshot` so the populate
1735 /// cost is paid at most once per metadata-stable interval.
1736 fn cached_metadata(&self) -> MetadataSnapshot {
1737 // Fast path: cache hit
1738 if let Ok(guard) = self.metadata_cache.lock()
1739 && let Some(cached) = guard.as_ref()
1740 {
1741 return cached.clone();
1742 }
1743
1744 // Cache miss: build a fresh metadata snapshot
1745 let mut tmp = super::super::snapshot::Snapshot::new(
1746 EpicsValue::Double(0.0),
1747 0,
1748 0,
1749 std::time::SystemTime::UNIX_EPOCH,
1750 );
1751 self.populate_display_info(&mut tmp);
1752 self.populate_control_info(&mut tmp);
1753 self.populate_enum_info(&mut tmp);
1754
1755 let meta = MetadataSnapshot {
1756 display: tmp.display,
1757 control: tmp.control,
1758 enums: tmp.enums,
1759 };
1760
1761 // Store back; ignore poisoning (cache is best-effort).
1762 if let Ok(mut guard) = self.metadata_cache.lock() {
1763 *guard = Some(meta.clone());
1764 }
1765 meta
1766 }
1767
1768 /// C `dbChannelSpecial(chan) == SPC_NOMOD` — **the single owner of the
1769 /// no-modify declaration**, for every consumer that needs to know whether a
1770 /// field can be written.
1771 ///
1772 /// C declares it once, in the `.dbd`, and reads it in two unrelated places:
1773 ///
1774 /// * `dbPut` (`dbAccess.c:123-126`, via `dbPutSpecial(paddr, 0)`) refuses
1775 /// the write — the port's `check_no_mod` gate;
1776 /// * `rsrvCheckPut` (`rsrv/camessage.c:2540-2551`) — `if
1777 /// (dbChannelSpecial(pciu->dbch) == SPC_NOMOD) return 0;` — which feeds
1778 /// the CA `ACCESS_RIGHTS` write bit (`camessage.c:1154-1156`) as well as
1779 /// both put paths, so a client sees `Access: read, no write` and never
1780 /// sends the doomed write.
1781 ///
1782 /// Only the first consumer existed in the port, so every dbCommon NOMOD
1783 /// field advertised WRITE on the wire (`caput N1.SEVR 2` was refused
1784 /// server-side, after the client had already sent it, with an async
1785 /// exception instead of C's clean client-side "Write access denied").
1786 ///
1787 /// Three sources, one answer:
1788 ///
1789 /// 1. the dbCommon `SPC_NOMOD` set below — common fields, so no record's
1790 /// `field_list` declares them;
1791 /// 2. the record type's **declaration**, resolved by `Self::field_desc` —
1792 /// the vendored `.dbd` whenever one exists, and only for a record type
1793 /// that has no `.dbd` at all (`motor`, `optics`, `scaler`, `std`) the
1794 /// record's own hand-written table, which for those Tier 3 types
1795 /// genuinely *is* their declaration;
1796 /// 3. [`Record::field_no_mod`] — an SPC_NOMOD a record's `cvt_dbaddr`
1797 /// raises from its own state (compress VAL under BALG=LIFO,
1798 /// `compressRecord.c:404-405`), which a static `FieldDesc` cannot
1799 /// express.
1800 ///
1801 /// `field` may be any case.
1802 pub fn is_no_mod(&self, field: &str) -> bool {
1803 if DBCOMMON_NOMOD.iter().any(|f| f.eq_ignore_ascii_case(field)) {
1804 return true;
1805 }
1806 if self.field_desc(field).is_some_and(|f| f.read_only) {
1807 return true;
1808 }
1809 self.record.field_no_mod(field)
1810 }
1811
1812 /// Check if the record is currently processing (PACT equivalent).
1813 pub fn is_processing(&self) -> bool {
1814 self.pact.load(std::sync::atomic::Ordering::Acquire)
1815 }
1816
1817 /// C `prec->pact = TRUE` — the record goes busy for an async device
1818 /// round-trip, an SDLY simulation defer, or an ODLY reprocess window.
1819 pub fn enter_pact(&self) {
1820 self.pact.store(true, std::sync::atomic::Ordering::Release);
1821 }
1822
1823 /// C `prec->pact = FALSE` — the ONLY release of PACT.
1824 ///
1825 /// The returned [`PactExit`] carries the release's debt to the cycle tail,
1826 /// where a queued put-notify is restarted — the omission the open-coded
1827 /// `processing.store(false)` at the ODLY continuation and the three SIM/SDLY
1828 /// releases made.
1829 ///
1830 /// `#[must_use]` does NOT enforce that debt and never did: the lint fires on
1831 /// an unused *expression*, so a site that binds the token with `let` and then
1832 /// leaves by `?` or an early `return` warns about nothing. The enforcement is
1833 /// `processing::CycleEndGuard`, whose `Drop` pays the tail for every exit
1834 /// that did not.
1835 pub fn leave_pact(&mut self) -> PactExit {
1836 self.pact.store(false, std::sync::atomic::Ordering::Release);
1837 PactExit::new(self.notify_restart_pending())
1838 }
1839
1840 /// The cycle-tail token for a record this cycle did NOT release PACT on.
1841 ///
1842 /// Still consults the queue: a notify parked behind an in-flight wait-set
1843 /// on an idle record is freed by the wait-set completion, and the tail is
1844 /// what promotes it.
1845 pub fn pact_exit_without_release(&self) -> PactExit {
1846 PactExit::new(self.notify_restart_pending())
1847 }
1848
1849 /// C `processNotifyCommon`'s two defer tests (dbNotify.c:213, 225), as one
1850 /// question: may a NEWLY ARRIVING put-notify take this record now?
1851 ///
1852 /// `true` for an in-flight wait-set (`precord->ppn`), for PACT, and for a
1853 /// non-empty restart list — the last so a notify arriving in the window
1854 /// between a completion and the restart check cannot jump the queue.
1855 ///
1856 /// A RESTARTED put is not asked this: it is already the record's owner (C
1857 /// `precord->ppn == ppn`, state `notifyRestartCallbackRequested`, which
1858 /// dbNotify.c:213 exempts by name) and only PACT can stop it — see
1859 /// `Self::requeue_notify_put`.
1860 pub fn notify_put_is_owned(&self) -> bool {
1861 self.notify.is_some() || self.is_processing() || !self.notify_restart_list.is_empty()
1862 }
1863
1864 /// C `processNotifyCommon`'s FIRST defer test alone (dbNotify.c:213):
1865 /// another `processNotify` owns this record, or one is already queued
1866 /// behind it. [`Self::notify_put_is_owned`] folds in the PACT arm
1867 /// (`:225`) as well.
1868 ///
1869 /// A DBF link-field put waits on ownership but NOT on PACT. A bare `sub`
1870 /// with an empty `SNAM` parks PACT=TRUE forever (subRecord.c:119-122), so
1871 /// a link put that waited on the PACT arm there would never be written and
1872 /// `caput <sub>.INPA 0` would read back empty. Ownership carries no such
1873 /// trap: the restart check drains the queue at every cycle end.
1874 pub fn notify_put_has_owner(&self) -> bool {
1875 self.notify.is_some() || !self.notify_restart_list.is_empty()
1876 }
1877
1878 /// C `ellSafeAdd(&precord->ppnr->restartList, &ppn->restartNode)` — the
1879 /// arriving put-notify joins the back of the queue, unwritten.
1880 ///
1881 /// Infallible: C has no "refuse" arm here, and a refusal loses the client's
1882 /// write. Call only under [`Self::notify_put_is_owned`].
1883 /// Take this record's put-notify slot, or queue behind whoever holds it.
1884 ///
1885 /// C `processNotifyCommon` (dbNotify.c:211-231) has exactly two outcomes
1886 /// and no third: the record is free and the notify takes it, or it is
1887 /// owned and the notify joins `precord->ppnr->restartList`. There is no
1888 /// refusal arm — `ECA_PUTCBINPROG` has one sender in all of base, the
1889 /// 60-second put-callback timeout in `write_notify_action`
1890 /// (`rsrv/camessage.c:1701` at R7.0.10).
1891 ///
1892 /// `None` means queued, and the caller MUST NOT process: the replay
1893 /// drives the record and fires the callback, so processing here would
1894 /// run the cycle twice for one client request.
1895 ///
1896 /// Ownership alone decides — NOT [`Self::notify_put_has_owner`]. A
1897 /// non-empty restart list stops a *fresh* arrival at the entry gate, but a
1898 /// replay reaching here has already been popped off that list and must
1899 /// take the slot with its successors still queued behind it, exactly as
1900 /// C `restartCheck` (dbNotify.c:158-168) assigns `precord->ppn = pfirst`
1901 /// while leaving the rest of `restartList` in place.
1902 pub fn install_or_queue_notify(
1903 &mut self,
1904 completion: crate::runtime::sync::oneshot::Sender<()>,
1905 ) -> Option<Arc<NotifyWaitSet>> {
1906 if self.notify.is_some() {
1907 self.queue_notify_put(DeferredNotify::Process { completion });
1908 return None;
1909 }
1910 let notify = NotifyWaitSet::for_entry_record(&self.name, completion);
1911 self.take_notify_slot(notify.clone());
1912 Some(notify)
1913 }
1914
1915 /// Take `ws` into this record's put-notify slot.
1916 ///
1917 /// **The only writer of [`Self::notify`] that installs a set** — the other
1918 /// two ([`Self::abandon_put_notify`], [`Self::release_notify`]) only clear
1919 /// it. Everything that makes a record a member of a wait-set happens here,
1920 /// so C's `precord->ppn = ppn` and its `ellSafeAdd(&waitList, ...)`
1921 /// (`dbNotify.c:226-227`, `:257-258`, `:497-498`) stay the single act they
1922 /// are in C. Split across two call sites, they were what let a member exist
1923 /// that no cancel could name.
1924 fn take_notify_slot(&mut self, ws: Arc<NotifyWaitSet>) {
1925 debug_assert!(
1926 self.notify.is_none(),
1927 "the slot must be tested free in the same critical section"
1928 );
1929 ws.record_joined(&self.name);
1930 self.notify = Some(ws);
1931 }
1932
1933 /// C `dbNotifyAdd` (dbNotify.c:477-501): a link target joins the wait-set
1934 /// of the put-notify driving the chain, so the initiator's completion
1935 /// waits for this record's cycle too.
1936 ///
1937 /// One of the two callers of `take_notify_slot`, the sole writer; the
1938 /// other is [`Self::install_or_queue_notify`]. All three live here so
1939 /// the slot has no assignment site outside this module — an open-coded one
1940 /// elsewhere is how a wait-set came to be installed without the record's
1941 /// write gate.
1942 ///
1943 /// A record already carrying a wait-set keeps it (C's `if (!pto->ppn …)`
1944 /// at `:492`), so this never displaces a live one, and the `enter` is
1945 /// paired with the `leave` the target's own cycle tail performs.
1946 pub fn join_put_notify(&mut self, src: Option<&Arc<NotifyWaitSet>>) {
1947 if self.notify.is_some() {
1948 return;
1949 }
1950 if let Some(ws) = src {
1951 ws.enter();
1952 self.take_notify_slot(ws.clone());
1953 }
1954 }
1955
1956 /// Give up a claim on the slot without ever having processed under it.
1957 ///
1958 /// NOT a completion. `complete_put_notify` (`processing.rs:449`, C
1959 /// `dbNotifyCompletion`) `leave`s the wait-set because the record
1960 /// contributed a cycle to it; an abandoned claim contributed nothing, so
1961 /// the set is dropped whole. Its `pending` never reaches zero, and the
1962 /// client's receiver wakes on the dropped sender — the same release C
1963 /// gives a `dbNotifyCancel`.
1964 ///
1965 /// The caller must be the claim's owner. Nothing else can have cleared or
1966 /// replaced the slot in between: [`Self::install_or_queue_notify`] and
1967 /// [`Self::join_put_notify`] both refuse an occupied slot, and
1968 /// [`Self::take_next_notify_restart`] will not pop while it is occupied,
1969 /// so the assertion below states an invariant rather than guarding a
1970 /// race.
1971 pub(crate) fn abandon_put_notify(&mut self, claimed: &Arc<NotifyWaitSet>) {
1972 let taken = self.notify.take();
1973 debug_assert!(
1974 taken.as_ref().is_some_and(|ws| Arc::ptr_eq(ws, claimed)),
1975 "only the claim owner may clear the put-notify slot"
1976 );
1977 }
1978
1979 /// The set in this record's slot if it can never be answered — the test
1980 /// half of C `dbNotifyCancel` (`dbNotify.c:385-430`), reached from
1981 /// `rsrvFreePutNotify` (`camessage.c:1630-1638`) when a client is torn down
1982 /// with its put-callback still busy.
1983 ///
1984 /// # Invariant (CONTRACT)
1985 ///
1986 /// A record's put-notify slot MUST NOT stay occupied by a notify nobody
1987 /// can be answered from. Without the release such a record is wedged for
1988 /// good: every later put-notify queues on `notify_restart_list` behind a
1989 /// completion that can never arrive and writes nothing — C
1990 /// `processNotifyCommon` tests ownership ABOVE `putCallback` — so the next
1991 /// client hangs too, and the one after it.
1992 ///
1993 /// This is what makes honouring a record type's forward-link gate safe at
1994 /// all. `busy` left at VAL=1 withholds the `ca_put_callback` exactly as C
1995 /// does (`busyRecord.c:271`); the client then gives up and exits, and that
1996 /// exit is the teardown modelled here.
1997 ///
1998 /// Two triggers reach it, and both are needed. The CA server calls it at
1999 /// client teardown, which is C's own moment
2000 /// (`rsrvFreePutNotify`, `camessage.c:1630-1638`), and it also runs at
2001 /// every ownership test, which catches a set whose client died on a
2002 /// transport the teardown hook does not cover. Only the first closes the
2003 /// case where a SECOND client is already queued on a record's restart list
2004 /// when the first dies: nothing then arrives to test ownership, and C's
2005 /// `restartCheck` would have handed that record to the queued client.
2006 ///
2007 /// The set names its own members ([`NotifyWaitSet::joined_records`]), so
2008 /// the sweep reaches a chain target exactly as C's `notifyProcessInProgress`
2009 /// arm does (`dbNotify.c:428-430`) rather than stopping at the entry.
2010 /// That distinction is not cosmetic: a chain target is the likelier victim,
2011 /// because the record whose cycle never ends is precisely the one this
2012 /// exists for — a `busy` left at VAL=1 withholds `recGblFwdLink` by
2013 /// contract, so its slot would otherwise be held by a dead set forever.
2014 ///
2015 /// See [`PvDatabase::cancel_unanswerable_notify`], the owner that runs it
2016 /// across the whole set.
2017 ///
2018 /// [`PvDatabase::cancel_unanswerable_notify`]: crate::server::database::PvDatabase::cancel_unanswerable_notify
2019 pub(crate) fn unanswerable_notify(&self) -> Option<Arc<NotifyWaitSet>> {
2020 self.notify
2021 .as_ref()
2022 .filter(|ws| ws.is_unanswerable())
2023 .cloned()
2024 }
2025
2026 /// Drop this record's claim on `dead` — C `restartCheck`'s
2027 /// `precord->ppn = 0` (`dbNotify.c:157`) as `dbNotifyCancel` reaches it.
2028 ///
2029 /// The record's own slot is the authority on membership, so this is
2030 /// `Arc::ptr_eq`-gated: a name the sweep carries for a record that has
2031 /// since completed and taken a different notify releases nothing.
2032 ///
2033 /// Returns whether the slot was freed, so the caller can promote the
2034 /// restart-list head (C `restartCheck`) exactly as a completion would.
2035 #[must_use = "a freed slot owes the restart list a drain"]
2036 pub(crate) fn release_notify(&mut self, dead: &Arc<NotifyWaitSet>) -> bool {
2037 if self.notify.as_ref().is_some_and(|ws| Arc::ptr_eq(ws, dead)) {
2038 self.notify = None;
2039 return true;
2040 }
2041 false
2042 }
2043
2044 /// Whether a put-notify owns this record — C `precord->ppn != NULL`.
2045 ///
2046 /// The public read of the slot. The wait-set itself stays crate-private so
2047 /// no caller outside this crate can `enter`/`leave` a set it does not own,
2048 /// which is the accounting [`NotifyWaitSet`] exists to keep.
2049 pub fn has_notify(&self) -> bool {
2050 self.notify.is_some()
2051 }
2052
2053 pub fn queue_notify_put(&mut self, put: DeferredNotify) {
2054 debug_assert!(
2055 self.notify_put_is_owned(),
2056 "a put-notify is queued only when the record is owned; otherwise it \
2057 takes the record directly"
2058 );
2059 self.notify_restart_list.push_back(put);
2060 }
2061
2062 /// C `processNotifyCommon`'s `precord->pact` arm reached by a RESTARTED
2063 /// notify (dbNotify.c:225-231): it stays `precord->ppn` and waits for the
2064 /// next completion, so it does NOT fall in behind puts that arrived after
2065 /// it. Back to the head.
2066 ///
2067 /// The only way a promotion can find the record busy is a scan that took
2068 /// PACT between the pop and the replay; the record's advisory write gate,
2069 /// held across both, keeps every other put out of that window.
2070 pub(crate) fn requeue_notify_put(&mut self, put: DeferredNotify) {
2071 debug_assert!(
2072 self.is_processing(),
2073 "a promoted put-notify returns to the head only because the record \
2074 went PACT under it"
2075 );
2076 self.notify_restart_list.push_front(put);
2077 }
2078
2079 /// C `restartCheck` (dbNotify.c:149-170) — promote the queue head once the
2080 /// record is free, or leave it queued for the next completion.
2081 ///
2082 /// **The only drain.** The freedom test lives here rather than at the call
2083 /// site, so promoting onto a record that is still PACT or still carries a
2084 /// wait-set is not expressible.
2085 pub(crate) fn take_next_notify_restart(&mut self) -> Option<DeferredNotify> {
2086 if self.notify.is_some() || self.is_processing() {
2087 return None;
2088 }
2089 self.notify_restart_list.pop_front()
2090 }
2091
2092 /// Does this record owe anyone a restart? The cheap read that keeps the
2093 /// per-cycle restart check off the spawn path when nothing is queued.
2094 pub(crate) fn notify_restart_pending(&self) -> bool {
2095 !self.notify_restart_list.is_empty()
2096 }
2097
2098 /// How many put-notifies are queued behind whoever owns this record — C
2099 /// `ellCount(&precord->ppnr->restartList)`.
2100 ///
2101 /// A count and not a bool because `dbNotifyDump` prints one line per
2102 /// queued entry (`dbNotify.c:678-685`); [`Self::notify_restart_pending`]
2103 /// answers the cheaper question the restart check asks. Read-only: the
2104 /// queue's only drain is still [`Self::take_next_notify_restart`].
2105 pub(crate) fn notify_restart_len(&self) -> usize {
2106 self.notify_restart_list.len()
2107 }
2108
2109 /// Unified field resolution: record fields → common fields → virtual
2110 /// fields — and, for a link field, C `dbGet`'s rendering of it.
2111 ///
2112 /// This is the port's `dbGet` (`dbAccess.c:625-961`): the read every
2113 /// external reader arrives at, whether it came from
2114 /// [`PvDatabase::get_pv`](crate::server::database::PvDatabase::get_pv)
2115 /// on behalf of a CA client, from `dbgf`, or from `dbpr`. C's `dbGet`
2116 /// sends `DBF_INLINK`/`DBF_OUTLINK`/`DBF_FWDLINK` to `getLinkValue`
2117 /// (`:944-947`), which renders the link with `dbGetString` (`:850-856`),
2118 /// so applying that here is what makes every reader agree without any of
2119 /// them knowing the rule.
2120 ///
2121 /// The STORE is still the text — `Record::get_field` — and that is what
2122 /// the link layer parses. The two are not the same value and do not share
2123 /// a name: C likewise reads `precord->inp` directly when it wants the
2124 /// link and `dbGet` when it wants what a client would see.
2125 pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
2126 let name = name.to_ascii_uppercase();
2127 let value = self.resolve_field_stored(&name)?;
2128 Some(self.as_a_reader_sees(&name, value))
2129 }
2130
2131 /// [`Self::resolve_field`] without the reader's view — what the field
2132 /// HOLDS, which for a link field is the text C's `dbParseLink` takes
2133 /// (`dbStaticLib.c:2246`) rather than what `dbGetString` renders
2134 /// (`:1906-2050`).
2135 ///
2136 /// `dbpr` needs both of the same field, and in C they come from one
2137 /// address: it prints the link's resolved TYPE in front of the rendered
2138 /// text (`dbTest.c:1205-1224`). Splitting the accessor chain here keeps
2139 /// that one address — a second walk to find the stored text would be a
2140 /// second answer to "which field is this", and the round before this one
2141 /// is what happens when those two disagree.
2142 ///
2143 /// `name` must already be upper-case.
2144 pub fn resolve_field_stored(&self, name: &str) -> Option<EpicsValue> {
2145 let value = match self.field_desc(name) {
2146 // C `dbGet`'s validity gate (`dbAccess.c:667-675`): the NAME
2147 // resolves — `dbNameToAddr` finds every field the `.dbd` declares
2148 // — and the READ is what fails, with `S_db_badDbrtype`. The two
2149 // outcomes leave by different doors one level up, in
2150 // `PvDatabase::get_pv`, which turns a declared-but-unresolved
2151 // field into `CaError::BadDbrType` and an undeclared one into
2152 // `ChannelNotFound`.
2153 //
2154 // The gate goes HERE, in front of the accessor chain, rather than
2155 // in whichever accessor would otherwise answer: `declared_default`
2156 // synthesises the declared type's zero for any field with no
2157 // stored value, so leaving the row to reach it served `REC.TIME`
2158 // as `UChar(0)` — a value C has no way to produce.
2159 Some(desc) if desc.unreadable() => return None,
2160 // C `dbFindFieldPart` — the record type's own `.dbd` table, then
2161 // `dbCommon`. Every accessor below reads STORAGE, and this port
2162 // keeps a good deal of storage on `CommonFields` that C keeps per
2163 // record type (`INP`, `OUT`, `SSCN`, the analog-alarm ladder), so
2164 // without the declaration in front of them a `calc` answered
2165 // `.OUT` with an empty string where C answers `PV 'C:GOOD.OUT'
2166 // not found`. The declaration is the namespace, not the storage.
2167 Some(_) => self
2168 .record
2169 .get_field(name)
2170 .or_else(|| self.get_common_field(name))
2171 .or_else(|| self.get_virtual_field(name))
2172 .or_else(|| self.declared_overrides.get(name).cloned())
2173 .or_else(|| self.declared_default(name))?,
2174 // C `dbNameToAddr` falls through to `dbGetAttributePart` on
2175 // `S_dbLib_fieldNotFound` (`dbAccess.c:672-675`), which is how
2176 // `RTYP` — declared by no record type — reads as the type name.
2177 // `VERS` and any `dbPutAttribute` name need the database's
2178 // attribute map and are answered a level up, in `get_pv`.
2179 None => self.get_virtual_field(name)?,
2180 };
2181 Some(value)
2182 }
2183
2184 /// C `dbGet`'s link arm applied to one resolved field: a link field reads
2185 /// as [`render_link_field`], everything else as itself.
2186 ///
2187 /// The class lookup is behind the string test because only a string-valued
2188 /// field can be a link, and the numeric fields a processing cycle reads
2189 /// (`HASH`, `SIMM`, `SDLY`) must not pay for a declaration scan.
2190 fn as_a_reader_sees(&self, upper_field: &str, value: EpicsValue) -> EpicsValue {
2191 let EpicsValue::String(ref text) = value else {
2192 return value;
2193 };
2194 let Some(class) = crate::types::dbf_link_class(self.record.record_type(), upper_field)
2195 else {
2196 return value;
2197 };
2198 EpicsValue::String(
2199 render_link_field(class, text.as_str_lossy().as_ref())
2200 .as_str()
2201 .into(),
2202 )
2203 }
2204
2205 /// The value a field that is DECLARED by the `.dbd` but has no live store
2206 /// on this record serves: its `initial(...)`, or a type-zero.
2207 ///
2208 /// C makes *every* `.dbd` field addressable — `dbNameToAddr` resolves the
2209 /// field from its `dbFldDes` and `dbGet` reads it out of record memory,
2210 /// which the dbd loader seeded with `initial()` (or left zero). A Rust
2211 /// record implements only the fields it has behaviour for, so a field it
2212 /// declares but never touches — `aSub.OVAL`, `sub.LA`, `sel.HOPR` — had no
2213 /// channel at all: [`Self::resolve_field`]'s three accessors all returned
2214 /// `None` and CA create-channel answered `S_dbLib_recNotFound`.
2215 ///
2216 /// The declared table is the contract for *which* fields exist; this is the
2217 /// last resort for the *value* of one with no runtime accessor, and it is
2218 /// exactly what an unprocessed C record on an empty `.db` returns —
2219 /// [`apply_dbd_initials`](crate::server::db_loader) seeds the same
2220 /// `initial()` into the fields the record *does* store, from the same
2221 /// generated table, so the two paths agree by construction.
2222 fn declared_default(&self, name: &str) -> Option<EpicsValue> {
2223 let desc = self.field_desc(name)?;
2224 // A `runtime_typed` field (`VAL`/`BG`, re-typed from `FTVL`/`SDEF`) is
2225 // record-owned by definition and its placeholder `dbf_type` is not what
2226 // it serves; never synthesise one here — the record itself answers it.
2227 if desc.runtime_typed {
2228 return None;
2229 }
2230 let initial = desc.initial.unwrap_or("");
2231 if let Some(choices) = desc
2232 .menu
2233 .or_else(|| self.record.menu_field_choices(name))
2234 .or_else(|| super::shared_menu_choices(name))
2235 {
2236 // A menu field with no `initial(...)` is index 0, exactly as an
2237 // empty numeric field is 0 below.
2238 if initial.is_empty() {
2239 return Some(EpicsValue::Enum(0));
2240 }
2241 return super::resolve_menu_field_string_db_load(name, choices, desc.dbf_type, initial)
2242 .ok();
2243 }
2244 // `parse` maps an empty string to the declared type's zero, so this one
2245 // call serves both `initial(...)` and no-initial fields.
2246 EpicsValue::parse_bytes(desc.dbf_type, initial.as_bytes()).ok()
2247 }
2248
2249 /// Resolve a field for EPICS `$` long-string (character-array) access.
2250 ///
2251 /// The `$` channel-name modifier (C `dbChannel.c:486-505`) re-views a
2252 /// field as a `DBR_CHAR` array: a `DBF_STRING` field becomes a char
2253 /// array of `field_size` elements, a link field a char array of
2254 /// `PVLINK_STRINGSZ`, and every other field type is rejected with
2255 /// `S_dbLib_fieldNotFound`. pvxs serves that char view as a
2256 /// `form = "String"` long-string `NTScalar` — it reads the `DBR_CHAR`
2257 /// bytes and NUL-terminates them back into a string
2258 /// (`ioc/iocsource.cpp:133-136`, `ioc/channel.cpp:62-74`).
2259 ///
2260 /// Both `DBF_STRING` fields and link fields resolve to an
2261 /// [`EpicsValue::String`] in this database (a link resolves to its
2262 /// textual form, see [`Self::get_common_field`]), so a field is
2263 /// `$`-eligible exactly when it resolves to a string value. Returns
2264 /// that string value for an eligible field, or `None` for a field the
2265 /// `$` modifier cannot view as a char array (the
2266 /// `S_dbLib_fieldNotFound` case) — the single owner of the
2267 /// dbChannel `$`-eligibility rule for the channel-resolution layer.
2268 pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
2269 match self.resolve_field(name)? {
2270 v @ EpicsValue::String(_) => Some(v),
2271 _ => None,
2272 }
2273 }
2274
2275 /// Choice table for a field served as `DBR_ENUM` from a `DBF_MENU`:
2276 /// the record's own record-specific menu
2277 /// ([`Record::menu_field_choices`]),
2278 /// else a shared menu keyed by field name
2279 /// ([`shared_menu_choices`](super::menu_choices::shared_menu_choices)).
2280 /// The choices a `menu()` field serves as its `DBR_ENUM` labels.
2281 ///
2282 /// The `.dbd` declaration is the first and best answer: a generated
2283 /// [`FieldDesc`] carries the field's own `menu(...)` choices, which is what
2284 /// C's `dbGetFieldIndex` -> `pamapdbfType` -> menu lookup resolves. The two
2285 /// hand-maintained fallbacks below are for record types still on a
2286 /// hand-written table; they go away with the last of them.
2287 ///
2288 /// `shared_menu_choices` in particular keys on the field NAME alone, across
2289 /// every record type — which is only correct while no two record types give
2290 /// the same field name different menus. Asking the field's own descriptor
2291 /// first removes that assumption.
2292 fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
2293 menu_choices_of(self.record.as_ref(), field)
2294 }
2295
2296 /// The choices this record's `DTYP` selects among — C's `dbDeviceMenu` for
2297 /// the record type, in `.dbd` declaration order.
2298 ///
2299 /// C's DTYP field IS the index into this list, and an unset DTYP is index 0
2300 /// — which is why a bare `record(ai,"X"){}` serves `Soft Channel` and a
2301 /// `record(calc,"X"){}`, whose record type declares no device support at
2302 /// all, serves the empty string.
2303 ///
2304 /// The port stores the device NAME rather than the index, because the name
2305 /// is what the device-support registry dispatches on, and a name registered
2306 /// at runtime by a downstream crate (`asynInt32`) has no `device()` line in
2307 /// any vendored `.dbd`. Such a name is appended as its own slot, so the
2308 /// index and the string still name the SAME device support: there is no
2309 /// value of DTYP that renders as a device this record is not bound to.
2310 /// `None` when the record type declares NO device support at all — C's
2311 /// `dbDeviceMenu *pdevs = paddr->pfldDes->ftPvt; if (!pdevs) goto nostrs;`
2312 /// (`dbAccess.c:176-179`), which clears `DBR_ENUM_STRS` so the client is
2313 /// sent no choice list at all.
2314 ///
2315 /// C keeps that case DISTINCT from a device menu that exists but is empty,
2316 /// and says so at `dbAccess.c:205`: *"indicate option data not available.
2317 /// distinct from no_str==0"*. An empty-but-present menu is still marked,
2318 /// with `no_str = 0`; a missing menu is not marked. Returning `Vec` here
2319 /// and defaulting the missing menu to `[]` collapsed the two, so a
2320 /// `record(calc,"X"){}` — whose record type has no `device()` line — served
2321 /// `value.choices = {0}[]` where QSRV2 omits the leaf entirely.
2322 pub(crate) fn device_choices(&self) -> Option<Vec<PvString>> {
2323 let record_type = self.record.record_type();
2324 // Base's build-time menu (`epics-base-rs/dbd`), then the menus a
2325 // downstream crate whose device support has no vendored `device()` line
2326 // registered at runtime (asyn's `asynInt32`, `asynFloat64`, ...). C's
2327 // `dbDeviceMenu` is the concatenation of every `device()` the loaded
2328 // `.dbd` set declares, in load order — base first, then asyn — so the
2329 // merge appends the contributed choices AFTER the declared ones.
2330 // The C None-vs-empty distinction (`dbAccess.c:176-179` vs `:205`): the
2331 // menu is present iff the loaded `.dbd` set declares ANY `device()` for
2332 // this type. A type base declares none for but asyn does (structurally
2333 // possible, though none of asyn's are such) is therefore present, not
2334 // None; a type neither declares for (calc) stays None.
2335 if super::dbd_generated::device_menu(record_type).is_none()
2336 && super::contributed_device_menu(record_type).is_empty()
2337 {
2338 return None;
2339 }
2340 // `merged_device_menu` = declared + contributed, the SAME source the
2341 // CA-put validation (`coerce_put_value`'s DTYP branch) resolves against,
2342 // so a client can put exactly the DTYP names it can read here.
2343 let mut names: Vec<PvString> = super::merged_device_menu(record_type)
2344 .into_iter()
2345 .map(PvString::from)
2346 .collect();
2347 let dtyp = self.common.dtyp.as_str();
2348 if !dtyp.is_empty() && !names.iter().any(|n| n.as_str_lossy() == dtyp) {
2349 names.push(PvString::from(dtyp));
2350 }
2351 Some(names)
2352 }
2353
2354 /// C `dbPutFieldLink`'s link-type gate (`dbAccess.c:1125-1137`): a link
2355 /// written at RUNTIME is held to the same `dbCanSetLink` rule as one written
2356 /// by the `.db`, against the device support the record's CURRENT `DTYP`
2357 /// binds. Same rule, same owner — [`super::check_link_assignment`]; only the
2358 /// DTYP it is asked about differs (the record's, not the `.db` text's).
2359 ///
2360 /// [`MenuBound::DbLoad`] is exempt, and that is not a hole: on the db-load
2361 /// path C does not check a link as each field is parsed either. It checks
2362 /// once, at `iocInit`, over the record as loaded (`dbStaticLib.c:2178-2231`)
2363 /// — which is why `field(INP,…)` may precede `field(DTYP,…)` in a `.db` and
2364 /// still bind. [`PvDatabase::db_init_record_links`] is that pass, and it
2365 /// reads the record's DTYP off the record itself, so it does not depend on
2366 /// the order the `.db` happened to spell its fields in. Gating here as well
2367 /// would re-introduce exactly that order dependence.
2368 ///
2369 /// [`PvDatabase::db_init_record_links`]: crate::server::database::PvDatabase
2370 fn check_link_assignment(
2371 &self,
2372 upper_field: &str,
2373 text: &str,
2374 bound: MenuBound,
2375 ) -> CaResult<()> {
2376 if matches!(bound, MenuBound::DbLoad) {
2377 return Ok(());
2378 }
2379 super::check_link_assignment(
2380 self.record.record_type(),
2381 Some(self.common.dtyp.as_str()),
2382 upper_field,
2383 text,
2384 )
2385 }
2386
2387 /// The value of the `DTYP` field: the index of the bound device support in
2388 /// [`Self::device_choices`]. An unset DTYP is index 0, exactly as in C.
2389 pub(crate) fn dtyp_index(&self) -> u16 {
2390 let dtyp = self.common.dtyp.as_str();
2391 if dtyp.is_empty() {
2392 return 0;
2393 }
2394 // A record type with no device menu has no slot for any DTYP, so the
2395 // index stays 0 — the same answer the old `unwrap_or(&[])` gave.
2396 self.device_choices()
2397 .unwrap_or_default()
2398 .iter()
2399 .position(|c| c.as_str_lossy() == dtyp)
2400 .unwrap_or(0) as u16
2401 }
2402
2403 /// **The** owner of "what string does this enum-valued field render as" —
2404 /// C's `[DBF_*][DBR_STRING]` conversion row, chosen by the field's DBF
2405 /// class. Every path that renders an enum as a string goes through here:
2406 /// the CA/PVA encoders (via [`EnumInfo::string_form`](crate::server::snapshot::EnumInfo::string_form) on the
2407 /// snapshot this builds) and the db-link read
2408 /// ([`Self::field_as_dbr_string`]). There is exactly one such table per
2409 /// field, and no path may reconstruct a second one.
2410 ///
2411 /// C's dispatch, and this function's, in the same order:
2412 ///
2413 /// * `DBF_MENU` / `DBF_DEVICE` -> `getMenuString` / `getDeviceString`, the
2414 /// field's own choice list. Asked FIRST, because a menu field on a record
2415 /// whose `VAL` is an enum (`bo.OMSL`) must render its menu's choices, not
2416 /// the record's `ZNAM`/`ONAM`.
2417 /// * `DBF_ENUM` `VAL` -> `getEnumString` -> the record's `get_enum_str`
2418 /// rset ([`Record::enum_string_form`]).
2419 ///
2420 /// `None` when the field has neither — C answers `S_db_noRSET`, an error;
2421 /// the port renders empty.
2422 ///
2423 /// Each class brings its own out-of-range rule with it (see
2424 /// [`EnumOverflow`](crate::server::snapshot::EnumOverflow)); the index is
2425 /// rendered as a number for a `DBF_MENU` and ONLY for a `DBF_MENU`.
2426 pub(crate) fn enum_string_form_for(&self, field: &str) -> Option<EnumStringForm> {
2427 if field.eq_ignore_ascii_case("DTYP") {
2428 // `None` propagates C's `goto nostrs` (`dbAccess.c:178`): a record
2429 // type with no `device()` declaration supplies no choice list, so
2430 // the leaf is omitted rather than marked empty.
2431 return self.device_choices().map(EnumStringForm::device);
2432 }
2433 if let Some(choices) = self.menu_choices_for(field) {
2434 return Some(EnumStringForm::menu(
2435 choices.iter().map(|c| PvString::from(*c)),
2436 ));
2437 }
2438 if field.eq_ignore_ascii_case("VAL") {
2439 return self.record.enum_string_form();
2440 }
2441 None
2442 }
2443
2444 /// Is `field` one of the DBF classes C's soft device support writes as
2445 /// `DBR_STRING`?
2446 ///
2447 /// `devsCalcoutSoft.c:128-130` (and its async twin, :83-85) switches the
2448 /// scalcout OUT put on the TARGET field's DBF type and sends `OSV` — the
2449 /// string result — for seven of them:
2450 ///
2451 /// ```c
2452 /// case DBF_STRING: case DBF_ENUM: case DBF_MENU: case DBF_DEVICE:
2453 /// case DBF_INLINK: case DBF_OUTLINK: case DBF_FWDLINK:
2454 /// status = dbPutLink(&pscalcout->out, DBR_STRING, &pscalcout->osv, 1);
2455 /// ```
2456 ///
2457 /// [`DbFieldType`] is the port's DBR *wire* type and cannot express
2458 /// `DBF_MENU` / `DBF_DEVICE` — C's DBF class is not the DBR type. The
2459 /// classification therefore lives here, with the record's field metadata,
2460 /// where each class is already known:
2461 ///
2462 /// * `DBF_STRING` and the three link classes — the port stores links and
2463 /// `DTYP` (C's only `DBF_DEVICE` field) as strings;
2464 /// * `DBF_ENUM` — an enum-typed field;
2465 /// * `DBF_MENU` — a menu-index field, i.e. one this record resolves choice
2466 /// labels for ([`Self::menu_choices_for`]): `PRIO`, `STAT`, `SEVR`,
2467 /// `DISS`, `ACKT`, `SCAN`, `IVOA`, `OMSL`, … The index is stored as a
2468 /// short, so a same-named field that is NOT a menu index (scalcout's
2469 /// string `OSV` shares a name with the alarm-severity menu) is
2470 /// classified by its own type, not by the name collision.
2471 ///
2472 /// Everything else (`DBF_DOUBLE`, `DBF_LONG`, `DBF_CHAR`, …) falls to the
2473 /// device support's `default:` arm.
2474 ///
2475 /// The question is about the target field's DECLARED class, so it is asked
2476 /// of the declaration ([`Self::declared_field_type`]) and not of the
2477 /// variant the record stores: C's `switch` is on `dbAddr.field_type`, which
2478 /// `dbNameToAddr` took from the `dbFldDes`. `DBF_MENU` and `DBF_DEVICE` both
2479 /// map to `DbFieldType::Enum` in the generated tables (`mapDBFToDBR`), and
2480 /// the three link classes to `DbFieldType::String`, so the seven C arms are
2481 /// exactly these two.
2482 pub(crate) fn field_puts_as_string(&self, field: &str) -> bool {
2483 let Some(declared) = self.declared_field_type(field) else {
2484 return false;
2485 };
2486 matches!(declared, DbFieldType::String | DbFieldType::Enum)
2487 }
2488
2489 /// The field's value as C `dbGetLink(plink, DBR_STRING, ...)` delivers it —
2490 /// the SOURCE side of an input link read with
2491 /// [`LinkReadAs::String`](super::record_trait::LinkReadAs::String).
2492 ///
2493 /// C converts at the source, through `dbConvert.c`'s
2494 /// `[field_type][DBR_STRING]` table: a `DBF_ENUM` field goes through
2495 /// `getEnumString` → the record's `get_enum_str` (mbbi's `ZRST`.., bi's
2496 /// `ZNAM`/`ONAM`) and a `DBF_MENU` field through `getMenuString` → the
2497 /// menu's choice string, i.e. the state LABEL in both cases, never the
2498 /// index. Only the record holds those tables, so the render lives here with
2499 /// the field metadata — the link-read owner has an index and nothing to
2500 /// resolve it with.
2501 ///
2502 /// The render goes through [`Self::enum_string_form_for`], the same owner
2503 /// the CA/PVA encoders use, so a link read and a `caget -t` of one field can
2504 /// never disagree about its string.
2505 pub(crate) fn field_as_dbr_string(&self, field: &str) -> Option<PvString> {
2506 let value = self.resolve_field(field)?;
2507 // A `DBF_ENUM` index, and a `DBF_MENU` index (stored as a short),
2508 // render through the field's string source. A short field that is
2509 // neither has no source and stays the plain number C converts it to.
2510 let idx = match value {
2511 EpicsValue::Enum(v) => Some(v),
2512 EpicsValue::Short(v) => u16::try_from(v).ok(),
2513 _ => None,
2514 };
2515 if let Some(idx) = idx
2516 && let Some(form) = self.enum_string_form_for(field)
2517 {
2518 return Some(form.render(idx));
2519 }
2520 value_as_dbr_string(&value)
2521 }
2522
2523 /// The field's declaration — its `dbFldDes`, in C's terms.
2524 ///
2525 /// The `.dbd` is the declaration, so the table generated FROM the `.dbd`
2526 /// ([`dbd_generated::record_fields`](super::dbd_generated::record_fields))
2527 /// is asked first, for every record type that has one. A record's own
2528 /// `Record::field_list` is a
2529 /// hand-written stand-in for that table, and it is consulted only for a
2530 /// record type the `.dbd` does not cover (`subArray`, and the record types
2531 /// the downstream crates add). It cannot be the primary answer: several of
2532 /// those tables are *derived from the record's Rust storage types* — the
2533 /// `#[derive(EpicsRecord)]` records type `longin.ADEL` `DBF_DOUBLE`
2534 /// because the struct member is an `f64`, where the `.dbd` says
2535 /// `DBF_LONG` — and reading the type off the storage is the whole defect
2536 /// this owner exists to close.
2537 ///
2538 /// `dbCommon` last, matching the order [`Self::resolve_field`] reads the
2539 /// value in, so a record-specific field always shadows the common one in
2540 /// both halves.
2541 ///
2542 /// `None` for a field with no declaration at all: a virtual field
2543 /// (`RTYP`, `TIME`, ...), which C answers from dbStaticLib rather than
2544 /// from a `dbFldDes`.
2545 pub(crate) fn field_desc(&self, field: &str) -> Option<&'static FieldDesc> {
2546 field_desc_of(self.record.as_ref(), field)
2547 }
2548
2549 /// Is `field` (already uppercased) a `DBF_NOACCESS` internal name —
2550 /// record-own (`BPTR`, `RPVT`, ...) or `dbCommon` (`MLOK`, `RSET`, ...)?
2551 ///
2552 /// C's `dbNameToAddr` resolves such a name, so a SEARCH for it is
2553 /// answered and the refusal lands at channel creation (`mapDBFToDBR` →
2554 /// `DBR_NOACCESS`). The search gate (`PvDatabase::has_name_no_resolve`)
2555 /// asks this so the port answers the same way; every value path stays
2556 /// closed to these names.
2557 pub(crate) fn resolves_noaccess_name(&self, field: &str) -> bool {
2558 is_dbcommon_noaccess(field) || self.record.noaccess_names().contains(&field)
2559 }
2560
2561 /// The `DBF_*` type `field` is SERVED as — the single source of truth for
2562 /// the type on the wire, on every delivery path.
2563 ///
2564 /// This is the field's DECLARED type ([`FieldDesc::dbf_type`], from the
2565 /// `.dbd`), not the type of whatever variant the record happens to store.
2566 /// C resolves a channel's `field_type` from the `dbFldDes` at
2567 /// name-resolution time (`dbChannelCreate` -> `dbNameToAddr`,
2568 /// `dbAccess.c:184-205`) and every later `dbGet`/`db_post_events` converts
2569 /// the stored bytes to it — the storage is private to the record, the
2570 /// declaration is the contract.
2571 ///
2572 /// Two answers are NOT the declaration:
2573 ///
2574 /// * a [`FieldDesc::runtime_typed`] field — C's `cvt_dbaddr` overwrites
2575 /// `paddr->field_type` from record state (`FTVL`, `FTA`, `SDEF`), and
2576 /// this port's `cvt_dbaddr` is the variant the record stores;
2577 /// * a field with no `FieldDesc` at all (a virtual field).
2578 ///
2579 /// In both cases the value's own type is the answer, so this returns
2580 /// `None` and [`Self::project_to_declared_type`] leaves the value alone.
2581 pub fn declared_field_type(&self, field: &str) -> Option<DbFieldType> {
2582 declared_field_type_of(self.record.as_ref(), field)
2583 }
2584
2585 /// Project a field's stored value onto its declared type
2586 /// ([`Self::declared_field_type`]) — the single owner of "what type this
2587 /// field goes on the wire as", run by the CA create-channel path
2588 /// ([`Self::client_field_value`]), the GET path
2589 /// ([`Self::snapshot_for_field`]) and the MONITOR path
2590 /// ([`Self::make_monitor_snapshot`]), so all three announce and serve the
2591 /// same type.
2592 ///
2593 /// The projection is [`EpicsValue::convert_to`], the one value-coercion
2594 /// owner — the same routine `dbGet` converts through. Never re-derive a
2595 /// conversion here: C picks its routine from BOTH the source and the
2596 /// destination type, and only `convert_to` knows that table.
2597 ///
2598 /// Idempotent: a value already of its declared type is short-circuited by
2599 /// `convert_to`, and re-projecting a projected value is a no-op. That is
2600 /// what lets the CA path derive the native type from the value it is about
2601 /// to serve.
2602 pub fn project_to_declared_type(&self, field: &str, value: EpicsValue) -> EpicsValue {
2603 match self.declared_field_type(field) {
2604 Some(declared) => value.convert_to(declared),
2605 None => value,
2606 }
2607 }
2608
2609 /// The client-facing value of `field`: the resolved value projected onto
2610 /// the field's declared type ([`Self::project_to_declared_type`]), so a
2611 /// native type derived from the value — which is what the CA
2612 /// create-channel path does — is the DECLARED type, and matches the
2613 /// GET/MONITOR data byte for byte.
2614 pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
2615 let value = self.resolve_field(field)?;
2616 Some(self.project_to_declared_type(field, value))
2617 }
2618
2619 /// Attach a `DBF_MENU` field's `menu()` choice labels to a built snapshot,
2620 /// so the CA/PVA enum encoders present `"NO CONVERSION"` rather than `0`.
2621 ///
2622 /// The VALUE half of the `DBF_MENU` -> `DBR_ENUM` mapping is not here: the
2623 /// `.dbd` declares a menu field `DBF_MENU`, the generator types that
2624 /// `DbFieldType::Enum` (`mapDBFToDBR`), and
2625 /// [`Self::project_to_declared_type`] — which every delivery path runs —
2626 /// makes the served value an [`EpicsValue::Enum`] on that declaration
2627 /// alone. So the label table is all that is left to attach, and it is
2628 /// attached exactly when the served value came out an enum. A same-named
2629 /// field that is NOT a menu index (`scalcout.OSV`, declared `DBF_STRING`,
2630 /// shares a name with the alarm-severity menu) is served as its own
2631 /// declared string and gets no choice table.
2632 fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
2633 if !matches!(snap.value, EpicsValue::Enum(_)) {
2634 return;
2635 }
2636 // `VAL` is the one field whose two rset slots differ: C's
2637 // `get_enum_strs` (the `DBR_GR_ENUM` labels) is TRIMMED to `no_str`
2638 // while `get_enum_str` (the DBR_STRING form) indexes the untrimmed
2639 // state array. `populate_enum_info` owns that pair; every OTHER
2640 // enum-valued field is a menu or a device, whose one choice list
2641 // answers both (C `getMenuString`/`getDeviceString` index the same
2642 // `papChoiceValue` the GR_ENUM reply carries).
2643 if field.eq_ignore_ascii_case("VAL") {
2644 return;
2645 }
2646 let Some(form) = self.enum_string_form_for(field) else {
2647 return;
2648 };
2649 snap.enums = Some(super::super::snapshot::EnumInfo::with_string_form(
2650 form.slots.clone(),
2651 form,
2652 ));
2653 }
2654
2655 /// Build a Snapshot with full metadata for the given field — for a field
2656 /// **no link backs**.
2657 ///
2658 /// A link-backed field answers `None` here on purpose. Its metadata has to
2659 /// be resolved from the target record, which needs a
2660 /// [`PvDatabase`](crate::server::database::PvDatabase) and, because the
2661 /// port has one lock per record instead of C's per-lock-set recursive
2662 /// mutex, has to happen with no record lock held. That is
2663 /// [`PvDatabase::channel_snapshot_for_field`](crate::server::database::PvDatabase::channel_snapshot_for_field),
2664 /// and it is the only entry point that can serve one. Answering `None`
2665 /// rather than a seeded snapshot is what makes a caller that reached for
2666 /// the wrong door serve nothing instead of something stale.
2667 pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
2668 if self.link_backed_metadata_field_of(field).is_some() {
2669 return None;
2670 }
2671 self.snapshot_for_field_with(field, LinkBacking::none())
2672 }
2673
2674 /// [`Self::snapshot_for_field`] with the link metadata the caller resolved
2675 /// for this build. `PvDatabase` is the intended caller; see [`LinkBacking`].
2676 pub fn snapshot_for_field_with(
2677 &self,
2678 field: &str,
2679 backing: LinkBacking<'_>,
2680 ) -> Option<super::super::snapshot::Snapshot> {
2681 // The GET path serves the field at its DECLARED type, the same type
2682 // the CA create-channel path announced from `client_field_value` and
2683 // the same one the monitor path posts.
2684 let value = self.client_field_value(field)?;
2685 Some(self.finish_field_snapshot(field, value, backing))
2686 }
2687
2688 /// Which of this record's own link fields, if any, supplies `field`'s
2689 /// metadata — C's `get_linkNumber` question, asked before any lock is
2690 /// dropped so `PvDatabase` knows whether it has to resolve at all.
2691 pub(crate) fn link_backed_metadata_field_of(&self, field: &str) -> Option<String> {
2692 self.record
2693 .link_backed_metadata_field(&field.to_ascii_uppercase())
2694 }
2695
2696 /// The value a channel bound to `field` serves, through the `$` view
2697 /// the channel was bound with.
2698 ///
2699 /// `dbChannelCreate` decides the view ONCE, at bind time
2700 /// (`dbChannel.c:486-505`), and every delivery path then reads through
2701 /// the `dbChannel` it produced; this is that single read. Callers must
2702 /// not re-derive it: resolving the bare field name answers "yes" for
2703 /// `VAL` whatever its type, so a path that does drops the eligibility
2704 /// half of the view entirely and admits `REC.VAL$` on a `DBF_DOUBLE`.
2705 ///
2706 /// `None` is `S_dbLib_fieldNotFound`: the record has no such field, or
2707 /// `$` was applied to a field that cannot be re-viewed as a character
2708 /// array (see [`Self::resolve_string_view_field`]).
2709 pub fn channel_field_value(&self, field: &str, string_view: bool) -> Option<EpicsValue> {
2710 if string_view {
2711 self.resolve_string_view_field(field)
2712 } else {
2713 self.client_field_value(field)
2714 }
2715 }
2716
2717 /// [`Self::snapshot_for_field_with`] through the same `$` view as
2718 /// [`Self::channel_field_value`] — the metadata is the field's either
2719 /// way, only the value is re-viewed.
2720 ///
2721 /// This is the `_with` variant deliberately: the view decides the VALUE,
2722 /// `backing` decides the METADATA, and the two are independent. A caller
2723 /// that has resolved a [`LinkBacking`] passes it straight through, so a
2724 /// link-backed `$` member keeps its target's units/precision.
2725 pub fn channel_snapshot_for_field(
2726 &self,
2727 field: &str,
2728 string_view: bool,
2729 backing: LinkBacking<'_>,
2730 ) -> Option<super::super::snapshot::Snapshot> {
2731 let value = self.channel_field_value(field, string_view)?;
2732 Some(self.finish_field_snapshot(field, value, backing))
2733 }
2734
2735 /// The one finishing pipeline behind both `Snapshot` producers
2736 /// ([`Self::snapshot_for_field`] for GET, [`Self::make_monitor_snapshot`]
2737 /// for updates). Every step that shapes a served snapshot — alarm/utag
2738 /// carry, the metadata cache, per-field routing and RSET overrides, menu
2739 /// enums, property support, the `Q:time:tag` nsec split — runs here, so
2740 /// the two paths cannot drift apart. Upstream pvxs PR #189 is exactly
2741 /// that drift: its subscription callback served unmasked nanoseconds
2742 /// while its GET path applied the nsec mask.
2743 fn finish_field_snapshot(
2744 &self,
2745 field: &str,
2746 value: EpicsValue,
2747 backing: LinkBacking<'_>,
2748 ) -> super::super::snapshot::Snapshot {
2749 let mut snap = super::super::snapshot::Snapshot::new(
2750 value,
2751 self.common.stat,
2752 self.common.sevr as u16,
2753 self.common.time,
2754 );
2755 // Default the served `timeStamp.userTag` to the record's `utag`,
2756 // mirroring pvxs `iocsource.cpp:245` (`auto utag = meta.utag;`).
2757 // The 64-bit `epicsUTag` narrows to the int32 NT wire field by
2758 // truncating to the low 32 bits — pvxs assigns the same uint64
2759 // straight into the `Int32` `timeStamp.userTag`. The `Q:time:tag`
2760 // nsec-LSB split below overrides this when configured, matching
2761 // pvxs `if(info.nsecMask) utag = meta.time.nsec & info.nsecMask;`
2762 // (:246-247 — the test and its assignment).
2763 snap.user_tag = self.common.utag as i32;
2764 // Carry the record's committed alarm message (`common.amsg`) so a
2765 // PVA read serves `alarm.message` from the record's own amsg
2766 // (pvxs `iocsource.cpp:230-236` prefers `meta.amsg`) rather than a
2767 // string re-synthesized from the condition code. Empty for records
2768 // that raise no message (C's plain `recGblSetSevr` clears namsg).
2769 snap.alarm.amsg = self.common.amsg.clone();
2770
2771 // Pull display/control/enums from the metadata cache (build on
2772 // first call, hit thereafter until invalidated by a metadata-class
2773 // field write).
2774 let meta = self.cached_metadata();
2775 snap.display = meta.display;
2776 snap.control = meta.control;
2777 snap.enums = meta.enums;
2778
2779 // The cache above is the record's VAL metadata. C routes PER FIELD, so
2780 // a non-VAL-class field does NOT get VAL's limits — see
2781 // [`Self::route_field_metadata`], which owns that decision.
2782 self.route_field_metadata(field, backing, &mut snap);
2783
2784 // Per-field RSET metadata (C get_units/get_precision/
2785 // get_graphic_double/get_control_double/get_alarm_double key on
2786 // dbGetFieldIndex) patches the record-level cache for this field.
2787 self.apply_field_metadata_override(field, &mut snap);
2788
2789 // DBF_MENU field (a shared menu such as `SCAN`/`OMSL`/`HHSV`/... or
2790 // a record-specific menu such as `sel.SELM`): carry the menu index
2791 // as DBR_ENUM and attach its `menu()` choice labels. See
2792 // `attach_menu_enum`. This overrides any record VAL enum table
2793 // copied from the metadata cache above, because a menu field
2794 // carries its own menu's choices, not the record's VAL state
2795 // strings.
2796 self.attach_menu_enum(field, &mut snap);
2797
2798 // The metadata VALUES and the mask that says which of them this
2799 // channel actually supplies are assigned by the same owner, from the
2800 // settled value, so they cannot disagree.
2801 self.assign_property_support(field, &mut snap);
2802
2803 // apply `info(Q:time:tag, "nsec:lsb:N")` — pvxs
2804 // `iocsource.cpp:239-248` publishes `nanoseconds & ~nsecMask` and
2805 // moves `nanoseconds & nsecMask` into `timeStamp.userTag`. The
2806 // split is applied to both `snap.timestamp` and `snap.user_tag` so
2807 // downstream encoders (NTScalar `timeStamp`, QSRV groups) all see
2808 // the same shape. A zero mask (tag absent or unparseable) is a
2809 // no-op inside the helper, exactly as pvxs's `if(info.nsecMask)`
2810 // gate is.
2811 crate::server::snapshot::apply_nsec_mask(&mut snap, self.qtime_nsec_mask());
2812
2813 snap
2814 }
2815
2816 /// Resolve `info(Q:time:tag)` to pvxs's `MappingInfo::nsecMask`.
2817 /// Returns 0 (the "no split" mask) when the tag is absent or does not
2818 /// parse — pvxs leaves `nsecMask` at its 0 initialiser in that case.
2819 ///
2820 /// pvxs `ioc/typeutils.cpp:79-88`:
2821 ///
2822 /// ```c
2823 /// if(auto val = ent.info("Q:time:tag")) {
2824 /// epicsInt32 dig = 0;
2825 /// if(strncmp(val, "nsec:lsb:", 9)==0 && !epicsParseInt32(&val[9], &dig, 10, nullptr)) {
2826 /// nsecMask = (uint64_t(1u)<<dig)-1u;
2827 /// }
2828 /// }
2829 /// ```
2830 ///
2831 /// The prefix test is a byte-exact `strncmp` — no case folding and no
2832 /// whitespace tolerance, so `NSEC:LSB:4` and `nsec: lsb: 4` do NOT
2833 /// match and leave the timestamp alone. There is no bounds clamp
2834 /// either: any `dig` `epicsParseInt32` accepts is shifted verbatim, so
2835 /// `nsec:lsb:31` yields the `0x7FFF_FFFF` mask pvxs actually serves.
2836 fn qtime_nsec_mask(&self) -> u64 {
2837 let Some(rest) = self
2838 .get_info("Q:time:tag")
2839 .and_then(|v| v.strip_prefix("nsec:lsb:"))
2840 else {
2841 return 0;
2842 };
2843 let Some(dig) = epics_parse_int32_base10(rest) else {
2844 return 0;
2845 };
2846 // C shifts `uint64_t(1u)` by an `epicsInt32`. A `dig` outside
2847 // `0..=63` is UB in C++; every ISA EPICS builds on (x86-64 `shlq`,
2848 // aarch64 `lsl`) takes the shift count modulo 64, which is what
2849 // `wrapping_shl` does — so `nsec:lsb:64` disables the split
2850 // (mask 0) and a negative `dig` shifts by `dig & 63`, the same
2851 // masks pvxs produces on those hosts.
2852 1u64.wrapping_shl(dig as u32) - 1
2853 }
2854
2855 /// Populate DisplayInfo from record fields if applicable.
2856 /// Resolve the `Q:form` info-tag value to a `display.form` menu index.
2857 ///
2858 /// pvxs publishes the fixed seven-entry form menu
2859 /// (Default/String/Binary/Decimal/Hex/Exponential/Engineering) for every
2860 /// numeric value and, for the VAL field only, sets `display.form.index`
2861 /// to the slot whose name equals the field's `Q:form` info tag
2862 /// (`iocsource.cpp:42-62`, case-sensitive). Unset or unrecognised ->
2863 /// `None` (form stays 0 = Default), exactly as pvxs leaves the index
2864 /// untouched on no match.
2865 fn q_form_index(&self) -> Option<i16> {
2866 const FORM_NAMES: [&str; 7] = [
2867 "Default",
2868 "String",
2869 "Binary",
2870 "Decimal",
2871 "Hex",
2872 "Exponential",
2873 "Engineering",
2874 ];
2875 let tag = self.info.get("Q:form")?;
2876 FORM_NAMES
2877 .iter()
2878 .position(|name| name == tag)
2879 .map(|i| i as i16)
2880 }
2881
2882 /// Stamp a built snapshot with the property mask THIS channel supplies —
2883 /// [`Record::property_support`] narrowed to the addressed field by C's
2884 /// second gate ([`PropertySupport::narrowed_to_field`]). Called by both
2885 /// snapshot builders once the value has settled (after
2886 /// [`Self::attach_menu_enum`] promoted a `DBF_MENU` field to its
2887 /// `DBR_ENUM` form), so the mask is read off the same value the client
2888 /// receives and no consumer has to re-derive either gate.
2889 fn assign_property_support(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
2890 snap.properties = self.record.property_support().narrowed_to_field(
2891 snap.value.db_field_type(),
2892 self.menu_choices_for(field).is_some(),
2893 );
2894 }
2895
2896 /// The property mask a channel on `field` supplies, without building a
2897 /// snapshot — what a PVA server needs to decide which NT leaves it may
2898 /// MARK for a channel it has not read yet (QSRV resolves a group's member
2899 /// masks once, at monitor start, rather than per event).
2900 ///
2901 /// Same two gates, same owner as `Self::assign_property_support`: an
2902 /// unknown field supplies nothing.
2903 pub fn property_support_for_field(&self, field: &str) -> PropertySupport {
2904 let Some(value) = self.client_field_value(field) else {
2905 return PropertySupport::NONE;
2906 };
2907 self.record.property_support().narrowed_to_field(
2908 value.db_field_type(),
2909 self.menu_choices_for(field).is_some(),
2910 )
2911 }
2912
2913 /// The record-level display metadata cache: units, precision and display
2914 /// limits as C's `get_units` / `get_precision` / `get_graphic_double`
2915 /// answer them for the fields their rset lists.
2916 ///
2917 /// Driven by [`Record::property_support`], not by a `match` on the record
2918 /// type. Those were two independent tables answering the same question,
2919 /// and nothing kept them in step: `default_property_support` declares
2920 /// units and precision for twenty-five record types where the arm list
2921 /// here covered nine, so the other sixteen declared the leaf and served
2922 /// `""` / `0` — `sel`, `sub`, `dfanout`, `subArray`, `scalcout`,
2923 /// `acalcout`, `epid`, `scaler`, `swait`, `sseq`, `seq`, `mca`,
2924 /// `histogram`, `transform`, `throttle` and `asyn`. Deriving the cache
2925 /// from the declaration makes "declares the slot" and "supplies the slot"
2926 /// one fact rather than two that can disagree.
2927 ///
2928 /// Precision matters well beyond `caget -d`: it is also what the
2929 /// DBF_DOUBLE to DBR_STRING conversion renders with, in C
2930 /// (`dbConvert.c:783-786` calls `prset->get_precision` with no field-type
2931 /// gate) and here (`codec.rs::convert_value_to_dbr_string`), so a missing
2932 /// slot changed the digits of a plain `caget`.
2933 ///
2934 /// The sources are the same in every C rset that supplies them — `EGU` for
2935 /// `get_units` and `PREC` for `get_precision` — so only the graphic pair
2936 /// needs a per-type table (`graphic_limit_fields`). Per-FIELD departures
2937 /// from the record's own values stay where C puts them, in that record's
2938 /// [`Record::field_metadata_override`], which is applied after this and
2939 /// wins.
2940 fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
2941 let slots = self.record.property_support();
2942 if slots.units || slots.precision || slots.graphic_double {
2943 let (upper, lower) = if slots.graphic_double {
2944 let (hi, lo) = super::record_trait::graphic_limit_fields(self.record.record_type());
2945 (self.metadata_limit(hi), self.metadata_limit(lo))
2946 } else {
2947 (0.0, 0.0)
2948 };
2949 snap.display = Some(super::super::snapshot::DisplayInfo {
2950 units: if slots.units {
2951 self.metadata_units()
2952 } else {
2953 Default::default()
2954 },
2955 precision: if slots.precision {
2956 self.metadata_limit("PREC") as i16
2957 } else {
2958 0
2959 },
2960 upper_disp_limit: upper,
2961 lower_disp_limit: lower,
2962 ..Default::default()
2963 });
2964 }
2965 // Apply the `Q:form` display-format hint. The block above builds
2966 // `snap.display` for every record type that supplies at least one
2967 // display slot — the same set for which pvxs emits
2968 // `display.form.choices`. This cache is record-level (it is the VAL
2969 // field's metadata); the VAL-only rule pvxs applies to
2970 // `display.form.index` (`iocsource.cpp:53`) is enforced per served
2971 // field in `apply_field_metadata_override`.
2972 if let Some(display) = snap.display.as_mut() {
2973 if let Some(form) = self.q_form_index() {
2974 display.form = form;
2975 }
2976 }
2977 // `display.description` from dbCommon DESC — pvxs QSRV fills it
2978 // on every metadata populate (iocsource.cpp:306-310), for every
2979 // record type including those with no other display source. The
2980 // qsrv builders always emit the leaf (defaulting a `None`
2981 // display), so creating the DisplayInfo here changes leaf
2982 // values, never the wire shape. Cache freshness is owned by the
2983 // DESC arm of `put_common_field`, which invalidates without
2984 // posting DBE_PROPERTY (epics-base#785 / UI-106).
2985 snap.display
2986 .get_or_insert_with(Default::default)
2987 .description = self.common.desc.clone();
2988 }
2989
2990 /// The record-level control-limit cache — what C's `get_control_double`
2991 /// answers for the fields its rset lists.
2992 ///
2993 /// Gated on the declared slot for the same reason as
2994 /// [`Self::populate_display_info`], and with the same effect: the arm list
2995 /// this replaces covered thirteen record types where
2996 /// `default_property_support` declares `control_double` for twenty-three,
2997 /// so `sel`, `sub`, `dfanout`, `subArray`, `histogram`, `scalcout`,
2998 /// `acalcout` and `epid` served 0/0 on `VAL` — a channel whose C rset
2999 /// answers the record's own operator range.
3000 ///
3001 /// Which of the record's fields that range comes from is the one thing
3002 /// that varies by type, and it lives in `control_limit_source`.
3003 fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
3004 use super::record_trait::ControlLimitSource;
3005
3006 if !self.record.property_support().control_double {
3007 return;
3008 }
3009 let (upper, lower) =
3010 match super::record_trait::control_limit_source(self.record.record_type()) {
3011 ControlLimitSource::Drive => {
3012 (self.metadata_limit("DRVH"), self.metadata_limit("DRVL"))
3013 }
3014 ControlLimitSource::DriveWhenSet => {
3015 let (drvh, drvl) = (self.metadata_limit("DRVH"), self.metadata_limit("DRVL"));
3016 if drvh > drvl {
3017 (drvh, drvl)
3018 } else {
3019 (self.metadata_limit("HOPR"), self.metadata_limit("LOPR"))
3020 }
3021 }
3022 ControlLimitSource::SoftLimits => {
3023 (self.metadata_limit("HLM"), self.metadata_limit("LLM"))
3024 }
3025 ControlLimitSource::Operator => {
3026 (self.metadata_limit("HOPR"), self.metadata_limit("LOPR"))
3027 }
3028 };
3029 snap.control = Some(super::super::snapshot::ControlInfo {
3030 upper_ctrl_limit: upper,
3031 lower_ctrl_limit: lower,
3032 });
3033 }
3034
3035 /// A numeric metadata field (`PREC`, `HOPR`, `DRVH`, ...) as C reads it —
3036 /// straight out of record memory, which for C means every field the `.dbd`
3037 /// declares.
3038 ///
3039 /// Through [`Self::resolve_field`], NOT `Record::get_field`, and that is
3040 /// the whole reason this cache used to read zero for the types it now
3041 /// serves: a Rust record implements only the fields it has behaviour for,
3042 /// so `sel`, `sub`, `dfanout` and their siblings model no `PREC`/`HOPR`
3043 /// cell at all and `get_field` answers `None` for them. Their `.db` values
3044 /// live in `declared_overrides`, and their unset defaults in the `.dbd`
3045 /// `initial()`, both of which only `resolve_field` reaches.
3046 fn metadata_limit(&self, field: &str) -> f64 {
3047 self.resolve_field(field)
3048 .and_then(|v| v.to_f64())
3049 .unwrap_or(0.0)
3050 }
3051
3052 /// `EGU`, the source every ported C `get_units` copies from. Empty for a
3053 /// record type whose `.dbd` declares no `EGU` — `seq` and `histogram` have
3054 /// none, and C writes nothing into the `dbAccess.c:378` seed for either.
3055 fn metadata_units(&self) -> crate::types::PvString {
3056 match self.resolve_field("EGU") {
3057 Some(EpicsValue::String(s)) => s,
3058 _ => Default::default(),
3059 }
3060 }
3061
3062 /// Whether C's `get_units` copies the record's own `EGU` into `field`.
3063 ///
3064 /// The fourth membership question, and the one the port never asked. Units
3065 /// had no per-field step at all: the record-level cache was the entire
3066 /// answer, so every field of a type that supplies the slot was served
3067 /// `EGU` — including the fields whose C rset tests first and writes
3068 /// nothing, leaving the `dbAccess.c:378` empty seed. Measured shape:
3069 /// `caget -d DBR_GR_DOUBLE AI.SMOO` served `EGU` where `aiRecord.c:223-226`
3070 /// deliberately skips the three raw-conversion fields.
3071 ///
3072 /// * `ai`/`ao` (`aiRecord.c:217-232`, `aoRecord.c:284-298`) — a DBF_DOUBLE
3073 /// field other than the raw-conversion ones, which carry no engineering
3074 /// units.
3075 /// * `calc`/`calcout`/`sub`/`sel`/`dfanout` (`calcRecord.c:169-182`,
3076 /// `calcoutRecord.c:425-444`, `subRecord.c:206-219`,
3077 /// `selRecord.c:136-143`, `dfanoutRecord.c:155-163`) — any DBF_DOUBLE
3078 /// field.
3079 /// * `longin`/`longout` (`longinRecord.c:183-191`) test DBF_LONG and the
3080 /// int64 pair (`int64inRecord.c:179-187`) DBF_INT64: the record's own VAL
3081 /// type, not DOUBLE.
3082 /// * `compress` (`compressRecord.c:449-458`) widens the DBF_DOUBLE test
3083 /// with `VAL`, whose served type comes from the record rather than the
3084 /// dbd.
3085 /// * the array types (`waveformRecord.c:220-233`, `aaiRecord.c`,
3086 /// `aaoRecord.c`, `subArrayRecord.c:202-215`) name `VAL`, `HOPR` and
3087 /// `LOPR`, and drop `VAL` when `FTVL` makes it strings or enums.
3088 /// * `histogram`, `seq`, `bo`, `table` and `aSub` never write `EGU` at all;
3089 /// each answers a literal or a link for a named set and nothing
3090 /// elsewhere, and the literals come from
3091 /// [`Record::field_metadata_override`].
3092 ///
3093 /// Every other ported type copies `EGU` with no test whatever
3094 /// (`sCalcoutRecord.c:603-609`, `aCalcoutRecord.c:743-749`,
3095 /// `epidRecord.c:217-223`, `mcaRecord.c:884-890`, `motorRecord.cc`'s
3096 /// `default:` arm).
3097 fn units_from_egu(&self, rtype: &str, field: &str) -> bool {
3098 use crate::types::DbFieldType as T;
3099 let f = field.to_ascii_uppercase();
3100 // The link arm is NOT here: `route_field_metadata` asks
3101 // [`Record::link_backed_metadata_field`] first and only falls through
3102 // to this EGU question for a field no link backs. This function
3103 // answers C's `else strncpy(units, prec->egu, ...)` branch alone.
3104 let own = |t: T| self.static_field_type(&f) == Some(t);
3105 match rtype {
3106 "ai" => own(T::Double) && !matches!(f.as_str(), "ASLO" | "AOFF" | "SMOO"),
3107 "ao" => own(T::Double) && !matches!(f.as_str(), "ASLO" | "AOFF"),
3108 "calc" | "calcout" | "sub" | "sel" | "dfanout" => own(T::Double),
3109 "longin" | "longout" => own(T::Long),
3110 "int64in" | "int64out" => own(T::Int64),
3111 "compress" => own(T::Double) || f == "VAL",
3112 "waveform" | "aai" | "aao" | "subArray" => {
3113 matches!(f.as_str(), "HOPR" | "LOPR")
3114 || (f == "VAL" && !self.ftvl_is_string_or_enum())
3115 }
3116 "histogram" | "seq" | "bo" | "table" | "aSub" => false,
3117 _ => true,
3118 }
3119 }
3120
3121 /// `FTVL` names `DBF_STRING` or `DBF_ENUM` — the two element types for
3122 /// which the array rsets break out of the `VAL` case before copying `EGU`.
3123 /// `menuFtype` is declared in `DBF_` code order, so `0` is STRING and `11`
3124 /// is ENUM.
3125 fn ftvl_is_string_or_enum(&self) -> bool {
3126 matches!(
3127 self.resolve_field("FTVL").and_then(|v| v.to_f64()),
3128 Some(x) if x == 0.0 || x == 11.0
3129 )
3130 }
3131
3132 /// Populate EnumInfo — C rset `get_enum_strs`.
3133 ///
3134 /// The table comes from [`Record::enum_state_strings`], the SAME slot the
3135 /// string-put converter (`dbConvert.c::putStringEnum`) resolves against, so
3136 /// the choice list a client reads and the names it may write are one table
3137 /// by construction. It arrives already trimmed to C's `no_str` (bi/bo/busy
3138 /// drop an empty ONAM behind a set ZNAM — `boRecord.c:342-352`; mbbi/mbbo
3139 /// cut at the last non-empty state — `mbbiRecord.c:262-269`).
3140 ///
3141 /// The `DBR_STRING` half of the same channel is the record's OTHER rset slot
3142 /// (`get_enum_str`), which is not this trimmed list — see
3143 /// [`EnumStringForm`]. A record that has no such slot (every record but
3144 /// bi/bo/busy/mbbi/mbbo, including the downstream crates' own enum records)
3145 /// renders from its label list, which is what C's absent slot amounts to.
3146 fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
3147 if let Some(strings) = self.record.enum_state_strings() {
3148 snap.enums = Some(match self.record.enum_string_form() {
3149 Some(form) => super::super::snapshot::EnumInfo::with_string_form(strings, form),
3150 None => super::super::snapshot::EnumInfo::new(strings),
3151 });
3152 }
3153 }
3154
3155 /// Get a common field value.
3156 pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
3157 match name {
3158 "SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
3159 "STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
3160 "NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
3161 "NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
3162 // epics-base PR #568 / #566 — alarm message string.
3163 "AMSG" => Some(EpicsValue::String(self.common.amsg.clone().into())),
3164 "NAMSG" => Some(EpicsValue::String(self.common.namsg.clone().into())),
3165 "ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
3166 // `ACKT` and `PINI` are `DBF_MENU` (`menuYesNo` /`menuPini`,
3167 // `dbCommon.dbd.pod:335,169`), not `DBF_UCHAR`: they carry a menu
3168 // index, which `promote_menu_value` lifts to `DBR_ENUM` with the
3169 // menu's choice strings. Storing them as `Short` is what makes
3170 // them eligible for that promotion — see `promote_menu_value`.
3171 "ACKT" => Some(EpicsValue::Short(if self.common.ackt { 1 } else { 0 })),
3172 // DBF_UCHAR: served as UChar (declared type) so the raw put byte
3173 // round-trips to the wire — see the DISP/TPRO comment above.
3174 "UDF" => Some(EpicsValue::UChar(self.common.udf)),
3175 "UDFS" => Some(EpicsValue::Short(self.common.udfs)),
3176 "SCAN" => Some(EpicsValue::Enum(self.common.scan.to_u16())),
3177 "SSCN" => Some(EpicsValue::Enum(self.common.sscn.to_u16())),
3178 // `OLDSIMM` is `DBF_MENU`/`menu(menuSimm)`, stored as the menu index
3179 // and promoted to `DBR_ENUM` with the NO/YES/RAW labels by
3180 // `promote_menu_value` (shared registry — the saved copy is ALWAYS
3181 // menuSimm, unlike the live SIMM). Written only by the simulation
3182 // owner (`rec_gbl_save_simm`); `special(SPC_NOMOD)` for clients.
3183 "OLDSIMM" => Some(EpicsValue::Short(self.common.oldsimm)),
3184 "PINI" => Some(EpicsValue::Short(self.common.pini)),
3185 // DISP/TPRO/RPRO/UDF are `DBF_UCHAR` in `dbCommon.dbd`. Serve them
3186 // as `UChar` — their DECLARED type — so `project_to_declared_type`
3187 // is identity and the raw put byte reaches the wire untouched (C
3188 // stores the byte and `caget` renders `DBR_CHAR` signed: 255 → -1).
3189 // Serving `Char` here instead routed the value through the lossy
3190 // `Char → UChar` projection (signed −1 clamped to 0), so a
3191 // `caput DISP 255` read back as 0 rather than C's -1. `BKPT` is
3192 // `DBF_NOACCESS`: no `FieldDesc`, no projection, served `Char`.
3193 "TPRO" => Some(EpicsValue::UChar(self.common.tpro)),
3194 "BKPT" => Some(EpicsValue::Char(self.common.bkpt)),
3195 "FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
3196 // A record type whose C `.dbd` has no INP has no `.INP` channel
3197 // either — C's dbChannel resolution is the dbd, so `dbgf HI.INP` on
3198 // a histogram answers "PV 'HI.INP' not found". The port keeps INP on
3199 // `CommonFields` for every record, so `declares_inp_link()` is what
3200 // stands in for the dbd, and it must gate the read side as well as
3201 // the write side (`put_common_field`) — otherwise the field is
3202 // unloadable and unwritable yet still resolves as a channel.
3203 "INP" if self.record.declares_inp_link() => {
3204 Some(EpicsValue::String(self.common.inp.clone().into()))
3205 }
3206 "OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
3207 // C's DTYP is `DBF_DEVICE`: an epicsEnum16 index into the record
3208 // type's device menu, NOT the name. The name is what this port
3209 // stores and dispatches on; the index is what the wire carries.
3210 "DTYP" => Some(EpicsValue::Enum(self.dtyp_index())),
3211 "TSE" => Some(EpicsValue::Short(self.common.tse)),
3212 "TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
3213 // C `UTAG` is DBF_UINT64 — exposed natively as the unsigned
3214 // 64-bit value variant so values above i64::MAX round-trip.
3215 "UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
3216 "ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
3217 "ASL" => Some(EpicsValue::Char(self.common.asl)),
3218 "DESC" => Some(EpicsValue::String(self.common.desc.clone())),
3219 "PHAS" => Some(EpicsValue::Short(self.common.phas)),
3220 "EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
3221 "PRIO" => Some(EpicsValue::Short(self.common.prio)),
3222 "DISV" => Some(EpicsValue::Short(self.common.disv)),
3223 "DISA" => Some(EpicsValue::Short(self.common.disa)),
3224 "SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
3225 "DISS" => Some(EpicsValue::Short(self.common.diss)),
3226 "HYST" => Some(EpicsValue::Double(self.common.hyst)),
3227 "LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
3228 "DISP" => Some(EpicsValue::UChar(self.common.disp)),
3229 "PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
3230 "RPRO" => Some(EpicsValue::UChar(self.common.rpro)),
3231 "PACT" => Some(EpicsValue::Char(if self.is_processing() { 1 } else { 0 })),
3232 // C `dbCommon.dbd`: `field(PROC,DBF_UCHAR)` — the raw put byte is
3233 // retained in `prec->proc` and served back SIGNED as `DBR_CHAR`
3234 // (`caput PROC 255` → `caget` = -1), exactly like DISP/RPRO. The
3235 // `pp(TRUE)` force-process is orthogonal: writing PROC still
3236 // reprocesses the record (put-path intercept), but the byte sticks.
3237 "PROC" => Some(EpicsValue::UChar(self.common.proc_field)),
3238 // Analog alarm fields
3239 "HIHI" => self
3240 .common
3241 .analog_alarm
3242 .as_ref()
3243 .map(|a| a.hihi.to_epics_value()),
3244 "HIGH" => self
3245 .common
3246 .analog_alarm
3247 .as_ref()
3248 .map(|a| a.high.to_epics_value()),
3249 "LOW" => self
3250 .common
3251 .analog_alarm
3252 .as_ref()
3253 .map(|a| a.low.to_epics_value()),
3254 "LOLO" => self
3255 .common
3256 .analog_alarm
3257 .as_ref()
3258 .map(|a| a.lolo.to_epics_value()),
3259 "HHSV" => self
3260 .common
3261 .analog_alarm
3262 .as_ref()
3263 .map(|a| EpicsValue::Short(a.hhsv)),
3264 "HSV" => self
3265 .common
3266 .analog_alarm
3267 .as_ref()
3268 .map(|a| EpicsValue::Short(a.hsv)),
3269 "LSV" => self
3270 .common
3271 .analog_alarm
3272 .as_ref()
3273 .map(|a| EpicsValue::Short(a.lsv)),
3274 "LLSV" => self
3275 .common
3276 .analog_alarm
3277 .as_ref()
3278 .map(|a| EpicsValue::Short(a.llsv)),
3279 // swait OUTN is aliased to common.out
3280 "OUTN" => {
3281 if self.record.record_type() == "swait" {
3282 Some(EpicsValue::String(self.common.out.clone().into()))
3283 } else {
3284 None
3285 }
3286 }
3287 _ => None,
3288 }
3289 }
3290
3291 /// `true` when the record type declares `name` in its own `field_list`,
3292 /// i.e. the record stores the field itself and owns whatever behaviour
3293 /// hangs off it.
3294 ///
3295 /// This separates the two meanings a link field carries. `common.inp` /
3296 /// `common.out` is the link *text* — always the value the `.db` file
3297 /// wrote, for every record type, because C device support reads
3298 /// `prec->inp` / `prec->out` at `init_record` no matter which layer owns
3299 /// the field ([`crate::server::db_loader::apply_fields`] keeps it
3300 /// populated). `parsed_inp` / `parsed_out` is the *framework's* dispatch
3301 /// of that link, and is armed only for a record type that does NOT
3302 /// declare the field: a record that declares it drives the link itself
3303 /// (`multi_output_links` for `acalcout`/`scalcout`, device support for
3304 /// `motorRecord`/`scalerRecord`, or its own `process`). Arming the
3305 /// framework path for those too would write the link twice per cycle.
3306 fn record_declares_field(&self, name: &str) -> bool {
3307 self.record.implements_field(name)
3308 }
3309
3310 /// Set a common field value from a runtime `dbPut` (CA/PVA/`dbpf`/link).
3311 /// Returns what scan index changes are needed.
3312 ///
3313 /// A `DBF_MENU` common field's string is converted by C's runtime
3314 /// converter, `dbConvert.c::putStringMenu` — see `MenuBound::DbPut`.
3315 pub fn put_common_field(
3316 &mut self,
3317 name: &str,
3318 value: EpicsValue,
3319 ) -> CaResult<CommonFieldPutResult> {
3320 self.put_common_field_bounded(name, value, MenuBound::DbPut)
3321 }
3322
3323 /// **The single owner of a record's SCAN transition** — C `dbPutField` on
3324 /// SCAN, which is `scanDelete(precord)` … `scanAdd(precord)`
3325 /// (`dbAccess.c::dbPutSpecial` SPC_SCAN, dbScan.c:236-248).
3326 ///
3327 /// Two callers reach it, and they are the two C sites that move a record
3328 /// between scan lists: a `SCAN` put ([`Self::put_common_field`]) and the
3329 /// simulation-mode scan swap (`recGblCheckSimm`, recGbl.c:427-437, which
3330 /// calls exactly the same `scanDelete`/`scanAdd` pair). Returns the delta
3331 /// for the scan-index owner (`PvDatabase::update_scan_index`) to apply once
3332 /// the record lock is down; [`CommonFieldPutResult::NoChange`] when the scan
3333 /// did not move.
3334 pub fn set_scan(&mut self, new_scan: ScanType) -> CommonFieldPutResult {
3335 let old_scan = self.common.scan;
3336 self.common.scan = new_scan;
3337 if old_scan == new_scan {
3338 return CommonFieldPutResult::NoChange;
3339 }
3340 // C `scanDelete`/`scanAdd` call the record's device support
3341 // `get_ioint_info(1)` / `get_ioint_info(0)`. Only a change of I/O Intr
3342 // *membership* reaches those; a Passive→"1 second" move calls neither.
3343 let was_io_intr = old_scan == ScanType::IoIntr;
3344 let is_io_intr = new_scan == ScanType::IoIntr;
3345 if was_io_intr != is_io_intr {
3346 self.record.set_io_intr_scan(is_io_intr);
3347 }
3348 CommonFieldPutResult::ScanChanged {
3349 old_scan,
3350 new_scan,
3351 phas: self.common.phas,
3352 }
3353 }
3354
3355 /// C `recGblSaveSimm` (`recGbl.c:421-425`) — latch the CURRENT simulation
3356 /// mode into OLDSIMM:
3357 ///
3358 /// ```c
3359 /// void recGblSaveSimm(const epicsEnum16 sscn,
3360 /// epicsEnum16 *poldsimm, const epicsEnum16 simm) {
3361 /// if (sscn == USHRT_MAX) return;
3362 /// *poldsimm = simm;
3363 /// }
3364 /// ```
3365 ///
3366 /// **The only writer of `CommonFields::oldsimm`.** Must run BEFORE the SIMM
3367 /// value moves — C calls it from `special(SPC_MOD)` pass 0 (before the put)
3368 /// and from `recGblGetSimm`/`recGblInitSimm` before the SIML read. The
3369 /// `sscn == 65535` guard is C's: with SSCN unset there is no scan to swap
3370 /// to, so the latch is not even taken (and [`Self::rec_gbl_check_simm`]
3371 /// bails on the same test, so the stale OLDSIMM is never read).
3372 ///
3373 /// A record type with no SSCN/OLDSIMM in its C dbd (`busy`, `swait`) passes
3374 /// neither pointer to any recGbl helper: no-op here.
3375 pub fn rec_gbl_save_simm(&mut self) {
3376 if !self.record.uses_recgbl_simm_helpers() {
3377 return;
3378 }
3379 // C `recGblSaveSimm`: `if (*psscn == USHRT_MAX) return;` — the literal
3380 // sentinel, not "any index outside the menu".
3381 if self.common.sscn.is_unset() {
3382 return;
3383 }
3384 if let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") {
3385 self.common.oldsimm = simm;
3386 }
3387 }
3388
3389 /// C `recGblCheckSimm` (`recGbl.c:427-437`) — on a SIMM transition, swap the
3390 /// record's SCAN with SSCN:
3391 ///
3392 /// ```c
3393 /// void recGblCheckSimm(struct dbCommon *pcommon, epicsEnum16 *psscn,
3394 /// const epicsEnum16 oldsimm, const epicsEnum16 simm) {
3395 /// if (*psscn == USHRT_MAX) return;
3396 /// if (simm != oldsimm) {
3397 /// epicsUInt16 scan = pcommon->scan;
3398 /// scanDelete(pcommon);
3399 /// pcommon->scan = *psscn;
3400 /// scanAdd(pcommon);
3401 /// *psscn = scan;
3402 /// }
3403 /// }
3404 /// ```
3405 ///
3406 /// This is what makes SSCN mean anything at all: a record configured
3407 /// `field(SCAN,"1 second") field(SSCN,"Passive")` stops periodic scanning
3408 /// the moment SIMM leaves NO, and resumes it when SIMM goes back — with the
3409 /// two fields having traded places each time. Both are a genuine swap, not
3410 /// an assignment: SSCN ends up holding the scan the record just left.
3411 ///
3412 /// **The only writer of the SIMM-driven SCAN/SSCN swap.** The scan-list
3413 /// move itself goes through the single SCAN owner [`Self::set_scan`], whose
3414 /// [`CommonFieldPutResult`] the caller hands to
3415 /// `PvDatabase::update_scan_index` once the record lock is down. Runs AFTER
3416 /// the SIMM value moved — C `special(SPC_MOD)` pass 1, and the tail of
3417 /// `recGblGetSimm`/`recGblInitSimm`.
3418 pub fn rec_gbl_check_simm(&mut self) -> CommonFieldPutResult {
3419 if !self.record.uses_recgbl_simm_helpers() {
3420 return CommonFieldPutResult::NoChange;
3421 }
3422 let Some(sim_scan) = self.common.sscn.scan() else {
3423 // `*psscn == USHRT_MAX` — SSCN unset, no swap. An SSCN that is
3424 // merely ILLEGAL still swaps: C assigns it into SCAN and `scanAdd`
3425 // then declines to scan the record.
3426 return CommonFieldPutResult::NoChange;
3427 };
3428 let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") else {
3429 return CommonFieldPutResult::NoChange;
3430 };
3431 if simm == self.common.oldsimm {
3432 return CommonFieldPutResult::NoChange;
3433 }
3434 let previous_scan = self.common.scan;
3435 let result = self.set_scan(sim_scan);
3436 self.common.sscn = SimModeScan::from_scan(previous_scan);
3437 result
3438 }
3439
3440 /// C `dbAccess.c::putAckt` (`:1285-1300`) — the **only** writer of ACKT.
3441 ///
3442 /// Reached from `dbPut` for a `DBR_PUT_ACKT` request *type*
3443 /// (`dbAccess.c:1331-1332`), ABOVE the `SPC_NOMOD` gate that refuses every
3444 /// ordinary put to the field. Posts exactly what C posts: the ACKT change,
3445 /// the ACKS it may lower, and the record-wide `DBE_ALARM` — and only when
3446 /// `ackt` actually changed (C returns 0 early otherwise).
3447 pub fn put_ackt(&mut self, value: u16, backing: LinkBacking<'_>) {
3448 let new_ackt = value != 0;
3449 if new_ackt == self.common.ackt {
3450 return;
3451 }
3452 use crate::server::recgbl::EventMask;
3453 let ack_mask = EventMask::VALUE | EventMask::ALARM;
3454 self.common.ackt = new_ackt;
3455 self.cleanup_subscribers();
3456 self.notify_field_backed("ACKT", ack_mask, backing);
3457 // C `:1294-1297`: turning transient acknowledgement off lowers a
3458 // sticky ACKS down to the current SEVR — an alarm that has already
3459 // cleared must not keep a higher unacknowledged severity.
3460 if !new_ackt && self.common.acks > self.common.sevr {
3461 self.common.acks = self.common.sevr;
3462 self.notify_field_backed("ACKS", ack_mask, backing);
3463 }
3464 self.notify_record_alarm(backing);
3465 }
3466
3467 /// C `dbAccess.c::putAcks` (`:1302-1315`) — the **only** runtime writer of
3468 /// ACKS. Reached from `dbPut` for a `DBR_PUT_ACKS` request type, ABOVE the
3469 /// `SPC_NOMOD` gate.
3470 ///
3471 /// The acknowledged severity is compared against the STORED unacknowledged
3472 /// severity `acks`, not the current `sevr`: an operator acknowledging at
3473 /// the severity that was latched into ACKS clears it even after `sevr` has
3474 /// since dropped. A too-low acknowledgement changes nothing and posts
3475 /// nothing; an acknowledgement of an already-clear ACKS still posts, which
3476 /// is C's literal `if (*psev >= precord->acks)` (0 >= 0 holds).
3477 pub fn put_acks(&mut self, value: u16, backing: LinkBacking<'_>) {
3478 let sev = AlarmSeverity::from_u16(value);
3479 if sev < self.common.acks {
3480 return;
3481 }
3482 use crate::server::recgbl::EventMask;
3483 self.common.acks = AlarmSeverity::NoAlarm;
3484 self.cleanup_subscribers();
3485 self.notify_field_backed("ACKS", EventMask::VALUE | EventMask::ALARM, backing);
3486 self.notify_record_alarm(backing);
3487 }
3488
3489 /// Set a common field value from the `.db` loader, which in C is a
3490 /// different converter with a different out-of-menu bound
3491 /// (`dbStaticRun.c::dbPutStringNum`; see `MenuBound::DbLoad`). It is what
3492 /// lets `field(SSCN,"65535")` — the menuScan "use SCAN" sentinel, out of
3493 /// the menu's 0-9 range — load, while `caput REC.SSCN 65535` is refused at
3494 /// runtime exactly as C refuses it.
3495 pub fn put_common_field_db_load(
3496 &mut self,
3497 name: &str,
3498 value: EpicsValue,
3499 ) -> CaResult<CommonFieldPutResult> {
3500 self.put_common_field_bounded(name, value, MenuBound::DbLoad)
3501 }
3502
3503 fn put_common_field_bounded(
3504 &mut self,
3505 name: &str,
3506 value: EpicsValue,
3507 bound: MenuBound,
3508 ) -> CaResult<CommonFieldPutResult> {
3509 let name = name.to_ascii_uppercase();
3510 self.record.validate_put(&name, &value)?;
3511 self.record.special(&name, false)?;
3512 // The db loader hands every common field to this path as a raw
3513 // `EpicsValue::String` (no per-field `FieldDesc` to parse against).
3514 // Coerce it to the field's canonical numeric/menu type up front so the
3515 // typed arms below apply a `field(PHAS, "1")` / `field(PRIO, "HIGH")`
3516 // directive instead of silently dropping it at IOC load. String-typed
3517 // and already-typed values pass through unchanged.
3518 let declared = declared_field_type_of(self.record.as_ref(), &name);
3519 let value = match coerce_common_field(&name, value, bound, declared)? {
3520 Converted::Stored(v) => v,
3521 // C's converter returned success without storing (`cvt_st_ul`'s
3522 // skipped store): the field keeps its old value, so no arm below
3523 // runs and no SCAN/PHAS transition happened.
3524 Converted::Unchanged => return Ok(CommonFieldPutResult::NoChange),
3525 };
3526 // C `dbPutString`/`dbPutField` route every link field's text through
3527 // `dbParseLink`, whose brace arm hands it to `dbJLinkParse`
3528 // (`dbStaticLib.c:2280-2286`); an unusable JSON link is
3529 // `S_dbLib_badField` and the field never takes the value. This is the
3530 // one funnel every common link field crosses — SDIS, TSEL, FLNK, DOL,
3531 // SIML, SIOL, INP, OUT — on both the db-load and the runtime put path,
3532 // so the rule holds without being restated per field.
3533 if let EpicsValue::String(ref s) = value {
3534 let text = s.as_str_lossy();
3535 if text.trim_start().starts_with('{')
3536 && crate::types::dbf_link_class(self.record.record_type(), &name).is_some()
3537 {
3538 super::check_json_link_text(&text)?;
3539 }
3540 }
3541 match name.as_str() {
3542 // `special(SPC_NOMOD)` — C `dbPutSpecial` refuses the put with
3543 // `S_db_noMod` (dbAccess.c:123-127). OLDSIMM is written only by the
3544 // simulation-mode owner (`rec_gbl_save_simm`).
3545 "OLDSIMM" => return Err(CaError::ReadOnlyField(name)),
3546 "SEVR" => {
3547 if let EpicsValue::Short(v) = value {
3548 self.common.sevr = AlarmSeverity::from_u16(v as u16);
3549 }
3550 }
3551 "STAT" => {
3552 if let EpicsValue::Short(v) = value {
3553 self.common.stat = v as u16;
3554 }
3555 }
3556 "NSEV" => {
3557 if let EpicsValue::Short(v) = value {
3558 self.common.nsev = AlarmSeverity::from_u16(v as u16);
3559 }
3560 }
3561 "NSTA" => {
3562 if let EpicsValue::Short(v) = value {
3563 self.common.nsta = v as u16;
3564 }
3565 }
3566 "AMSG" => {
3567 if let EpicsValue::String(s) = value {
3568 self.common.amsg = s.as_str_lossy().into_owned();
3569 }
3570 }
3571 "NAMSG" => {
3572 if let EpicsValue::String(s) = value {
3573 self.common.namsg = s.as_str_lossy().into_owned();
3574 }
3575 }
3576 // ACKS/ACKT carry NO acknowledgement semantics here. They are
3577 // `special(SPC_NOMOD)` in `dbCommon.dbd:150-159`, so no runtime put
3578 // reaches this arm — the gate refuses it. C's acknowledgement is
3579 // driven by the DBR *request type* (`DBR_PUT_ACKS`/`ACKT`), which
3580 // `dbPut` intercepts ABOVE the SPC_NOMOD gate and hands to
3581 // [`Self::put_acks`] / [`Self::put_ackt`]. What is left here is the
3582 // `dbLoadRecords` / `dbStaticLib` load path (`field(ACKT,"YES")`),
3583 // which stores the value verbatim — C `dbPutString` never crosses
3584 // `dbPut`.
3585 "ACKS" => {
3586 if let EpicsValue::Short(v) = value {
3587 self.common.acks = AlarmSeverity::from_u16(v as u16);
3588 }
3589 }
3590 "ACKT" => match value {
3591 EpicsValue::Char(v) => self.common.ackt = v != 0,
3592 EpicsValue::Short(v) => self.common.ackt = v != 0,
3593 _ => return Ok(CommonFieldPutResult::NoChange),
3594 },
3595 "UDF" => {
3596 // Store the raw put byte (C keeps the epicsUInt8 verbatim); a
3597 // record that re-derives UDF on process overwrites it, one that
3598 // sources nothing this cycle keeps it (put-defect cluster #3).
3599 // The calc family reads UDF straight from `common` too
3600 // (`clears_udf() == false`), so the stored byte stands.
3601 if let EpicsValue::Char(v) = value {
3602 self.common.udf = v;
3603 }
3604 }
3605 "UDFS" => {
3606 self.common.udfs = menu_ordinal_raw(&value);
3607 }
3608 // The `String` form never reaches these three menu arms:
3609 // `coerce_common_field` has already run it through the one
3610 // menu converter, which either produced an `Enum` index or failed
3611 // the put with `S_db_badChoice`.
3612 "SCAN" => {
3613 let new_scan = match &value {
3614 EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
3615 EpicsValue::Enum(v) => ScanType::from_u16(*v),
3616 _ => return Ok(CommonFieldPutResult::NoChange),
3617 };
3618 let result = self.set_scan(new_scan);
3619 if !matches!(result, CommonFieldPutResult::NoChange) {
3620 self.record.on_put(&name);
3621 self.record.special(&name, true)?;
3622 return Ok(result);
3623 }
3624 }
3625 "SSCN" => {
3626 let new_sscn = match &value {
3627 EpicsValue::Short(v) => SimModeScan::from_u16(*v as u16),
3628 EpicsValue::Enum(v) => SimModeScan::from_u16(*v),
3629 _ => return Ok(CommonFieldPutResult::NoChange),
3630 };
3631 self.common.sscn = new_sscn;
3632 }
3633 // `PINI` is `menu(menuPini)` — the six choices NO/YES/RUN/RUNNING/
3634 // PAUSE/PAUSED (`menuPini.dbd.pod:59-65`). Resolved exactly like
3635 // `SCAN`: a menu label or a bare index, never a truthiness test.
3636 // The pre-fix `bool` arm collapsed `RUN` (index 2) to `false`, so
3637 // `caput REC.PINI RUN` *disabled* PINI instead of selecting the
3638 // iocRun pass.
3639 "PINI" => {
3640 // Store the RAW ordinal (see [`CommonFields::pini`]): C's numeric
3641 // menu put keeps `(epicsEnum16)`, so an out-of-range `caput
3642 // REC.PINI 6` / `-1` round-trips and simply matches no lifecycle
3643 // pass in `doRecordPini`. A `String` label is already resolved to
3644 // `Enum` by `coerce_common_field` (menuPini via `putStringMenu`).
3645 self.common.pini = match &value {
3646 EpicsValue::Short(v) => *v,
3647 EpicsValue::Char(v) => *v as i16,
3648 EpicsValue::Enum(v) => *v as i16,
3649 _ => return Ok(CommonFieldPutResult::NoChange),
3650 };
3651 }
3652 "TPRO" => {
3653 if let EpicsValue::Char(v) = value {
3654 self.common.tpro = v;
3655 }
3656 }
3657 "BKPT" => {
3658 if let EpicsValue::Char(v) = value {
3659 self.common.bkpt = v;
3660 }
3661 }
3662 "FLNK" => {
3663 if let EpicsValue::String(s) = value {
3664 self.common.flnk = s.as_str_lossy().into_owned();
3665 self.parsed_flnk = parse_forward_link_v2(&self.common.flnk);
3666 }
3667 }
3668 "INP" => {
3669 // A record type whose C `.dbd` has no INP must refuse it, the
3670 // way C's dbd does ("field not found" at load, record inert) —
3671 // `histogram`'s input link is SVL, not INP
3672 // (histogramRecord.dbd.pod:212). Without this the port accepts a
3673 // `field(INP,...)` no C IOC can load.
3674 if !self.record.declares_inp_link() {
3675 return Err(CaError::FieldNotFound("INP".to_string()));
3676 }
3677 if let EpicsValue::String(s) = value {
3678 self.check_link_assignment("INP", &s.as_str_lossy(), bound)?;
3679 self.common.inp = s.as_str_lossy().into_owned();
3680 if !self.record_declares_field("INP") {
3681 self.parsed_inp = parse_link_v2(&self.common.inp);
3682 }
3683 }
3684 }
3685 "OUT" => {
3686 if let EpicsValue::String(s) = value {
3687 let s = s.as_str_lossy();
3688 self.check_link_assignment("OUT", &s, bound)?;
3689 // C `dbParseLink` (dbStaticLib.c:2382-2386) discards a
3690 // CP/CPP modifier on a DBF_OUTLINK and warns once, naming
3691 // the holder record, its field and the target. The discard
3692 // itself is owned by `parse_output_link_v2` below; only the
3693 // diagnostic lives here, where the record name exists and
3694 // the link text is being (re)loaded rather than re-parsed
3695 // per process cycle.
3696 if out_link_discards_cp(&s) {
3697 tracing::warn!(
3698 target: "epics_base_rs::record",
3699 record = %self.name,
3700 field = "OUT",
3701 link = %s,
3702 "Discarding CP/CPP modifier in CA output link"
3703 );
3704 }
3705 self.common.out = s.into_owned();
3706 // C `dbDbPutValue` (dbDbLink.c:386-389): an OUT
3707 // link processes its target only on an explicit
3708 // ` PP` token (or a `.PROC` destination). A bare
3709 // OUT link is NPP — `parse_output_link_v2`
3710 // downgrades the modifier-less `ProcessPassive`
3711 // default that `parse_link_v2` would otherwise
3712 // apply.
3713 if !self.record_declares_field("OUT") {
3714 self.parsed_out = parse_output_link_v2(&self.common.out);
3715 }
3716 // C `longoutRecord.c::special` (PR #6c573b4 part 2)
3717 // and similar OOCH-style hooks need `after=true`
3718 // to fire after the link has actually moved. The
3719 // earlier `validate_put` + `special(name, false)`
3720 // pair only covered the before-side.
3721 self.record.special(&name, true)?;
3722 }
3723 }
3724 // Two shapes reach DTYP and both name a device support:
3725 //
3726 // * the `.db` loader hands over the NAME verbatim, and it may be a
3727 // name registered at runtime by a downstream crate ("asynInt32")
3728 // that no vendored `.dbd` declares — C would reject that at load,
3729 // the port's registry accepts it (Tier 3);
3730 // * a `dbPut` arrives as the menu INDEX, because `DBF_DEVICE` is
3731 // served as `DBR_ENUM` and `coerce_put_value` already resolved an
3732 // incoming label through the device menu (C `putStringMenu`,
3733 // which fails `S_db_badChoice` on a name the menu does not have).
3734 //
3735 // The index is meaningful against the MERGED device menu (static
3736 // `device()` declarations + runtime-contributed device support) —
3737 // the exact list `coerce_put_value` bounded it by. Resolving it
3738 // against the static-only menu here would drop every contributed
3739 // name (asyn's "asynInt32", scaler-rs's "Asyn Scaler") back to
3740 // NoChange, leaving DTYP unset after a valid put.
3741 "DTYP" => match value {
3742 EpicsValue::String(s) => self.common.dtyp = s.as_str_lossy().into_owned(),
3743 EpicsValue::Enum(i) => {
3744 let merged = super::merged_device_menu(self.record.record_type());
3745 match merged.get(i as usize) {
3746 Some(name) => self.common.dtyp = (*name).to_string(),
3747 None => return Ok(CommonFieldPutResult::NoChange),
3748 }
3749 }
3750 _ => return Ok(CommonFieldPutResult::NoChange),
3751 },
3752 "TSE" => {
3753 if let EpicsValue::Short(v) = value {
3754 self.common.tse = v;
3755 }
3756 }
3757 "TSEL" => {
3758 if let EpicsValue::String(s) = value {
3759 self.common.tsel = s.as_str_lossy().into_owned();
3760 self.parsed_tsel = parse_link_v2(&self.common.tsel);
3761 }
3762 }
3763 "UTAG" => {
3764 // C UTAG is DBF_UINT64 — accept any integer-shaped value and
3765 // store the unsigned 64-bit tag. The db loader feeds every
3766 // common field as EpicsValue::String, so parse field(UTAG, "N")
3767 // rather than dropping it silently at IOC load; a CA write to
3768 // this u64 field crosses as DBR_DOUBLE (CA has no uint64 wire
3769 // type), so accept Double too.
3770 match value {
3771 EpicsValue::UInt64(v) => self.common.utag = v,
3772 EpicsValue::Int64(v) => self.common.utag = v as u64,
3773 EpicsValue::Long(v) => self.common.utag = v as u64,
3774 EpicsValue::Short(v) => self.common.utag = v as u64,
3775 EpicsValue::Enum(v) => self.common.utag = v as u64,
3776 EpicsValue::Char(v) => self.common.utag = v as u64,
3777 EpicsValue::Double(v) => self.common.utag = v as u64,
3778 EpicsValue::String(s) => {
3779 if let Ok(EpicsValue::UInt64(v)) =
3780 EpicsValue::parse(DbFieldType::UInt64, s.as_str_lossy().trim())
3781 {
3782 self.common.utag = v;
3783 }
3784 }
3785 _ => {}
3786 }
3787 }
3788 "ASG" => {
3789 if let EpicsValue::String(s) = value {
3790 self.common.asg = s.as_str_lossy().into_owned();
3791 }
3792 }
3793 "ASL" => {
3794 // C dbCommon.ASL is `epicsUInt32` in the .dbd but
3795 // only ever 0 or 1; accept Char / Short / Long for
3796 // the common put paths and clamp to {0, 1}.
3797 // db_loader feeds every common field as
3798 // `EpicsValue::String`; also accept that so a
3799 // `.db` `field(ASL, "1")` directive isn't silently
3800 // ignored at IOC load.
3801 let n: i64 = match value {
3802 EpicsValue::Char(v) => v as i64,
3803 EpicsValue::Short(v) => v as i64,
3804 EpicsValue::Long(v) => v as i64,
3805 EpicsValue::Int64(v) => v,
3806 EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
3807 _ => return Ok(CommonFieldPutResult::NoChange),
3808 };
3809 self.common.asl = if n != 0 { 1 } else { 0 };
3810 }
3811 "DESC" => {
3812 if let EpicsValue::String(s) = value {
3813 // DBF_STRING data field — store the bytes verbatim so a
3814 // non-UTF-8 DESC round-trips unchanged.
3815 if self.common.desc != s {
3816 self.common.desc = s;
3817 // DESC feeds `display.description` (a metadata-cache
3818 // source) but is not property-class — C never marks
3819 // it prop(YES) (epics-base#785) — so refresh the
3820 // cache here at the write owner without posting
3821 // DBE_PROPERTY: the pvxs behavior (fresh on the next
3822 // metadata build, no event).
3823 self.invalidate_metadata_cache();
3824 }
3825 }
3826 }
3827 "PHAS" => {
3828 if let EpicsValue::Short(v) = value {
3829 let old_phas = self.common.phas;
3830 self.common.phas = v;
3831 // Only a record that IS in a scan list can be re-sorted
3832 // within one; the same gate the index owner applies.
3833 if old_phas != v && self.common.scan.scan_list().is_some() {
3834 let scan = self.common.scan;
3835 self.record.on_put(&name);
3836 self.record.special(&name, true)?;
3837 return Ok(CommonFieldPutResult::PhasChanged {
3838 scan,
3839 old_phas,
3840 new_phas: v,
3841 });
3842 }
3843 }
3844 }
3845 "EVNT" => {
3846 // C `EVNT` is DBF_STRING (event name). Accept a
3847 // string directly; accept a numeric value too for
3848 // backward compatibility (numeric events / a calc
3849 // record driving EVNT) by formatting it as a string.
3850 match value {
3851 EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
3852 EpicsValue::Short(v) => self.common.evnt = v.to_string(),
3853 EpicsValue::Long(v) => self.common.evnt = v.to_string(),
3854 EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
3855 EpicsValue::Double(v) => {
3856 // Match C `eventNameToHandle`: a double with
3857 // an integer part is treated as that integer.
3858 self.common.evnt = (v as i64).to_string();
3859 }
3860 _ => {}
3861 }
3862 }
3863 "PRIO" => {
3864 if let EpicsValue::Short(v) = value {
3865 self.common.prio = v;
3866 }
3867 }
3868 "DISV" => {
3869 if let EpicsValue::Short(v) = value {
3870 self.common.disv = v;
3871 }
3872 }
3873 "DISA" => {
3874 if let EpicsValue::Short(v) = value {
3875 self.common.disa = v;
3876 }
3877 }
3878 "SDIS" => {
3879 if let EpicsValue::String(s) = value {
3880 self.common.sdis = s.as_str_lossy().into_owned();
3881 self.parsed_sdis = parse_link_v2(&self.common.sdis);
3882 }
3883 }
3884 "DISS" => {
3885 self.common.diss = menu_ordinal_raw(&value);
3886 }
3887 "HYST" => {
3888 if let Some(v) = value.to_f64() {
3889 self.common.hyst = v;
3890 }
3891 }
3892 "LCNT" => {
3893 if let EpicsValue::Short(v) = value {
3894 self.common.lcnt = v;
3895 }
3896 }
3897 "DISP" => {
3898 if let EpicsValue::Char(v) = value {
3899 self.common.disp = v;
3900 }
3901 }
3902 "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
3903 "RPRO" => {
3904 if let EpicsValue::Char(v) = value {
3905 self.common.rpro = v;
3906 }
3907 }
3908 "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
3909 // C `dbPut` stores the raw byte in `prec->proc` (retained across
3910 // processing — C never resets it); `coerce_common_field` has
3911 // already projected the put onto `DBF_UCHAR` (→ `Char`). The
3912 // `pp(TRUE)` reprocess is driven separately by the put-path
3913 // force-process intercept, so this arm ONLY records the byte.
3914 "PROC" => {
3915 if let EpicsValue::Char(v) = value {
3916 self.common.proc_field = v;
3917 }
3918 }
3919 // Analog alarm limits. The DB-load String was already coerced to
3920 // the field's DECLARED `.dbd` type by `coerce_common_field` — the
3921 // one owner of "what type does this common field hold" — so every
3922 // writer (`.db` load, `caput`, a link) lands here with a numeric
3923 // value in the record's own alarm domain, `epicsInt64` included.
3924 "HIHI" => {
3925 if let (Some(v), Some(a)) = (
3926 AlarmLimit::from_stored(&value),
3927 self.common.analog_alarm.as_mut(),
3928 ) {
3929 a.hihi = v;
3930 }
3931 }
3932 "HIGH" => {
3933 if let (Some(v), Some(a)) = (
3934 AlarmLimit::from_stored(&value),
3935 self.common.analog_alarm.as_mut(),
3936 ) {
3937 a.high = v;
3938 }
3939 }
3940 "LOW" => {
3941 if let (Some(v), Some(a)) = (
3942 AlarmLimit::from_stored(&value),
3943 self.common.analog_alarm.as_mut(),
3944 ) {
3945 a.low = v;
3946 }
3947 }
3948 "LOLO" => {
3949 if let (Some(v), Some(a)) = (
3950 AlarmLimit::from_stored(&value),
3951 self.common.analog_alarm.as_mut(),
3952 ) {
3953 a.lolo = v;
3954 }
3955 }
3956 "HHSV" => {
3957 if let Some(a) = &mut self.common.analog_alarm {
3958 a.hhsv = menu_ordinal_raw(&value);
3959 }
3960 }
3961 "HSV" => {
3962 if let Some(a) = &mut self.common.analog_alarm {
3963 a.hsv = menu_ordinal_raw(&value);
3964 }
3965 }
3966 "LSV" => {
3967 if let Some(a) = &mut self.common.analog_alarm {
3968 a.lsv = menu_ordinal_raw(&value);
3969 }
3970 }
3971 "LLSV" => {
3972 if let Some(a) = &mut self.common.analog_alarm {
3973 a.llsv = menu_ordinal_raw(&value);
3974 }
3975 }
3976 // swait-specific: OUTN is the output link name for swait records.
3977 // Mirrors to common.out so the processing framework dispatches it.
3978 "OUTN" => {
3979 if self.record.record_type() != "swait" {
3980 // No OUTN field on any other record type — the same
3981 // `S_dbLib_fieldNotFound` the catch-all below reports.
3982 return Err(self.unknown_field_error(name));
3983 }
3984 if let EpicsValue::String(s) = value {
3985 self.common.out = s.as_str_lossy().into_owned();
3986 // Bare OUT link is NPP — see the "OUT" arm.
3987 self.parsed_out = parse_output_link_v2(&self.common.out);
3988 }
3989 }
3990 // C `dbNameToAddr` (dbAccess.c:660-676) resolves the field part
3991 // with `dbFindFieldPart`, then falls back to `dbGetAttributePart`.
3992 // A name that is neither a record field, nor a dbCommon field, nor
3993 // an attribute resolves to nothing (`S_dbLib_fieldNotFound`), so
3994 // `dbPutField` is never reached and the caller reports the error —
3995 // `dbpf` prints "PV '%s' not found" and returns -1 (dbTest.c:787-795).
3996 // Returning success here made a put to a misspelled field a silent
3997 // no-op.
3998 //
3999 // But a field the record's `.dbd` DECLARES and no arm above stored
4000 // is NOT unknown: C `dbPut` writes it into record memory even when
4001 // no record code reads it back (`caput dfanout.HOPR 10`). Land it in
4002 // the per-instance declared-override store — the write analog of
4003 // `declared_default` — so the put is accepted and a later read
4004 // reflects it. `put_declared_override` still returns
4005 // `unknown_field_error` for a name with no `dbFldDes`, so a
4006 // misspelled field is refused exactly as before.
4007 _ => return self.put_declared_override(&name, value, bound),
4008 }
4009 self.record.on_put(&name);
4010 // C `dbPut` (dbAccess.c:1399-1405) returns the after-put
4011 // `dbPutSpecial(paddr, 1)` status to the caller — the stored value
4012 // stays, but the monitor post and the process are skipped and the
4013 // client sees the failure. Never drop it.
4014 self.record.special(&name, true)?;
4015 Ok(CommonFieldPutResult::NoChange)
4016 }
4017
4018 /// The error C reports for a write to a field name that
4019 /// [`Self::put_common_field`] does not own.
4020 ///
4021 /// Two C outcomes, split by whether the name resolves at all:
4022 ///
4023 /// - A record *attribute* (`NAME`, `RTYP`) resolves — `dbGetAttributePart`
4024 /// succeeds — but the write is refused: `NAME` is `special(SPC_NOMOD)`
4025 /// (dbCommon.dbd:13-17) so `dbPutSpecial` pass 0 returns `S_db_noMod`
4026 /// (dbAccess.c:123-124), and an attribute address carries
4027 /// `special == SPC_ATTRIBUTE`, which `dbPutField` rejects with the same
4028 /// `S_db_noMod` (dbAccess.c:1252-1253).
4029 /// - Anything else does not resolve: `S_dbLib_fieldNotFound`.
4030 fn unknown_field_error(&self, name: String) -> CaError {
4031 if self.get_virtual_field(&name).is_some() {
4032 CaError::ReadOnlyField(name)
4033 } else {
4034 CaError::FieldNotFound(name)
4035 }
4036 }
4037
4038 /// Store a put to a field the record's `.dbd` DECLARES but the record
4039 /// models no storage for — the WRITE owner of [`Self::declared_overrides`]
4040 /// and the write analog of [`Self::declared_default`].
4041 ///
4042 /// Reached only from [`Self::put_common_field_bounded`]'s catch-all, i.e.
4043 /// after both `Record::put_field` (returned `FieldNotFound`) and every
4044 /// `dbCommon` arm above have declined the field. Three gates, mirroring
4045 /// C `dbNameToAddr`/`dbPut`:
4046 ///
4047 /// * NO `dbFldDes` (`field_desc` is `None`) — the name is not a field of
4048 /// this record type at all. C resolves nothing and `dbPutField` reports
4049 /// `S_dbLib_fieldNotFound`; return [`Self::unknown_field_error`] (which
4050 /// also renders `NAME`/`RTYP` as the read-only attributes they are).
4051 /// * `special(SPC_NOMOD)` — a declared field that is immutable
4052 /// ([`Self::is_no_mod`]: the `.dbd` `read_only`/attribute bit or the
4053 /// record's runtime `field_no_mod`). C refuses the put with `S_db_noMod`;
4054 /// the runtime dispatch already gates this via `field_io::check_no_mod`,
4055 /// but the db-load path does not, so enforce it here too — never store an
4056 /// SPC_NOMOD field in the override map.
4057 /// * [`FieldDesc::runtime_typed`] — a field whose served type C's
4058 /// `cvt_dbaddr` re-derives from record state (`waveform.VAL` from `FTVL`,
4059 /// `aSub.A` from `FTA`). Such a field is record-owned by definition, so
4060 /// its `put_field` should have taken the put; if it somehow reached here
4061 /// the override store must not shadow it (`declared_default` skips it for
4062 /// the same reason). Treat as not-found rather than store a value under
4063 /// the wrong type.
4064 /// * PARTIALLY modeled — `Record::get_field` serves the field but no
4065 /// `put_field` arm accepts it (`calcout.PVAL` → `self.pval`). The record
4066 /// owns the read path, so the write belongs in its own `put_field`, not a
4067 /// shadow cell; refuse here rather than store a value `resolve_field`
4068 /// would never reach. See the inline note on the `get_field` guard.
4069 ///
4070 /// Otherwise coerce the incoming value to the field's C-declared DBF type
4071 /// through the one write-side value-coercion owner
4072 /// ([`coerce_put_value`](crate::server::record::coerce_put_value)) — so a
4073 /// `.db`/`caput` string parses with C's range rules (`caput REC.PREC 99999`
4074 /// into a `DBF_SHORT` is refused, not wrapped) and a menu label resolves
4075 /// against the field's own choices — and store it. Returns
4076 /// [`CommonFieldPutResult::NoChange`]: there is no scan/phas/alarm side
4077 /// effect for a metadata field with no record behaviour, and the caller's
4078 /// value-field monitor post reads the stored value back through
4079 /// [`Self::resolve_field`].
4080 fn put_declared_override(
4081 &mut self,
4082 name: &str,
4083 value: EpicsValue,
4084 bound: MenuBound,
4085 ) -> CaResult<CommonFieldPutResult> {
4086 let Some(desc) = self.field_desc(name) else {
4087 return Err(self.unknown_field_error(name.to_string()));
4088 };
4089 if desc.runtime_typed {
4090 return Err(self.unknown_field_error(name.to_string()));
4091 }
4092 if matches!(bound, MenuBound::DbPut) && self.is_no_mod(name) {
4093 // C `dbPutSpecial` pass 0 refuses SPC_NOMOD with `S_db_noMod`
4094 // (dbAccess.c:123-127) — and `dbPutSpecial` is reached only from
4095 // `dbPutField`/`dbPut`, the RUNTIME path. `dbLoadRecords` writes
4096 // through dbStatic's `dbPutString` (dbStaticLib.c:2570), which
4097 // consults `special` for `SPC_CALC` alone; SPC_NOMOD appears in
4098 // that layer only as a filter on `dbLexRoutines.c:1285`'s
4099 // misspelled-field guesser, never as a refusal of a field the
4100 // `.db` names outright. Refusing both paths dropped every
4101 // `field(<SPC_NOMOD>,…)` directive with a stderr line —
4102 // `mca`'s SIOL/SIML, `sub`'s LA..LU, `sel`'s LA..NLST,
4103 // `scalcout`'s PA..MLST, `asyn`'s AINP/NORD/ERRS and `swait`'s
4104 // VERS — so a simulated `mca` could not be given a SIOL at all.
4105 return Err(CaError::ReadOnlyField(name.to_string()));
4106 }
4107 // The override is the WRITABLE TWIN of `declared_default`, and
4108 // `declared_default` is `resolve_field`'s fallback ONLY when the record
4109 // itself serves nothing (`Record::get_field` is `None`). If the record
4110 // DOES serve this field (`get_field` is `Some`), it is not unmodeled —
4111 // it is PARTIALLY modeled: a getter into record memory (e.g.
4112 // `calcout.PVAL` → `self.pval`, which `process()` also writes) but no
4113 // matching `put_field` arm. Storing here would place the value in a
4114 // second cell that `resolve_field` never reaches (`get_field` shadows
4115 // the override) and that no `process()` keeps in step — a silent write
4116 // loss. Such a field's put belongs in the record's OWN `put_field`
4117 // (a per-record setter, a distinct change); refuse it here rather than
4118 // half-accept it, so `resolve_field` stays single-valued. A field the
4119 // record does not serve at all falls through to be stored.
4120 if self.record.get_field(name).is_some() {
4121 return Err(self.unknown_field_error(name.to_string()));
4122 }
4123 let target = desc.dbf_type;
4124 match crate::server::record::coerce_put_value(self.record.as_ref(), name, target, value)? {
4125 Converted::Stored(coerced) => {
4126 self.declared_overrides
4127 .insert(name.to_ascii_uppercase(), coerced);
4128 }
4129 // Nothing stored: the override keeps whatever it held.
4130 Converted::Unchanged => {}
4131 }
4132 Ok(CommonFieldPutResult::NoChange)
4133 }
4134
4135 /// Get virtual fields (NAME, RTYP).
4136 pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
4137 match name {
4138 "NAME" => Some(EpicsValue::String(self.name.clone().into())),
4139 "RTYP" => Some(EpicsValue::String(
4140 self.record.record_type().to_string().into(),
4141 )),
4142 _ => None,
4143 }
4144 }
4145
4146 /// Evaluate alarms based on record type and current value.
4147 /// Uses rec_gbl_set_sevr to accumulate into nsta/nsev.
4148 ///
4149 /// CALC_ALARM is NOT raised here. C raises it inside the record's own
4150 /// `process()` (`calcRecord.c:121-123`, `calcoutRecord.c:238-241`,
4151 /// `sCalcoutRecord.c:357-363`, `aCalcoutRecord.c:304-305`,
4152 /// `swaitRecord.c:409-410`), and in the port [`Record::check_alarms`] — which
4153 /// runs immediately before this — is that owner. It used to be raised here
4154 /// instead, keyed on a hardcoded `rtype` list plus a `CALC_ALARM` pseudo-field
4155 /// no DBD declares; swait is what that construction cost: it carried the flag
4156 /// but was not on the list, so a failed `calcPerform` alarmed nowhere.
4157 pub fn evaluate_alarms(&mut self) {
4158 use crate::server::recgbl;
4159
4160 // Check UDF first — but only for record types whose C support carries
4161 // the `if (prec->udf) recGblSetSevr(..., UDF_ALARM, ...)` guard. C has
4162 // no central UDF alarm; see `Record::raises_udf_alarm`.
4163 if self.record.raises_udf_alarm() {
4164 recgbl::rec_gbl_check_udf(
4165 &mut self.common,
4166 self.record.udf_alarm_on_exact_one(),
4167 self.record.udf_alarm_severity(),
4168 self.record.udf_alarm_message(),
4169 );
4170 }
4171
4172 // The analog-alarm SLOT is the enumeration — a record has the ladder iff
4173 // `new_boxed` gave it a config, which is the one place the C `.dbd`
4174 // survey lives. A second `match rtype` here was the same list written
4175 // twice, and the two could disagree: scalcout was in neither, so its ten
4176 // C alarm fields could not even be put.
4177 //
4178 // bi / bo / busy / mbbi / mbbo STATE+COS (and mbbo SOFT) alarm evaluation
4179 // lives in each record's `Record::check_alarms` hook (C `checkAlarms`);
4180 // those records carry no analog config, so they never reach here and
4181 // cannot double-raise.
4182 if let Some(ref alarm_cfg) = self.common.analog_alarm.clone() {
4183 // VAL goes down in the variant the record stores it in, not
4184 // flattened to `f64`: it is what picks the ladder's comparison
4185 // domain, and `Int64(v) as f64` had already rounded the value
4186 // before the first comparison ran.
4187 let val = match self.record.val() {
4188 Some(v @ (EpicsValue::Double(_) | EpicsValue::Long(_) | EpicsValue::Int64(_))) => v,
4189 _ => return,
4190 };
4191 self.evaluate_analog_alarm(val, alarm_cfg);
4192 }
4193 }
4194
4195 fn evaluate_analog_alarm(&mut self, val: EpicsValue, cfg: &AnalogAlarmConfig) {
4196 use crate::server::recgbl::{self, alarm_status};
4197
4198 // C `checkAlarms` returns immediately on a UDF cycle: it raises
4199 // `UDF_ALARM`/`UDFS` (already done by `rec_gbl_check_udf` in
4200 // `evaluate_alarms`), zeroes `AFVL` on the AFTC-capable records, and
4201 // returns BEFORE the range check — so `LALM` is left untouched and
4202 // `AFVL` is not filtered this cycle. The identical guard appears in
4203 // every record that shares this arm (`aiRecord.c:319-323`,
4204 // `aoRecord.c:383-386`, `longinRecord.c:274-278`,
4205 // `longoutRecord.c:317-320`, `int64inRecord.c:267-271`,
4206 // `int64outRecord.c:298-301`, `calcRecord.c:300-304`,
4207 // `calcoutRecord.c:563-566`). AFTC-capable records (ai/longin/
4208 // int64in/calc) carry `AFVL` and zero it (`prec->afvl = 0`); the
4209 // out records (ao/longout/int64out/calcout) have no `AFVL` and just
4210 // return. Running the range check here would drift `LALM` to `val`
4211 // (NaN on an undefined cycle) and filter `AFVL` — both observable.
4212 if self.common.udf != 0 {
4213 if matches!(
4214 self.record.record_type(),
4215 "calc" | "ai" | "longin" | "int64in"
4216 ) && self.record.get_field("AFVL").and_then(|v| v.to_f64()) != Some(0.0)
4217 {
4218 let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
4219 }
4220 return;
4221 }
4222
4223 // One rule for every ladder input: a record that DECLARES the field
4224 // owns it, because `Record::put_field` absorbs the client's put before
4225 // `put_common_field` ever runs, and only an undeclared field falls
4226 // through to `CommonFields`. MDEL/ADEL/MLST/ALST in
4227 // `check_monitor_deadbands` already read this way; HYST did not, so
4228 // `int64in`/`int64out`'s `pub hyst` swallowed every put while the
4229 // hysteresis compared against a permanent 0.0 — with `caget .HYST`
4230 // reading the value back, which is what made it silent.
4231 //
4232 // `common.hyst` stays an `f64` and stays exact: after the limits moved
4233 // to the declared type its only remaining readers are the
4234 // `DBF_DOUBLE` records and longin/longout, and every `epicsInt32` is
4235 // an `f64` exactly.
4236 let hyst_field = self.record.get_field("HYST");
4237 let lalm_field = self.record.get_field("LALM");
4238
4239 // C-style per-level hysteresis: alarm fires if val passes the level,
4240 // OR if we were already at that alarm level (lalm == alev) and val
4241 // hasn't retreated past the hysteresis margin.
4242 //
4243 // `alarm_range` is the C-style integer level: 1=Lolo, 2=Low,
4244 // 3=Normal, 4=High, 5=Hihi. Required for the calc-record AFTC
4245 // filter (`calcRecord.c::checkAlarms:339-381`) which filters
4246 // on the range level (not on severity) and re-maps back.
4247 // C's `checkAlarms` enables each level with a NONZERO test on the raw
4248 // severity ordinal (`if (prec->hhsv && …)`) and passes that raw ordinal
4249 // to `recGblSetSevr`; `recGblResetAlarms` then clamps the resulting
4250 // *severity* to `INVALID_ALARM` while the *status* keeps the level. So an
4251 // out-of-range selector (`HHSV = 4`) still fires HIHI and lands
4252 // SEVR=INVALID/STAT=HIHI — reproduced by testing `!= 0` and mapping the
4253 // ordinal through [`AlarmSeverity::from_u16`] (which clamps `>= 3` to
4254 // `Invalid`).
4255 let sevs = [cfg.hhsv, cfg.llsv, cfg.hsv, cfg.lsv];
4256 let mut alarm_range = match &val {
4257 // The `DBF_LONG`/`DBF_INT64` records. `convert_to(Int64)` is the
4258 // workspace's one value-coercion owner, so a limit, a hysteresis
4259 // and a LALM all land here as the exact `epicsInt64` C compares.
4260 EpicsValue::Long(_) | EpicsValue::Int64(_) => {
4261 let int = |v: Option<EpicsValue>, dflt: i128| -> i128 {
4262 match v.map(|v| v.convert_to(DbFieldType::Int64)) {
4263 Some(EpicsValue::Int64(i)) => i as i128,
4264 _ => dflt,
4265 }
4266 };
4267 let v = int(Some(val.clone()), 0);
4268 super::alarm::analog_alarm_range(
4269 v,
4270 int(hyst_field, self.common.hyst as i128),
4271 int(lalm_field, v),
4272 [
4273 cfg.hihi.as_i128(),
4274 cfg.lolo.as_i128(),
4275 cfg.high.as_i128(),
4276 cfg.low.as_i128(),
4277 ],
4278 sevs,
4279 )
4280 }
4281 _ => {
4282 let v = val.to_f64().unwrap_or(0.0);
4283 super::alarm::analog_alarm_range(
4284 v,
4285 hyst_field
4286 .and_then(|h| h.to_f64())
4287 .unwrap_or(self.common.hyst),
4288 lalm_field.and_then(|l| l.to_f64()).unwrap_or(v),
4289 [
4290 cfg.hihi.as_f64(),
4291 cfg.lolo.as_f64(),
4292 cfg.high.as_f64(),
4293 cfg.low.as_f64(),
4294 ],
4295 sevs,
4296 )
4297 }
4298 };
4299
4300 // C `range_stat[]` (`int64inRecord.c:250-253`) plus the severity and
4301 // `alev` each range selects. ONE table, because C reaches the same
4302 // mapping twice — once out of the ladder and once out of the AFTC
4303 // filter's `switch (alarmRange)` (`:326-346`).
4304 let resolve = |range: u16| -> (AlarmSeverity, u16, Option<AlarmLimit>) {
4305 match range {
4306 5 => (
4307 AlarmSeverity::from_u16(cfg.hhsv as u16),
4308 alarm_status::HIHI_ALARM,
4309 Some(cfg.hihi),
4310 ),
4311 4 => (
4312 AlarmSeverity::from_u16(cfg.hsv as u16),
4313 alarm_status::HIGH_ALARM,
4314 Some(cfg.high),
4315 ),
4316 2 => (
4317 AlarmSeverity::from_u16(cfg.lsv as u16),
4318 alarm_status::LOW_ALARM,
4319 Some(cfg.low),
4320 ),
4321 1 => (
4322 AlarmSeverity::from_u16(cfg.llsv as u16),
4323 alarm_status::LOLO_ALARM,
4324 Some(cfg.lolo),
4325 ),
4326 _ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, None),
4327 }
4328 };
4329
4330 // C parity: the alarm-range AFTC low-pass filter
4331 // (`{ai,longin,int64in,calc}Record.c::checkAlarms`) smooths the
4332 // integer `alarmRange` and re-maps. Only records that carry the
4333 // AFTC/AFVL fields run it — `ao`/`longout`/`int64out`/`calcout`
4334 // have no AFTC field (confirmed via the respective `.dbd.pod`),
4335 // so they are excluded.
4336 let aftc_capable = matches!(
4337 self.record.record_type(),
4338 "calc" | "ai" | "longin" | "int64in"
4339 );
4340 if aftc_capable {
4341 let aftc = self
4342 .record
4343 .get_field("AFTC")
4344 .and_then(|v| v.to_f64())
4345 .unwrap_or(0.0);
4346 let afvl = self
4347 .record
4348 .get_field("AFVL")
4349 .and_then(|v| v.to_f64())
4350 .unwrap_or(0.0);
4351 if aftc > 0.0 {
4352 let now = crate::runtime::general_time::get_current();
4353 let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
4354 alarm_range,
4355 aftc,
4356 afvl,
4357 self.common.time,
4358 now,
4359 );
4360 let _ = self.record.put_field("AFVL", EpicsValue::Double(new_afvl));
4361 // C re-maps through the SAME `switch (alarmRange)` the ladder
4362 // fell out of, so the filter changes only the range and
4363 // `resolve` below answers for both.
4364 alarm_range = filtered_range;
4365 } else {
4366 // aftc <= 0 disables the filter. C `checkAlarms`
4367 // (e.g. aiRecord.c:356,401) initialises the local
4368 // `afvl = 0` and unconditionally stores `prec->afvl =
4369 // afvl` at the end, so a disabled filter drives AFVL to
4370 // 0. Mirror that here so a stale accumulator from a prior
4371 // `aftc > 0` run cannot mis-seed the filter if AFTC is
4372 // re-enabled later.
4373 if afvl != 0.0 {
4374 let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
4375 }
4376 }
4377 }
4378 let (new_sevr, new_stat, alev) = resolve(alarm_range);
4379
4380 if new_sevr != AlarmSeverity::NoAlarm {
4381 // C `aiRecord.c:405-406` — the latch is armed to the THRESHOLD, and
4382 // only when `recGblSetSevr` returns TRUE. A level that fires while a
4383 // higher-or-equal severity is already pending (an MS input link, a
4384 // SIMM alarm, a device INVALID) raises nothing, so C leaves LALM
4385 // where it was; arming it there would let the next cycle's
4386 // `lalm == alev && val >= alev - hyst` clause hold an alarm C has
4387 // already cleared.
4388 if recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr) {
4389 self.put_coerced("LALM", alev.map(|l| l.to_epics_value()).unwrap_or(val));
4390 }
4391 } else {
4392 // No alarm condition: reset LALM to current value. C `aiRecord.c:409`
4393 // does this unconditionally — only the alarm arm is gated.
4394 self.put_coerced("LALM", val);
4395 }
4396 }
4397
4398 /// Invoke the registered subroutine (`sub`/`aSub` `SNAM`) if one is
4399 /// bound, before the record's `process()` body runs.
4400 ///
4401 /// C `subRecord.c::do_sub` / `aSubRecord.c::do_sub` call the named
4402 /// subroutine on EVERY `process()`. The function registry lives on the
4403 /// framework (`RecordInstance::subroutine`), not on the record, so the
4404 /// record's own `process()` is a no-op for these two types and the
4405 /// framework must drive the call. This is the SINGLE owner of that call
4406 /// for every dispatch path: the main engine
4407 /// (`process_record_with_links_inner`, the SCAN / event / CA-put-to-PP /
4408 /// FLNK path) and the by-name `process_local` (`db.process_record`,
4409 /// QSRV group / foreign-call path) both route through here, so a
4410 /// `sub`/`aSub` runs identically regardless of how it is processed.
4411 /// Previously only `process_local` invoked the subroutine, so on the
4412 /// main engine path `VAL`/`VALA..VALU`/`OUTA..OUTU` never updated.
4413 /// The cycle's status is delivered to the record on EVERY exit path — see
4414 /// [`Record::set_subroutine_status`], which aSub's OUT-link gate reads. The
4415 /// delivery is factored out of the body below so a future early return
4416 /// cannot skip it: the body returns the status, this wrapper publishes it.
4417 pub(crate) fn run_registered_subroutine(&mut self) -> CaResult<()> {
4418 let outcome = self.run_subroutine_body();
4419 // A subroutine that errored out has no C counterpart (a C subroutine
4420 // returns a `long`); it is a failed cycle, so it takes the non-zero
4421 // arm — no outputs.
4422 let status = *outcome.as_ref().unwrap_or(&SUBROUTINE_STATUS_ERROR);
4423 self.record.set_subroutine_status(status);
4424 outcome.map(|_| ())
4425 }
4426
4427 /// Returns C `process`'s `status` for this cycle: 0 only when `do_sub` ran
4428 /// and returned 0.
4429 ///
4430 /// This is C `process`'s
4431 /// `if (!status) { status = do_sub(prec); prec->val = status; }`
4432 /// (aSubRecord.c:216-224, subRecord.c:142-147). The VAL publish is HERE
4433 /// rather than inside [`Self::do_sub`] precisely because C puts it here:
4434 /// every `do_sub` exit — empty SNAM, unregistered SNAM, the subroutine's
4435 /// own return — publishes its status as aSub's VAL from this one site, and
4436 /// only the pre-`do_sub` skip (a failed `fetch_values`) leaves VAL alone.
4437 fn run_subroutine_body(&mut self) -> CaResult<i64> {
4438 // aSub `LFLG=READ`: a `SUBL` re-resolution that found a bad/unregistered
4439 // name (C `fetch_values` -> `S_db_BadSub`) or failed to read the link
4440 // signals "skip do_sub this cycle" — C `process` runs `do_sub` only on
4441 // `!status`. The framework's failed input-link fetch arms the same flag.
4442 // One-shot: taken (cleared) whether or not a subroutine is set, so it
4443 // never leaks into the next cycle. The single consumer of the flag,
4444 // shared by every process path.
4445 if std::mem::take(&mut self.suppress_subroutine_run) {
4446 return Ok(SUBROUTINE_STATUS_SKIPPED);
4447 }
4448
4449 // Every record type reaches this call on the process path, but only
4450 // `sub` and `aSub` have a `do_sub` in their rset at all. For all the
4451 // others "no subroutine is bound" is their permanent normal state, not
4452 // an unresolved SNAM, so they must not take `do_sub`'s bad-sub exit.
4453 let Some(kind) = SubroutineKind::of(self.record.record_type()) else {
4454 return Ok(SUBROUTINE_STATUS_SKIPPED);
4455 };
4456
4457 let status = self.do_sub(kind)?;
4458
4459 // aSub publishes the status as VAL (C `aSubRecord.c:224`
4460 // `prec->val = status`). The subroutine's computed outputs live in
4461 // VALA..VALU, so VAL is the return code and overwrites whatever the
4462 // closure may have written to VAL. `sub` does NOT do this — its VAL
4463 // is the value the subroutine computed. aSub VAL is DBF_LONG
4464 // (epicsInt32); the status is a C `long` truncated into it.
4465 if kind == SubroutineKind::ASub {
4466 let _ = self
4467 .record
4468 .put_field("VAL", EpicsValue::Long(status as i32));
4469 }
4470 Ok(status)
4471 }
4472
4473 /// C `do_sub` — `aSubRecord.c:454-473` and `subRecord.c:420-437`, which
4474 /// differ in exactly two places and agree everywhere else:
4475 ///
4476 /// * aSub short-circuits an EMPTY SNAM to `return 0` BEFORE the null-pointer
4477 /// check (`if (prec->snam[0] == 0) return 0;`), so a bare
4478 /// `record(aSub,"X"){}` is a no-op that completes with status 0, not a
4479 /// bad-sub. `sub` has no such branch — it cannot reach here with an empty
4480 /// SNAM because `init_record` parks PACT
4481 /// (`Record::init_record_parks_pact`, subRecord.c:119-123).
4482 /// * an unresolved subroutine raises `BAD_SUB_ALARM` at `INVALID_ALARM` in
4483 /// both, but aSub returns `S_db_BadSub` (which `run_subroutine_body`
4484 /// publishes as VAL and aSub's OUT gate reads as "push nothing") while
4485 /// `sub` returns 0.
4486 ///
4487 /// The raise is per-cycle, not a one-shot init diagnostic: C `iocInit`
4488 /// discards `init_record`'s status (iocInit.c:569-570), so the record loads
4489 /// and scans and every process cycle re-raises BAD_SUB/INVALID.
4490 fn do_sub(&mut self, kind: SubroutineKind) -> CaResult<i64> {
4491 use crate::server::recgbl::{self, alarm_status};
4492
4493 // Clone the Arc so the borrow on `self.subroutine` is released
4494 // before we mutate `self.record` / `self.common` below.
4495 let Some(sub_fn) = self.subroutine.clone() else {
4496 let snam_empty = matches!(
4497 self.record.get_field("SNAM"),
4498 Some(EpicsValue::String(s)) if s.is_empty()
4499 );
4500 if kind == SubroutineKind::ASub && snam_empty {
4501 return Ok(0);
4502 }
4503 recgbl::rec_gbl_set_sevr(
4504 &mut self.common,
4505 alarm_status::BAD_SUB_ALARM,
4506 AlarmSeverity::Invalid,
4507 );
4508 return Ok(match kind {
4509 SubroutineKind::ASub => S_DB_BAD_SUB,
4510 SubroutineKind::Sub => 0,
4511 });
4512 };
4513 // C `do_sub` returns the subroutine's `long` status.
4514 let status = sub_fn(&mut *self.record)?;
4515
4516 // A negative status raises SOFT_ALARM at the record's BRSV severity
4517 // (C `do_sub`: `if (status < 0) recGblSetSevr(SOFT_ALARM,
4518 // prec->brsv)`). It accumulates into nsta/nsev for this cycle's
4519 // recGblResetAlarms commit and runs before checkAlarms, so a higher
4520 // analog severity (e.g. the shared analog-alarm owner) still wins via
4521 // the raise-only rule. BRSV defaults to NO_ALARM, under which
4522 // recGblSetSevr is a no-op.
4523 if status < 0 {
4524 let brsv = self
4525 .record
4526 .get_field("BRSV")
4527 .and_then(|v| v.to_f64())
4528 .map(|f| AlarmSeverity::from_u16(f as u16))
4529 .unwrap_or(AlarmSeverity::NoAlarm);
4530 recgbl::rec_gbl_set_sevr(&mut self.common, alarm_status::SOFT_ALARM, brsv);
4531 } else {
4532 // C `do_sub`'s `else` arm — the ONE place either flavour writes UDF,
4533 // reached only where the subroutine actually ran and returned `>= 0`.
4534 // aSub takes `prec->udf = FALSE` (`aSubRecord.c:470`); `sub` takes
4535 // `prec->udf = isnan(prec->val)` (`subRecord.c:434`).
4536 //
4537 // `sub`'s derive is the same expression the framework's per-cycle
4538 // blanket computes, made HERE instead of there so that the cycles
4539 // which run no subroutine — the unresolved-SNAM `BAD_SUB_ALARM`
4540 // return above, a failed `fetch_values` (`suppress_subroutine_run`),
4541 // a negative status — leave UDF at its previous value, exactly as C
4542 // does. Both flavours therefore opt out of the blanket
4543 // (`Record::clears_udf` == false).
4544 self.common.udf = match kind {
4545 SubroutineKind::ASub => 0,
4546 SubroutineKind::Sub => self.record.value_is_undefined() as u8,
4547 };
4548 }
4549 Ok(status)
4550 }
4551
4552 /// The single owner of a process cycle's SUBSCRIBER posts — C `monitor()`'s
4553 /// "post every subscribed field this cycle touched" loop.
4554 ///
4555 /// Every processing path (`process_record_with_links_inner`, the deferred
4556 /// async-completion path, the simulation path, and [`Self::process_local`])
4557 /// calls this; none of them may reimplement the rules, because a rule that
4558 /// holds on one path and not another is a monitor that fires on a scan cycle
4559 /// but not on an async completion. The per-field mask resolvers
4560 /// ([`AuxPostMask`], [`crate::server::record::value_gate`]) were already
4561 /// single-owned for the same reason — this is the loop around them.
4562 ///
4563 /// It also UPDATES `last_posted` for everything it emits, and it TAKES the
4564 /// record's per-cycle post mask ([`Record::take_cycle_posted_fields`]), so
4565 /// it must run exactly once per cycle.
4566 ///
4567 /// The rules, in order:
4568 ///
4569 /// * The deadband field (default VAL), the
4570 /// [`recgbl::RECGBL_POSTED_ALARM_FIELDS`](crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS)
4571 /// (SEVR/STAT/AMSG/ACKS) and UDF are emitted by the caller with their own
4572 /// C masks and are skipped here.
4573 /// * [`Record::event_posted_fields`] post from their own event path
4574 /// (waveform HASH) — never from change detection.
4575 /// * [`Record::process_posted_fields`], when declared, is the closed set of
4576 /// fields a process cycle may post at all.
4577 /// * A secondary value field ([`Record::fields_posted_with_value_mask`])
4578 /// carries VAL's monitor mask, gated per its [`ValuePostGate`](super::ValuePostGate).
4579 /// * A CHANGED field carries [`AuxPostMask::mask_for`] — unless it is a
4580 /// [`Record::fields_posted_only_when_marked`] field, which C never
4581 /// change-detects (aCalcout AA..LL) and which therefore posts from its
4582 /// mark alone.
4583 /// * An UNCHANGED field posts only if the record marked it this cycle:
4584 /// statically ([`Record::force_posted_fields`]), per-cycle
4585 /// ([`Record::take_cycle_posted_fields`]), on the alarm transition
4586 /// ([`Record::alarm_cycle_monitored_fields`]), or in the DBE_LOG sweep
4587 /// ([`Record::log_swept_fields`]).
4588 pub(crate) fn collect_subscriber_posts(
4589 &mut self,
4590 deadband_field: &str,
4591 deadband_mask: EventMask,
4592 alarm_bits: EventMask,
4593 aux_post: AuxPostMask,
4594 include_val: bool,
4595 ) -> Vec<(String, EpicsValue, EventMask)> {
4596 use crate::server::record::{CyclePostMask, ValuePostGate, value_gate};
4597
4598 // C's default for a change-detected auxiliary post:
4599 // `monitor_mask | DBE_VALUE | DBE_LOG` (calcRecord.c:420, subRecord.c:400;
4600 // motor `DBE_VAL_LOG` for marked fields, motorRecord.cc:3522-3645).
4601 let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
4602 let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
4603 &[]
4604 } else {
4605 self.record.alarm_cycle_monitored_fields()
4606 };
4607 let force_fields = self.record.force_posted_fields();
4608 // TAKE — this also clears the state it answers from (C's
4609 // `pcalc->newm = 0`), which is why this loop may run only once per cycle.
4610 let mut cycle_posted = self.record.take_cycle_posted_fields();
4611 // The record-lifetime sibling: C's `firstCalcPosted == 0` term, which
4612 // iocInit's per-cycle drain must not be able to eat. Merged here so
4613 // both reach the same branch with the same mask mapping.
4614 cycle_posted.extend(self.record.take_first_monitor_cycle());
4615 let log_swept = self.record.log_swept_fields();
4616 // C change-detects nothing about these fields; only the record's own
4617 // per-cycle mark may post them (aCalcout AA..LL — no PAA..PLL previous
4618 // copy exists to compare against).
4619 let marked_only = self.record.fields_posted_only_when_marked();
4620 let value_masked = self.record.fields_posted_with_value_mask();
4621 // C `if (prec->omod) monitor_mask |= (DBE_VALUE|DBE_LOG)` — the guard
4622 // `OnChangeForced` fields sit behind, which the record may open on a
4623 // cycle where VAL's own mask is shut. TAKEn, like `cycle_posted`, so
4624 // this loop may run only once per cycle.
4625 let secondary_guard = deadband_mask | self.record.take_secondary_value_mask();
4626 let event_posted = self.record.event_posted_fields();
4627 let process_posted = self.record.process_posted_fields();
4628
4629 let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
4630 // C aoRecord.c:536-549: the secondary block runs once per cycle, from
4631 // inside `if (monitor_mask)`, and each field's own `oraw != rval` test
4632 // is welded to the `oraw = rval` that follows its `db_post_events`.
4633 // Decided HERE and not in the subscriber walk below, for two reasons
4634 // the walk cannot satisfy: C's guard is the record's own old copy, not
4635 // this loop's `last_posted` change detection, and C posts whether or
4636 // not anyone is subscribed — so the bookkeeping must not depend on who
4637 // is watching.
4638 let forced_posts: Vec<(String, EpicsValue, EventMask)> = if secondary_guard.is_empty() {
4639 Vec::new()
4640 } else {
4641 let forced_mask = secondary_guard | EventMask::VALUE | EventMask::LOG;
4642 let forced: Vec<&'static str> = value_masked
4643 .iter()
4644 .filter(|(_, gate)| *gate == ValuePostGate::OnChangeForced)
4645 .map(|(name, _)| *name)
4646 .collect();
4647 let mut out = Vec::new();
4648 for name in forced {
4649 if !self.record.take_secondary_value_change(name) {
4650 continue;
4651 }
4652 if let Some(val) = self.resolve_field(name) {
4653 out.push((name.to_string(), val, forced_mask));
4654 }
4655 }
4656 out
4657 };
4658 for (field, subs) in &self.subscribers {
4659 if subs.is_empty()
4660 || field == deadband_field
4661 // SEVR/STAT/AMSG/ACKS are posted by `recGblResetAlarms` itself,
4662 // each with its own C mask (recGbl.c:202-222) — the caller emits
4663 // them from `alarm_field_posts`. A second, change-detected copy
4664 // here would double-post with a mask C never uses for them
4665 // (`alarm_bits | DBE_VALUE | DBE_LOG` instead of C's DBE_VALUE
4666 // on ACKS). UDF is excluded for the opposite reason: NO C
4667 // `monitor()` posts it at all, so a processing cycle that
4668 // redefines VAL must emit no `.UDF` event (a caput to `.UDF`
4669 // still posts, through the generic put path).
4670 || crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS.contains(&field.as_str())
4671 || field == "UDF"
4672 || event_posted.contains(&field.as_str())
4673 || !process_posted.is_none_or(|allowed| allowed.contains(&field.as_str()))
4674 {
4675 continue;
4676 }
4677 let Some(val) = self.resolve_field(field) else {
4678 continue;
4679 };
4680 let changed = match self.posted_value(field) {
4681 Some(prev) => prev != &val,
4682 None => true,
4683 };
4684 if let Some(gate) = value_gate(value_masked, field) {
4685 // C posts this secondary value field with VAL's own monitor_mask,
4686 // from inside the guard that decides whether VAL posts at all —
4687 // never a forced DBE_VALUE|DBE_LOG. `ValuePostGate` says whether C
4688 // also re-tests the field's own value inside that guard (ai RVAL,
4689 // aiRecord.c:462) or posts it whenever the guard fires (timestamp
4690 // RVAL, timestampRecord.c:160).
4691 let post = match gate {
4692 ValuePostGate::OnChange => changed && !deadband_mask.is_empty(),
4693 ValuePostGate::WithValue => include_val,
4694 // Decided once per cycle in `forced_posts` above, against
4695 // the record's own old copy — never here, where the answer
4696 // would depend on this loop's `last_posted` cache and on
4697 // the field having a subscriber.
4698 ValuePostGate::OnChangeForced => false,
4699 };
4700 if post {
4701 sub_updates.push((field.clone(), val.clone(), deadband_mask));
4702 }
4703 } else if changed && !marked_only.contains(&field.as_str()) {
4704 sub_updates.push((
4705 field.clone(),
4706 val.clone(),
4707 aux_post.mask_for(field, alarm_bits, deadband_mask),
4708 ));
4709 } else if force_fields.contains(&field.as_str()) {
4710 // C `monitor()` posts a statically re-marked field with
4711 // `monitor_mask | DBE_VAL_LOG` even when unchanged.
4712 sub_updates.push((field.clone(), val.clone(), aux_mask));
4713 } else if cycle_posted.iter().any(|(name, _)| *name == field) {
4714 // One event per MARK, each with the mask of the C call site that
4715 // made it (`CyclePostMask`) — a field marked twice (aCalcout's
4716 // AMASK `afterCalc` post AND its NEWM `monitor()` post) is posted
4717 // twice, exactly as C posts it from both loops.
4718 for (_, cycle_mask) in cycle_posted.iter().filter(|(name, _)| *name == field) {
4719 let mask = match cycle_mask {
4720 CyclePostMask::Value => EventMask::VALUE,
4721 CyclePostMask::ValueLog => EventMask::VALUE | EventMask::LOG,
4722 CyclePostMask::MonitorValueLog => aux_mask,
4723 };
4724 sub_updates.push((field.clone(), val.clone(), mask));
4725 }
4726 } else if alarm_fanout.contains(&field.as_str()) {
4727 // C motor `monitor()` (motorRecord.cc:3456-3646) posts every listed
4728 // field once `monitor_mask != 0`, so a DBE_ALARM-only subscriber
4729 // observes the alarm moment on any of them.
4730 sub_updates.push((field.clone(), val.clone(), alarm_bits));
4731 }
4732 // C `scalerRecord.c::monitor():757-773` posts EVERY S1..Snch with a
4733 // literal DBE_LOG on every cycle it runs (it runs when `ss == IDLE`,
4734 // scalerRecord.c:510). That sweep is INDEPENDENT of the change post,
4735 // not an alternative to it: on the count-completion cycle `ss` is
4736 // IDLE and `updateCounts()` has ALREADY posted each changed Sn with
4737 // DBE_VALUE (:582), so C emits two events for that field in that one
4738 // cycle — DBE_VALUE, then DBE_LOG. Making this an `else if` on
4739 // `changed` dropped the DBE_LOG half exactly when it matters: a
4740 // DBE_LOG-only archiver would never receive the final counts.
4741 //
4742 // The sweep carries the ALARM-transition bits too. DEVIATION from C,
4743 // deliberate — CBUG-B19. C's `monitor()` opens with
4744 // `monitor_mask = recGblResetAlarms(pscal); monitor_mask |=
4745 // (DBE_VALUE|DBE_LOG);` and then posts with a LITERAL `DBE_LOG`
4746 // (scalerRecord.c:764-771) — `monitor_mask` is assigned, OR-ed, and
4747 // never read. Those two lines are dead, and their only plausible use
4748 // was as the third `db_post_events` argument.
4749 // `recGblResetAlarms` returns the alarm-transition mask that every
4750 // other record ORs into its value posts, so discarding it drops the
4751 // alarm bit: a client subscribed to `Sn` with DBE_ALARM receives
4752 // NOTHING on an alarm-severity transition of the record.
4753 //
4754 // The DBE_VALUE half of C's dead `|=` is deliberately NOT
4755 // resurrected: this sweep is unconditional, so adding VALUE would
4756 // fire a value event at every VALUE subscriber on every idle scan,
4757 // changed or not — that would be a new defect, not a fix. The value
4758 // path is separately served by the change post (C's `updateCounts()`
4759 // DBE_VALUE at `:582`).
4760 if log_swept.contains(&field.as_str()) {
4761 sub_updates.push((field.clone(), val, EventMask::LOG | alarm_bits));
4762 }
4763 }
4764 // A guarded secondary post reaches the snapshot only if the field has
4765 // a subscriber, exactly as every other branch of the walk above; C's
4766 // `db_post_events` with no subscriber delivers nothing either. The
4767 // record's old copy has already advanced regardless — that is the
4768 // half that must not depend on who is watching.
4769 for (field, val, mask) in forced_posts {
4770 if self.subscribers.get(&field).is_some_and(|s| !s.is_empty()) {
4771 sub_updates.push((field, val, mask));
4772 }
4773 }
4774 for (field, val, _) in &sub_updates {
4775 self.record_value_post(field, val.clone());
4776 }
4777 sub_updates
4778 }
4779
4780 /// Basic process: process record, evaluate alarms, timestamp, build snapshot.
4781 /// This does NOT handle links — see process_with_context in database.rs.
4782 ///
4783 /// Returns the value/log snapshot plus a list of alarm-field posts
4784 /// (`SEVR`/`STAT`/`AMSG`/`ACKS`) with their individual C event masks.
4785 /// `SEVR` is posted `DBE_VALUE` only; `STAT`/`AMSG` carry `DBE_ALARM`
4786 /// (sevr/amsg change) and/or `DBE_VALUE` (stat change). The caller
4787 /// must fire these via `notify_field` so a `DBE_VALUE`-only `.SEVR`
4788 /// subscriber is not missed on an alarm-only change and a
4789 /// `DBE_ALARM`-only subscriber is not wrongly notified — C parity
4790 /// with `recGblResetAlarms` (recGbl.c:202-222), matching the
4791 /// `processing.rs` link path.
4792 pub fn process_local(
4793 &mut self,
4794 ) -> CaResult<(
4795 ProcessSnapshot,
4796 Vec<(&'static str, crate::server::recgbl::EventMask)>,
4797 )> {
4798 use crate::server::recgbl::{self, EventMask};
4799 const LCNT_ALARM_THRESHOLD: i16 = 10;
4800
4801 if self.pact.swap(true, std::sync::atomic::Ordering::AcqRel) {
4802 // C `dbProcess` PACT-active guard (dbAccess.c:544-557):
4803 //
4804 // if ((precord->stat == SCAN_ALARM) ||
4805 // (precord->lcnt++ < MAX_LOCK) ||
4806 // (precord->sevr >= INVALID_ALARM)) goto all_done;
4807 // recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM,
4808 // "Async in progress");
4809 //
4810 // The alarm fires EXACTLY ONCE — on the attempt whose
4811 // pre-increment lcnt equals MAX_LOCK — and is then blocked
4812 // by the stat == SCAN_ALARM / sevr >= INVALID bails, the
4813 // same shape as the link path
4814 // (`process_record_with_links_inner`). The pre-fix guard
4815 // here used post-increment `lcnt >= threshold` with no
4816 // already-raised bail, so every reentrant attempt past the
4817 // threshold re-posted the unchanged SEVR/STAT/VAL (and the
4818 // first fire came one attempt early); it also wrote
4819 // sevr/stat directly, skipping `recGblSetSevrMsg` +
4820 // `recGblResetAlarms` — losing the "Async in progress"
4821 // AMSG and the acks bookkeeping the reset performs.
4822 let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
4823 let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
4824 let lcnt_before = self.common.lcnt;
4825 self.common.lcnt = lcnt_before.saturating_add(1);
4826 if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
4827 return Ok((
4828 ProcessSnapshot {
4829 changed_fields: Vec::new(),
4830 },
4831 Vec::new(),
4832 ));
4833 }
4834 recgbl::rec_gbl_set_sevr_msg(
4835 &mut self.common,
4836 recgbl::alarm_status::SCAN_ALARM,
4837 AlarmSeverity::Invalid,
4838 "Async in progress",
4839 );
4840 let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
4841 // Per-field C masks (recGbl.c:202-222): this guard only
4842 // runs on a fresh SCAN_ALARM/INVALID raise, so sevr AND
4843 // stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post
4844 // the shared `stat_mask` = DBE_ALARM|DBE_VALUE, VAL posts
4845 // DBE_VALUE|DBE_LOG plus `val_mask` = DBE_ALARM.
4846 let stat_mask = EventMask::ALARM | EventMask::VALUE;
4847 let mut changed_fields = Vec::new();
4848 if let Some(val) = self.record.val() {
4849 changed_fields.push((
4850 "VAL".to_string(),
4851 val,
4852 EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
4853 ));
4854 }
4855 changed_fields.push((
4856 "SEVR".to_string(),
4857 EpicsValue::Short(self.common.sevr as i16),
4858 EventMask::VALUE,
4859 ));
4860 changed_fields.push((
4861 "STAT".to_string(),
4862 EpicsValue::Short(self.common.stat as i16),
4863 stat_mask,
4864 ));
4865 // AMSG carries "Async in progress" alongside the STAT
4866 // transition (C recGbl.c posts STAT and AMSG together
4867 // when any alarm field moved).
4868 changed_fields.push((
4869 "AMSG".to_string(),
4870 EpicsValue::String(self.common.amsg.clone().into()),
4871 stat_mask,
4872 ));
4873 return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
4874 }
4875 self.common.lcnt = 0;
4876 // RAII guard that resets `self.pact` to false on drop — both for the
4877 // normal exit path and for any `?` early return. The guard holds a raw
4878 // pointer rather than a reference because we still need `self` mutably
4879 // while the guard is alive (the record body below mutates other `self`
4880 // fields).
4881 //
4882 // This is the one PACT release that does not go through `leave_pact`,
4883 // and it provably owes no restart: `process_local` holds `&mut self` for
4884 // the whole PACT window, and a put-notify is queued only through
4885 // `queue_notify_put`, which needs that same `&mut`. So nothing can join
4886 // the restart list inside the window, and the `swap(true)` above proved
4887 // the record was idle on entry.
4888 debug_assert!(
4889 self.notify_restart_list.is_empty(),
4890 "a queued put-notify implies the record was owned, which the swap \
4891 above proved it was not"
4892 );
4893 struct ProcessGuard(*const AtomicBool);
4894 // SAFETY: AtomicBool is Sync; raw pointers don't auto-derive
4895 // Send. We hand-roll Send because the ptr targets a field of
4896 // `self`, which the caller already proves can be borrowed
4897 // through this code path. The pointer is only ever read for an
4898 // atomic store, never written, dereferenced for raw access, or
4899 // escaped from this scope.
4900 unsafe impl Send for ProcessGuard {}
4901 impl Drop for ProcessGuard {
4902 fn drop(&mut self) {
4903 // SAFETY: `self.0` was constructed from
4904 // `&self.pact as *const AtomicBool` below, where
4905 // `self` is the live RecordInstance whose lifetime
4906 // strictly outlives `_guard`. RecordInstance is
4907 // !Unpin-equivalent in practice (we never move it
4908 // while held in the database's `Arc<RwLock<_>>`), so
4909 // the pointer remains valid until Drop runs.
4910 unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
4911 }
4912 }
4913 let _guard = ProcessGuard(&self.pact as *const AtomicBool);
4914
4915 // Call subroutine if registered (for sub/aSub records). Single owner
4916 // shared with the main engine path — see `run_registered_subroutine`.
4917 self.run_registered_subroutine()?;
4918 // Soft-Channel input records must skip the RVAL->VAL convert
4919 // (C `devAiSoft.c` `read_ai` returns 2 = "don't convert" for
4920 // every Soft-Channel input record, incl. one with a constant /
4921 // unset INP). Without this, `process_local` on a soft input
4922 // with a preset VAL — e.g. NaN — would run `convert()` and
4923 // clobber it, after which the UDF check below would see a
4924 // defined value and wrongly clear UDF. The
4925 // `processing.rs` link path already does this; `process_local`
4926 // is the separate foreign-call path (`db.process_record`) and
4927 // needs the same skip. `SoftDtyp::Raw` is excluded below and still
4928 // runs convert.
4929 //
4930 // Gated on `soft_channel_skips_convert()` — identical to the
4931 // `processing.rs` link path — so this only suppresses the
4932 // `RVAL → VAL` convert step. `set_device_did_compute` is an
4933 // overloaded hook: `ai/bi/mbbi/mbbi_direct` read it as
4934 // "skip convert" (override true), but `epid` reads it as
4935 // "skip the whole built-in PID compute" (keeps default false).
4936 // Without this gate, a Soft-Channel `epid` driven through
4937 // `process_local` (`db.process_record`, e.g. QSRV group proc
4938 // members) would skip `do_pid()` entirely — the regression
4939 // d1032fe5 fixed on the `processing.rs` path only.
4940 {
4941 // The same "does the input dset return 2" question the
4942 // `processing.rs` link path asks — Plain and Async, not Raw.
4943 let is_soft = matches!(
4944 crate::server::device_support::classify_soft(&self.common.dtyp),
4945 Some(
4946 crate::server::device_support::SoftDtyp::Plain
4947 | crate::server::device_support::SoftDtyp::Async
4948 )
4949 );
4950 let is_output = self.record.can_device_write();
4951 if is_soft && !is_output && self.record.soft_channel_skips_convert() {
4952 self.record.set_device_did_compute(true);
4953 }
4954 }
4955 // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so the
4956 // record's process() can see it — same as the processing.rs link
4957 // path. `process_local` is the foreign-call path
4958 // (`db.process_record`); without this a record driven through it
4959 // (e.g. QSRV group-process members) would not see UDF/TSE.
4960 {
4961 let ctx = self.common.process_context();
4962 self.record.set_process_context(&ctx);
4963 }
4964 let outcome = self.record.process()?;
4965 let process_result = outcome.result;
4966 // Note: process_local() does not execute ProcessActions — those are
4967 // handled by the full process_record_with_links() path in processing.rs.
4968 //
4969 // It must still apply `post_write_fields`. There are no link writes
4970 // here for them to be ordered against, so the ordering rule is
4971 // satisfied trivially; what is NOT optional is applying them at all —
4972 // a record that hands its completion-flag clear to the framework
4973 // (sseq's `busy`, scaler's `cnt`) would otherwise stay busy forever on
4974 // this path. Same store-then-`DBE_VALUE`-post as
4975 // `PvDatabase::publish_post_write_fields`.
4976 for (field, value) in outcome.post_write_fields {
4977 if self.record.put_field_internal(&field, value).is_ok() {
4978 self.notify_field_written(&field);
4979 self.notify_field(&field, crate::server::recgbl::EventMask::VALUE);
4980 }
4981 }
4982
4983 // If the record reports it modified a metadata-class field during
4984 // process(), invalidate the metadata cache so the next snapshot
4985 // rebuilds from the new values. Default impl returns false, so
4986 // most records pay zero cost here.
4987 if self.record.took_metadata_change() {
4988 self.invalidate_metadata_cache();
4989 // mirror C db_post_events(precord, NULL, DBE_PROPERTY) after record processing.
4990 // `none()` and not a parameter, alone among the `DBE_PROPERTY`
4991 // sweeps: this function is the link-LESS process path by its own
4992 // contract above, and every production process cycle goes through
4993 // `PvDatabase::process_record_with_links`, which resolves. The
4994 // claim is therefore "no caller of `process_local` has a
4995 // link-backed subscriber", and a caller that acquires one must
4996 // move to the link path rather than pass a backing here.
4997 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
4998 for f in fields {
4999 self.notify_field_with_origin(
5000 &f,
5001 crate::server::recgbl::EventMask::PROPERTY,
5002 0,
5003 LinkBacking::none(),
5004 );
5005 }
5006 }
5007
5008 if process_result == RecordProcessResult::AsyncPending {
5009 // Async: PACT stays set, no further processing this cycle
5010 // Don't clear processing flag (guard won't run — we leak it intentionally)
5011 std::mem::forget(_guard);
5012 return Ok((
5013 ProcessSnapshot {
5014 changed_fields: Vec::new(),
5015 },
5016 Vec::new(),
5017 ));
5018 }
5019 if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
5020 // Intermediate notification (e.g. DMOV=0 at move start).
5021 // Unlike AsyncPending, we DO release the processing flag so
5022 // subsequent I/O Intr cycles can continue processing normally.
5023 self.common.time = crate::runtime::general_time::get_current();
5024 // Filter out fields that haven't actually changed, and update
5025 // MLST/last_posted for those that have. Each intermediate
5026 // post carries DBE_VALUE|DBE_LOG — C motor's mid-move
5027 // `db_post_events` calls use `DBE_VAL_LOG`
5028 // (motorRecord.cc:2606 DMOV, and every other do_work post);
5029 // no alarm transition ran on this pending pass.
5030 let mut changed_fields = Vec::new();
5031 for (name, val) in fields {
5032 let changed = match self.posted_value(&name) {
5033 Some(prev) => prev != &val,
5034 None => true,
5035 };
5036 if changed {
5037 if name == "VAL" {
5038 if let Some(f) = val.to_f64() {
5039 self.put_coerced("MLST", EpicsValue::Double(f));
5040 self.common.mlst = Some(f);
5041 }
5042 }
5043 self.record_value_post(&name, val.clone());
5044 changed_fields.push((name, val, EventMask::VALUE | EventMask::LOG));
5045 }
5046 }
5047 // _guard drops here, clearing the processing flag
5048 return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
5049 }
5050 if process_result == RecordProcessResult::CompleteNoEmit {
5051 // The record accumulated this cycle without emitting (compress
5052 // `status == 1`). C `compressRecord.c:365` runs the completion
5053 // epilogue (udf clear, timestamp, monitor, FLNK) only on an emit
5054 // cycle (`if (status != 1)`), so a non-emitting cycle must publish
5055 // nothing — skip the epilogue and return an empty snapshot, exactly
5056 // as the production engine path does in `processing.rs`. This keeps
5057 // the emit-gate uniform across both process-dispatch paths so the
5058 // invariant holds by construction, not by "process_local never
5059 // produces it". CompleteNoEmit is synchronous (PACT already
5060 // cleared); the `_guard` drops here, clearing the processing flag.
5061 return Ok((
5062 ProcessSnapshot {
5063 changed_fields: Vec::new(),
5064 },
5065 Vec::new(),
5066 ));
5067 }
5068
5069 // `CompleteDeferOutput` (swait ODLY delay-start) is NOT special-cased
5070 // here: it deliberately shares the Complete value-side snapshot builder
5071 // below. C `swaitRecord.c::process` posts the value side (`monitor()`,
5072 // line 475) on the delaying cycle, so building the snapshot now is the
5073 // correct, parity-matching behavior — unlike `CompleteNoEmit` above,
5074 // whose fall-through would wrongly emit. The variant's *other* halves —
5075 // holding PACT across the delay and deferring OUT/OEVT/FLNK to the
5076 // `ReprocessAfter` continuation — are the engine path's responsibility
5077 // (`processing.rs::process_record_with_links_inner`); `process_local` is
5078 // a body-only test helper that dispatches no FLNK/output and no
5079 // `ProcessAction`, and no test drives a swait ODLY record through it. So
5080 // the invariant still holds by construction across both dispatch paths:
5081 // both publish the value side here, both leave the output side to the
5082 // engine.
5083
5084 // UDF update before alarm evaluation — C parity (see
5085 // `processing.rs`). A NaN / undefined value keeps UDF true so
5086 // `recGblCheckUDF` raises UDF_ALARM this cycle instead of the
5087 // record reporting a stale/garbage value with no alarm.
5088 if self.record.clears_udf() {
5089 self.common.udf = self.record.value_is_undefined() as u8;
5090 }
5091 // Per-record alarm hook (C `checkAlarms()`).
5092 self.record.check_alarms(&mut self.common);
5093
5094 // Evaluate alarms (accumulates into nsta/nsev)
5095 self.evaluate_alarms();
5096
5097 // Transfer nsta/nsev → sevr/stat, detect alarm change
5098 let alarm_result = recgbl::rec_gbl_reset_alarms(&mut self.common);
5099
5100 self.common.time = crate::runtime::general_time::get_current();
5101 // UDF already updated above — do not clear unconditionally.
5102
5103 // Deadband check for VAL monitor filtering
5104 let (include_val, include_archive) = self.check_deadband_ext();
5105 // C `recGblResetAlarms` `val_mask = DBE_ALARM`
5106 // (recGbl.c:194/203/212): every monitored-value post this cycle
5107 // carries DBE_ALARM when the severity/status OR the alarm
5108 // message moved — same parity rule as the `processing.rs`
5109 // paths.
5110 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
5111 EventMask::ALARM
5112 } else {
5113 EventMask::NONE
5114 };
5115
5116 // Build snapshot
5117 let mut changed_fields = Vec::new();
5118 // Same deadband-field routing and per-field mask as the
5119 // `processing.rs` paths: the tracked field posts the classes
5120 // that actually fired (MDEL → DBE_VALUE, ADEL → DBE_LOG, alarm
5121 // movement → DBE_ALARM); a non-primary deadband field (motor
5122 // RBV — C motor `monitor()`, motorRecord.cc:3468-3507) leaves
5123 // VAL to the generic change-detection loop below.
5124 let deadband_field = self.record.monitor_deadband_field();
5125 // The mask every change-detected aux field posts with — owned by
5126 // `AuxPostMask`, the same resolver the `processing.rs` paths use, so
5127 // this builder cannot drift from them on what mask a field carries.
5128 let aux_post = AuxPostMask::of(self.record.as_ref());
5129 // The deadband field's post — mask owned by `deadband_post`, the single
5130 // assembler C's `db_post_events(&prec->val, monitor_mask)` maps to.
5131 let deadband = self.deadband_post(alarm_bits, include_val, include_archive);
5132 let deadband_mask = deadband.mask;
5133 if let Some((field, value)) = deadband.field {
5134 changed_fields.push((field, value, deadband_mask));
5135 }
5136 // C `recGblResetAlarms` (recGbl.c:202-222) posts each alarm
5137 // field with its OWN per-field mask, not one record-wide mask:
5138 // * SEVR — DBE_VALUE, ONLY on a sevr change.
5139 // * STAT — DBE_ALARM (sevr change) | DBE_VALUE (stat change).
5140 // * ACKS — DBE_VALUE, only when an alarm field moved.
5141 // Pushing SEVR/STAT into `changed_fields` collapses them onto
5142 // the single record-wide `event_mask` (which carries ALARM on
5143 // `alarm_changed`): a DBE_VALUE-only `.SEVR` subscriber would
5144 // miss a stat-only-driven sevr change, and a DBE_ALARM-only
5145 // `.SEVR` subscriber would be wrongly notified. Post them via
5146 // `notify_field` with their individual masks instead — exactly
5147 // as the `processing.rs` link path does.
5148 let sevr_changed = self.common.sevr != alarm_result.prev_sevr;
5149 let stat_changed = self.common.stat != alarm_result.prev_stat;
5150 let stat_mask = {
5151 let mut m = EventMask::NONE;
5152 // C `recGblResetAlarms` carries DBE_ALARM on the STAT/AMSG
5153 // posts whenever the severity OR the alarm message moved —
5154 // not on a severity change alone. Aligning with the
5155 // `processing.rs` link path (and `complete_async_record`).
5156 if sevr_changed || alarm_result.amsg_changed {
5157 m |= EventMask::ALARM;
5158 }
5159 if stat_changed {
5160 m |= EventMask::VALUE;
5161 }
5162 m
5163 };
5164 let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
5165 if sevr_changed {
5166 alarm_posts.push(("SEVR", EventMask::VALUE));
5167 }
5168 if !stat_mask.is_empty() {
5169 alarm_posts.push(("STAT", stat_mask));
5170 // AMSG shares STAT's mask — C posts it alongside STAT when
5171 // any alarm field moved.
5172 alarm_posts.push(("AMSG", stat_mask));
5173 }
5174 // C parity (recGbl.c:214-217): ACKS is posted (DBE_VALUE) whenever the
5175 // alarm-acknowledge rule fires — `acks_posted` already folds in C's
5176 // `if (stat_mask)` guard, and the post carries no value-change test.
5177 if alarm_result.acks_posted {
5178 alarm_posts.push(("ACKS", EventMask::VALUE));
5179 }
5180
5181 // The cycle's subscriber posts — assembled by the single owner
5182 // `collect_subscriber_posts`, shared with every `processing.rs` path.
5183 changed_fields.extend(self.collect_subscriber_posts(
5184 deadband_field,
5185 deadband_mask,
5186 alarm_bits,
5187 aux_post,
5188 include_val,
5189 ));
5190 // C waveform/aai/aao `monitor()` posts HASH with a literal
5191 // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
5192 // 317-319), independent of the VAL post mask. `array_hash_changed`
5193 // was set by `check_deadband_ext` this cycle.
5194 if self.array_hash_changed {
5195 if let Some(h) = self.resolve_field("HASH") {
5196 changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
5197 }
5198 }
5199
5200 // No `.UDF` post — C `monitor()` posts UDF nowhere, and
5201 // `recGblResetAlarms` (recGbl.c:202-222) posts only SEVR/STAT/AMSG/
5202 // ACKS. A `.UDF` event exists only where C's generic `dbPut` posts
5203 // the field it wrote (dbAccess.c:1411-1413) — i.e. a client caput to
5204 // `.UDF` itself.
5205
5206 Ok((ProcessSnapshot { changed_fields }, alarm_posts))
5207 }
5208
5209 /// **The single owner of "write a value into a record field in the type
5210 /// that field stores"** — a `put_field` arm binds ONE variant and silently
5211 /// drops the rest, and the trackers this writes differ in type per record:
5212 /// C declares LALM/ALST/MLST with the record's VAL type, `DBF_INT64` on
5213 /// int64in/int64out (`int64inRecord.dbd.pod:233-243`), `DBF_LONG` on
5214 /// longin/longout, `DBF_DOUBLE` elsewhere.
5215 ///
5216 /// Takes the value in the CALLER's domain rather than an `f64`: the alarm
5217 /// ladder's `alev` is an `epicsInt64` on the int64 records and going
5218 /// through a double would have rounded the very threshold LALM exists to
5219 /// remember.
5220 pub(crate) fn put_coerced(&mut self, field: &str, val: EpicsValue) {
5221 let target_type = self
5222 .record
5223 .get_field(field)
5224 .map(|v| v.db_field_type())
5225 .unwrap_or(crate::types::DbFieldType::Double);
5226 let coerced = val.convert_to(target_type);
5227 let _ = self.record.put_field(field, coerced);
5228 }
5229
5230 /// Check MDEL/ADEL deadbands for VAL monitor/archive filtering.
5231 /// Returns `(monitor_trigger, archive_trigger)`.
5232 ///
5233 /// Updates `MLST`/`ALST` (record-owned) and the `CommonFields`
5234 /// `mlst/alst` shadow when a trigger fires. Records without
5235 /// MDEL/ADEL (e.g. motor) default to deadband=0 (any actual
5236 /// change triggers).
5237 ///
5238 /// Delegates the comparison to the free function [`check_deadband`]
5239 /// below, which ports C `recGblCheckDeadband` (recGbl.c:345-370).
5240 /// `None` there is "this record type carries no MLST/ALST cell and
5241 /// nothing has been posted yet", the only state C does not have.
5242 /// The single owner of the deadband field's monitor post — C `monitor()`'s
5243 /// `db_post_events(&prec->val, monitor_mask)`, the one post every record
5244 /// makes for the value it deadbands.
5245 ///
5246 /// [`Self::check_deadband_ext`] decides WHETHER the MDEL/ADEL classes fired;
5247 /// this decides what the resulting post looks like, and it is the only place
5248 /// that assembles that mask. The three `processing.rs` snapshot builders and
5249 /// the `notify_monitors` path all route through here, so a record's mask rule
5250 /// cannot hold on one processing path and not another.
5251 ///
5252 /// Two record hooks strip C's `DBE_LOG` from the post:
5253 ///
5254 /// * [`Record::value_only_change_fields`] — C posts a literal `DBE_VALUE`
5255 /// (scaler VAL, scalerRecord.c:478).
5256 /// * [`Record::fields_posted_with_monitor_mask`] — C posts
5257 /// `monitor_mask | DBE_VALUE` (event VAL, eventRecord.c:163). `monitor_mask`
5258 /// there is `recGblResetAlarms`'s return, i.e. the alarm bits alone, so the
5259 /// post carries `DBE_VALUE` (+ `DBE_ALARM` when the alarm moved) and never
5260 /// the archive `DBE_LOG` — an event's VAL reaches a `DBE_LOG` archiver on
5261 /// no cycle at all.
5262 ///
5263 /// [`DeadbandPost::field`] is `None` when no class fired, i.e. when C's
5264 /// `if (monitor_mask)` guard would skip the post.
5265 /// C `monitor()`'s VALUE / LOG gate for the primary-value post —
5266 /// `(include_val, include_archive)`, the single owner every processing path
5267 /// feeds into [`Self::deadband_post`] and [`Self::collect_subscriber_posts`].
5268 /// Keeping it in one place is what stops the rule from holding on the
5269 /// synchronous path but not the async-continuation / put-notify paths.
5270 pub(crate) fn value_include_classes(&mut self) -> (bool, bool) {
5271 // fanout/seq "trigger" records post VAL only with the alarm events
5272 // `recGblResetAlarms` returns, never DBE_VALUE/DBE_LOG — see
5273 // `Record::process_posts_value_monitor`. The alarm bits still reach VAL
5274 // via `deadband_post`'s `alarm_bits`, so an alarm transition still posts
5275 // it; only the value/archive classes are suppressed.
5276 if !self.record.process_posts_value_monitor() {
5277 return (false, false);
5278 }
5279 match self.record.monitor_value_changed() {
5280 // lsi/lso post VALUE|LOG only when the string actually changed (C
5281 // `lsiRecord.c`/`lsoRecord.c` monitor: `len != olen || memcmp(oval,
5282 // val, len)`); they have no MDEL/ADEL deadband to express that, so
5283 // the gate is explicit. The MPST/APST `menuPost` "Always" override
5284 // OR-adds DBE_VALUE / DBE_LOG even on an unchanged cycle (C monitor:
5285 // `if (mpst == menuPost_Always) events |= DBE_VALUE; if (apst ==
5286 // menuPost_Always) events |= DBE_LOG;`).
5287 Some(changed) => {
5288 let (val_always, archive_always) = self.record.monitor_always_post();
5289 (changed || val_always, changed || archive_always)
5290 }
5291 None => {
5292 if self.record.uses_monitor_deadband() {
5293 self.check_deadband_ext()
5294 } else {
5295 // Binary records (bi/bo/busy/mbbi/mbbo): always post monitors
5296 (true, true)
5297 }
5298 }
5299 }
5300 }
5301
5302 pub(crate) fn deadband_post(
5303 &self,
5304 alarm_bits: EventMask,
5305 include_val: bool,
5306 include_archive: bool,
5307 ) -> DeadbandPost {
5308 let field = self.record.monitor_deadband_field();
5309 let log_suppressed = self.record.value_only_change_fields().contains(&field)
5310 || self
5311 .record
5312 .fields_posted_with_monitor_mask()
5313 .contains(&field);
5314
5315 let mut mask = alarm_bits;
5316 if include_val {
5317 mask |= EventMask::VALUE;
5318 }
5319 if include_archive && !log_suppressed {
5320 mask |= EventMask::LOG;
5321 }
5322
5323 // The closed set applies to THIS post too. `process_posted_fields` is
5324 // "the CLOSED set of fields a process cycle of this record may post" —
5325 // and the deadband post is a post. A record whose C `monitor()` never
5326 // names the deadband field must not have one invented for it: transform
5327 // `monitor()` (transformRecord.c:786-809) walks A..P and posts no VAL
5328 // at all — VAL is an inert dummy (`:422`) — so an alarm cycle, whose
5329 // `alarm_bits` alone make `mask` non-empty, was firing a `.VAL` monitor
5330 // C never sends. Gating here rather than at each builder keeps the
5331 // single owner of the deadband post the single enforcer of the set.
5332 let in_closed_set = self
5333 .record
5334 .process_posted_fields()
5335 .is_none_or(|allowed| allowed.contains(&field));
5336
5337 let value = if mask.is_empty() || !in_closed_set {
5338 None
5339 } else if field == "VAL" {
5340 self.record.val()
5341 } else {
5342 self.resolve_field(field)
5343 };
5344 DeadbandPost {
5345 mask,
5346 field: value.map(|v| (field.to_string(), v)),
5347 }
5348 }
5349
5350 pub fn check_deadband_ext(&mut self) -> (bool, bool) {
5351 // C waveform/aai/aao `monitor()` (waveformRecord.c:291-326) replaces
5352 // the analog MDEL/ADEL deadband with the MPST/APST "Always vs On
5353 // Change" mechanism: the record hashes its array content and posts
5354 // `DBE_VALUE`/`DBE_LOG` either always or only when the hash changed,
5355 // and posts `HASH` (`DBE_VALUE`) on a hash change. The record owns
5356 // the hash compute + `HASH` update; `array_hash_changed` carries the
5357 // event to the snapshot builders, which post `HASH` (the field is
5358 // excluded from the generic change-detection loop via
5359 // `event_posted_fields`).
5360 if let Some(post) = self.record.array_monitor_post() {
5361 self.array_hash_changed = post.hash_changed;
5362 return (post.post_value, post.post_archive);
5363 }
5364 self.array_hash_changed = false;
5365
5366 // The deadband is evaluated against `monitor_deadband_value()`,
5367 // not `val()` directly: a record whose monitored quantity is
5368 // not its primary value (e.g. the motor record, VAL=setpoint /
5369 // RBV=readback — C `monitor()` deadbands RBV) overrides that
5370 // hook. Default is `val()`, so other records are unaffected.
5371 let val = match self
5372 .record
5373 .monitor_deadband_value()
5374 .and_then(|v| v.to_f64())
5375 {
5376 Some(v) => v,
5377 None => return (true, true),
5378 };
5379
5380 let mdel = self
5381 .record
5382 .get_field("MDEL")
5383 .and_then(|v| v.to_f64())
5384 .unwrap_or(0.0);
5385 let adel = self
5386 .record
5387 .get_field("ADEL")
5388 .and_then(|v| v.to_f64())
5389 .unwrap_or(0.0);
5390
5391 // Use record's MLST/ALST fields if available, otherwise fall back to
5392 // CommonFields. `None` survives to `check_deadband` as the "nothing
5393 // posted yet" state: record types that carry no MLST/ALST cell (sel,
5394 // scalcout) have nowhere to hold a last-posted value.
5395 let mlst = self
5396 .record
5397 .get_field("MLST")
5398 .and_then(|v| v.to_f64())
5399 .or(self.common.mlst);
5400 let alst = self
5401 .record
5402 .get_field("ALST")
5403 .and_then(|v| v.to_f64())
5404 .or(self.common.alst);
5405
5406 let monitor_trigger = check_deadband(val, mlst, mdel);
5407 let archive_trigger = check_deadband(val, alst, adel);
5408
5409 if archive_trigger {
5410 self.put_coerced("ALST", EpicsValue::Double(val));
5411 self.common.alst = Some(val);
5412 }
5413 if monitor_trigger {
5414 self.put_coerced("MLST", EpicsValue::Double(val));
5415 self.common.mlst = Some(val);
5416 }
5417
5418 (monitor_trigger, archive_trigger)
5419 }
5420
5421 /// Build a Snapshot for a given value, populated with the record's display
5422 /// metadata and the link metadata the poster resolved for this batch. Uses
5423 /// the metadata cache so the populate cost is paid at most once per
5424 /// metadata-stable interval (cf. `cached_metadata`).
5425 ///
5426 /// There is deliberately no `backing`-less form. One existed, defaulting to
5427 /// [`LinkBacking::none`], and it made "nothing was resolved" the thing a
5428 /// caller says by saying nothing — which is how the `DBE_PROPERTY` sweep
5429 /// came to post `CALC.A` with the calc's own precision (see
5430 /// `link_backed_metadata_is_read_live.rs`). A caller with nothing to
5431 /// resolve still writes `LinkBacking::none()`, and then it is a claim a
5432 /// reviewer can see and check.
5433 ///
5434 /// The monitor path reaches the same one consumer the GET path does
5435 /// (`finish_field_snapshot` -> `route_field_metadata`), so it carried the
5436 /// same defect: measured on the wire, a `camonitor -s` on a `calc`'s `A`
5437 /// after `caput TARGET.PREC 4` with the source never processed printed
5438 /// `5.0` where C printed `5.0000`. The resolve cannot happen here — the
5439 /// post runs with the record's own lock held — so the caller that owns the
5440 /// process/put cycle resolves it at a point where no lock is held and
5441 /// hands it in.
5442 pub fn make_monitor_snapshot(
5443 &self,
5444 field: &str,
5445 value: EpicsValue,
5446 backing: LinkBacking<'_>,
5447 ) -> super::super::snapshot::Snapshot {
5448 // A monitor update is posted from the record's own change-detection
5449 // loop, which hands over the STORED variant. Project it onto the
5450 // field's declared type here, at the same owner the GET path and the
5451 // CA create-channel path use, or a client that was told `DBR_ENUM` at
5452 // create time would be posted a `DBR_SHORT` update.
5453 // The poster's obligation, checked rather than trusted: a link-backed
5454 // field carries its target's units/precision/limits, and only a caller
5455 // holding no record lock can resolve them. A `LinkBacking::none()`
5456 // here would silently serve the slot's C seed instead — the X2 defect
5457 // in its new clothes. Debug-only because it is a property of the call
5458 // graph, not of the data: every path either resolves or provably
5459 // posts no link-backed field, and the suite is what proves it.
5460 debug_assert!(
5461 !backing.is_unresolved()
5462 || self
5463 .record
5464 .link_backed_metadata_field(&field.to_ascii_uppercase())
5465 .is_none(),
5466 "{}: monitor post of link-backed field {field} with nothing resolved \
5467 — the poster must call PvDatabase::resolve_link_backed_metadata \
5468 at a point where it holds no record lock",
5469 self.name
5470 );
5471 let value = self.project_to_declared_type(field, value);
5472 self.finish_field_snapshot(field, value, backing)
5473 }
5474
5475 /// Apply a record's per-field metadata override (C RSET
5476 /// `get_units`/`get_precision`/`get_graphic_double`/
5477 /// `get_control_double`/`get_alarm_double`, all keyed by field)
5478 /// over the cached record-level metadata. Shared by the GET and
5479 /// monitor snapshot builders. Computed live on every call — never
5480 /// cached — so overrides derived from fields outside the
5481 /// [`is_metadata_cache_source`] set cannot go stale.
5482 ///
5483 /// This is also where the record-level `Q:form` info tag is narrowed to
5484 /// the served field: QSRV assigns `display.form.index` only when the
5485 /// channel addresses the VAL field (`IOCSource::initialize` gates it on
5486 /// `dbIsValueField(dbChannelFldDes(chan))`, `iocsource.cpp:53`; the form
5487 /// *menu*, `form.choices`, is published for every field). The metadata
5488 /// cache is per-record, so a channel on `REC.RVAL` of a record carrying
5489 /// `info(Q:form, "Hex")` used to report Hex where pvxs reports Default.
5490 /// Both `Snapshot` producers (`snapshot_for_field` for GET,
5491 /// `make_monitor_snapshot` for updates) run this one owner, so
5492 /// `DisplayInfo::form` means exactly one thing on every path: the form
5493 /// index that applies to THIS field.
5494 fn apply_field_metadata_override(
5495 &self,
5496 field: &str,
5497 snap: &mut super::super::snapshot::Snapshot,
5498 ) {
5499 if let Some(display) = snap.display.as_mut()
5500 && !crate::server::database::is_value_field(field)
5501 {
5502 display.form = 0;
5503 }
5504 let Some(ov) = self.record.field_metadata_override(field) else {
5505 return;
5506 };
5507 if ov.units.is_some()
5508 || ov.precision.is_some()
5509 || ov.disp_limits.is_some()
5510 || ov.alarm_limits.is_some()
5511 {
5512 let d = snap.display.get_or_insert_with(Default::default);
5513 if let Some(units) = ov.units {
5514 d.units = units;
5515 }
5516 if let Some(precision) = ov.precision {
5517 d.precision = precision;
5518 }
5519 if let Some((upper, lower)) = ov.disp_limits {
5520 d.upper_disp_limit = upper;
5521 d.lower_disp_limit = lower;
5522 }
5523 if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
5524 d.upper_alarm_limit = hihi;
5525 d.upper_warning_limit = high;
5526 d.lower_warning_limit = low;
5527 d.lower_alarm_limit = lolo;
5528 }
5529 }
5530 if let Some((upper, lower)) = ov.ctrl_limits {
5531 let c = snap.control.get_or_insert_with(Default::default);
5532 c.upper_ctrl_limit = upper;
5533 c.lower_ctrl_limit = lower;
5534 }
5535 }
5536
5537 /// C's rset metadata slots route **per field**, on `dbGetFieldIndex`. The
5538 /// port's metadata cache is the record's VAL metadata, and serving it to
5539 /// every field is what made a non-VAL field report VAL's limits.
5540 ///
5541 /// Every base record's `get_control_double` / `get_alarm_double` has the
5542 /// same two-arm shape: a listed set of field indices that take the
5543 /// record's own limits, and a `default:` arm that hands the field to
5544 /// `recGblGetControlDouble` / `recGblGetAlarmDouble` — the field TYPE's
5545 /// numeric range, and four NaN. This routes the `default:` arm; a listed
5546 /// field keeps the cache, which already holds exactly the record's own
5547 /// limits (and already distinguishes `ao`'s DRVH/DRVL from `ai`'s
5548 /// HOPR/LOPR).
5549 ///
5550 /// The three slots' listed sets are **different**, and each has its own
5551 /// owner here: [`Self::control_explicit_field`],
5552 /// [`Self::graphic_explicit_field`] and [`Self::alarm_explicit_field`].
5553 /// They are separate switches over separate field lists in C, so the
5554 /// membership question is asked once per slot, never once for both — and
5555 /// each list varies by record TYPE, so it is asked once per type too.
5556 ///
5557 /// Measured on a real `softIocPVX` against `record(calc,"X"){}`:
5558 /// `.PHAS` (DBF_SHORT, unlisted) serves control ±32767 — the SHRT range —
5559 /// while `.VAL` and `.HIHI` (both listed) serve 0/0 from HOPR/LOPR.
5560 ///
5561 /// Display (graphic) limits differ from control in one way: the `default:`
5562 /// arm of `get_graphic_double` tries a LINK first (`calcRecord.c`
5563 /// `get_linkNumber` → `dbGetGraphicLimits`) and only falls to `recGbl` for
5564 /// a field that backs no link. A constant (unset) link has no metadata
5565 /// getters, so the `dbAccess.c:216` 0/0 seed stands — measured: `CALC.A`
5566 /// serves display 0/0 but control ±1e300. [`Record::link_backed_metadata_field`]
5567 /// carries that per-record C knowledge.
5568 ///
5569 /// Units and precision are routed here too, and they are NOT the
5570 /// `get_*_double` shape: neither has a `recGbl` range arm, so a field the
5571 /// type's switch does not name keeps `dbAccess.c`'s memset — empty units,
5572 /// and the precision seed. They were built into the record-level cache
5573 /// until `subArray`/`sel`/`sub`/`dfanout` were measured serving `""`/`0`
5574 /// against C's EGU/PREC, which is the same dual meaning the alarm leaves
5575 /// had: whether a field carried the record's own value depended on a
5576 /// `match rtype` in a different function.
5577 ///
5578 /// The last arm is not the same for every record type — a slot can also
5579 /// fall through WITHOUT delegating, keeping the seed. That fact is one bit
5580 /// per record type, read from its C source: `control_default_arm`.
5581 fn route_field_metadata(
5582 &self,
5583 field: &str,
5584 backing: LinkBacking<'_>,
5585 snap: &mut super::super::snapshot::Snapshot,
5586 ) {
5587 // The rset slots this record type actually supplies. A NULL slot makes
5588 // `dbAccess.c` clear the option bit, so the leaf is never served and
5589 // there is nothing to route — minting a value here would put a
5590 // fabricated number into the struct while `Snapshot::properties` says
5591 // the slot is absent, and the two wires read different halves of that
5592 // disagreement: CA goes through the mask-gated accessors
5593 // (`codec.rs::get_limits` calls `graphic_limits()`/`alarm_limits()`/
5594 // `control_limits()`, each `then_some`-gated), PVA reads the struct
5595 // straight (`native_source.rs:226`) under its own leaf mask.
5596 let slots = self.record.property_support();
5597 let rtype = self.record.record_type();
5598 let f = field.to_ascii_uppercase();
5599
5600 // C's `get_linkNumber` question, asked ONCE per snapshot: which of this
5601 // record's own link fields, if any, supplies this field's metadata.
5602 // Four of the six slots consult it — `aSubRecord.c:306-404` is the
5603 // complete specimen — and control does not, because
5604 // `dbGetControlLimits` has no caller anywhere in base.
5605 let link_backed = self.record.link_backed_metadata_field(&f);
5606 // Resolved for THIS build and handed in — see [`LinkBacking`]. Reading
5607 // a value the record had stored is what made a `caput SRC.EGU` invisible
5608 // to a passive source's clients until the source next processed.
5609 let link_meta = link_backed.as_ref().and_then(|lf| backing.metadata(lf));
5610
5611 // C `get_units`'s "no case" arm — the field the rset tests for and
5612 // declines to write, leaving `dbAccess.c:378`'s zeroed buffer. The
5613 // record-level cache holds `EGU` for every type that supplies the
5614 // slot, so this is the step that takes it back off the fields C never
5615 // gives it to. See [`Self::units_from_egu`].
5616 if slots.units {
5617 if link_backed.is_some() {
5618 // C's link arm — `dbGetUnits(&prec->inpa + n, ...)`, which
5619 // writes only what the TARGET record supplies. A constant or
5620 // unresolved link supplies nothing and `dbAccess.c:378`'s
5621 // zeroed buffer stands.
5622 snap.display.get_or_insert_with(Default::default).units = link_meta
5623 .and_then(|m| m.units.as_deref())
5624 .map(crate::types::PvString::from)
5625 .unwrap_or_default();
5626 } else if !self.units_from_egu(rtype, &f) {
5627 snap.display.get_or_insert_with(Default::default).units = Default::default();
5628 }
5629 }
5630
5631 // C `get_precision`'s link arm, which the port had no arm for at all.
5632 // All five link-routing types seed `*pprecision = prec->prec` — the
5633 // record's own PREC, already in the metadata cache — and overwrite it
5634 // only when `dbGetPrecision` on the backing link SUCCEEDS
5635 // (`calcRecord.c:184-203`, `aSubRecord.c:323-348`). So an unresolved or
5636 // constant link means "leave the cache alone", which is the `None`
5637 // arm here.
5638 if slots.precision {
5639 let link_precision = link_meta.and_then(|m| m.precision);
5640 if link_backed.is_some()
5641 && let Some(precision) = link_precision
5642 {
5643 snap.display.get_or_insert_with(Default::default).precision = precision;
5644 }
5645 // C `get_precision`'s SHARED TAIL — `recGblGetPrec`
5646 // (`recGbl.c:119-144`), which every one of these bodies hands the
5647 // fields it did not name. For a field that can carry a precision
5648 // (`dbAccess.c:388-389` gates `DBR_PRECISION` on
5649 // `DBF_FLOAT`/`DBF_DOUBLE`) the tail does one thing: clamp a PREC
5650 // outside `0..=15` to 15. Applied here rather than per record so
5651 // "does this field take the tail" has one owner —
5652 // [`Self::precision_explicit_field`] — instead of thirty
5653 // `field_metadata_override`s that each have to remember it.
5654 //
5655 // Gated on the field being float or double so the tail runs
5656 // exactly where C's does: `dbAccess.c:388-389` refuses to call
5657 // `get_precision` at all for any other type, which is what keeps
5658 // `recGblGetPrec`'s integer arm (`*precision = 0`) unobservable —
5659 // it must stay unobservable here too.
5660 let field_type = self.static_field_type(field);
5661 if matches!(
5662 field_type,
5663 Some(crate::types::DbFieldType::Float | crate::types::DbFieldType::Double)
5664 ) && !Self::precision_explicit_field(
5665 rtype,
5666 &f,
5667 link_backed.is_some(),
5668 link_precision.is_some(),
5669 ) {
5670 let d = snap.display.get_or_insert_with(Default::default);
5671 d.precision = crate::server::recgbl::rec_gbl_get_prec(field_type, d.precision);
5672 }
5673 }
5674
5675 // C `get_control_double`'s last arm. No base record routes control
5676 // through a link: `dbGetControlLimits` has zero callers in all of
5677 // base, so unlike display this arm needs no link branch.
5678 if slots.control_double && !Self::control_explicit_field(rtype, field) {
5679 let (upper, lower) =
5680 match super::record_trait::control_default_arm(self.record.record_type()) {
5681 // `recGblGetControlDouble` → `getMaxRangeValues(field_type)`.
5682 // A type with no case in C's switch (STRING/MENU/DEVICE/links)
5683 // is written by nothing, leaving the `dbAccess.c:256` seed —
5684 // which is 0/0, exactly what `unwrap_or` supplies.
5685 super::record_trait::RsetDefaultArm::RecGblRange => {
5686 self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
5687 }
5688 // The slot exists but writes nothing here, so the same
5689 // `dbAccess.c:256` seed stands. Modelled as a value rather
5690 // than as `None`: C's option bit is ON (the slot is supplied
5691 // and returned 0), so the leaf IS served — carrying the seed.
5692 super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
5693 };
5694 snap.control = Some(super::super::snapshot::ControlInfo {
5695 upper_ctrl_limit: upper,
5696 lower_ctrl_limit: lower,
5697 });
5698 }
5699
5700 // C `get_graphic_double`'s last arm. Unlike control this one has a LINK
5701 // branch ahead of the recGbl call, so the three answers are: keep the
5702 // cache (listed on HOPR/LOPR), the link's limits, or the default arm.
5703 if slots.graphic_double && !Self::graphic_explicit_field(rtype, field) {
5704 let (upper, lower) = if link_backed.is_some() {
5705 // `dbGetGraphicLimits` on the backing link. A CONSTANT link has
5706 // no metadata getters and an unresolved one has nothing cached,
5707 // so in both cases the `dbAccess.c:216` 0/0 seed stands.
5708 link_meta
5709 .and_then(|m| m.graphic_limits)
5710 .map(|(lower, upper)| (upper, lower))
5711 .unwrap_or((0.0, 0.0))
5712 } else {
5713 match super::record_trait::graphic_default_arm(rtype) {
5714 super::record_trait::RsetDefaultArm::RecGblRange => {
5715 self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
5716 }
5717 super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
5718 }
5719 };
5720 let d = snap.display.get_or_insert_with(Default::default);
5721 d.upper_disp_limit = upper;
5722 d.lower_disp_limit = lower;
5723 }
5724
5725 // C `get_alarm_double`, BOTH arms. This branch owns the four limits
5726 // outright: `slots.alarm_double` is exactly the condition under which
5727 // `getProperties` assigns the four `valueAlarm.*Limit` leaves, so
5728 // whenever the leaves are served this assigns them.
5729 //
5730 // The explicit arm used to be left to the record-level metadata cache
5731 // (`populate_display_info`), whose `match rtype` covered only some of
5732 // the types that supply the slot. A type it missed reached the wire
5733 // with `snap.display == None` and the four leaves kept the NT's
5734 // structural 0 — measured: DFANOUT.VAL, SEL.VAL and SUB.VAL served 0
5735 // where C serves NaN. That made "which limits does VAL carry" depend on
5736 // a match arm existing somewhere else, which is the dual meaning this
5737 // single owner removes.
5738 if slots.alarm_double {
5739 let (hihi, high, low, lolo) = if Self::alarm_explicit_field(rtype, field) {
5740 self.explicit_alarm_limits(rtype)
5741 } else if link_backed.is_some() {
5742 // `dbGetAlarmLimits` on the backing link; `dbAccess.c:294`'s
5743 // four NaN stand when it supplies nothing.
5744 link_meta
5745 .and_then(|m| m.alarm_limits)
5746 .map(|(lolo, low, high, hihi)| (hihi, high, low, lolo))
5747 .unwrap_or_else(crate::server::recgbl::rec_gbl_get_alarm_double)
5748 } else {
5749 crate::server::recgbl::rec_gbl_get_alarm_double()
5750 };
5751 // The four alarm limits live on DisplayInfo because that mirrors
5752 // C's `dbr_gr_double` packing, which the CA encoder depends on.
5753 // Minting it here is safe: every other DisplayInfo field defaults
5754 // to the same value the `None` path already served.
5755 let d = snap.display.get_or_insert_with(Default::default);
5756 d.upper_alarm_limit = hihi;
5757 d.upper_warning_limit = high;
5758 d.lower_warning_limit = low;
5759 d.lower_alarm_limit = lolo;
5760 }
5761 }
5762
5763 /// The four limits C's `get_alarm_double` serves for the fields its rset
5764 /// lists — [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields).
5765 ///
5766 /// Read through [`Self::resolve_field`], the same unified accessor C's
5767 /// `prec->hihi` is. The port stores the eight alarm fields in one of two
5768 /// disjoint homes — `common.analog_alarm` for the types with the analog
5769 /// ladder (`ai`/`ao`/`calc`/…), the record's own struct for the types
5770 /// without it (`dfanout`/`sel`) — and `resolve_field` spans both. Reading
5771 /// the ladder slot directly instead would answer NaN for every `dfanout`
5772 /// and `sel` no matter how its HIHI/HHSV were set, because those two types
5773 /// have no slot at all.
5774 fn explicit_alarm_limits(&self, rtype: &str) -> (f64, f64, f64, f64) {
5775 let limit = |name: &str| {
5776 self.resolve_field(name)
5777 .and_then(|v| v.to_f64())
5778 .unwrap_or(0.0)
5779 };
5780 // The raw stored ordinal, NOT clamped to 0..=3: C tests `prec->hhsv`
5781 // for NONZERO, so an out-of-range severity still enables its limit.
5782 let severity = |name: &str| {
5783 self.resolve_field(name)
5784 .and_then(|v| v.to_f64())
5785 .unwrap_or(0.0) as i16
5786 };
5787 match super::record_trait::alarm_val_arm(rtype) {
5788 super::record_trait::AlarmValArm::Unconditional => {
5789 (limit("HIHI"), limit("HIGH"), limit("LOW"), limit("LOLO"))
5790 }
5791 super::record_trait::AlarmValArm::Gated => (
5792 gated(severity("HHSV"), limit("HIHI")),
5793 gated(severity("HSV"), limit("HIGH")),
5794 gated(severity("LSV"), limit("LOW")),
5795 gated(severity("LLSV"), limit("LOLO")),
5796 ),
5797 }
5798 }
5799
5800 /// The fields C's **`get_control_double`** answers with the record's own
5801 /// cached limits, rather than letting them fall to the `default:` arm.
5802 ///
5803 /// "VAL plus the seven alarm bands" is one type's list, not the shared
5804 /// one: it holds for `ai` (`aiRecord.c:267-288`), `ao`, `calc`, `calcout`,
5805 /// `longin`, `longout`, `int64in`, `int64out` and `sub`
5806 /// (`subRecord.c:272-292`) — the `_` arm — and for no other type. Every
5807 /// list below is transcribed from that type's own rset:
5808 ///
5809 /// * `aSub` (`aSubRecord.c:372-376`) is a bare `recGblGetControlDouble`:
5810 /// it lists NOTHING, VAL included.
5811 /// * `seq` (`seqRecord.c:342-353`) lists only DLYn and `bo`
5812 /// (`boRecord.c:310-318`) only HIGH — and both answer a LITERAL rather
5813 /// than the cache, so they come from
5814 /// [`Record::field_metadata_override`] (which runs after this routing
5815 /// and wins over the `default:` arm). Nothing of these two types keeps
5816 /// the cache, VAL included.
5817 /// * `dfanout` (`dfanoutRecord.c:197-213`) lists VAL and the three
5818 /// latches but NOT the four bands.
5819 /// * `sel` (`selRecord.c:203-235`) lists the eight plus `A`..`L` /
5820 /// `LA`..`LL`; `acalcout`/`scalcout` (`aCalcoutRecord.c:793-822`,
5821 /// `sCalcoutRecord.c:653-682`) list VAL and the four bands but NOT the
5822 /// latches, plus `A`..`L` / `PA`..`PL`.
5823 /// * `epid` (`epidRecord.c:263-287`) lists VAL, the four bands and CVAL on
5824 /// HOPR/LOPR; `motor` (`motorRecord.cc:3263-3308`) lists VAL and RBV on
5825 /// HLM/LLM.
5826 /// * the array types (`waveformRecord.c:268-289`, `aaiRecord.c:287-304`,
5827 /// `aaoRecord.c:292-309`, `compressRecord.c:487-502`,
5828 /// `histogramRecord.c:458-475`, `subArrayRecord.c:258-287`) list VAL
5829 /// alone on the cache — their other listed fields answer computed spans,
5830 /// so those too come from [`Record::field_metadata_override`].
5831 ///
5832 /// Fields whose listed case answers something OTHER than the record's
5833 /// cached limits are deliberately absent — `motor`'s DVAL/DRBV (DHLM/DLLM)
5834 /// and `epid`'s OVAL/P/I/D (DRVH/DRVL) have no override yet and so still
5835 /// take the `default:` arm.
5836 ///
5837 /// **Not** the other two slots' lists — see [`Self::alarm_explicit_field`]
5838 /// (smaller) and [`Self::graphic_explicit_field`] (larger, and cut short
5839 /// for different types). C's three rset arms are separate switches over
5840 /// separate field lists, so one shared predicate could only ever be right
5841 /// for one of them.
5842 fn control_explicit_field(rtype: &str, field: &str) -> bool {
5843 // The types that list nothing the cache can answer, VAL included.
5844 //
5845 // `tableRecord.c:795-810` and `mcaRecord.c:929-943` are the same
5846 // shape as `aSub`: a small named set (table's six user coordinates
5847 // `AX`..`Z`, mca's dead `BPTR` arm) and `recGblGetControlDouble` for
5848 // everything else — VAL and the alarm bands included. Both named sets
5849 // answer a literal rather than the record's HOPR/LOPR, so they come
5850 // from [`Record::field_metadata_override`] and nothing here keeps the
5851 // cache.
5852 if matches!(rtype, "aSub" | "seq" | "bo" | "table" | "mca") {
5853 return false;
5854 }
5855 if crate::server::database::is_value_field(field) {
5856 return true;
5857 }
5858 let f = field.to_ascii_uppercase();
5859 let bands: &[&str] = match rtype {
5860 "dfanout" => &["LALM", "ALST", "MLST"],
5861 "acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
5862 "waveform" | "aai" | "aao" | "compress" | "histogram" | "subArray" | "motor" => &[],
5863 _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
5864 };
5865 if bands.contains(&f.as_str()) {
5866 return true;
5867 }
5868 match rtype {
5869 // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
5870 "sel" => Self::calc_arg_field(&f, 12),
5871 "acalcout" | "scalcout" => {
5872 Self::calc_arg_field(&f, 12)
5873 || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
5874 }
5875 "epid" => f == "CVAL",
5876 "motor" => f == "RBV",
5877 _ => false,
5878 }
5879 }
5880
5881 /// The fields C's **`get_alarm_double`** lists explicitly — **VAL alone**,
5882 /// not the eight [`Self::control_explicit_field`] lists.
5883 ///
5884 /// Transcribed from every rset in base that supplies the slot. Most are a
5885 /// bare `if (dbGetFieldIndex(paddr) == indexof(VAL))` with every other
5886 /// field falling to `recGblGetAlarmDouble` (`recGbl.c:155-162`, four NaN):
5887 /// `aiRecord.c:294`, `aoRecord.c:368`, `dfanoutRecord.c:218`,
5888 /// `int64inRecord.c:239`, `int64outRecord.c:283`, `longinRecord.c:244`,
5889 /// `longoutRecord.c:300`, `selRecord.c:241`. Three do NOT have that shape
5890 /// and reach the same NaN for a band field the long way —
5891 /// `calcRecord.c:257-280`, `calcoutRecord.c:532-555` and
5892 /// `subRecord.c:294-317` hoist the index into a `fieldIndex` local, test
5893 /// VAL, and otherwise try `get_linkNumber` first, so only a field that is
5894 /// neither VAL nor an `INPx` slot falls through to `recGblGetAlarmDouble`
5895 /// (`subRecord.c:313-314`); an `INPx` field takes that LINK's alarm limits
5896 /// through `dbGetAlarmLimits`, not the four NaN.
5897 ///
5898 /// So `.HIHI` serves VAL's *control* limits but NOT VAL's *alarm* limits —
5899 /// the band fields' four alarm limits are the recGbl NaN. Routing both
5900 /// slots off one VAL-class predicate is what put the record's own
5901 /// valueAlarm limits on all eight.
5902 ///
5903 /// Which fields each type lists — and the fact that some list none, and
5904 /// that `motor` lists two — is one per-type table,
5905 /// [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields);
5906 /// what that listed arm ANSWERS is its twin,
5907 /// [`alarm_val_arm`](super::record_trait::alarm_val_arm). Keeping the two
5908 /// questions in one place is what lets this predicate stay a pure
5909 /// membership test.
5910 fn alarm_explicit_field(rtype: &str, field: &str) -> bool {
5911 super::record_trait::alarm_explicit_fields(rtype)
5912 .iter()
5913 .any(|f| field.eq_ignore_ascii_case(f))
5914 }
5915
5916 /// `A`..`A+n-1` (a single letter) or `LA`..`LA+n-1` — C's calc-family
5917 /// argument fields, addressed by index range rather than by name.
5918 ///
5919 /// `calcRecord.c:161-167` / `calcoutRecord.c:417-423` test
5920 /// `idx >= indexof(A) && idx < indexof(A) + CALCPERFORM_NARGS`, and the dbd
5921 /// declares those `CALCPERFORM_NARGS` fields contiguously as the single
5922 /// letters `A`..`U` (`postfix.h:29` = 21, `calcRecord.dbd.pod:801-985`), so
5923 /// the index range and the letter range are the same set.
5924 fn calc_arg_field(field: &str, nargs: u8) -> bool {
5925 let last = b'A' + nargs - 1;
5926 match field.as_bytes() {
5927 [c] => c.is_ascii_uppercase() && *c <= last,
5928 [b'L', c] => c.is_ascii_uppercase() && *c <= last,
5929 _ => false,
5930 }
5931 }
5932
5933 /// The fields C's **`get_graphic_double`** answers with the record's own
5934 /// `HOPR`/`LOPR` — which is exactly what the VAL metadata cache already
5935 /// holds, so routing must leave them on it.
5936 ///
5937 /// The third membership question, and a third distinct set: the alarm arm
5938 /// lists VAL alone and the control arm lists the eight, but graphic lists
5939 /// the eight PLUS a per-type tail, and two types cut it short.
5940 ///
5941 /// * base analog (`aiRecord.c:244-265`, `aoRecord.c:316-339`,
5942 /// `calcRecord.c:187-212`, `calcoutRecord.c:452-484`,
5943 /// `subRecord.c:242-270`, `selRecord.c:181-201`,
5944 /// `dfanoutRecord.c:181-195`, `longinRecord.c:190-204`,
5945 /// `longoutRecord.c`, `int64inRecord.c:196-210`, `int64outRecord.c`):
5946 /// the eight.
5947 /// * `acalcout`/`scalcout` (`aCalcoutRecord.c:1046`, `sCalcoutRecord.c:906`)
5948 /// list only VAL/HIHI/HIGH/LOW/LOLO — NOT LALM/ALST/MLST — plus the
5949 /// `A`..`L` and `PA`..`PL` ranges.
5950 /// * `sel` (`selRecord.c:193-196`) also lists `A`..`L` / `LA`..`LL`, via a
5951 /// GCC case range. It has no link arm at all, so its args are HOPR/LOPR
5952 /// where calc's identically-named ones are link-backed.
5953 /// * the SVAL family (`aiRecord.c:253`, `longinRecord.c`,
5954 /// `int64inRecord.c:205`), `ao`'s `OVAL`/`PVAL`/`IVOV`
5955 /// (`aoRecord.c:322-338`), and `compress`'s `IHIL`/`ILIL`
5956 /// (`compressRecord.c:474-476`).
5957 ///
5958 /// Fields whose graphic case answers something OTHER than HOPR/LOPR are
5959 /// NOT here — they cannot keep the cache and are supplied by
5960 /// [`Record::field_metadata_override`] instead (`histogram` WDTH,
5961 /// `subArray`/`waveform`/`aai`/`aao` index fields, `seq` DLYn,
5962 /// `calcout` ODLY).
5963 fn graphic_explicit_field(rtype: &str, field: &str) -> bool {
5964 // The two types that do not list VAL. Neither switch is keyed on
5965 // VAL at all: `seqRecord.c:282-297` keys on `index - indexof(DLY0)`,
5966 // so every field BELOW DLY0 — VAL included — reaches
5967 // `recGblGetGraphicDouble`; `aSubRecord.c:350-368` keys on the link
5968 // number, and VAL is neither an inlink nor an outlink, so it falls out
5969 // having written nothing (the `graphic_default_arm` Seed).
5970 //
5971 // Measured: `SEQ.VAL` served display 0/0 — the empty VAL cache — where
5972 // C serves the DBF_LONG range.
5973 //
5974 // `tableRecord.c:778-792` and `mcaRecord.c:910-927` key on a named set
5975 // too — table's `AX`..`Z` window, mca's `DTIM`/`IDTIM` percent scale
5976 // and dead `BPTR` arm — and hand every other field, VAL included, to
5977 // `recGblGetGraphicDouble`. Both named sets answer literals through
5978 // [`Record::field_metadata_override`], so neither type keeps the cache.
5979 if matches!(rtype, "seq" | "aSub" | "table" | "mca") {
5980 return false;
5981 }
5982 if crate::server::database::is_value_field(field) {
5983 return true;
5984 }
5985 let f = field.to_ascii_uppercase();
5986 let bands: &[&str] = match rtype {
5987 "acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
5988 // `swaitRecord.c:597-606` is a bare `pfield == &pwait->val` test,
5989 // so its `ALST`/`MLST` take `recGblGetGraphicDouble` — and swait
5990 // has no HIHI/HIGH/LOW/LOLO to ask about.
5991 "swait" => &[],
5992 _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
5993 };
5994 if bands.contains(&f.as_str()) {
5995 return true;
5996 }
5997 match rtype {
5998 "ai" | "longin" | "int64in" => f == "SVAL",
5999 "ao" => matches!(f.as_str(), "OVAL" | "PVAL" | "IVOV"),
6000 "compress" => matches!(f.as_str(), "IHIL" | "ILIL"),
6001 // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
6002 "sel" => Self::calc_arg_field(&f, 12),
6003 // A..L and PA..PL, both to HOPR/LOPR.
6004 "acalcout" | "scalcout" => {
6005 Self::calc_arg_field(&f, 12)
6006 || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
6007 }
6008 // `epidRecord.c:238-248` names CVAL alongside VAL and the four
6009 // bands — the same list its `get_control_double` (`:263-273`) has
6010 // and this predicate did not.
6011 "epid" => f == "CVAL",
6012 _ => false,
6013 }
6014 }
6015
6016 /// The fields C's **`get_precision`** answers without reaching
6017 /// `recGblGetPrec` — the fourth membership question, and a fourth distinct
6018 /// set.
6019 ///
6020 /// Every `get_precision` in base and in the ported modules is the same
6021 /// two-part body: name some fields and answer them outright, hand the rest
6022 /// to `recGblGetPrec` (`recGbl.c:119-144`). For a field that can carry a
6023 /// precision at all — `dbAccess.c:388-389` gates `DBR_PRECISION` on
6024 /// `DBF_FLOAT`/`DBF_DOUBLE` — that shared tail does exactly one thing:
6025 /// clamp an out-of-range `PREC` to 15. So this predicate is what decides
6026 /// whether `caput REC.PREC 20` reaches a client as 20 or as 15, and the
6027 /// two answers differ per FIELD within one record: `ai.VAL` returns before
6028 /// the tail and serves 20, `ai.HOPR` falls into it and serves 15
6029 /// (`aiRecord.c:234-242`).
6030 ///
6031 /// The literal arms (`bo.HIGH`, `seq.DLYn`, `calcout.ODLY`,
6032 /// `histogram.SDEL`, `motor.VERS`, …) need no entry: they are
6033 /// [`Record::field_metadata_override`]s, which run after this and win, and
6034 /// every literal C uses is already inside `0..=15`.
6035 ///
6036 /// `link_supplied` is `dbGetPrecision`'s status on the backing link, and
6037 /// only `seq` reads it — see the `link_backed` arm.
6038 fn precision_explicit_field(
6039 rtype: &str,
6040 field: &str,
6041 link_backed: bool,
6042 link_supplied: bool,
6043 ) -> bool {
6044 match rtype {
6045 // No `recGblGetPrec` in the body at all: every field keeps PREC,
6046 // `ODLY` its literal 3 (`swaitRecord.c:583-595`).
6047 "swait" => return true,
6048 // `if (fieldIndex == VERS) 2; else if (fieldIndex >= VAL) prec;
6049 // else recGblGetPrec(...) /* Field is in dbCommon */`
6050 // (`transformRecord.c:752-767`, `scalerRecord.c:728-741`,
6051 // `tableRecord.c:814-828`). Only fields BELOW `VAL` — dbCommon —
6052 // reach the tail, and dbCommon declares no `DBF_FLOAT`/`DBF_DOUBLE`
6053 // field, so nothing that can be served ever gets there.
6054 "transform" | "scaler" | "table" => return true,
6055 // The same split inverted: `if (pfield < &pR->val) return 0;` then
6056 // `recGblGetPrec` (`sseqRecord.c:810-822`). Here it is the RECORD's
6057 // own fields — every `DLYn`, i.e. everything that can be served —
6058 // that reaches the tail, and the exempt half is the dbCommon one
6059 // that cannot.
6060 "sseq" => return false,
6061 // Falls through on every field, `DLY` included: it takes `DPREC`
6062 // instead of `PREC` and is clamped anyway
6063 // (`throttleRecord.c:451-464`).
6064 "throttle" => return false,
6065 _ => {}
6066 }
6067 if link_backed {
6068 // `if (linkNumber >= 0) { if (dbGetPrecision(...) == 0) *p = ...; }
6069 // else recGblGetPrec(...)` — the link arm returns whether or not
6070 // the link answered (`calcRecord.c:194-201`,
6071 // `calcoutRecord.c:461-468`, `subRecord.c:231-238`,
6072 // `aSubRecord.c:330-346`).
6073 //
6074 // `seq` is the exception, and it is why this takes a second
6075 // argument: its `case 2:` returns ONLY when `dbGetPrecision`
6076 // succeeded, and a `DOn` over a constant `DOLn` falls out of the
6077 // switch into the shared tail (`seqRecord.c:310-317`).
6078 return if rtype == "seq" { link_supplied } else { true };
6079 }
6080 if crate::server::database::is_value_field(field) {
6081 // `*precision = prec->prec; if (VAL) return 0;` — the common
6082 // shape (`aiRecord.c:238-239`, `aaiRecord.c:262-264`,
6083 // `aaoRecord.c:267-269`, `aoRecord.c:304-312`,
6084 // `calcRecord.c:190-192`, `calcoutRecord.c:457-459`,
6085 // `compressRecord.c:464-466`, `dfanoutRecord.c:169-171`,
6086 // `selRecord.c:152-155`, `subArrayRecord.c:221-223`,
6087 // `subRecord.c:227-229`, `waveformRecord.c:239-241`,
6088 // `sCalcoutRecord.c:616-618`, `aCalcoutRecord.c:756-758`,
6089 // `epidRecord.c:230-233`).
6090 //
6091 // The five that do NOT name VAL: `aSub` keys on the link number
6092 // only and VAL is neither an inlink nor an outlink
6093 // (`aSubRecord.c:330-346`); `mca` names `BPTR` and the four
6094 // calibration fields (`mcaRecord.c:898-905`); `seq` keys on
6095 // `index - indexof(DLY0)`, leaving VAL below the switch
6096 // (`seqRecord.c:305-317`); `histogram`'s switch has no VAL case
6097 // (`histogramRecord.c:423-436`); `motor` reaches the tail from
6098 // `default:` (`motorRecord.cc:3319-3335`).
6099 return !matches!(rtype, "aSub" | "mca" | "seq" | "histogram" | "motor");
6100 }
6101 let f = field.to_ascii_uppercase();
6102 match rtype {
6103 // `case VAL: case OVAL: case PVAL: break;` (`aoRecord.c:305-312`).
6104 "ao" => matches!(f.as_str(), "OVAL" | "PVAL"),
6105 // `if (fieldIndex == VAL || fieldIndex == CVAL) return 0;`
6106 // (`epidRecord.c:231-232`).
6107 "epid" => f == "CVAL",
6108 // The five cases that answer `prec->prec`
6109 // (`histogramRecord.c:424-430`). `SDEL` is the sixth case and a
6110 // literal, so the override covers it. Note that histogram's tail
6111 // gets an UNSEEDED `precision` — the record never assigns
6112 // `prec->prec` before the switch — so C answers `dbAccess.c:387`'s
6113 // zeroed buffer there, not a clamped PREC; `SDLY` is histogram's
6114 // only such field and carries its own `Some(0)` override.
6115 "histogram" => matches!(f.as_str(), "ULIM" | "LLIM" | "SGNL" | "SVAL" | "WDTH"),
6116 // `BPTR` returns, and the four calibration fields answer a literal
6117 // 6 (`mcaRecord.c:898-905`).
6118 "mca" => matches!(f.as_str(), "BPTR" | "CALO" | "CALS" | "CALQ" | "TTH"),
6119 // `case RRBV: case RMP: case REP: *precision = 0; break;` and
6120 // `case VERS: *precision = 2; break;` — both `break` past the
6121 // switch to the bare `return`, never to `recGblGetPrec`
6122 // (`motorRecord.cc:3322-3330`).
6123 "motor" => matches!(f.as_str(), "RRBV" | "RMP" | "REP" | "VERS"),
6124 // `sel` is deliberately absent: its `A`..`L` / `LA`..`LL` loop
6125 // compares `paddr->pfield` against `&pvalue` and `&plvalue` — the
6126 // addresses of the two LOCAL pointers, not the fields they walk
6127 // (`selRecord.c:159-160`) — so the test never matches and every
6128 // `sel` argument reaches `recGblGetPrec`. Transcribed as C
6129 // behaves, not as it reads.
6130 _ => false,
6131 }
6132 }
6133
6134 /// The field's type as the **dbd declares it**, which is the only type
6135 /// `recGblGetPrec` / `getMaxRangeValues` ever see.
6136 ///
6137 /// C reads `pdbFldDes->field_type` (`recGbl.c:127`, `:151`, `:169`) — the
6138 /// STATIC descriptor — so a `cvt_dbaddr` retype (the port's
6139 /// `runtime_typed`, DBF_NOACCESS in the dbd) never reaches the switch and
6140 /// the switch has no case for it. `None` reproduces that: no case, no
6141 /// write.
6142 fn static_field_type(&self, field: &str) -> Option<crate::types::DbFieldType> {
6143 let desc = self.field_desc(field)?;
6144 (!desc.runtime_typed).then_some(desc.dbf_type)
6145 }
6146
6147 /// `recGblGetGraphicDouble` / `recGblGetControlDouble` for `field` — the
6148 /// same `getMaxRangeValues` table both C entry points share
6149 /// (`recGbl.c:146-171`). `None` where C's switch has no case (STRING,
6150 /// MENU, DEVICE, NOACCESS, links), which writes nothing.
6151 ///
6152 /// `declared_dbf` is C's `pdbFldDes->field_type` verbatim. Deciding
6153 /// menu-ness from `desc.menu` instead asked whether the field carries its
6154 /// own inline choice list, which `SCAN` and `DTYP` do not — their choices
6155 /// come from the scan table and the device registry — so both reported a
6156 /// `DBF_USHORT` range of 65535/0 to `gft` where C reports 0/0.
6157 fn rec_gbl_range_for(&self, field: &str) -> Option<(f64, f64)> {
6158 let desc = self.field_desc(field)?;
6159 crate::server::recgbl::rec_gbl_get_graphic_double(desc.declared_dbf)
6160 }
6161
6162 /// Notify subscribers from a snapshot (call outside lock).
6163 /// Each entry carries its own posting mask: only subscribers whose
6164 /// mask intersects that field's mask are notified, and the delivered
6165 /// [`MonitorEvent`] reports that intersection — C
6166 /// `db_post_events(prec, &field, mask)` per-field granularity, then
6167 /// `pLog->mask = caEventMask & pevent->select` per subscriber.
6168 ///
6169 /// `backing` is the link metadata the process cycle resolved for this
6170 /// batch, at its own no-lock-held point. See [`Self::make_monitor_snapshot`]
6171 /// for why it has no default.
6172 pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot, backing: LinkBacking<'_>) {
6173 use crate::server::database::filters::FilteredMonitorEvent;
6174
6175 // Same ambient-origin inheritance as `notify_field_with_origin`:
6176 // a process cycle driven by an in-process writer's put tags its
6177 // posts with the writer's origin, so the writer's own filtered
6178 // subscriptions do not hear its cascade. 0 outside any scope.
6179 let origin = ambient_write_origin();
6180
6181 for (field, value, posting_mask) in &snapshot.changed_fields {
6182 let posting_mask = *posting_mask;
6183 if let Some(subs) = self.subscribers.get(field) {
6184 // Build a full snapshot once per field (with display
6185 // metadata) and hand every subscriber a reference to that one
6186 // snapshot — C posts the fixed-size `db_field_log` and reads
6187 // the wide value by reference at delivery (`camessage.c:516`),
6188 // so a per-subscriber deep copy of an array value is a port
6189 // deviation, not parity.
6190 let mon_snap = Arc::new(self.make_monitor_snapshot(field, value.clone(), backing));
6191 for sub in subs {
6192 // Paused subscriber (`db_event_disable`): suppress at
6193 // the source — no delivery, no coalesce.
6194 if !sub.active {
6195 continue;
6196 }
6197 // Gate and narrow in one step through
6198 // `Subscriber::delivered_mask`, which owns C's
6199 // twice-used `caEventMask & pevent->select`. An empty
6200 // posting mask means nothing changed and ands to zero,
6201 // so it skips there rather than needing a check here.
6202 if let Some(mask) = sub.delivered_mask(posting_mask) {
6203 let event = MonitorEvent {
6204 snapshot: mon_snap.clone(),
6205 origin,
6206 mask,
6207 };
6208 // Server-side filter chain (3.15.7). Empty chain
6209 // is identity, so no behaviour change for the
6210 // common no-filter case.
6211 let filtered = if sub.filters.is_empty() {
6212 Some(event)
6213 } else {
6214 sub.filters
6215 .apply(FilteredMonitorEvent::new(event))
6216 .map(|fe| fe.event)
6217 };
6218 let Some(event) = filtered else {
6219 continue;
6220 };
6221 // C `db_queue_event_log`: append, or replace this
6222 // monitor's last queued entry in place when the queue
6223 // is in flow control or nearly full. The queue owns
6224 // that decision and counts the displaced value.
6225 sub.post(event);
6226 }
6227 }
6228 }
6229 }
6230 }
6231
6232 /// Notify subscribers of a specific field, filtering by event mask.
6233 ///
6234 /// The last wrapper that still answers for its callers: `none()` here is a
6235 /// claim that no caller of this function names a link-backed field, and it
6236 /// is made once for 25 production call sites rather than at each of them.
6237 /// [`Self::notify_field_backed`] is the form for a caller that cannot make
6238 /// that claim.
6239 pub fn notify_field(&mut self, field: &str, mask: crate::server::recgbl::EventMask) {
6240 self.notify_field_with_origin(field, mask, 0, LinkBacking::none());
6241 }
6242
6243 /// [`Self::notify_field`] for a poster that may name a link-backed field
6244 /// and has resolved its backing.
6245 pub fn notify_field_backed(
6246 &mut self,
6247 field: &str,
6248 mask: crate::server::recgbl::EventMask,
6249 backing: LinkBacking<'_>,
6250 ) {
6251 self.notify_field_with_origin(field, mask, 0, backing);
6252 }
6253
6254 /// C `db_post_events(precord, NULL, DBE_ALARM)`: post a record-wide
6255 /// alarm event. Delivers to every subscriber on any field whose mask
6256 /// includes DBE_ALARM, each carrying its own monitored field's current
6257 /// value (the per-field `notify_field` already filters by mask
6258 /// intersection). Used by the alarm-acknowledge (ACKT/ACKS) put path so
6259 /// an alarm-mask monitor on any field observes the acknowledgement.
6260 pub fn notify_record_alarm(&mut self, backing: LinkBacking<'_>) {
6261 // Every subscribed field, so a client monitoring a link-backed one
6262 // (`CALC.A`) is in the set — this poster takes a backing for that
6263 // reason and not because the alarm itself is link-backed.
6264 let fields: Vec<String> = self.subscribers.keys().cloned().collect();
6265 for field in fields {
6266 self.notify_field_backed(&field, crate::server::recgbl::EventMask::ALARM, backing);
6267 }
6268 }
6269
6270 /// Notify subscribers with an origin tag for self-write filtering.
6271 ///
6272 /// This is C `db_post_events(precord, pfield, mask)` for one field, and —
6273 /// per the `last_posted` contract — the poster that advances the
6274 /// already-published value when `mask` carries a value class. Taking
6275 /// `&mut self` is what makes that unbypassable: there is no way to publish
6276 /// a field's value through the framework without the change detector
6277 /// learning that it was published.
6278 ///
6279 /// `backing` is the link metadata the put path resolved for this post, at
6280 /// its own no-lock-held point. See [`Self::make_monitor_snapshot`] for why
6281 /// it has no default.
6282 pub fn notify_field_with_origin(
6283 &mut self,
6284 field: &str,
6285 mask: crate::server::recgbl::EventMask,
6286 origin: u64,
6287 backing: LinkBacking<'_>,
6288 ) {
6289 use crate::server::database::filters::FilteredMonitorEvent;
6290 // A poster that carries no origin of its own inherits the ambient
6291 // one (0 outside any scope): this is how every post inside an
6292 // SNL writer's synchronous put+process cascade gets the writer's
6293 // tag without threading a parameter through the whole processing
6294 // machinery. An explicit origin always wins.
6295 let origin = if origin != 0 {
6296 origin
6297 } else {
6298 ambient_write_origin()
6299 };
6300 // A value-class post publishes the field to its DBE_VALUE/DBE_LOG
6301 // subscribers, exactly as C's `dbPut` does for the put field
6302 // (dbAccess.c:1414) — record it so the next process cycle's
6303 // change-detection loop does not publish the same value a second
6304 // time. An alarm-only / property-only post publishes no value, so it
6305 // leaves the map alone.
6306 let publishes_value = mask.intersects(
6307 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
6308 );
6309 let mut posted: Option<EpicsValue> = None;
6310 if let Some(subs) = self.subscribers.get(field) {
6311 if let Some(value) = self.resolve_field(field) {
6312 if publishes_value {
6313 posted = Some(value.clone());
6314 }
6315 let mon_snap = Arc::new(self.make_monitor_snapshot(field, value, backing));
6316 for sub in subs {
6317 // Paused subscriber (`db_event_disable`): suppress at
6318 // the source — no delivery, no coalesce.
6319 if !sub.active {
6320 continue;
6321 }
6322 // Same single owner as the snapshot path: gate and
6323 // narrow are one operation (C `dbEvent.c:896-900`).
6324 if let Some(mask) = sub.delivered_mask(mask) {
6325 let event = MonitorEvent {
6326 snapshot: mon_snap.clone(),
6327 origin,
6328 mask,
6329 };
6330 // Server-side filter chain (3.15.7). Empty
6331 // chain (the default for every subscriber
6332 // until a `.{filter:opts}` PV-name suffix
6333 // parser wires one in) is the identity, so
6334 // existing subscribers see no behaviour
6335 // change. A filter returning `None` silences
6336 // this event for this subscriber only.
6337 let filtered = if sub.filters.is_empty() {
6338 Some(event)
6339 } else {
6340 sub.filters
6341 .apply(FilteredMonitorEvent::new(event))
6342 .map(|fe| fe.event)
6343 };
6344 let Some(event) = filtered else {
6345 continue;
6346 };
6347 // Same single post owner as the snapshot path.
6348 sub.post(event);
6349 }
6350 }
6351 }
6352 }
6353 // The value is now published to this field's value-class subscribers:
6354 // hand it to the `last_posted` owner so the change detector does not
6355 // publish it again. Delivery to any individual subscriber may have
6356 // been filtered out, exactly as C's `db_post_events` may find an empty
6357 // `mlis` — C still leaves `monitor()`'s `*_lst` state advanced by the
6358 // cycle that ran, so the post, not the delivery, is what counts.
6359 if let Some(value) = posted {
6360 self.record_value_post(field, value);
6361 }
6362 }
6363
6364 /// Add a subscriber for a specific field. Returns `None` when the
6365 /// per-field subscriber cap (`EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`)
6366 /// is reached. the parallel cap on `ProcessVariable`
6367 /// defends against a misbehaving client opening many
6368 /// MONITOR ops against one shared PV; the same defence is needed
6369 /// for record fields, which the CA server's
6370 /// `ChannelTarget::RecordField` path lands on.
6371 pub fn add_subscriber(
6372 &mut self,
6373 field: &str,
6374 sid: u32,
6375 data_type: DbFieldType,
6376 mask: u16,
6377 ) -> Option<EventReader> {
6378 self.add_subscriber_on(&EventUser::new(), field, sid, data_type, mask)
6379 }
6380
6381 /// Add a field subscriber whose events queue on `user`'s event queue —
6382 /// C `db_add_event` with the circuit's `event_user` as context. Every
6383 /// subscription on one CA circuit shares that queue and therefore its
6384 /// `nDuplicates`, so a duplicate queued for one of them releases the
6385 /// EVENTS_OFF drain for all of them (`dbEvent.c:947`). In-process consumers
6386 /// use [`Self::add_subscriber`], which gives each its own `event_user`.
6387 pub fn add_subscriber_on(
6388 &mut self,
6389 user: &EventUser,
6390 field: &str,
6391 sid: u32,
6392 data_type: DbFieldType,
6393 mask: u16,
6394 ) -> Option<EventReader> {
6395 let cap = crate::server::pv::max_subscribers_per_pv();
6396 // A destroyed record takes no new monitor, so `destroyed => no
6397 // subscribers` survives a CREATE_CHAN + EVENT_ADD that races the
6398 // removal. Both are `&mut self`, so there is no window between them.
6399 if self.destroyed {
6400 return None;
6401 }
6402 let field_str = field.to_string();
6403 let bucket = self.subscribers.entry(field_str.clone()).or_default();
6404 // Reap rows whose consumer is gone before
6405 // counting against the cap. A record field whose value
6406 // never changes (e.g. a quasi-static catalog field) never
6407 // triggers `notify_field_with_origin`'s retain-filter, so
6408 // a long-lived subscribe-disconnect storm could pin the
6409 // bucket at `cap` worth of dead rows and lock out
6410 // genuine new subscribers.
6411 bucket.retain(|s| !s.is_closed());
6412 if bucket.len() >= cap {
6413 tracing::warn!(
6414 record = %self.name,
6415 field = %field_str,
6416 live = bucket.len(),
6417 cap,
6418 "record field subscriber cap reached, refusing add_subscriber"
6419 );
6420 return None;
6421 }
6422 let (sink, reader) = crate::server::event_queue::attach(user, sid);
6423 bucket.push(Subscriber {
6424 sid,
6425 data_type,
6426 mask,
6427 sink,
6428 filters: crate::server::database::filters::FilterChain::new(),
6429 active: true,
6430 });
6431 // Initialize last_posted with current value so the first process cycle
6432 // doesn't treat it as "changed" (the initial value is already sent
6433 // to the client as part of EVENT_ADD response).
6434 if !self.last_posted.contains_key(&field_str) {
6435 if let Some(val) = self.resolve_field(&field_str) {
6436 self.last_posted.insert(field_str, val);
6437 }
6438 }
6439 Some(reader)
6440 }
6441
6442 /// Attach a filter to the most recently added subscriber for
6443 /// `field`. Returns `false` when no subscriber exists yet on that
6444 /// field (call `add_subscriber` first). The CA / PVA channel-name
6445 /// parsers will use this once `.{filter:opts}` syntax is wired.
6446 /// Tests can also use it directly to compose filter chains.
6447 pub fn attach_filter_to_last_subscriber(
6448 &mut self,
6449 field: &str,
6450 filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
6451 ) -> bool {
6452 if let Some(bucket) = self.subscribers.get_mut(field) {
6453 if let Some(sub) = bucket.last_mut() {
6454 sub.filters.push(filter);
6455 return true;
6456 }
6457 }
6458 false
6459 }
6460
6461 /// Remove a subscriber by subscription ID from all fields.
6462 pub fn remove_subscriber(&mut self, sid: u32) {
6463 for subs in self.subscribers.values_mut() {
6464 subs.retain(|s| s.sid != sid);
6465 }
6466 }
6467
6468 /// Destroy this record: drop every field monitor and refuse every future
6469 /// one. The record-backed half of the rule
6470 /// [`crate::server::pv::ProcessVariable::destroy`] states for simple PVs,
6471 /// so one sweep in a server closes both kinds of channel. Returns `true`
6472 /// for the call that performed the transition.
6473 pub(crate) fn destroy(&mut self) -> bool {
6474 let first = !self.destroyed;
6475 self.destroyed = true;
6476 self.subscribers.clear();
6477 first
6478 }
6479
6480 /// Whether `Self::destroy` has run.
6481 pub fn is_destroyed(&self) -> bool {
6482 self.destroyed
6483 }
6484
6485 /// Pause / resume one subscriber's event flow at the source
6486 /// (`db_event_disable` / `db_event_enable`). `active == false`
6487 /// suppresses every subsequent post to this subscriber, so the record stops
6488 /// doing per-event work for it. Entries already queued stay queued and are
6489 /// still delivered, exactly as in C: `db_event_disable` only unlinks the
6490 /// subscription from the record's monitor list (`dbEvent.c:524-535`) and
6491 /// never reaches into the event queue. No-op if no subscriber has this
6492 /// `sid`. The caller holds the record write lock, so this is exclusive with
6493 /// the read-locked post paths that consult `Subscriber::active`.
6494 pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
6495 for subs in self.subscribers.values_mut() {
6496 for sub in subs.iter_mut() {
6497 if sub.sid == sid {
6498 sub.active = active;
6499 }
6500 }
6501 }
6502 }
6503
6504 /// Clean up subscriber rows whose consumer is gone.
6505 pub fn cleanup_subscribers(&mut self) {
6506 for subs in self.subscribers.values_mut() {
6507 subs.retain(|s| !s.is_closed());
6508 }
6509 }
6510}
6511
6512/// C `recGblCheckDeadband` (recGbl.c:345-370), spelled as C spells it:
6513///
6514/// ```c
6515/// double delta = 0;
6516/// if (finite(newval) && finite(*poldval)) {
6517/// delta = *poldval - newval;
6518/// if (delta < 0.0) delta = -delta;
6519/// }
6520/// else if (!isnan(newval) != !isnan(*poldval) ||
6521/// !isinf(newval) != !isinf(*poldval)) delta = epicsINF;
6522/// else if (isinf(newval) && newval != *poldval) delta = epicsINF;
6523/// if (delta > deadband) { *monitor_mask |= add_mask; *poldval = newval; }
6524/// ```
6525///
6526/// The `delta = 0` initialiser is load-bearing: the pairs no branch matches
6527/// — both NaN, or two same-signed infinities — reach `0 > deadband`, so they
6528/// fire only for a negative deadband and otherwise leave `*poldval` alone.
6529/// That is why the whole rule has to stay a single `delta > deadband` rather
6530/// than a chain of early returns, and why NaN cannot be read as a marker
6531/// here: C compares a NaN `*poldval` against a NaN `newval` by these rules
6532/// and finds them unchanged. `dbnd.c parse_ok` relies on exactly that when it
6533/// seeds its own `last` to `epicsNAN`.
6534///
6535/// `oldval` is `None` for the record types that carry no MLST/ALST cell to
6536/// hold a last-posted value; nothing was posted, so the first post is
6537/// unconditional. C has no such state — its MLST is a plain double the record
6538/// type initialises (0 for `calc` and `sel`, `prec->val` for `ai`,
6539/// aiRecord.c:129-130).
6540pub(crate) fn check_deadband(newval: f64, oldval: Option<f64>, deadband: f64) -> bool {
6541 let Some(oldval) = oldval else {
6542 return true;
6543 };
6544 let delta = if newval.is_finite() && oldval.is_finite() {
6545 (oldval - newval).abs()
6546 } else if newval.is_nan() != oldval.is_nan() || newval.is_infinite() != oldval.is_infinite() {
6547 // One is NaN or +/-inf and the other is not.
6548 f64::INFINITY
6549 } else if newval.is_infinite() && newval != oldval {
6550 // One is +inf, the other -inf.
6551 f64::INFINITY
6552 } else {
6553 0.0
6554 };
6555 delta > deadband
6556}
6557
6558#[cfg(test)]
6559mod device_menu_marking_tests {
6560 use super::*;
6561 use crate::server::records::ai::AiRecord;
6562 use crate::server::records::calc::CalcRecord;
6563 use crate::server::records::mbbo::MbboRecord;
6564
6565 /// C `dbAccess.c:176-179`: a `DBF_DEVICE` field whose record type declares
6566 /// no device support has `pfldDes->ftPvt == NULL` and takes `goto nostrs`,
6567 /// which clears `DBR_ENUM_STRS` — the client is sent NO choice list.
6568 ///
6569 /// `calc` declares no `device()` line, so QSRV2 omits `value.choices` on
6570 /// `CALC.DTYP`. The port used to default the missing menu to `[]` and mark
6571 /// an empty list instead.
6572 #[test]
6573 fn dtyp_of_a_record_type_with_no_device_support_supplies_no_choices() {
6574 let inst = RecordInstance::new("X".into(), CalcRecord::default());
6575 assert!(
6576 super::super::dbd_generated::device_menu("calc").is_none(),
6577 "precondition: calc declares no device() line (C ftPvt == NULL)"
6578 );
6579 assert!(
6580 inst.device_choices().is_none(),
6581 "a record type with no device menu must report None, not an empty list"
6582 );
6583 assert!(
6584 inst.enum_string_form_for("DTYP").is_none(),
6585 "DTYP must supply no enum-string form, so no `value.choices` is marked"
6586 );
6587 }
6588
6589 /// The other side of C's `dbAccess.c:205` comment — *"indicate option data
6590 /// not available. distinct from no_str==0"*. `ai` DOES declare device
6591 /// support, so its menu exists and its choices are served.
6592 #[test]
6593 fn dtyp_of_a_record_type_with_device_support_supplies_its_choices() {
6594 let inst = RecordInstance::new("X".into(), AiRecord::default());
6595 let choices = inst
6596 .device_choices()
6597 .expect("ai declares device() lines, so its menu exists");
6598 assert!(
6599 choices.iter().any(|c| c.as_str_lossy() == "Soft Channel"),
6600 "ai's device menu must carry its declared choices, got {choices:?}"
6601 );
6602 assert!(inst.enum_string_form_for("DTYP").is_some());
6603 }
6604
6605 /// An unset `DTYP` is index 0 on both sides of the distinction — a record
6606 /// type with no device menu has no slot for any DTYP, so the index stays 0
6607 /// rather than panicking or shifting.
6608 #[test]
6609 fn dtyp_index_is_zero_when_the_record_type_has_no_device_menu() {
6610 let inst = RecordInstance::new("X".into(), CalcRecord::default());
6611 assert_eq!(inst.dtyp_index(), 0);
6612 }
6613
6614 /// A downstream crate's registered device menu (asyn's) is merged AFTER the
6615 /// base-declared choices, matching a C fat softIoc that loaded `asyn.dbd`:
6616 /// `mbbo.DTYP` = the three base soft entries then `asynInt32`,
6617 /// `asynUInt32Digital`, in that order. `dtyp_index` reads the merged list,
6618 /// so an `mbbo` bound to `asynInt32` reports index 3 — the wire value C
6619 /// serves — instead of the appended-as-own-slot index the port gave before
6620 /// the menu was known.
6621 #[test]
6622 fn a_registered_device_menu_merges_after_the_base_declared_choices() {
6623 // The list asyn's generated `dbd_generated::DEVICE_MENU_MBBO` carries.
6624 static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
6625 super::super::register_device_menu("mbbo", ASYN_MBBO);
6626
6627 let mut inst = RecordInstance::new("X".into(), MbboRecord::default());
6628 let merged: Vec<String> = inst
6629 .device_choices()
6630 .expect("mbbo declares device() lines")
6631 .iter()
6632 .map(|c| c.as_str_lossy().into_owned())
6633 .collect();
6634 assert_eq!(
6635 merged,
6636 vec![
6637 "Soft Channel",
6638 "Raw Soft Channel",
6639 "Async Soft Channel",
6640 "asynInt32",
6641 "asynUInt32Digital",
6642 ],
6643 "base-declared choices first, asyn-contributed appended in asyn.dbd order"
6644 );
6645
6646 inst.common.dtyp = "asynInt32".into();
6647 assert_eq!(
6648 inst.dtyp_index(),
6649 3,
6650 "an asyn DTYP indexes into the merged menu, not an appended own slot"
6651 );
6652 }
6653
6654 /// The None-vs-empty contract survives the merge: a record type neither
6655 /// base nor any downstream crate contributes a `device()` for (calc) stays
6656 /// `None`, never `Some([])`, even after asyn menus are registered in this
6657 /// process.
6658 #[test]
6659 fn calc_stays_none_after_asyn_menus_are_registered() {
6660 static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
6661 super::super::register_device_menu("mbbo", ASYN_MBBO);
6662
6663 let inst = RecordInstance::new("X".into(), CalcRecord::default());
6664 assert!(
6665 inst.device_choices().is_none(),
6666 "calc declares no device() and gets no contribution — still None"
6667 );
6668 }
6669}
6670
6671#[cfg(test)]
6672mod property_support_owner_tests {
6673 use crate::server::record::record_trait::default_property_support;
6674 use crate::server::snapshot::PropertySupport as P;
6675
6676 /// `sseqRecord.c:124-144` — the rset table NULLs every property slot
6677 /// except `get_precision`:
6678 ///
6679 /// ```c
6680 /// NULL, /* get_units */
6681 /// get_precision, /* get_precision */
6682 /// NULL, /* get_enum_str */
6683 /// NULL, /* get_enum_strs */
6684 /// NULL, /* put_enum_str */
6685 /// NULL, /* get_graphic_double */
6686 /// NULL, /* get_control_double */
6687 /// NULL /* get_alarm_double */
6688 /// ```
6689 ///
6690 /// `sseq` was previously grouped with the full-numeric synApps types, so
6691 /// the port marked six leaves per field that QSRV2 omits entirely.
6692 #[test]
6693 fn sseq_supplies_only_precision() {
6694 assert_eq!(
6695 default_property_support("sseq"),
6696 P {
6697 precision: true,
6698 ..P::NONE
6699 }
6700 );
6701 }
6702
6703 /// A record type the table does not name keeps the permissive
6704 /// `NUMERIC` default rather than silently losing metadata. This is the
6705 /// arm `asyn` used to land on — and why marking had to become a trait
6706 /// method: asyn-rs cannot add a row here.
6707 #[test]
6708 fn an_untranscribed_record_type_keeps_the_permissive_default() {
6709 assert_eq!(default_property_support("no-such-record-type"), P::NUMERIC);
6710 }
6711}
6712
6713#[cfg(test)]
6714mod metadata_cache_tests {
6715 use super::*;
6716 use crate::server::records::ai::AiRecord;
6717
6718 /// Helper: build an AiRecord wrapped in a RecordInstance with EGU/PREC/HOPR/LOPR set.
6719 fn ai_instance() -> RecordInstance {
6720 let mut rec = AiRecord::default();
6721 let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
6722 let _ = rec.put_field("PREC", EpicsValue::Short(2));
6723 let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
6724 let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
6725 let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
6726 RecordInstance::new("TEMP".to_string(), rec)
6727 }
6728
6729 /// a record-field monitor whose event queue has run short of room
6730 /// replaces its last queued entry in place (C `db_queue_event_log`,
6731 /// `dbEvent.c:812-827`), and the displaced value — which the consumer never
6732 /// observed — must be counted in the shared `dropped_monitor_events()`
6733 /// counter (C `nreplace`), the same accounting a `ProcessVariable` post
6734 /// uses. Before the fix the record-field path overwrote its coalesce slot
6735 /// without counting, hiding slow-consumer loss on the path most CA/PVA
6736 /// database monitors use. The counter is process-global, so the assertion is
6737 /// a strict monotonic increase (robust under parallel tests); the
6738 /// revert-verify runs this test in isolation.
6739 #[test]
6740 fn bfr10_record_field_overflow_counts_dropped_event() {
6741 use crate::server::event_queue::{event_que_size, events_per_que};
6742 use crate::server::pv::dropped_monitor_events;
6743 use crate::server::recgbl::EventMask;
6744 let mut inst = ai_instance();
6745 // Keep the reader alive and do NOT drain, so the ring fills to the
6746 // replace threshold and later posts displace the tail entry.
6747 let _reader = inst
6748 .add_subscriber(
6749 "VAL",
6750 1,
6751 crate::types::DbFieldType::Double,
6752 EventMask::VALUE.bits(),
6753 )
6754 .expect("subscriber added");
6755 let before = dropped_monitor_events();
6756 let posts = event_que_size() - events_per_que() + 10;
6757 for _ in 0..posts {
6758 inst.notify_field_with_origin("VAL", EventMask::VALUE, 0, LinkBacking::none());
6759 }
6760 let after = dropped_monitor_events();
6761 assert!(
6762 after > before,
6763 "a post that replaces an unobserved queued entry must record a \
6764 dropped monitor event (before={before}, after={after})"
6765 );
6766 }
6767
6768 #[test]
6769 fn metadata_cache_source_set_check() {
6770 // Every field `populate_display_info` / `populate_control_info` /
6771 // `populate_enum_info` reads.
6772 assert!(is_metadata_cache_source("EGU"));
6773 assert!(is_metadata_cache_source("PREC"));
6774 assert!(is_metadata_cache_source("HOPR"));
6775 assert!(is_metadata_cache_source("LOPR"));
6776 assert!(is_metadata_cache_source("DRVH"));
6777 assert!(is_metadata_cache_source("ZNAM"));
6778 assert!(is_metadata_cache_source("ZRST"));
6779 assert!(is_metadata_cache_source("FFST"));
6780
6781 // The cache holds no alarm limits — `explicit_alarm_limits` is on the
6782 // live `apply_field_metadata_override` path — so HIHI is property-class
6783 // without being a cache source.
6784 assert!(!is_metadata_cache_source("HIHI"));
6785 assert!(!is_metadata_cache_source("VAL"));
6786 assert!(!is_metadata_cache_source("DESC"));
6787 assert!(!is_metadata_cache_source("SCAN"));
6788 assert!(!is_metadata_cache_source("PHAS"));
6789 }
6790
6791 #[test]
6792 fn cache_starts_empty_then_populates_on_first_snapshot() {
6793 let inst = ai_instance();
6794
6795 // Cache starts empty
6796 assert!(inst.metadata_cache.lock().unwrap().is_none());
6797
6798 // First snapshot triggers populate + cache store
6799 let snap = inst.snapshot_for_field("VAL").unwrap();
6800 let display = snap.display.expect("ai snapshot must have display");
6801 assert_eq!(display.units, "degC");
6802 assert_eq!(display.precision, 2);
6803 assert_eq!(display.upper_disp_limit, 100.0);
6804 assert_eq!(display.lower_disp_limit, 0.0);
6805
6806 // Cache is now populated
6807 assert!(inst.metadata_cache.lock().unwrap().is_some());
6808 }
6809
6810 #[test]
6811 fn q_form_info_tag_sets_display_form_index() {
6812 // pvxs maps the `Q:form` info tag to `display.form.index` for the
6813 // VAL field (iocsource.cpp:42-62). "Hex" is slot 4 of the
6814 // seven-entry menu (Default/String/Binary/Decimal/Hex/...).
6815 let mut inst = ai_instance();
6816 inst.set_info("Q:form", "Hex");
6817 let snap = inst.snapshot_for_field("VAL").unwrap();
6818 let display = snap.display.expect("ai snapshot must have display");
6819 assert_eq!(display.form, 4, "Q:form=Hex -> display.form index 4");
6820 }
6821
6822 /// R16-31: `Q:form` is a record-level info tag, but QSRV assigns
6823 /// `display.form.index` only when the channel addresses the VAL field
6824 /// (`if(dbIsValueField(dbChannelFldDes(chan)))`, `iocsource.cpp:53`). A
6825 /// snapshot of any other field of the same record reports the default
6826 /// form, on both the GET and the monitor producer.
6827 #[test]
6828 fn q_form_applies_to_the_val_field_only() {
6829 let mut inst = ai_instance();
6830 inst.set_info("Q:form", "Hex");
6831
6832 let val = inst.snapshot_for_field("VAL").unwrap();
6833 assert_eq!(val.display.expect("ai display").form, 4);
6834
6835 for non_val in ["RVAL", "SEVR", "HOPR"] {
6836 let Some(snap) = inst.snapshot_for_field(non_val) else {
6837 panic!("ai.{non_val} must resolve");
6838 };
6839 assert_eq!(
6840 snap.display.expect("ai display").form,
6841 0,
6842 "Q:form must not reach ai.{non_val} — pvxs applies it to VAL only"
6843 );
6844 }
6845
6846 // The monitor producer shares the same per-field owner.
6847 let update = inst.make_monitor_snapshot("RVAL", EpicsValue::Long(7), LinkBacking::none());
6848 assert_eq!(
6849 update.display.expect("ai display").form,
6850 0,
6851 "a monitor update on a non-VAL field carries the default form too"
6852 );
6853 let update =
6854 inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none());
6855 assert_eq!(update.display.expect("ai display").form, 4);
6856 }
6857
6858 #[test]
6859 fn q_form_absent_or_unknown_leaves_form_default() {
6860 // No `Q:form` tag -> form stays 0 (Default).
6861 let inst = ai_instance();
6862 let snap = inst.snapshot_for_field("VAL").unwrap();
6863 assert_eq!(snap.display.expect("ai display").form, 0);
6864
6865 // Unrecognised tag -> pvxs leaves the index untouched (0).
6866 let mut inst2 = ai_instance();
6867 inst2.set_info("Q:form", "Nonsense");
6868 let snap2 = inst2.snapshot_for_field("VAL").unwrap();
6869 assert_eq!(snap2.display.expect("ai display").form, 0);
6870 }
6871
6872 /// `info(Q:time:tag)` resolves to pvxs's `nsecMask`
6873 /// (`ioc/typeutils.cpp:79-88`). The prefix test there is a byte-exact
6874 /// `strncmp("nsec:lsb:", 9)` and the digit count is fed straight to
6875 /// `(uint64_t(1u)<<dig)-1u` — no case folding, no whitespace tolerance
6876 /// around the prefix, and no bounds clamp. Each boundary gets a case.
6877 #[test]
6878 fn qtime_nsec_mask_matches_pvxs_updatensecmask() {
6879 let cases: &[(&str, u64)] = &[
6880 // parses: `epicsParseInt32` skips whitespace around the digits
6881 // and accepts a sign.
6882 ("nsec:lsb:20", (1 << 20) - 1),
6883 ("nsec:lsb:1", 1),
6884 ("nsec:lsb: 4 ", 0xF),
6885 ("nsec:lsb:+4", 0xF),
6886 // no clamp: 31 is the mask pvxs actually serves (the old Rust
6887 // `(1..=30)` guard dropped it), and 0 is pvxs's "off" mask.
6888 ("nsec:lsb:31", 0x7FFF_FFFF),
6889 ("nsec:lsb:0", 0),
6890 // `strncmp` is byte-exact: case-folded or whitespace-split
6891 // prefixes do not match, so pvxs leaves `nsecMask` at 0.
6892 ("NSEC:LSB:4", 0),
6893 ("Nsec:Lsb:4", 0),
6894 ("nsec: lsb: 4", 0),
6895 (" nsec:lsb:4", 0),
6896 // `epicsParseInt32` failures: no conversion, extraneous trailing
6897 // bytes, overflow past epicsInt32.
6898 ("nsec:lsb:", 0),
6899 ("nsec:lsb:abc", 0),
6900 ("nsec:lsb:4x", 0),
6901 ("nsec:lsb:4 5", 0),
6902 ("nsec:lsb:99999999999999999999", 0),
6903 ("nsec:lsb:2147483648", 0),
6904 ];
6905 for (tag, want) in cases {
6906 let mut inst = ai_instance();
6907 inst.set_info("Q:time:tag", *tag);
6908 assert_eq!(
6909 inst.qtime_nsec_mask(),
6910 *want,
6911 "info(Q:time:tag, {tag:?}) must resolve to nsecMask {want:#x}"
6912 );
6913 }
6914 // Tag absent entirely → pvxs never enters the `if(auto val = ...)`
6915 // body and `nsecMask` stays 0.
6916 assert_eq!(ai_instance().qtime_nsec_mask(), 0);
6917 }
6918
6919 /// End-to-end on the snapshot: `nsec:lsb:31` publishes
6920 /// `nanoseconds & ~mask` (0, since nanoseconds < 1e9 < 2^31) and
6921 /// `userTag = nanoseconds & mask` (pvxs `iocsource.cpp:239-248`). The
6922 /// old `(1..=30)` clamp served the raw nanoseconds and the record's
6923 /// utag instead.
6924 #[test]
6925 fn qtime_nsec_lsb_31_is_served_not_ignored() {
6926 use std::time::{Duration, SystemTime};
6927 let mut inst = ai_instance();
6928 // 123_456_700, not …789: Windows `SystemTime` is a FILETIME with 100 ns
6929 // resolution, so a sub-100 ns literal is truncated on readback and the
6930 // assertion below would see …700. Any value < 2^31 exercises the
6931 // nsec:lsb:31 mask identically, so pin one that survives the round trip.
6932 inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
6933 inst.common.utag = 5;
6934 inst.set_info("Q:time:tag", "nsec:lsb:31");
6935
6936 let snap = inst.snapshot_for_field("VAL").unwrap();
6937 assert_eq!(snap.user_tag, 123_456_700);
6938 assert_eq!(snap.timestamp.subsec_nanos(), 0);
6939 assert_eq!(snap.timestamp.unix_secs(), 42);
6940 }
6941
6942 /// The monitor path applies the same `Q:time:tag` nsec split as GET.
6943 /// Pre-fix, `make_monitor_snapshot` skipped `apply_nsec_mask`, so a
6944 /// monitor update on a `nsec:lsb:N` record posted the raw nanoseconds
6945 /// and the record utag while a GET of the same channel served the
6946 /// split — upstream pvxs PR #189 is the same defect in its
6947 /// `subscriptionCallback`.
6948 #[test]
6949 fn qtime_nsec_mask_applies_on_the_monitor_path() {
6950 use std::time::{Duration, SystemTime};
6951 let mut inst = ai_instance();
6952 // 100 ns-multiple so the subsec_nanos assertion holds on Windows too;
6953 // see qtime_nsec_lsb_31_is_served_not_ignored for the FILETIME reason.
6954 inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
6955 inst.common.utag = 5;
6956 inst.set_info("Q:time:tag", "nsec:lsb:31");
6957
6958 let mon = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none());
6959 assert_eq!(mon.user_tag, 123_456_700);
6960 assert_eq!(mon.timestamp.subsec_nanos(), 0);
6961 assert_eq!(mon.timestamp.unix_secs(), 42);
6962 }
6963
6964 /// The mirror boundary: a tag pvxs's `strncmp` rejects must leave the
6965 /// timestamp and the record's own utag alone. The old case-insensitive
6966 /// split matched `NSEC:LSB:4` and masked the wire timestamp pvxs serves
6967 /// unmasked.
6968 #[test]
6969 fn qtime_uppercase_tag_leaves_timestamp_untouched() {
6970 use std::time::{Duration, SystemTime};
6971 let mut inst = ai_instance();
6972 // 100 ns-multiple so the subsec_nanos assertion holds on Windows too;
6973 // see qtime_nsec_lsb_31_is_served_not_ignored for the FILETIME reason.
6974 inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
6975 inst.common.utag = 5;
6976 inst.set_info("Q:time:tag", "NSEC:LSB:4");
6977
6978 let snap = inst.snapshot_for_field("VAL").unwrap();
6979 assert_eq!(
6980 snap.user_tag, 5,
6981 "record utag must survive a non-matching tag"
6982 );
6983 assert_eq!(snap.timestamp.subsec_nanos(), 123_456_700);
6984 }
6985
6986 /// the served `timeStamp.userTag` defaults to the record's `utag`
6987 /// (pvxs `iocsource.cpp:245`), on both the GET (`snapshot_for_field`)
6988 /// and MONITOR (`make_monitor_snapshot`) paths. Pre-fix both hard-set
6989 /// it to 0, dropping the record's tag. A bit-31 utag also pins the
6990 /// `u64 -> i32` narrowing: the low 32 bits' pattern is preserved
6991 /// (no clamp), matching pvxs assigning `epicsUTag` into the `Int32`
6992 /// wire field.
6993 #[test]
6994 fn snapshot_serves_record_utag_as_timestamp_usertag() {
6995 let mut inst = ai_instance();
6996 // no `info(Q:time:tag, ...)` on this record, so the nsec-LSB
6997 // override never fires and the utag default is what is served.
6998 inst.common.utag = 0x9000_0000;
6999 let want = 0x9000_0000u32 as i32;
7000
7001 let get = inst.snapshot_for_field("VAL").unwrap();
7002 assert_eq!(
7003 get.user_tag, want,
7004 "GET path must serve the record's utag as timeStamp.userTag"
7005 );
7006
7007 let mon = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none());
7008 assert_eq!(
7009 mon.user_tag, want,
7010 "MONITOR path must carry the record's utag too"
7011 );
7012 }
7013
7014 #[test]
7015 fn cache_hit_returns_same_metadata() {
7016 let inst = ai_instance();
7017
7018 // Prime the cache
7019 let snap1 = inst.snapshot_for_field("VAL").unwrap();
7020 let display1 = snap1.display.unwrap();
7021
7022 // Subsequent snapshots return the same cached metadata
7023 let snap2 = inst.snapshot_for_field("VAL").unwrap();
7024 let display2 = snap2.display.unwrap();
7025
7026 assert_eq!(display1.units, display2.units);
7027 assert_eq!(display1.precision, display2.precision);
7028 assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
7029 assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
7030 }
7031
7032 #[test]
7033 fn invalidate_clears_cache() {
7034 let inst = ai_instance();
7035 let _ = inst.snapshot_for_field("VAL");
7036 assert!(inst.metadata_cache.lock().unwrap().is_some());
7037
7038 inst.invalidate_metadata_cache();
7039 assert!(inst.metadata_cache.lock().unwrap().is_none());
7040 }
7041
7042 #[test]
7043 fn notify_field_written_invalidates_for_metadata_field() {
7044 let inst = ai_instance();
7045 let _ = inst.snapshot_for_field("VAL");
7046 assert!(inst.metadata_cache.lock().unwrap().is_some());
7047
7048 // Writing a metadata field should invalidate
7049 inst.notify_field_written("EGU");
7050 assert!(inst.metadata_cache.lock().unwrap().is_none());
7051 }
7052
7053 #[test]
7054 fn notify_field_written_skips_non_metadata_field() {
7055 let inst = ai_instance();
7056 let _ = inst.snapshot_for_field("VAL");
7057 assert!(inst.metadata_cache.lock().unwrap().is_some());
7058
7059 // Writing a value field should NOT invalidate the cache
7060 inst.notify_field_written("VAL");
7061 assert!(inst.metadata_cache.lock().unwrap().is_some());
7062
7063 // DESC is not property-class either — its cache invalidation
7064 // is owned by the DESC arm of `put_common_field`, not by this
7065 // notify path (UI-106).
7066 inst.notify_field_written("DESC");
7067 assert!(inst.metadata_cache.lock().unwrap().is_some());
7068 }
7069
7070 #[test]
7071 fn notify_field_written_is_case_insensitive() {
7072 let inst = ai_instance();
7073 let _ = inst.snapshot_for_field("VAL");
7074 assert!(inst.metadata_cache.lock().unwrap().is_some());
7075
7076 // Lowercase metadata field name should still trigger invalidation
7077 inst.notify_field_written("egu");
7078 assert!(inst.metadata_cache.lock().unwrap().is_none());
7079 }
7080
7081 /// epics-base faac1df1 — `notify_field_written_if_changed` must
7082 /// SKIP the cache invalidation when the metadata field's value
7083 /// didn't actually change. Otherwise a stream of idempotent puts
7084 /// from a CSS panel binds DBE_PROPERTY subscribers to bogus
7085 /// "property changed" events on every cycle.
7086 #[test]
7087 fn notify_field_written_if_changed_skips_when_unchanged() {
7088 let mut inst = ai_instance();
7089 let _ = inst.snapshot_for_field("VAL");
7090 assert!(inst.metadata_cache.lock().unwrap().is_some());
7091
7092 // Capture prev, do a no-op put, then notify — cache must remain.
7093 let prev = inst.record.get_field("EGU");
7094 let _ = inst.record.put_field("EGU", prev.clone().unwrap());
7095 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
7096 assert!(
7097 inst.metadata_cache.lock().unwrap().is_some(),
7098 "no-op put must not invalidate the metadata cache"
7099 );
7100 }
7101
7102 /// And when the value DID change, the cache must invalidate.
7103 #[test]
7104 fn notify_field_written_if_changed_invalidates_on_real_change() {
7105 let mut inst = ai_instance();
7106 let _ = inst.snapshot_for_field("VAL");
7107 assert!(inst.metadata_cache.lock().unwrap().is_some());
7108
7109 let prev = inst.record.get_field("EGU");
7110 let _ = inst
7111 .record
7112 .put_field("EGU", EpicsValue::String("kPa".into()));
7113 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
7114 assert!(
7115 inst.metadata_cache.lock().unwrap().is_none(),
7116 "real metadata change must invalidate cache"
7117 );
7118 }
7119
7120 /// UI-106 / epics-base#785 — DESC feeds `display.description`
7121 /// (pvxs fills it on every metadata populate, iocsource.cpp:306-310),
7122 /// and a changed DESC refreshes the cache at its write owner so the
7123 /// next snapshot serves the new text.
7124 #[test]
7125 fn desc_reaches_display_description_and_a_write_refreshes_it() {
7126 let mut inst = ai_instance();
7127 inst.put_common_field("DESC", EpicsValue::String("before".into()))
7128 .unwrap();
7129 let snap = inst.snapshot_for_field("VAL").unwrap();
7130 assert_eq!(
7131 snap.display.as_ref().unwrap().description.as_str_lossy(),
7132 "before"
7133 );
7134 inst.put_common_field("DESC", EpicsValue::String("after".into()))
7135 .unwrap();
7136 assert!(
7137 inst.metadata_cache.lock().unwrap().is_none(),
7138 "a changed DESC must invalidate the metadata cache"
7139 );
7140 let snap = inst.snapshot_for_field("VAL").unwrap();
7141 assert_eq!(
7142 snap.display.as_ref().unwrap().description.as_str_lossy(),
7143 "after"
7144 );
7145 }
7146
7147 /// …and an idempotent DESC put must NOT invalidate — same
7148 /// discipline as faac1df1 for the property-class fields.
7149 #[test]
7150 fn an_idempotent_desc_put_keeps_the_cache() {
7151 let mut inst = ai_instance();
7152 inst.put_common_field("DESC", EpicsValue::String("same".into()))
7153 .unwrap();
7154 let _ = inst.snapshot_for_field("VAL");
7155 assert!(inst.metadata_cache.lock().unwrap().is_some());
7156 inst.put_common_field("DESC", EpicsValue::String("same".into()))
7157 .unwrap();
7158 assert!(
7159 inst.metadata_cache.lock().unwrap().is_some(),
7160 "an unchanged DESC must not invalidate the metadata cache"
7161 );
7162 }
7163
7164 /// Non-metadata fields don't carry property semantics — the
7165 /// `if_changed` variant must never invalidate for them, matching
7166 /// the existing `notify_field_written` short-circuit.
7167 #[test]
7168 fn notify_field_written_if_changed_skips_non_metadata_field() {
7169 let mut inst = ai_instance();
7170 let _ = inst.snapshot_for_field("VAL");
7171 assert!(inst.metadata_cache.lock().unwrap().is_some());
7172 // VAL is neither a cache source nor `prop(YES)` — must be skipped
7173 // even with a changed value.
7174 inst.notify_field_written_if_changed("VAL", None, LinkBacking::none());
7175 assert!(inst.metadata_cache.lock().unwrap().is_some());
7176 }
7177
7178 #[test]
7179 fn cache_picks_up_new_value_after_invalidation() {
7180 let mut inst = ai_instance();
7181
7182 // First snapshot: degC
7183 let snap1 = inst.snapshot_for_field("VAL").unwrap();
7184 assert_eq!(snap1.display.unwrap().units, "degC");
7185
7186 // Mutate EGU and invalidate
7187 let _ = inst
7188 .record
7189 .put_field("EGU", EpicsValue::String("mV".into()));
7190 inst.notify_field_written("EGU");
7191
7192 // Second snapshot: mV (rebuilt)
7193 let snap2 = inst.snapshot_for_field("VAL").unwrap();
7194 assert_eq!(snap2.display.unwrap().units, "mV");
7195 }
7196
7197 /// R19-41: every snapshot carries the mask of which properties the
7198 /// channel SUPPLIES — C's `rset` slots (`dbAccess.c:336-427` clears the
7199 /// option bit of each NULL slot) narrowed to the addressed field. One
7200 /// case per gate boundary; the three record types are the ones measured
7201 /// against pvxs, which marks none of these leaves.
7202 #[test]
7203 fn property_support_masks_what_the_record_type_does_not_supply() {
7204 use crate::server::records::longout::LongoutRecord;
7205 use crate::server::records::stringout::StringoutRecord;
7206 use crate::server::records::waveform::WaveformRecord;
7207
7208 // ai VAL (DBF_DOUBLE): every numeric slot, no enum strings.
7209 let ai = ai_instance();
7210 let p = ai.snapshot_for_field("VAL").unwrap().properties;
7211 assert_eq!(p, PropertySupport::NUMERIC);
7212 assert_eq!(
7213 ai.snapshot_for_field("VAL").unwrap().precision(),
7214 Some(2),
7215 "an ai supplies get_precision and VAL is DBF_DOUBLE"
7216 );
7217
7218 // ai RVAL (DBF_LONG): the SAME rset, but C keeps DBR_PRECISION only
7219 // for DBF_FLOAT/DBF_DOUBLE (`dbAccess.c:386-395`).
7220 let rval = ai.snapshot_for_field("RVAL").unwrap();
7221 assert!(
7222 !rval.properties.precision && rval.precision().is_none(),
7223 "a non-float field supplies no precision even when the rset does"
7224 );
7225 assert!(
7226 rval.properties.units,
7227 "the other slots are unaffected by the field's type"
7228 );
7229
7230 // longout: `#define get_precision NULL`.
7231 let lo = RecordInstance::new("LO".to_string(), LongoutRecord::default());
7232 let lo = lo.snapshot_for_field("VAL").unwrap();
7233 assert!(!lo.properties.precision && lo.precision().is_none());
7234 assert!(lo.properties.units && lo.properties.graphic_double);
7235
7236 // stringout: no property slot at all.
7237 let so = RecordInstance::new("SO".to_string(), StringoutRecord::default());
7238 let so = so.snapshot_for_field("VAL").unwrap();
7239 assert_eq!(so.properties, PropertySupport::NONE);
7240 assert!(so.units().is_none(), "a stringout supplies no EGU");
7241
7242 // waveform: `#define get_alarm_double NULL`.
7243 let wf = RecordInstance::new("WF".to_string(), WaveformRecord::default());
7244 let wf = wf.snapshot_for_field("VAL").unwrap();
7245 assert!(
7246 !wf.properties.alarm_double && wf.alarm_limits().is_none(),
7247 "a waveform supplies no alarm limits — a GUI must not draw bands at zero"
7248 );
7249 assert!(wf.properties.units && wf.properties.graphic_double);
7250 }
7251
7252 #[test]
7253 fn make_monitor_snapshot_uses_cache() {
7254 let inst = ai_instance();
7255 assert!(inst.metadata_cache.lock().unwrap().is_none());
7256
7257 // make_monitor_snapshot should also populate the cache
7258 let snap = inst.make_monitor_snapshot("VAL", EpicsValue::Double(42.0), LinkBacking::none());
7259 assert!(snap.display.is_some());
7260 assert!(inst.metadata_cache.lock().unwrap().is_some());
7261
7262 // Subsequent call hits cache
7263 let snap2 =
7264 inst.make_monitor_snapshot("VAL", EpicsValue::Double(43.0), LinkBacking::none());
7265 let d1 = snap.display.unwrap();
7266 let d2 = snap2.display.unwrap();
7267 assert_eq!(d1.units, d2.units);
7268 assert_eq!(d1.precision, d2.precision);
7269 }
7270
7271 /// Stub record with a per-field metadata override on SPD only —
7272 /// models a C RSET whose get_units/get_graphic_double key on
7273 /// dbGetFieldIndex (e.g. motorRecord.cc:3156-3361).
7274 static PER_FIELD_META_FIELDS: &[crate::server::record::FieldDesc] = &[
7275 crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
7276 crate::server::record::FieldDesc::new("SPD", crate::types::DbFieldType::Double, false),
7277 crate::server::record::FieldDesc::new("EGU", crate::types::DbFieldType::String, false),
7278 crate::server::record::FieldDesc::new("PREC", crate::types::DbFieldType::Short, false),
7279 crate::server::record::FieldDesc::new("HOPR", crate::types::DbFieldType::Double, false),
7280 crate::server::record::FieldDesc::new("LOPR", crate::types::DbFieldType::Double, false),
7281 ];
7282
7283 struct PerFieldMetaRecord;
7284
7285 impl Record for PerFieldMetaRecord {
7286 /// Its own type, not `ai`: the fixture serves `SPD`, which no `ai`
7287 /// declares, and a field is readable only where the record type
7288 /// declares it (`resolve_field`). Record-level metadata still
7289 /// populates, because that comes from EGU/PREC/HOPR/LOPR below.
7290 fn record_type(&self) -> &'static str {
7291 "per_field_meta"
7292 }
7293 fn get_field(&self, name: &str) -> Option<EpicsValue> {
7294 match name {
7295 "VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
7296 "EGU" => Some(EpicsValue::String("mm".into())),
7297 "PREC" => Some(EpicsValue::Short(3)),
7298 "HOPR" => Some(EpicsValue::Double(100.0)),
7299 "LOPR" => Some(EpicsValue::Double(-100.0)),
7300 _ => None,
7301 }
7302 }
7303 fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
7304 Err(CaError::FieldNotFound(name.to_string()))
7305 }
7306 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
7307 PER_FIELD_META_FIELDS
7308 }
7309 fn field_metadata_override(
7310 &self,
7311 field: &str,
7312 ) -> Option<crate::server::record::FieldMetadataOverride> {
7313 if field != "SPD" {
7314 return None;
7315 }
7316 Some(crate::server::record::FieldMetadataOverride {
7317 units: Some("mm/sec".into()),
7318 precision: Some(1),
7319 disp_limits: Some((5.0, 0.5)),
7320 ctrl_limits: Some((4.0, 1.0)),
7321 alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
7322 })
7323 }
7324 }
7325
7326 #[test]
7327 fn field_metadata_override_applies_on_get_and_monitor_paths() {
7328 let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
7329
7330 // VAL: no override — record-level metadata serves it.
7331 let snap = inst.snapshot_for_field("VAL").unwrap();
7332 let d = snap.display.unwrap();
7333 assert_eq!(d.units, "mm");
7334 assert_eq!(d.precision, 3);
7335 assert_eq!(d.upper_disp_limit, 100.0);
7336
7337 // SPD via the GET path: every member patched over the cache.
7338 let snap = inst.snapshot_for_field("SPD").unwrap();
7339 let d = snap.display.unwrap();
7340 assert_eq!(d.units, "mm/sec");
7341 assert_eq!(d.precision, 1);
7342 assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
7343 assert_eq!(
7344 (
7345 d.upper_alarm_limit,
7346 d.upper_warning_limit,
7347 d.lower_warning_limit,
7348 d.lower_alarm_limit
7349 ),
7350 (9.0, 8.0, -8.0, -9.0)
7351 );
7352 let c = snap.control.unwrap();
7353 assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
7354
7355 // SPD via the monitor path: identical override.
7356 let snap = inst.make_monitor_snapshot("SPD", EpicsValue::Double(2.0), LinkBacking::none());
7357 let d = snap.display.unwrap();
7358 assert_eq!(d.units, "mm/sec");
7359 assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
7360 let c = snap.control.unwrap();
7361 assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
7362 }
7363
7364 /// Stub modelling the motor monitor() shape (C motorRecord.cc:
7365 /// 3468-3507): VAL is a setpoint, the MDEL/ADEL deadband tracks
7366 /// the RBV readback, which advances on every process.
7367 static READBACK_DEADBAND_FIELDS: &[crate::server::record::FieldDesc] = &[
7368 crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
7369 crate::server::record::FieldDesc::new("RBV", crate::types::DbFieldType::Double, false),
7370 crate::server::record::FieldDesc::new("MDEL", crate::types::DbFieldType::Double, false),
7371 crate::server::record::FieldDesc::new("ADEL", crate::types::DbFieldType::Double, false),
7372 ];
7373
7374 struct ReadbackDeadbandRecord {
7375 val: f64,
7376 rbv: f64,
7377 deadband: f64,
7378 }
7379
7380 impl Record for ReadbackDeadbandRecord {
7381 /// `RBV` is a motor field, not an `ai` one, and only a record type
7382 /// that declares a field can serve it.
7383 fn record_type(&self) -> &'static str {
7384 "readback_deadband"
7385 }
7386 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
7387 self.rbv += 30.0;
7388 Ok(crate::server::record::ProcessOutcome::complete())
7389 }
7390 fn get_field(&self, name: &str) -> Option<EpicsValue> {
7391 match name {
7392 "VAL" => Some(EpicsValue::Double(self.val)),
7393 "RBV" => Some(EpicsValue::Double(self.rbv)),
7394 "MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
7395 _ => None,
7396 }
7397 }
7398 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
7399 match (name, value) {
7400 ("VAL", EpicsValue::Double(v)) => {
7401 self.val = v;
7402 Ok(())
7403 }
7404 ("MDEL", EpicsValue::Double(v)) => {
7405 self.deadband = v;
7406 Ok(())
7407 }
7408 _ => Err(CaError::FieldNotFound(name.to_string())),
7409 }
7410 }
7411 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
7412 READBACK_DEADBAND_FIELDS
7413 }
7414 fn monitor_deadband_value(&self) -> Option<EpicsValue> {
7415 Some(EpicsValue::Double(self.rbv))
7416 }
7417 fn monitor_deadband_field(&self) -> &'static str {
7418 "RBV"
7419 }
7420 }
7421
7422 /// C motor monitor() parity: MDEL/ADEL throttle the deadband
7423 /// field's (RBV) delivery; VAL posts only when the setpoint
7424 /// actually changed — not on every readback poll.
7425 #[test]
7426 fn deadband_field_routes_readback_and_val_posts_only_on_change() {
7427 use crate::server::recgbl::EventMask;
7428 let mut inst = RecordInstance::new(
7429 "RDB".to_string(),
7430 ReadbackDeadbandRecord {
7431 val: 5.0,
7432 rbv: 0.0,
7433 deadband: 10.0,
7434 },
7435 );
7436 let _val_rx = inst
7437 .add_subscriber(
7438 "VAL",
7439 1,
7440 crate::types::DbFieldType::Double,
7441 EventMask::VALUE.bits(),
7442 )
7443 .expect("VAL subscriber");
7444 let _rbv_rx = inst
7445 .add_subscriber(
7446 "RBV",
7447 2,
7448 crate::types::DbFieldType::Double,
7449 EventMask::VALUE.bits(),
7450 )
7451 .expect("RBV subscriber");
7452 let names = |snap: &ProcessSnapshot| {
7453 snap.changed_fields
7454 .iter()
7455 .map(|(n, _, _)| n.clone())
7456 .collect::<Vec<_>>()
7457 };
7458
7459 // Cycle 1 (first publish): RBV fires via the deadband trigger
7460 // (MLST starts at the NaN never-posted sentinel). VAL must NOT
7461 // post: `add_subscriber` seeded `last_posted` with the current
7462 // value (the initial value already went out with EVENT_ADD), and
7463 // C monitor() posts VAL only when MARKED(M_VAL) — nothing marked
7464 // it.
7465 let (snap, _) = inst.process_local().unwrap();
7466 let n = names(&snap);
7467 assert!(n.contains(&"RBV".to_string()), "{n:?}");
7468 assert!(
7469 !n.contains(&"VAL".to_string()),
7470 "VAL unchanged since subscribe must not post: {n:?}"
7471 );
7472
7473 // Cycle 2: RBV moved past MDEL, VAL unchanged → RBV posted,
7474 // VAL not re-posted.
7475 let (snap, _) = inst.process_local().unwrap();
7476 let n = names(&snap);
7477 assert!(n.contains(&"RBV".to_string()), "RBV crossed MDEL: {n:?}");
7478 assert!(
7479 !n.contains(&"VAL".to_string()),
7480 "unchanged VAL must not post: {n:?}"
7481 );
7482
7483 // Cycle 3: widen the deadband — RBV moves within it → throttled.
7484 let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
7485 let (snap, _) = inst.process_local().unwrap();
7486 let n = names(&snap);
7487 assert!(
7488 !n.contains(&"RBV".to_string()),
7489 "MDEL must throttle RBV: {n:?}"
7490 );
7491
7492 // Cycle 4: setpoint moves while RBV stays inside the deadband →
7493 // VAL posts via change detection, RBV stays throttled.
7494 let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
7495 let (snap, _) = inst.process_local().unwrap();
7496 let n = names(&snap);
7497 assert!(
7498 n.contains(&"VAL".to_string()),
7499 "changed VAL must post: {n:?}"
7500 );
7501 assert!(
7502 !n.contains(&"RBV".to_string()),
7503 "MDEL must throttle RBV: {n:?}"
7504 );
7505 }
7506
7507 /// A subroutine-less aSub (empty SNAM — the record the PVA monitor
7508 /// oracle drives as `ORACLE:MONSCAN:ASUB`) mirrors C `do_sub`
7509 /// (aSubRecord.c:459-465): an empty SNAM returns 0 BEFORE the bad-sub
7510 /// check, and C `process` (`:224`) runs `prec->val = status = 0` every
7511 /// cycle. So a periodic scan forces VAL back to 0, and C `monitor()`
7512 /// (`:414`, `val != oval`) posts nothing — the driven `dbPut`s are the
7513 /// only VAL events.
7514 ///
7515 /// Before the fix the port's "no bound subroutine" branch returned
7516 /// `S_db_BadSub` and never wrote VAL, so a scanned aSub kept VAL at the
7517 /// last client put and the deadband gate re-posted it on every scan (the
7518 /// oracle's 7 updates where C posts 4). This pins both halves: `process`
7519 /// resets VAL to 0, and a scan of the reset value posts nothing.
7520 #[test]
7521 fn subroutineless_asub_process_resets_val_and_stops_scan_overposting() {
7522 use crate::server::recgbl::EventMask;
7523 use crate::server::records::asub_record::ASubRecord;
7524
7525 let mut inst = RecordInstance::new("ASUB".to_string(), ASubRecord::default());
7526 // The default record: no subroutine bound, SNAM empty.
7527 assert!(inst.subroutine.is_none());
7528 let _val_rx = inst
7529 .add_subscriber(
7530 "VAL",
7531 1,
7532 crate::types::DbFieldType::Long,
7533 EventMask::VALUE.bits(),
7534 )
7535 .expect("VAL subscriber");
7536 let posts_val =
7537 |snap: &ProcessSnapshot| snap.changed_fields.iter().any(|(n, _, _)| n == "VAL");
7538
7539 // A settling scan of the unchanged record: C `do_sub` returns 0 and
7540 // `process` leaves VAL at 0 (already 0), settling the monitor gate.
7541 let _ = inst.process_local().unwrap();
7542 assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
7543 // status 0 -> C `if (!status)` drives every OUT link (aSub's
7544 // `multi_output_links` gate reads the cycle status); a bad-sub status
7545 // would suppress all 21.
7546 assert_eq!(
7547 inst.record.multi_output_links().len(),
7548 21,
7549 "empty-SNAM do_sub status must be 0, not S_db_BadSub"
7550 );
7551
7552 // A client caput lands on VAL (DBF_LONG, not process-passive: it posts
7553 // but does not itself process, leaving VAL non-zero — exactly how the
7554 // oracle drives the scanned reproducer between scans).
7555 inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
7556
7557 // The periodic scan processes. C forces VAL back to 0 and posts
7558 // nothing (val == oval == 0). Before the fix VAL stayed 7 and the scan
7559 // re-posted it.
7560 let (snap, _) = inst.process_local().unwrap();
7561 assert_eq!(
7562 inst.record.get_field("VAL"),
7563 Some(EpicsValue::Long(0)),
7564 "a scan must reset VAL to the do_sub status (0)"
7565 );
7566 assert!(
7567 !posts_val(&snap),
7568 "a scan that resets VAL to 0 must not re-post it"
7569 );
7570
7571 // A second driven put + scan: the monitor marker stays at 0, so no
7572 // scan ever re-posts the reset value.
7573 inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
7574 let (snap, _) = inst.process_local().unwrap();
7575 assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
7576 assert!(
7577 !posts_val(&snap),
7578 "repeated scans must not re-post the reset VAL"
7579 );
7580 }
7581
7582 /// Record that names DIFF in `force_posted_fields` (the motor's C
7583 /// `process_motor_info` unconditional `MARK(M_DIFF)`) while keeping
7584 /// every value constant — a settled axis parked at a fixed non-zero
7585 /// following error. VAL is a control: not force-listed, so it must
7586 /// fall back to change-detection.
7587 static FORCE_POST_FIELDS: &[crate::server::record::FieldDesc] = &[
7588 crate::server::record::FieldDesc::new("DIFF", crate::types::DbFieldType::Double, false),
7589 crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
7590 ];
7591
7592 struct ForcePostRecord {
7593 diff: f64,
7594 val: f64,
7595 }
7596
7597 impl Record for ForcePostRecord {
7598 fn record_type(&self) -> &'static str {
7599 "force_post"
7600 }
7601 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
7602 // Values never change — the readback already matches; only the
7603 // unconditional MARK should keep DIFF flowing.
7604 Ok(crate::server::record::ProcessOutcome::complete())
7605 }
7606 fn get_field(&self, name: &str) -> Option<EpicsValue> {
7607 match name {
7608 "DIFF" => Some(EpicsValue::Double(self.diff)),
7609 "VAL" => Some(EpicsValue::Double(self.val)),
7610 _ => None,
7611 }
7612 }
7613 fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
7614 Err(CaError::FieldNotFound(name.to_string()))
7615 }
7616 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
7617 FORCE_POST_FIELDS
7618 }
7619 fn force_posted_fields(&self) -> &'static [&'static str] {
7620 &["DIFF"]
7621 }
7622 }
7623
7624 /// C motorRecord parity: `process_motor_info` MARKs M_DIFF/M_RDIF every
7625 /// CALLBACK_DATA pass and `monitor()` posts them with `DBE_VAL_LOG`
7626 /// regardless of change, so a force-posted field re-posts on an
7627 /// otherwise-idle cycle while an unchanged non-force field does not.
7628 #[test]
7629 fn force_posted_field_reposts_unchanged_value_each_cycle() {
7630 use crate::server::recgbl::EventMask;
7631 let mut inst = RecordInstance::new(
7632 "FP".to_string(),
7633 ForcePostRecord {
7634 diff: 2.5,
7635 val: 1.0,
7636 },
7637 );
7638 let _diff_rx = inst
7639 .add_subscriber(
7640 "DIFF",
7641 1,
7642 crate::types::DbFieldType::Double,
7643 EventMask::VALUE.bits(),
7644 )
7645 .expect("DIFF subscriber");
7646 let _val_rx = inst
7647 .add_subscriber(
7648 "VAL",
7649 2,
7650 crate::types::DbFieldType::Double,
7651 EventMask::VALUE.bits(),
7652 )
7653 .expect("VAL subscriber");
7654 let names = |snap: &ProcessSnapshot| {
7655 snap.changed_fields
7656 .iter()
7657 .map(|(n, _, _)| n.clone())
7658 .collect::<Vec<_>>()
7659 };
7660
7661 // Cycle 1 (first publish): both DIFF and VAL post — last_posted is
7662 // empty so change-detection treats every subscribed field as new.
7663 let (snap1, _) = inst.process_local().unwrap();
7664 assert!(
7665 names(&snap1).contains(&"DIFF".to_string()),
7666 "DIFF posts on first publish: {:?}",
7667 names(&snap1)
7668 );
7669
7670 // Cycle 2: nothing changed. VAL (not force-listed) must NOT re-post;
7671 // DIFF (force-listed) MUST re-post — the C unconditional MARK +
7672 // DBE_VAL_LOG. This is the divergence MOT-1 closes.
7673 let (snap2, _) = inst.process_local().unwrap();
7674 assert!(
7675 names(&snap2).contains(&"DIFF".to_string()),
7676 "force-posted DIFF must re-post when unchanged: {:?}",
7677 names(&snap2)
7678 );
7679 assert!(
7680 !names(&snap2).contains(&"VAL".to_string()),
7681 "an unchanged non-force field must not re-post: {:?}",
7682 names(&snap2)
7683 );
7684 // The forced re-post carries DBE_VALUE|DBE_LOG (no alarm bits this
7685 // cycle), matching C `monitor_mask | DBE_VAL_LOG` with monitor_mask=0.
7686 let diff_mask = snap2
7687 .changed_fields
7688 .iter()
7689 .find(|(n, _, _)| n == "DIFF")
7690 .map(|(_, _, m)| *m)
7691 .expect("DIFF post present");
7692 assert_eq!(
7693 diff_mask.bits(),
7694 (EventMask::VALUE | EventMask::LOG).bits(),
7695 "forced re-post mask is DBE_VAL_LOG"
7696 );
7697 }
7698
7699 /// Record that names S1 in `log_swept_fields` (the scaler's idle
7700 /// `monitor()` DBE_LOG sweep) while keeping every value constant. S2
7701 /// is a control: subscribed but NOT swept, so an unchanged S2 must
7702 /// not re-post. Neither field is the primary `VAL`, so the default
7703 /// deadband field resolves to nothing and does not confound the test.
7704 static LOG_SWEEP_FIELDS: &[crate::server::record::FieldDesc] = &[
7705 crate::server::record::FieldDesc::new("S1", crate::types::DbFieldType::Long, false),
7706 crate::server::record::FieldDesc::new("S2", crate::types::DbFieldType::Long, false),
7707 ];
7708
7709 struct LogSweepRecord {
7710 s1: i32,
7711 s2: i32,
7712 }
7713
7714 impl Record for LogSweepRecord {
7715 fn record_type(&self) -> &'static str {
7716 "scaler"
7717 }
7718 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
7719 // Counts never change — only the unconditional idle LOG sweep
7720 // should keep S1 flowing to a DBE_LOG (archiver) subscriber.
7721 Ok(crate::server::record::ProcessOutcome::complete())
7722 }
7723 fn get_field(&self, name: &str) -> Option<EpicsValue> {
7724 match name {
7725 "S1" => Some(EpicsValue::Long(self.s1)),
7726 "S2" => Some(EpicsValue::Long(self.s2)),
7727 _ => None,
7728 }
7729 }
7730 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
7731 match (name, value) {
7732 ("S1", EpicsValue::Long(v)) => {
7733 self.s1 = v;
7734 Ok(())
7735 }
7736 ("S2", EpicsValue::Long(v)) => {
7737 self.s2 = v;
7738 Ok(())
7739 }
7740 _ => Err(CaError::FieldNotFound(name.to_string())),
7741 }
7742 }
7743 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
7744 LOG_SWEEP_FIELDS
7745 }
7746 fn log_swept_fields(&self) -> &'static [&'static str] {
7747 &["S1"]
7748 }
7749 }
7750
7751 /// C `scalerRecord.c::monitor():757-773` sweeps each active channel with a
7752 /// literal `DBE_LOG` on every cycle it runs, unconditionally — the sweep is
7753 /// INDEPENDENT of the change post, not an alternative to it (R12-62). So an
7754 /// UNCHANGED swept field posts `DBE_LOG` only, and a CHANGED swept field
7755 /// posts TWICE on that one cycle: once by change-detection, and once by the
7756 /// sweep with `DBE_LOG`. (In C's scaler those two are `updateCounts()`'s
7757 /// `DBE_VALUE` at `:582` and `monitor()`'s `DBE_LOG` at `:771`.) A
7758 /// non-swept field never re-posts when unchanged. `add_subscriber` seeds
7759 /// `last_posted` with the current value (the initial value goes out via
7760 /// EVENT_ADD), so a freshly subscribed unchanged field already takes the
7761 /// sweep path on cycle 1.
7762 #[test]
7763 fn log_swept_field_reposts_unchanged_with_log_mask_only() {
7764 use crate::server::recgbl::EventMask;
7765 let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
7766 let _s1_rx = inst
7767 .add_subscriber(
7768 "S1",
7769 1,
7770 crate::types::DbFieldType::Long,
7771 EventMask::LOG.bits(),
7772 )
7773 .expect("S1 subscriber");
7774 let _s2_rx = inst
7775 .add_subscriber(
7776 "S2",
7777 2,
7778 crate::types::DbFieldType::Long,
7779 EventMask::VALUE.bits(),
7780 )
7781 .expect("S2 subscriber");
7782 let names = |snap: &ProcessSnapshot| {
7783 snap.changed_fields
7784 .iter()
7785 .map(|(n, _, _)| n.clone())
7786 .collect::<Vec<_>>()
7787 };
7788 let count_of = |snap: &ProcessSnapshot, f: &str| {
7789 snap.changed_fields
7790 .iter()
7791 .filter(|(n, _, _)| n == f)
7792 .count()
7793 };
7794 let mask_of = |snap: &ProcessSnapshot, f: &str| {
7795 snap.changed_fields
7796 .iter()
7797 .find(|(n, _, _)| n == f)
7798 .map(|(_, _, m)| *m)
7799 };
7800
7801 // Cycle 1: nothing changed since subscribe. S1 (swept) re-posts
7802 // with DBE_LOG ONLY; S2 (not swept) must NOT re-post.
7803 let (snap1, _) = inst.process_local().unwrap();
7804 assert!(
7805 names(&snap1).contains(&"S1".to_string()),
7806 "log-swept S1 must re-post when unchanged: {:?}",
7807 names(&snap1)
7808 );
7809 assert!(
7810 !names(&snap1).contains(&"S2".to_string()),
7811 "unchanged non-swept S2 must not re-post: {:?}",
7812 names(&snap1)
7813 );
7814 // DBE_LOG, plus the DBE_ALARM of this cycle's transition: a record
7815 // starts UDF/INVALID and its first process clears that, so cycle 1 IS an
7816 // alarm transition (CBUG-B19 — C's sweep drops the alarm bit; this
7817 // assertion used to require a bare DBE_LOG). No DBE_VALUE either way:
7818 // the counts have not moved.
7819 assert_eq!(
7820 mask_of(&snap1, "S1").unwrap().bits(),
7821 (EventMask::LOG | EventMask::ALARM).bits(),
7822 "idle sweep posts DBE_LOG + the alarm transition, never DBE_VALUE"
7823 );
7824
7825 // Cycle 2: S1's count changed. Change-detection delivers it, and the
7826 // sweep delivers it AGAIN with DBE_LOG — the two C `db_post_events`
7827 // calls of the count-completion cycle.
7828 inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
7829 let (snap2, _) = inst.process_local().unwrap();
7830 assert_eq!(
7831 count_of(&snap2, "S1"),
7832 2,
7833 "a changed swept field posts twice — change post + independent \
7834 DBE_LOG sweep: {:?}",
7835 snap2.changed_fields
7836 );
7837 let s1_masks: Vec<u16> = snap2
7838 .changed_fields
7839 .iter()
7840 .filter(|(n, _, _)| n == "S1")
7841 .map(|(_, _, m)| m.bits())
7842 .collect();
7843 assert_eq!(
7844 s1_masks,
7845 vec![
7846 (EventMask::VALUE | EventMask::LOG).bits(),
7847 EventMask::LOG.bits()
7848 ],
7849 "change post first (VALUE|LOG here — this stub is not a \
7850 value_only_change_fields record), then the sweep's literal DBE_LOG"
7851 );
7852
7853 // Cycle 3: unchanged again — back to the DBE_LOG-only sweep.
7854 let (snap3, _) = inst.process_local().unwrap();
7855 assert_eq!(
7856 mask_of(&snap3, "S1").unwrap().bits(),
7857 EventMask::LOG.bits(),
7858 "unchanged-again S1 returns to the DBE_LOG-only sweep"
7859 );
7860 }
7861
7862 /// A log-swept record that can raise an alarm on demand — the scaler's
7863 /// `do_alarm()` (scalerRecord.c:745-755) in miniature.
7864 static ALARMING_LOG_SWEEP_FIELDS: &[crate::server::record::FieldDesc] =
7865 &[crate::server::record::FieldDesc::new(
7866 "S1",
7867 crate::types::DbFieldType::Long,
7868 false,
7869 )];
7870
7871 struct AlarmingLogSweepRecord {
7872 s1: i32,
7873 alarm: bool,
7874 }
7875
7876 impl Record for AlarmingLogSweepRecord {
7877 fn record_type(&self) -> &'static str {
7878 "scaler"
7879 }
7880 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
7881 Ok(crate::server::record::ProcessOutcome::complete())
7882 }
7883 /// This fixture drives its alarm purely through `check_alarms`
7884 /// (`self.alarm`), so it must NOT also raise the central UDF alarm —
7885 /// otherwise the born `udf = 1` pins severity at INVALID every cycle
7886 /// and there is never a real NO_ALARM → INVALID transition to test.
7887 /// (Before `rec_gbl_check_udf` stopped fabricating a UDF message, the
7888 /// ALARM bit this test asserts came from that fabricated amsg
7889 /// flipping to "" — an artifact, not the severity transition the
7890 /// test name and comments describe.) With no UDF alarm, cycle 1
7891 /// genuinely clears the born UDF/INVALID to NO_ALARM, and the
7892 /// `self.alarm` cycle is a true severity transition.
7893 fn raises_udf_alarm(&self) -> bool {
7894 false
7895 }
7896 fn check_alarms(&mut self, common: &mut crate::server::record::CommonFields) {
7897 if self.alarm {
7898 crate::server::recgbl::rec_gbl_set_sevr(
7899 common,
7900 crate::server::recgbl::alarm_status::UDF_ALARM,
7901 crate::server::record::AlarmSeverity::Invalid,
7902 );
7903 }
7904 }
7905 fn get_field(&self, name: &str) -> Option<EpicsValue> {
7906 match name {
7907 "S1" => Some(EpicsValue::Long(self.s1)),
7908 _ => None,
7909 }
7910 }
7911 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
7912 match (name, value) {
7913 ("S1", EpicsValue::Long(v)) => {
7914 self.s1 = v;
7915 Ok(())
7916 }
7917 _ => Err(CaError::FieldNotFound(name.to_string())),
7918 }
7919 }
7920 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
7921 ALARMING_LOG_SWEEP_FIELDS
7922 }
7923 fn log_swept_fields(&self) -> &'static [&'static str] {
7924 &["S1"]
7925 }
7926 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
7927 Some(self)
7928 }
7929 }
7930
7931 /// CBUG-B19 — the sweep post carries the alarm-transition bits.
7932 ///
7933 /// DEVIATION from C, deliberate. C's scaler `monitor()` computes
7934 /// `monitor_mask = recGblResetAlarms(pscal)` (scalerRecord.c:764), ORs
7935 /// `DBE_VALUE|DBE_LOG` into it (`:766`), and then posts every `Sn` with a
7936 /// LITERAL `DBE_LOG` (`:771`) — `monitor_mask` is assigned, OR-ed, and never
7937 /// read. The alarm bit that `recGblResetAlarms` returns is exactly what every
7938 /// other record ORs into its value posts, so C drops it: a client subscribed
7939 /// to `Sn` with DBE_ALARM receives NOTHING on a severity transition.
7940 ///
7941 /// The DBE_VALUE half of C's dead `|=` is deliberately not resurrected — the
7942 /// sweep is unconditional, so a VALUE bit here would fire a value event on
7943 /// every idle scan whether or not the counts moved. The first assertion pins
7944 /// that.
7945 #[test]
7946 fn b19_log_swept_field_carries_the_alarm_transition_bits() {
7947 use crate::server::recgbl::EventMask;
7948 let mut inst = RecordInstance::new(
7949 "SW".to_string(),
7950 AlarmingLogSweepRecord {
7951 s1: 7,
7952 alarm: false,
7953 },
7954 );
7955 let _s1_rx = inst
7956 .add_subscriber(
7957 "S1",
7958 1,
7959 crate::types::DbFieldType::Long,
7960 (EventMask::LOG | EventMask::ALARM).bits(),
7961 )
7962 .expect("S1 subscriber");
7963 let mask_of = |snap: &ProcessSnapshot, f: &str| {
7964 snap.changed_fields
7965 .iter()
7966 .find(|(n, _, _)| n == f)
7967 .map(|(_, _, m)| *m)
7968 };
7969
7970 // Cycle 1 clears the record's initial UDF/INVALID alarm, which is itself
7971 // a transition; cycle 2 is the quiet baseline. The sweep is then DBE_LOG
7972 // alone — in particular NOT DBE_VALUE, since the counts have not moved.
7973 let _ = inst.process_local().unwrap();
7974 let (snap1, _) = inst.process_local().unwrap();
7975 assert_eq!(
7976 mask_of(&snap1, "S1").unwrap().bits(),
7977 EventMask::LOG.bits(),
7978 "no alarm transition → the sweep is DBE_LOG only"
7979 );
7980
7981 // The alarm fires: severity moves NO_ALARM → INVALID, so this cycle's
7982 // posts carry DBE_ALARM. C posts DBE_LOG here and the alarm subscriber
7983 // learns nothing.
7984 if let Some(r) = inst
7985 .record
7986 .as_any_mut()
7987 .and_then(|a| a.downcast_mut::<AlarmingLogSweepRecord>())
7988 {
7989 r.alarm = true;
7990 }
7991 let (snap2, _) = inst.process_local().unwrap();
7992 assert_eq!(
7993 mask_of(&snap2, "S1").unwrap().bits(),
7994 (EventMask::LOG | EventMask::ALARM).bits(),
7995 "the severity transition must reach the swept field (C drops it)"
7996 );
7997
7998 // Severity stays INVALID: no transition, so no alarm bit — the sweep is
7999 // DBE_LOG again.
8000 let (snap3, _) = inst.process_local().unwrap();
8001 assert_eq!(
8002 mask_of(&snap3, "S1").unwrap().bits(),
8003 EventMask::LOG.bits(),
8004 "a steady severity is not a transition"
8005 );
8006 }
8007
8008 /// Stub record that simulates a record whose process() mutates an
8009 /// internal metadata field. Used to verify that the
8010 /// `Record::took_metadata_change()` hook actually triggers cache
8011 /// invalidation in `process_local()`.
8012 struct MutatingMetaRecord {
8013 val: f64,
8014 egu: String,
8015 took_change: bool,
8016 }
8017
8018 impl Record for MutatingMetaRecord {
8019 fn record_type(&self) -> &'static str {
8020 "ai" // pretend to be ai so populate_display_info populates EGU
8021 }
8022 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
8023 // Simulate dynamic metadata change inside processing
8024 self.egu = "kV".into();
8025 self.took_change = true;
8026 Ok(crate::server::record::ProcessOutcome::complete())
8027 }
8028 fn get_field(&self, name: &str) -> Option<EpicsValue> {
8029 match name {
8030 "VAL" => Some(EpicsValue::Double(self.val)),
8031 "EGU" => Some(EpicsValue::String(self.egu.clone().into())),
8032 "PREC" => Some(EpicsValue::Short(0)),
8033 "HOPR" => Some(EpicsValue::Double(0.0)),
8034 "LOPR" => Some(EpicsValue::Double(0.0)),
8035 _ => None,
8036 }
8037 }
8038 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
8039 match (name, value) {
8040 ("VAL", EpicsValue::Double(v)) => {
8041 self.val = v;
8042 Ok(())
8043 }
8044 ("EGU", EpicsValue::String(s)) => {
8045 self.egu = s.as_str_lossy().into_owned();
8046 Ok(())
8047 }
8048 _ => Err(CaError::FieldNotFound(name.to_string())),
8049 }
8050 }
8051 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8052 &[]
8053 }
8054 fn took_metadata_change(&mut self) -> bool {
8055 let was = self.took_change;
8056 self.took_change = false; // reset after reporting
8057 was
8058 }
8059 }
8060
8061 #[test]
8062 fn process_local_invalidates_cache_on_took_metadata_change() {
8063 let mut inst = RecordInstance::new(
8064 "MUT".to_string(),
8065 MutatingMetaRecord {
8066 val: 1.0,
8067 egu: "V".to_string(),
8068 took_change: false,
8069 },
8070 );
8071
8072 // Build the cache once with the original EGU
8073 let snap1 = inst.snapshot_for_field("VAL").unwrap();
8074 assert_eq!(snap1.display.unwrap().units, "V");
8075 assert!(inst.metadata_cache.lock().unwrap().is_some());
8076
8077 // Run process_local — the stub record sets took_change inside process()
8078 let _ = inst.process_local();
8079
8080 // Cache should now be invalidated (took_metadata_change returned true)
8081 assert!(
8082 inst.metadata_cache.lock().unwrap().is_none(),
8083 "process_local should invalidate cache when took_metadata_change is true"
8084 );
8085
8086 // Next snapshot picks up the new EGU
8087 let snap2 = inst.snapshot_for_field("VAL").unwrap();
8088 assert_eq!(snap2.display.unwrap().units, "kV");
8089 }
8090
8091 /// Stub record that does NOT mutate metadata fields. Verifies the
8092 /// default `took_metadata_change` returns false and the cache stays.
8093 struct StableMetaRecord {
8094 val: f64,
8095 }
8096 impl Record for StableMetaRecord {
8097 fn record_type(&self) -> &'static str {
8098 "ai"
8099 }
8100 fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
8101 self.val += 1.0;
8102 Ok(crate::server::record::ProcessOutcome::complete())
8103 }
8104 fn get_field(&self, name: &str) -> Option<EpicsValue> {
8105 match name {
8106 "VAL" => Some(EpicsValue::Double(self.val)),
8107 "EGU" => Some(EpicsValue::String("V".into())),
8108 "PREC" => Some(EpicsValue::Short(0)),
8109 "HOPR" => Some(EpicsValue::Double(0.0)),
8110 "LOPR" => Some(EpicsValue::Double(0.0)),
8111 _ => None,
8112 }
8113 }
8114 fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
8115 Ok(())
8116 }
8117 fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8118 &[]
8119 }
8120 // took_metadata_change uses default impl (returns false)
8121 }
8122
8123 #[test]
8124 fn process_local_keeps_cache_when_no_metadata_change() {
8125 let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
8126
8127 let _ = inst.snapshot_for_field("VAL");
8128 assert!(inst.metadata_cache.lock().unwrap().is_some());
8129
8130 // Run process_local several times — cache should remain intact
8131 let _ = inst.process_local();
8132 assert!(inst.metadata_cache.lock().unwrap().is_some());
8133 let _ = inst.process_local();
8134 assert!(inst.metadata_cache.lock().unwrap().is_some());
8135 let _ = inst.process_local();
8136 assert!(inst.metadata_cache.lock().unwrap().is_some());
8137 }
8138
8139 // ── Regression: DBE_PROPERTY event delivery boundaries ──────────────
8140
8141 /// Subscribe `VAL` for PROPERTY, put `field`, run the post gate, and report
8142 /// whether an event was delivered. `put` must differ from the field's
8143 /// current value or the change-detection suppresses the post either way.
8144 fn property_event_on_put<R: Record>(rec: R, field: &str, put: EpicsValue) -> bool {
8145 use crate::server::recgbl::EventMask;
8146 let mut inst = RecordInstance::new("PROPGATE".to_string(), rec);
8147 let mut rx = inst
8148 .add_subscriber(
8149 "VAL",
8150 1,
8151 crate::types::DbFieldType::Double,
8152 EventMask::PROPERTY.bits(),
8153 )
8154 .expect("subscriber added");
8155 let prev = inst.record.get_field(field);
8156 assert_ne!(prev.as_ref(), Some(&put), "{field}: put must be a change");
8157 inst.record.put_field(field, put).expect("put accepted");
8158 inst.notify_field_written_if_changed(field, prev.as_ref(), LinkBacking::none());
8159 rx.try_recv().is_ok()
8160 }
8161
8162 /// Boundary: `prop(YES)`. `histogramRecord.dbd.pod` declares ULIM
8163 /// `special(SPC_RESET)` + `prop(YES)`, so C's `dbPut` sets
8164 /// `propertyUpdate` (dbAccess.c:1330) and posts DBE_PROPERTY. ULIM is
8165 /// nobody's cache source — it reaches the wire through the live
8166 /// `apply_field_metadata_override` — so a set keyed on cache sources
8167 /// cannot answer this, which is why the gate reads the declaration.
8168 #[test]
8169 fn prop_yes_field_posts_property_event() {
8170 use crate::server::records::histogram::HistogramRecord;
8171 assert!(
8172 property_event_on_put(
8173 HistogramRecord::default(),
8174 "ULIM",
8175 EpicsValue::Double(100.0)
8176 ),
8177 "histogram.ULIM is prop(YES) — a changed put must post DBE_PROPERTY"
8178 );
8179 }
8180
8181 /// Boundary: `pp(TRUE)` without `prop`. `biRecord.dbd.pod` declares ZSV
8182 /// `pp(TRUE)`, `menu(menuAlarmSevr)` and no `prop`, so C's
8183 /// `paddr->pfldDes->prop` is 0 and no property event is posted.
8184 #[test]
8185 fn pp_true_without_prop_posts_no_property_event() {
8186 use crate::server::records::bi::BiRecord;
8187 assert!(
8188 !property_event_on_put(BiRecord::default(), "ZSV", EpicsValue::Short(2)),
8189 "bi.ZSV is pp(TRUE) but not prop(YES) — it must post no DBE_PROPERTY"
8190 );
8191 }
8192
8193 /// Boundary 1: metadata field written with a CHANGED value, subscriber
8194 /// mask includes PROPERTY → subscriber receives an event.
8195 /// Mirrors C dbAccess.c:1395-1396 `if (propertyUpdate && !status)`
8196 /// `db_post_events(precord,NULL,DBE_PROPERTY)`.
8197 #[test]
8198 fn r47_property_event_delivered_on_changed_metadata() {
8199 use crate::server::recgbl::EventMask;
8200 let mut inst = ai_instance();
8201 let mut rx = inst
8202 .add_subscriber(
8203 "VAL",
8204 1,
8205 crate::types::DbFieldType::Double,
8206 EventMask::PROPERTY.bits(),
8207 )
8208 .expect("subscriber added");
8209
8210 let prev = inst.record.get_field("EGU"); // "degC"
8211 let _ = inst
8212 .record
8213 .put_field("EGU", EpicsValue::String("kPa".into()));
8214 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
8215
8216 assert!(
8217 rx.try_recv().is_ok(),
8218 "PROPERTY subscriber must receive event when metadata field changes"
8219 );
8220 }
8221
8222 /// Boundary 2: same metadata field written with the SAME value → NO event.
8223 /// Matches C suppression at dbAccess.c:1379-1383 and the `prev != now` gate.
8224 #[test]
8225 fn r47_no_event_on_unchanged_metadata() {
8226 use crate::server::recgbl::EventMask;
8227 let mut inst = ai_instance();
8228 let mut rx = inst
8229 .add_subscriber(
8230 "VAL",
8231 1,
8232 crate::types::DbFieldType::Double,
8233 EventMask::PROPERTY.bits(),
8234 )
8235 .expect("subscriber added");
8236
8237 let prev = inst.record.get_field("EGU"); // "degC"
8238 // Write the same value — no change
8239 let _ = inst.record.put_field("EGU", prev.clone().unwrap());
8240 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
8241
8242 assert!(
8243 rx.try_recv().is_err(),
8244 "PROPERTY subscriber must NOT receive event when metadata value is unchanged"
8245 );
8246 }
8247
8248 /// Boundary 3: VALUE-only subscriber (no PROPERTY bit) receives NO event
8249 /// from a metadata write, even when the field value changed.
8250 #[test]
8251 fn r47_value_only_subscriber_no_event_on_metadata_write() {
8252 use crate::server::recgbl::EventMask;
8253 let mut inst = ai_instance();
8254 let mut rx = inst
8255 .add_subscriber(
8256 "VAL",
8257 1,
8258 crate::types::DbFieldType::Double,
8259 EventMask::VALUE.bits(),
8260 )
8261 .expect("subscriber added");
8262
8263 let prev = inst.record.get_field("EGU"); // "degC"
8264 let _ = inst
8265 .record
8266 .put_field("EGU", EpicsValue::String("kPa".into()));
8267 inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
8268
8269 assert!(
8270 rx.try_recv().is_err(),
8271 "VALUE-only subscriber must NOT receive event from a metadata write"
8272 );
8273 }
8274
8275 /// Boundary 4 (took_metadata_change path): PROPERTY subscriber receives
8276 /// event after process_local() when the record reports a metadata change.
8277 #[test]
8278 fn r47_process_local_property_event_on_took_metadata_change() {
8279 use crate::server::recgbl::EventMask;
8280 let mut inst = RecordInstance::new(
8281 "MUT2".to_string(),
8282 MutatingMetaRecord {
8283 val: 1.0,
8284 egu: "V".to_string(),
8285 took_change: false,
8286 },
8287 );
8288 let mut rx = inst
8289 .add_subscriber(
8290 "VAL",
8291 1,
8292 crate::types::DbFieldType::Double,
8293 EventMask::PROPERTY.bits(),
8294 )
8295 .expect("subscriber added");
8296
8297 // process() sets took_change = true and updates egu to "kV"
8298 let _ = inst.process_local();
8299
8300 assert!(
8301 rx.try_recv().is_ok(),
8302 "PROPERTY subscriber must receive event after process_local reports took_metadata_change"
8303 );
8304 }
8305}
8306
8307#[cfg(test)]
8308mod aftc_filter_tests {
8309 //! Tests for the shared AFTC alarm-range filter
8310 //! (`records::alarm_filter::aftc_filter`) as driven by
8311 //! `evaluate_analog_alarm`. Pure-function tests: no record instance
8312 //! needed — the filter is a stateless transform of (raw_alarm, aftc,
8313 //! afvl_in, t_last, t_now). Algorithm provenance: 2009 EPICS
8314 //! Codeathon (epics-base `824d37811`), C `aiRecord.c:355-401`.
8315
8316 use crate::server::records::alarm_filter::aftc_filter;
8317 use std::time::{Duration, SystemTime};
8318
8319 fn at(secs: f64) -> SystemTime {
8320 SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
8321 }
8322
8323 #[test]
8324 fn disabled_when_aftc_le_zero() {
8325 // aftc=0 means filter disabled — pass-through.
8326 let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
8327 assert_eq!(out, 2);
8328 assert_eq!(afvl, 0.0);
8329 }
8330
8331 #[test]
8332 fn initial_sample_seeds_state_unchanged_alarm() {
8333 // afvl=0 means first sample after enable — alarm passes through
8334 // and accumulator seeds with the raw severity.
8335 let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
8336 assert_eq!(out, 2);
8337 assert_eq!(afvl, 2.0);
8338 }
8339
8340 #[test]
8341 fn raises_alarm_only_after_full_time_constant() {
8342 // Single-step heuristic: with `aftc = 3s` and `dt = 0.1s`, alpha
8343 // ≈ 0.967, so a one-shot raw_alarm=2 against afvl=0.0 should not
8344 // produce alarm=2 yet — the filter must hold off until the
8345 // accumulator crosses the threshold.
8346 // Seed with afvl=0.01 (tiny prior, simulating "almost no alarm
8347 // yet"); the filter must keep alarm at 0 after one short tick.
8348 let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
8349 assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
8350 assert!(afvl > 0.0 && afvl < 2.0);
8351 }
8352
8353 #[test]
8354 fn dt_zero_is_no_op() {
8355 // Two evaluations at the same instant produce no filter advance.
8356 let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
8357 assert_eq!(out, 1); // floor(|1.5|) = 1
8358 assert_eq!(afvl, 1.5);
8359 }
8360
8361 #[test]
8362 fn long_steady_state_converges_to_alarm() {
8363 // After many steps with raw_alarm=2 and dt much smaller than aftc,
8364 // the accumulator must converge towards 2.
8365 let aftc = 1.0;
8366 let mut afvl = 0.0;
8367 let mut last = at(0.0);
8368 let mut alarm = 0;
8369 for i in 1..=100 {
8370 let now = at(i as f64 * 0.05);
8371 let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
8372 alarm = out;
8373 afvl = new_afvl;
8374 last = now;
8375 }
8376 assert_eq!(
8377 alarm, 2,
8378 "after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
8379 );
8380 assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
8381 }
8382}
8383
8384#[cfg(test)]
8385mod check_deadband_tests {
8386 use super::check_deadband;
8387
8388 const NAN: f64 = f64::NAN;
8389 const INF: f64 = f64::INFINITY;
8390
8391 /// C's own test for this function, transcribed:
8392 /// `modules/database/test/ioc/db/recGblCheckDeadbandTest.c` runs all 19
8393 /// (oldval, newval) pairs it can build from {below-band, above-band,
8394 /// unchanged, -0.0, NaN, +inf, -inf} against deadbands -1, 0 and 1.5, and
8395 /// carries the expected mask for each of the 57 cells. Transcribing the
8396 /// table rather than writing cases per story is what keeps the pairs no C
8397 /// branch matches — `(NaN, NaN)` and same-signed infinity — from being
8398 /// dropped, since they are exactly the ones a reader is tempted to fold
8399 /// into "not comparable, so post".
8400 #[test]
8401 fn matches_the_c_recgblcheckdeadband_truth_table() {
8402 // t_SetValues: [oldval, newval]
8403 let pairs: [(f64, f64); 19] = [
8404 (1.0, 2.0),
8405 (0.0, 2.0),
8406 (0.0, 0.0),
8407 (-0.0, 0.0),
8408 (1.0, NAN),
8409 (1.0, INF),
8410 (1.0, -INF),
8411 (NAN, 1.0),
8412 (NAN, NAN),
8413 (NAN, INF),
8414 (NAN, -INF),
8415 (INF, 1.0),
8416 (INF, NAN),
8417 (INF, INF),
8418 (INF, -INF),
8419 (-INF, 1.0),
8420 (-INF, NAN),
8421 (-INF, INF),
8422 (-INF, -INF),
8423 ];
8424 // t_ExpectedUpdates, one row per deadband in t_Deadband.
8425 let expected: [(f64, [bool; 19]); 3] = [
8426 (
8427 -1.0,
8428 [
8429 true, true, true, true, true, true, true, true, true, true, true, true, true,
8430 true, true, true, true, true, true,
8431 ],
8432 ),
8433 (
8434 0.0,
8435 [
8436 true, true, false, false, true, true, true, true, false, true, true, true,
8437 true, false, true, true, true, true, false,
8438 ],
8439 ),
8440 (
8441 1.5,
8442 [
8443 false, true, false, false, true, true, true, true, false, true, true, true,
8444 true, false, true, true, true, true, false,
8445 ],
8446 ),
8447 ];
8448
8449 for (deadband, row) in expected {
8450 for (i, ((oldval, newval), want)) in pairs.iter().zip(row).enumerate() {
8451 assert_eq!(
8452 check_deadband(*newval, Some(*oldval), deadband),
8453 want,
8454 "C pattern {i}: deadband={deadband} oldval={oldval} newval={newval}"
8455 );
8456 }
8457 }
8458 }
8459
8460 /// The port-only state: a record type with no MLST/ALST cell has posted
8461 /// nothing, so the first comparison has no baseline and must fire whatever
8462 /// the value and the deadband are — including the value C's table says
8463 /// would not fire against an equal baseline.
8464 #[test]
8465 fn never_posted_fires_regardless_of_value_or_deadband() {
8466 for value in [0.0, 1.0, NAN, INF, -INF] {
8467 for deadband in [-1.0, 0.0, 1.5] {
8468 assert!(
8469 check_deadband(value, None, deadband),
8470 "never-posted must fire: value={value} deadband={deadband}"
8471 );
8472 }
8473 }
8474 }
8475}
8476
8477#[cfg(test)]
8478mod common_field_dbload_tests {
8479 use super::*;
8480 use crate::server::records::ai::AiRecord;
8481
8482 /// The db loader feeds every common field to `put_common_field` as an
8483 /// `EpicsValue::String`. Each numeric/menu common field directive must
8484 /// take effect at load — both the integer form (`field(PHAS, "1")`) and
8485 /// the menu-label form (`field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`) —
8486 /// rather than being silently dropped because the arm matched only its
8487 /// typed variant. One assertion per affected common-field arm.
8488 #[test]
8489 fn db_loaded_string_common_fields_take_effect() {
8490 let mut inst = RecordInstance::new("REC".to_string(), AiRecord::default());
8491 let put = |inst: &mut RecordInstance, f: &str, v: &str| {
8492 inst.put_common_field_db_load(f, EpicsValue::String(v.into()))
8493 .unwrap_or_else(|e| panic!("put_common_field_db_load({f}, {v:?}) failed: {e}"));
8494 };
8495
8496 // Integer-valued directives.
8497 put(&mut inst, "PHAS", "1");
8498 assert_eq!(inst.common.phas, 1, "field(PHAS, \"1\")");
8499 put(&mut inst, "TSE", "-2");
8500 assert_eq!(inst.common.tse, -2, "field(TSE, \"-2\")");
8501 put(&mut inst, "DISV", "1");
8502 assert_eq!(inst.common.disv, 1, "field(DISV, \"1\")");
8503 put(&mut inst, "DISA", "1");
8504 assert_eq!(inst.common.disa, 1, "field(DISA, \"1\")");
8505 put(&mut inst, "LCNT", "3");
8506 assert_eq!(inst.common.lcnt, 3, "field(LCNT, \"3\")");
8507 put(&mut inst, "DISP", "1");
8508 assert!(inst.common.disp != 0, "field(DISP, \"1\")");
8509 put(&mut inst, "UDF", "0");
8510 assert!(inst.common.udf == 0, "field(UDF, \"0\")");
8511
8512 // Menu-label directives (resolved via the one menu converter).
8513 put(&mut inst, "PRIO", "HIGH");
8514 assert_eq!(inst.common.prio, 2, "field(PRIO, \"HIGH\")");
8515 put(&mut inst, "DISS", "MAJOR");
8516 assert_eq!(
8517 inst.common.diss,
8518 AlarmSeverity::Major as i16,
8519 "field(DISS, \"MAJOR\")"
8520 );
8521 put(&mut inst, "UDFS", "NO_ALARM");
8522 assert_eq!(
8523 inst.common.udfs,
8524 AlarmSeverity::NoAlarm as i16,
8525 "field(UDFS, \"NO_ALARM\")"
8526 );
8527 put(&mut inst, "ACKT", "NO");
8528 assert!(!inst.common.ackt, "field(ACKT, \"NO\")");
8529
8530 // Numeric form of a menu field still works (field(PRIO, "0")).
8531 put(&mut inst, "PRIO", "0");
8532 assert_eq!(inst.common.prio, 0, "field(PRIO, \"0\")");
8533
8534 // A String-typed common field is untouched by the coercion.
8535 put(&mut inst, "DESC", "a description");
8536 assert_eq!(inst.common.desc.as_str_lossy().as_ref(), "a description");
8537 }
8538}
8539
8540#[cfg(test)]
8541mod declared_override_tests {
8542 use super::*;
8543 use crate::server::records::dfanout::DfanoutRecord;
8544
8545 /// A field `dfanout`'s `.dbd` DECLARES (HOPR/LOPR/PREC/EGU) but the
8546 /// `DfanoutRecord` struct models no storage for: a put must be ACCEPTED and
8547 /// stored (C `dbPut` writes it into record memory), and a later
8548 /// `resolve_field` must serve the written value — not the `.dbd` initial.
8549 #[test]
8550 fn declared_but_unmodeled_field_put_is_stored_and_served() {
8551 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
8552
8553 // Untouched: reads its declared default (initial / type-zero), NOT an
8554 // error, and the override store is empty.
8555 assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(0.0)));
8556 assert!(inst.declared_overrides.is_empty());
8557
8558 // DBF_DOUBLE, DBF_SHORT and DBF_STRING declared metadata fields all
8559 // land, coerced to the declared type.
8560 inst.put_common_field("HOPR", EpicsValue::String("10".into()))
8561 .expect("caput dfanout.HOPR 10 must be accepted");
8562 inst.put_common_field("PREC", EpicsValue::String("3".into()))
8563 .expect("caput dfanout.PREC 3 must be accepted");
8564 inst.put_common_field("EGU", EpicsValue::String("volts".into()))
8565 .expect("caput dfanout.EGU volts must be accepted");
8566
8567 assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(10.0)));
8568 assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
8569 assert_eq!(
8570 inst.resolve_field("EGU"),
8571 Some(EpicsValue::String("volts".into()))
8572 );
8573 // Case-insensitive key: the lower-case read reaches the same slot.
8574 assert_eq!(inst.resolve_field("hopr"), Some(EpicsValue::Double(10.0)));
8575 }
8576
8577 /// The declared type's C range rules apply through the write-side coercion
8578 /// owner: `caput dfanout.PREC 99999` into a `DBF_SHORT` is REFUSED (C
8579 /// `epicsParseInt16` overflow → `S_db_badField`), and the field keeps its
8580 /// prior value — never wraps to a garbage `Short`.
8581 #[test]
8582 fn declared_override_honors_declared_type_range() {
8583 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
8584 inst.put_common_field("PREC", EpicsValue::String("3".into()))
8585 .expect("in-range PREC accepted");
8586 assert!(
8587 inst.put_common_field("PREC", EpicsValue::String("99999".into()))
8588 .is_err(),
8589 "PREC 99999 overflows DBF_SHORT and must be refused"
8590 );
8591 assert!(
8592 inst.put_common_field("PREC", EpicsValue::String("abc".into()))
8593 .is_err(),
8594 "non-numeric PREC must be refused"
8595 );
8596 // The refused puts left the accepted value intact.
8597 assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
8598 }
8599
8600 /// An UNDECLARED field name is still `FieldNotFound` — the override store
8601 /// captures only fields with a real `dbFldDes`, so a misspelled field is
8602 /// refused exactly as C's `dbNameToAddr` refuses it.
8603 #[test]
8604 fn undeclared_field_is_still_not_found() {
8605 let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
8606 assert!(matches!(
8607 inst.put_common_field("XYZZY", EpicsValue::String("1".into())),
8608 Err(CaError::FieldNotFound(_))
8609 ));
8610 assert!(inst.declared_overrides.is_empty());
8611 }
8612
8613 /// A PARTIALLY modeled field — one the record SERVES via `get_field` but
8614 /// has no `put_field` arm for (`calcout.PVAL` → `self.pval`) — must NOT
8615 /// land in the override map: doing so would place the value where
8616 /// `resolve_field` (which reads `get_field` first) never sees it, a silent
8617 /// write loss. The override is only for fields the record serves nothing
8618 /// for; a partially modeled field's put is the record's own concern.
8619 #[test]
8620 fn partially_modeled_field_is_not_captured_by_override() {
8621 use crate::server::records::calcout::CalcoutRecord;
8622 let mut inst = RecordInstance::new("CO".to_string(), CalcoutRecord::default());
8623 // PVAL is served by the record (its own storage), so it is not stored
8624 // in the override map; the map stays empty and no ghost cell shadows
8625 // the record's read.
8626 let _ = inst.put_common_field("PVAL", EpicsValue::String("1".into()));
8627 assert!(
8628 inst.declared_overrides.is_empty(),
8629 "a field the record serves via get_field must not enter the override map"
8630 );
8631 }
8632}
8633
8634#[cfg(test)]
8635mod pact_exit_tests {
8636 use super::*;
8637 use crate::server::records::ai::AiRecord;
8638
8639 fn instance() -> RecordInstance {
8640 RecordInstance::new("PACT:REC".to_string(), AiRecord::new(0.0))
8641 }
8642
8643 /// The two boundary values of the bit `leave_pact` mints. It is minted
8644 /// under the `&mut self` the caller already holds, which is what lets
8645 /// `PvDatabase::apply_pact_exit` take no record lock and so be safe to
8646 /// call from a `Drop` that still has a `rec.write()` alive in scope.
8647 #[test]
8648 fn leave_pact_reports_an_empty_restart_queue_as_nothing_to_do() {
8649 let mut inst = instance();
8650 inst.enter_pact();
8651 assert!(!inst.leave_pact().restart_pending());
8652 }
8653
8654 #[test]
8655 fn leave_pact_reports_a_queued_notify_so_the_tail_drains_it() {
8656 let mut inst = instance();
8657 inst.enter_pact();
8658 let (tx, _rx) = crate::runtime::sync::oneshot::channel();
8659 inst.queue_notify_put(DeferredNotify::Process { completion: tx });
8660 assert!(inst.leave_pact().restart_pending());
8661 }
8662}
8663
8664#[cfg(test)]
8665mod declaration_gate_tests {
8666 use super::*;
8667 use crate::server::records::{bi::BiRecord, calc::CalcRecord, histogram::HistogramRecord};
8668
8669 fn inst(name: &str, record: Box<dyn Record>) -> RecordInstance {
8670 RecordInstance::new_boxed(name.to_string(), record)
8671 }
8672
8673 /// The boundary is DECLARED / NOT DECLARED, one case each way per
8674 /// storage that this port keeps for every record but C keeps per record
8675 /// type. Every expectation measured on `softIoc` R7.0.10-146 with
8676 /// `record(calc,"C:GOOD")`, `record(bi,"B:ONE")`,
8677 /// `record(histogram,"H:ONE")`:
8678 ///
8679 /// ```text
8680 /// dbgf C:GOOD.OUT PV 'C:GOOD.OUT' not found
8681 /// dbgf C:GOOD.INP PV 'C:GOOD.INP' not found
8682 /// dbgf C:GOOD.SSCN PV 'C:GOOD.SSCN' not found
8683 /// dbgf C:GOOD.OLDSIMM PV 'C:GOOD.OLDSIMM' not found
8684 /// dbgf C:GOOD.NOSUCH PV 'C:GOOD.NOSUCH' not found
8685 /// dbgf C:GOOD.RTYP DBF_STRING: "calc"
8686 /// dbgf C:GOOD.NAME DBF_STRING: "C:GOOD"
8687 /// dbgf B:ONE.INP DBF_STRING: ""
8688 /// dbgf B:ONE.OUT PV 'B:ONE.OUT' not found
8689 /// dbgf B:ONE.HIHI PV 'B:ONE.HIHI' not found
8690 /// dbgf H:ONE.INP PV 'H:ONE.INP' not found
8691 /// ```
8692 #[test]
8693 fn a_field_resolves_exactly_where_the_record_type_declares_it() {
8694 let calc = inst("C:GOOD", Box::new(CalcRecord::default()));
8695 for undeclared in ["OUT", "INP", "SSCN", "OLDSIMM", "NOSUCH"] {
8696 assert_eq!(calc.resolve_field(undeclared), None, "calc.{undeclared}");
8697 }
8698 // Undeclared, but C's `dbNameToAddr` falls through to the record
8699 // type's attributes for it.
8700 assert_eq!(
8701 calc.resolve_field("RTYP"),
8702 Some(EpicsValue::String("calc".into()))
8703 );
8704 // Declared by dbCommon, so it stays readable.
8705 assert_eq!(
8706 calc.resolve_field("NAME"),
8707 Some(EpicsValue::String("C:GOOD".into()))
8708 );
8709 assert!(calc.resolve_field("CALC").is_some());
8710
8711 // The same storage, on a record type that DOES declare INP and does
8712 // not declare OUT or the analog-alarm ladder.
8713 let bi = inst("B:ONE", Box::new(BiRecord::default()));
8714 assert_eq!(bi.resolve_field("INP"), Some(EpicsValue::String("".into())));
8715 assert_eq!(bi.resolve_field("OUT"), None);
8716 assert_eq!(bi.resolve_field("HIHI"), None);
8717
8718 // `histogramRecord.dbd` declares SVL, not INP — the case
8719 // `Record::declares_inp_link` was written for, now answered by the
8720 // declaration itself.
8721 let histogram = inst("H:ONE", Box::new(HistogramRecord::default()));
8722 assert_eq!(histogram.resolve_field("INP"), None);
8723 assert!(histogram.resolve_field("SVL").is_some());
8724 }
8725
8726 /// The channel-existence side must agree with the read side, or a client
8727 /// gets a SEARCH answered and a CREATE refused (or worse, the reverse).
8728 /// `resolve_string_view_field` is the `$` long-string route to the same
8729 /// funnel.
8730 #[test]
8731 fn the_long_string_view_is_gated_by_the_same_declaration() {
8732 let calc = inst("C:GOOD", Box::new(CalcRecord::default()));
8733 assert_eq!(calc.resolve_string_view_field("OUT"), None);
8734 assert!(calc.resolve_string_view_field("CALC").is_some());
8735 }
8736}
8737
8738#[cfg(test)]
8739mod link_field_rendering_tests {
8740 use super::render_link_field;
8741
8742 /// One case per boundary of C's `dbGetString` link switch
8743 /// (`dbStaticLib.c:1906-2050`), not one per scenario: the modifier chain has
8744 /// a defaulted arm, and the three field types mask it differently, so the
8745 /// cases that matter are the mask edges rather than a walk of realistic
8746 /// links.
8747 #[test]
8748 fn a_link_field_renders_with_cs_parsed_modifiers() {
8749 use crate::types::DbfLinkClass::{FwdLink, InLink, OutLink};
8750 for (class, text, want) in [
8751 // An input link's absent modifiers are C's defaults, not absences.
8752 (InLink, "L:B", "L:B NPP NMS"),
8753 (InLink, "L:B MS", "L:B NPP MS"),
8754 (InLink, "L:B PP MS", "L:B PP MS"),
8755 (InLink, "L:B MSI", "L:B NPP MSI"),
8756 (InLink, "L:B MSS", "L:B NPP MSS"),
8757 // The process class is one assignment down C's chain, so ` CA`
8758 // appears only when no PP/CP/CPP won it.
8759 (InLink, "L:B CA", "L:B CA NMS"),
8760 (InLink, "L:B CP", "L:B CP NMS"),
8761 (InLink, "L:B CPP", "L:B CPP NMS"),
8762 (InLink, "L:B CP CA", "L:B CA NMS"),
8763 (InLink, "L:B CP NPP", "L:B NPP NMS"),
8764 // The target is the slice before the first space, verbatim: a
8765 // `.FIELD` survives where a rebuild through `channel_name` would
8766 // drop an explicit `.VAL`.
8767 (InLink, "L:B.SEVR MS", "L:B.SEVR NPP MS"),
8768 (InLink, "L:B.VAL", "L:B.VAL NPP NMS"),
8769 // `DBF_OUTLINK` masks CP/CPP off before the render sees it.
8770 (OutLink, "L:B", "L:B NPP NMS"),
8771 (OutLink, "L:B CPP MS", "L:B NPP MS"),
8772 // `DBF_FWDLINK` keeps only CA and prints no severity switch at all.
8773 (FwdLink, "L:B", "L:B"),
8774 (FwdLink, "L:B CA", "L:B CA"),
8775 (FwdLink, "L:B PP MS", "L:B"),
8776 // Everything that is not a PV link is its own text.
8777 (InLink, "12.5", "12.5"),
8778 (InLink, "", ""),
8779 (InLink, "[1, 2, 3]", "[1, 2, 3]"),
8780 (InLink, "@dev p1 p2", "@dev p1 p2"),
8781 (InLink, "{\"const\":1}", "{\"const\":1}"),
8782 ] {
8783 assert_eq!(render_link_field(class, text), want, "{class:?} {text:?}");
8784 }
8785 }
8786}
8787
8788#[cfg(test)]
8789mod unanswerable_notify_tests {
8790 use super::*;
8791 use crate::server::records::calc::CalcRecord;
8792
8793 fn rec(name: &str) -> RecordInstance {
8794 RecordInstance::new(name.into(), CalcRecord::default())
8795 }
8796
8797 /// The ordinary case: the client is still waiting, so the slot is its own.
8798 /// C only reaches `dbNotifyCancel` from a teardown.
8799 #[test]
8800 fn a_waiting_client_keeps_the_slot() {
8801 let mut r = rec("A");
8802 let (tx, _rx) = crate::runtime::sync::oneshot::channel();
8803 r.install_or_queue_notify(tx).expect("slot was free");
8804 assert!(r.unanswerable_notify().is_none());
8805 assert!(r.has_notify(), "a live put-callback must not be cancelled");
8806 }
8807
8808 /// C `rsrvFreePutNotify` (`camessage.c:1630-1638`): the client went away
8809 /// with its put-callback still busy, so the notify leaves the record.
8810 #[test]
8811 fn a_departed_client_releases_the_slot() {
8812 let mut r = rec("A");
8813 let (tx, rx) = crate::runtime::sync::oneshot::channel();
8814 r.install_or_queue_notify(tx).expect("slot was free");
8815 drop(rx);
8816 let dead = r.unanswerable_notify().expect("nobody can answer it");
8817 assert!(r.release_notify(&dead));
8818 assert!(
8819 !r.has_notify(),
8820 "the record must be free for the next put-notify"
8821 );
8822 }
8823
8824 /// A notify that COMPLETED is not unanswerable — it answered. The sender is
8825 /// spent, so a receiver dropped afterwards says nothing about the slot.
8826 #[test]
8827 fn a_completed_notify_is_not_cancelled() {
8828 let mut r = rec("A");
8829 let (tx, rx) = crate::runtime::sync::oneshot::channel();
8830 let set = r.install_or_queue_notify(tx).expect("slot was free");
8831 set.leave();
8832 drop(rx);
8833 assert!(r.unanswerable_notify().is_none());
8834 }
8835
8836 /// The set names every record that holds it, entry and `dbNotifyAdd`
8837 /// target alike — C `pnotifyPvt->waitList` (`dbNotify.c:227`/`:498`), which
8838 /// `dbNotifyCancel` walks at `:428`.
8839 #[test]
8840 fn the_set_names_the_entry_and_every_chain_target() {
8841 let mut entry = rec("A");
8842 let mut target = rec("B");
8843 let (tx, _rx) = crate::runtime::sync::oneshot::channel();
8844 let set = entry.install_or_queue_notify(tx).expect("slot was free");
8845 target.join_put_notify(Some(&set));
8846 let members: Vec<String> = set
8847 .joined_records()
8848 .into_iter()
8849 .map(|n| n.to_string())
8850 .collect();
8851 assert_eq!(members, vec!["A".to_string(), "B".to_string()]);
8852 }
8853
8854 /// A chain target releases too. It holds the same wait-set through
8855 /// `dbNotifyAdd`, and the record whose cycle never ends is exactly the one
8856 /// that keeps it — a `busy` FLNK target left at VAL=1 declines its own
8857 /// `recGblFwdLink` by contract, so nothing else would ever free it. C
8858 /// empties the whole wait list (`dbNotify.c:428-430`) before it looks at
8859 /// the entry at all.
8860 #[test]
8861 fn a_chain_target_holding_the_same_set_releases_too() {
8862 let mut entry = rec("A");
8863 let mut target = rec("B");
8864 let (tx, rx) = crate::runtime::sync::oneshot::channel();
8865 let set = entry.install_or_queue_notify(tx).expect("slot was free");
8866 target.join_put_notify(Some(&set));
8867 drop(rx);
8868 let dead = target.unanswerable_notify().expect("nobody can answer it");
8869 assert!(Arc::ptr_eq(&dead, &set));
8870 assert!(target.release_notify(&dead));
8871 assert!(!target.has_notify(), "B must be free for the next put");
8872 assert!(entry.release_notify(&dead));
8873 assert!(!entry.has_notify());
8874 }
8875
8876 /// The record's own slot is the authority, not the name the sweep carries:
8877 /// a member that has since completed and taken a LIVE notify keeps it.
8878 /// C re-tests `precord->ppn` for the same reason (`restartCheck`'s
8879 /// `assert(precord->ppn)`, `dbNotify.c:154`).
8880 #[test]
8881 fn a_member_that_moved_on_to_a_live_notify_is_left_alone() {
8882 let mut entry = rec("A");
8883 let mut target = rec("B");
8884 let (tx, rx) = crate::runtime::sync::oneshot::channel();
8885 let dead = entry.install_or_queue_notify(tx).expect("slot was free");
8886 target.join_put_notify(Some(&dead));
8887 drop(rx);
8888
8889 // B finished its contribution and a fresh client took it.
8890 assert!(target.release_notify(&dead));
8891 let (tx2, _rx2) = crate::runtime::sync::oneshot::channel();
8892 target.install_or_queue_notify(tx2).expect("slot was free");
8893
8894 assert!(
8895 !target.release_notify(&dead),
8896 "the stale name must not evict the live notify"
8897 );
8898 assert!(target.has_notify());
8899 }
8900}