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