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