Skip to main content

epics_base_rs/server/
pv.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
3
4use crate::runtime::sync::PriorityInheritanceMutex;
5
6use crate::error::CaError;
7use crate::server::event_queue::{EventReader, EventSink, EventUser, PostOutcome, TryRecvError};
8use crate::server::snapshot::{ControlInfo, DisplayInfo, EnumInfo, PropertySupport, Snapshot};
9use crate::types::{DbFieldType, EpicsValue, WallTime};
10
11/// Per-PV subscriber cap. Default 1024 — comfortably above
12/// any realistic dashboard fan-out, small enough to bound the
13/// per-PV `Vec<Subscriber>` under abuse. Override via
14/// `EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`.
15pub(crate) fn max_subscribers_per_pv() -> usize {
16    crate::runtime::env::get("EPICS_CAS_MAX_SUBSCRIBERS_PER_PV")
17        .and_then(|s| s.parse::<usize>().ok())
18        .unwrap_or(1024)
19        .max(8)
20}
21
22/// Process-global counter of monitor events the subscriber never observed
23/// because a later post replaced them in the event queue — C `evSubscrip
24/// ::nreplace` (`dbEvent.c:821`), summed over every monitor. Covers both
25/// `ProcessVariable` and `RecordInstance` posts, because both reach the queue
26/// through the single [`EventSink::post`] owner. Mirrors the pattern of
27/// `dropped_monitors` on the client side (subscribe_with_deadband).
28///
29/// read via [`dropped_monitor_events`]. That reader is not yet
30/// wired to a live scrape surface — the `/queues` admin endpoint
31/// currently renders configured limits only, not this counter — so do
32/// not assume the value is observable through an endpoint until that
33/// wiring lands.
34static DROPPED_MONITOR_EVENTS: AtomicU64 = AtomicU64::new(0);
35
36/// Read the cumulative count of dropped monitor events. Intended for
37/// introspection / metrics; see `DROPPED_MONITOR_EVENTS` for the
38/// current wiring status.
39pub fn dropped_monitor_events() -> u64 {
40    DROPPED_MONITOR_EVENTS.load(Ordering::Relaxed)
41}
42
43/// Identity of the client driving a `WriteHook` invocation. Carries
44/// the user/host/peer fields the CA TCP handler already tracks for
45/// audit + access security, so a proxy hook (gateway, ACL filter,
46/// putlog) can make decisions without re-deriving them.
47#[derive(Debug, Clone, Default)]
48pub struct WriteContext {
49    /// CA `CLIENT_NAME` username, or empty if unknown.
50    pub user: String,
51    /// CA `HOST_NAME` hostname (or peer IP fallback), used for ACF
52    /// matching against `HAG(...)` groups.
53    pub host: String,
54    /// Raw `peer.ip():peer.port()` string, retained for audit/log use.
55    pub peer: String,
56}
57
58/// Async hook invoked by client-originated writes (CA `caput`, CA
59/// `WRITE_NOTIFY`) before the PV's local value is set. Used by the CA
60/// gateway and similar proxies to forward writes upstream instead of
61/// landing them in the local `ProcessVariable`.
62///
63/// The hook receives the proposed new value plus a [`WriteContext`]
64/// identifying the client, and must return either:
65/// * `Ok(())` — the write was accepted (e.g. forwarded to upstream).
66///   The caller does NOT update the local `value` field — the
67///   subsequent upstream-monitor event is expected to do that. This
68///   matches CA-gateway semantics where the cached value reflects
69///   reality after the round-trip.
70/// * `Err(CaError)` — the write was rejected. The caller surfaces
71///   the error to the CA client (`WRITE_NOTIFY` carries the ECA
72///   status). The hook itself decides whether to update local state
73///   on rejection.
74///
75/// The hook is consulted only on the client → server path. Internal
76/// callers (`ProcessVariable::set`, `put_pv_and_post`) bypass it so
77/// the upstream-monitor forwarder can update local state without
78/// recursing into itself.
79///
80/// ## Stale-local hazard
81///
82/// "Hook returns `Ok` → caller does NOT update local value" assumes
83/// the upstream will emit a monitor event reflecting the new value.
84/// EPICS records can violate that assumption: PP=NO fields,
85/// PUT-only fields (e.g. `.PROC`), and records configured to suppress
86/// monitor events on identical values. In those cases the shadow
87/// PV remains at its pre-put value indefinitely — caput appears to
88/// succeed but `caget` afterwards returns the old value.
89///
90/// Hook implementors who target such records SHOULD update the local
91/// `ProcessVariable` themselves on `Ok` — typically by invoking
92/// `pv.set(new_value).await` AFTER the upstream put-ack, accepting
93/// the cost of one local mutation per put. The base hook contract
94/// stays "do nothing on Ok" because most monitor-driven shadows
95/// (the CA gateway's primary use case) WILL receive a monitor event
96/// and updating locally would race with it.
97///
98/// ## Reentrancy
99///
100/// The TCP write path clones the hook `Arc` and releases the read
101/// guard BEFORE invoking it, so a hook that calls
102/// `pv.set_write_hook(...)` to swap itself does not deadlock. A hook
103/// that calls `pv.set(...)` reentrantly is allowed but defeats the
104/// "let the upstream-monitor update local state" contract — the
105/// reentrant `set` will be silently overwritten by the next
106/// upstream event.
107pub type WriteHook = Arc<
108    dyn Fn(
109            EpicsValue,
110            WriteContext,
111        )
112            -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), CaError>> + Send>>
113        + Send
114        + Sync,
115>;
116
117/// read/write access decision for a gateway shadow PV,
118/// evaluated for a specific downstream `(user, host)`. Mirrors the CA
119/// access-rights model the server reports to the client and gates
120/// reads on.
121#[derive(Debug, Clone, Copy)]
122pub struct AccessDecision {
123    /// Client may GET / MONITOR (`EVENT_ADD`) the PV.
124    pub read: bool,
125    /// Client may PUT (`WRITE` / `WRITE_NOTIFY`) the PV.
126    pub write: bool,
127}
128
129/// per-PV access hook installed by a proxy (the CA
130/// gateway) so the CA server routes a shadow PV's access-rights
131/// decision through the proxy's own ACF instead of the server's.
132/// Given the downstream client's `(user, host)`, it returns the
133/// [`AccessDecision`].
134///
135/// Symmetric to [`WriteHook`]: the gateway captures its single
136/// `ArcSwap<AccessConfig>` and the PV's `.pvlist` ASG/ASL in the
137/// closure, so `compute_access` reports access rights and gates reads
138/// with the same `can_read` / `can_write` the write hook uses — one
139/// ACF authority, no second copy to keep in sync. The hook is
140/// synchronous (it only reads an in-memory `ArcSwap`, no `.await`); the
141/// server consults it at `CREATE_CHAN` and on access-rights
142/// re-evaluation.
143pub type AccessHook = Arc<dyn Fn(&str, &str) -> AccessDecision + Send + Sync>;
144
145/// per-PV read hook consulted by the CA server's one-shot GET path
146/// (`CA_PROTO_READ` / `CA_PROTO_READ_NOTIFY`) when set. A bare PV serves
147/// reads straight from its stored value cell; a proxy (the CA gateway in
148/// its no-cache mode) installs this hook so each downstream GET is
149/// satisfied by a *fresh* upstream fetch instead of the last cached
150/// value. Mirrors C ca-gateway `-no_cache`, where a connected channel
151/// with caching disabled forwards every read as a fresh
152/// `ca_array_get_callback()` to the IOC (`gateVc.cc:1361-1369`) rather
153/// than returning `vc->eventData()`.
154///
155/// The hook returns a full [`Snapshot`], not a bare value: C `-no_cache`
156/// reads issue `ca_array_get_callback(eventType(), ...)` with `eventType()`
157/// a `DBR_TIME_*` class, and `getTimeCB` decodes the event's status,
158/// severity, and timestamp into `setEventData` before the GET completes
159/// (`gatePv.cc:976`, `:1789-1794`). The hook therefore owns producing the
160/// fresh value *together with* its upstream alarm/timestamp so the read
161/// path never synthesizes metadata by grafting a fresh value onto an
162/// unrelated cached snapshot. Property metadata (display/control/enum) is
163/// not carried by a `DBR_TIME_*` event in either C or here; the consumer
164/// overlays the shadow's last-known property metadata for those fields
165/// (a separate upstream path feeds them, as C splits value/time from the
166/// property monitor).
167///
168/// The hook is async (it performs an upstream get) and fallible: on
169/// `Err` the server surfaces the failure to the client (`ECA_GETFAIL`)
170/// exactly as the IOC's own get-callback error would propagate. Only the
171/// GET path consults it ([`ProcessVariable::read_snapshot`]); monitor
172/// fan-out, the initial monitor event, and access-rights re-posts keep
173/// serving the stored snapshot, so a no-cache PV still backs a downstream
174/// monitor with its upstream subscription's events.
175///
176/// `None` (the default) leaves the read path byte-for-byte unchanged for
177/// every record-backed and cached PV — the hook is purely additive.
178pub type ReadHook = Arc<
179    dyn Fn()
180            -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Snapshot, CaError>> + Send>>
181        + Send
182        + Sync,
183>;
184
185/// A monitor event sent to subscribers when a PV value changes.
186/// Carries a full Snapshot so GR/CTRL metadata (PREC, EGU, limits) is available.
187#[derive(Debug, Clone)]
188pub struct MonitorEvent {
189    /// The posted value, **shared** with every other subscriber this post
190    /// reached.
191    ///
192    /// C never copies a wide value into an event at all. `db_create_field_log`
193    /// stores anything wider than `union native_value` by reference
194    /// (`dbfl_type_ref`, `dtor == NULL`, so `dbfl_has_copy` is false), and the
195    /// value is read at DELIVERY: `read_reply` reserves the payload inside the
196    /// client's existing send buffer (`cas_copy_in_header`,
197    /// `camessage.c:516`) and `dbGet` converts straight from the record's live
198    /// field into it (`dbAccess.c:1020`, the `!dbfl_has_copy(pfl)` arm). One
199    /// array, N events pointing at it, zero retained copies.
200    ///
201    /// The port cannot read the record at delivery — the event outlives the
202    /// record lock — so it snapshots once at post time. `Arc` is what makes
203    /// that snapshot C's single array instead of one owned copy per
204    /// subscriber: `record_instance` built `make_monitor_snapshot` once and
205    /// then deep-cloned it per subscriber, so a 1 MiB waveform with four
206    /// monitors cost 4 MiB retained where C costs four ~100-byte field logs.
207    /// Measured consequence on `x86_64-wrs-vxworks`: `memory allocation of
208    /// 1048576 bytes failed` and `signal 6` at fan-out 4, while the same four
209    /// clients without array monitors survived at a HIGHER MEM_USED
210    /// (213,311,488 B vs the 211,804,160 B the aborting run died at) — the
211    /// fan-out was the discriminator, not the memory level.
212    pub snapshot: Arc<Snapshot>,
213    /// Origin writer ID. When non-zero, subscribers with the same
214    /// `ignore_origin` can filter out self-triggered events.
215    /// Used to prevent sequencer write-back loops.
216    ///
217    /// **Scope**: tagged explicitly by the `put_*_post` tier
218    /// (`put_pv_and_post_with_origin`, records and simple PVs alike), and
219    /// inherited by every post inside the `put_*_process` tier's
220    /// synchronous put+process cascade through the thread-local ambient
221    /// write origin (`AmbientWriteOriginScope`) — both record funnels
222    /// (`notify_field_with_origin`, `notify_from_snapshot`) and the
223    /// simple-PV funnel (`ProcessVariable::deliver`) apply the same
224    /// inheritance rule. Posts from work a cascade merely spawned (async
225    /// record completions, driver pollers) run outside any scope and stay
226    /// origin 0.
227    pub origin: u64,
228    /// The `DBE_*` event class(es) this post carries FOR THIS SUBSCRIBER
229    /// — the poster's mask intersected with the subscriber's `select`,
230    /// never the poster's mask alone. C stamps exactly that on the field
231    /// log (`pLog->mask = caEventMask & pevent->select`,
232    /// `dbEvent.c:896-900`) and pvxs narrows per event from
233    /// `pDbFieldLog->mask` (`groupsource.cpp:331-337`). The intersection
234    /// is produced by `Subscriber::delivered_mask`, which is also the
235    /// delivery gate, so the two cannot drift apart. Carrying it on the
236    /// event lets subscribers narrow what they decode (e.g. a QSRV group
237    /// monitor updating only alarm leaves on a `DBE_ALARM`-only event)
238    /// and lets the `.{dbnd}`/`.{sync}`/`.{dec}` pre-chain filters see the
239    /// classes the client actually asked for.
240    /// When events coalesce under a slow consumer, masks accumulate by
241    /// OR — the surviving snapshot is the newest, the mask reports every
242    /// class that changed since the last delivered event.
243    pub mask: crate::server::recgbl::EventMask,
244}
245
246/// A subscriber waiting for PV value updates — C `evSubscrip`'s producer-side
247/// view. Its pending events live in the shared event queue
248/// ([`crate::server::event_queue`]), reached only through `sink`.
249pub struct Subscriber {
250    pub sid: u32,
251    pub data_type: DbFieldType,
252    pub mask: u16,
253    /// Producer half of this monitor's slot in the circuit's event queue.
254    /// `pub(crate)` so no code outside this crate can enqueue past the
255    /// append-vs-replace rule the queue owns.
256    pub(crate) sink: EventSink,
257    /// Server-side channel filter chain (epics-base 3.15.7).
258    /// Defaults to empty — every event passes unchanged. Populated
259    /// by the subscription path when the channel name carries a
260    /// `.{filter:opts}` JSON suffix (`dbnd`, `arr`, `ts`, ...).
261    pub filters: crate::server::database::filters::FilterChain,
262    /// Delivery gate. `true` (the default) delivers events normally;
263    /// `false` suppresses every post to this subscriber at the source —
264    /// nothing reaches the event queue, no filter is evaluated — so a
265    /// paused monitor stops the record-event work entirely, not just the
266    /// downstream frame. Mirrors EPICS `db_event_disable` / pvxs
267    /// `onStart(false)` (singlesource.cpp:151-173, groupsource.cpp:151-281):
268    /// the subscription object survives, only its event flow is gated, so
269    /// the same subscriber resumes on re-enable. Flipped only under the
270    /// owner's write lock via [`super::record::record_instance::RecordInstance::set_subscriber_active`]
271    /// (records) — the post paths read it under the matching read lock.
272    pub active: bool,
273}
274
275impl Subscriber {
276    /// The mask this subscriber's field log carries for `post`, or `None`
277    /// when the post is not for it.
278    ///
279    /// C `db_post_events` evaluates `caEventMask & pevent->select` TWICE
280    /// (`dbEvent.c:896-900`): once as the delivery gate, and again as the
281    /// mask stamped on the log the pre-chain filters then see
282    /// (`pLog->mask = caEventMask & pevent->select`). Returning the
283    /// narrowed mask rather than a bool is what keeps the two inseparable
284    /// — a caller cannot learn that it may deliver without also learning
285    /// what mask to deliver under, so the wide poster mask can no longer
286    /// leak past the gate into `dbnd`/`sync`/`dec`.
287    ///
288    /// An all-zero intersection means no delivery, which is also C: an
289    /// empty `caEventMask` ands to zero and the poster loop skips the
290    /// subscriber entirely.
291    pub(crate) fn delivered_mask(
292        &self,
293        post: crate::server::recgbl::EventMask,
294    ) -> Option<crate::server::recgbl::EventMask> {
295        let narrowed = post & crate::server::recgbl::EventMask::from_bits(self.mask);
296        (!narrowed.is_empty()).then_some(narrowed)
297    }
298
299    /// The single post path for both event sources (`ProcessVariable` value /
300    /// alarm / property posts and `RecordInstance` field monitors): hand the
301    /// event to this monitor's event queue, which owns C's append-vs-replace
302    /// decision (`db_queue_event_log`), and apply the one piece of accounting
303    /// that lives outside the queue — the counter for a value that a later post
304    /// displaced before the consumer ever saw it (C `nreplace`, plus the
305    /// latest-only collapse that C leaves uncounted; both mean one value the
306    /// consumer will never see).
307    pub(crate) fn post(&self, event: MonitorEvent) {
308        if matches!(
309            self.sink.post(event),
310            PostOutcome::Replaced | PostOutcome::Collapsed
311        ) {
312            DROPPED_MONITOR_EVENTS.fetch_add(1, Ordering::Relaxed);
313        }
314    }
315
316    /// The consumer for this monitor is gone; the producer row can be reaped.
317    pub(crate) fn is_closed(&self) -> bool {
318        self.sink.is_closed()
319    }
320}
321
322/// Shadow `DBR_GR_*` / `DBR_CTRL_*` / enum metadata for a
323/// non-record-backed PV.
324///
325/// A bare [`ProcessVariable`] has no record engine to derive units /
326/// precision / display+alarm+control limits / enum labels from, so a
327/// proxy that fronts an upstream IOC (the CA / PVA gateway) fetches the
328/// upstream's control metadata and installs it here via
329/// [`ProcessVariable::set_metadata`]. Every snapshot the PV emits —
330/// the GET path ([`ProcessVariable::snapshot`]) and every monitor
331/// event ([`ProcessVariable::post_property`], value, alarm, and
332/// gateway snapshot posts) — then carries it, so a downstream client
333/// that requested a `DBR_GR_*` / `DBR_CTRL_*` type receives the
334/// upstream metadata instead of zeroed limits.
335///
336/// Mirrors the C ca-gateway, where `gatePvData` subscribes to
337/// `DBE_PROPERTY` and issues a control-type `ca_array_get_callback`
338/// (`gatePv.cc:850-934`) then copies units / precision / graphic +
339/// control limits into the gateway's gdd attributes
340/// (`gatePv.cc:1916-2007`).
341#[derive(Debug, Clone, Default)]
342pub struct PvMetadata {
343    pub display: Option<DisplayInfo>,
344    pub control: Option<ControlInfo>,
345    pub enums: Option<EnumInfo>,
346}
347
348/// Metadata of the most recent full-snapshot write to a bare PV:
349/// alarm + acquisition timestamp + userTag. A bare `ProcessVariable`
350/// has no alarm engine, so without this it would forget everything a
351/// full-value write carried beyond the raw value. pvxs mailbox
352/// `SharedPV::post()` assigns the *whole* posted value to the current
353/// value (`sharedpv.cpp:417-432`); to match that, a PV that received a
354/// full posted Value must reflect its alarm/time on every later GET,
355/// not just to the monitor subscribers that saw the post live.
356#[derive(Clone)]
357struct PostedMeta {
358    alarm: crate::server::snapshot::AlarmInfo,
359    timestamp: WallTime,
360    user_tag: i32,
361}
362
363/// A process variable hosted by the server.
364pub struct ProcessVariable {
365    pub name: String,
366    /// The stored value. A synchronous `parking_lot::RwLock` (matching the
367    /// sibling `posted_meta` / `metadata` / hook locks): every access is a
368    /// single-expression read-or-write with no `.await` held across the
369    /// guard, so the value-read path (`get`, `snapshot`) is pure lock work
370    /// with no reactor dependency — the sans-io READ path. The write side
371    /// (`set` / `set_snapshot`) still `.await`s the monitor fan-out, but
372    /// drops this guard first.
373    pub value: parking_lot::RwLock<EpicsValue>,
374    /// Monitor fan-out list — **L7** of `doc/rtems-priority-locks-design.md`
375    /// §3.
376    ///
377    /// A BLOCKING mutex, not the async one: every emission path runs from a
378    /// record-processing thread with the record's advisory gate (L1) held, and
379    /// C's `db_post_events` likewise takes `evUser->lock` from inside
380    /// `dbScanLock` (`dbEvent.c::db_post_events`). Holding an async mutex here
381    /// would put a suspension point inside that window. Every critical section
382    /// below is bounded list work (`retain` / `push` / `sub.post`), with no
383    /// I/O and no `.await` inside it.
384    ///
385    /// Specifically a [`PriorityInheritanceMutex`] rather than a plain
386    /// `parking_lot::Mutex`, because `evUser->lock` is an `epicsMutex` and on
387    /// the RTEMS arm every `epicsMutex` is a `PTHREAD_PRIO_INHERIT` pthread
388    /// mutex (`os/posix/osdMutex.c:71-88`, compiled for RTEMS via
389    /// `os/RTEMS-posix/osdMutex.c:8`). It is taken from banded IOC threads on
390    /// both sides — the emitting record-processing thread and a `CAS-client`
391    /// thread running `remove_subscriber` — so a plain mutex here reintroduces
392    /// the inversion L1 was converted to remove. Off the PI targets this is
393    /// `parking_lot::Mutex`, i.e. exactly what it was.
394    ///
395    /// A leaf of the acquisition order (`record_lock.rs` module doc): no other
396    /// lock is taken while it is held.
397    pub subscribers: PriorityInheritanceMutex<Vec<Subscriber>>,
398    /// Sticky metadata of the last full-snapshot write. `None` until a
399    /// [`Self::set_snapshot`] lands; a value-only [`Self::set`] clears it
400    /// back to `None` (a plain value write carries no explicit
401    /// alarm/time, so it reverts to NO_ALARM + wall-clock-now). When
402    /// `Some`, [`Self::snapshot`] serves these instead of the defaults.
403    /// Single meaning: the served snapshot reflects the most recent
404    /// write — value always current, metadata from that write.
405    posted_meta: parking_lot::RwLock<Option<PostedMeta>>,
406    /// Shadow DBR_GR_*/DBR_CTRL_*/enum metadata, installed by a proxy
407    /// (CA / PVA gateway) via [`Self::set_metadata`]. Empty for a plain
408    /// local PV. Stored under the same sync `parking_lot::RwLock` slot
409    /// rationale as the hooks: every snapshot builder reads it without
410    /// an `.await`. See [`PvMetadata`].
411    metadata: parking_lot::RwLock<PvMetadata>,
412    /// Optional hook consulted on client-originated writes. When set,
413    /// the CA TCP write path delegates to the hook instead of doing a
414    /// local `pv.set()`. See [`WriteHook`].
415    ///
416    /// Stored under `parking_lot::RwLock` (sync) rather than the
417    /// async `tokio::sync::RwLock` so the hot put-path can read it
418    /// without an `.await` round-trip — `write_hook()` is now a
419    /// constant-time clone of the optional `Arc`. The hook itself
420    /// is async (returns a `Future`); only the slot is sync.
421    write_hook: parking_lot::RwLock<Option<WriteHook>>,
422    /// optional access hook consulted by the CA server's
423    /// `compute_access` to decide a downstream client's read/write
424    /// rights for this PV. When set, it overrides the server's own ACF
425    /// for this PV — the gateway uses it to enforce `.pvlist` ASG-based
426    /// `can_read` / `can_write`, symmetric to [`Self::write_hook`].
427    /// Same sync `parking_lot::RwLock` slot rationale as `write_hook`.
428    access_hook: parking_lot::RwLock<Option<AccessHook>>,
429    /// optional read hook consulted by the CA server's one-shot GET path
430    /// ([`Self::read_snapshot`]) to fetch a fresh value instead of the
431    /// stored cell. Used by the CA gateway's no-cache mode to forward
432    /// each downstream GET to upstream. `None` (the default) keeps the
433    /// read path serving the stored value, identical to before. Same
434    /// sync slot rationale as [`Self::write_hook`]: the GET path clones
435    /// the optional `Arc` without an `.await`, then awaits the hook
436    /// outside any lock. See [`ReadHook`].
437    read_hook: parking_lot::RwLock<Option<ReadHook>>,
438}
439
440impl ProcessVariable {
441    pub fn new(name: String, initial: EpicsValue) -> Self {
442        Self {
443            name,
444            value: parking_lot::RwLock::new(initial),
445            subscribers: PriorityInheritanceMutex::new(Vec::new()),
446            metadata: parking_lot::RwLock::new(PvMetadata::default()),
447            posted_meta: parking_lot::RwLock::new(None),
448            write_hook: parking_lot::RwLock::new(None),
449            access_hook: parking_lot::RwLock::new(None),
450            read_hook: parking_lot::RwLock::new(None),
451        }
452    }
453
454    /// Install (or replace) the shadow DBR_GR_*/DBR_CTRL_*/enum
455    /// metadata served on this PV's snapshots. Used by the CA / PVA
456    /// gateway after fetching the upstream IOC's control metadata. See
457    /// [`PvMetadata`]. To publish the change to downstream property
458    /// monitors, follow with [`Self::post_property`].
459    pub fn set_metadata(&self, metadata: PvMetadata) {
460        *self.metadata.write() = metadata;
461    }
462
463    /// Snapshot (clone) of the installed shadow metadata; empty
464    /// (`Default`) for a plain local PV.
465    pub fn metadata(&self) -> PvMetadata {
466        self.metadata.read().clone()
467    }
468
469    /// Fill any metadata field the snapshot leaves `None` from the
470    /// installed shadow metadata. A field the caller already populated
471    /// (e.g. a gateway snapshot that carried its own metadata) wins —
472    /// this only supplies what is otherwise absent, so every emission
473    /// path serves the upstream metadata uniformly without clobbering a
474    /// richer source.
475    fn apply_metadata(&self, snap: &mut Snapshot) {
476        let meta = self.metadata.read();
477        if snap.display.is_none() {
478            snap.display = meta.display.clone();
479        }
480        if snap.control.is_none() {
481            snap.control = meta.control.clone();
482        }
483        if snap.enums.is_none() {
484            snap.enums = meta.enums.clone();
485        }
486        // A bare PV has no `rset`, so "which properties does this channel
487        // supply" is answered by what metadata it actually HAS: a proxy that
488        // shadowed an upstream IOC's display/control/enum info supplies those
489        // properties, a mailbox PV that nobody gave metadata to supplies none.
490        // Assigned here, in the one owner of a bare PV's metadata, so the mask
491        // and the values it describes cannot disagree.
492        // See [`crate::server::snapshot::PropertySupport`].
493        snap.properties = PropertySupport {
494            units: snap.display.is_some(),
495            precision: snap.display.is_some(),
496            graphic_double: snap.display.is_some(),
497            alarm_double: snap.display.is_some(),
498            control_double: snap.control.is_some(),
499            enum_strs: snap.enums.is_some(),
500        }
501        .narrowed_to_field(snap.value.db_field_type(), false);
502    }
503
504    /// Install an access hook. Replaces any previously
505    /// installed hook.
506    pub fn set_access_hook(&self, hook: AccessHook) {
507        *self.access_hook.write() = Some(hook);
508    }
509
510    /// Snapshot of the installed access hook (clone of the `Arc`), or
511    /// `None`. Consulted by the CA server's `compute_access`; cheap and
512    /// non-async, like [`Self::write_hook`].
513    pub fn access_hook(&self) -> Option<AccessHook> {
514        self.access_hook.read().clone()
515    }
516
517    /// Install a read hook. Replaces any previously-installed hook.
518    /// Used by the CA gateway's no-cache mode so each downstream GET is
519    /// served by a fresh upstream fetch. See [`ReadHook`].
520    pub fn set_read_hook(&self, hook: ReadHook) {
521        *self.read_hook.write() = Some(hook);
522    }
523
524    /// Snapshot of the installed read hook (clone of the `Arc`), or
525    /// `None`. Cheap and non-async, like [`Self::write_hook`]: the read
526    /// lock is released before the cloned `Arc` returns, so the caller's
527    /// subsequent `await` on the hook holds no lock.
528    pub fn read_hook(&self) -> Option<ReadHook> {
529        self.read_hook.read().clone()
530    }
531
532    /// Install a write hook. Replaces any previously-installed hook.
533    pub fn set_write_hook(&self, hook: WriteHook) {
534        *self.write_hook.write() = Some(hook);
535    }
536
537    /// Remove any installed write hook.
538    pub fn clear_write_hook(&self) {
539        *self.write_hook.write() = None;
540    }
541
542    /// Snapshot of the installed write hook (clone of the `Arc`), or
543    /// `None` if none. Used by the CA TCP write path; cheap and
544    /// non-async — the read lock is released before the cloned `Arc`
545    /// returns, so the caller's subsequent `await` on the hook does
546    /// not hold any lock.
547    pub fn write_hook(&self) -> Option<WriteHook> {
548        self.write_hook.read().clone()
549    }
550
551    /// Get the current value.
552    ///
553    /// Synchronous: a single-expression read-lock clone with no `.await`,
554    /// so the value-read path carries no reactor dependency (sans-io).
555    pub fn get(&self) -> EpicsValue {
556        self.value.read().clone()
557    }
558
559    /// Build a Snapshot for this bare PV.
560    ///
561    /// A `ProcessVariable` is a non-record-backed channel: it has no
562    /// alarm engine, no DESC/EGU/PREC metadata and no timestamp user
563    /// tag of its own. The snapshot is therefore value + `NO_ALARM` +
564    /// wall-clock now, with `user_tag` = 0. Display / control / enum
565    /// metadata is `None` *unless* a proxy installed it via
566    /// [`Self::set_metadata`] (the CA / PVA gateway shadowing an
567    /// upstream IOC) — see `Self::apply_metadata`. Record-backed
568    /// channels build their snapshot via
569    /// `RecordInstance::snapshot_for_field`, which carries the record's
570    /// own alarm/metadata. The only path that injects a non-zero alarm
571    /// onto a bare PV is [`Self::post_alarm`] (used by the gateway
572    /// adapter to surface upstream disconnect).
573    pub fn snapshot(&self) -> Snapshot {
574        let value = self.value.read().clone();
575        // Serve the sticky metadata of the last full-snapshot write if
576        // one landed (pvxs mailbox parity: a posted full Value stays the
577        // current value, alarm/time included); otherwise the bare-PV
578        // default of NO_ALARM + wall-clock-now.
579        let mut snap = match self.posted_meta.read().clone() {
580            Some(m) => {
581                let mut s = Snapshot::new(value, m.alarm.status, m.alarm.severity, m.timestamp);
582                s.alarm.ackt = m.alarm.ackt;
583                s.alarm.acks = m.alarm.acks;
584                s.user_tag = m.user_tag;
585                s
586            }
587            None => Snapshot::new(value, 0, 0, crate::runtime::time::now_wall()),
588        };
589        self.apply_metadata(&mut snap);
590        snap
591    }
592
593    /// Build the snapshot served on a one-shot client GET
594    /// (`CA_PROTO_READ` / `CA_PROTO_READ_NOTIFY`).
595    ///
596    /// When a [`ReadHook`] is installed (the CA gateway's no-cache mode),
597    /// the snapshot is fetched fresh through the hook — value *and* its
598    /// upstream alarm status/severity and IOC timestamp together — and the
599    /// shadow's last-known property metadata (display/control/enum) is
600    /// overlaid for the fields a `DBR_TIME_*` event does not carry; on hook
601    /// error the failure propagates so the server can answer `ECA_GETFAIL`,
602    /// matching C ca-gateway forwarding each read to the IOC under
603    /// `-no_cache` (`gateVc.cc:1361-1369`, `gatePv.cc:976`/`:1789-1794`).
604    /// Without a hook this is exactly [`Self::snapshot`] wrapped in `Ok`,
605    /// so the GET path is unchanged for every record-backed and cached PV.
606    ///
607    /// Only the GET path calls this; monitor fan-out, the initial monitor
608    /// event, and access-rights re-posts keep using [`Self::snapshot`]
609    /// (the stored value), so a no-cache PV still backs a downstream
610    /// monitor with its upstream subscription's events rather than a
611    /// per-event upstream get.
612    pub async fn read_snapshot(&self) -> Result<Snapshot, CaError> {
613        match self.read_hook() {
614            Some(hook) => {
615                // The hook issues a metadata-bearing upstream GET
616                // (`DbrClass::Time`), so the returned snapshot already
617                // carries the fresh value WITH its upstream alarm
618                // status/severity and IOC timestamp — mirroring C
619                // `getTimeCB` decoding the `DBR_TIME_*` event before
620                // `setEventData`. A `DBR_TIME_*` event does not carry
621                // display/control/enum metadata, so overlay the shadow's
622                // last-known property metadata for those absent fields only
623                // (a separate upstream path feeds it, exactly as C splits
624                // the value/time path from the property monitor). Never
625                // graft the fresh value onto the stored snapshot's
626                // alarm/time, which may be stale or the bare-PV default.
627                let mut snap = hook().await?;
628                self.apply_metadata(&mut snap);
629                Ok(snap)
630            }
631            None => Ok(self.snapshot()),
632        }
633    }
634
635    /// Synchronous companion to [`Self::read_snapshot`] for the one-shot GET
636    /// path (`CA_PROTO_READ` / `CA_PROTO_READ_NOTIFY`).
637    ///
638    /// `Some(snapshot)` when NO read hook is installed — the sans-io GET that
639    /// every record-backed and cached PV takes: [`Self::snapshot`] of the
640    /// stored value, produced with no `.await` and no reactor dependency.
641    /// `None` when a gateway no-cache [`ReadHook`] IS installed, whose `hook()`
642    /// is a genuine upstream network GET; the caller must then take the async
643    /// [`Self::read_snapshot`] instead. This keeps the hook / no-hook decision
644    /// in one owner, in lockstep with `read_snapshot` — the only difference is
645    /// that the async fallible upstream fetch is surfaced to the caller as
646    /// `None` rather than performed here.
647    pub fn read_snapshot_local(&self) -> Option<Snapshot> {
648        match self.read_hook() {
649            Some(_) => None,
650            None => Some(self.snapshot()),
651        }
652    }
653
654    /// Set a new value and notify all subscribers.
655    pub fn set(&self, new_value: EpicsValue) {
656        self.set_with_origin(new_value, 0);
657    }
658
659    /// [`Self::set`] tagged with the writer's origin: the value post carries
660    /// `origin` so an origin-aware consumer can recognise (and skip) the
661    /// writer's own event — the simple-PV side of the
662    /// `put_pv_and_post_with_origin` self-write contract. Origin 0 is the
663    /// untagged default (never filtered).
664    pub fn set_with_origin(&self, new_value: EpicsValue, origin: u64) {
665        {
666            let mut val = self.value.write();
667            *val = new_value.clone();
668        }
669        // A plain value write carries no explicit alarm/time — revert to
670        // the bare-PV default so a stale full-snapshot's metadata does
671        // not linger on a value the client never stamped.
672        *self.posted_meta.write() = None;
673        self.notify_subscribers(new_value, origin);
674    }
675
676    /// Set value from a full snapshot (value + alarm + timestamp) and notify
677    /// all subscribers. Used by the CA gateway forwarding task to propagate
678    /// the upstream alarm status/severity and IOC timestamp to downstream
679    /// monitors. Mirrors `gateVcData::setEventData` + `vcPostEvent` in the
680    /// C ca-gateway: the incoming `dbr_time_xxx` GDD carries all three fields.
681    pub fn set_snapshot(&self, snapshot: Snapshot) {
682        {
683            let mut val = self.value.write();
684            *val = snapshot.value.clone();
685        }
686        // Persist the posted alarm/time/userTag so a later GET reflects
687        // the full posted value, not just the live monitor fan-out.
688        *self.posted_meta.write() = Some(PostedMeta {
689            alarm: snapshot.alarm.clone(),
690            timestamp: snapshot.timestamp,
691            user_tag: snapshot.user_tag,
692        });
693        self.notify_subscribers_from_snapshot(snapshot);
694    }
695
696    /// Single delivery owner: emit `snapshot` to every live subscriber
697    /// whose `DBE_*` mask intersects `post`.
698    ///
699    /// Every emission path ([`Self::notify_subscribers`] value posts,
700    /// [`Self::post_alarm`], [`Self::notify_subscribers_from_snapshot`]
701    /// gateway posts, [`Self::post_property`]) routes through here so the
702    /// mask gate (`caEventMask & pevent->select`, `dbEvent.c:892-900`),
703    /// the per-subscriber channel-filter chain, and the slow-consumer
704    /// coalesce-overflow accounting are applied identically — one event
705    /// class differs per caller, nothing else. The snapshot is built once
706    /// by the caller (one timestamp per logical event) and SHARED with every
707    /// subscriber — C's one array behind N field logs. A per-subscription
708    /// filter that rewrites the value pays for its own copy, and only then
709    /// (`Arc::make_mut`), which is also C: the filter chain runs
710    /// per-subscription and a filter that changes the value makes its own
711    /// field log.
712    fn deliver(&self, post: crate::server::recgbl::EventMask, snapshot: Snapshot, origin: u64) {
713        use crate::server::database::filters::FilteredMonitorEvent;
714        let snapshot = Arc::new(snapshot);
715        // Same ambient-origin inheritance as the record funnels
716        // (`notify_field_with_origin` / `notify_from_snapshot`): a post
717        // carrying no origin of its own inherits the current thread's
718        // ambient write origin, so a simple PV written from inside an
719        // in-process writer's synchronous put cascade tags its event
720        // with the writer's origin too. 0 outside any scope.
721        let origin = if origin != 0 {
722            origin
723        } else {
724            crate::server::record::ambient_write_origin()
725        };
726        let mut subs = self.subscribers.lock();
727        // Remove subscribers whose consumer has been dropped.
728        subs.retain(|sub| !sub.is_closed());
729        for sub in subs.iter() {
730            // Paused subscribers (`db_event_disable`) receive nothing —
731            // skip before any work so a disabled monitor stops the event
732            // flow at the source.
733            if !sub.active {
734                continue;
735            }
736            // Gate and narrow in one step: `Subscriber::delivered_mask`
737            // owns C's twice-used `caEventMask & pevent->select`.
738            let Some(mask) = sub.delivered_mask(post) else {
739                continue;
740            };
741            let event = MonitorEvent {
742                snapshot: Arc::clone(&snapshot),
743                origin,
744                mask,
745            };
746            // The channel-filter chain may suppress this event (e.g.
747            // `dbnd` deadband not crossed); the event's mask tells value
748            // filters whether to pass through (446e0d4a).
749            let filtered = if sub.filters.is_empty() {
750                Some(event)
751            } else {
752                sub.filters
753                    .apply(FilteredMonitorEvent::new(event))
754                    .map(|fe| fe.event)
755            };
756            let Some(event) = filtered else {
757                continue;
758            };
759            // C `db_queue_event_log`: the queue appends, or replaces this
760            // monitor's last entry in place when it is in flow control or
761            // nearly full. Earlier distinct entries are never discarded.
762            sub.post(event);
763        }
764    }
765
766    /// Push a fresh monitor event holding the current value but with
767    /// the supplied alarm severity/status. Used by the PVA / CA
768    /// gateway adapter to surface upstream-disconnect to downstream
769    /// monitor subscribers without dropping the simple PV (which
770    /// would force every downstream client into ECA_DISCONN +
771    /// reconnect storms when the upstream is just briefly
772    /// unreachable). Mirrors gatePvData::death's "alarm-post"
773    /// alternative discussed in the C++ ca-gateway audit.
774    pub fn post_alarm(&self, severity: u16, status: u16) {
775        use crate::server::recgbl::EventMask;
776        let value = self.value.read().clone();
777        let mut snapshot = Snapshot::new(value, status, severity, crate::runtime::time::now_wall());
778        self.apply_metadata(&mut snapshot);
779        // ALARM|LOG so DBE_LOG (archiver) subscribers receive alarm events.
780        self.deliver(EventMask::ALARM | EventMask::LOG, snapshot, 0);
781    }
782
783    /// Post a `DBE_PROPERTY` monitor event carrying the decoded upstream
784    /// CTRL event `snapshot` — its value plus the upstream status /
785    /// severity and timestamp — overlaid with the installed shadow
786    /// metadata, so downstream property-change monitors re-read the units /
787    /// precision / limits / enum labels with the *upstream* alarm state.
788    ///
789    /// Used by the CA / PVA gateway when an upstream `DBE_PROPERTY` event
790    /// fires (metadata changed) after it has refreshed the shadow PV via
791    /// [`Self::set_metadata`]. The caller supplies the snapshot rather than
792    /// this method synthesising one: C ca-gateway decodes the upstream
793    /// `DBR_CTRL_*` callback and re-posts the value with `setStatSevr()`
794    /// status/severity preserved (`gatePv.cc:2413-2438`,
795    /// `runValueDataCB`), leaving the timestamp as the control DBR carries
796    /// none — it must NOT be replaced with a fresh `NO_ALARM` /
797    /// wall-clock-now snapshot just because metadata changed. Pass the
798    /// timestamp the upstream value carried (the control event has none of
799    /// its own); pass `status`/`severity` from the upstream CTRL payload.
800    /// Property events are a distinct class from value/alarm: only
801    /// `DBE_PROPERTY` subscribers receive them.
802    pub async fn post_property(&self, mut snapshot: Snapshot) {
803        use crate::server::recgbl::EventMask;
804        self.apply_metadata(&mut snapshot);
805        self.deliver(EventMask::PROPERTY, snapshot, 0);
806    }
807
808    /// Notify all subscribers of a new value, tagged with the writer's
809    /// `origin` (0 = untagged).
810    fn notify_subscribers(&self, value: EpicsValue, origin: u64) {
811        use crate::server::recgbl::EventMask;
812        let mut snapshot = Snapshot::new(value, 0, 0, crate::runtime::time::now_wall());
813        self.apply_metadata(&mut snapshot);
814        // VALUE|LOG so DBE_LOG (archiver) subscribers receive value events.
815        self.deliver(EventMask::VALUE | EventMask::LOG, snapshot, origin);
816    }
817
818    /// Notify all subscribers using a pre-built Snapshot (value + alarm +
819    /// timestamp). Used by `set_snapshot` to propagate the upstream alarm
820    /// and IOC timestamp without synthesising a new zero-alarm local-time
821    /// snapshot. Installed shadow metadata fills any metadata field the
822    /// gateway snapshot left absent (see [`Self::apply_metadata`]).
823    fn notify_subscribers_from_snapshot(&self, mut snapshot: Snapshot) {
824        use crate::server::recgbl::EventMask;
825        self.apply_metadata(&mut snapshot);
826        // C gateway fires postEvent(VALUE|ALARM|LOG) for every
827        // upstream event (gateVc.cc:374-376); match it so DBE_LOG
828        // archivers and DBE_ALARM-only monitors receive gateway snapshot posts.
829        self.deliver(
830            EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
831            snapshot,
832            0,
833        );
834    }
835
836    /// Add an in-process subscriber, attached to an event queue of its own.
837    ///
838    /// C `db_add_event(ctx, ...)` puts a monitor on the queue chain of the
839    /// `event_user` (client) that owns it. An in-process consumer is its own
840    /// client, so it gets its own [`EventUser`] — nothing else shares its
841    /// queue, and flow control (a CA circuit concept) never engages on it.
842    /// The CA server, whose subscriptions DO share one circuit-wide queue, uses
843    /// [`Self::add_subscriber_on`].
844    pub fn add_subscriber(
845        &self,
846        sid: u32,
847        data_type: DbFieldType,
848        mask: u16,
849    ) -> Option<EventReader> {
850        self.add_subscriber_on(&EventUser::new(), sid, data_type, mask)
851    }
852
853    /// Add a subscriber whose events are queued on `user`'s event queue —
854    /// C `db_add_event` with the circuit's `event_user` as context. Every
855    /// subscription on one CA circuit shares that queue, and therefore its
856    /// `nDuplicates`: a duplicate queued for one of them releases the
857    /// EVENTS_OFF drain for all of them (`dbEvent.c:947`).
858    ///
859    /// Returns `None` when the per-PV subscriber cap is reached (defends
860    /// against a misbehaving client opening many MONITOR ops against one shared
861    /// PV; the per-channel cap limits channels but not subscriber rows on a
862    /// single PV). Operators override it via `EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`.
863    pub fn add_subscriber_on(
864        &self,
865        user: &EventUser,
866        sid: u32,
867        data_type: DbFieldType,
868        mask: u16,
869    ) -> Option<EventReader> {
870        let cap = max_subscribers_per_pv();
871        let mut subs = self.subscribers.lock();
872        // Reap rows whose consumer is gone BEFORE counting
873        // against the cap. `notify_subscribers` / `post_alarm`
874        // already retain-filter on every emission, but a PV with
875        // no value changes (e.g. a static catalog entry that
876        // dashboards latch onto and drop) never triggered the
877        // reaper — a long-lived subscribe / disconnect storm could
878        // pin the Vec at `cap` worth of dead rows and lock
879        // out genuine new subscribers with a false-positive cap-
880        // reached warning. Same defect class as the
881        // NDPluginPva subscribe reaper (qsrv/pva_adapter.rs:247).
882        subs.retain(|s| !s.is_closed());
883        if subs.len() >= cap {
884            tracing::warn!(
885                pv = %self.name,
886                live = subs.len(),
887                cap,
888                "PV subscriber cap reached, refusing add_subscriber"
889            );
890            return None;
891        }
892        let (sink, reader) = crate::server::event_queue::attach(user, sid);
893        subs.push(Subscriber {
894            sid,
895            data_type,
896            mask,
897            sink,
898            filters: crate::server::database::filters::FilterChain::new(),
899            active: true,
900        });
901        Some(reader)
902    }
903
904    /// attach a channel-filter chain to an already-added
905    /// subscriber (looked up by `sid`). The CA server first
906    /// `add_subscriber`s, then attaches the chain parsed from the
907    /// channel's `.{...}` suffix — symmetric with the record-field
908    /// `RecordInstance::attach_filter_to_last_subscriber` path, so a
909    /// `SimplePv` monitor runs the SAME filter chain as a record-field
910    /// monitor instead of the empty default `FilterChain` that
911    /// `add_subscriber` installs. Update delivery
912    /// (`Self::notify_subscribers` / [`Self::post_alarm`]) already
913    /// applies `sub.filters`; this is the missing wiring that populates
914    /// it.
915    ///
916    /// The caller passes a FRESH chain per subscriber so stateful
917    /// filters (`dbnd` last-value, `dec` counter, `sync` state) stay
918    /// isolated across subscribers. An empty chain is a no-op (keeps the
919    /// default). No-op when no subscriber matches `sid` (e.g. it was
920    /// reaped between add and attach).
921    pub fn attach_filters_to_subscriber(
922        &self,
923        sid: u32,
924        filters: crate::server::database::filters::FilterChain,
925    ) {
926        if filters.is_empty() {
927            return;
928        }
929        let mut subs = self.subscribers.lock();
930        if let Some(sub) = subs.iter_mut().find(|s| s.sid == sid) {
931            sub.filters = filters;
932        }
933    }
934
935    /// Remove a subscriber by subscription ID.
936    pub fn remove_subscriber(&self, sid: u32) {
937        let mut subs = self.subscribers.lock();
938        subs.retain(|s| s.sid != sid);
939    }
940}
941
942/// Subscriber-id source for in-process [`PvSubscription`] monitors on a
943/// [`ProcessVariable`]. A `ProcessVariable`'s subscriber `Vec` is disjoint
944/// from any `RecordInstance`'s, so this is independent of the record-side
945/// allocator; it only has to stay unique among the simple-PV subscribers
946/// competing for one PV. Seeded at 1_000_000 for the same reason the
947/// record allocator is — keep in-process sids clear of the low,
948/// client-assigned wire subscription ids the CA server also registers on
949/// the same PV.
950static NEXT_PV_SUB_SID: AtomicU32 = AtomicU32::new(1_000_000);
951
952fn next_pv_sub_sid() -> u32 {
953    NEXT_PV_SUB_SID.fetch_add(1, Ordering::Relaxed)
954}
955
956/// In-process value-change monitor on a simple [`ProcessVariable`], the
957/// counterpart of the record-side `DbSubscription`.
958///
959/// The PUT path (`ProcessVariable::set` / `set_snapshot`) calls
960/// `notify_subscribers`, which fans the new value out to every registered
961/// subscriber, so a consumer holding a `PvSubscription` observes every
962/// later PUT — not just the connect-time snapshot. This mirrors pvxs
963/// `SharedPV::post()` delivering a cloned update to each stored subscriber
964/// (`sharedpv.cpp:417-440`).
965///
966/// The handle owns its `Subscriber` slot: `Drop` removes it, so a dropped
967/// consumer cannot leave a dead subscriber row in
968/// `ProcessVariable.subscribers` — the same leak `DbSubscription`'s `Drop`
969/// closes for records.
970pub struct PvSubscription {
971    reader: EventReader,
972    pv: Arc<ProcessVariable>,
973    sid: u32,
974}
975
976impl PvSubscription {
977    /// Register a value-change monitor on `pv`. Returns `None` when the
978    /// per-PV subscriber cap is reached. The caller emits the initial
979    /// snapshot itself (pvxs `SharedPV::attach` posts the current value
980    /// before storing the subscriber); registering the subscriber *before*
981    /// reading that snapshot is the miss-free ordering — a PUT racing the
982    /// two is then delivered through the stream rather than lost.
983    pub async fn subscribe(pv: Arc<ProcessVariable>) -> Option<Self> {
984        use crate::server::recgbl::EventMask;
985        // VALUE|LOG matches the record-side `DbSubscription` default so
986        // simple-PV and record-backed monitors gate identically; a
987        // pure-alarm `post_alarm` (ALARM|LOG) still intersects via LOG.
988        let mask = (EventMask::VALUE | EventMask::LOG).bits();
989        let sid = next_pv_sub_sid();
990        // `data_type` is nominal for snapshot consumers: `deliver` ships
991        // the full `Snapshot` and gates only on mask/filters, never on the
992        // stored type — `DbSubscription` likewise registers as `Double`.
993        let reader = pv.add_subscriber(sid, DbFieldType::Double, mask)?;
994        Some(Self { reader, pv, sid })
995    }
996
997    /// Await the next value change as a full `Snapshot`. A consumer that falls
998    /// behind sees the same thing a C monitor does: its earlier distinct queued
999    /// updates, and then — once the queue ran short of room — a tail entry
1000    /// carrying the latest value, because further posts replaced that entry in
1001    /// place rather than appending (`db_queue_event_log`, `dbEvent.c:812-820`).
1002    pub async fn recv_snapshot(&mut self) -> Option<Snapshot> {
1003        // Free when this reader holds the last reference to the shared
1004        // snapshot, which is the single-subscriber case; a copy only when
1005        // another subscriber still holds it.
1006        Some(Arc::unwrap_or_clone(self.reader.recv().await?.snapshot))
1007    }
1008
1009    /// Non-blocking [`Self::recv_snapshot`]. Delegates to
1010    /// [`EventReader::try_recv`] (`event_queue.rs:570`) — same queue, same
1011    /// EVENTS_OFF gate, no suspension.
1012    ///
1013    /// Lets a PVA monitor source that adapts this stream be polled from a
1014    /// blocking drain loop with no reactor present
1015    /// (`doc/rtems-runtime-portability-design.md` §9 phase 6).
1016    pub fn try_recv_snapshot(&mut self) -> Result<Snapshot, TryRecvError> {
1017        self.reader
1018            .try_recv()
1019            .map(|e| Arc::unwrap_or_clone(e.snapshot))
1020    }
1021
1022    /// Await the next change as the full [`MonitorEvent`] — snapshot plus the
1023    /// per-event `DBE_*` mask. The mask-carrying counterpart of
1024    /// [`recv_snapshot`](Self::recv_snapshot), matching
1025    /// `DbSubscription::recv_event` so a consumer can treat a simple-PV and a
1026    /// record subscription through one shape.
1027    pub async fn recv_event(&mut self) -> Option<MonitorEvent> {
1028        self.reader.recv().await
1029    }
1030
1031    /// Non-blocking [`Self::recv_event`].
1032    pub fn try_recv_event(&mut self) -> Result<MonitorEvent, TryRecvError> {
1033        self.reader.try_recv()
1034    }
1035}
1036
1037impl Drop for PvSubscription {
1038    fn drop(&mut self) {
1039        let pv = self.pv.clone();
1040        let sid = self.sid;
1041        // Mirror `DbSubscription::drop`: `remove_subscriber` needs an async
1042        // lock, so remove the slot off-thread. No current runtime means no
1043        // live subscription to clean up.
1044        if tokio::runtime::Handle::try_current().is_ok() {
1045            crate::runtime::task::spawn_background(async move {
1046                pv.remove_subscriber(sid);
1047            });
1048        }
1049    }
1050}
1051
1052#[cfg(test)]
1053mod mask_gate_tests {
1054    use super::*;
1055
1056    // CA DBE_* monitor mask bits (db_access.h).
1057    const DBE_VALUE: u16 = 1;
1058    const DBE_LOG: u16 = 2;
1059    const DBE_ALARM: u16 = 4;
1060
1061    fn pv() -> ProcessVariable {
1062        ProcessVariable::new("test:pv".into(), EpicsValue::Double(0.0))
1063    }
1064
1065    /// A full-snapshot write must persist alarm + timestamp + userTag so
1066    /// a later `snapshot()` (the GET path) reflects them — not just the
1067    /// live monitor fan-out. A subsequent value-only `set()` carries no
1068    /// explicit metadata and must revert the snapshot to NO_ALARM.
1069    #[epics_macros_rs::epics_test]
1070    async fn set_snapshot_metadata_persists_then_value_set_clears() {
1071        let pv = pv();
1072
1073        // 42 ns exact: a `SystemTime` rounds this to 0 on Windows, so the
1074        // round-trip is built from `WallTime` integers to actually exercise
1075        // sub-100 ns persistence through `PostedMeta`.
1076        let posted_time = WallTime::from_unix(1_600_000_000, 42);
1077        let mut snap = Snapshot::new(EpicsValue::Double(7.0), 3, 2, posted_time);
1078        snap.user_tag = 9;
1079        pv.set_snapshot(snap);
1080
1081        let got = pv.snapshot();
1082        assert_eq!(got.value, EpicsValue::Double(7.0), "value persisted");
1083        assert_eq!(got.alarm.status, 3, "alarm.status persisted to GET");
1084        assert_eq!(got.alarm.severity, 2, "alarm.severity persisted to GET");
1085        assert_eq!(got.user_tag, 9, "userTag persisted to GET");
1086        assert_eq!(got.timestamp, posted_time, "timestamp persisted to GET");
1087
1088        // A plain value write reverts to the bare-PV default.
1089        pv.set(EpicsValue::Double(8.0));
1090        let after = pv.snapshot();
1091        assert_eq!(after.value, EpicsValue::Double(8.0));
1092        assert_eq!(after.alarm.status, 0, "value set clears posted alarm");
1093        assert_eq!(after.alarm.severity, 0, "value set clears posted severity");
1094        assert_eq!(after.user_tag, 0, "value set clears posted userTag");
1095        assert_ne!(
1096            after.timestamp, posted_time,
1097            "value set must restamp the timestamp, not keep the posted one"
1098        );
1099    }
1100
1101    /// a `DBE_ALARM`-only subscriber must not receive a plain
1102    /// value set, but must receive an alarm post.
1103    #[epics_macros_rs::epics_test]
1104    async fn alarm_only_subscriber_skips_value_post() {
1105        let pv = pv();
1106        let mut rx = pv
1107            .add_subscriber(1, DbFieldType::Double, DBE_ALARM)
1108            .expect("subscriber added");
1109        pv.set(EpicsValue::Double(1.0));
1110        assert!(
1111            rx.try_recv().is_err(),
1112            "DBE_ALARM-only subscriber must not receive a value post"
1113        );
1114        pv.post_alarm(2, 3);
1115        assert!(
1116            rx.try_recv().is_ok(),
1117            "DBE_ALARM subscriber must receive an alarm post"
1118        );
1119    }
1120
1121    /// a `DBE_VALUE`-only subscriber must not receive a
1122    /// `post_alarm`, but must receive value sets.
1123    #[epics_macros_rs::epics_test]
1124    async fn value_only_subscriber_skips_alarm_post() {
1125        let pv = pv();
1126        let mut rx = pv
1127            .add_subscriber(1, DbFieldType::Double, DBE_VALUE)
1128            .expect("subscriber added");
1129        pv.post_alarm(2, 3);
1130        assert!(
1131            rx.try_recv().is_err(),
1132            "DBE_VALUE-only subscriber must not receive an alarm post"
1133        );
1134        pv.set(EpicsValue::Double(1.0));
1135        assert!(
1136            rx.try_recv().is_ok(),
1137            "DBE_VALUE subscriber must receive a value post"
1138        );
1139    }
1140
1141    /// C `db_post_events` stamps the field log with `caEventMask &
1142    /// pevent->select` (`dbEvent.c:896-900`), not with the poster's mask.
1143    /// `set_snapshot` posts `VALUE|LOG|ALARM` (the gateway-parity class set
1144    /// at `notify_subscribers_from_snapshot`), so a `DBE_VALUE`-only
1145    /// subscriber must see `DBE_VALUE` alone on the delivered event.
1146    #[epics_macros_rs::epics_test]
1147    async fn delivered_mask_is_narrowed_to_the_subscriber_select() {
1148        use crate::server::recgbl::EventMask;
1149        let pv = pv();
1150        let mut rx = pv
1151            .add_subscriber(1, DbFieldType::Double, DBE_VALUE)
1152            .expect("subscriber added");
1153        pv.set_snapshot(snapshot());
1154        let ev = rx.try_recv().expect("value-class post delivered");
1155        assert_eq!(
1156            ev.mask,
1157            EventMask::VALUE,
1158            "delivered mask must be post & select, not the poster's full class set"
1159        );
1160
1161        // The same narrowing on the other side: an ALARM-only subscriber
1162        // hears the same post as DBE_ALARM alone.
1163        let mut rx_alarm = pv
1164            .add_subscriber(2, DbFieldType::Double, DBE_ALARM)
1165            .expect("subscriber added");
1166        pv.set_snapshot(snapshot());
1167        let ev = rx_alarm.try_recv().expect("alarm-class post delivered");
1168        assert_eq!(ev.mask, EventMask::ALARM, "narrowed for an ALARM-only sub");
1169    }
1170
1171    /// The consequence the narrowing exists for: a `.{dbnd}` pre-chain
1172    /// filter passes an event unconditionally when the log mask carries a
1173    /// class other than `DBE_VALUE`/`DBE_LOG` (`dbnd.c:84`, `send =
1174    /// pfl->mask & ~(DBE_VALUE|DBE_LOG)`). Handing it the poster's
1175    /// `VALUE|LOG|ALARM` therefore let every sub-deadband update through on
1176    /// the `DBE_ALARM` bit the client never subscribed to, silently
1177    /// defeating the deadband.
1178    #[epics_macros_rs::epics_test]
1179    async fn dbnd_on_a_value_only_subscriber_is_not_bypassed_by_the_alarm_bit() {
1180        use crate::server::database::filters::parser::parse_filter_chain;
1181        let pv = pv();
1182        let mut rx = pv
1183            .add_subscriber(1, DbFieldType::Double, DBE_VALUE)
1184            .expect("subscriber added");
1185        pv.attach_filters_to_subscriber(1, parse_filter_chain(r#"{"dbnd":{"d":10}}"#));
1186
1187        // First event: `dbnd`'s baseline is NaN, so C's `delta > deadband`
1188        // is INF > 10 and it always passes.
1189        pv.set_snapshot(Snapshot::new(
1190            EpicsValue::Double(0.0),
1191            0,
1192            0,
1193            std::time::SystemTime::UNIX_EPOCH,
1194        ));
1195        assert!(
1196            rx.try_recv().is_ok(),
1197            "first event establishes the baseline"
1198        );
1199
1200        // Second event moves 0 -> 6 with a MINOR alarm: inside the band, so
1201        // C drops it. The alarm class is not in this subscriber's select and
1202        // must not reach the filter.
1203        pv.set_snapshot(Snapshot::new(
1204            EpicsValue::Double(6.0),
1205            7,
1206            1,
1207            std::time::SystemTime::UNIX_EPOCH,
1208        ));
1209        assert!(
1210            rx.try_recv().is_err(),
1211            "sub-deadband update must stay dropped; the poster's DBE_ALARM \
1212             bit is not part of a DBE_VALUE-only subscription"
1213        );
1214    }
1215
1216    // --- Regression: set_snapshot must reach DBE_LOG and DBE_ALARM-only subs ---
1217
1218    fn snapshot() -> Snapshot {
1219        Snapshot::new(
1220            EpicsValue::Double(2.0),
1221            0,
1222            0,
1223            std::time::SystemTime::UNIX_EPOCH,
1224        )
1225    }
1226
1227    /// A DBE_LOG (archiver) subscriber must receive a set_snapshot post.
1228    #[epics_macros_rs::epics_test]
1229    async fn log_subscriber_receives_snapshot_post() {
1230        let pv = pv();
1231        let mut rx = pv
1232            .add_subscriber(1, DbFieldType::Double, DBE_LOG)
1233            .expect("subscriber added");
1234        pv.set_snapshot(snapshot());
1235        assert!(
1236            rx.try_recv().is_ok(),
1237            "DBE_LOG subscriber must receive a set_snapshot post"
1238        );
1239    }
1240
1241    /// A DBE_ALARM-only subscriber must receive a set_snapshot post.
1242    #[epics_macros_rs::epics_test]
1243    async fn alarm_only_subscriber_receives_snapshot_post() {
1244        let pv = pv();
1245        let mut rx = pv
1246            .add_subscriber(1, DbFieldType::Double, DBE_ALARM)
1247            .expect("subscriber added");
1248        pv.set_snapshot(snapshot());
1249        assert!(
1250            rx.try_recv().is_ok(),
1251            "DBE_ALARM-only subscriber must receive a set_snapshot post"
1252        );
1253    }
1254
1255    /// A DBE_VALUE subscriber must still receive a set_snapshot post.
1256    #[epics_macros_rs::epics_test]
1257    async fn value_subscriber_receives_snapshot_post() {
1258        let pv = pv();
1259        let mut rx = pv
1260            .add_subscriber(1, DbFieldType::Double, DBE_VALUE)
1261            .expect("subscriber added");
1262        pv.set_snapshot(snapshot());
1263        assert!(
1264            rx.try_recv().is_ok(),
1265            "DBE_VALUE subscriber must receive a set_snapshot post"
1266        );
1267    }
1268
1269    /// A `DBE_VALUE | DBE_ALARM` subscriber receives both event classes.
1270    #[epics_macros_rs::epics_test]
1271    async fn both_classes_receive_both_posts() {
1272        let pv = pv();
1273        let mut rx = pv
1274            .add_subscriber(1, DbFieldType::Double, DBE_VALUE | DBE_ALARM)
1275            .expect("subscriber added");
1276        pv.set(EpicsValue::Double(1.0));
1277        assert!(rx.try_recv().is_ok(), "value post delivered to VALUE|ALARM");
1278        pv.post_alarm(2, 3);
1279        assert!(rx.try_recv().is_ok(), "alarm post delivered to VALUE|ALARM");
1280    }
1281
1282    /// A DBE_LOG-only subscriber (archiver) must receive both value
1283    /// events and alarm events.  Pre-fix: VALUE-only / ALARM-only post masks
1284    /// never intersected DBE_LOG(2), so archivers received silence.
1285    #[epics_macros_rs::epics_test]
1286    async fn br_r52_log_subscriber_receives_value_and_alarm_events() {
1287        const DBE_LOG: u16 = 2;
1288        let pv = pv();
1289        let mut rx = pv
1290            .add_subscriber(1, DbFieldType::Double, DBE_LOG)
1291            .expect("subscriber added");
1292        pv.set(EpicsValue::Double(1.0));
1293        assert!(
1294            rx.try_recv().is_ok(),
1295            "DBE_LOG subscriber must receive a value post"
1296        );
1297        pv.post_alarm(2, 3);
1298        assert!(
1299            rx.try_recv().is_ok(),
1300            "DBE_LOG subscriber must receive an alarm post"
1301        );
1302    }
1303
1304    /// Every delivered event carries its post's `DBE_*` class — the
1305    /// per-event mask C attaches to the field log (`db_field_log.mask`)
1306    /// and pvxs narrows monitor decoding with (`groupsource.cpp:331-337`).
1307    #[epics_macros_rs::epics_test]
1308    async fn monitor_event_carries_post_class_mask() {
1309        use crate::server::recgbl::EventMask;
1310        let pv = pv();
1311        let mut rx = pv
1312            .add_subscriber(1, DbFieldType::Double, DBE_VALUE | DBE_LOG | DBE_ALARM)
1313            .expect("subscriber added");
1314        pv.set(EpicsValue::Double(1.0));
1315        assert_eq!(
1316            rx.try_recv().expect("value event").mask,
1317            EventMask::VALUE | EventMask::LOG,
1318            "value post carries VALUE|LOG"
1319        );
1320        pv.post_alarm(2, 3);
1321        assert_eq!(
1322            rx.try_recv().expect("alarm event").mask,
1323            EventMask::ALARM | EventMask::LOG,
1324            "alarm post carries ALARM|LOG"
1325        );
1326    }
1327
1328    /// When the queue runs short of room and a post replaces this monitor's
1329    /// last entry in place, the surviving entry's mask is the OR of the
1330    /// displaced event's class and its own: the displaced *value* is gone (C
1331    /// frees the field log), but a narrow consumer must still learn that an
1332    /// ALARM-class change happened inside the coalesced tail.
1333    #[epics_macros_rs::epics_test]
1334    async fn in_place_replacement_accumulates_event_class_masks() {
1335        use crate::server::event_queue::{event_que_size, events_per_que};
1336        use crate::server::recgbl::EventMask;
1337        let pv = Arc::new(ProcessVariable::new(
1338            "coalesce:mask".into(),
1339            EpicsValue::Double(0.0),
1340        ));
1341        let mut reader = pv
1342            .add_subscriber(7, DbFieldType::Double, DBE_VALUE | DBE_LOG | DBE_ALARM)
1343            .expect("subscriber added");
1344        // Append VALUE|LOG posts until the ring space reaches the replace
1345        // threshold; from here every post overwrites the tail entry.
1346        let appended = event_que_size() - events_per_que();
1347        for i in 1..=appended {
1348            pv.set(EpicsValue::Double(i as f64));
1349        }
1350        // Replaces the tail: its class (ALARM|LOG) must not be lost.
1351        pv.post_alarm(2, 3);
1352        // Replaces it again with a value post — both classes fold into the
1353        // survivor.
1354        pv.set(EpicsValue::Double(99.0));
1355
1356        let mut last = None;
1357        while let Ok(event) = reader.try_recv() {
1358            last = Some(event);
1359        }
1360        let delivered = last.expect("the tail entry is delivered");
1361        assert_eq!(
1362            delivered.snapshot.value.to_f64(),
1363            Some(99.0),
1364            "the tail entry carries the newest value"
1365        );
1366        assert!(
1367            delivered
1368                .mask
1369                .contains(EventMask::VALUE | EventMask::ALARM | EventMask::LOG),
1370            "the displaced alarm class survives in the delivered mask (got {:?})",
1371            delivered.mask
1372        );
1373    }
1374
1375    /// R8-22 (simple-PV path): a monitor whose queue runs out of room during a
1376    /// burst must receive its EARLIER DISTINCT queued updates and then a tail
1377    /// entry carrying the latest value — C `db_queue_event_log` replaces only
1378    /// `*pLastLog` (`dbEvent.c:812-820`) and leaves the earlier entries queued.
1379    ///
1380    /// The old primitive parked the newest value in a side coalesce slot, and
1381    /// the consumer, finding it set, discarded the ENTIRE queued backlog and
1382    /// delivered only that newest value — so a 200-post burst came out as a
1383    /// single event instead of {1..107, 200}.
1384    #[epics_macros_rs::epics_test]
1385    async fn r8_22_pv_burst_keeps_earlier_distinct_updates() {
1386        use crate::server::event_queue::{event_que_size, events_per_que};
1387        use std::time::Duration;
1388        let pv = Arc::new(ProcessVariable::new(
1389            "coalesce:pv".into(),
1390            EpicsValue::Double(0.0),
1391        ));
1392        let mut sub = PvSubscription::subscribe(pv.clone())
1393            .await
1394            .expect("subscribe");
1395        // With nothing draining, the first `appended` posts take ring entries
1396        // and every later post replaces the tail entry in place.
1397        let appended = event_que_size() - events_per_que();
1398        let burst = appended + 92;
1399        for i in 1..=burst {
1400            pv.set(EpicsValue::Double(i as f64));
1401        }
1402        let mut seq = Vec::new();
1403        while let Ok(Some(snap)) =
1404            crate::runtime::task::timeout(Duration::from_millis(200), sub.recv_snapshot()).await
1405        {
1406            seq.push(snap.value.to_f64().expect("double value"));
1407        }
1408        let want: Vec<f64> = (1..appended)
1409            .map(|i| i as f64)
1410            .chain(std::iter::once(burst as f64))
1411            .collect();
1412        assert_eq!(
1413            seq, want,
1414            "burst delivery must be {{earlier distinct backlog…, coalesced tail}}"
1415        );
1416    }
1417}
1418
1419#[cfg(test)]
1420mod metadata_tests {
1421    use super::*;
1422
1423    fn meta() -> PvMetadata {
1424        PvMetadata {
1425            display: Some(DisplayInfo {
1426                units: "degC".into(),
1427                precision: 2,
1428                upper_disp_limit: 100.0,
1429                lower_disp_limit: -50.0,
1430                upper_alarm_limit: 90.0,
1431                upper_warning_limit: 80.0,
1432                lower_warning_limit: -20.0,
1433                lower_alarm_limit: -40.0,
1434                ..Default::default()
1435            }),
1436            control: Some(ControlInfo {
1437                upper_ctrl_limit: 95.0,
1438                lower_ctrl_limit: -45.0,
1439            }),
1440            enums: None,
1441        }
1442    }
1443
1444    fn pv() -> ProcessVariable {
1445        ProcessVariable::new("m:pv".into(), EpicsValue::Double(1.0))
1446    }
1447
1448    /// `set_with_origin` tags the value event with the writer's origin,
1449    /// plain `set` stays untagged, and a plain `set` inside an
1450    /// `AmbientWriteOriginScope` inherits the scope's origin — the
1451    /// simple-PV side of the record funnels' inheritance rule.
1452    #[epics_macros_rs::epics_test]
1453    async fn set_with_origin_tags_the_value_event() {
1454        const DBE_VALUE: u16 = 1;
1455        let pv = pv();
1456        let mut rx = pv
1457            .add_subscriber(1, DbFieldType::Double, DBE_VALUE)
1458            .expect("subscriber added");
1459
1460        pv.set(EpicsValue::Double(2.0));
1461        assert_eq!(rx.try_recv().expect("plain set posts").origin, 0);
1462
1463        pv.set_with_origin(EpicsValue::Double(3.0), 77);
1464        assert_eq!(rx.try_recv().expect("tagged set posts").origin, 77);
1465
1466        {
1467            let _scope = crate::server::record::ambient_write_origin_scope(88);
1468            pv.set(EpicsValue::Double(4.0));
1469        }
1470        assert_eq!(
1471            rx.try_recv().expect("ambient-scoped set posts").origin,
1472            88,
1473            "an originless simple-PV post inside an ambient scope must inherit it"
1474        );
1475    }
1476
1477    /// A bare PV serves no metadata until a proxy installs it; after
1478    /// `set_metadata`, the GET snapshot carries the shadow DBR_GR/DBR_CTRL.
1479    #[epics_macros_rs::epics_test]
1480    async fn set_metadata_serves_on_get_snapshot() {
1481        let pv = pv();
1482        assert!(
1483            pv.snapshot().display.is_none(),
1484            "bare PV must carry no metadata before install"
1485        );
1486        pv.set_metadata(meta());
1487        let snap = pv.snapshot();
1488        let d = snap.display.expect("display installed");
1489        assert_eq!(d.units, "degC");
1490        assert_eq!(d.precision, 2);
1491        assert_eq!(
1492            snap.control.expect("control installed").upper_ctrl_limit,
1493            95.0
1494        );
1495    }
1496
1497    /// A CTRL-type monitor must see the installed limits on every value
1498    /// event, not only the initial GET — value posts carry the metadata.
1499    #[epics_macros_rs::epics_test]
1500    async fn installed_metadata_rides_value_posts() {
1501        const DBE_VALUE: u16 = 1;
1502        let pv = pv();
1503        pv.set_metadata(meta());
1504        let mut rx = pv
1505            .add_subscriber(1, DbFieldType::Double, DBE_VALUE)
1506            .expect("subscriber added");
1507        pv.set(EpicsValue::Double(2.0));
1508        let ev = rx.try_recv().expect("value event delivered");
1509        assert_eq!(
1510            ev.snapshot
1511                .display
1512                .clone()
1513                .expect("metadata on value post")
1514                .units,
1515            "degC"
1516        );
1517    }
1518
1519    /// `apply_metadata` only supplies fields the caller left absent: a
1520    /// gateway snapshot that already carries its own display wins.
1521    #[epics_macros_rs::epics_test]
1522    async fn apply_metadata_does_not_clobber_caller_metadata() {
1523        const DBE_VALUE: u16 = 1;
1524        let pv = pv();
1525        pv.set_metadata(meta()); // installed units = degC
1526        let mut rx = pv
1527            .add_subscriber(1, DbFieldType::Double, DBE_VALUE)
1528            .expect("subscriber added");
1529        let mut snap = Snapshot::new(
1530            EpicsValue::Double(3.0),
1531            0,
1532            0,
1533            std::time::SystemTime::UNIX_EPOCH,
1534        );
1535        snap.display = Some(DisplayInfo {
1536            units: "volts".into(),
1537            ..Default::default()
1538        });
1539        pv.set_snapshot(snap);
1540        let ev = rx.try_recv().expect("snapshot delivered");
1541        assert_eq!(
1542            ev.snapshot
1543                .display
1544                .clone()
1545                .expect("caller display kept")
1546                .units,
1547            "volts"
1548        );
1549    }
1550
1551    /// `post_property` reaches DBE_PROPERTY subscribers (carrying the
1552    /// metadata) and not DBE_VALUE-only subscribers.
1553    #[epics_macros_rs::epics_test]
1554    async fn post_property_reaches_only_property_subscribers() {
1555        const DBE_VALUE: u16 = 1;
1556        const DBE_PROPERTY: u16 = 8;
1557        let pv = pv();
1558        pv.set_metadata(meta());
1559        let mut prop_rx = pv
1560            .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
1561            .expect("subscriber added");
1562        let mut val_rx = pv
1563            .add_subscriber(2, DbFieldType::Double, DBE_VALUE)
1564            .expect("subscriber added");
1565        pv.post_property(Snapshot::new(
1566            EpicsValue::Double(1.0),
1567            0,
1568            0,
1569            std::time::SystemTime::UNIX_EPOCH,
1570        ))
1571        .await;
1572        let ev = prop_rx
1573            .try_recv()
1574            .expect("DBE_PROPERTY subscriber receives property post");
1575        assert_eq!(
1576            ev.snapshot
1577                .display
1578                .clone()
1579                .expect("property post carries metadata")
1580                .units,
1581            "degC"
1582        );
1583        assert!(
1584            val_rx.try_recv().is_err(),
1585            "DBE_VALUE-only subscriber must not receive a property post"
1586        );
1587    }
1588
1589    /// A property post
1590    /// must carry the upstream CTRL event's status/severity and timestamp,
1591    /// not a fabricated `NO_ALARM` / wall-clock-now snapshot. C ca-gateway
1592    /// preserves `setStatSevr()` on the property callback
1593    /// (`gatePv.cc:2413-2438`); a downstream `DBE_PROPERTY` monitor must
1594    /// see `severity=MAJOR` and the upstream timestamp, even though only
1595    /// metadata changed.
1596    #[epics_macros_rs::epics_test]
1597    async fn post_property_preserves_upstream_alarm_and_timestamp() {
1598        const DBE_PROPERTY: u16 = 8;
1599        const MAJOR: u16 = 2; // epicsSevMajor
1600        const HIGH: u16 = 3; // epicsAlarmHigh
1601        let pv = pv();
1602        pv.set_metadata(meta());
1603        let mut prop_rx = pv
1604            .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
1605            .expect("subscriber added");
1606        // The upstream CTRL event timestamp: a fixed point in the past, so
1607        // it is unmistakably NOT a fresh wall clock minted by the post.
1608        let upstream_ts = WallTime::from_unix(1_000_000, 0);
1609        pv.post_property(Snapshot::new(
1610            EpicsValue::Double(2.0),
1611            HIGH,
1612            MAJOR,
1613            upstream_ts,
1614        ))
1615        .await;
1616        let ev = prop_rx.try_recv().expect("property post delivered");
1617        assert_eq!(
1618            ev.snapshot.alarm.severity, MAJOR,
1619            "property post must carry the upstream MAJOR severity, not NO_ALARM"
1620        );
1621        assert_eq!(ev.snapshot.alarm.status, HIGH, "upstream status preserved");
1622        assert_eq!(
1623            ev.snapshot.timestamp, upstream_ts,
1624            "property post must keep the upstream timestamp, not a fresh wall clock"
1625        );
1626        // Shadow metadata is still overlaid onto the upstream snapshot.
1627        assert_eq!(
1628            ev.snapshot
1629                .display
1630                .clone()
1631                .expect("property post carries shadow metadata")
1632                .units,
1633            "degC"
1634        );
1635    }
1636}
1637
1638#[cfg(test)]
1639mod read_hook_tests {
1640    use super::*;
1641
1642    fn pv() -> ProcessVariable {
1643        ProcessVariable::new("g:pv".into(), EpicsValue::Double(1.0))
1644    }
1645
1646    /// No hook installed (the default for every record-backed and cached
1647    /// PV): `read_snapshot` is exactly `snapshot` wrapped in `Ok` — the
1648    /// stored value, byte-for-byte unchanged.
1649    #[epics_macros_rs::epics_test]
1650    async fn read_snapshot_without_hook_equals_snapshot() {
1651        let pv = pv();
1652        let read = pv.read_snapshot().await.expect("no-hook read never errors");
1653        let stored = pv.snapshot();
1654        assert_eq!(read.value, stored.value);
1655        assert_eq!(read.value, EpicsValue::Double(1.0));
1656    }
1657
1658    /// With a hook installed (no-cache mode), the GET value comes fresh
1659    /// from the hook, NOT from the stored shadow value — the stored value
1660    /// stays a stale sentinel that the hook overrides.
1661    #[epics_macros_rs::epics_test]
1662    async fn read_snapshot_fires_hook_for_fresh_value() {
1663        let pv = pv();
1664        // Stored shadow value is a sentinel the hook must override.
1665        pv.set(EpicsValue::Double(999.0));
1666        pv.set_read_hook(Arc::new(|| {
1667            Box::pin(async {
1668                Ok(Snapshot::new(
1669                    EpicsValue::Double(42.0),
1670                    0,
1671                    0,
1672                    std::time::UNIX_EPOCH,
1673                ))
1674            })
1675        }));
1676        let read = pv.read_snapshot().await.expect("hook returns Ok");
1677        assert_eq!(
1678            read.value,
1679            EpicsValue::Double(42.0),
1680            "GET must serve the hook's fresh value, not the stored sentinel"
1681        );
1682    }
1683
1684    /// A hook failure propagates so the server can answer `ECA_GETFAIL`,
1685    /// matching C ca-gateway forwarding each read to the IOC.
1686    #[epics_macros_rs::epics_test]
1687    async fn read_snapshot_propagates_hook_error() {
1688        let pv = pv();
1689        pv.set_read_hook(Arc::new(|| Box::pin(async { Err(CaError::Disconnected) })));
1690        let err = pv.read_snapshot().await.expect_err("hook error propagates");
1691        assert!(matches!(err, CaError::Disconnected));
1692    }
1693
1694    /// No hook (every record-backed and cached PV): the sync companion
1695    /// `read_snapshot_local` yields `Some(snapshot)`, byte-for-byte the same
1696    /// value as `snapshot` / the async `read_snapshot` — the fully sans-io
1697    /// GET path.
1698    #[test]
1699    fn read_snapshot_local_without_hook_is_some_and_matches_snapshot() {
1700        let pv = pv();
1701        let local = pv
1702            .read_snapshot_local()
1703            .expect("no hook ⇒ sync snapshot is Some");
1704        assert_eq!(local.value, pv.snapshot().value);
1705        assert_eq!(local.value, EpicsValue::Double(1.0));
1706    }
1707
1708    /// A read hook installed (gateway no-cache): the sync companion returns
1709    /// `None`, the signal that the caller must take the async upstream-GET
1710    /// path — `read_snapshot_local` never fires the hook itself.
1711    #[test]
1712    fn read_snapshot_local_with_hook_is_none() {
1713        let pv = pv();
1714        pv.set_read_hook(Arc::new(|| {
1715            Box::pin(async {
1716                Ok(Snapshot::new(
1717                    EpicsValue::Double(42.0),
1718                    0,
1719                    0,
1720                    std::time::UNIX_EPOCH,
1721                ))
1722            })
1723        }));
1724        assert!(
1725            pv.read_snapshot_local().is_none(),
1726            "a read hook ⇒ the sync path defers to the async upstream GET"
1727        );
1728    }
1729
1730    /// The read hook is GET-path only: `snapshot` (monitor fan-out, the
1731    /// initial monitor event, access-rights re-posts) keeps serving the
1732    /// stored value even when a hook is installed.
1733    #[epics_macros_rs::epics_test]
1734    async fn snapshot_ignores_read_hook() {
1735        let pv = pv();
1736        pv.set(EpicsValue::Double(7.0));
1737        pv.set_read_hook(Arc::new(|| {
1738            Box::pin(async {
1739                Ok(Snapshot::new(
1740                    EpicsValue::Double(42.0),
1741                    0,
1742                    0,
1743                    std::time::UNIX_EPOCH,
1744                ))
1745            })
1746        }));
1747        let snap = pv.snapshot();
1748        assert_eq!(
1749            snap.value,
1750            EpicsValue::Double(7.0),
1751            "snapshot must serve the stored value, never the read hook"
1752        );
1753    }
1754
1755    /// Fresh value + upstream alarm/time ride from the hook; the shadow's
1756    /// installed *property* metadata (display/control/enum) — which a
1757    /// `DBR_TIME_*` event does not carry — is overlaid for those fields.
1758    #[epics_macros_rs::epics_test]
1759    async fn read_snapshot_carries_shadow_metadata() {
1760        let pv = pv();
1761        pv.set_metadata(PvMetadata {
1762            display: Some(DisplayInfo {
1763                units: "mm".into(),
1764                precision: 3,
1765                ..Default::default()
1766            }),
1767            control: None,
1768            enums: None,
1769        });
1770        // The hook returns a Time-class snapshot (value + alarm + time,
1771        // no display/control/enum), exactly as `get_with_metadata(Time)`.
1772        pv.set_read_hook(Arc::new(|| {
1773            Box::pin(async {
1774                Ok(Snapshot::new(
1775                    EpicsValue::Double(5.0),
1776                    0,
1777                    0,
1778                    std::time::UNIX_EPOCH,
1779                ))
1780            })
1781        }));
1782        let read = pv.read_snapshot().await.expect("hook returns Ok");
1783        assert_eq!(read.value, EpicsValue::Double(5.0));
1784        assert_eq!(
1785            read.display
1786                .expect("shadow property metadata rides fresh value")
1787                .units,
1788            "mm"
1789        );
1790    }
1791
1792    /// A no-cache GET must report the FRESH upstream alarm and timestamp
1793    /// that travel with the value (C `getTimeCB` decodes the `DBR_TIME_*`
1794    /// event's status/severity/time before `setEventData`,
1795    /// `gatePv.cc:1789-1794`), NOT the shadow's last monitor-posted (or
1796    /// bare-PV default) alarm/time. Before the fix the read hook returned
1797    /// a bare value and `read_snapshot` grafted it onto the stored
1798    /// snapshot, so the GET reported the new value with a stale or default
1799    /// status/severity/timestamp.
1800    #[epics_macros_rs::epics_test]
1801    async fn read_snapshot_carries_upstream_alarm_not_shadow() {
1802        use std::time::{Duration, UNIX_EPOCH};
1803        let pv = pv();
1804        // The shadow's stored snapshot carries one alarm/time (a prior
1805        // monitor post). Make it concrete and DIFFERENT from the upstream
1806        // GET so a graft-onto-shadow regression is observable.
1807        let shadow_time = UNIX_EPOCH + Duration::from_secs(1_000);
1808        pv.set_snapshot(Snapshot::new(EpicsValue::Double(1.0), 7, 1, shadow_time));
1809        // The fresh upstream GET reports a different value, alarm, and time.
1810        let upstream_time = WallTime::from_unix(2_000, 0);
1811        pv.set_read_hook(Arc::new(move || {
1812            Box::pin(
1813                async move { Ok(Snapshot::new(EpicsValue::Double(5.0), 17, 2, upstream_time)) },
1814            )
1815        }));
1816        let read = pv.read_snapshot().await.expect("hook returns Ok");
1817        assert_eq!(read.value, EpicsValue::Double(5.0), "fresh upstream value");
1818        assert_eq!(
1819            read.alarm.status, 17,
1820            "upstream alarm status, not shadow's 7"
1821        );
1822        assert_eq!(read.alarm.severity, 2, "upstream severity, not shadow's 1");
1823        assert_eq!(
1824            read.timestamp, upstream_time,
1825            "upstream timestamp, not shadow's"
1826        );
1827    }
1828}