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::runtime::sync::mpsc;
7
8use crate::error::{CaError, CaResult};
9use crate::server::pv::{MonitorEvent, Subscriber};
10use crate::server::snapshot::{ControlInfo, DisplayInfo, EnumInfo};
11use crate::types::{DbFieldType, EpicsValue, PvString};
12
13use super::alarm::{AlarmSeverity, AnalogAlarmConfig};
14use super::common_fields::CommonFields;
15use super::link::{ParsedLink, parse_link_v2, parse_output_link_v2};
16use super::record_trait::{
17    CommonFieldPutResult, ProcessSnapshot, Record, RecordProcessResult, SubroutineFn,
18};
19use super::scan::ScanType;
20
21/// Put-notify completion wait-set — the C `dbNotify.c` `processNotify`
22/// waitList analogue (`dbNotifyAdd` / `dbNotifyCompletion`).
23///
24/// A `ca_put_callback` / WRITE_NOTIFY completion must fire only after the
25/// originating (put-target) record AND every record reached through its
26/// FLNK / OUT / process-action dispatch chain (synchronous *or* async)
27/// has finished processing. A single wait-set owns the completion
28/// oneshot; only it fires, and only when the last chain member leaves.
29///
30/// Counting convention: [`Self::new`] arms `pending = 1` for the
31/// originating record (which always joins). Every additional PP target
32/// that will process under the active notify [`Self::enter`]s on join
33/// (C `dbNotifyAdd`), and every record [`Self::leave`]s when its
34/// processing completes (C `dbNotifyCompletion`). The oneshot fires on
35/// the `leave` that drops `pending` to zero.
36pub struct NotifyWaitSet {
37    pending: AtomicUsize,
38    tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
39}
40
41impl NotifyWaitSet {
42    /// Arm a wait-set whose `tx` fires when the chain settles. `pending`
43    /// starts at 1 for the originating record — its completion `leave`s
44    /// that implicit slot, so a put with no chain targets fires
45    /// immediately on the originating record's own completion.
46    pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
47        Arc::new(Self {
48            pending: AtomicUsize::new(1),
49            tx: StdMutex::new(Some(tx)),
50        })
51    }
52
53    /// A PP target joined the chain (C `dbNotifyAdd`). Balanced by exactly
54    /// one [`Self::leave`].
55    pub fn enter(&self) {
56        self.pending.fetch_add(1, Ordering::AcqRel);
57    }
58
59    /// A record finished its contribution (C `dbNotifyCompletion`). Fires
60    /// the completion oneshot on the `leave` that empties the set.
61    pub fn leave(&self) {
62        let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
63        debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
64        if prev == 1 {
65            if let Some(tx) = self.tx.lock().unwrap().take() {
66                let _ = tx.send(());
67            }
68        }
69    }
70
71    /// True once every chain member has left (the completion has fired).
72    /// Used by the put entry to decide synchronous (return `None`) vs
73    /// async-pending (return the receiver) completion.
74    pub fn completed(&self) -> bool {
75        self.pending.load(Ordering::Acquire) == 0
76    }
77}
78
79/// Cached metadata for a record.
80///
81/// Stores the result of `populate_display_info` / `populate_control_info` /
82/// `populate_enum_info` so subsequent `snapshot_for_field` /
83/// `make_monitor_snapshot` calls can skip rebuilding the metadata. The
84/// cache is invalidated whenever a metadata-class field is written
85/// (EGU, PREC, HOPR, LOPR, alarm limits, DRVH/DRVL, state strings).
86///
87/// In a CA-only IOC this is a CPU win; in a hybrid CA + PVA IOC where
88/// every snapshot needs full metadata for NTScalar serialization, the
89/// cache eliminates redundant per-event populate work.
90#[derive(Clone, Default)]
91pub(crate) struct MetadataSnapshot {
92    pub display: Option<DisplayInfo>,
93    pub control: Option<ControlInfo>,
94    pub enums: Option<EnumInfo>,
95}
96
97/// Returns true if this field is property-class — the C `prop(YES)`
98/// dbd attribute: writing a changed value posts `DBE_PROPERTY` to the
99/// record's subscribers AND invalidates the metadata cache. Field name
100/// is expected uppercase.
101///
102/// **Every field read by `populate_display_info`,
103/// `populate_control_info`, or `populate_enum_info` MUST be in this
104/// set** — otherwise the cache serves stale metadata until some other
105/// tracked field is written. The reverse does not hold: a field may be
106/// property-class without being a cache source (e.g. the motor fields
107/// below feed the live-computed `field_metadata_override`, never the
108/// cache — its invalidation on their write is harmless).
109///
110/// Currently uncovered (because they are not yet populated by any
111/// `populate_*` function): `DESC` (would map to `display.description`
112/// — populate hook missing), `Q:form` info tag (would map to
113/// `display.form`). Add to this set if/when those are wired up.
114fn is_metadata_field(name: &str) -> bool {
115    matches!(
116        name,
117        // Display info (analog + integer + motor) — `prop(YES)` in
118        // ai/ao/longin/longout DBDs.
119        "EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
120        // Alarm limits (used by both display and the analog_alarm config) —
121        // ai/ao/longin/longout `prop(YES)`.
122        | "HIHI" | "HIGH" | "LOW" | "LOLO"
123        // Alarm severities for the four limit thresholds —
124        // ai/ao/longin/longout `prop(YES)` per upstream DBDs
125        // (`aiRecord.dbd.pod` lines 357-388).
126        | "HHSV" | "HSV" | "LSV" | "LLSV"
127        // Output ctrl limits — ao/longout `prop(YES)`.
128        | "DRVH" | "DRVL"
129        // motor `prop(YES)` (`motorRecord.dbd` 154/161/289/361/368):
130        // VBAS/VMAX bound VELO's range, MRES the RVAL/RRBV raw range,
131        // DHLM/DLLM the DVAL/DRBV range — all served per field by
132        // `Record::field_metadata_override` (C get_graphic_double /
133        // get_control_double). HLM/LLM/EGU/PREC and the alarm limits
134        // are motor `prop(YES)` too, already listed above.
135        | "VBAS" | "VMAX" | "MRES" | "DHLM" | "DLLM"
136        // bi/bo/busy enum strings — `prop(YES)`.
137        | "ZNAM" | "ONAM"
138        // bi/bo state severities — `biRecord.dbd.pod` / `boRecord.dbd.pod`
139        // `prop(YES)` for ZSV/OSV/COSV (zero / one / change-of-state).
140        | "ZSV" | "OSV" | "COSV"
141        // mbbi/mbbo state strings (16 levels) — `prop(YES)`.
142        | "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
143        | "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
144    )
145}
146
147/// One alarm limit for a DBR_AL_DOUBLE response: the value when its
148/// severity threshold is enabled, `NaN` otherwise. Mirrors C
149/// `get_alarm_double`'s `prec->hhsv ? prec->hihi : epicsNAN`.
150fn gated(severity: AlarmSeverity, limit: f64) -> f64 {
151    if severity != AlarmSeverity::NoAlarm {
152        limit
153    } else {
154        f64::NAN
155    }
156}
157
158fn parse_alarm_severity(value: &EpicsValue) -> AlarmSeverity {
159    match value {
160        EpicsValue::Short(v) => AlarmSeverity::from_u16(*v as u16),
161        EpicsValue::String(s) => AlarmSeverity::from_u16(match s.as_str_lossy().as_ref() {
162            "NO_ALARM" => 0,
163            "MINOR" => 1,
164            "MAJOR" => 2,
165            "INVALID" => 3,
166            other => other.parse::<u16>().unwrap_or(0),
167        }),
168        other => AlarmSeverity::from_u16(other.to_f64().unwrap_or(0.0) as u16),
169    }
170}
171
172/// A type-erased record instance stored in the database.
173pub struct RecordInstance {
174    pub name: String,
175    pub record: Box<dyn Record>,
176    pub common: CommonFields,
177    pub subscribers: HashMap<String, Vec<Subscriber>>,
178    // Link parse cache
179    pub parsed_inp: ParsedLink,
180    pub parsed_out: ParsedLink,
181    pub parsed_flnk: ParsedLink,
182    pub parsed_sdis: ParsedLink,
183    pub parsed_tsel: ParsedLink,
184    // Device support
185    pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
186    // Subroutine (for sub records)
187    pub subroutine: Option<Arc<SubroutineFn>>,
188    // Re-entrancy guard
189    pub processing: AtomicBool,
190    // Put-notify wait-set this record currently belongs to (C
191    // `precord->ppn`). Set when the record joins an active put-notify
192    // (originating put target, or a FLNK/OUT PP target via `dbNotifyAdd`);
193    // taken + `leave`d when the record's processing completes. `None`
194    // outside any put-notify. See [`NotifyWaitSet`].
195    pub notify: Option<Arc<NotifyWaitSet>>,
196    // Last posted values for subscribed fields (generic change detection)
197    pub last_posted: HashMap<String, EpicsValue>,
198    /// Generation counter for ReprocessAfter timer cancellation.
199    /// Bumped each process cycle. Spawned timers check this to avoid
200    /// stale re-processes from accumulated timers.
201    pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
202    /// Per-record info tags from `info("key", "value")` directives in
203    /// the .db file (epics-base info(...) grammar). Consumers include
204    /// asyn (`asyn:READBACK`), record-as-PV bridge tags
205    /// (`Q:group`, `Q:form`), and IOC-specific extensions. Empty for
206    /// records loaded without info(...) clauses.
207    pub info: HashMap<String, String>,
208    /// Cached metadata (display/control/enums) — `None` means stale or
209    /// not yet built. Populated lazily by `snapshot_for_field` /
210    /// `make_monitor_snapshot` and invalidated by `invalidate_metadata_cache`
211    /// whenever a metadata-class field (EGU/PREC/HOPR/LOPR/limit/state)
212    /// is written.
213    ///
214    /// Wrapped in `std::sync::Mutex` for interior mutability — the
215    /// containing `RecordInstance` is shared via `Arc<RwLock<...>>` from
216    /// `PvDatabase`, and snapshot construction holds a read lock; the
217    /// inner Mutex lets us still mutate the cache from a `&self` method.
218    ///
219    /// # Cache invariant (CONTRACT)
220    ///
221    /// The cache is **only correct under the following contract**: every
222    /// code path that mutates a metadata-class field (the set defined in
223    /// the file-private `is_metadata_field` predicate) MUST call
224    /// [`RecordInstance::notify_field_written`] (or
225    /// [`RecordInstance::invalidate_metadata_cache`] directly) afterward.
226    ///
227    /// All current write paths in `field_io.rs` already do this. If you
228    /// add a new code path that:
229    ///
230    /// - calls `instance.record.put_field(...)` directly, OR
231    /// - mutates record fields from inside `Record::process()`,
232    ///   `Record::on_put`, or `Record::special` and that mutation could
233    ///   touch a metadata-class field, OR
234    /// - lets a `Box<dyn Record>` implementation expose its own
235    ///   mutation methods that change metadata fields,
236    ///
237    /// then call `instance.notify_field_written(field_name)` to keep the
238    /// cache consistent. Forgetting will produce a stale snapshot —
239    /// monitors will continue to see the old EGU/PREC/limits until the
240    /// next legitimate metadata-field write triggers invalidation.
241    ///
242    /// # Symmetric note for `populate_*` extensions
243    ///
244    /// If a future change adds a new field to `populate_display_info`,
245    /// `populate_control_info`, or `populate_enum_info` (e.g. populating
246    /// `display.form` from a record's `Q:form` info tag, or
247    /// `display.description` from DESC), the new source field name MUST
248    /// also be added to `is_metadata_field` so writes to it invalidate
249    /// the cache.
250    pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
251}
252
253impl RecordInstance {
254    pub fn new(name: String, record: impl Record) -> Self {
255        Self::new_boxed(name, Box::new(record))
256    }
257
258    pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
259        let rtype = record.record_type();
260        let analog_alarm = match rtype {
261            // C parity: every record type whose dbd carries
262            // HIHI/HIGH/LOW/LOLO/HHSV/HSV/LSV/LLSV gets an analog-alarm
263            // config slot. Previously calc / calcout were missing —
264            // their put_field for those fields silently no-op'd
265            // because `self.common.analog_alarm` was None at the
266            // mutation site. Confirmed via
267            // calcRecord.dbd.pod:716-744 (HIHI..LLSV) and
268            // calcoutRecord.dbd.pod:1103+ (same).
269            "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout" => {
270                Some(AnalogAlarmConfig::default())
271            }
272            _ => None,
273        };
274        let mut common = CommonFields::default();
275        common.analog_alarm = analog_alarm;
276
277        Self {
278            name,
279            record,
280            common,
281            subscribers: HashMap::new(),
282            parsed_inp: ParsedLink::None,
283            parsed_out: ParsedLink::None,
284            parsed_flnk: ParsedLink::None,
285            parsed_sdis: ParsedLink::None,
286            parsed_tsel: ParsedLink::None,
287            device: None,
288            subroutine: None,
289            processing: AtomicBool::new(false),
290            notify: None,
291            last_posted: HashMap::new(),
292            reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
293            info: HashMap::new(),
294            metadata_cache: StdMutex::new(None),
295        }
296    }
297
298    /// Set a single `info("key", "value")` tag on this record. Last
299    /// write wins. Used by the .db loader (`info(...)` directive) and
300    /// `dbpf`-style tools.
301    pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
302        self.info.insert(key.into(), value.into());
303    }
304
305    /// Look up a single info tag. Returns `None` when the record has
306    /// no tag with that key.
307    pub fn get_info(&self, key: &str) -> Option<&str> {
308        self.info.get(key).map(|s| s.as_str())
309    }
310
311    /// Invalidate the metadata cache. Called after writing any
312    /// metadata-class field (EGU, PREC, HOPR/LOPR, alarm limits,
313    /// DRVH/DRVL, enum strings). The next snapshot will rebuild the
314    /// cache from the new values.
315    pub fn invalidate_metadata_cache(&self) {
316        if let Ok(mut guard) = self.metadata_cache.lock() {
317            *guard = None;
318        }
319    }
320
321    /// Hook called by the database after a field is written. If the
322    /// field is in the metadata-class set, the cache is invalidated so
323    /// the next snapshot picks up the new value.
324    ///
325    /// Field name is automatically uppercased.
326    pub fn notify_field_written(&self, field: &str) {
327        let upper = field.to_ascii_uppercase();
328        if is_metadata_field(&upper) {
329            self.invalidate_metadata_cache();
330        }
331    }
332
333    /// Like [`notify_field_written`] but skips the invalidation when
334    /// the put did not actually change the field's value. Mirrors
335    /// epics-base `faac1df1` — `DBE_PROPERTY` events fire only on
336    /// real changes, not on idempotent writes (the C path compares
337    /// `paddr->pfield` against the converted payload before setting
338    /// the `propertyUpdate` flag).
339    ///
340    /// `prev` is the value captured BEFORE the put. Callers that
341    /// don't need the change-detection (e.g. internal writers that
342    /// know the field is non-metadata) can keep using
343    /// [`notify_field_written`].
344    // must post EventMask::PROPERTY to all field subscribers when metadata changes
345    pub fn notify_field_written_if_changed(&self, field: &str, prev: Option<&EpicsValue>) {
346        let upper = field.to_ascii_uppercase();
347        if !is_metadata_field(&upper) {
348            return;
349        }
350        let now = self.record.get_field(&upper);
351        if prev != now.as_ref() {
352            self.invalidate_metadata_cache();
353            // mirror C dbAccess.c:1396-1397 db_post_events(precord, NULL, DBE_PROPERTY).
354            // Collect keys first to avoid a re-entrant immutable borrow on subscribers.
355            let fields: Vec<String> = self.subscribers.keys().cloned().collect();
356            for f in fields {
357                self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
358            }
359        }
360    }
361
362    /// Returns the cached MetadataSnapshot, building and storing it on
363    /// the first call (or after invalidation). Used by both
364    /// `snapshot_for_field` and `make_monitor_snapshot` so the populate
365    /// cost is paid at most once per metadata-stable interval.
366    fn cached_metadata(&self) -> MetadataSnapshot {
367        // Fast path: cache hit
368        if let Ok(guard) = self.metadata_cache.lock()
369            && let Some(cached) = guard.as_ref()
370        {
371            return cached.clone();
372        }
373
374        // Cache miss: build a fresh metadata snapshot
375        let mut tmp = super::super::snapshot::Snapshot::new(
376            EpicsValue::Double(0.0),
377            0,
378            0,
379            std::time::SystemTime::UNIX_EPOCH,
380        );
381        self.populate_display_info(&mut tmp);
382        self.populate_control_info(&mut tmp);
383        self.populate_enum_info(&mut tmp);
384
385        let meta = MetadataSnapshot {
386            display: tmp.display,
387            control: tmp.control,
388            enums: tmp.enums,
389        };
390
391        // Store back; ignore poisoning (cache is best-effort).
392        if let Ok(mut guard) = self.metadata_cache.lock() {
393            *guard = Some(meta.clone());
394        }
395        meta
396    }
397
398    /// Check if the record is currently processing (PACT equivalent).
399    pub fn is_processing(&self) -> bool {
400        self.processing.load(std::sync::atomic::Ordering::Acquire)
401    }
402
403    /// Unified field resolution: record fields → common fields → virtual fields.
404    pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
405        let name = name.to_ascii_uppercase();
406        self.record
407            .get_field(&name)
408            .or_else(|| self.get_common_field(&name))
409            .or_else(|| self.get_virtual_field(&name))
410    }
411
412    /// Resolve a field for EPICS `$` long-string (character-array) access.
413    ///
414    /// The `$` channel-name modifier (C `dbChannel.c:486-505`) re-views a
415    /// field as a `DBR_CHAR` array: a `DBF_STRING` field becomes a char
416    /// array of `field_size` elements, a link field a char array of
417    /// `PVLINK_STRINGSZ`, and every other field type is rejected with
418    /// `S_dbLib_fieldNotFound`. pvxs serves that char view as a
419    /// `form = "String"` long-string `NTScalar` — it reads the `DBR_CHAR`
420    /// bytes and NUL-terminates them back into a string
421    /// (`ioc/iocsource.cpp:133-136`, `ioc/channel.cpp:62-74`).
422    ///
423    /// Both `DBF_STRING` fields and link fields resolve to an
424    /// [`EpicsValue::String`] in this database (a link resolves to its
425    /// textual form, see [`Self::get_common_field`]), so a field is
426    /// `$`-eligible exactly when it resolves to a string value. Returns
427    /// that string value for an eligible field, or `None` for a field the
428    /// `$` modifier cannot view as a char array (the
429    /// `S_dbLib_fieldNotFound` case) — the single owner of the
430    /// dbChannel `$`-eligibility rule for the channel-resolution layer.
431    pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
432        match self.resolve_field(name)? {
433            v @ EpicsValue::String(_) => Some(v),
434            _ => None,
435        }
436    }
437
438    /// Choice table for a field served as `DBR_ENUM` from a `DBF_MENU`:
439    /// the record's own record-specific menu
440    /// ([`Record::menu_field_choices`](super::record_trait::Record::menu_field_choices)),
441    /// else a shared menu keyed by field name
442    /// ([`shared_menu_choices`](super::menu_choices::shared_menu_choices)).
443    fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
444        self.record
445            .menu_field_choices(field)
446            .or_else(|| super::menu_choices::shared_menu_choices(field))
447    }
448
449    /// Promote a `DBF_MENU` field's value to its `DBR_ENUM` client form: a
450    /// menu index stored as a short becomes [`EpicsValue::Enum`], so the
451    /// wire type a client sees is `DBR_ENUM` (CA) / `NTEnum` (PVA),
452    /// matching C dbStaticLib serving `DBF_MENU` as `DBR_ENUM`. The
453    /// menu index is held internally as `DbFieldType::Short`, so only that
454    /// representation is promoted; a same-named field that is not a menu
455    /// index here (e.g. `scalcout.OSV`, a string) is returned unchanged.
456    /// Idempotent for a value already delivered as `Enum` (`.SCAN`/`SSCN`,
457    /// the record-specific `SELM`).
458    fn promote_menu_value(&self, field: &str, value: EpicsValue) -> EpicsValue {
459        if self.menu_choices_for(field).is_some() {
460            if let EpicsValue::Short(idx) = value {
461                return EpicsValue::Enum(idx as u16);
462            }
463        }
464        value
465    }
466
467    /// The client-facing value of `field`: the resolved value with a
468    /// `DBF_MENU` field promoted to its `DBR_ENUM` form (see
469    /// [`Self::promote_menu_value`]), so a wire type derived directly from
470    /// the value matches the GET/MONITOR data. Used by the CA create-
471    /// channel path, which reads the native type from the value rather
472    /// than from [`Self::snapshot_for_field`].
473    pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
474        let value = self.resolve_field(field)?;
475        Some(self.promote_menu_value(field, value))
476    }
477
478    /// Attach the `DBF_MENU` → `DBR_ENUM` representation to a built
479    /// snapshot: promote the value to [`EpicsValue::Enum`] and attach the
480    /// menu's `menu()` choice labels so the CA/PVA enum encoders present
481    /// them. The single owner of "menu field -> (enum value, choice
482    /// table)" for both the GET ([`Self::snapshot_for_field`]) and MONITOR
483    /// ([`Self::make_monitor_snapshot`]) snapshot builders, so the wire
484    /// form is identical on every delivery path. A same-named non-menu
485    /// field (whose value is not a menu index) keeps its plain value and
486    /// gets no choice table.
487    fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
488        let Some(choices) = self.menu_choices_for(field) else {
489            return;
490        };
491        snap.value = self.promote_menu_value(field, snap.value.clone());
492        if matches!(snap.value, EpicsValue::Enum(_)) {
493            snap.enums = Some(super::super::snapshot::EnumInfo {
494                strings: choices.iter().map(|s| PvString::from(*s)).collect(),
495            });
496        }
497    }
498
499    /// Build a Snapshot with full metadata for the given field.
500    pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
501        let value = self.resolve_field(field)?;
502        let mut snap = super::super::snapshot::Snapshot::new(
503            value,
504            self.common.stat,
505            self.common.sevr as u16,
506            self.common.time,
507        );
508        // Default the served `timeStamp.userTag` to the record's `utag`,
509        // mirroring pvxs `iocsource.cpp:245` (`auto utag = meta.utag;`).
510        // The 64-bit `epicsUTag` narrows to the int32 NT wire field by
511        // truncating to the low 32 bits — pvxs assigns the same uint64
512        // straight into the `Int32` `timeStamp.userTag`. The `Q:time:tag`
513        // nsec-LSB split below overrides this when configured, matching
514        // pvxs `if(info.nsecMask) utag = meta.time.nsec & info.nsecMask;`
515        // (:247).
516        snap.user_tag = self.common.utag as i32;
517
518        // Pull display/control/enums from the metadata cache (build on
519        // first call, hit thereafter until invalidated by a metadata-class
520        // field write).
521        let meta = self.cached_metadata();
522        snap.display = meta.display;
523        snap.control = meta.control;
524        snap.enums = meta.enums;
525
526        // Per-field RSET metadata (C get_units/get_precision/
527        // get_graphic_double/get_control_double/get_alarm_double key on
528        // dbGetFieldIndex) patches the record-level cache for this field.
529        self.apply_field_metadata_override(field, &mut snap);
530
531        // Common-field enum mapping (e.g. .SCAN choices) is field-specific
532        // and not part of the per-record cache.
533        self.populate_common_enum_info(field, &mut snap);
534
535        // DBF_MENU field (a shared menu such as `OMSL`/`HHSV`/`SIMM`/... or
536        // a record-specific menu such as `sel.SELM`): carry the menu index
537        // as DBR_ENUM and attach its `menu()` choice labels. See
538        // `attach_menu_enum`. This overrides any record VAL enum table
539        // copied from the metadata cache above, because a menu field
540        // carries its own menu's choices, not the record's VAL state
541        // strings.
542        self.attach_menu_enum(field, &mut snap);
543
544        // apply `info(Q:time:tag, "nsec:lsb:N")` — pvxs
545        // typeutils.cpp:79 splits the low N bits of the timestamp's
546        // nanoseconds into `timeStamp.userTag` and clears those bits
547        // from `nanoseconds`. Standard pvxs convention is `nsec:lsb:N`
548        // with N in 1..=30; values outside that range are ignored to
549        // match pvxs's bounds clamp. The split is applied to both
550        // `snap.timestamp` and `snap.user_tag` so downstream encoders
551        // (NTScalar `timeStamp`, QSRV groups via `+nsecmask`) all see
552        // the same shape.
553        if let Some(n) = self.parse_qtime_tag_nsec_lsb() {
554            crate::server::snapshot::apply_nsec_lsb_split(&mut snap, n);
555        }
556
557        Some(snap)
558    }
559
560    /// Parse `info(Q:time:tag, "nsec:lsb:N")` and return `N`.
561    /// Returns `None` when the info tag is absent or malformed (pvxs
562    /// silently ignores bad values; we match that by returning None
563    /// so the timestamp is emitted unchanged).
564    fn parse_qtime_tag_nsec_lsb(&self) -> Option<u8> {
565        let raw = self.get_info("Q:time:tag")?;
566        // Accept `nsec:lsb:N` with arbitrary whitespace and case.
567        let mut parts = raw.split(':');
568        let key = parts.next()?.trim();
569        let suffix = parts.next()?.trim();
570        let n = parts.next()?.trim();
571        if !key.eq_ignore_ascii_case("nsec") || !suffix.eq_ignore_ascii_case("lsb") {
572            return None;
573        }
574        let n: u32 = n.parse().ok()?;
575        // pvxs clamps to [1, 30]; values outside leave the timestamp
576        // alone (the userTag is meaningful only with a non-trivial
577        // mask, and >30 would consume all nanoseconds).
578        if (1..=30).contains(&n) {
579            Some(n as u8)
580        } else {
581            None
582        }
583    }
584
585    /// Populate DisplayInfo from record fields if applicable.
586    fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
587        let rtype = self.record.record_type();
588        match rtype {
589            "ai" | "ao" | "calc" | "calcout" => {
590                let egu = self
591                    .record
592                    .get_field("EGU")
593                    .and_then(|v| {
594                        if let EpicsValue::String(s) = v {
595                            Some(s)
596                        } else {
597                            None
598                        }
599                    })
600                    .unwrap_or_default();
601                let prec = self
602                    .record
603                    .get_field("PREC")
604                    .and_then(|v| v.to_f64())
605                    .unwrap_or(0.0) as i16;
606                let hopr = self
607                    .record
608                    .get_field("HOPR")
609                    .and_then(|v| v.to_f64())
610                    .unwrap_or(0.0);
611                let lopr = self
612                    .record
613                    .get_field("LOPR")
614                    .and_then(|v| v.to_f64())
615                    .unwrap_or(0.0);
616                let (hihi, high, low, lolo) = self.alarm_limits();
617                snap.display = Some(super::super::snapshot::DisplayInfo {
618                    units: egu,
619                    precision: prec,
620                    upper_disp_limit: hopr,
621                    lower_disp_limit: lopr,
622                    upper_alarm_limit: hihi,
623                    upper_warning_limit: high,
624                    lower_warning_limit: low,
625                    lower_alarm_limit: lolo,
626                    ..Default::default()
627                });
628            }
629            "longin" | "longout" | "int64in" | "int64out" => {
630                let egu = self
631                    .record
632                    .get_field("EGU")
633                    .and_then(|v| {
634                        if let EpicsValue::String(s) = v {
635                            Some(s)
636                        } else {
637                            None
638                        }
639                    })
640                    .unwrap_or_default();
641                let hopr = self
642                    .record
643                    .get_field("HOPR")
644                    .and_then(|v| v.to_f64())
645                    .unwrap_or(0.0);
646                let lopr = self
647                    .record
648                    .get_field("LOPR")
649                    .and_then(|v| v.to_f64())
650                    .unwrap_or(0.0);
651                // longin/longout severity-gate (get_alarm_double);
652                // int64in/int64out send the limits verbatim (C is
653                // unconditional for those two record types only).
654                let (hihi, high, low, lolo) = match rtype {
655                    "int64in" | "int64out" => self.alarm_limits_unchecked(),
656                    _ => self.alarm_limits(),
657                };
658                snap.display = Some(super::super::snapshot::DisplayInfo {
659                    units: egu,
660                    precision: 0,
661                    upper_disp_limit: hopr,
662                    lower_disp_limit: lopr,
663                    upper_alarm_limit: hihi,
664                    upper_warning_limit: high,
665                    lower_warning_limit: low,
666                    lower_alarm_limit: lolo,
667                    ..Default::default()
668                });
669            }
670            // waveform/aai/aao — HOPR/LOPR/PREC/EGU for VAL display limits.
671            // (waveformRecord.c:251-252,239; aaiRecord.c:280-281,268; aaoRecord.c:283-284)
672            "waveform" | "aai" | "aao" => {
673                let egu = self
674                    .record
675                    .get_field("EGU")
676                    .and_then(|v| {
677                        if let EpicsValue::String(s) = v {
678                            Some(s)
679                        } else {
680                            None
681                        }
682                    })
683                    .unwrap_or_default();
684                let prec = self
685                    .record
686                    .get_field("PREC")
687                    .and_then(|v| v.to_f64())
688                    .unwrap_or(0.0) as i16;
689                let hopr = self
690                    .record
691                    .get_field("HOPR")
692                    .and_then(|v| v.to_f64())
693                    .unwrap_or(0.0);
694                let lopr = self
695                    .record
696                    .get_field("LOPR")
697                    .and_then(|v| v.to_f64())
698                    .unwrap_or(0.0);
699                snap.display = Some(super::super::snapshot::DisplayInfo {
700                    units: egu,
701                    precision: prec,
702                    upper_disp_limit: hopr,
703                    lower_disp_limit: lopr,
704                    upper_alarm_limit: 0.0,
705                    upper_warning_limit: 0.0,
706                    lower_warning_limit: 0.0,
707                    lower_alarm_limit: 0.0,
708                    ..Default::default()
709                });
710            }
711            // compress — HOPR/LOPR/PREC/EGU for VAL display limits.
712            // (compressRecord.c:478-479,464,455)
713            "compress" => {
714                let egu = self
715                    .record
716                    .get_field("EGU")
717                    .and_then(|v| {
718                        if let EpicsValue::String(s) = v {
719                            Some(s)
720                        } else {
721                            None
722                        }
723                    })
724                    .unwrap_or_default();
725                let prec = self
726                    .record
727                    .get_field("PREC")
728                    .and_then(|v| v.to_f64())
729                    .unwrap_or(0.0) as i16;
730                let hopr = self
731                    .record
732                    .get_field("HOPR")
733                    .and_then(|v| v.to_f64())
734                    .unwrap_or(0.0);
735                let lopr = self
736                    .record
737                    .get_field("LOPR")
738                    .and_then(|v| v.to_f64())
739                    .unwrap_or(0.0);
740                snap.display = Some(super::super::snapshot::DisplayInfo {
741                    units: egu,
742                    precision: prec,
743                    upper_disp_limit: hopr,
744                    lower_disp_limit: lopr,
745                    upper_alarm_limit: 0.0,
746                    upper_warning_limit: 0.0,
747                    lower_warning_limit: 0.0,
748                    lower_alarm_limit: 0.0,
749                    ..Default::default()
750                });
751            }
752            "motor" => {
753                let egu = self
754                    .record
755                    .get_field("EGU")
756                    .and_then(|v| {
757                        if let EpicsValue::String(s) = v {
758                            Some(s)
759                        } else {
760                            None
761                        }
762                    })
763                    .unwrap_or_default();
764                let prec = self
765                    .record
766                    .get_field("PREC")
767                    .and_then(|v| v.to_f64())
768                    .unwrap_or(0.0) as i16;
769                let hlm = self
770                    .record
771                    .get_field("HLM")
772                    .and_then(|v| v.to_f64())
773                    .unwrap_or(0.0);
774                let llm = self
775                    .record
776                    .get_field("LLM")
777                    .and_then(|v| v.to_f64())
778                    .unwrap_or(0.0);
779                snap.display = Some(super::super::snapshot::DisplayInfo {
780                    units: egu,
781                    precision: prec,
782                    upper_disp_limit: hlm,
783                    lower_disp_limit: llm,
784                    upper_alarm_limit: 0.0,
785                    upper_warning_limit: 0.0,
786                    lower_warning_limit: 0.0,
787                    lower_alarm_limit: 0.0,
788                    ..Default::default()
789                });
790            }
791            _ => {}
792        }
793    }
794
795    /// Populate ControlInfo from record fields if applicable.
796    fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
797        let rtype = self.record.record_type();
798        match rtype {
799            // ao unconditionally uses DRVH/DRVL (aoRecord.c:356-357).
800            "ao" => {
801                let upper = self
802                    .record
803                    .get_field("DRVH")
804                    .and_then(|v| v.to_f64())
805                    .unwrap_or(0.0);
806                let lower = self
807                    .record
808                    .get_field("DRVL")
809                    .and_then(|v| v.to_f64())
810                    .unwrap_or(0.0);
811                snap.control = Some(super::super::snapshot::ControlInfo {
812                    upper_ctrl_limit: upper,
813                    lower_ctrl_limit: lower,
814                });
815            }
816            // longout/int64out use DRVH/DRVL only when drvh > drvl, else HOPR/LOPR
817            // (longoutRecord.c:282-287, int64outRecord.c:265-270).
818            "longout" | "int64out" => {
819                let drvh = self
820                    .record
821                    .get_field("DRVH")
822                    .and_then(|v| v.to_f64())
823                    .unwrap_or(0.0);
824                let drvl = self
825                    .record
826                    .get_field("DRVL")
827                    .and_then(|v| v.to_f64())
828                    .unwrap_or(0.0);
829                let (upper, lower) = if drvh > drvl {
830                    (drvh, drvl)
831                } else {
832                    let hopr = self
833                        .record
834                        .get_field("HOPR")
835                        .and_then(|v| v.to_f64())
836                        .unwrap_or(0.0);
837                    let lopr = self
838                        .record
839                        .get_field("LOPR")
840                        .and_then(|v| v.to_f64())
841                        .unwrap_or(0.0);
842                    (hopr, lopr)
843                };
844                snap.control = Some(super::super::snapshot::ControlInfo {
845                    upper_ctrl_limit: upper,
846                    lower_ctrl_limit: lower,
847                });
848            }
849            "motor" => {
850                // Motor records use HLM/LLM as control limits
851                let hlm = self
852                    .record
853                    .get_field("HLM")
854                    .and_then(|v| v.to_f64())
855                    .unwrap_or(0.0);
856                let llm = self
857                    .record
858                    .get_field("LLM")
859                    .and_then(|v| v.to_f64())
860                    .unwrap_or(0.0);
861                snap.control = Some(super::super::snapshot::ControlInfo {
862                    upper_ctrl_limit: hlm,
863                    lower_ctrl_limit: llm,
864                });
865            }
866            // int64in uses HOPR/LOPR as control limits (int64inRecord.c:226-227)
867            "ai" | "int64in" | "longin" | "calc" | "calcout" => {
868                // Input records use HOPR/LOPR as control limits
869                let hopr = self
870                    .record
871                    .get_field("HOPR")
872                    .and_then(|v| v.to_f64())
873                    .unwrap_or(0.0);
874                let lopr = self
875                    .record
876                    .get_field("LOPR")
877                    .and_then(|v| v.to_f64())
878                    .unwrap_or(0.0);
879                snap.control = Some(super::super::snapshot::ControlInfo {
880                    upper_ctrl_limit: hopr,
881                    lower_ctrl_limit: lopr,
882                });
883            }
884            _ => {}
885        }
886    }
887
888    /// Populate EnumInfo from record fields if applicable.
889    fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
890        let rtype = self.record.record_type();
891        match rtype {
892            // bi/bo/busy — C trims no_str to 1 when ZNAM set and ONAM empty (boRecord.c:342-352).
893            "bi" | "bo" | "busy" => {
894                let znam = self
895                    .record
896                    .get_field("ZNAM")
897                    .and_then(|v| {
898                        if let EpicsValue::String(s) = v {
899                            Some(s)
900                        } else {
901                            None
902                        }
903                    })
904                    .unwrap_or_default();
905                let onam = self
906                    .record
907                    .get_field("ONAM")
908                    .and_then(|v| {
909                        if let EpicsValue::String(s) = v {
910                            Some(s)
911                        } else {
912                            None
913                        }
914                    })
915                    .unwrap_or_default();
916                let no_str_1 = !znam.is_empty() && onam.is_empty();
917                let mut strings = vec![znam, onam];
918                if no_str_1 {
919                    strings.truncate(1);
920                }
921                snap.enums = Some(super::super::snapshot::EnumInfo { strings });
922            }
923            // mbbi/mbbo — C uses highwater mark: last non-empty index + 1 (mbbiRecord.c:262-269).
924            "mbbi" | "mbbo" => {
925                let state_fields = [
926                    "ZRST", "ONST", "TWST", "THST", "FRST", "FVST", "SXST", "SVST", "EIST", "NIST",
927                    "TEST", "ELST", "TVST", "TTST", "FTST", "FFST",
928                ];
929                let mut strings: Vec<PvString> = state_fields
930                    .iter()
931                    .map(|f| {
932                        self.record
933                            .get_field(f)
934                            .and_then(|v| {
935                                if let EpicsValue::String(s) = v {
936                                    Some(s)
937                                } else {
938                                    None
939                                }
940                            })
941                            .unwrap_or_default()
942                    })
943                    .collect();
944                let no_str = strings
945                    .iter()
946                    .rposition(|s| !s.is_empty())
947                    .map(|i| i + 1)
948                    .unwrap_or(0);
949                strings.truncate(no_str);
950                snap.enums = Some(super::super::snapshot::EnumInfo { strings });
951            }
952            _ => {}
953        }
954    }
955
956    /// Populate enum strings for common fields accessed via CA (e.g. .SCAN).
957    fn populate_common_enum_info(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
958        match field {
959            "SCAN" => {
960                snap.enums = Some(super::super::snapshot::EnumInfo {
961                    strings: vec![
962                        "Passive".into(),
963                        "Event".into(),
964                        "I/O Intr".into(),
965                        "10 second".into(),
966                        "5 second".into(),
967                        "2 second".into(),
968                        "1 second".into(),
969                        ".5 second".into(),
970                        ".2 second".into(),
971                        ".1 second".into(),
972                    ],
973                });
974            }
975            _ => {}
976        }
977    }
978
979    /// Extract analog alarm limits from CommonFields.
980    // DBR_GR_*/DBR_CTRL_* alarm limits MUST be severity-gated — C
981    // get_alarm_double returns `prec->hhsv ? prec->hihi : epicsNAN`
982    // (aiRecord.c:295-298 and ao/longin/longout/calc/calcout). int64in/
983    // int64out are the sole exception (unconditional, int64inRecord.c:239-243)
984    // and use `alarm_limits_unchecked()`. NaN encodes byte-exact for every
985    // DBR variant: f64/f32 keep NaN, integer casts make `NaN as iN == 0`,
986    // matching dbAccess.c:300-326 (`finite(ald)?cast:0`).
987    fn alarm_limits(&self) -> (f64, f64, f64, f64) {
988        match self.common.analog_alarm {
989            // Each limit is reported only when its severity is enabled,
990            // exactly as C `get_alarm_double` (`x ? limit : epicsNAN`).
991            Some(ref aa) => (
992                gated(aa.hhsv, aa.hihi),
993                gated(aa.hsv, aa.high),
994                gated(aa.lsv, aa.low),
995                gated(aa.llsv, aa.lolo),
996            ),
997            // No analog-alarm config ⇒ all severities are NO_ALARM in C,
998            // so every limit is NaN (not 0).
999            None => (f64::NAN, f64::NAN, f64::NAN, f64::NAN),
1000        }
1001    }
1002
1003    // int64in/int64out are the one analog family whose C `get_alarm_double`
1004    // is UNCONDITIONAL (int64inRecord.c:239-243, int64outRecord.c:283-287):
1005    // the limits are sent verbatim regardless of HHSV/HSV/LSV/LLSV. Keep a
1006    // separate accessor so the gated `alarm_limits()` cannot leak into this
1007    // path.
1008    fn alarm_limits_unchecked(&self) -> (f64, f64, f64, f64) {
1009        if let Some(ref aa) = self.common.analog_alarm {
1010            (aa.hihi, aa.high, aa.low, aa.lolo)
1011        } else {
1012            (0.0, 0.0, 0.0, 0.0)
1013        }
1014    }
1015
1016    /// Get a common field value.
1017    pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
1018        match name {
1019            "SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
1020            "STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
1021            "NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
1022            "NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
1023            // epics-base PR #568 / #566 — alarm message string.
1024            "AMSG" => Some(EpicsValue::String(self.common.amsg.clone().into())),
1025            "NAMSG" => Some(EpicsValue::String(self.common.namsg.clone().into())),
1026            "ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
1027            "ACKT" => Some(EpicsValue::Char(if self.common.ackt { 1 } else { 0 })),
1028            "UDF" => Some(EpicsValue::Char(if self.common.udf { 1 } else { 0 })),
1029            "UDFS" => Some(EpicsValue::Short(self.common.udfs as i16)),
1030            "SCAN" => Some(EpicsValue::Enum(self.common.scan as u16)),
1031            "SSCN" => Some(EpicsValue::Enum(self.common.sscn as u16)),
1032            "PINI" => Some(EpicsValue::Char(if self.common.pini { 1 } else { 0 })),
1033            "TPRO" => Some(EpicsValue::Char(if self.common.tpro { 1 } else { 0 })),
1034            "BKPT" => Some(EpicsValue::Char(self.common.bkpt)),
1035            "FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
1036            "INP" => Some(EpicsValue::String(self.common.inp.clone().into())),
1037            "OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
1038            "DTYP" => Some(EpicsValue::String(self.common.dtyp.clone().into())),
1039            "TSE" => Some(EpicsValue::Short(self.common.tse)),
1040            "TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
1041            // C `UTAG` is DBF_UINT64 — exposed natively as the unsigned
1042            // 64-bit value variant so values above i64::MAX round-trip.
1043            "UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
1044            "ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
1045            "ASL" => Some(EpicsValue::Char(self.common.asl)),
1046            "DESC" => Some(EpicsValue::String(self.common.desc.clone())),
1047            "PHAS" => Some(EpicsValue::Short(self.common.phas)),
1048            "EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
1049            "PRIO" => Some(EpicsValue::Short(self.common.prio)),
1050            "DISV" => Some(EpicsValue::Short(self.common.disv)),
1051            "DISA" => Some(EpicsValue::Short(self.common.disa)),
1052            "SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
1053            "DISS" => Some(EpicsValue::Short(self.common.diss as i16)),
1054            "HYST" => Some(EpicsValue::Double(self.common.hyst)),
1055            "LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
1056            "DISP" => Some(EpicsValue::Char(if self.common.disp { 1 } else { 0 })),
1057            "PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
1058            "RPRO" => Some(EpicsValue::Char(if self.common.rpro { 1 } else { 0 })),
1059            "PACT" => Some(EpicsValue::Char(
1060                if self.processing.load(std::sync::atomic::Ordering::Acquire) {
1061                    1
1062                } else {
1063                    0
1064                },
1065            )),
1066            "PROC" => Some(EpicsValue::Char(0)), // Always 0 (trigger-only)
1067            // Analog alarm fields
1068            "HIHI" => self
1069                .common
1070                .analog_alarm
1071                .as_ref()
1072                .map(|a| EpicsValue::Double(a.hihi)),
1073            "HIGH" => self
1074                .common
1075                .analog_alarm
1076                .as_ref()
1077                .map(|a| EpicsValue::Double(a.high)),
1078            "LOW" => self
1079                .common
1080                .analog_alarm
1081                .as_ref()
1082                .map(|a| EpicsValue::Double(a.low)),
1083            "LOLO" => self
1084                .common
1085                .analog_alarm
1086                .as_ref()
1087                .map(|a| EpicsValue::Double(a.lolo)),
1088            "HHSV" => self
1089                .common
1090                .analog_alarm
1091                .as_ref()
1092                .map(|a| EpicsValue::Short(a.hhsv as i16)),
1093            "HSV" => self
1094                .common
1095                .analog_alarm
1096                .as_ref()
1097                .map(|a| EpicsValue::Short(a.hsv as i16)),
1098            "LSV" => self
1099                .common
1100                .analog_alarm
1101                .as_ref()
1102                .map(|a| EpicsValue::Short(a.lsv as i16)),
1103            "LLSV" => self
1104                .common
1105                .analog_alarm
1106                .as_ref()
1107                .map(|a| EpicsValue::Short(a.llsv as i16)),
1108            // swait OUTN is aliased to common.out
1109            "OUTN" => {
1110                if self.record.record_type() == "swait" {
1111                    Some(EpicsValue::String(self.common.out.clone().into()))
1112                } else {
1113                    None
1114                }
1115            }
1116            _ => None,
1117        }
1118    }
1119
1120    /// Set a common field value. Returns what scan index changes are needed.
1121    pub fn put_common_field(
1122        &mut self,
1123        name: &str,
1124        value: EpicsValue,
1125    ) -> CaResult<CommonFieldPutResult> {
1126        let name = name.to_ascii_uppercase();
1127        self.record.validate_put(&name, &value)?;
1128        self.record.special(&name, false)?;
1129        match name.as_str() {
1130            "SEVR" => {
1131                if let EpicsValue::Short(v) = value {
1132                    self.common.sevr = AlarmSeverity::from_u16(v as u16);
1133                }
1134            }
1135            "STAT" => {
1136                if let EpicsValue::Short(v) = value {
1137                    self.common.stat = v as u16;
1138                }
1139            }
1140            "NSEV" => {
1141                if let EpicsValue::Short(v) = value {
1142                    self.common.nsev = AlarmSeverity::from_u16(v as u16);
1143                }
1144            }
1145            "NSTA" => {
1146                if let EpicsValue::Short(v) = value {
1147                    self.common.nsta = v as u16;
1148                }
1149            }
1150            "AMSG" => {
1151                if let EpicsValue::String(s) = value {
1152                    self.common.amsg = s.as_str_lossy().into_owned();
1153                }
1154            }
1155            "NAMSG" => {
1156                if let EpicsValue::String(s) = value {
1157                    self.common.namsg = s.as_str_lossy().into_owned();
1158                }
1159            }
1160            "ACKS" => {
1161                if let EpicsValue::Short(v) = value {
1162                    let sev = AlarmSeverity::from_u16(v as u16);
1163                    // C `dbAccess.c:1309` putAcks:
1164                    //   `if (*psev >= precord->acks) precord->acks = 0;`
1165                    // The written severity is compared against the
1166                    // STORED unacknowledged severity `acks` — NOT the
1167                    // current `sevr`. An operator acknowledging an
1168                    // alarm at the severity that was latched into ACKS
1169                    // must clear it even after `sevr` has since
1170                    // dropped; comparing against `sevr` instead would
1171                    // leave a stale unacknowledged alarm stuck.
1172                    if sev >= self.common.acks {
1173                        self.common.acks = AlarmSeverity::NoAlarm;
1174                    }
1175                }
1176            }
1177            "ACKT" => {
1178                let new_ackt = match value {
1179                    EpicsValue::Char(v) => v != 0,
1180                    EpicsValue::Short(v) => v != 0,
1181                    _ => return Ok(CommonFieldPutResult::NoChange),
1182                };
1183                self.common.ackt = new_ackt;
1184                // C `dbAccess.c:1294-1297` putAckt: when ACKT is set
1185                // false (transient acknowledgement disabled) and the
1186                // stored unacknowledged severity is higher than the
1187                // current `sevr`, lower `acks` down to `sevr` — a
1188                // transient alarm that has already cleared should not
1189                // keep a sticky higher-severity ACKS once transient
1190                // acknowledgement is turned off.
1191                if !new_ackt && self.common.acks > self.common.sevr {
1192                    self.common.acks = self.common.sevr;
1193                }
1194            }
1195            "UDF" => {
1196                if let EpicsValue::Char(v) = value {
1197                    self.common.udf = v != 0;
1198                }
1199            }
1200            "UDFS" => {
1201                if let EpicsValue::Short(v) = value {
1202                    self.common.udfs = AlarmSeverity::from_u16(v as u16);
1203                }
1204            }
1205            "SCAN" => {
1206                let old_scan = self.common.scan;
1207                let new_scan = match &value {
1208                    EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
1209                    EpicsValue::Enum(v) => ScanType::from_u16(*v),
1210                    EpicsValue::String(s) => ScanType::from_str(s.as_str_lossy().as_ref())?,
1211                    _ => return Ok(CommonFieldPutResult::NoChange),
1212                };
1213                self.common.scan = new_scan;
1214                if old_scan != new_scan {
1215                    let phas = self.common.phas;
1216                    self.record.on_put(&name);
1217                    let _ = self.record.special(&name, true);
1218                    return Ok(CommonFieldPutResult::ScanChanged {
1219                        old_scan,
1220                        new_scan,
1221                        phas,
1222                    });
1223                }
1224            }
1225            "SSCN" => {
1226                let new_sscn = match &value {
1227                    EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
1228                    EpicsValue::Enum(v) => ScanType::from_u16(*v),
1229                    EpicsValue::String(s) => ScanType::from_str(s.as_str_lossy().as_ref())?,
1230                    _ => return Ok(CommonFieldPutResult::NoChange),
1231                };
1232                self.common.sscn = new_sscn;
1233            }
1234            "PINI" => {
1235                if let EpicsValue::Char(v) = value {
1236                    self.common.pini = v != 0;
1237                } else if let EpicsValue::String(s) = &value {
1238                    self.common.pini = s == "YES" || s == "1" || s == "true";
1239                }
1240            }
1241            "TPRO" => {
1242                if let EpicsValue::Char(v) = value {
1243                    self.common.tpro = v != 0;
1244                }
1245            }
1246            "BKPT" => {
1247                if let EpicsValue::Char(v) = value {
1248                    self.common.bkpt = v;
1249                }
1250            }
1251            "FLNK" => {
1252                if let EpicsValue::String(s) = value {
1253                    self.common.flnk = s.as_str_lossy().into_owned();
1254                    self.parsed_flnk = parse_link_v2(&self.common.flnk);
1255                }
1256            }
1257            "INP" => {
1258                if let EpicsValue::String(s) = value {
1259                    self.common.inp = s.as_str_lossy().into_owned();
1260                    self.parsed_inp = parse_link_v2(&self.common.inp);
1261                }
1262            }
1263            "OUT" => {
1264                if let EpicsValue::String(s) = value {
1265                    let s = s.as_str_lossy();
1266                    // C parity (acd1aef): CP/CPP modifiers on output links are
1267                    // meaningless (they request "process on change" semantics
1268                    // that only apply to input links). dbParseLink in C strips
1269                    // them and emits an errlogPrintf warning naming the source
1270                    // record and field. Mirror the diagnostic here.
1271                    let trimmed = s.trim_end();
1272                    if trimmed.ends_with(" CP") || trimmed.ends_with(" CPP") {
1273                        tracing::warn!(
1274                            target: "epics_base_rs::record",
1275                            record = %name,
1276                            field = "OUT",
1277                            "CP/CPP modifier ignored on output link"
1278                        );
1279                    }
1280                    self.common.out = s.into_owned();
1281                    // C `dbDbPutValue` (dbDbLink.c:386-389): an OUT
1282                    // link processes its target only on an explicit
1283                    // ` PP` token (or a `.PROC` destination). A bare
1284                    // OUT link is NPP — `parse_output_link_v2`
1285                    // downgrades the modifier-less `ProcessPassive`
1286                    // default that `parse_link_v2` would otherwise
1287                    // apply.
1288                    self.parsed_out = parse_output_link_v2(&self.common.out);
1289                    // C `longoutRecord.c::special` (PR #6c573b4 part 2)
1290                    // and similar OOCH-style hooks need `after=true`
1291                    // to fire after the link has actually moved. The
1292                    // earlier `validate_put` + `special(name, false)`
1293                    // pair only covered the before-side.
1294                    let _ = self.record.special(&name, true);
1295                }
1296            }
1297            "DTYP" => {
1298                if let EpicsValue::String(s) = value {
1299                    self.common.dtyp = s.as_str_lossy().into_owned();
1300                }
1301            }
1302            "TSE" => {
1303                if let EpicsValue::Short(v) = value {
1304                    self.common.tse = v;
1305                }
1306            }
1307            "TSEL" => {
1308                if let EpicsValue::String(s) = value {
1309                    self.common.tsel = s.as_str_lossy().into_owned();
1310                    self.parsed_tsel = parse_link_v2(&self.common.tsel);
1311                }
1312            }
1313            "UTAG" => {
1314                // C UTAG is DBF_UINT64 — accept any integer-shaped
1315                // value and store the unsigned 64-bit tag.
1316                match value {
1317                    EpicsValue::UInt64(v) => self.common.utag = v,
1318                    EpicsValue::Int64(v) => self.common.utag = v as u64,
1319                    EpicsValue::Long(v) => self.common.utag = v as u64,
1320                    EpicsValue::Short(v) => self.common.utag = v as u64,
1321                    EpicsValue::Enum(v) => self.common.utag = v as u64,
1322                    EpicsValue::Char(v) => self.common.utag = v as u64,
1323                    _ => {}
1324                }
1325            }
1326            "ASG" => {
1327                if let EpicsValue::String(s) = value {
1328                    self.common.asg = s.as_str_lossy().into_owned();
1329                }
1330            }
1331            "ASL" => {
1332                // C dbCommon.ASL is `epicsUInt32` in the .dbd but
1333                // only ever 0 or 1; accept Char / Short / Long for
1334                // the common put paths and clamp to {0, 1}.
1335                // db_loader feeds every common field as
1336                // `EpicsValue::String`; also accept that so a
1337                // `.db` `field(ASL, "1")` directive isn't silently
1338                // ignored at IOC load.
1339                let n: i64 = match value {
1340                    EpicsValue::Char(v) => v as i64,
1341                    EpicsValue::Short(v) => v as i64,
1342                    EpicsValue::Long(v) => v as i64,
1343                    EpicsValue::Int64(v) => v,
1344                    EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
1345                    _ => return Ok(CommonFieldPutResult::NoChange),
1346                };
1347                self.common.asl = if n != 0 { 1 } else { 0 };
1348            }
1349            "DESC" => {
1350                if let EpicsValue::String(s) = value {
1351                    // DBF_STRING data field — store the bytes verbatim so a
1352                    // non-UTF-8 DESC round-trips unchanged.
1353                    self.common.desc = s;
1354                }
1355            }
1356            "PHAS" => {
1357                if let EpicsValue::Short(v) = value {
1358                    let old_phas = self.common.phas;
1359                    self.common.phas = v;
1360                    if old_phas != v && self.common.scan != ScanType::Passive {
1361                        let scan = self.common.scan;
1362                        self.record.on_put(&name);
1363                        let _ = self.record.special(&name, true);
1364                        return Ok(CommonFieldPutResult::PhasChanged {
1365                            scan,
1366                            old_phas,
1367                            new_phas: v,
1368                        });
1369                    }
1370                }
1371            }
1372            "EVNT" => {
1373                // C `EVNT` is DBF_STRING (event name). Accept a
1374                // string directly; accept a numeric value too for
1375                // backward compatibility (numeric events / a calc
1376                // record driving EVNT) by formatting it as a string.
1377                match value {
1378                    EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
1379                    EpicsValue::Short(v) => self.common.evnt = v.to_string(),
1380                    EpicsValue::Long(v) => self.common.evnt = v.to_string(),
1381                    EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
1382                    EpicsValue::Double(v) => {
1383                        // Match C `eventNameToHandle`: a double with
1384                        // an integer part is treated as that integer.
1385                        self.common.evnt = (v as i64).to_string();
1386                    }
1387                    _ => {}
1388                }
1389            }
1390            "PRIO" => {
1391                if let EpicsValue::Short(v) = value {
1392                    self.common.prio = v;
1393                }
1394            }
1395            "DISV" => {
1396                if let EpicsValue::Short(v) = value {
1397                    self.common.disv = v;
1398                }
1399            }
1400            "DISA" => {
1401                if let EpicsValue::Short(v) = value {
1402                    self.common.disa = v;
1403                }
1404            }
1405            "SDIS" => {
1406                if let EpicsValue::String(s) = value {
1407                    self.common.sdis = s.as_str_lossy().into_owned();
1408                    self.parsed_sdis = parse_link_v2(&self.common.sdis);
1409                }
1410            }
1411            "DISS" => {
1412                if let EpicsValue::Short(v) = value {
1413                    self.common.diss = AlarmSeverity::from_u16(v as u16);
1414                }
1415            }
1416            "HYST" => {
1417                if let EpicsValue::Double(v) = value {
1418                    self.common.hyst = v;
1419                }
1420            }
1421            "LCNT" => {
1422                if let EpicsValue::Short(v) = value {
1423                    self.common.lcnt = v;
1424                }
1425            }
1426            "DISP" => match value {
1427                EpicsValue::Char(v) => self.common.disp = v != 0,
1428                EpicsValue::Short(v) => self.common.disp = v != 0,
1429                _ => {}
1430            },
1431            "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
1432            "RPRO" => {
1433                if let EpicsValue::Char(v) = value {
1434                    self.common.rpro = v != 0;
1435                }
1436            }
1437            "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
1438            "PROC" => { /* Trigger handled by put_record_field_from_ca; no-op here */ }
1439            // Analog alarm fields — accept Double, Long, or String (DB-load path sends String)
1440            "HIHI" => {
1441                if let Some(a) = &mut self.common.analog_alarm {
1442                    if let Some(v) = value.to_f64().or_else(|| {
1443                        if let EpicsValue::String(s) = &value {
1444                            s.as_str_lossy().parse::<f64>().ok()
1445                        } else {
1446                            None
1447                        }
1448                    }) {
1449                        a.hihi = v;
1450                    }
1451                }
1452            }
1453            "HIGH" => {
1454                if let Some(a) = &mut self.common.analog_alarm {
1455                    if let Some(v) = value.to_f64().or_else(|| {
1456                        if let EpicsValue::String(s) = &value {
1457                            s.as_str_lossy().parse::<f64>().ok()
1458                        } else {
1459                            None
1460                        }
1461                    }) {
1462                        a.high = v;
1463                    }
1464                }
1465            }
1466            "LOW" => {
1467                if let Some(a) = &mut self.common.analog_alarm {
1468                    if let Some(v) = value.to_f64().or_else(|| {
1469                        if let EpicsValue::String(s) = &value {
1470                            s.as_str_lossy().parse::<f64>().ok()
1471                        } else {
1472                            None
1473                        }
1474                    }) {
1475                        a.low = v;
1476                    }
1477                }
1478            }
1479            "LOLO" => {
1480                if let Some(a) = &mut self.common.analog_alarm {
1481                    if let Some(v) = value.to_f64().or_else(|| {
1482                        if let EpicsValue::String(s) = &value {
1483                            s.as_str_lossy().parse::<f64>().ok()
1484                        } else {
1485                            None
1486                        }
1487                    }) {
1488                        a.lolo = v;
1489                    }
1490                }
1491            }
1492            "HHSV" => {
1493                if let Some(a) = &mut self.common.analog_alarm {
1494                    a.hhsv = parse_alarm_severity(&value);
1495                }
1496            }
1497            "HSV" => {
1498                if let Some(a) = &mut self.common.analog_alarm {
1499                    a.hsv = parse_alarm_severity(&value);
1500                }
1501            }
1502            "LSV" => {
1503                if let Some(a) = &mut self.common.analog_alarm {
1504                    a.lsv = parse_alarm_severity(&value);
1505                }
1506            }
1507            "LLSV" => {
1508                if let Some(a) = &mut self.common.analog_alarm {
1509                    a.llsv = parse_alarm_severity(&value);
1510                }
1511            }
1512            // swait-specific: OUTN is the output link name for swait records.
1513            // Mirrors to common.out so the processing framework dispatches it.
1514            "OUTN" => {
1515                if self.record.record_type() == "swait" {
1516                    if let EpicsValue::String(s) = value {
1517                        self.common.out = s.as_str_lossy().into_owned();
1518                        // Bare OUT link is NPP — see the "OUT" arm.
1519                        self.parsed_out = parse_output_link_v2(&self.common.out);
1520                    }
1521                }
1522            }
1523            _ => {}
1524        }
1525        self.record.on_put(&name);
1526        let _ = self.record.special(&name, true);
1527        Ok(CommonFieldPutResult::NoChange)
1528    }
1529
1530    /// Get virtual fields (NAME, RTYP).
1531    pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
1532        match name {
1533            "NAME" => Some(EpicsValue::String(self.name.clone().into())),
1534            "RTYP" => Some(EpicsValue::String(
1535                self.record.record_type().to_string().into(),
1536            )),
1537            _ => None,
1538        }
1539    }
1540
1541    /// Evaluate alarms based on record type and current value.
1542    /// Uses rec_gbl_set_sevr to accumulate into nsta/nsev.
1543    pub fn evaluate_alarms(&mut self) {
1544        use crate::server::recgbl::{self, alarm_status};
1545
1546        // Check UDF first
1547        recgbl::rec_gbl_check_udf(&mut self.common);
1548
1549        // Check CALC_ALARM for calc/calcout records
1550        let rtype = self.record.record_type();
1551        if rtype == "calc" || rtype == "calcout" || rtype == "scalcout" {
1552            // calc_alarm is exposed as a boolean field - check it
1553            if let Some(EpicsValue::Char(1)) = self.record.get_field("CALC_ALARM") {
1554                recgbl::rec_gbl_set_sevr_msg(
1555                    &mut self.common,
1556                    alarm_status::CALC_ALARM,
1557                    crate::server::record::AlarmSeverity::Invalid,
1558                    "CALC expression evaluation failed",
1559                );
1560            }
1561        }
1562
1563        match rtype {
1564            "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout" => {
1565                if let Some(ref alarm_cfg) = self.common.analog_alarm.clone() {
1566                    let val = match self.record.val() {
1567                        Some(EpicsValue::Double(v)) => v,
1568                        Some(EpicsValue::Long(v)) => v as f64,
1569                        Some(EpicsValue::Int64(v)) => v as f64,
1570                        _ => return,
1571                    };
1572                    self.evaluate_analog_alarm(val, alarm_cfg);
1573                }
1574            }
1575            // bi / bo / busy / mbbi / mbbo STATE+COS (and mbbo SOFT)
1576            // alarm evaluation now lives in each record's
1577            // `Record::check_alarms` hook (C `checkAlarms`). Keeping an
1578            // arm here would double-raise.
1579            _ => {} // no-op for other types
1580        }
1581    }
1582
1583    fn evaluate_analog_alarm(&mut self, val: f64, cfg: &AnalogAlarmConfig) {
1584        use crate::server::recgbl::{self, alarm_status};
1585
1586        let hyst = self.common.hyst;
1587        let lalm = self
1588            .record
1589            .get_field("LALM")
1590            .and_then(|v| v.to_f64())
1591            .unwrap_or(val);
1592
1593        // C-style per-level hysteresis: alarm fires if val passes the level,
1594        // OR if we were already at that alarm level (lalm == alev) and val
1595        // hasn't retreated past the hysteresis margin.
1596        //
1597        // `alarm_range` is the C-style integer level: 1=Lolo, 2=Low,
1598        // 3=Normal, 4=High, 5=Hihi. Required for the calc-record AFTC
1599        // filter (`calcRecord.c::checkAlarms:339-381`) which filters
1600        // on the range level (not on severity) and re-maps back.
1601        let (mut new_sevr, mut new_stat, mut alev, mut alarm_range) = if cfg.hhsv
1602            != AlarmSeverity::NoAlarm
1603            && (val >= cfg.hihi || (lalm == cfg.hihi && val >= cfg.hihi - hyst))
1604        {
1605            (cfg.hhsv, alarm_status::HIHI_ALARM, cfg.hihi, 5u16)
1606        } else if cfg.llsv != AlarmSeverity::NoAlarm
1607            && (val <= cfg.lolo || (lalm == cfg.lolo && val <= cfg.lolo + hyst))
1608        {
1609            (cfg.llsv, alarm_status::LOLO_ALARM, cfg.lolo, 1u16)
1610        } else if cfg.hsv != AlarmSeverity::NoAlarm
1611            && (val >= cfg.high || (lalm == cfg.high && val >= cfg.high - hyst))
1612        {
1613            (cfg.hsv, alarm_status::HIGH_ALARM, cfg.high, 4u16)
1614        } else if cfg.lsv != AlarmSeverity::NoAlarm
1615            && (val <= cfg.low || (lalm == cfg.low && val <= cfg.low + hyst))
1616        {
1617            (cfg.lsv, alarm_status::LOW_ALARM, cfg.low, 2u16)
1618        } else {
1619            (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val, 3u16)
1620        };
1621
1622        // C parity: the alarm-range AFTC low-pass filter
1623        // (`{ai,longin,int64in,calc}Record.c::checkAlarms`) smooths the
1624        // integer `alarmRange` and re-maps. Only records that carry the
1625        // AFTC/AFVL fields run it — `ao`/`longout`/`int64out`/`calcout`
1626        // have no AFTC field (confirmed via the respective `.dbd.pod`),
1627        // so they are excluded.
1628        let aftc_capable = matches!(
1629            self.record.record_type(),
1630            "calc" | "ai" | "longin" | "int64in"
1631        );
1632        if aftc_capable {
1633            let aftc = self
1634                .record
1635                .get_field("AFTC")
1636                .and_then(|v| v.to_f64())
1637                .unwrap_or(0.0);
1638            let afvl = self
1639                .record
1640                .get_field("AFVL")
1641                .and_then(|v| v.to_f64())
1642                .unwrap_or(0.0);
1643            if aftc > 0.0 {
1644                let now = crate::runtime::general_time::get_current();
1645                let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
1646                    alarm_range,
1647                    aftc,
1648                    afvl,
1649                    self.common.time,
1650                    now,
1651                );
1652                let _ = self.record.put_field("AFVL", EpicsValue::Double(new_afvl));
1653                if filtered_range != alarm_range {
1654                    // Re-map filtered range back to (sevr, stat, alev).
1655                    let (mapped_sevr, mapped_stat, mapped_alev) = match filtered_range {
1656                        5 => (cfg.hhsv, alarm_status::HIHI_ALARM, cfg.hihi),
1657                        4 => (cfg.hsv, alarm_status::HIGH_ALARM, cfg.high),
1658                        2 => (cfg.lsv, alarm_status::LOW_ALARM, cfg.low),
1659                        1 => (cfg.llsv, alarm_status::LOLO_ALARM, cfg.lolo),
1660                        _ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val),
1661                    };
1662                    new_sevr = mapped_sevr;
1663                    new_stat = mapped_stat;
1664                    alev = mapped_alev;
1665                    alarm_range = filtered_range;
1666                }
1667            } else {
1668                // aftc <= 0 disables the filter. C `checkAlarms`
1669                // (e.g. aiRecord.c:356,402) initialises the local
1670                // `afvl = 0` and unconditionally stores `prec->afvl =
1671                // afvl` at the end, so a disabled filter drives AFVL to
1672                // 0. Mirror that here so a stale accumulator from a prior
1673                // `aftc > 0` run cannot mis-seed the filter if AFTC is
1674                // re-enabled later.
1675                if afvl != 0.0 {
1676                    let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
1677                }
1678            }
1679        }
1680        let _ = alarm_range; // suppress unused-var on non-calc paths
1681
1682        if new_sevr != AlarmSeverity::NoAlarm {
1683            recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr);
1684            // C sets LALM to the alarm threshold level, not the current value
1685            let _ = self.record.put_field("LALM", EpicsValue::Double(alev));
1686        } else {
1687            // No alarm condition: reset LALM to current value (like C)
1688            let _ = self.record.put_field("LALM", EpicsValue::Double(val));
1689        }
1690    }
1691
1692    /// Basic process: process record, evaluate alarms, timestamp, build snapshot.
1693    /// This does NOT handle links — see process_with_context in database.rs.
1694    ///
1695    /// Returns the value/log snapshot plus a list of alarm-field posts
1696    /// (`SEVR`/`STAT`/`AMSG`/`ACKS`) with their individual C event masks.
1697    /// `SEVR` is posted `DBE_VALUE` only; `STAT`/`AMSG` carry `DBE_ALARM`
1698    /// (sevr/amsg change) and/or `DBE_VALUE` (stat change). The caller
1699    /// must fire these via `notify_field` so a `DBE_VALUE`-only `.SEVR`
1700    /// subscriber is not missed on an alarm-only change and a
1701    /// `DBE_ALARM`-only subscriber is not wrongly notified — C parity
1702    /// with `recGblResetAlarms` (recGbl.c:201-220), matching the
1703    /// `processing.rs` link path.
1704    pub fn process_local(
1705        &mut self,
1706    ) -> CaResult<(
1707        ProcessSnapshot,
1708        Vec<(&'static str, crate::server::recgbl::EventMask)>,
1709    )> {
1710        use crate::server::recgbl::{self, EventMask};
1711        const LCNT_ALARM_THRESHOLD: i16 = 10;
1712
1713        if self
1714            .processing
1715            .swap(true, std::sync::atomic::Ordering::AcqRel)
1716        {
1717            // C `dbProcess` PACT-active guard (dbAccess.c:544-557):
1718            //
1719            //   if ((precord->stat == SCAN_ALARM) ||
1720            //       (precord->lcnt++ < MAX_LOCK) ||
1721            //       (precord->sevr >= INVALID_ALARM)) goto all_done;
1722            //   recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM,
1723            //                    "Async in progress");
1724            //
1725            // The alarm fires EXACTLY ONCE — on the attempt whose
1726            // pre-increment lcnt equals MAX_LOCK — and is then blocked
1727            // by the stat == SCAN_ALARM / sevr >= INVALID bails, the
1728            // same shape as the link path
1729            // (`process_record_with_links_inner`). The pre-fix guard
1730            // here used post-increment `lcnt >= threshold` with no
1731            // already-raised bail, so every reentrant attempt past the
1732            // threshold re-posted the unchanged SEVR/STAT/VAL (and the
1733            // first fire came one attempt early); it also wrote
1734            // sevr/stat directly, skipping `recGblSetSevrMsg` +
1735            // `recGblResetAlarms` — losing the "Async in progress"
1736            // AMSG and the acks bookkeeping the reset performs.
1737            let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
1738            let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
1739            let lcnt_before = self.common.lcnt;
1740            self.common.lcnt = lcnt_before.saturating_add(1);
1741            if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
1742                return Ok((
1743                    ProcessSnapshot {
1744                        changed_fields: Vec::new(),
1745                    },
1746                    Vec::new(),
1747                ));
1748            }
1749            recgbl::rec_gbl_set_sevr_msg(
1750                &mut self.common,
1751                recgbl::alarm_status::SCAN_ALARM,
1752                AlarmSeverity::Invalid,
1753                "Async in progress",
1754            );
1755            let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
1756            // Per-field C masks (recGbl.c:201-220): this guard only
1757            // runs on a fresh SCAN_ALARM/INVALID raise, so sevr AND
1758            // stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post
1759            // the shared `stat_mask` = DBE_ALARM|DBE_VALUE, VAL posts
1760            // DBE_VALUE|DBE_LOG plus `val_mask` = DBE_ALARM.
1761            let stat_mask = EventMask::ALARM | EventMask::VALUE;
1762            let mut changed_fields = Vec::new();
1763            if let Some(val) = self.record.val() {
1764                changed_fields.push((
1765                    "VAL".to_string(),
1766                    val,
1767                    EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
1768                ));
1769            }
1770            changed_fields.push((
1771                "SEVR".to_string(),
1772                EpicsValue::Short(self.common.sevr as i16),
1773                EventMask::VALUE,
1774            ));
1775            changed_fields.push((
1776                "STAT".to_string(),
1777                EpicsValue::Short(self.common.stat as i16),
1778                stat_mask,
1779            ));
1780            // AMSG carries "Async in progress" alongside the STAT
1781            // transition (C recGbl.c posts STAT and AMSG together
1782            // when any alarm field moved).
1783            changed_fields.push((
1784                "AMSG".to_string(),
1785                EpicsValue::String(self.common.amsg.clone().into()),
1786                stat_mask,
1787            ));
1788            return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
1789        }
1790        self.common.lcnt = 0;
1791        // RAII guard that resets `self.processing` to false on drop —
1792        // both for the normal exit path and for any `?` early return.
1793        // The guard holds a raw pointer rather than a reference because
1794        // we still need `self` mutably while the guard is alive (the
1795        // record body below mutates other `self` fields).
1796        struct ProcessGuard(*const AtomicBool);
1797        // SAFETY: AtomicBool is Sync; raw pointers don't auto-derive
1798        // Send. We hand-roll Send because the ptr targets a field of
1799        // `self`, which the caller already proves can be borrowed
1800        // through this code path. The pointer is only ever read for an
1801        // atomic store, never written, dereferenced for raw access, or
1802        // escaped from this scope.
1803        unsafe impl Send for ProcessGuard {}
1804        impl Drop for ProcessGuard {
1805            fn drop(&mut self) {
1806                // SAFETY: `self.0` was constructed from
1807                // `&self.processing as *const AtomicBool` below, where
1808                // `self` is the live RecordInstance whose lifetime
1809                // strictly outlives `_guard`. RecordInstance is
1810                // !Unpin-equivalent in practice (we never move it
1811                // while held in the database's `Arc<RwLock<_>>`), so
1812                // the pointer remains valid until Drop runs.
1813                unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
1814            }
1815        }
1816        let _guard = ProcessGuard(&self.processing as *const AtomicBool);
1817
1818        // Call subroutine if registered (for sub records)
1819        if let Some(ref sub_fn) = self.subroutine {
1820            sub_fn(&mut *self.record)?;
1821        }
1822        // Soft-Channel input records must skip the RVAL->VAL convert
1823        // (C `devAiSoft.c` `read_ai` returns 2 = "don't convert" for
1824        // every Soft-Channel input record, incl. one with a constant /
1825        // unset INP). Without this, `process_local` on a soft input
1826        // with a preset VAL — e.g. NaN — would run `convert()` and
1827        // clobber it, after which the UDF check below would see a
1828        // defined value and wrongly clear UDF. The
1829        // `processing.rs` link path already does this; `process_local`
1830        // is the separate foreign-call path (`db.process_record`) and
1831        // needs the same skip. "Raw Soft Channel" has a distinct DTYP
1832        // so it is excluded by `is_soft` and still runs convert.
1833        //
1834        // Gated on `soft_channel_skips_convert()` — identical to the
1835        // `processing.rs` link path — so this only suppresses the
1836        // `RVAL → VAL` convert step. `set_device_did_compute` is an
1837        // overloaded hook: `ai/bi/mbbi/mbbi_direct` read it as
1838        // "skip convert" (override true), but `epid` reads it as
1839        // "skip the whole built-in PID compute" (keeps default false).
1840        // Without this gate, a Soft-Channel `epid` driven through
1841        // `process_local` (`db.process_record`, e.g. QSRV group proc
1842        // members) would skip `do_pid()` entirely — the regression
1843        // d1032fe5 fixed on the `processing.rs` path only.
1844        {
1845            let is_soft = self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel";
1846            let is_output = self.record.can_device_write();
1847            if is_soft && !is_output && self.record.soft_channel_skips_convert() {
1848                self.record.set_device_did_compute(true);
1849            }
1850        }
1851        // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so the
1852        // record's process() can see it — same as the processing.rs link
1853        // path. `process_local` is the foreign-call path
1854        // (`db.process_record`); without this a record driven through it
1855        // (e.g. QSRV group-process members) would not see UDF/TSE.
1856        {
1857            let ctx = self.common.process_context();
1858            self.record.set_process_context(&ctx);
1859        }
1860        let outcome = self.record.process()?;
1861        let process_result = outcome.result;
1862        // Note: process_local() does not execute ProcessActions — those are
1863        // handled by the full process_record_with_links() path in processing.rs.
1864
1865        // If the record reports it modified a metadata-class field during
1866        // process(), invalidate the metadata cache so the next snapshot
1867        // rebuilds from the new values. Default impl returns false, so
1868        // most records pay zero cost here.
1869        if self.record.took_metadata_change() {
1870            self.invalidate_metadata_cache();
1871            // mirror C db_post_events(precord, NULL, DBE_PROPERTY) after record processing.
1872            let fields: Vec<String> = self.subscribers.keys().cloned().collect();
1873            for f in fields {
1874                self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
1875            }
1876        }
1877
1878        if process_result == RecordProcessResult::AsyncPending {
1879            // Async: PACT stays set, no further processing this cycle
1880            // Don't clear processing flag (guard won't run — we leak it intentionally)
1881            std::mem::forget(_guard);
1882            return Ok((
1883                ProcessSnapshot {
1884                    changed_fields: Vec::new(),
1885                },
1886                Vec::new(),
1887            ));
1888        }
1889        if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
1890            // Intermediate notification (e.g. DMOV=0 at move start).
1891            // Unlike AsyncPending, we DO release the processing flag so
1892            // subsequent I/O Intr cycles can continue processing normally.
1893            self.common.time = crate::runtime::general_time::get_current();
1894            // Filter out fields that haven't actually changed, and update
1895            // MLST/last_posted for those that have. Each intermediate
1896            // post carries DBE_VALUE|DBE_LOG — C motor's mid-move
1897            // `db_post_events` calls use `DBE_VAL_LOG`
1898            // (motorRecord.cc:2606 DMOV, and every other do_work post);
1899            // no alarm transition ran on this pending pass.
1900            let mut changed_fields = Vec::new();
1901            for (name, val) in fields {
1902                let changed = match self.last_posted.get(&name) {
1903                    Some(prev) => prev != &val,
1904                    None => true,
1905                };
1906                if changed {
1907                    if name == "VAL" {
1908                        if let Some(f) = val.to_f64() {
1909                            self.put_coerced("MLST", f);
1910                            self.common.mlst = Some(f);
1911                        }
1912                    }
1913                    self.last_posted.insert(name.clone(), val.clone());
1914                    changed_fields.push((name, val, EventMask::VALUE | EventMask::LOG));
1915                }
1916            }
1917            // _guard drops here, clearing the processing flag
1918            return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
1919        }
1920
1921        // UDF update before alarm evaluation — C parity (see
1922        // `processing.rs`). A NaN / undefined value keeps UDF true so
1923        // `recGblCheckUDF` raises UDF_ALARM this cycle instead of the
1924        // record reporting a stale/garbage value with no alarm.
1925        if self.record.clears_udf() {
1926            self.common.udf = self.record.value_is_undefined();
1927        }
1928        // Per-record alarm hook (C `checkAlarms()`).
1929        self.record.check_alarms(&mut self.common);
1930
1931        // Evaluate alarms (accumulates into nsta/nsev)
1932        self.evaluate_alarms();
1933
1934        // Transfer nsta/nsev → sevr/stat, detect alarm change
1935        let alarm_result = recgbl::rec_gbl_reset_alarms(&mut self.common);
1936
1937        self.common.time = crate::runtime::general_time::get_current();
1938        // UDF already updated above — do not clear unconditionally.
1939
1940        // Deadband check for VAL monitor filtering
1941        let (include_val, include_archive) = self.check_deadband_ext();
1942        // C `recGblResetAlarms` `val_mask = DBE_ALARM`
1943        // (recGbl.c:194/203/212): every monitored-value post this cycle
1944        // carries DBE_ALARM when the severity/status OR the alarm
1945        // message moved — same parity rule as the `processing.rs`
1946        // paths.
1947        let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
1948            EventMask::ALARM
1949        } else {
1950            EventMask::NONE
1951        };
1952
1953        // Build snapshot
1954        let mut changed_fields = Vec::new();
1955        // Same deadband-field routing and per-field mask as the
1956        // `processing.rs` paths: the tracked field posts the classes
1957        // that actually fired (MDEL → DBE_VALUE, ADEL → DBE_LOG, alarm
1958        // movement → DBE_ALARM); a non-primary deadband field (motor
1959        // RBV — C motor `monitor()`, motorRecord.cc:3468-3507) leaves
1960        // VAL to the generic change-detection loop below.
1961        let deadband_field = self.record.monitor_deadband_field();
1962        let deadband_mask = {
1963            let mut m = alarm_bits;
1964            if include_val {
1965                m |= EventMask::VALUE;
1966            }
1967            if include_archive {
1968                m |= EventMask::LOG;
1969            }
1970            m
1971        };
1972        if !deadband_mask.is_empty() {
1973            let dval = if deadband_field == "VAL" {
1974                self.record.val()
1975            } else {
1976                self.resolve_field(deadband_field)
1977            };
1978            if let Some(val) = dval {
1979                changed_fields.push((deadband_field.to_string(), val, deadband_mask));
1980            }
1981        }
1982        // C `recGblResetAlarms` (recGbl.c:201-220) posts each alarm
1983        // field with its OWN per-field mask, not one record-wide mask:
1984        //   * SEVR — DBE_VALUE, ONLY on a sevr change.
1985        //   * STAT — DBE_ALARM (sevr change) | DBE_VALUE (stat change).
1986        //   * ACKS — DBE_VALUE, only when an alarm field moved.
1987        // Pushing SEVR/STAT into `changed_fields` collapses them onto
1988        // the single record-wide `event_mask` (which carries ALARM on
1989        // `alarm_changed`): a DBE_VALUE-only `.SEVR` subscriber would
1990        // miss a stat-only-driven sevr change, and a DBE_ALARM-only
1991        // `.SEVR` subscriber would be wrongly notified. Post them via
1992        // `notify_field` with their individual masks instead — exactly
1993        // as the `processing.rs` link path does.
1994        let sevr_changed = self.common.sevr != alarm_result.prev_sevr;
1995        let stat_changed = self.common.stat != alarm_result.prev_stat;
1996        let stat_mask = {
1997            let mut m = EventMask::NONE;
1998            // C `recGblResetAlarms` carries DBE_ALARM on the STAT/AMSG
1999            // posts whenever the severity OR the alarm message moved —
2000            // not on a severity change alone. Aligning with the
2001            // `processing.rs` link path (and `complete_async_record`).
2002            if sevr_changed || alarm_result.amsg_changed {
2003                m |= EventMask::ALARM;
2004            }
2005            if stat_changed {
2006                m |= EventMask::VALUE;
2007            }
2008            m
2009        };
2010        let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
2011        if sevr_changed {
2012            alarm_posts.push(("SEVR", EventMask::VALUE));
2013        }
2014        if !stat_mask.is_empty() {
2015            alarm_posts.push(("STAT", stat_mask));
2016            // AMSG shares STAT's mask — C posts it alongside STAT when
2017            // any alarm field moved.
2018            alarm_posts.push(("AMSG", stat_mask));
2019        }
2020        // C parity (recGbl.c:216): ACKS is posted (DBE_VALUE) only when
2021        // an alarm field moved (`stat_mask != 0`) AND it was raised.
2022        if alarm_result.acks_changed && !stat_mask.is_empty() {
2023            alarm_posts.push(("ACKS", EventMask::VALUE));
2024        }
2025
2026        // Add subscribed fields that actually changed since last notification.
2027        // Exclude {deadband-field}/SEVR/STAT/AMSG/UDF — all five are already
2028        // emitted by this path (the deadband field, default VAL, in
2029        // `changed_fields`, SEVR/STAT/AMSG via `alarm_posts`, UDF via the
2030        // explicit UDF push below). Mirrors the two `processing.rs`
2031        // snapshot gates, which exclude the same five; excluding only the
2032        // first three would double-post AMSG and UDF. Each carries
2033        // DBE_VALUE|DBE_LOG plus the cycle's alarm bits — the C
2034        // convention for change-detected auxiliary posts
2035        // (`monitor_mask | DBE_VALUE | DBE_LOG`, calcRecord.c:420,
2036        // subRecord.c:400; motor `DBE_VAL_LOG` for marked fields,
2037        // motorRecord.cc:3522-3645).
2038        //
2039        // On a cycle whose alarm transition fired, fields named by
2040        // `alarm_cycle_monitored_fields` post even when unchanged, with
2041        // the alarm bits alone — C motor `monitor()`
2042        // (motorRecord.cc:3513-3645) posts every listed field once
2043        // `monitor_mask != 0`, so a `DBE_ALARM`-only subscriber
2044        // observes the alarm moment on any of them.
2045        let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
2046        let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
2047            &[]
2048        } else {
2049            self.record.alarm_cycle_monitored_fields()
2050        };
2051        // Fields the record force-posts every cycle it recomputed them
2052        // (C unconditional MARK + DBE_VAL_LOG), even when unchanged —
2053        // see `Record::force_posted_fields`. Empty for most records.
2054        let force_fields = self.record.force_posted_fields();
2055        // Fields the record re-posts with DBE_LOG only every cycle it
2056        // names them, regardless of change — see `Record::log_swept_fields`
2057        // (the scaler's idle S1..Snch sweep). Empty for most records.
2058        let log_swept = self.record.log_swept_fields();
2059        let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
2060        for (field, subs) in &self.subscribers {
2061            if !subs.is_empty()
2062                && field != deadband_field
2063                && field != "SEVR"
2064                && field != "STAT"
2065                && field != "AMSG"
2066                && field != "UDF"
2067            {
2068                if let Some(val) = self.resolve_field(field) {
2069                    let changed = match self.last_posted.get(field) {
2070                        Some(prev) => prev != &val,
2071                        None => true,
2072                    };
2073                    if changed {
2074                        sub_updates.push((field.clone(), val, aux_mask));
2075                    } else if force_fields.contains(&field.as_str()) {
2076                        // C `monitor()` posts a re-marked field with
2077                        // `monitor_mask | DBE_VAL_LOG` even when unchanged.
2078                        sub_updates.push((field.clone(), val, aux_mask));
2079                    } else if alarm_fanout.contains(&field.as_str()) {
2080                        sub_updates.push((field.clone(), val, alarm_bits));
2081                    } else if log_swept.contains(&field.as_str()) {
2082                        // C scalerRecord.c:770-787 `monitor()`: every idle
2083                        // process re-posts each S1..Snch with a literal
2084                        // DBE_LOG regardless of change. A changed field is
2085                        // already delivered above with DBE_VALUE|DBE_LOG
2086                        // (covering the LOG subscriber), so only the
2087                        // unchanged sweep lands here — no double post.
2088                        sub_updates.push((field.clone(), val, EventMask::LOG));
2089                    }
2090                }
2091            }
2092        }
2093        if !sub_updates.is_empty() {
2094            for (field, val, _) in &sub_updates {
2095                self.last_posted.insert(field.clone(), val.clone());
2096            }
2097            changed_fields.extend(sub_updates);
2098        }
2099
2100        // Post UDF on the snapshot whenever any monitor event fires this
2101        // cycle, carrying the union of the cycle's posted classes —
2102        // mirrors the two `processing.rs` UDF pushes. C
2103        // `recGblResetAlarms` / `recGblCheckUDF` (recGbl.c) keep UDF
2104        // current every process cycle, and `db_post_events` delivers
2105        // `.UDF` alongside the record-wide post. `process_local` is the
2106        // foreign-process path (`db.process_record`, e.g. QSRV
2107        // group-process members); without this push a UDF change here is
2108        // never delivered to `.UDF` subscribers — the `sub_updates` loop
2109        // above deliberately excludes UDF to avoid a double-post, so the
2110        // push must be here.
2111        let cycle_mask = changed_fields
2112            .iter()
2113            .fold(EventMask::NONE, |m, (_, _, fm)| m | *fm);
2114        if !cycle_mask.is_empty() {
2115            changed_fields.push((
2116                "UDF".to_string(),
2117                EpicsValue::Char(if self.common.udf { 1 } else { 0 }),
2118                cycle_mask,
2119            ));
2120        }
2121
2122        Ok((ProcessSnapshot { changed_fields }, alarm_posts))
2123    }
2124
2125    /// Put a f64 value into a record field, coercing to the field's native type.
2126    pub(crate) fn put_coerced(&mut self, field: &str, val: f64) {
2127        use crate::types::EpicsValue;
2128        let target_type = self
2129            .record
2130            .get_field(field)
2131            .map(|v| v.db_field_type())
2132            .unwrap_or(crate::types::DbFieldType::Double);
2133        let coerced = EpicsValue::Double(val).convert_to(target_type);
2134        let _ = self.record.put_field(field, coerced);
2135    }
2136
2137    /// Check MDEL/ADEL deadbands for VAL monitor/archive filtering.
2138    /// Returns `(monitor_trigger, archive_trigger)`.
2139    ///
2140    /// Updates `MLST`/`ALST` (record-owned) and the `CommonFields`
2141    /// `mlst/alst` shadow when a trigger fires. Records without
2142    /// MDEL/ADEL (e.g. motor) default to deadband=0 (any actual
2143    /// change triggers).
2144    ///
2145    /// Delegates per-axis deadband comparison to the free function
2146    /// [`check_deadband`] below — see that function's docstring for
2147    /// the four-quadrant NaN/infinity rule mirroring C
2148    /// `recGblCheckDeadband` (recGbl.c:345-370).
2149    ///
2150    /// **C-parity design note**: the Rust port uses `NaN` as the
2151    /// "never posted" sentinel for `MLST`/`ALST`. C achieves the
2152    /// same first-publish guarantee by allocating MLST/ALST in
2153    /// BSS-zeroed storage with a value of 0.0 that the C code is
2154    /// allowed to match against — but the first observed value is
2155    /// not necessarily 0.0, and the C rule "MLST==0 means never
2156    /// posted" relies on the deadband comparison `abs(val - 0.0)`
2157    /// firing on any non-zero first value. NaN is strictly more
2158    /// correct for the Rust port because a legitimate first
2159    /// `val=0.0` still fires on `NaN.is_nan() → true`. This
2160    /// sentinel-as-design is intentional, documented inside
2161    /// [`check_deadband`] (the `oldval.is_nan() → return true` short
2162    /// circuit). It is NOT a deviation inherited from an earlier
2163    /// silent compromise — `record_tests.rs::deadband_*` pins both
2164    /// the NaN-sentinel behaviour and the C four-quadrant transitions.
2165    pub fn check_deadband_ext(&mut self) -> (bool, bool) {
2166        // The deadband is evaluated against `monitor_deadband_value()`,
2167        // not `val()` directly: a record whose monitored quantity is
2168        // not its primary value (e.g. the motor record, VAL=setpoint /
2169        // RBV=readback — C `monitor()` deadbands RBV) overrides that
2170        // hook. Default is `val()`, so other records are unaffected.
2171        let val = match self
2172            .record
2173            .monitor_deadband_value()
2174            .and_then(|v| v.to_f64())
2175        {
2176            Some(v) => v,
2177            None => return (true, true),
2178        };
2179
2180        let mdel = self
2181            .record
2182            .get_field("MDEL")
2183            .and_then(|v| v.to_f64())
2184            .unwrap_or(0.0);
2185        let adel = self
2186            .record
2187            .get_field("ADEL")
2188            .and_then(|v| v.to_f64())
2189            .unwrap_or(0.0);
2190
2191        // Use record's MLST/ALST fields if available, otherwise fall back to CommonFields
2192        let mlst = self
2193            .record
2194            .get_field("MLST")
2195            .and_then(|v| v.to_f64())
2196            .or(self.common.mlst)
2197            .unwrap_or(f64::NAN);
2198        let alst = self
2199            .record
2200            .get_field("ALST")
2201            .and_then(|v| v.to_f64())
2202            .or(self.common.alst)
2203            .unwrap_or(f64::NAN);
2204
2205        let monitor_trigger = check_deadband(val, mlst, mdel);
2206        let archive_trigger = check_deadband(val, alst, adel);
2207
2208        if archive_trigger {
2209            self.put_coerced("ALST", val);
2210            self.common.alst = Some(val);
2211        }
2212        if monitor_trigger {
2213            self.put_coerced("MLST", val);
2214            self.common.mlst = Some(val);
2215        }
2216
2217        (monitor_trigger, archive_trigger)
2218    }
2219
2220    /// Build a Snapshot for a given value, populated with the record's display metadata.
2221    /// Uses the metadata cache so the populate cost is paid at most once
2222    /// per metadata-stable interval (cf. `cached_metadata`).
2223    fn make_monitor_snapshot(
2224        &self,
2225        field: &str,
2226        value: EpicsValue,
2227    ) -> super::super::snapshot::Snapshot {
2228        let mut snap = super::super::snapshot::Snapshot::new(
2229            value,
2230            self.common.stat,
2231            self.common.sevr as u16,
2232            self.common.time,
2233        );
2234        // Carry the record's `utag` into the monitor update's
2235        // `timeStamp.userTag`, same as the GET path
2236        // (`snapshot_for_field`) and pvxs `iocsource.cpp:245`. Narrows
2237        // the 64-bit `epicsUTag` to the int32 wire field by low-32-bit
2238        // truncation.
2239        snap.user_tag = self.common.utag as i32;
2240        let meta = self.cached_metadata();
2241        snap.display = meta.display;
2242        snap.control = meta.control;
2243        snap.enums = meta.enums;
2244        // Per-field RSET metadata, same as the GET path
2245        // (`snapshot_for_field`) — a monitor update for VELO must carry
2246        // VELO's limits, not the record-level VAL limits.
2247        self.apply_field_metadata_override(field, &mut snap);
2248        // A monitored DBF_MENU field carries the same DBR_ENUM value and
2249        // choice labels as the GET path, so a `camonitor`/`pvmonitor`
2250        // update shows the menu label, not a bare index.
2251        self.attach_menu_enum(field, &mut snap);
2252        snap
2253    }
2254
2255    /// Apply a record's per-field metadata override (C RSET
2256    /// `get_units`/`get_precision`/`get_graphic_double`/
2257    /// `get_control_double`/`get_alarm_double`, all keyed by field)
2258    /// over the cached record-level metadata. Shared by the GET and
2259    /// monitor snapshot builders. Computed live on every call — never
2260    /// cached — so overrides derived from fields outside the
2261    /// `is_metadata_field` set cannot go stale.
2262    fn apply_field_metadata_override(
2263        &self,
2264        field: &str,
2265        snap: &mut super::super::snapshot::Snapshot,
2266    ) {
2267        let Some(ov) = self.record.field_metadata_override(field) else {
2268            return;
2269        };
2270        if ov.units.is_some()
2271            || ov.precision.is_some()
2272            || ov.disp_limits.is_some()
2273            || ov.alarm_limits.is_some()
2274        {
2275            let d = snap.display.get_or_insert_with(Default::default);
2276            if let Some(units) = ov.units {
2277                d.units = units;
2278            }
2279            if let Some(precision) = ov.precision {
2280                d.precision = precision;
2281            }
2282            if let Some((upper, lower)) = ov.disp_limits {
2283                d.upper_disp_limit = upper;
2284                d.lower_disp_limit = lower;
2285            }
2286            if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
2287                d.upper_alarm_limit = hihi;
2288                d.upper_warning_limit = high;
2289                d.lower_warning_limit = low;
2290                d.lower_alarm_limit = lolo;
2291            }
2292        }
2293        if let Some((upper, lower)) = ov.ctrl_limits {
2294            let c = snap.control.get_or_insert_with(Default::default);
2295            c.upper_ctrl_limit = upper;
2296            c.lower_ctrl_limit = lower;
2297        }
2298    }
2299
2300    /// Notify subscribers from a snapshot (call outside lock).
2301    /// Each entry carries its own posting mask: only subscribers whose
2302    /// mask intersects that field's mask are notified, and the
2303    /// delivered [`MonitorEvent`] reports exactly that field's classes
2304    /// (C `db_post_events(prec, &field, mask)` per-field granularity).
2305    pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot) {
2306        use crate::server::database::filters::FilteredMonitorEvent;
2307        use crate::server::recgbl::EventMask;
2308
2309        for (field, value, posting_mask) in &snapshot.changed_fields {
2310            let posting_mask = *posting_mask;
2311            if let Some(subs) = self.subscribers.get(field) {
2312                // Build a full snapshot once per field (with display metadata)
2313                let mon_snap = self.make_monitor_snapshot(field, value.clone());
2314                for sub in subs {
2315                    // Paused subscriber (`db_event_disable`): suppress at
2316                    // the source — no delivery, no coalesce.
2317                    if !sub.active {
2318                        continue;
2319                    }
2320                    let sub_mask = EventMask::from_bits(sub.mask);
2321                    // Only send when posting mask intersects subscriber mask.
2322                    // Empty posting mask means nothing changed — skip.
2323                    if !posting_mask.is_empty() && sub_mask.intersects(posting_mask) {
2324                        let event = MonitorEvent {
2325                            snapshot: mon_snap.clone(),
2326                            origin: 0,
2327                            mask: posting_mask,
2328                        };
2329                        // Server-side filter chain (3.15.7). Empty chain
2330                        // is identity, so no behaviour change for the
2331                        // common no-filter case.
2332                        let filtered = if sub.filters.is_empty() {
2333                            Some(event)
2334                        } else {
2335                            sub.filters
2336                                .apply(FilteredMonitorEvent::new(event))
2337                                .map(|fe| fe.event)
2338                        };
2339                        let Some(event) = filtered else {
2340                            continue;
2341                        };
2342                        if sub.tx.try_send(event.clone()).is_err() {
2343                            // route the coalesce overwrite through
2344                            // the single owner so a record-field monitor
2345                            // value lost to a slow consumer is counted in
2346                            // `dropped_monitor_events()`, exactly like a
2347                            // `ProcessVariable` overflow.
2348                            sub.coalesce_overflow(event);
2349                        }
2350                    }
2351                }
2352            }
2353        }
2354    }
2355
2356    /// Notify subscribers of a specific field, filtering by event mask.
2357    pub fn notify_field(&self, field: &str, mask: crate::server::recgbl::EventMask) {
2358        self.notify_field_with_origin(field, mask, 0);
2359    }
2360
2361    /// C `db_post_events(precord, NULL, DBE_ALARM)`: post a record-wide
2362    /// alarm event. Delivers to every subscriber on any field whose mask
2363    /// includes DBE_ALARM, each carrying its own monitored field's current
2364    /// value (the per-field `notify_field` already filters by mask
2365    /// intersection). Used by the alarm-acknowledge (ACKT/ACKS) put path so
2366    /// an alarm-mask monitor on any field observes the acknowledgement.
2367    pub fn notify_record_alarm(&self) {
2368        let fields: Vec<String> = self.subscribers.keys().cloned().collect();
2369        for field in fields {
2370            self.notify_field(&field, crate::server::recgbl::EventMask::ALARM);
2371        }
2372    }
2373
2374    /// Notify subscribers with an origin tag for self-write filtering.
2375    pub fn notify_field_with_origin(
2376        &self,
2377        field: &str,
2378        mask: crate::server::recgbl::EventMask,
2379        origin: u64,
2380    ) {
2381        use crate::server::database::filters::FilteredMonitorEvent;
2382        if let Some(subs) = self.subscribers.get(field) {
2383            if let Some(value) = self.resolve_field(field) {
2384                let mon_snap = self.make_monitor_snapshot(field, value);
2385                for sub in subs {
2386                    // Paused subscriber (`db_event_disable`): suppress at
2387                    // the source — no delivery, no coalesce.
2388                    if !sub.active {
2389                        continue;
2390                    }
2391                    let sub_mask = crate::server::recgbl::EventMask::from_bits(sub.mask);
2392                    if mask.is_empty() || sub_mask.intersects(mask) {
2393                        let event = MonitorEvent {
2394                            snapshot: mon_snap.clone(),
2395                            origin,
2396                            mask,
2397                        };
2398                        // Server-side filter chain (3.15.7). Empty
2399                        // chain (the default for every subscriber
2400                        // until a `.{filter:opts}` PV-name suffix
2401                        // parser wires one in) is the identity, so
2402                        // existing subscribers see no behaviour
2403                        // change. A filter returning `None` silences
2404                        // this event for this subscriber only.
2405                        let filtered = if sub.filters.is_empty() {
2406                            Some(event)
2407                        } else {
2408                            sub.filters
2409                                .apply(FilteredMonitorEvent::new(event))
2410                                .map(|fe| fe.event)
2411                        };
2412                        let Some(event) = filtered else {
2413                            continue;
2414                        };
2415                        if sub.tx.try_send(event.clone()).is_err() {
2416                            // same single coalesce-overflow owner
2417                            // as the snapshot path — record-field loss to
2418                            // a slow consumer must be counted, not silently
2419                            // overwritten.
2420                            sub.coalesce_overflow(event);
2421                        }
2422                    }
2423                }
2424            }
2425        }
2426    }
2427
2428    /// Add a subscriber for a specific field. Returns `None` when the
2429    /// per-field subscriber cap (`EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`)
2430    /// is reached. the parallel cap on `ProcessVariable`
2431    /// defends against a misbehaving client opening many
2432    /// MONITOR ops against one shared PV; the same defence is needed
2433    /// for record fields, which the CA server's
2434    /// `ChannelTarget::RecordField` path lands on.
2435    pub fn add_subscriber(
2436        &mut self,
2437        field: &str,
2438        sid: u32,
2439        data_type: DbFieldType,
2440        mask: u16,
2441    ) -> Option<mpsc::Receiver<MonitorEvent>> {
2442        let cap = crate::server::pv::max_subscribers_per_pv();
2443        let field_str = field.to_string();
2444        let bucket = self.subscribers.entry(field_str.clone()).or_default();
2445        // Reap dead Senders before
2446        // counting against the cap. A record field whose value
2447        // never changes (e.g. a quasi-static catalog field) never
2448        // triggers `notify_field_with_origin`'s retain-filter, so
2449        // a long-lived subscribe-disconnect storm could pin the
2450        // bucket at `cap` worth of closed Senders and lock out
2451        // genuine new subscribers.
2452        bucket.retain(|s| !s.tx.is_closed());
2453        if bucket.len() >= cap {
2454            tracing::warn!(
2455                record = %self.name,
2456                field = %field_str,
2457                live = bucket.len(),
2458                cap,
2459                "record field subscriber cap reached, refusing add_subscriber"
2460            );
2461            return None;
2462        }
2463        let (tx, rx) = mpsc::channel(64);
2464        bucket.push(Subscriber {
2465            sid,
2466            data_type,
2467            mask,
2468            tx,
2469            coalesced: std::sync::Arc::new(std::sync::Mutex::new(None)),
2470            filters: crate::server::database::filters::FilterChain::new(),
2471            active: true,
2472        });
2473        // Initialize last_posted with current value so the first process cycle
2474        // doesn't treat it as "changed" (the initial value is already sent
2475        // to the client as part of EVENT_ADD response).
2476        if !self.last_posted.contains_key(&field_str) {
2477            if let Some(val) = self.resolve_field(&field_str) {
2478                self.last_posted.insert(field_str, val);
2479            }
2480        }
2481        Some(rx)
2482    }
2483
2484    /// Attach a filter to the most recently added subscriber for
2485    /// `field`. Returns `false` when no subscriber exists yet on that
2486    /// field (call `add_subscriber` first). The CA / PVA channel-name
2487    /// parsers will use this once `.{filter:opts}` syntax is wired.
2488    /// Tests can also use it directly to compose filter chains.
2489    pub fn attach_filter_to_last_subscriber(
2490        &mut self,
2491        field: &str,
2492        filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
2493    ) -> bool {
2494        if let Some(bucket) = self.subscribers.get_mut(field) {
2495            if let Some(sub) = bucket.last_mut() {
2496                sub.filters.push(filter);
2497                return true;
2498            }
2499        }
2500        false
2501    }
2502
2503    /// Remove a subscriber by subscription ID from all fields.
2504    pub fn remove_subscriber(&mut self, sid: u32) {
2505        for subs in self.subscribers.values_mut() {
2506            subs.retain(|s| s.sid != sid);
2507        }
2508    }
2509
2510    /// Pause / resume one subscriber's event flow at the source
2511    /// (`db_event_disable` / `db_event_enable`). `active == false`
2512    /// suppresses every subsequent post to this subscriber AND drops any
2513    /// pending coalesced overflow, so a resumed monitor restarts from the
2514    /// source-side edge rather than replaying a value captured while it
2515    /// was paused. No-op if no subscriber has this `sid`. The caller holds
2516    /// the record write lock, so this is exclusive with the read-locked
2517    /// post paths that consult `Subscriber::active`.
2518    pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
2519        for subs in self.subscribers.values_mut() {
2520            for sub in subs.iter_mut() {
2521                if sub.sid == sid {
2522                    sub.active = active;
2523                    if !active && let Ok(mut slot) = sub.coalesced.lock() {
2524                        *slot = None;
2525                    }
2526                }
2527            }
2528        }
2529    }
2530
2531    /// Take any pending coalesced overflow event for `sid` across all
2532    /// fields. Drops-oldest semantics: if the per-subscriber mpsc filled
2533    /// while the consumer was slow, the newest event was stashed in the
2534    /// coalesce slot and is returned here.
2535    pub fn pop_coalesced(&self, sid: u32) -> Option<MonitorEvent> {
2536        for subs in self.subscribers.values() {
2537            for sub in subs {
2538                if sub.sid == sid {
2539                    if let Ok(mut slot) = sub.coalesced.lock() {
2540                        if let Some(ev) = slot.take() {
2541                            return Some(ev);
2542                        }
2543                    }
2544                }
2545            }
2546        }
2547        None
2548    }
2549
2550    /// Clean up closed subscriber channels.
2551    pub fn cleanup_subscribers(&mut self) {
2552        for subs in self.subscribers.values_mut() {
2553            subs.retain(|s| !s.tx.is_closed());
2554        }
2555    }
2556}
2557
2558/// C `recGblCheckDeadband` parity (recGbl.c:345-370). The four branches
2559/// the C path enumerates:
2560///
2561/// 1. Both `newval` and `oldval` finite: `delta = |old - new|`, fire when
2562///    `delta > deadband`.
2563/// 2. Exactly one of {newval, oldval} is NaN, the other not — OR exactly
2564///    one is +/-inf, the other not: `delta = +inf`, always fires.
2565/// 3. Both infinite with opposite signs: `delta = +inf`, always fires.
2566/// 4. Otherwise (e.g. both NaN, both same-signed infinity): no fire.
2567///
2568/// `oldval = NaN` is treated as "never posted" and fires (matches the
2569/// `mlst.is_nan() → trigger` short-circuit the Rust port already had).
2570/// `deadband < 0` fires unconditionally (matches `delta > deadband`
2571/// with a negative deadband — same effect on every numeric value).
2572pub(crate) fn check_deadband(newval: f64, oldval: f64, deadband: f64) -> bool {
2573    // Fire unconditionally when no prior posting has happened. C achieves
2574    // the same effect through the field being default-initialised to a
2575    // sentinel; Rust uses NaN-as-sentinel.
2576    if oldval.is_nan() {
2577        return true;
2578    }
2579    // Negative deadband short-circuits — any value passes.
2580    if deadband < 0.0 {
2581        return true;
2582    }
2583    let new_finite = newval.is_finite();
2584    let old_finite = oldval.is_finite();
2585    if new_finite && old_finite {
2586        return (newval - oldval).abs() > deadband;
2587    }
2588    // From here on, at least one of the two is not finite. We've already
2589    // ruled out oldval=NaN above, so any newval=NaN here is the "newval
2590    // went NaN while oldval was finite/inf" case — must fire (C case 2).
2591    if newval.is_nan() {
2592        return true;
2593    }
2594    // Exactly one infinite, the other finite: C case 2 → fire.
2595    if new_finite != old_finite {
2596        return true;
2597    }
2598    // Both infinite. Opposite signs → fire (C case 3); same sign → no
2599    // fire (C path leaves delta=0 and the `delta > deadband` check fails
2600    // for any non-negative deadband).
2601    newval != oldval
2602}
2603
2604#[cfg(test)]
2605mod metadata_cache_tests {
2606    use super::*;
2607    use crate::server::records::ai::AiRecord;
2608
2609    /// Helper: build an AiRecord wrapped in a RecordInstance with EGU/PREC/HOPR/LOPR set.
2610    fn ai_instance() -> RecordInstance {
2611        let mut rec = AiRecord::default();
2612        let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
2613        let _ = rec.put_field("PREC", EpicsValue::Short(2));
2614        let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
2615        let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
2616        let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
2617        RecordInstance::new("TEMP".to_string(), rec)
2618    }
2619
2620    /// a record-field monitor whose bounded queue is full and
2621    /// whose coalesce slot already holds an unobserved value must count
2622    /// the displaced value in the shared `dropped_monitor_events()`
2623    /// counter — the same accounting a `ProcessVariable` overflow uses.
2624    /// Before the fix the record-field path overwrote the slot without
2625    /// counting, hiding slow-consumer loss on the path most CA/PVA
2626    /// database monitors use. The counter is process-global, so the
2627    /// assertion is a strict monotonic increase (robust under parallel
2628    /// tests); the revert-verify runs this test in isolation.
2629    #[test]
2630    fn bfr10_record_field_overflow_counts_dropped_event() {
2631        use crate::server::pv::dropped_monitor_events;
2632        use crate::server::recgbl::EventMask;
2633        let mut inst = ai_instance();
2634        // Keep `rx` alive (do NOT drain) so the bounded 64-deep queue
2635        // fills, then the coalesce slot, before overflow replacement.
2636        let _rx = inst
2637            .add_subscriber(
2638                "VAL",
2639                1,
2640                crate::types::DbFieldType::Double,
2641                EventMask::VALUE.bits(),
2642            )
2643            .expect("subscriber added");
2644        let before = dropped_monitor_events();
2645        // 64 sends fill the queue; the 65th fills the (empty) coalesce
2646        // slot; each send after that overwrites an UNOBSERVED slot value
2647        // and must be counted as a dropped monitor event.
2648        for _ in 0..70 {
2649            inst.notify_field_with_origin("VAL", EventMask::VALUE, 0);
2650        }
2651        let after = dropped_monitor_events();
2652        assert!(
2653            after > before,
2654            "record-field overflow onto an occupied coalesce slot must \
2655             record a dropped monitor event (before={before}, after={after})"
2656        );
2657    }
2658
2659    #[test]
2660    fn metadata_field_set_check() {
2661        // Sanity check that the metadata field set is recognized.
2662        assert!(is_metadata_field("EGU"));
2663        assert!(is_metadata_field("PREC"));
2664        assert!(is_metadata_field("HOPR"));
2665        assert!(is_metadata_field("LOPR"));
2666        assert!(is_metadata_field("HIHI"));
2667        assert!(is_metadata_field("DRVH"));
2668        assert!(is_metadata_field("ZNAM"));
2669        assert!(is_metadata_field("ZRST"));
2670        assert!(is_metadata_field("FFST"));
2671
2672        // Non-metadata fields should NOT invalidate the cache
2673        assert!(!is_metadata_field("VAL"));
2674        assert!(!is_metadata_field("DESC"));
2675        assert!(!is_metadata_field("SCAN"));
2676        assert!(!is_metadata_field("PHAS"));
2677    }
2678
2679    #[test]
2680    fn cache_starts_empty_then_populates_on_first_snapshot() {
2681        let inst = ai_instance();
2682
2683        // Cache starts empty
2684        assert!(inst.metadata_cache.lock().unwrap().is_none());
2685
2686        // First snapshot triggers populate + cache store
2687        let snap = inst.snapshot_for_field("VAL").unwrap();
2688        let display = snap.display.expect("ai snapshot must have display");
2689        assert_eq!(display.units, "degC");
2690        assert_eq!(display.precision, 2);
2691        assert_eq!(display.upper_disp_limit, 100.0);
2692        assert_eq!(display.lower_disp_limit, 0.0);
2693
2694        // Cache is now populated
2695        assert!(inst.metadata_cache.lock().unwrap().is_some());
2696    }
2697
2698    /// the served `timeStamp.userTag` defaults to the record's `utag`
2699    /// (pvxs `iocsource.cpp:245`), on both the GET (`snapshot_for_field`)
2700    /// and MONITOR (`make_monitor_snapshot`) paths. Pre-fix both hard-set
2701    /// it to 0, dropping the record's tag. A bit-31 utag also pins the
2702    /// `u64 -> i32` narrowing: the low 32 bits' pattern is preserved
2703    /// (no clamp), matching pvxs assigning `epicsUTag` into the `Int32`
2704    /// wire field.
2705    #[test]
2706    fn snapshot_serves_record_utag_as_timestamp_usertag() {
2707        let mut inst = ai_instance();
2708        // no `info(Q:time:tag, ...)` on this record, so the nsec-LSB
2709        // override never fires and the utag default is what is served.
2710        inst.common.utag = 0x9000_0000;
2711        let want = 0x9000_0000u32 as i32;
2712
2713        let get = inst.snapshot_for_field("VAL").unwrap();
2714        assert_eq!(
2715            get.user_tag, want,
2716            "GET path must serve the record's utag as timeStamp.userTag"
2717        );
2718
2719        let mon = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
2720        assert_eq!(
2721            mon.user_tag, want,
2722            "MONITOR path must carry the record's utag too"
2723        );
2724    }
2725
2726    #[test]
2727    fn cache_hit_returns_same_metadata() {
2728        let inst = ai_instance();
2729
2730        // Prime the cache
2731        let snap1 = inst.snapshot_for_field("VAL").unwrap();
2732        let display1 = snap1.display.unwrap();
2733
2734        // Subsequent snapshots return the same cached metadata
2735        let snap2 = inst.snapshot_for_field("VAL").unwrap();
2736        let display2 = snap2.display.unwrap();
2737
2738        assert_eq!(display1.units, display2.units);
2739        assert_eq!(display1.precision, display2.precision);
2740        assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
2741        assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
2742    }
2743
2744    #[test]
2745    fn invalidate_clears_cache() {
2746        let inst = ai_instance();
2747        let _ = inst.snapshot_for_field("VAL");
2748        assert!(inst.metadata_cache.lock().unwrap().is_some());
2749
2750        inst.invalidate_metadata_cache();
2751        assert!(inst.metadata_cache.lock().unwrap().is_none());
2752    }
2753
2754    #[test]
2755    fn notify_field_written_invalidates_for_metadata_field() {
2756        let inst = ai_instance();
2757        let _ = inst.snapshot_for_field("VAL");
2758        assert!(inst.metadata_cache.lock().unwrap().is_some());
2759
2760        // Writing a metadata field should invalidate
2761        inst.notify_field_written("EGU");
2762        assert!(inst.metadata_cache.lock().unwrap().is_none());
2763    }
2764
2765    #[test]
2766    fn notify_field_written_skips_non_metadata_field() {
2767        let inst = ai_instance();
2768        let _ = inst.snapshot_for_field("VAL");
2769        assert!(inst.metadata_cache.lock().unwrap().is_some());
2770
2771        // Writing a value field should NOT invalidate the cache
2772        inst.notify_field_written("VAL");
2773        assert!(inst.metadata_cache.lock().unwrap().is_some());
2774
2775        // Same for DESC
2776        inst.notify_field_written("DESC");
2777        assert!(inst.metadata_cache.lock().unwrap().is_some());
2778    }
2779
2780    #[test]
2781    fn notify_field_written_is_case_insensitive() {
2782        let inst = ai_instance();
2783        let _ = inst.snapshot_for_field("VAL");
2784        assert!(inst.metadata_cache.lock().unwrap().is_some());
2785
2786        // Lowercase metadata field name should still trigger invalidation
2787        inst.notify_field_written("egu");
2788        assert!(inst.metadata_cache.lock().unwrap().is_none());
2789    }
2790
2791    /// epics-base faac1df1 — `notify_field_written_if_changed` must
2792    /// SKIP the cache invalidation when the metadata field's value
2793    /// didn't actually change. Otherwise a stream of idempotent puts
2794    /// from a CSS panel binds DBE_PROPERTY subscribers to bogus
2795    /// "property changed" events on every cycle.
2796    #[test]
2797    fn notify_field_written_if_changed_skips_when_unchanged() {
2798        let mut inst = ai_instance();
2799        let _ = inst.snapshot_for_field("VAL");
2800        assert!(inst.metadata_cache.lock().unwrap().is_some());
2801
2802        // Capture prev, do a no-op put, then notify — cache must remain.
2803        let prev = inst.record.get_field("EGU");
2804        let _ = inst.record.put_field("EGU", prev.clone().unwrap());
2805        inst.notify_field_written_if_changed("EGU", prev.as_ref());
2806        assert!(
2807            inst.metadata_cache.lock().unwrap().is_some(),
2808            "no-op put must not invalidate the metadata cache"
2809        );
2810    }
2811
2812    /// And when the value DID change, the cache must invalidate.
2813    #[test]
2814    fn notify_field_written_if_changed_invalidates_on_real_change() {
2815        let mut inst = ai_instance();
2816        let _ = inst.snapshot_for_field("VAL");
2817        assert!(inst.metadata_cache.lock().unwrap().is_some());
2818
2819        let prev = inst.record.get_field("EGU");
2820        let _ = inst
2821            .record
2822            .put_field("EGU", EpicsValue::String("kPa".into()));
2823        inst.notify_field_written_if_changed("EGU", prev.as_ref());
2824        assert!(
2825            inst.metadata_cache.lock().unwrap().is_none(),
2826            "real metadata change must invalidate cache"
2827        );
2828    }
2829
2830    /// Non-metadata fields don't carry property semantics — the
2831    /// `if_changed` variant must never invalidate for them, matching
2832    /// the existing `notify_field_written` short-circuit.
2833    #[test]
2834    fn notify_field_written_if_changed_skips_non_metadata_field() {
2835        let inst = ai_instance();
2836        let _ = inst.snapshot_for_field("VAL");
2837        assert!(inst.metadata_cache.lock().unwrap().is_some());
2838        // VAL is not in is_metadata_field set — must be skipped even
2839        // with a changed value.
2840        inst.notify_field_written_if_changed("VAL", None);
2841        assert!(inst.metadata_cache.lock().unwrap().is_some());
2842    }
2843
2844    #[test]
2845    fn cache_picks_up_new_value_after_invalidation() {
2846        let mut inst = ai_instance();
2847
2848        // First snapshot: degC
2849        let snap1 = inst.snapshot_for_field("VAL").unwrap();
2850        assert_eq!(snap1.display.unwrap().units, "degC");
2851
2852        // Mutate EGU and invalidate
2853        let _ = inst
2854            .record
2855            .put_field("EGU", EpicsValue::String("mV".into()));
2856        inst.notify_field_written("EGU");
2857
2858        // Second snapshot: mV (rebuilt)
2859        let snap2 = inst.snapshot_for_field("VAL").unwrap();
2860        assert_eq!(snap2.display.unwrap().units, "mV");
2861    }
2862
2863    #[test]
2864    fn make_monitor_snapshot_uses_cache() {
2865        let inst = ai_instance();
2866        assert!(inst.metadata_cache.lock().unwrap().is_none());
2867
2868        // make_monitor_snapshot should also populate the cache
2869        let snap = inst.make_monitor_snapshot("VAL", EpicsValue::Double(42.0));
2870        assert!(snap.display.is_some());
2871        assert!(inst.metadata_cache.lock().unwrap().is_some());
2872
2873        // Subsequent call hits cache
2874        let snap2 = inst.make_monitor_snapshot("VAL", EpicsValue::Double(43.0));
2875        let d1 = snap.display.unwrap();
2876        let d2 = snap2.display.unwrap();
2877        assert_eq!(d1.units, d2.units);
2878        assert_eq!(d1.precision, d2.precision);
2879    }
2880
2881    /// Stub record with a per-field metadata override on SPD only —
2882    /// models a C RSET whose get_units/get_graphic_double key on
2883    /// dbGetFieldIndex (e.g. motorRecord.cc:3156-3361).
2884    struct PerFieldMetaRecord;
2885
2886    impl Record for PerFieldMetaRecord {
2887        fn record_type(&self) -> &'static str {
2888            "ai" // record-level metadata populates from EGU/PREC/HOPR/LOPR
2889        }
2890        fn get_field(&self, name: &str) -> Option<EpicsValue> {
2891            match name {
2892                "VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
2893                "EGU" => Some(EpicsValue::String("mm".into())),
2894                "PREC" => Some(EpicsValue::Short(3)),
2895                "HOPR" => Some(EpicsValue::Double(100.0)),
2896                "LOPR" => Some(EpicsValue::Double(-100.0)),
2897                _ => None,
2898            }
2899        }
2900        fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
2901            Err(CaError::FieldNotFound(name.to_string()))
2902        }
2903        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
2904            &[]
2905        }
2906        fn field_metadata_override(
2907            &self,
2908            field: &str,
2909        ) -> Option<crate::server::record::FieldMetadataOverride> {
2910            if field != "SPD" {
2911                return None;
2912            }
2913            Some(crate::server::record::FieldMetadataOverride {
2914                units: Some("mm/sec".into()),
2915                precision: Some(1),
2916                disp_limits: Some((5.0, 0.5)),
2917                ctrl_limits: Some((4.0, 1.0)),
2918                alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
2919            })
2920        }
2921    }
2922
2923    #[test]
2924    fn field_metadata_override_applies_on_get_and_monitor_paths() {
2925        let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
2926
2927        // VAL: no override — record-level metadata serves it.
2928        let snap = inst.snapshot_for_field("VAL").unwrap();
2929        let d = snap.display.unwrap();
2930        assert_eq!(d.units, "mm");
2931        assert_eq!(d.precision, 3);
2932        assert_eq!(d.upper_disp_limit, 100.0);
2933
2934        // SPD via the GET path: every member patched over the cache.
2935        let snap = inst.snapshot_for_field("SPD").unwrap();
2936        let d = snap.display.unwrap();
2937        assert_eq!(d.units, "mm/sec");
2938        assert_eq!(d.precision, 1);
2939        assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
2940        assert_eq!(
2941            (
2942                d.upper_alarm_limit,
2943                d.upper_warning_limit,
2944                d.lower_warning_limit,
2945                d.lower_alarm_limit
2946            ),
2947            (9.0, 8.0, -8.0, -9.0)
2948        );
2949        let c = snap.control.unwrap();
2950        assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
2951
2952        // SPD via the monitor path: identical override.
2953        let snap = inst.make_monitor_snapshot("SPD", EpicsValue::Double(2.0));
2954        let d = snap.display.unwrap();
2955        assert_eq!(d.units, "mm/sec");
2956        assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
2957        let c = snap.control.unwrap();
2958        assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
2959    }
2960
2961    /// Stub modelling the motor monitor() shape (C motorRecord.cc:
2962    /// 3468-3507): VAL is a setpoint, the MDEL/ADEL deadband tracks
2963    /// the RBV readback, which advances on every process.
2964    struct ReadbackDeadbandRecord {
2965        val: f64,
2966        rbv: f64,
2967        deadband: f64,
2968    }
2969
2970    impl Record for ReadbackDeadbandRecord {
2971        fn record_type(&self) -> &'static str {
2972            "ai"
2973        }
2974        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
2975            self.rbv += 30.0;
2976            Ok(crate::server::record::ProcessOutcome::complete())
2977        }
2978        fn get_field(&self, name: &str) -> Option<EpicsValue> {
2979            match name {
2980                "VAL" => Some(EpicsValue::Double(self.val)),
2981                "RBV" => Some(EpicsValue::Double(self.rbv)),
2982                "MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
2983                _ => None,
2984            }
2985        }
2986        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
2987            match (name, value) {
2988                ("VAL", EpicsValue::Double(v)) => {
2989                    self.val = v;
2990                    Ok(())
2991                }
2992                ("MDEL", EpicsValue::Double(v)) => {
2993                    self.deadband = v;
2994                    Ok(())
2995                }
2996                _ => Err(CaError::FieldNotFound(name.to_string())),
2997            }
2998        }
2999        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3000            &[]
3001        }
3002        fn monitor_deadband_value(&self) -> Option<EpicsValue> {
3003            Some(EpicsValue::Double(self.rbv))
3004        }
3005        fn monitor_deadband_field(&self) -> &'static str {
3006            "RBV"
3007        }
3008    }
3009
3010    /// C motor monitor() parity: MDEL/ADEL throttle the deadband
3011    /// field's (RBV) delivery; VAL posts only when the setpoint
3012    /// actually changed — not on every readback poll.
3013    #[test]
3014    fn deadband_field_routes_readback_and_val_posts_only_on_change() {
3015        use crate::server::recgbl::EventMask;
3016        let mut inst = RecordInstance::new(
3017            "RDB".to_string(),
3018            ReadbackDeadbandRecord {
3019                val: 5.0,
3020                rbv: 0.0,
3021                deadband: 10.0,
3022            },
3023        );
3024        let _val_rx = inst
3025            .add_subscriber(
3026                "VAL",
3027                1,
3028                crate::types::DbFieldType::Double,
3029                EventMask::VALUE.bits(),
3030            )
3031            .expect("VAL subscriber");
3032        let _rbv_rx = inst
3033            .add_subscriber(
3034                "RBV",
3035                2,
3036                crate::types::DbFieldType::Double,
3037                EventMask::VALUE.bits(),
3038            )
3039            .expect("RBV subscriber");
3040        let names = |snap: &ProcessSnapshot| {
3041            snap.changed_fields
3042                .iter()
3043                .map(|(n, _, _)| n.clone())
3044                .collect::<Vec<_>>()
3045        };
3046
3047        // Cycle 1 (first publish): RBV fires via the deadband trigger
3048        // (MLST starts at the NaN never-posted sentinel). VAL must NOT
3049        // post: `add_subscriber` seeded `last_posted` with the current
3050        // value (the initial value already went out with EVENT_ADD), and
3051        // C monitor() posts VAL only when MARKED(M_VAL) — nothing marked
3052        // it.
3053        let (snap, _) = inst.process_local().unwrap();
3054        let n = names(&snap);
3055        assert!(n.contains(&"RBV".to_string()), "{n:?}");
3056        assert!(
3057            !n.contains(&"VAL".to_string()),
3058            "VAL unchanged since subscribe must not post: {n:?}"
3059        );
3060
3061        // Cycle 2: RBV moved past MDEL, VAL unchanged → RBV posted,
3062        // VAL not re-posted.
3063        let (snap, _) = inst.process_local().unwrap();
3064        let n = names(&snap);
3065        assert!(n.contains(&"RBV".to_string()), "RBV crossed MDEL: {n:?}");
3066        assert!(
3067            !n.contains(&"VAL".to_string()),
3068            "unchanged VAL must not post: {n:?}"
3069        );
3070
3071        // Cycle 3: widen the deadband — RBV moves within it → throttled.
3072        let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
3073        let (snap, _) = inst.process_local().unwrap();
3074        let n = names(&snap);
3075        assert!(
3076            !n.contains(&"RBV".to_string()),
3077            "MDEL must throttle RBV: {n:?}"
3078        );
3079
3080        // Cycle 4: setpoint moves while RBV stays inside the deadband →
3081        // VAL posts via change detection, RBV stays throttled.
3082        let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
3083        let (snap, _) = inst.process_local().unwrap();
3084        let n = names(&snap);
3085        assert!(
3086            n.contains(&"VAL".to_string()),
3087            "changed VAL must post: {n:?}"
3088        );
3089        assert!(
3090            !n.contains(&"RBV".to_string()),
3091            "MDEL must throttle RBV: {n:?}"
3092        );
3093    }
3094
3095    /// Record that names DIFF in `force_posted_fields` (the motor's C
3096    /// `process_motor_info` unconditional `MARK(M_DIFF)`) while keeping
3097    /// every value constant — a settled axis parked at a fixed non-zero
3098    /// following error. VAL is a control: not force-listed, so it must
3099    /// fall back to change-detection.
3100    struct ForcePostRecord {
3101        diff: f64,
3102        val: f64,
3103    }
3104
3105    impl Record for ForcePostRecord {
3106        fn record_type(&self) -> &'static str {
3107            "ai"
3108        }
3109        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3110            // Values never change — the readback already matches; only the
3111            // unconditional MARK should keep DIFF flowing.
3112            Ok(crate::server::record::ProcessOutcome::complete())
3113        }
3114        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3115            match name {
3116                "DIFF" => Some(EpicsValue::Double(self.diff)),
3117                "VAL" => Some(EpicsValue::Double(self.val)),
3118                _ => None,
3119            }
3120        }
3121        fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
3122            Err(CaError::FieldNotFound(name.to_string()))
3123        }
3124        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3125            &[]
3126        }
3127        fn force_posted_fields(&self) -> &'static [&'static str] {
3128            &["DIFF"]
3129        }
3130    }
3131
3132    /// C motorRecord parity: `process_motor_info` MARKs M_DIFF/M_RDIF every
3133    /// CALLBACK_DATA pass and `monitor()` posts them with `DBE_VAL_LOG`
3134    /// regardless of change, so a force-posted field re-posts on an
3135    /// otherwise-idle cycle while an unchanged non-force field does not.
3136    #[test]
3137    fn force_posted_field_reposts_unchanged_value_each_cycle() {
3138        use crate::server::recgbl::EventMask;
3139        let mut inst = RecordInstance::new(
3140            "FP".to_string(),
3141            ForcePostRecord {
3142                diff: 2.5,
3143                val: 1.0,
3144            },
3145        );
3146        let _diff_rx = inst
3147            .add_subscriber(
3148                "DIFF",
3149                1,
3150                crate::types::DbFieldType::Double,
3151                EventMask::VALUE.bits(),
3152            )
3153            .expect("DIFF subscriber");
3154        let _val_rx = inst
3155            .add_subscriber(
3156                "VAL",
3157                2,
3158                crate::types::DbFieldType::Double,
3159                EventMask::VALUE.bits(),
3160            )
3161            .expect("VAL subscriber");
3162        let names = |snap: &ProcessSnapshot| {
3163            snap.changed_fields
3164                .iter()
3165                .map(|(n, _, _)| n.clone())
3166                .collect::<Vec<_>>()
3167        };
3168
3169        // Cycle 1 (first publish): both DIFF and VAL post — last_posted is
3170        // empty so change-detection treats every subscribed field as new.
3171        let (snap1, _) = inst.process_local().unwrap();
3172        assert!(
3173            names(&snap1).contains(&"DIFF".to_string()),
3174            "DIFF posts on first publish: {:?}",
3175            names(&snap1)
3176        );
3177
3178        // Cycle 2: nothing changed. VAL (not force-listed) must NOT re-post;
3179        // DIFF (force-listed) MUST re-post — the C unconditional MARK +
3180        // DBE_VAL_LOG. This is the divergence MOT-1 closes.
3181        let (snap2, _) = inst.process_local().unwrap();
3182        assert!(
3183            names(&snap2).contains(&"DIFF".to_string()),
3184            "force-posted DIFF must re-post when unchanged: {:?}",
3185            names(&snap2)
3186        );
3187        assert!(
3188            !names(&snap2).contains(&"VAL".to_string()),
3189            "an unchanged non-force field must not re-post: {:?}",
3190            names(&snap2)
3191        );
3192        // The forced re-post carries DBE_VALUE|DBE_LOG (no alarm bits this
3193        // cycle), matching C `monitor_mask | DBE_VAL_LOG` with monitor_mask=0.
3194        let diff_mask = snap2
3195            .changed_fields
3196            .iter()
3197            .find(|(n, _, _)| n == "DIFF")
3198            .map(|(_, _, m)| *m)
3199            .expect("DIFF post present");
3200        assert_eq!(
3201            diff_mask.bits(),
3202            (EventMask::VALUE | EventMask::LOG).bits(),
3203            "forced re-post mask is DBE_VAL_LOG"
3204        );
3205    }
3206
3207    /// Record that names S1 in `log_swept_fields` (the scaler's idle
3208    /// `monitor()` DBE_LOG sweep) while keeping every value constant. S2
3209    /// is a control: subscribed but NOT swept, so an unchanged S2 must
3210    /// not re-post. Neither field is the primary `VAL`, so the default
3211    /// deadband field resolves to nothing and does not confound the test.
3212    struct LogSweepRecord {
3213        s1: i32,
3214        s2: i32,
3215    }
3216
3217    impl Record for LogSweepRecord {
3218        fn record_type(&self) -> &'static str {
3219            "scaler"
3220        }
3221        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3222            // Counts never change — only the unconditional idle LOG sweep
3223            // should keep S1 flowing to a DBE_LOG (archiver) subscriber.
3224            Ok(crate::server::record::ProcessOutcome::complete())
3225        }
3226        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3227            match name {
3228                "S1" => Some(EpicsValue::Long(self.s1)),
3229                "S2" => Some(EpicsValue::Long(self.s2)),
3230                _ => None,
3231            }
3232        }
3233        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
3234            match (name, value) {
3235                ("S1", EpicsValue::Long(v)) => {
3236                    self.s1 = v;
3237                    Ok(())
3238                }
3239                ("S2", EpicsValue::Long(v)) => {
3240                    self.s2 = v;
3241                    Ok(())
3242                }
3243                _ => Err(CaError::FieldNotFound(name.to_string())),
3244            }
3245        }
3246        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3247            &[]
3248        }
3249        fn log_swept_fields(&self) -> &'static [&'static str] {
3250            &["S1"]
3251        }
3252    }
3253
3254    /// C scalerRecord.c:770-787 `monitor()` sweeps each active channel
3255    /// with a literal `DBE_LOG` on every idle process: an UNCHANGED swept
3256    /// field re-posts with `DBE_LOG` ONLY, while a CHANGED swept field is
3257    /// delivered once by change-detection with `DBE_VALUE|DBE_LOG` (NOT
3258    /// double-posted by the sweep). A non-swept field never re-posts when
3259    /// unchanged. `add_subscriber` seeds `last_posted` with the current
3260    /// value (the initial value goes out via EVENT_ADD), so a freshly
3261    /// subscribed unchanged field already takes the sweep path on cycle 1.
3262    #[test]
3263    fn log_swept_field_reposts_unchanged_with_log_mask_only() {
3264        use crate::server::recgbl::EventMask;
3265        let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
3266        let _s1_rx = inst
3267            .add_subscriber(
3268                "S1",
3269                1,
3270                crate::types::DbFieldType::Long,
3271                EventMask::LOG.bits(),
3272            )
3273            .expect("S1 subscriber");
3274        let _s2_rx = inst
3275            .add_subscriber(
3276                "S2",
3277                2,
3278                crate::types::DbFieldType::Long,
3279                EventMask::VALUE.bits(),
3280            )
3281            .expect("S2 subscriber");
3282        let names = |snap: &ProcessSnapshot| {
3283            snap.changed_fields
3284                .iter()
3285                .map(|(n, _, _)| n.clone())
3286                .collect::<Vec<_>>()
3287        };
3288        let count_of = |snap: &ProcessSnapshot, f: &str| {
3289            snap.changed_fields
3290                .iter()
3291                .filter(|(n, _, _)| n == f)
3292                .count()
3293        };
3294        let mask_of = |snap: &ProcessSnapshot, f: &str| {
3295            snap.changed_fields
3296                .iter()
3297                .find(|(n, _, _)| n == f)
3298                .map(|(_, _, m)| *m)
3299        };
3300
3301        // Cycle 1: nothing changed since subscribe. S1 (swept) re-posts
3302        // with DBE_LOG ONLY; S2 (not swept) must NOT re-post.
3303        let (snap1, _) = inst.process_local().unwrap();
3304        assert!(
3305            names(&snap1).contains(&"S1".to_string()),
3306            "log-swept S1 must re-post when unchanged: {:?}",
3307            names(&snap1)
3308        );
3309        assert!(
3310            !names(&snap1).contains(&"S2".to_string()),
3311            "unchanged non-swept S2 must not re-post: {:?}",
3312            names(&snap1)
3313        );
3314        assert_eq!(
3315            mask_of(&snap1, "S1").unwrap().bits(),
3316            EventMask::LOG.bits(),
3317            "idle sweep posts DBE_LOG only (no DBE_VALUE)"
3318        );
3319
3320        // Cycle 2: S1's count changed. Change-detection delivers it ONCE
3321        // with DBE_VALUE|DBE_LOG; the sweep does NOT add a second post.
3322        inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
3323        let (snap2, _) = inst.process_local().unwrap();
3324        assert_eq!(
3325            count_of(&snap2, "S1"),
3326            1,
3327            "a changed swept field posts exactly once (no double-post): {:?}",
3328            snap2.changed_fields
3329        );
3330        assert_eq!(
3331            mask_of(&snap2, "S1").unwrap().bits(),
3332            (EventMask::VALUE | EventMask::LOG).bits(),
3333            "a changed swept field posts VALUE|LOG via change-detection"
3334        );
3335
3336        // Cycle 3: unchanged again — back to the DBE_LOG-only sweep.
3337        let (snap3, _) = inst.process_local().unwrap();
3338        assert_eq!(
3339            mask_of(&snap3, "S1").unwrap().bits(),
3340            EventMask::LOG.bits(),
3341            "unchanged-again S1 returns to the DBE_LOG-only sweep"
3342        );
3343    }
3344
3345    /// Stub record that simulates a record whose process() mutates an
3346    /// internal metadata field. Used to verify that the
3347    /// `Record::took_metadata_change()` hook actually triggers cache
3348    /// invalidation in `process_local()`.
3349    struct MutatingMetaRecord {
3350        val: f64,
3351        egu: String,
3352        took_change: bool,
3353    }
3354
3355    impl Record for MutatingMetaRecord {
3356        fn record_type(&self) -> &'static str {
3357            "ai" // pretend to be ai so populate_display_info populates EGU
3358        }
3359        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3360            // Simulate dynamic metadata change inside processing
3361            self.egu = "kV".into();
3362            self.took_change = true;
3363            Ok(crate::server::record::ProcessOutcome::complete())
3364        }
3365        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3366            match name {
3367                "VAL" => Some(EpicsValue::Double(self.val)),
3368                "EGU" => Some(EpicsValue::String(self.egu.clone().into())),
3369                "PREC" => Some(EpicsValue::Short(0)),
3370                "HOPR" => Some(EpicsValue::Double(0.0)),
3371                "LOPR" => Some(EpicsValue::Double(0.0)),
3372                _ => None,
3373            }
3374        }
3375        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
3376            match (name, value) {
3377                ("VAL", EpicsValue::Double(v)) => {
3378                    self.val = v;
3379                    Ok(())
3380                }
3381                ("EGU", EpicsValue::String(s)) => {
3382                    self.egu = s.as_str_lossy().into_owned();
3383                    Ok(())
3384                }
3385                _ => Err(CaError::FieldNotFound(name.to_string())),
3386            }
3387        }
3388        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3389            &[]
3390        }
3391        fn took_metadata_change(&mut self) -> bool {
3392            let was = self.took_change;
3393            self.took_change = false; // reset after reporting
3394            was
3395        }
3396    }
3397
3398    #[test]
3399    fn process_local_invalidates_cache_on_took_metadata_change() {
3400        let mut inst = RecordInstance::new(
3401            "MUT".to_string(),
3402            MutatingMetaRecord {
3403                val: 1.0,
3404                egu: "V".to_string(),
3405                took_change: false,
3406            },
3407        );
3408
3409        // Build the cache once with the original EGU
3410        let snap1 = inst.snapshot_for_field("VAL").unwrap();
3411        assert_eq!(snap1.display.unwrap().units, "V");
3412        assert!(inst.metadata_cache.lock().unwrap().is_some());
3413
3414        // Run process_local — the stub record sets took_change inside process()
3415        let _ = inst.process_local();
3416
3417        // Cache should now be invalidated (took_metadata_change returned true)
3418        assert!(
3419            inst.metadata_cache.lock().unwrap().is_none(),
3420            "process_local should invalidate cache when took_metadata_change is true"
3421        );
3422
3423        // Next snapshot picks up the new EGU
3424        let snap2 = inst.snapshot_for_field("VAL").unwrap();
3425        assert_eq!(snap2.display.unwrap().units, "kV");
3426    }
3427
3428    /// Stub record that does NOT mutate metadata fields. Verifies the
3429    /// default `took_metadata_change` returns false and the cache stays.
3430    struct StableMetaRecord {
3431        val: f64,
3432    }
3433    impl Record for StableMetaRecord {
3434        fn record_type(&self) -> &'static str {
3435            "ai"
3436        }
3437        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3438            self.val += 1.0;
3439            Ok(crate::server::record::ProcessOutcome::complete())
3440        }
3441        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3442            match name {
3443                "VAL" => Some(EpicsValue::Double(self.val)),
3444                "EGU" => Some(EpicsValue::String("V".into())),
3445                "PREC" => Some(EpicsValue::Short(0)),
3446                "HOPR" => Some(EpicsValue::Double(0.0)),
3447                "LOPR" => Some(EpicsValue::Double(0.0)),
3448                _ => None,
3449            }
3450        }
3451        fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
3452            Ok(())
3453        }
3454        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3455            &[]
3456        }
3457        // took_metadata_change uses default impl (returns false)
3458    }
3459
3460    #[test]
3461    fn process_local_keeps_cache_when_no_metadata_change() {
3462        let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
3463
3464        let _ = inst.snapshot_for_field("VAL");
3465        assert!(inst.metadata_cache.lock().unwrap().is_some());
3466
3467        // Run process_local several times — cache should remain intact
3468        let _ = inst.process_local();
3469        assert!(inst.metadata_cache.lock().unwrap().is_some());
3470        let _ = inst.process_local();
3471        assert!(inst.metadata_cache.lock().unwrap().is_some());
3472        let _ = inst.process_local();
3473        assert!(inst.metadata_cache.lock().unwrap().is_some());
3474    }
3475
3476    // ── Regression: DBE_PROPERTY event delivery boundaries ──────────────
3477
3478    /// motor `prop(YES)` fields (motorRecord.dbd 154/161/289/361/368)
3479    /// are property-class: a changed write must post DBE_PROPERTY
3480    /// (C dbAccess.c dbPut, `pfldDes->prop`). They feed the
3481    /// live-computed `field_metadata_override`, not the cache, but the
3482    /// posting gate is this same set.
3483    #[test]
3484    fn motor_prop_yes_fields_are_property_class() {
3485        for f in ["VBAS", "VMAX", "MRES", "DHLM", "DLLM"] {
3486            assert!(is_metadata_field(f), "{f} must be property-class");
3487        }
3488    }
3489
3490    /// Boundary 1: metadata field written with a CHANGED value, subscriber
3491    /// mask includes PROPERTY → subscriber receives an event.
3492    /// Mirrors C dbAccess.c:1396-1397 `db_post_events(precord,NULL,DBE_PROPERTY)`.
3493    #[test]
3494    fn r47_property_event_delivered_on_changed_metadata() {
3495        use crate::server::recgbl::EventMask;
3496        let mut inst = ai_instance();
3497        let mut rx = inst
3498            .add_subscriber(
3499                "VAL",
3500                1,
3501                crate::types::DbFieldType::Double,
3502                EventMask::PROPERTY.bits(),
3503            )
3504            .expect("subscriber added");
3505
3506        let prev = inst.record.get_field("EGU"); // "degC"
3507        let _ = inst
3508            .record
3509            .put_field("EGU", EpicsValue::String("kPa".into()));
3510        inst.notify_field_written_if_changed("EGU", prev.as_ref());
3511
3512        assert!(
3513            rx.try_recv().is_ok(),
3514            "PROPERTY subscriber must receive event when metadata field changes"
3515        );
3516    }
3517
3518    /// Boundary 2: same metadata field written with the SAME value → NO event.
3519    /// Matches C suppression at dbAccess.c:1379-1383 and the `prev != now` gate.
3520    #[test]
3521    fn r47_no_event_on_unchanged_metadata() {
3522        use crate::server::recgbl::EventMask;
3523        let mut inst = ai_instance();
3524        let mut rx = inst
3525            .add_subscriber(
3526                "VAL",
3527                1,
3528                crate::types::DbFieldType::Double,
3529                EventMask::PROPERTY.bits(),
3530            )
3531            .expect("subscriber added");
3532
3533        let prev = inst.record.get_field("EGU"); // "degC"
3534        // Write the same value — no change
3535        let _ = inst.record.put_field("EGU", prev.clone().unwrap());
3536        inst.notify_field_written_if_changed("EGU", prev.as_ref());
3537
3538        assert!(
3539            rx.try_recv().is_err(),
3540            "PROPERTY subscriber must NOT receive event when metadata value is unchanged"
3541        );
3542    }
3543
3544    /// Boundary 3: VALUE-only subscriber (no PROPERTY bit) receives NO event
3545    /// from a metadata write, even when the field value changed.
3546    #[test]
3547    fn r47_value_only_subscriber_no_event_on_metadata_write() {
3548        use crate::server::recgbl::EventMask;
3549        let mut inst = ai_instance();
3550        let mut rx = inst
3551            .add_subscriber(
3552                "VAL",
3553                1,
3554                crate::types::DbFieldType::Double,
3555                EventMask::VALUE.bits(),
3556            )
3557            .expect("subscriber added");
3558
3559        let prev = inst.record.get_field("EGU"); // "degC"
3560        let _ = inst
3561            .record
3562            .put_field("EGU", EpicsValue::String("kPa".into()));
3563        inst.notify_field_written_if_changed("EGU", prev.as_ref());
3564
3565        assert!(
3566            rx.try_recv().is_err(),
3567            "VALUE-only subscriber must NOT receive event from a metadata write"
3568        );
3569    }
3570
3571    /// Boundary 4 (took_metadata_change path): PROPERTY subscriber receives
3572    /// event after process_local() when the record reports a metadata change.
3573    #[test]
3574    fn r47_process_local_property_event_on_took_metadata_change() {
3575        use crate::server::recgbl::EventMask;
3576        let mut inst = RecordInstance::new(
3577            "MUT2".to_string(),
3578            MutatingMetaRecord {
3579                val: 1.0,
3580                egu: "V".to_string(),
3581                took_change: false,
3582            },
3583        );
3584        let mut rx = inst
3585            .add_subscriber(
3586                "VAL",
3587                1,
3588                crate::types::DbFieldType::Double,
3589                EventMask::PROPERTY.bits(),
3590            )
3591            .expect("subscriber added");
3592
3593        // process() sets took_change = true and updates egu to "kV"
3594        let _ = inst.process_local();
3595
3596        assert!(
3597            rx.try_recv().is_ok(),
3598            "PROPERTY subscriber must receive event after process_local reports took_metadata_change"
3599        );
3600    }
3601}
3602
3603#[cfg(test)]
3604mod aftc_filter_tests {
3605    //! Tests for the shared AFTC alarm-range filter
3606    //! (`records::alarm_filter::aftc_filter`) as driven by
3607    //! `evaluate_analog_alarm`. Pure-function tests: no record instance
3608    //! needed — the filter is a stateless transform of (raw_alarm, aftc,
3609    //! afvl_in, t_last, t_now). Algorithm provenance: 2009 EPICS
3610    //! Codeathon (epics-base `824d37811`), C `aiRecord.c:355-401`.
3611
3612    use crate::server::records::alarm_filter::aftc_filter;
3613    use std::time::{Duration, SystemTime};
3614
3615    fn at(secs: f64) -> SystemTime {
3616        SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
3617    }
3618
3619    #[test]
3620    fn disabled_when_aftc_le_zero() {
3621        // aftc=0 means filter disabled — pass-through.
3622        let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
3623        assert_eq!(out, 2);
3624        assert_eq!(afvl, 0.0);
3625    }
3626
3627    #[test]
3628    fn initial_sample_seeds_state_unchanged_alarm() {
3629        // afvl=0 means first sample after enable — alarm passes through
3630        // and accumulator seeds with the raw severity.
3631        let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
3632        assert_eq!(out, 2);
3633        assert_eq!(afvl, 2.0);
3634    }
3635
3636    #[test]
3637    fn raises_alarm_only_after_full_time_constant() {
3638        // Single-step heuristic: with `aftc = 3s` and `dt = 0.1s`, alpha
3639        // ≈ 0.967, so a one-shot raw_alarm=2 against afvl=0.0 should not
3640        // produce alarm=2 yet — the filter must hold off until the
3641        // accumulator crosses the threshold.
3642        // Seed with afvl=0.01 (tiny prior, simulating "almost no alarm
3643        // yet"); the filter must keep alarm at 0 after one short tick.
3644        let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
3645        assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
3646        assert!(afvl > 0.0 && afvl < 2.0);
3647    }
3648
3649    #[test]
3650    fn dt_zero_is_no_op() {
3651        // Two evaluations at the same instant produce no filter advance.
3652        let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
3653        assert_eq!(out, 1); // floor(|1.5|) = 1
3654        assert_eq!(afvl, 1.5);
3655    }
3656
3657    #[test]
3658    fn long_steady_state_converges_to_alarm() {
3659        // After many steps with raw_alarm=2 and dt much smaller than aftc,
3660        // the accumulator must converge towards 2.
3661        let aftc = 1.0;
3662        let mut afvl = 0.0;
3663        let mut last = at(0.0);
3664        let mut alarm = 0;
3665        for i in 1..=100 {
3666            let now = at(i as f64 * 0.05);
3667            let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
3668            alarm = out;
3669            afvl = new_afvl;
3670            last = now;
3671        }
3672        assert_eq!(
3673            alarm, 2,
3674            "after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
3675        );
3676        assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
3677    }
3678}
3679
3680#[cfg(test)]
3681mod check_deadband_tests {
3682    use super::check_deadband;
3683
3684    /// Sentinel: `oldval=NaN` means "no prior posting", always fire.
3685    #[test]
3686    fn nan_old_value_fires() {
3687        assert!(check_deadband(0.0, f64::NAN, 1.0));
3688        assert!(check_deadband(f64::NAN, f64::NAN, 1.0));
3689    }
3690
3691    /// C path: `delta > deadband` with both finite. delta within deadband
3692    /// must NOT fire.
3693    #[test]
3694    fn within_finite_deadband_does_not_fire() {
3695        assert!(!check_deadband(10.0, 10.5, 1.0));
3696        assert!(!check_deadband(10.0, 9.5, 1.0));
3697        // Boundary: `delta == deadband` is NOT strictly greater.
3698        assert!(!check_deadband(10.0, 11.0, 1.0));
3699    }
3700
3701    /// `delta > deadband` with both finite, beyond → fire.
3702    #[test]
3703    fn beyond_finite_deadband_fires() {
3704        assert!(check_deadband(10.0, 12.0, 1.0));
3705    }
3706
3707    /// Negative deadband acts as "always fire" (C `delta > deadband` is
3708    /// trivially true for any non-negative delta).
3709    #[test]
3710    fn negative_deadband_fires() {
3711        assert!(check_deadband(10.0, 10.0, -1.0));
3712    }
3713
3714    /// C parity bug fix (recGbl.c:355-358): exactly one of {newval,
3715    /// oldval} is NaN — fire. Rust port previously short-circuited only
3716    /// on `oldval=NaN`; `newval=NaN` with `oldval=finite` produced
3717    /// `(NaN - finite).abs() = NaN`, `NaN > deadband = false` →
3718    /// silently dropped the NaN transition. End effect: a record that
3719    /// went UDF (e.g. divide-by-zero in calc) never posted the change
3720    /// to monitors, leaving every camonitor seeing the last valid value.
3721    #[test]
3722    fn newval_nan_with_finite_oldval_fires() {
3723        assert!(check_deadband(f64::NAN, 10.0, 1.0));
3724    }
3725
3726    /// C path case 2 (recGbl.c:355): exactly one infinite, the other
3727    /// finite — fire.
3728    #[test]
3729    fn one_finite_one_infinite_fires() {
3730        assert!(check_deadband(f64::INFINITY, 10.0, 1.0));
3731        assert!(check_deadband(10.0, f64::INFINITY, 1.0));
3732        assert!(check_deadband(f64::NEG_INFINITY, 10.0, 1.0));
3733    }
3734
3735    /// C path case 3 (recGbl.c:360-362): both infinite with opposite
3736    /// signs — fire.
3737    #[test]
3738    fn opposite_signed_infinities_fire() {
3739        assert!(check_deadband(f64::INFINITY, f64::NEG_INFINITY, 1.0));
3740        assert!(check_deadband(f64::NEG_INFINITY, f64::INFINITY, 1.0));
3741    }
3742
3743    /// Same-signed infinity → no fire (C path leaves `delta = 0`,
3744    /// `0 > deadband` is false for any non-negative deadband).
3745    #[test]
3746    fn same_signed_infinity_does_not_fire() {
3747        assert!(!check_deadband(f64::INFINITY, f64::INFINITY, 1.0));
3748        assert!(!check_deadband(f64::NEG_INFINITY, f64::NEG_INFINITY, 1.0));
3749    }
3750}