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