Skip to main content

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, &registry),
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}