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