Skip to main content

epics_base_rs/server/database/
field_io.rs

1use std::collections::HashSet;
2
3use crate::error::{CaError, CaResult};
4use crate::server::snapshot::Snapshot;
5use crate::types::EpicsValue;
6
7use super::PvDatabase;
8
9/// Coerce a write `value` to a record field's stored `target` type. A
10/// `DBR_STRING` write to a `DBF_MENU` field resolves the label against THAT
11/// field's own menu first (C `dbConvert` `putStringMenu`: an exact label,
12/// then a numeric index) — never the field-blind global table that
13/// `EpicsValue::convert_to` would otherwise consult, which mis-maps a
14/// menu-specific label (e.g. `SELM "Specified"`). Every other value, and a
15/// string that names no choice, falls through to the single value-coercion
16/// owner `EpicsValue::convert_to`.
17fn coerce_write_value(
18    record: &dyn crate::server::record::Record,
19    field: &str,
20    target: crate::types::DbFieldType,
21    value: EpicsValue,
22) -> EpicsValue {
23    if let EpicsValue::String(s) = &value {
24        if let Some(choices) = record
25            .menu_field_choices(field)
26            .or_else(|| crate::server::record::shared_menu_choices(field))
27        {
28            let s = s.as_str_lossy();
29            if let Some(resolved) =
30                crate::server::record::resolve_menu_field_string(choices, target, &s)
31            {
32                return resolved;
33            }
34        }
35    }
36    value.convert_to(target)
37}
38
39impl PvDatabase {
40    /// Get a PV value synchronously from a blocking thread.
41    ///
42    /// Uses `block_in_place` + `Handle::block_on` to bridge the async
43    /// `get_pv` call. Safe to call from std::threads spawned within
44    /// a tokio runtime context.
45    pub fn get_pv_blocking(&self, name: &str) -> CaResult<EpicsValue> {
46        let db = self.clone();
47        let name = name.to_string();
48        if crate::runtime::task::RuntimeHandle::try_current().is_ok() {
49            crate::__tokio::task::block_in_place(|| {
50                crate::runtime::task::RuntimeHandle::current().block_on(db.get_pv(&name))
51            })
52        } else {
53            Err(CaError::InvalidValue(
54                "no runtime for get_pv_blocking".into(),
55            ))
56        }
57    }
58
59    /// Get the current value of a PV or record field.
60    /// Uses resolve_field for records (3-level priority).
61    pub async fn get_pv(&self, name: &str) -> CaResult<EpicsValue> {
62        let (base, field) = super::parse_pv_name(name);
63        let field = field.to_ascii_uppercase();
64
65        // Check simple PVs first (exact match)
66        if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
67            return Ok(pv.get().await);
68        }
69
70        // Records — alias-aware via `get_record` (epics-base PR #336).
71        if let Some(rec) = self.get_record(base).await {
72            let instance = rec.read().await;
73            return instance
74                .resolve_field(&field)
75                .ok_or_else(|| CaError::ChannelNotFound(name.to_string()));
76        }
77
78        Err(CaError::ChannelNotFound(name.to_string()))
79    }
80
81    /// Set a PV value or record field, notifying subscribers.
82    /// Tries record put_field first, then put_common_field as fallback.
83    ///
84    /// Acquires the record's advisory write gate.
85    pub async fn put_pv(&self, name: &str, value: EpicsValue) -> CaResult<()> {
86        self.put_pv_inner(name, value, true).await
87    }
88
89    /// `put_pv` variant for a caller already holding the
90    /// record's advisory write gate (QSRV atomic group PUT). See
91    /// [`Self::put_record_field_from_ca_already_locked`].
92    pub async fn put_pv_already_locked(&self, name: &str, value: EpicsValue) -> CaResult<()> {
93        self.put_pv_inner(name, value, false).await
94    }
95
96    async fn put_pv_inner(
97        &self,
98        name: &str,
99        value: EpicsValue,
100        acquire_gate: bool,
101    ) -> CaResult<()> {
102        let (base, field) = super::parse_pv_name(name);
103        let field = field.to_ascii_uppercase();
104
105        // Check simple PVs first
106        if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
107            pv.set(value).await;
108            return Ok(());
109        }
110
111        // Records — alias-aware (epics-base PR #336).
112        if let Some(rec) = self.get_record(base).await {
113            // `base` may be an alias; resolve to the canonical record
114            // name so scan-index updates target the right entry.
115            let canonical_base: String = self
116                .resolve_alias(base)
117                .await
118                .unwrap_or_else(|| base.to_string());
119            // advisory write gate (`dbScanLock` analogue) so a
120            // plain `put_pv` to a backing record cannot interleave
121            // with an atomic group transaction holding the same gate.
122            // Skipped when the caller already owns the gate.
123            let _record_gate = if acquire_gate {
124                Some(self.lock_record(&canonical_base).await)
125            } else {
126                None
127            };
128            let mut instance = rec.write().await;
129
130            // Coerce value to field's native type
131            let value = {
132                let target_type = instance
133                    .record
134                    .field_list()
135                    .iter()
136                    .find(|f| f.name.eq_ignore_ascii_case(&field))
137                    .map(|f| f.dbf_type);
138                if let Some(target) = target_type {
139                    if value.db_field_type() != target {
140                        // C EPICS dbPut (12cfd41): nRequest=0 into a scalar
141                        // field must NOT silently coerce. `convert_to` on an
142                        // empty array calls `to_f64().unwrap_or(0.0)` and
143                        // would produce a scalar zero — the same garbage-
144                        // value bug the C fix raised LINK_ALARM for.
145                        if value.is_empty_array() {
146                            return Err(CaError::InvalidValue(format!(
147                                "empty array cannot be coerced to scalar field {field}"
148                            )));
149                        }
150                        coerce_write_value(&*instance.record, &field, target, value)
151                    } else {
152                        value
153                    }
154                } else {
155                    value
156                }
157            };
158
159            // Capture the pre-put value so the metadata-cache
160            // invalidation (and the downstream `DBE_PROPERTY`
161            // emission) can be skipped when the put is a no-op —
162            // epics-base faac1df1.
163            let prev_value = instance.record.get_field(&field);
164
165            // put_pv is C EPICS dbPut: write value + special/on_put.
166            // Does NOT post monitor events (use put_pv_and_post for that).
167            // Does NOT clear UDF or trigger processing.
168            use crate::server::record::CommonFieldPutResult;
169            let common_result = match instance.record.put_field(&field, value.clone()) {
170                Ok(()) => {
171                    instance.record.on_put(&field);
172                    let _ = instance.record.special(&field, true);
173                    CommonFieldPutResult::NoChange
174                }
175                Err(CaError::FieldNotFound(_)) => instance.put_common_field(&field, value)?,
176                Err(e) => return Err(e),
177            };
178
179            // Invalidate metadata cache only if the metadata-class
180            // field's value actually changed (faac1df1).
181            instance.notify_field_written_if_changed(&field, prev_value.as_ref());
182
183            // Update scan index if SCAN or PHAS changed
184            match common_result {
185                CommonFieldPutResult::ScanChanged {
186                    old_scan,
187                    new_scan,
188                    phas,
189                } => {
190                    drop(instance);
191                    self.update_scan_index(&canonical_base, old_scan, new_scan, phas, phas)
192                        .await;
193                }
194                CommonFieldPutResult::PhasChanged {
195                    scan: s,
196                    old_phas,
197                    new_phas,
198                } => {
199                    drop(instance);
200                    self.update_scan_index(&canonical_base, s, s, old_phas, new_phas)
201                        .await;
202                }
203                CommonFieldPutResult::NoChange => {}
204            }
205
206            // mirror the CA-write path's ASG-field notifier so
207            // restore scripts / autosave / admin tools that go via
208            // `put_pv` (not `put_record_field_from_ca`) also trigger
209            // per-client `reeval_access_rights`. C `dbAccess.c::
210            // dbPutSpecial` invokes the SPC_AS callback from dbPut
211            // regardless of caller entry path.
212            if field == "ASG" {
213                crate::server::access_security::notify_asg_field_changed();
214            }
215
216            return Ok(());
217        }
218
219        Err(CaError::ChannelNotFound(name.to_string()))
220    }
221
222    /// Write a value and post monitor events if changed.
223    /// Equivalent to C EPICS `dbPut` + `db_post_events(DBE_VALUE|DBE_LOG)`.
224    ///
225    /// Use for readback/status mirror PVs that are written by sequencer-style
226    /// code and need to be visible to CA monitors without triggering record
227    /// processing. Clears UDF/UDF_ALARM on primary field write.
228    ///
229    /// `origin`: writer ID for self-write filtering. Subscribers with the
230    /// same `ignore_origin` will skip this event. Pass 0 to disable.
231    pub async fn put_pv_and_post(&self, name: &str, value: EpicsValue) -> CaResult<()> {
232        self.put_pv_and_post_with_origin(name, value, 0).await
233    }
234
235    /// Push a monitor event holding the simple PV's *current* value
236    /// but with explicit alarm severity/status. Used by the gateway
237    /// to surface upstream-disconnect to downstream monitor
238    /// subscribers without dropping the shadow PV (which would force
239    /// downstream clients into ECA_DISCONN reconnect storms on every
240    /// transient hiccup). Returns `ChannelNotFound` for record-backed
241    /// PVs — those carry their own `common.sevr/stat` in record
242    /// processing.
243    pub async fn post_alarm(&self, name: &str, severity: u16, status: u16) -> CaResult<()> {
244        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
245            pv.post_alarm(severity, status).await;
246            return Ok(());
247        }
248        Err(crate::error::CaError::ChannelNotFound(name.to_string()))
249    }
250
251    /// Propagate a full upstream snapshot (value + alarm status/severity +
252    /// IOC timestamp) to a simple shadow PV and fan out to downstream
253    /// monitor subscribers. Used by the CA gateway forwarding task to avoid
254    /// discarding the upstream alarm and timestamp decoded from the incoming
255    /// `DBR_TIME_*` frame. Returns `ChannelNotFound` for record-backed PVs
256    /// (those carry their own alarm engine and are not shadow PVs).
257    pub async fn put_pv_and_post_snapshot(&self, name: &str, snapshot: Snapshot) -> CaResult<()> {
258        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
259            pv.set_snapshot(snapshot).await;
260            return Ok(());
261        }
262        Err(CaError::ChannelNotFound(name.to_string()))
263    }
264
265    /// Install upstream `DBR_CTRL_*` metadata (display / control limits,
266    /// enum labels) on a shadow simple PV WITHOUT posting an event.
267    ///
268    /// The CA gateway calls this once on upstream connect, after its initial
269    /// `DBR_CTRL_*` get, so a later downstream `DBR_CTRL_*` / `DBR_GR_*` read
270    /// returns the real limits instead of zeroed ones. No `DBE_PROPERTY`
271    /// monitor event fires — nothing has *changed* yet, this only seeds the
272    /// attribute cache. Mirrors C `gatePvData::getCB` → `runDataCB` →
273    /// `vc->setPvData(dd)` (`gatePv.cc:1693-1695`), which seeds the property
274    /// cache from the initial control get in both cache modes before any
275    /// monitor is enabled.
276    ///
277    /// Returns `ChannelNotFound` for record-backed PVs — those own their own
278    /// metadata via record processing and are not gateway shadow PVs.
279    pub async fn set_pv_metadata(&self, name: &str, snapshot: &Snapshot) -> CaResult<()> {
280        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
281            pv.set_metadata(metadata_from_snapshot(snapshot));
282            return Ok(());
283        }
284        Err(CaError::ChannelNotFound(name.to_string()))
285    }
286
287    /// Refresh a shadow simple PV's upstream metadata AND post a
288    /// `DBE_PROPERTY` monitor event carrying `snapshot` to downstream
289    /// property subscribers.
290    ///
291    /// `snapshot` is the decoded upstream `DBR_CTRL_*` property event: it
292    /// carries the control value and the upstream `status` / `severity`,
293    /// and (because control DBR structs carry no timestamp) an undefined
294    /// timestamp the caller must NOT replace with a fresh wall-clock. The
295    /// gateway's property monitor calls this on every upstream
296    /// `DBE_PROPERTY` event, mirroring C `gatePvData::propEventCB` →
297    /// `runDataCB` + `setPvData` + `runValueDataCB` +
298    /// `vcPostEvent(propertyEventMask())` (`gatePv.cc:1571-1607`): the
299    /// attribute cache is refreshed and a property event is posted with the
300    /// upstream alarm state preserved (`setStatSevr`) and the undefined
301    /// control-DBR timestamp left as-is (`gatePv.cc:1594-1595`).
302    ///
303    /// Returns `ChannelNotFound` for record-backed PVs.
304    pub async fn post_pv_property(&self, name: &str, snapshot: Snapshot) -> CaResult<()> {
305        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
306            pv.set_metadata(metadata_from_snapshot(&snapshot));
307            pv.post_property(snapshot).await;
308            return Ok(());
309        }
310        Err(CaError::ChannelNotFound(name.to_string()))
311    }
312
313    /// Like `put_pv_and_post` but with explicit origin tag.
314    pub async fn put_pv_and_post_with_origin(
315        &self,
316        name: &str,
317        value: EpicsValue,
318        origin: u64,
319    ) -> CaResult<()> {
320        let (base, field) = super::parse_pv_name(name);
321        let field = field.to_ascii_uppercase();
322
323        // Simple-PV path: PVs registered via `add_pv` (e.g. CA gateway
324        // shadow PVs, IOCsh stats PVs) are stored in `simple_pvs`,
325        // not `records`. Without this branch the function would
326        // silently return `ChannelNotFound` for every gateway-mirrored
327        // PV — `ProcessVariable::set` already does the
328        // notify-subscribers fan-out internally so all we need here is
329        // to delegate. The `origin` tag is a no-op for simple PVs
330        // because they don't yet plumb origin through `set`.
331        if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
332            let _ = origin; // simple PVs don't currently honor origin tagging
333            pv.set(value).await;
334            return Ok(());
335        }
336
337        if let Some(rec) = self.get_record(base).await {
338            // `put_pv_and_post` is a public record-write API —
339            // it must take the same advisory write gate
340            // (`dbScanLock` analogue) as `put_pv` /
341            // `put_record_field_from_ca`, or a gateway/sequencer
342            // write through this helper can still land between the
343            // member writes of a QSRV atomic group or a pvalink
344            // atomic scan epoch holding `lock_records`. `base` is
345            // alias-resolved to the canonical record name so an alias
346            // and its target share one gate. Held until return.
347            let canonical_base: String = self
348                .resolve_alias(base)
349                .await
350                .unwrap_or_else(|| base.to_string());
351            let _record_gate = self.lock_record(&canonical_base).await;
352
353            let mut instance = rec.write().await;
354
355            // Type coercion
356            let value = {
357                let target_type = instance
358                    .record
359                    .field_list()
360                    .iter()
361                    .find(|f| f.name.eq_ignore_ascii_case(&field))
362                    .map(|f| f.dbf_type);
363                if let Some(target) = target_type {
364                    if value.db_field_type() != target {
365                        // C EPICS dbPut (12cfd41): empty-array → scalar
366                        // coercion would produce silent zero; reject.
367                        if value.is_empty_array() {
368                            return Err(CaError::InvalidValue(format!(
369                                "empty array cannot be coerced to scalar field {field}"
370                            )));
371                        }
372                        coerce_write_value(&*instance.record, &field, target, value)
373                    } else {
374                        value
375                    }
376                } else {
377                    value
378                }
379            };
380
381            let old_value = instance.record.get_field(&field);
382            let old_stat = instance.common.stat;
383            let old_sevr = instance.common.sevr;
384            // Snapshot side-effect-prone fields BEFORE the put. The
385            // array-family records (waveform/aai/aao/subArray) update
386            // NORD as a side-effect of put_field("VAL"); other record
387            // types return None for "NORD" and the comparison reduces
388            // to None==None → unchanged.
389            let old_nord = if field == "VAL" {
390                instance.record.get_field("NORD")
391            } else {
392                None
393            };
394
395            // Write value + special/on_put
396            match instance.record.put_field(&field, value.clone()) {
397                Ok(()) => {
398                    instance.record.on_put(&field);
399                    let _ = instance.record.special(&field, true);
400                    // Clear UDF/UDF_ALARM on primary field write
401                    if field == instance.record.primary_field() {
402                        instance.common.udf = false;
403                        if instance.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM {
404                            instance.common.stat = 0;
405                            instance.common.sevr = crate::server::record::AlarmSeverity::NoAlarm;
406                        }
407                    }
408                }
409                Err(CaError::FieldNotFound(_)) => {
410                    instance.put_common_field(&field, value)?;
411                }
412                Err(e) => return Err(e),
413            }
414
415            // Invalidate metadata cache only if a metadata-class
416            // field actually changed value (faac1df1 — DBE_PROPERTY
417            // fires on real changes, not no-op writes).
418            instance.notify_field_written_if_changed(&field, old_value.as_ref());
419
420            // Post monitor events if value or alarm changed
421            let new_value = instance.record.get_field(&field);
422            let value_changed = old_value != new_value;
423            let alarm_changed =
424                old_stat != instance.common.stat || old_sevr != instance.common.sevr;
425            let new_nord = if field == "VAL" {
426                instance.record.get_field("NORD")
427            } else {
428                None
429            };
430            let nord_changed = field == "VAL" && old_nord != new_nord && new_nord.is_some();
431            if value_changed || alarm_changed || nord_changed {
432                // Update timestamp so the snapshot carries current time
433                instance.common.time = crate::runtime::general_time::get_current();
434                instance.cleanup_subscribers();
435                if value_changed || alarm_changed {
436                    instance.notify_field_with_origin(
437                        &field,
438                        crate::server::recgbl::EventMask::VALUE
439                            | crate::server::recgbl::EventMask::LOG
440                            | crate::server::recgbl::EventMask::ALARM,
441                        origin,
442                    );
443                }
444                // Surface the implicit NORD update to NORD subscribers
445                // for waveform/aai/aao/subArray. Without this, a CA
446                // gateway forwarding upstream waveform monitors via
447                // put_pv_and_post would update VAL on the shadow PV
448                // but leave downstream NORD subscribers stuck at their
449                // last seen length — a frozen-element-count bug that
450                // surfaces in PyDM image views and similar consumers
451                // that compute height = element_count / width.
452                if nord_changed {
453                    instance.notify_field_with_origin(
454                        "NORD",
455                        crate::server::recgbl::EventMask::VALUE
456                            | crate::server::recgbl::EventMask::LOG,
457                        origin,
458                    );
459                }
460            }
461
462            // same SPC_AS parity as `put_pv` / `put_pv_no_process`
463            // / the CA-write path — a gateway mirroring `.ASG` via
464            // `put_pv_and_post` must still trigger per-client
465            // re-eval.
466            if field == "ASG" {
467                crate::server::access_security::notify_asg_field_changed();
468            }
469
470            return Ok(());
471        }
472
473        Err(CaError::ChannelNotFound(name.to_string()))
474    }
475
476    /// CA client's unified entry point for record field put.
477    /// Handles DISP/PROC/PACT/LCNT checks, field put, device write, and Passive process.
478    ///
479    /// Acquires the record's advisory write gate
480    /// (`dbScanLock` analogue) for the duration of the write.
481    pub async fn put_record_field_from_ca(
482        &self,
483        record_name: &str,
484        field: &str,
485        value: EpicsValue,
486    ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
487        self.put_record_field_from_ca_inner(record_name, field, value, true, true)
488            .await
489    }
490
491    /// Variant for a caller that already owns the target
492    /// record's advisory write gate — the QSRV atomic group PUT,
493    /// which acquired every member-record gate up-front via
494    /// [`Self::lock_records`]. The per-record `tokio::sync::Mutex`
495    /// gate is NOT reentrant, so the atomic group path MUST use this
496    /// `_already_locked` entry to avoid dead-locking on its own
497    /// `ManyRecordWriteGuard`.
498    pub async fn put_record_field_from_ca_already_locked(
499        &self,
500        record_name: &str,
501        field: &str,
502        value: EpicsValue,
503    ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
504        self.put_record_field_from_ca_inner(record_name, field, value, false, true)
505            .await
506    }
507
508    /// Fire-and-forget variant — C `dbPutField` semantics: the put
509    /// processes the record but creates NO put-notify wait-set (C
510    /// builds a `putNotify` only in `dbPutNotify`, i.e. for
511    /// WRITE_NOTIFY). A caller that does not await the returned
512    /// receiver MUST use this entry: parking a wait-set whose receiver
513    /// is dropped occupies `RecordInstance::notify` until the record's
514    /// async work ends (a motor's whole motion), failing every
515    /// legitimate WRITE_NOTIFY on the record with ECA_PUTCBINPROG in
516    /// the meantime.
517    pub async fn put_record_field_from_ca_no_notify(
518        &self,
519        record_name: &str,
520        field: &str,
521        value: EpicsValue,
522    ) -> CaResult<()> {
523        self.put_record_field_from_ca_inner(record_name, field, value, true, false)
524            .await
525            .map(|_| ())
526    }
527
528    /// Fire-and-forget + caller-held gate: see
529    /// [`Self::put_record_field_from_ca_no_notify`] and
530    /// [`Self::put_record_field_from_ca_already_locked`].
531    pub async fn put_record_field_from_ca_no_notify_already_locked(
532        &self,
533        record_name: &str,
534        field: &str,
535        value: EpicsValue,
536    ) -> CaResult<()> {
537        self.put_record_field_from_ca_inner(record_name, field, value, false, false)
538            .await
539            .map(|_| ())
540    }
541
542    async fn put_record_field_from_ca_inner(
543        &self,
544        record_name: &str,
545        field: &str,
546        value: EpicsValue,
547        acquire_gate: bool,
548        want_notify: bool,
549    ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
550        let field = field.to_ascii_uppercase();
551
552        // Get record Arc — alias-aware (epics-base PR #336) so a CA
553        // client that connects via an alias name can put fields on
554        // the canonical record.
555        let rec = self
556            .get_record(record_name)
557            .await
558            .ok_or_else(|| CaError::ChannelNotFound(record_name.to_string()))?;
559        // Normalise to the canonical name for the rest of this
560        // function — every subsequent call (PACT/LCNT lookup,
561        // `process_record_with_links`, `update_scan_index`) uses the
562        // raw records map and would miss when `record_name` is an
563        // alias. Resolve once up front.
564        let canonical_owned;
565        let record_name: &str = if let Some(target) = self.resolve_alias(record_name).await {
566            canonical_owned = target;
567            &canonical_owned
568        } else {
569            record_name
570        };
571
572        // take the record's advisory write gate — the
573        // `dbScanLock(precord)` analogue. While a QSRV atomic group
574        // PUT/GET holds this record's gate via `lock_records`, this
575        // plain write blocks here, so a direct backing-record write
576        // can no longer land between member writes of an atomic group
577        // transaction. Held until the function returns. Skipped when
578        // the caller (atomic group PUT) already owns the gate — the
579        // gate `Mutex` is not reentrant.
580        let _record_gate = if acquire_gate {
581            Some(self.lock_record(record_name).await)
582        } else {
583            None
584        };
585
586        // Special field intercepts (read lock, then drop)
587        {
588            let instance = rec.read().await;
589            match field.as_str() {
590                "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
591                "LCNT" => return Err(CaError::ReadOnlyField("LCNT".into())),
592                "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
593                _ => {}
594            }
595
596            // PROC intercept: trigger processing regardless of DISP.
597            // Falls through to the put_notify_tx registration below
598            // so async records (motor, asyn-backed AO) signal real
599            // completion; otherwise WRITE_NOTIFY would return ECA_NORMAL
600            // before the device move actually finished.
601            if field == "PROC" {
602                // C `dbPutField` (dbAccess.c:1265) matches the proc field by
603                // pointer with NO value check: any write to PROC — including
604                // 0 — processes the record (when !pact). The standard
605                // `caput REC.PROC 0` / `dbpf REC.PROC 0` force-process idiom
606                // must therefore not be skipped for a zero value.
607                drop(instance);
608                // Continue to the put-notify setup + process below
609                // by jumping past the field-write step (the value
610                // itself isn't stored; PROC is a trigger). A
611                // fire-and-forget caller parks nothing — C `dbPutField`
612                // on PROC processes the record with no putNotify.
613                let parked = if want_notify {
614                    let (completion_tx, completion_rx) = crate::runtime::sync::oneshot::channel();
615                    let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
616                    {
617                        let rec = self.inner.records.read().await;
618                        if let Some(rec_arc) = rec.get(record_name) {
619                            let mut guard = rec_arc.write().await;
620                            if guard.notify.is_some() {
621                                return Err(CaError::PutCallbackInProgress(
622                                    record_name.to_string(),
623                                ));
624                            }
625                            guard.notify = Some(notify.clone());
626                        }
627                    }
628                    Some((notify, completion_rx))
629                } else {
630                    None
631                };
632                let mut visited = HashSet::new();
633                // this PROC trigger already holds `record_name`'s
634                // advisory write gate — either `_record_gate` above, or
635                // the QSRV atomic group's `lock_records` epoch when
636                // entered via `put_record_field_from_ca_already_locked`.
637                // The gate `Mutex` is not reentrant, so the processing
638                // call MUST use the `_already_locked` variant.
639                let _ = self
640                    .process_record_with_links_already_locked(record_name, &mut visited, 0)
641                    .await;
642                // The wait-set fires the oneshot only after the whole
643                // FLNK/OUT chain (sync + async) settles. If it has
644                // already completed the chain was fully synchronous —
645                // report immediate success; otherwise hand the receiver
646                // to the CA layer to await the deferred completion.
647                return match parked {
648                    Some((notify, completion_rx)) => {
649                        if notify.completed() {
650                            Ok(None)
651                        } else {
652                            Ok(Some(completion_rx))
653                        }
654                    }
655                    None => Ok(None),
656                };
657            }
658
659            // DISP check: block CA puts to non-DISP fields when DISP=1
660            if instance.common.disp && field != "DISP" {
661                return Err(CaError::PutDisabled(field));
662            }
663        }
664
665        // Normal field put (write lock)
666        let common_result = {
667            let mut instance = rec.write().await;
668            instance.common.putf = true;
669
670            // Coerce value to the field's native DBR type (e.g. String → Double for ao.VAL).
671            // This matches C EPICS db_put_field() which converts from the CA client's type
672            // to the record field's native type.
673            let value = {
674                let target_type = instance
675                    .record
676                    .field_list()
677                    .iter()
678                    .find(|f| f.name.eq_ignore_ascii_case(&field))
679                    .map(|f| f.dbf_type);
680                if let Some(target) = target_type {
681                    if value.db_field_type() != target {
682                        // C EPICS dbPut (12cfd41): empty-array → scalar
683                        // coercion would produce silent zero; reject.
684                        if value.is_empty_array() {
685                            instance.common.putf = false;
686                            return Err(CaError::InvalidValue(format!(
687                                "empty array cannot be coerced to scalar field {field}"
688                            )));
689                        }
690                        coerce_write_value(&*instance.record, &field, target, value)
691                    } else {
692                        value
693                    }
694                } else {
695                    value
696                }
697            };
698
699            // SPC_NOMOD: reject writes to read-only fields (C EPICS S_db_noMod)
700            let is_read_only = instance
701                .record
702                .field_list()
703                .iter()
704                .find(|f| f.name.eq_ignore_ascii_case(&field))
705                .is_some_and(|f| f.read_only);
706            if is_read_only {
707                instance.common.putf = false;
708                return Err(CaError::ReadOnlyField(field));
709            }
710
711            // Pre-write special hook (C EPICS dbPutSpecial pass=0)
712            if let Err(e) = instance.record.special(&field, false) {
713                instance.common.putf = false;
714                return Err(e);
715            }
716
717            // Capture pre-put value for faac1df1 idempotent-write suppression.
718            let prev_value = instance.record.get_field(&field);
719
720            // Try record-specific field first; fall back to common on FieldNotFound.
721            // For record-owned fields, call on_put() and special() after successful put,
722            // matching what put_common_field() does for common fields.
723            use crate::server::record::CommonFieldPutResult;
724            // Snapshot alarm-ack state so the post block can replicate C
725            // putAckt/putAcks (dbAccess.c:1285-1315), which post only when
726            // `ackt`/`acks` actually change.
727            let ackt_before = instance.common.ackt;
728            let acks_before = instance.common.acks;
729            let common_result = match instance.record.put_field(&field, value.clone()) {
730                Ok(()) => {
731                    instance.record.on_put(&field);
732                    let _ = instance.record.special(&field, true);
733                    // C `dbAccess.c::dbPut:1410-1411` clears
734                    // `precord->udf = FALSE` synchronously when the
735                    // put target is the record-type's primary value
736                    // field (`dbIsValueField`). The clear happens
737                    // BEFORE `dbProcess` runs, so any reader between
738                    // the put and the process-cycle's own clear sees
739                    // the new value with a consistent UDF=false.
740                    //
741                    // Rust's processing path also clears UDF via
742                    // `clears_udf()` in process/complete_async_record,
743                    // but that runs AFTER the put lock drops and the
744                    // process re-acquires — leaving a small window
745                    // where another reader can observe (new VAL,
746                    // udf=true). For async records the window spans
747                    // the entire device round trip. Clear here to
748                    // close the window. The same clear already exists
749                    // in `put_pv_and_post` (line 256-262); mirror it.
750                    if field == instance.record.primary_field() {
751                        instance.common.udf = false;
752                        if instance.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM {
753                            instance.common.stat = 0;
754                            instance.common.sevr = crate::server::record::AlarmSeverity::NoAlarm;
755                        }
756                    }
757                    CommonFieldPutResult::NoChange
758                }
759                Err(CaError::FieldNotFound(_)) => instance.put_common_field(&field, value)?,
760                Err(e) => {
761                    instance.common.putf = false;
762                    return Err(e);
763                }
764            };
765
766            // Invalidate metadata cache only if the metadata-class
767            // field's value actually changed (faac1df1).
768            instance.notify_field_written_if_changed(&field, prev_value.as_ref());
769
770            // C `dbAccess.c::dbPutField:1276` sets `precord->putf = TRUE`
771            // immediately before calling `dbProcess`, and the flag stays
772            // TRUE through the entire process cycle. It is cleared only
773            // in `recGblFwdLink` (recGbl.c:302) after FLNK fires, OR in
774            // the disable-alarm bail (dbAccess.c:576). The Rust port
775            // previously cleared `putf` here — BEFORE the
776            // `process_record_with_links` call below — so any code
777            // path (TPRO trace, async-completion logic, monitor on
778            // .PUTF) observing the bit during the process cycle saw
779            // `putf=0` and could not distinguish put-driven vs
780            // scan-driven processing.
781            //
782            // DO NOT clear `putf` here. The clearing now happens after
783            // the process call returns (synchronous completion) or in
784            // `complete_async_record` (async completion).
785
786            instance.cleanup_subscribers();
787            // C `dbPut:1408-1414` posts DBE_VALUE|DBE_LOG for the put field
788            // unless `(isValueField && pfldDes->process_passive)` — the
789            // immediate post is suppressed for the value field ONLY when that
790            // field is `pp(TRUE)`, because then the reprocess cycle
791            // (`dbPutField:1265-1268`) re-posts it via the deadband snapshot.
792            // For a value field that is NOT `pp` (calc/calcout/aSub VAL), C
793            // posts here and does not reprocess; the port must do the same,
794            // because the `should_process` gate below skips the cycle for a
795            // non-`pp` value field — without this post a direct VAL put would
796            // fire no monitor at all.
797            if field.eq_ignore_ascii_case("ACKT") || field.eq_ignore_ascii_case("ACKS") {
798                // Alarm-acknowledge fields are C `dbPut`'s DBR_PUT_ACKT/ACKS
799                // special handlers (`dbAccess.c:1285-1315`), NOT a plain
800                // common-field put. They post with DBE_VALUE|DBE_ALARM (never
801                // DBE_LOG), and post a record-wide DBE_ALARM
802                // (`db_post_events(precord, NULL, DBE_ALARM)`) so an
803                // alarm-mask monitor on ANY field observes the ack — but only
804                // when the ack state actually changed, so the generic
805                // DBE_VALUE|DBE_LOG post below is fully suppressed here.
806                use crate::server::recgbl::EventMask;
807                let ack_mask = EventMask::VALUE | EventMask::ALARM;
808                if field.eq_ignore_ascii_case("ACKT") {
809                    // putAckt: post only on a real ackt change; re-post ACKS
810                    // when turning ACKT off lowered it (C:1294-1297).
811                    if instance.common.ackt != ackt_before {
812                        instance.notify_field(&field, ack_mask);
813                        if instance.common.acks != acks_before {
814                            instance.notify_field("ACKS", ack_mask);
815                        }
816                        instance.notify_record_alarm();
817                    }
818                } else {
819                    // putAcks: post only when the write actually cleared ACKS
820                    // (C:1309-1313); a too-low ack severity posts nothing.
821                    if instance.common.acks != acks_before {
822                        instance.notify_field(&field, ack_mask);
823                        instance.notify_record_alarm();
824                    }
825                }
826            } else {
827                let suppress_value_field_post = field == instance.record.primary_field()
828                    && match instance.record.process_passive_fields() {
829                        Some(pp) => pp.iter().any(|f| f.eq_ignore_ascii_case(&field)),
830                        // Un-modeled record types keep the legacy "process on
831                        // every put" behavior (`should_process = true` below),
832                        // so the reprocess cycle posts the value field —
833                        // suppress the immediate post here to avoid a
834                        // duplicate event.
835                        None => true,
836                    };
837                if !suppress_value_field_post {
838                    instance.notify_field(
839                        &field,
840                        crate::server::recgbl::EventMask::VALUE
841                            | crate::server::recgbl::EventMask::LOG,
842                    );
843                }
844
845                // Fields a `special()` changed as a side effect of this put
846                // (e.g. compress RES reset zeroing NUSE/VAL) get their monitors
847                // posted here, mirroring the explicit `db_post_events` a C
848                // `special()` makes — these fields are not pp(TRUE), so no
849                // process cycle would otherwise post them.
850                for sf in instance.record.monitor_side_effect_fields(&field) {
851                    instance.notify_field(
852                        sf,
853                        crate::server::recgbl::EventMask::VALUE
854                            | crate::server::recgbl::EventMask::LOG,
855                    );
856                }
857            }
858
859            common_result
860        };
861        // ASG-field change re-evaluation hook. C
862        // `asDbLib.c:107-110,144` `asSpcAsCallback` invokes
863        // `asChangeGroup` → `asAddMemberPvt` → `asComputePvt` for
864        // every `ASGCLIENT` on `dbPut record.ASG NEW_ASG`. Pre-fix
865        // Rust mutated `common.asg` directly with no notification,
866        // so the wire ACCESS_RIGHTS the client saw still reflected
867        // the OLD ASG until something else triggered re-eval. Now we
868        // fire a process-wide notifier that the CA server folds into
869        // its per-client `reeval_access_rights` path.
870        if field == "ASG" {
871            crate::server::access_security::notify_asg_field_changed();
872        }
873        // record lock released
874
875        // Update scan index if SCAN or PHAS changed
876        match common_result {
877            crate::server::record::CommonFieldPutResult::ScanChanged {
878                old_scan,
879                new_scan,
880                phas,
881            } => {
882                self.update_scan_index(record_name, old_scan, new_scan, phas, phas)
883                    .await;
884            }
885            crate::server::record::CommonFieldPutResult::PhasChanged {
886                scan: s,
887                old_phas,
888                new_phas,
889            } => {
890                self.update_scan_index(record_name, s, s, old_phas, new_phas)
891                    .await;
892            }
893            crate::server::record::CommonFieldPutResult::NoChange => {}
894        }
895
896        // C `dbAccess.c::dbPutField:1263-1268` re-processes the
897        // record on a put only when the put field is `pp(TRUE)` AND the
898        // record is Passive (`SCAN == 0`). (The `PROC` field has its own
899        // always-process intercept above, matching C's
900        // `pfield == &precord->proc`; alarm-ack fields like ACKT/ACKS are
901        // not `pp(TRUE)` so they fall out here, matching C's
902        // `dbrType < DBR_PUT_ACKT`.) Processing on every put would
903        // double-process scanned records and spuriously process puts to
904        // non-`pp` fields (extra FLNK / monitors / device writes /
905        // timestamps). A record type whose DBD pp-flags are not modeled
906        // returns `None` and keeps the legacy "process on every put"
907        // behavior so un-modeled types (other crates, tests) are unchanged.
908        let should_process = {
909            let instance = rec.read().await;
910            match instance.record.process_passive_fields() {
911                Some(pp) => {
912                    instance.common.scan == crate::server::record::ScanType::Passive
913                        && pp.iter().any(|f| f.eq_ignore_ascii_case(&field))
914                }
915                None => true,
916            }
917        };
918
919        if !should_process {
920            // No processing cycle. C never sets `putf` on this path, so
921            // clear the flag the field-put set at entry, and report
922            // immediate (synchronous) completion to a WRITE_NOTIFY caller.
923            let recs = self.inner.records.read().await;
924            if let Some(rec_arc) = recs.get(record_name) {
925                let mut guard = rec_arc.write().await;
926                if !guard.is_processing() {
927                    guard.common.putf = false;
928                }
929            }
930            return Ok(None);
931        }
932
933        // Set up the put-notify wait-set BEFORE processing. The wait-set
934        // fires `completion_tx` only after the originating record AND
935        // every FLNK/OUT chain target it triggers (sync or async) has
936        // completed — C `dbNotify.c` `processNotify`/`dbNotifyCompletion`.
937        // Refuse a second concurrent WRITE_NOTIFY on the same record:
938        // C EPICS returns S_db_Blocked / ECA_PUTCBINPROG, and silently
939        // overwriting the wait-set would drop the prior Sender, waking
940        // the prior caller's rx with RecvError that the CA dispatcher
941        // treats as success.
942        //
943        // A fire-and-forget put parks NOTHING — C builds a `putNotify`
944        // only in `dbPutNotify`; `dbPutField` processes the record with
945        // no notify state at all. It therefore neither conflicts with
946        // nor disturbs a WRITE_NOTIFY already parked on the record.
947        let parked = if want_notify {
948            let (completion_tx, completion_rx) = crate::runtime::sync::oneshot::channel();
949            let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
950            {
951                let rec = self.inner.records.read().await;
952                if let Some(rec_arc) = rec.get(record_name) {
953                    let mut guard = rec_arc.write().await;
954                    if guard.notify.is_some() {
955                        return Err(CaError::PutCallbackInProgress(record_name.to_string()));
956                    }
957                    guard.notify = Some(notify.clone());
958                }
959            }
960            Some((notify, completion_rx))
961        } else {
962            None
963        };
964
965        // When a CA put writes directly to VAL on an INPUT record whose
966        // VAL is the engineering value, the built-in `RVAL → VAL`
967        // `convert()` must be suppressed for the put-driven process —
968        // re-deriving VAL from a stale RVAL would clobber the value the
969        // operator just wrote (the soft ai preset-NaN case, processing.rs
970        // ~line 677). The framework expresses this by calling
971        // `set_device_did_compute(true)`.
972        //
973        // This MUST be gated on `soft_channel_skips_convert()`. Output
974        // records (mbbo/mbbo_direct/bo/ao) implement
975        // `set_device_did_compute` as "skip the VAL → RVAL output
976        // convert" — the OPPOSITE direction. C `mbboRecord.c::process`
977        // (line 217), `mbboDirectRecord.c::process` (line 198) and
978        // `boRecord.c::process` (line 207) call `convert()`
979        // unconditionally on every non-pact process; a CA VAL-put on an
980        // output record MUST recompute RVAL/ORAW. Suppressing it there
981        // left RVAL/ORAW/ORBV stale. Output records return the default
982        // `false` from `soft_channel_skips_convert()`, so this gate
983        // matches the identical gates in processing.rs (line 694) and
984        // record_instance.rs (line 1381).
985        if field == "VAL" {
986            let recs = self.inner.records.read().await;
987            if let Some(rec_arc) = recs.get(record_name) {
988                let mut guard = rec_arc.write().await;
989                if guard.record.soft_channel_skips_convert() {
990                    guard.record.set_device_did_compute(true);
991                }
992            }
993        }
994
995        // Process the record after field put.
996        {
997            let mut visited = HashSet::new();
998            // `record_name`'s advisory write gate is already
999            // held by this `put` (the `_record_gate` taken above, or
1000            // the QSRV atomic group's `lock_records` epoch via
1001            // `put_record_field_from_ca_already_locked`). The gate
1002            // `Mutex` is not reentrant — use the `_already_locked`
1003            // processing entry.
1004            let _ = self
1005                .process_record_with_links_already_locked(record_name, &mut visited, 0)
1006                .await;
1007        }
1008
1009        // Is the ORIGINATING record itself still async-pending? Its
1010        // wait-set membership is taken + `leave`d at its own completion
1011        // (sync-end, or later in `complete_async_record_inner`), so a
1012        // lingering `notify` on its instance means its device round-trip
1013        // is still in flight. This gates only the originating record's
1014        // PUTF clear — independent of whether downstream chain targets
1015        // are still pending.
1016        //
1017        // A fire-and-forget put parked nothing, and a `notify` it sees
1018        // on the instance belongs to some other caller's WRITE_NOTIFY —
1019        // not evidence about THIS put. Fall through to the guarded
1020        // clear; its `!is_processing()` gate already preserves PUTF
1021        // across an async-pending device round-trip.
1022        let originating_pending = want_notify && {
1023            let rec = self.inner.records.read().await;
1024            if let Some(rec_arc) = rec.get(record_name) {
1025                rec_arc.read().await.notify.is_some()
1026            } else {
1027                false
1028            }
1029        };
1030
1031        // C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` after
1032        // the forward-link dispatch — the marker only lives for the
1033        // duration of the put's processing cycle. For SYNCHRONOUS
1034        // completions (PACT was cleared by the time
1035        // `process_record_with_links` returns) clear it here. For
1036        // async-pending records, the clearing happens later in
1037        // `complete_async_record_inner` (which runs FLNK as part of
1038        // the completion path) so the PUTF marker survives the
1039        // device-write round trip.
1040        if !originating_pending {
1041            let rec = self.inner.records.read().await;
1042            if let Some(rec_arc) = rec.get(record_name) {
1043                let mut guard = rec_arc.write().await;
1044                if !guard.is_processing() {
1045                    guard.common.putf = false;
1046                }
1047            }
1048        }
1049
1050        // CA completion gates on the WHOLE chain, not just the
1051        // originating record: the put-notify must not report
1052        // done until every FLNK/OUT target it drove — including an async
1053        // FLNK target that the originating record's sync cycle merely
1054        // kicked off — has settled. `completed()` is true iff the
1055        // wait-set drained to zero during this call (fully synchronous
1056        // chain); otherwise the receiver fires later from the last
1057        // chain member's `leave`.
1058        match parked {
1059            Some((notify, completion_rx)) => {
1060                if notify.completed() {
1061                    Ok(None)
1062                } else {
1063                    Ok(Some(completion_rx))
1064                }
1065            }
1066            None => Ok(None),
1067        }
1068    }
1069
1070    /// Put a PV value without triggering process (for restore).
1071    pub async fn put_pv_no_process(&self, name: &str, value: EpicsValue) -> CaResult<()> {
1072        let (base, field) = super::parse_pv_name(name);
1073        let field = field.to_ascii_uppercase();
1074
1075        if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
1076            pv.set(value).await;
1077            return Ok(());
1078        }
1079
1080        // Records — alias-aware (epics-base PR #336).
1081        if let Some(rec) = self.get_record(base).await {
1082            // `put_pv_no_process` is a public record-write API
1083            // (autosave restore). It must take the advisory write gate
1084            // (`dbScanLock` analogue) so an autosave restore cannot
1085            // land between the member writes of a QSRV atomic group or
1086            // a pvalink atomic scan epoch holding `lock_records`.
1087            // `base` is alias-resolved so an alias and its target
1088            // share one gate. Held until return.
1089            let canonical_base: String = self
1090                .resolve_alias(base)
1091                .await
1092                .unwrap_or_else(|| base.to_string());
1093            let _record_gate = self.lock_record(&canonical_base).await;
1094
1095            let mut instance = rec.write().await;
1096            let prev_value = instance.record.get_field(&field);
1097            match instance.record.put_field(&field, value.clone()) {
1098                Ok(()) => {}
1099                Err(CaError::FieldNotFound(_)) => {
1100                    instance.put_common_field(&field, value)?;
1101                }
1102                Err(e) => return Err(e),
1103            }
1104            // Invalidate metadata cache only if the metadata-class
1105            // field actually changed (faac1df1).
1106            instance.notify_field_written_if_changed(&field, prev_value.as_ref());
1107            // same SPC_AS parity as `put_pv` / the CA-write
1108            // path — autosave-style restores writing `.ASG` at IOC
1109            // startup must still trigger per-client re-eval.
1110            if field == "ASG" {
1111                crate::server::access_security::notify_asg_field_changed();
1112            }
1113            return Ok(());
1114        }
1115
1116        Err(CaError::ChannelNotFound(name.to_string()))
1117    }
1118}
1119
1120/// Project a decoded `DBR_CTRL_*` / `DBR_GR_*` snapshot's metadata fields
1121/// (display / control limits, enum labels) into the shadow-PV
1122/// [`PvMetadata`](crate::server::pv::PvMetadata) the CA gateway installs.
1123/// A non-metadata (TIME/STS) snapshot carries `None` in all three, which
1124/// clears the shadow metadata — but the gateway only ever feeds this a
1125/// control-class snapshot, matching C `setPvData` replacing the attribute
1126/// gdd wholesale from the control get/event.
1127fn metadata_from_snapshot(snapshot: &Snapshot) -> crate::server::pv::PvMetadata {
1128    crate::server::pv::PvMetadata {
1129        display: snapshot.display.clone(),
1130        control: snapshot.control.clone(),
1131        enums: snapshot.enums.clone(),
1132    }
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::super::PvDatabase;
1138    use crate::types::EpicsValue;
1139
1140    /// Regression: prior to fixing B1, `put_pv_and_post` walked only
1141    /// `inner.records` and returned `ChannelNotFound` for everything
1142    /// `add_pv`-registered. The CA gateway's monitor forwarder uses
1143    /// `add_pv` then expects `put_pv_and_post` to fan-out to
1144    /// downstream subscribers — without the simple-PV branch, every
1145    /// upstream event was silently dropped and the gateway delivered
1146    /// no monitors.
1147    #[tokio::test]
1148    async fn put_pv_and_post_handles_simple_pv() {
1149        let db = PvDatabase::new();
1150        db.add_pv("gw:test", EpicsValue::Double(0.0)).await.unwrap();
1151
1152        // Should NOT return ChannelNotFound.
1153        db.put_pv_and_post("gw:test", EpicsValue::Double(42.0))
1154            .await
1155            .expect("simple PV put_pv_and_post must succeed");
1156
1157        // Value actually landed.
1158        let pv = db.find_pv("gw:test").await.expect("PV exists");
1159        assert!(matches!(pv.get().await, EpicsValue::Double(v) if v == 42.0));
1160    }
1161
1162    /// Regression: `get_pv`, `put_pv`, `put_pv_and_post`,
1163    /// and `put_pv_no_process` all bypassed `get_record` and walked
1164    /// `self.inner.records` directly, so alias names from epics-base
1165    /// PR #336 silently returned `ChannelNotFound`. A later fix closed
1166    /// `get_record` but the same defect was hiding in field_io.rs.
1167    /// All four CA-server-and-bridge entry points must accept aliases.
1168    #[tokio::test]
1169    async fn field_io_entry_points_accept_aliases() {
1170        use crate::server::records::ai::AiRecord;
1171
1172        let db = PvDatabase::new();
1173        db.add_record("CANON", Box::new(AiRecord::new(0.0)))
1174            .await
1175            .unwrap();
1176        db.add_alias("ALT", "CANON").await.unwrap();
1177
1178        // get_pv via alias
1179        db.put_pv("CANON.VAL", EpicsValue::Double(1.5))
1180            .await
1181            .unwrap();
1182        let v = db.get_pv("ALT.VAL").await.unwrap();
1183        assert!(matches!(v, EpicsValue::Double(x) if x == 1.5));
1184
1185        // put_pv via alias
1186        db.put_pv("ALT.VAL", EpicsValue::Double(7.0)).await.unwrap();
1187        let v = db.get_pv("CANON.VAL").await.unwrap();
1188        assert!(matches!(v, EpicsValue::Double(x) if x == 7.0));
1189
1190        // put_pv_and_post via alias
1191        db.put_pv_and_post("ALT.VAL", EpicsValue::Double(11.0))
1192            .await
1193            .unwrap();
1194        let v = db.get_pv("CANON.VAL").await.unwrap();
1195        assert!(matches!(v, EpicsValue::Double(x) if x == 11.0));
1196
1197        // put_pv_no_process via alias
1198        db.put_pv_no_process("ALT.VAL", EpicsValue::Double(13.0))
1199            .await
1200            .unwrap();
1201        let v = db.get_pv("ALT.VAL").await.unwrap();
1202        assert!(matches!(v, EpicsValue::Double(x) if x == 13.0));
1203    }
1204
1205    /// A `DBR_STRING` menu label written to a `DBF_MENU` field resolves
1206    /// against THAT field's own menu (C `dbConvert` `putStringMenu`), not the
1207    /// field-blind global table that `EpicsValue::convert_to` would consult.
1208    /// Covers the `put_pv` (`put_pv_inner`) and `put_pv_and_post` coercion
1209    /// sites; the CA field-put path (`put_record_field_from_ca_inner`) shares
1210    /// the identical `coerce_write_value` helper.
1211    #[tokio::test]
1212    async fn write_path_menu_label_resolves_against_field_menu() {
1213        use crate::server::records::sel::SelRecord;
1214
1215        let db = PvDatabase::new();
1216        db.add_record("SEL", Box::new(SelRecord::default()))
1217            .await
1218            .unwrap();
1219
1220        // put_pv (put_pv_inner): "Specified" is selSELM index 0, NOT the
1221        // menuFanout index 1 the global table would have returned.
1222        db.put_pv("SEL.SELM", EpicsValue::String("Specified".into()))
1223            .await
1224            .unwrap();
1225        assert_eq!(db.get_pv("SEL.SELM").await.unwrap(), EpicsValue::Enum(0));
1226
1227        // put_pv_and_post: a later choice, proving the whole menu.
1228        db.put_pv_and_post("SEL.SELM", EpicsValue::String("High Signal".into()))
1229            .await
1230            .unwrap();
1231        assert_eq!(db.get_pv("SEL.SELM").await.unwrap(), EpicsValue::Enum(1));
1232
1233        // A bare numeric string still resolves (C epicsParseUInt16 fallback).
1234        db.put_pv("SEL.SELM", EpicsValue::String("2".into()))
1235            .await
1236            .unwrap();
1237        assert_eq!(db.get_pv("SEL.SELM").await.unwrap(), EpicsValue::Enum(2));
1238    }
1239
1240    /// `set_pv_metadata` installs the upstream `DBR_CTRL_*` metadata on a
1241    /// shadow simple PV WITHOUT posting any event (the CA gateway's
1242    /// connect-time seed). A later GET-class read must then see the
1243    /// installed limits/units, and a `DBE_PROPERTY` subscriber must NOT
1244    /// have received anything (nothing *changed* yet). An unknown / record
1245    /// name is rejected with `ChannelNotFound`.
1246    #[tokio::test]
1247    async fn set_pv_metadata_installs_without_posting() {
1248        use crate::error::CaError;
1249        use crate::server::snapshot::{DisplayInfo, Snapshot};
1250        use crate::types::DbFieldType;
1251        use std::time::SystemTime;
1252
1253        let db = PvDatabase::new();
1254        db.add_pv("gw:meta", EpicsValue::Double(0.0)).await.unwrap();
1255
1256        // A DBE_PROPERTY subscriber attached BEFORE the seed — it must stay
1257        // empty, because seeding metadata is not a property *change*.
1258        const DBE_PROPERTY: u16 = 8;
1259        let pv = db.find_pv("gw:meta").await.expect("PV exists");
1260        let mut prop_rx = pv
1261            .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
1262            .await
1263            .expect("subscriber added");
1264
1265        // Build a CTRL-class snapshot carrying display metadata.
1266        let mut ctrl = Snapshot::new(EpicsValue::Double(0.0), 0, 0, SystemTime::UNIX_EPOCH);
1267        ctrl.display = Some(DisplayInfo {
1268            units: "mm".into(),
1269            precision: 3,
1270            upper_disp_limit: 10.0,
1271            lower_disp_limit: -10.0,
1272            ..Default::default()
1273        });
1274
1275        db.set_pv_metadata("gw:meta", &ctrl)
1276            .await
1277            .expect("simple PV set_pv_metadata must succeed");
1278
1279        // The metadata landed on the shadow PV.
1280        let installed = pv.metadata();
1281        assert_eq!(
1282            installed.display.expect("display metadata installed").units,
1283            "mm"
1284        );
1285
1286        // No event was posted (seed != change).
1287        assert!(
1288            prop_rx.try_recv().is_err(),
1289            "set_pv_metadata must not post a DBE_PROPERTY event"
1290        );
1291
1292        // Unknown / non-simple PV is rejected.
1293        assert!(matches!(
1294            db.set_pv_metadata("no:such:pv", &ctrl).await,
1295            Err(CaError::ChannelNotFound(_))
1296        ));
1297    }
1298
1299    /// `post_pv_property` refreshes the shadow metadata AND posts a
1300    /// `DBE_PROPERTY` event carrying the supplied snapshot's metadata,
1301    /// upstream status/severity, and (undefined control-DBR) timestamp — to
1302    /// `DBE_PROPERTY` subscribers only. This is the DB-routing layer the
1303    /// gateway's property monitor drives on every upstream `DBE_PROPERTY`
1304    /// event. An unknown / record name is rejected with `ChannelNotFound`.
1305    #[tokio::test]
1306    async fn post_pv_property_refreshes_and_posts_property_event() {
1307        use crate::error::CaError;
1308        use crate::server::snapshot::{DisplayInfo, Snapshot};
1309        use crate::types::{DbFieldType, WallTime};
1310
1311        const DBE_PROPERTY: u16 = 8;
1312        const DBE_VALUE: u16 = 1;
1313        const MAJOR: u16 = 2;
1314        const HIGH: u16 = 3;
1315
1316        let db = PvDatabase::new();
1317        db.add_pv("gw:prop", EpicsValue::Double(0.0)).await.unwrap();
1318        let pv = db.find_pv("gw:prop").await.expect("PV exists");
1319
1320        let mut prop_rx = pv
1321            .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
1322            .await
1323            .expect("property subscriber added");
1324        let mut val_rx = pv
1325            .add_subscriber(2, DbFieldType::Double, DBE_VALUE)
1326            .await
1327            .expect("value subscriber added");
1328
1329        // Upstream CTRL event: metadata + MAJOR/HIGH alarm + a fixed past
1330        // timestamp that is unmistakably not a fresh wall clock.
1331        let upstream_ts = WallTime::from_unix(2_000_000, 0);
1332        let mut ctrl = Snapshot::new(EpicsValue::Double(5.0), HIGH, MAJOR, upstream_ts);
1333        ctrl.display = Some(DisplayInfo {
1334            units: "V".into(),
1335            precision: 1,
1336            ..Default::default()
1337        });
1338
1339        db.post_pv_property("gw:prop", ctrl)
1340            .await
1341            .expect("simple PV post_pv_property must succeed");
1342
1343        // The metadata was refreshed on the shadow PV.
1344        assert_eq!(
1345            pv.metadata().display.expect("metadata refreshed").units,
1346            "V"
1347        );
1348
1349        // The DBE_PROPERTY subscriber received the metadata-bearing event,
1350        // with the upstream alarm and timestamp preserved.
1351        let ev = prop_rx
1352            .try_recv()
1353            .expect("DBE_PROPERTY subscriber receives the property event");
1354        assert_eq!(
1355            ev.snapshot.display.expect("event carries metadata").units,
1356            "V"
1357        );
1358        assert_eq!(
1359            ev.snapshot.alarm.severity, MAJOR,
1360            "upstream severity preserved"
1361        );
1362        assert_eq!(ev.snapshot.alarm.status, HIGH, "upstream status preserved");
1363        assert_eq!(
1364            ev.snapshot.timestamp, upstream_ts,
1365            "control-DBR timestamp preserved, not a fresh wall clock"
1366        );
1367
1368        // The DBE_VALUE-only subscriber must NOT receive a property event.
1369        assert!(
1370            val_rx.try_recv().is_err(),
1371            "DBE_VALUE-only subscriber must not receive a property post"
1372        );
1373
1374        // Unknown / non-simple PV is rejected.
1375        let again = Snapshot::new(EpicsValue::Double(0.0), 0, 0, WallTime::UNIX_EPOCH);
1376        assert!(matches!(
1377            db.post_pv_property("no:such:pv", again).await,
1378            Err(CaError::ChannelNotFound(_))
1379        ));
1380    }
1381
1382    /// Regression: `put_record_field_from_ca` (the CA
1383    /// server's main put fast path) must accept aliases. Pre-fix it
1384    /// only consulted `inner.records` directly. Also exercises the
1385    /// canonical-name normalisation that protects subsequent
1386    /// `process_record_with_links` / `update_scan_index` calls.
1387    #[tokio::test]
1388    async fn put_record_field_from_ca_accepts_alias() {
1389        use crate::server::records::ai::AiRecord;
1390
1391        let db = PvDatabase::new();
1392        db.add_record("CANON", Box::new(AiRecord::new(0.0)))
1393            .await
1394            .unwrap();
1395        db.add_alias("ALT", "CANON").await.unwrap();
1396
1397        // Put VAL via the alias name.
1398        let _ = db
1399            .put_record_field_from_ca("ALT", "VAL", EpicsValue::Double(2.5))
1400            .await
1401            .expect("put via alias must succeed");
1402
1403        // Read back via canonical to confirm the value landed on the
1404        // right record.
1405        let v = db.get_pv("CANON.VAL").await.unwrap();
1406        assert!(matches!(v, EpicsValue::Double(x) if x == 2.5));
1407    }
1408
1409    /// Regression: a DBR_PUT_ACKT alarm-acknowledge put posts a record-wide
1410    /// DBE_ALARM (C `dbAccess.c:1299` putAckt
1411    /// `db_post_events(precord, NULL, DBE_ALARM)`), so an alarm-mask monitor
1412    /// on ANY field is notified — and a DBE_VALUE-only monitor is not.
1413    /// Pre-fix the ack field posted only itself with DBE_VALUE|DBE_LOG, so no
1414    /// alarm-mask subscriber observed the acknowledgement, and the post fired
1415    /// on every put regardless of whether `ackt` changed.
1416    #[tokio::test]
1417    async fn alarm_ack_put_posts_record_wide_dbe_alarm() {
1418        use crate::server::recgbl::EventMask;
1419        use crate::server::records::ai::AiRecord;
1420        use crate::types::DbFieldType;
1421
1422        let db = PvDatabase::new();
1423        db.add_record("A:REC", Box::new(AiRecord::new(1.0)))
1424            .await
1425            .unwrap();
1426        let rec = db.get_record("A:REC").await.expect("record exists");
1427
1428        let (mut alarm_rx, mut value_rx) = {
1429            let mut inst = rec.write().await;
1430            let a = inst
1431                .add_subscriber("VAL", 1, DbFieldType::Double, EventMask::ALARM.bits())
1432                .expect("alarm subscriber");
1433            let v = inst
1434                .add_subscriber("VAL", 2, DbFieldType::Double, EventMask::VALUE.bits())
1435                .expect("value subscriber");
1436            (a, v)
1437        };
1438
1439        // DBR_PUT_ACKT arrives as Short. ACKT defaults YES (true), so writing
1440        // 0 (disable transient acknowledgement) is a real change.
1441        db.put_record_field_from_ca_no_notify("A:REC", "ACKT", EpicsValue::Short(0))
1442            .await
1443            .expect("ackt put");
1444
1445        // The alarm-mask monitor on VAL receives the record-wide DBE_ALARM.
1446        assert!(
1447            alarm_rx.try_recv().is_ok(),
1448            "DBE_ALARM subscriber must receive the record-wide alarm post"
1449        );
1450        // The DBE_VALUE-only monitor on VAL must NOT: VAL's value is unchanged.
1451        assert!(
1452            value_rx.try_recv().is_err(),
1453            "DBE_VALUE-only subscriber must not receive the alarm post"
1454        );
1455
1456        // Re-putting the same ACKT value is a no-op: C putAckt returns early
1457        // on an unchanged ackt, so no further alarm post fires.
1458        db.put_record_field_from_ca_no_notify("A:REC", "ACKT", EpicsValue::Short(0))
1459            .await
1460            .expect("ackt re-put");
1461        assert!(
1462            alarm_rx.try_recv().is_err(),
1463            "unchanged ACKT must post nothing"
1464        );
1465    }
1466
1467    /// `post_property_fields` writes each field through the internal put and
1468    /// posts a `DBE_PROPERTY` monitor — the C
1469    /// `db_post_events(precord, &precord->val, DBE_PROPERTY)` that asyn's
1470    /// runtime enum re-propagation drives (devAsynInt32.c callbackEnum). A
1471    /// `DBE_VALUE`-only subscriber on the same field must NOT receive it:
1472    /// re-keying enum strings is a property change, not a value change.
1473    #[tokio::test]
1474    async fn post_property_fields_writes_and_posts_dbe_property_only() {
1475        use crate::server::recgbl::EventMask;
1476        use crate::server::records::mbbi::MbbiRecord;
1477        use crate::types::DbFieldType;
1478
1479        let db = PvDatabase::new();
1480        db.add_record("M:ENUM", Box::new(MbbiRecord::new(0)))
1481            .await
1482            .unwrap();
1483        let rec = db.get_record("M:ENUM").await.expect("record exists");
1484
1485        let (mut prop_rx, mut val_rx) = {
1486            let mut inst = rec.write().await;
1487            let p = inst
1488                .add_subscriber("ZRST", 1, DbFieldType::String, EventMask::PROPERTY.bits())
1489                .expect("property subscriber");
1490            let v = inst
1491                .add_subscriber("ZRST", 2, DbFieldType::String, EventMask::VALUE.bits())
1492                .expect("value subscriber");
1493            (p, v)
1494        };
1495
1496        let posted = db
1497            .post_property_fields(
1498                "M:ENUM",
1499                vec![("ZRST".to_string(), EpicsValue::String("LABEL".into()))],
1500            )
1501            .await
1502            .expect("post_property_fields succeeds");
1503        assert_eq!(posted, vec!["ZRST".to_string()]);
1504
1505        // The field landed on the record.
1506        assert_eq!(
1507            db.get_pv("M:ENUM.ZRST").await.unwrap(),
1508            EpicsValue::String("LABEL".into())
1509        );
1510
1511        // The DBE_PROPERTY subscriber received the event; the DBE_VALUE-only
1512        // subscriber did not (mask 0x08 vs 0x01, no intersection).
1513        assert!(
1514            prop_rx.try_recv().is_ok(),
1515            "DBE_PROPERTY subscriber must receive the property post"
1516        );
1517        assert!(
1518            val_rx.try_recv().is_err(),
1519            "DBE_VALUE-only subscriber must not receive a property post"
1520        );
1521    }
1522
1523    /// Regression: a direct CA put to a record whose value field VAL is NOT
1524    /// `pp(TRUE)` (calc / calcout / aSub) must still fire a DBE_VALUE monitor.
1525    /// C `dbAccess.c::dbPut:1408-1414` posts the value field immediately
1526    /// unless it is `pp(TRUE)`. The port previously suppressed the immediate
1527    /// post for every `VAL` and — with the `should_process` gate — skipped
1528    /// the reprocess cycle for a non-`pp` VAL, so the operator's write fired
1529    /// no monitor at all. calc's VAL is not in its `pp` field set, so the
1530    /// immediate post is the only event that can fire.
1531    #[tokio::test]
1532    async fn ca_put_to_non_pp_val_posts_monitor() {
1533        use crate::server::database::db_access::DbSubscription;
1534        use crate::server::records::calc::CalcRecord;
1535
1536        let db = PvDatabase::new();
1537        db.add_record("CALC1", Box::new(CalcRecord::new("0")))
1538            .await
1539            .unwrap();
1540
1541        let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
1542            .await
1543            .expect("subscribe to CALC1.VAL");
1544
1545        db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(5.0))
1546            .await
1547            .expect("CA put to CALC1.VAL must succeed");
1548
1549        let got = tokio::time::timeout(std::time::Duration::from_secs(1), sub.recv_f64())
1550            .await
1551            .expect("a DBE_VALUE monitor must fire for a direct VAL put to a non-pp record");
1552        assert_eq!(got, Some(5.0));
1553    }
1554
1555    /// Record-backed consumer half of the source-coalesced stale-tail rule.
1556    ///
1557    /// `DbSubscription::next_event` shares the `coalesce_consume` ordering
1558    /// rule with `PvSubscription`: a record-field monitor whose bounded
1559    /// queue overflows mid-burst must converge on the newest value and
1560    /// never step back to an older queued one. Each non-pp `VAL` put posts
1561    /// exactly one DBE_VALUE monitor with the put value and does NOT
1562    /// reprocess (see `ca_put_to_non_pp_val_posts_monitor`), so 80 distinct
1563    /// puts produce a strictly increasing 1..=80 stream; with no consumer
1564    /// draining, 1..=64 fill the queue and the newest (80) lands in the
1565    /// coalesce slot.
1566    ///
1567    /// Before the fix `next_event` returned the coalesced `80` and then
1568    /// replayed the stale tail `1..=64` (`80, 1, 2, ...`) — value time
1569    /// going backwards.
1570    #[tokio::test]
1571    async fn r0604_db_overflow_never_delivers_newest_then_old() {
1572        use crate::server::database::db_access::DbSubscription;
1573        use crate::server::records::calc::CalcRecord;
1574
1575        let db = PvDatabase::new();
1576        db.add_record("CALC1", Box::new(CalcRecord::new("0")))
1577            .await
1578            .unwrap();
1579        let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
1580            .await
1581            .expect("subscribe to CALC1.VAL");
1582
1583        for i in 1..=80u32 {
1584            db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(i as f64))
1585                .await
1586                .expect("CA put to CALC1.VAL must succeed");
1587        }
1588
1589        // Drain every immediately-available delivery; the recv past the
1590        // last event has nothing queued and times out, ending collection.
1591        let mut seq = Vec::new();
1592        while let Ok(Some(v)) =
1593            tokio::time::timeout(std::time::Duration::from_millis(200), sub.recv_f64()).await
1594        {
1595            seq.push(v);
1596        }
1597        assert!(!seq.is_empty(), "consumer must observe at least one value");
1598        for w in seq.windows(2) {
1599            assert!(
1600                w[0] <= w[1],
1601                "record monitor delivery stepped backward {} -> {} (sequence {seq:?})",
1602                w[0],
1603                w[1],
1604            );
1605        }
1606        assert_eq!(
1607            *seq.last().unwrap(),
1608            80.0,
1609            "record consumer must converge on the newest produced value (sequence {seq:?})"
1610        );
1611    }
1612}