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)
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)
475 .await
476 }
477
478 async fn put_record_field_from_ca_inner(
479 &self,
480 record_name: &str,
481 field: &str,
482 value: EpicsValue,
483 acquire_gate: bool,
484 ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
485 let field = field.to_ascii_uppercase();
486
487 // Get record Arc — alias-aware (epics-base PR #336) so a CA
488 // client that connects via an alias name can put fields on
489 // the canonical record.
490 let rec = self
491 .get_record(record_name)
492 .await
493 .ok_or_else(|| CaError::ChannelNotFound(record_name.to_string()))?;
494 // Normalise to the canonical name for the rest of this
495 // function — every subsequent call (PACT/LCNT lookup,
496 // `process_record_with_links`, `update_scan_index`) uses the
497 // raw records map and would miss when `record_name` is an
498 // alias. Resolve once up front.
499 let canonical_owned;
500 let record_name: &str = if let Some(target) = self.resolve_alias(record_name).await {
501 canonical_owned = target;
502 &canonical_owned
503 } else {
504 record_name
505 };
506
507 // take the record's advisory write gate — the
508 // `dbScanLock(precord)` analogue. While a QSRV atomic group
509 // PUT/GET holds this record's gate via `lock_records`, this
510 // plain write blocks here, so a direct backing-record write
511 // can no longer land between member writes of an atomic group
512 // transaction. Held until the function returns. Skipped when
513 // the caller (atomic group PUT) already owns the gate — the
514 // gate `Mutex` is not reentrant.
515 let _record_gate = if acquire_gate {
516 Some(self.lock_record(record_name).await)
517 } else {
518 None
519 };
520
521 // Special field intercepts (read lock, then drop)
522 {
523 let instance = rec.read().await;
524 match field.as_str() {
525 "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
526 "LCNT" => return Err(CaError::ReadOnlyField("LCNT".into())),
527 "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
528 _ => {}
529 }
530
531 // PROC intercept: trigger processing regardless of DISP.
532 // Falls through to the put_notify_tx registration below
533 // so async records (motor, asyn-backed AO) signal real
534 // completion; otherwise WRITE_NOTIFY would return ECA_NORMAL
535 // before the device move actually finished.
536 if field == "PROC" {
537 // C `dbPutField` (dbAccess.c:1265) matches the proc field by
538 // pointer with NO value check: any write to PROC — including
539 // 0 — processes the record (when !pact). The standard
540 // `caput REC.PROC 0` / `dbpf REC.PROC 0` force-process idiom
541 // must therefore not be skipped for a zero value.
542 drop(instance);
543 // Continue to the put-notify setup + process below
544 // by jumping past the field-write step (the value
545 // itself isn't stored; PROC is a trigger).
546 let (completion_tx, completion_rx) = crate::runtime::sync::oneshot::channel();
547 let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
548 {
549 let rec = self.inner.records.read().await;
550 if let Some(rec_arc) = rec.get(record_name) {
551 let mut guard = rec_arc.write().await;
552 if guard.notify.is_some() {
553 return Err(CaError::PutCallbackInProgress(record_name.to_string()));
554 }
555 guard.notify = Some(notify.clone());
556 }
557 }
558 let mut visited = HashSet::new();
559 // this PROC trigger already holds `record_name`'s
560 // advisory write gate — either `_record_gate` above, or
561 // the QSRV atomic group's `lock_records` epoch when
562 // entered via `put_record_field_from_ca_already_locked`.
563 // The gate `Mutex` is not reentrant, so the processing
564 // call MUST use the `_already_locked` variant.
565 let _ = self
566 .process_record_with_links_already_locked(record_name, &mut visited, 0)
567 .await;
568 // The wait-set fires the oneshot only after the whole
569 // FLNK/OUT chain (sync + async) settles. If it has
570 // already completed the chain was fully synchronous —
571 // report immediate success; otherwise hand the receiver
572 // to the CA layer to await the deferred completion.
573 return if notify.completed() {
574 Ok(None)
575 } else {
576 Ok(Some(completion_rx))
577 };
578 }
579
580 // DISP check: block CA puts to non-DISP fields when DISP=1
581 if instance.common.disp && field != "DISP" {
582 return Err(CaError::PutDisabled(field));
583 }
584 }
585
586 // Normal field put (write lock)
587 let common_result = {
588 let mut instance = rec.write().await;
589 instance.common.putf = true;
590
591 // Coerce value to the field's native DBR type (e.g. String → Double for ao.VAL).
592 // This matches C EPICS db_put_field() which converts from the CA client's type
593 // to the record field's native type.
594 let value = {
595 let target_type = instance
596 .record
597 .field_list()
598 .iter()
599 .find(|f| f.name.eq_ignore_ascii_case(&field))
600 .map(|f| f.dbf_type);
601 if let Some(target) = target_type {
602 if value.db_field_type() != target {
603 // C EPICS dbPut (12cfd41): empty-array → scalar
604 // coercion would produce silent zero; reject.
605 if value.is_empty_array() {
606 instance.common.putf = false;
607 return Err(CaError::InvalidValue(format!(
608 "empty array cannot be coerced to scalar field {field}"
609 )));
610 }
611 value.convert_to(target)
612 } else {
613 value
614 }
615 } else {
616 value
617 }
618 };
619
620 // SPC_NOMOD: reject writes to read-only fields (C EPICS S_db_noMod)
621 let is_read_only = instance
622 .record
623 .field_list()
624 .iter()
625 .find(|f| f.name.eq_ignore_ascii_case(&field))
626 .is_some_and(|f| f.read_only);
627 if is_read_only {
628 instance.common.putf = false;
629 return Err(CaError::ReadOnlyField(field));
630 }
631
632 // Pre-write special hook (C EPICS dbPutSpecial pass=0)
633 if let Err(e) = instance.record.special(&field, false) {
634 instance.common.putf = false;
635 return Err(e);
636 }
637
638 // Capture pre-put value for faac1df1 idempotent-write suppression.
639 let prev_value = instance.record.get_field(&field);
640
641 // Try record-specific field first; fall back to common on FieldNotFound.
642 // For record-owned fields, call on_put() and special() after successful put,
643 // matching what put_common_field() does for common fields.
644 use crate::server::record::CommonFieldPutResult;
645 let common_result = match instance.record.put_field(&field, value.clone()) {
646 Ok(()) => {
647 instance.record.on_put(&field);
648 let _ = instance.record.special(&field, true);
649 // C `dbAccess.c::dbPut:1410-1411` clears
650 // `precord->udf = FALSE` synchronously when the
651 // put target is the record-type's primary value
652 // field (`dbIsValueField`). The clear happens
653 // BEFORE `dbProcess` runs, so any reader between
654 // the put and the process-cycle's own clear sees
655 // the new value with a consistent UDF=false.
656 //
657 // Rust's processing path also clears UDF via
658 // `clears_udf()` in process/complete_async_record,
659 // but that runs AFTER the put lock drops and the
660 // process re-acquires — leaving a small window
661 // where another reader can observe (new VAL,
662 // udf=true). For async records the window spans
663 // the entire device round trip. Clear here to
664 // close the window. The same clear already exists
665 // in `put_pv_and_post` (line 256-262); mirror it.
666 if field == instance.record.primary_field() {
667 instance.common.udf = false;
668 if instance.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM {
669 instance.common.stat = 0;
670 instance.common.sevr = crate::server::record::AlarmSeverity::NoAlarm;
671 }
672 }
673 CommonFieldPutResult::NoChange
674 }
675 Err(CaError::FieldNotFound(_)) => instance.put_common_field(&field, value)?,
676 Err(e) => {
677 instance.common.putf = false;
678 return Err(e);
679 }
680 };
681
682 // Invalidate metadata cache only if the metadata-class
683 // field's value actually changed (faac1df1).
684 instance.notify_field_written_if_changed(&field, prev_value.as_ref());
685
686 // C `dbAccess.c::dbPutField:1276` sets `precord->putf = TRUE`
687 // immediately before calling `dbProcess`, and the flag stays
688 // TRUE through the entire process cycle. It is cleared only
689 // in `recGblFwdLink` (recGbl.c:302) after FLNK fires, OR in
690 // the disable-alarm bail (dbAccess.c:576). The Rust port
691 // previously cleared `putf` here — BEFORE the
692 // `process_record_with_links` call below — so any code
693 // path (TPRO trace, async-completion logic, monitor on
694 // .PUTF) observing the bit during the process cycle saw
695 // `putf=0` and could not distinguish put-driven vs
696 // scan-driven processing.
697 //
698 // DO NOT clear `putf` here. The clearing now happens after
699 // the process call returns (synchronous completion) or in
700 // `complete_async_record` (async completion).
701
702 instance.cleanup_subscribers();
703 // C `dbPut:1408-1414` posts DBE_VALUE|DBE_LOG for the put field
704 // unless `(isValueField && pfldDes->process_passive)` — the
705 // immediate post is suppressed for the value field ONLY when that
706 // field is `pp(TRUE)`, because then the reprocess cycle
707 // (`dbPutField:1265-1268`) re-posts it via the deadband snapshot.
708 // For a value field that is NOT `pp` (calc/calcout/aSub VAL), C
709 // posts here and does not reprocess; the port must do the same,
710 // because the `should_process` gate below skips the cycle for a
711 // non-`pp` value field — without this post a direct VAL put would
712 // fire no monitor at all.
713 let suppress_value_field_post = field == instance.record.primary_field()
714 && match instance.record.process_passive_fields() {
715 Some(pp) => pp.iter().any(|f| f.eq_ignore_ascii_case(&field)),
716 // Un-modeled record types keep the legacy "process on every
717 // put" behavior (`should_process = true` below), so the
718 // reprocess cycle posts the value field — suppress the
719 // immediate post here to avoid a duplicate event.
720 None => true,
721 };
722 if !suppress_value_field_post {
723 instance.notify_field(
724 &field,
725 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
726 );
727 }
728
729 // Fields a `special()` changed as a side effect of this put
730 // (e.g. compress RES reset zeroing NUSE/VAL) get their monitors
731 // posted here, mirroring the explicit `db_post_events` a C
732 // `special()` makes — these fields are not pp(TRUE), so no
733 // process cycle would otherwise post them.
734 for sf in instance.record.monitor_side_effect_fields(&field) {
735 instance.notify_field(
736 sf,
737 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
738 );
739 }
740
741 common_result
742 };
743 // ASG-field change re-evaluation hook. C
744 // `asDbLib.c:107-110,144` `asSpcAsCallback` invokes
745 // `asChangeGroup` → `asAddMemberPvt` → `asComputePvt` for
746 // every `ASGCLIENT` on `dbPut record.ASG NEW_ASG`. Pre-fix
747 // Rust mutated `common.asg` directly with no notification,
748 // so the wire ACCESS_RIGHTS the client saw still reflected
749 // the OLD ASG until something else triggered re-eval. Now we
750 // fire a process-wide notifier that the CA server folds into
751 // its per-client `reeval_access_rights` path.
752 if field == "ASG" {
753 crate::server::access_security::notify_asg_field_changed();
754 }
755 // record lock released
756
757 // Update scan index if SCAN or PHAS changed
758 match common_result {
759 crate::server::record::CommonFieldPutResult::ScanChanged {
760 old_scan,
761 new_scan,
762 phas,
763 } => {
764 self.update_scan_index(record_name, old_scan, new_scan, phas, phas)
765 .await;
766 }
767 crate::server::record::CommonFieldPutResult::PhasChanged {
768 scan: s,
769 old_phas,
770 new_phas,
771 } => {
772 self.update_scan_index(record_name, s, s, old_phas, new_phas)
773 .await;
774 }
775 crate::server::record::CommonFieldPutResult::NoChange => {}
776 }
777
778 // C `dbAccess.c::dbPutField:1263-1268` re-processes the
779 // record on a put only when the put field is `pp(TRUE)` AND the
780 // record is Passive (`SCAN == 0`). (The `PROC` field has its own
781 // always-process intercept above, matching C's
782 // `pfield == &precord->proc`; alarm-ack fields like ACKT/ACKS are
783 // not `pp(TRUE)` so they fall out here, matching C's
784 // `dbrType < DBR_PUT_ACKT`.) Processing on every put would
785 // double-process scanned records and spuriously process puts to
786 // non-`pp` fields (extra FLNK / monitors / device writes /
787 // timestamps). A record type whose DBD pp-flags are not modeled
788 // returns `None` and keeps the legacy "process on every put"
789 // behavior so un-modeled types (other crates, tests) are unchanged.
790 let should_process = {
791 let instance = rec.read().await;
792 match instance.record.process_passive_fields() {
793 Some(pp) => {
794 instance.common.scan == crate::server::record::ScanType::Passive
795 && pp.iter().any(|f| f.eq_ignore_ascii_case(&field))
796 }
797 None => true,
798 }
799 };
800
801 if !should_process {
802 // No processing cycle. C never sets `putf` on this path, so
803 // clear the flag the field-put set at entry, and report
804 // immediate (synchronous) completion to a WRITE_NOTIFY caller.
805 let recs = self.inner.records.read().await;
806 if let Some(rec_arc) = recs.get(record_name) {
807 let mut guard = rec_arc.write().await;
808 if !guard.is_processing() {
809 guard.common.putf = false;
810 }
811 }
812 return Ok(None);
813 }
814
815 // Set up the put-notify wait-set BEFORE processing. The wait-set
816 // fires `completion_tx` only after the originating record AND
817 // every FLNK/OUT chain target it triggers (sync or async) has
818 // completed — C `dbNotify.c` `processNotify`/`dbNotifyCompletion`.
819 // Refuse a second concurrent WRITE_NOTIFY on the same record:
820 // C EPICS returns S_db_Blocked / ECA_PUTCBINPROG, and silently
821 // overwriting the wait-set would drop the prior Sender, waking
822 // the prior caller's rx with RecvError that the CA dispatcher
823 // treats as success.
824 let (completion_tx, completion_rx) = crate::runtime::sync::oneshot::channel();
825 let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
826 {
827 let rec = self.inner.records.read().await;
828 if let Some(rec_arc) = rec.get(record_name) {
829 let mut guard = rec_arc.write().await;
830 if guard.notify.is_some() {
831 return Err(CaError::PutCallbackInProgress(record_name.to_string()));
832 }
833 guard.notify = Some(notify.clone());
834 }
835 }
836
837 // When a CA put writes directly to VAL on an INPUT record whose
838 // VAL is the engineering value, the built-in `RVAL → VAL`
839 // `convert()` must be suppressed for the put-driven process —
840 // re-deriving VAL from a stale RVAL would clobber the value the
841 // operator just wrote (the soft ai preset-NaN case, processing.rs
842 // ~line 677). The framework expresses this by calling
843 // `set_device_did_compute(true)`.
844 //
845 // This MUST be gated on `soft_channel_skips_convert()`. Output
846 // records (mbbo/mbbo_direct/bo/ao) implement
847 // `set_device_did_compute` as "skip the VAL → RVAL output
848 // convert" — the OPPOSITE direction. C `mbboRecord.c::process`
849 // (line 217), `mbboDirectRecord.c::process` (line 198) and
850 // `boRecord.c::process` (line 207) call `convert()`
851 // unconditionally on every non-pact process; a CA VAL-put on an
852 // output record MUST recompute RVAL/ORAW. Suppressing it there
853 // left RVAL/ORAW/ORBV stale. Output records return the default
854 // `false` from `soft_channel_skips_convert()`, so this gate
855 // matches the identical gates in processing.rs (line 694) and
856 // record_instance.rs (line 1381).
857 if field == "VAL" {
858 let recs = self.inner.records.read().await;
859 if let Some(rec_arc) = recs.get(record_name) {
860 let mut guard = rec_arc.write().await;
861 if guard.record.soft_channel_skips_convert() {
862 guard.record.set_device_did_compute(true);
863 }
864 }
865 }
866
867 // Process the record after field put.
868 {
869 let mut visited = HashSet::new();
870 // `record_name`'s advisory write gate is already
871 // held by this `put` (the `_record_gate` taken above, or
872 // the QSRV atomic group's `lock_records` epoch via
873 // `put_record_field_from_ca_already_locked`). The gate
874 // `Mutex` is not reentrant — use the `_already_locked`
875 // processing entry.
876 let _ = self
877 .process_record_with_links_already_locked(record_name, &mut visited, 0)
878 .await;
879 }
880
881 // Is the ORIGINATING record itself still async-pending? Its
882 // wait-set membership is taken + `leave`d at its own completion
883 // (sync-end, or later in `complete_async_record_inner`), so a
884 // lingering `notify` on its instance means its device round-trip
885 // is still in flight. This gates only the originating record's
886 // PUTF clear — independent of whether downstream chain targets
887 // are still pending.
888 let originating_pending = {
889 let rec = self.inner.records.read().await;
890 if let Some(rec_arc) = rec.get(record_name) {
891 rec_arc.read().await.notify.is_some()
892 } else {
893 false
894 }
895 };
896
897 // C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` after
898 // the forward-link dispatch — the marker only lives for the
899 // duration of the put's processing cycle. For SYNCHRONOUS
900 // completions (PACT was cleared by the time
901 // `process_record_with_links` returns) clear it here. For
902 // async-pending records, the clearing happens later in
903 // `complete_async_record_inner` (which runs FLNK as part of
904 // the completion path) so the PUTF marker survives the
905 // device-write round trip.
906 if !originating_pending {
907 let rec = self.inner.records.read().await;
908 if let Some(rec_arc) = rec.get(record_name) {
909 let mut guard = rec_arc.write().await;
910 if !guard.is_processing() {
911 guard.common.putf = false;
912 }
913 }
914 }
915
916 // CA completion gates on the WHOLE chain, not just the
917 // originating record: the put-notify must not report
918 // done until every FLNK/OUT target it drove — including an async
919 // FLNK target that the originating record's sync cycle merely
920 // kicked off — has settled. `completed()` is true iff the
921 // wait-set drained to zero during this call (fully synchronous
922 // chain); otherwise the receiver fires later from the last
923 // chain member's `leave`.
924 if notify.completed() {
925 Ok(None)
926 } else {
927 Ok(Some(completion_rx))
928 }
929 }
930
931 /// Put a PV value without triggering process (for restore).
932 pub async fn put_pv_no_process(&self, name: &str, value: EpicsValue) -> CaResult<()> {
933 let (base, field) = super::parse_pv_name(name);
934 let field = field.to_ascii_uppercase();
935
936 if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
937 pv.set(value).await;
938 return Ok(());
939 }
940
941 // Records — alias-aware (epics-base PR #336).
942 if let Some(rec) = self.get_record(base).await {
943 // `put_pv_no_process` is a public record-write API
944 // (autosave restore). It must take the advisory write gate
945 // (`dbScanLock` analogue) so an autosave restore cannot
946 // land between the member writes of a QSRV atomic group or
947 // a pvalink atomic scan epoch holding `lock_records`.
948 // `base` is alias-resolved so an alias and its target
949 // share one gate. Held until return.
950 let canonical_base: String = self
951 .resolve_alias(base)
952 .await
953 .unwrap_or_else(|| base.to_string());
954 let _record_gate = self.lock_record(&canonical_base).await;
955
956 let mut instance = rec.write().await;
957 let prev_value = instance.record.get_field(&field);
958 match instance.record.put_field(&field, value.clone()) {
959 Ok(()) => {}
960 Err(CaError::FieldNotFound(_)) => {
961 instance.put_common_field(&field, value)?;
962 }
963 Err(e) => return Err(e),
964 }
965 // Invalidate metadata cache only if the metadata-class
966 // field actually changed (faac1df1).
967 instance.notify_field_written_if_changed(&field, prev_value.as_ref());
968 // same SPC_AS parity as `put_pv` / the CA-write
969 // path — autosave-style restores writing `.ASG` at IOC
970 // startup must still trigger per-client re-eval.
971 if field == "ASG" {
972 crate::server::access_security::notify_asg_field_changed();
973 }
974 return Ok(());
975 }
976
977 Err(CaError::ChannelNotFound(name.to_string()))
978 }
979}
980
981/// Project a decoded `DBR_CTRL_*` / `DBR_GR_*` snapshot's metadata fields
982/// (display / control limits, enum labels) into the shadow-PV
983/// [`PvMetadata`](crate::server::pv::PvMetadata) the CA gateway installs.
984/// A non-metadata (TIME/STS) snapshot carries `None` in all three, which
985/// clears the shadow metadata — but the gateway only ever feeds this a
986/// control-class snapshot, matching C `setPvData` replacing the attribute
987/// gdd wholesale from the control get/event.
988fn metadata_from_snapshot(snapshot: &Snapshot) -> crate::server::pv::PvMetadata {
989 crate::server::pv::PvMetadata {
990 display: snapshot.display.clone(),
991 control: snapshot.control.clone(),
992 enums: snapshot.enums.clone(),
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::super::PvDatabase;
999 use crate::types::EpicsValue;
1000
1001 /// Regression: prior to fixing B1, `put_pv_and_post` walked only
1002 /// `inner.records` and returned `ChannelNotFound` for everything
1003 /// `add_pv`-registered. The CA gateway's monitor forwarder uses
1004 /// `add_pv` then expects `put_pv_and_post` to fan-out to
1005 /// downstream subscribers — without the simple-PV branch, every
1006 /// upstream event was silently dropped and the gateway delivered
1007 /// no monitors.
1008 #[tokio::test]
1009 async fn put_pv_and_post_handles_simple_pv() {
1010 let db = PvDatabase::new();
1011 db.add_pv("gw:test", EpicsValue::Double(0.0)).await.unwrap();
1012
1013 // Should NOT return ChannelNotFound.
1014 db.put_pv_and_post("gw:test", EpicsValue::Double(42.0))
1015 .await
1016 .expect("simple PV put_pv_and_post must succeed");
1017
1018 // Value actually landed.
1019 let pv = db.find_pv("gw:test").await.expect("PV exists");
1020 assert!(matches!(pv.get().await, EpicsValue::Double(v) if v == 42.0));
1021 }
1022
1023 /// Regression: `get_pv`, `put_pv`, `put_pv_and_post`,
1024 /// and `put_pv_no_process` all bypassed `get_record` and walked
1025 /// `self.inner.records` directly, so alias names from epics-base
1026 /// PR #336 silently returned `ChannelNotFound`. A later fix closed
1027 /// `get_record` but the same defect was hiding in field_io.rs.
1028 /// All four CA-server-and-bridge entry points must accept aliases.
1029 #[tokio::test]
1030 async fn field_io_entry_points_accept_aliases() {
1031 use crate::server::records::ai::AiRecord;
1032
1033 let db = PvDatabase::new();
1034 db.add_record("CANON", Box::new(AiRecord::new(0.0)))
1035 .await
1036 .unwrap();
1037 db.add_alias("ALT", "CANON").await.unwrap();
1038
1039 // get_pv via alias
1040 db.put_pv("CANON.VAL", EpicsValue::Double(1.5))
1041 .await
1042 .unwrap();
1043 let v = db.get_pv("ALT.VAL").await.unwrap();
1044 assert!(matches!(v, EpicsValue::Double(x) if x == 1.5));
1045
1046 // put_pv via alias
1047 db.put_pv("ALT.VAL", EpicsValue::Double(7.0)).await.unwrap();
1048 let v = db.get_pv("CANON.VAL").await.unwrap();
1049 assert!(matches!(v, EpicsValue::Double(x) if x == 7.0));
1050
1051 // put_pv_and_post via alias
1052 db.put_pv_and_post("ALT.VAL", EpicsValue::Double(11.0))
1053 .await
1054 .unwrap();
1055 let v = db.get_pv("CANON.VAL").await.unwrap();
1056 assert!(matches!(v, EpicsValue::Double(x) if x == 11.0));
1057
1058 // put_pv_no_process via alias
1059 db.put_pv_no_process("ALT.VAL", EpicsValue::Double(13.0))
1060 .await
1061 .unwrap();
1062 let v = db.get_pv("ALT.VAL").await.unwrap();
1063 assert!(matches!(v, EpicsValue::Double(x) if x == 13.0));
1064 }
1065
1066 /// `set_pv_metadata` installs the upstream `DBR_CTRL_*` metadata on a
1067 /// shadow simple PV WITHOUT posting any event (the CA gateway's
1068 /// connect-time seed). A later GET-class read must then see the
1069 /// installed limits/units, and a `DBE_PROPERTY` subscriber must NOT
1070 /// have received anything (nothing *changed* yet). An unknown / record
1071 /// name is rejected with `ChannelNotFound`.
1072 #[tokio::test]
1073 async fn set_pv_metadata_installs_without_posting() {
1074 use crate::error::CaError;
1075 use crate::server::snapshot::{DisplayInfo, Snapshot};
1076 use crate::types::DbFieldType;
1077 use std::time::SystemTime;
1078
1079 let db = PvDatabase::new();
1080 db.add_pv("gw:meta", EpicsValue::Double(0.0)).await.unwrap();
1081
1082 // A DBE_PROPERTY subscriber attached BEFORE the seed — it must stay
1083 // empty, because seeding metadata is not a property *change*.
1084 const DBE_PROPERTY: u16 = 8;
1085 let pv = db.find_pv("gw:meta").await.expect("PV exists");
1086 let mut prop_rx = pv
1087 .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
1088 .await
1089 .expect("subscriber added");
1090
1091 // Build a CTRL-class snapshot carrying display metadata.
1092 let mut ctrl = Snapshot::new(EpicsValue::Double(0.0), 0, 0, SystemTime::UNIX_EPOCH);
1093 ctrl.display = Some(DisplayInfo {
1094 units: "mm".into(),
1095 precision: 3,
1096 upper_disp_limit: 10.0,
1097 lower_disp_limit: -10.0,
1098 ..Default::default()
1099 });
1100
1101 db.set_pv_metadata("gw:meta", &ctrl)
1102 .await
1103 .expect("simple PV set_pv_metadata must succeed");
1104
1105 // The metadata landed on the shadow PV.
1106 let installed = pv.metadata();
1107 assert_eq!(
1108 installed.display.expect("display metadata installed").units,
1109 "mm"
1110 );
1111
1112 // No event was posted (seed != change).
1113 assert!(
1114 prop_rx.try_recv().is_err(),
1115 "set_pv_metadata must not post a DBE_PROPERTY event"
1116 );
1117
1118 // Unknown / non-simple PV is rejected.
1119 assert!(matches!(
1120 db.set_pv_metadata("no:such:pv", &ctrl).await,
1121 Err(CaError::ChannelNotFound(_))
1122 ));
1123 }
1124
1125 /// `post_pv_property` refreshes the shadow metadata AND posts a
1126 /// `DBE_PROPERTY` event carrying the supplied snapshot's metadata,
1127 /// upstream status/severity, and (undefined control-DBR) timestamp — to
1128 /// `DBE_PROPERTY` subscribers only. This is the DB-routing layer the
1129 /// gateway's property monitor drives on every upstream `DBE_PROPERTY`
1130 /// event. An unknown / record name is rejected with `ChannelNotFound`.
1131 #[tokio::test]
1132 async fn post_pv_property_refreshes_and_posts_property_event() {
1133 use crate::error::CaError;
1134 use crate::server::snapshot::{DisplayInfo, Snapshot};
1135 use crate::types::{DbFieldType, WallTime};
1136
1137 const DBE_PROPERTY: u16 = 8;
1138 const DBE_VALUE: u16 = 1;
1139 const MAJOR: u16 = 2;
1140 const HIGH: u16 = 3;
1141
1142 let db = PvDatabase::new();
1143 db.add_pv("gw:prop", EpicsValue::Double(0.0)).await.unwrap();
1144 let pv = db.find_pv("gw:prop").await.expect("PV exists");
1145
1146 let mut prop_rx = pv
1147 .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
1148 .await
1149 .expect("property subscriber added");
1150 let mut val_rx = pv
1151 .add_subscriber(2, DbFieldType::Double, DBE_VALUE)
1152 .await
1153 .expect("value subscriber added");
1154
1155 // Upstream CTRL event: metadata + MAJOR/HIGH alarm + a fixed past
1156 // timestamp that is unmistakably not a fresh wall clock.
1157 let upstream_ts = WallTime::from_unix(2_000_000, 0);
1158 let mut ctrl = Snapshot::new(EpicsValue::Double(5.0), HIGH, MAJOR, upstream_ts);
1159 ctrl.display = Some(DisplayInfo {
1160 units: "V".into(),
1161 precision: 1,
1162 ..Default::default()
1163 });
1164
1165 db.post_pv_property("gw:prop", ctrl)
1166 .await
1167 .expect("simple PV post_pv_property must succeed");
1168
1169 // The metadata was refreshed on the shadow PV.
1170 assert_eq!(
1171 pv.metadata().display.expect("metadata refreshed").units,
1172 "V"
1173 );
1174
1175 // The DBE_PROPERTY subscriber received the metadata-bearing event,
1176 // with the upstream alarm and timestamp preserved.
1177 let ev = prop_rx
1178 .try_recv()
1179 .expect("DBE_PROPERTY subscriber receives the property event");
1180 assert_eq!(
1181 ev.snapshot.display.expect("event carries metadata").units,
1182 "V"
1183 );
1184 assert_eq!(
1185 ev.snapshot.alarm.severity, MAJOR,
1186 "upstream severity preserved"
1187 );
1188 assert_eq!(ev.snapshot.alarm.status, HIGH, "upstream status preserved");
1189 assert_eq!(
1190 ev.snapshot.timestamp, upstream_ts,
1191 "control-DBR timestamp preserved, not a fresh wall clock"
1192 );
1193
1194 // The DBE_VALUE-only subscriber must NOT receive a property event.
1195 assert!(
1196 val_rx.try_recv().is_err(),
1197 "DBE_VALUE-only subscriber must not receive a property post"
1198 );
1199
1200 // Unknown / non-simple PV is rejected.
1201 let again = Snapshot::new(EpicsValue::Double(0.0), 0, 0, WallTime::UNIX_EPOCH);
1202 assert!(matches!(
1203 db.post_pv_property("no:such:pv", again).await,
1204 Err(CaError::ChannelNotFound(_))
1205 ));
1206 }
1207
1208 /// Regression: `put_record_field_from_ca` (the CA
1209 /// server's main put fast path) must accept aliases. Pre-fix it
1210 /// only consulted `inner.records` directly. Also exercises the
1211 /// canonical-name normalisation that protects subsequent
1212 /// `process_record_with_links` / `update_scan_index` calls.
1213 #[tokio::test]
1214 async fn put_record_field_from_ca_accepts_alias() {
1215 use crate::server::records::ai::AiRecord;
1216
1217 let db = PvDatabase::new();
1218 db.add_record("CANON", Box::new(AiRecord::new(0.0)))
1219 .await
1220 .unwrap();
1221 db.add_alias("ALT", "CANON").await.unwrap();
1222
1223 // Put VAL via the alias name.
1224 let _ = db
1225 .put_record_field_from_ca("ALT", "VAL", EpicsValue::Double(2.5))
1226 .await
1227 .expect("put via alias must succeed");
1228
1229 // Read back via canonical to confirm the value landed on the
1230 // right record.
1231 let v = db.get_pv("CANON.VAL").await.unwrap();
1232 assert!(matches!(v, EpicsValue::Double(x) if x == 2.5));
1233 }
1234
1235 /// Regression: a direct CA put to a record whose value field VAL is NOT
1236 /// `pp(TRUE)` (calc / calcout / aSub) must still fire a DBE_VALUE monitor.
1237 /// C `dbAccess.c::dbPut:1408-1414` posts the value field immediately
1238 /// unless it is `pp(TRUE)`. The port previously suppressed the immediate
1239 /// post for every `VAL` and — with the `should_process` gate — skipped
1240 /// the reprocess cycle for a non-`pp` VAL, so the operator's write fired
1241 /// no monitor at all. calc's VAL is not in its `pp` field set, so the
1242 /// immediate post is the only event that can fire.
1243 #[tokio::test]
1244 async fn ca_put_to_non_pp_val_posts_monitor() {
1245 use crate::server::database::db_access::DbSubscription;
1246 use crate::server::records::calc::CalcRecord;
1247
1248 let db = PvDatabase::new();
1249 db.add_record("CALC1", Box::new(CalcRecord::new("0")))
1250 .await
1251 .unwrap();
1252
1253 let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
1254 .await
1255 .expect("subscribe to CALC1.VAL");
1256
1257 db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(5.0))
1258 .await
1259 .expect("CA put to CALC1.VAL must succeed");
1260
1261 let got = tokio::time::timeout(std::time::Duration::from_secs(1), sub.recv_f64())
1262 .await
1263 .expect("a DBE_VALUE monitor must fire for a direct VAL put to a non-pp record");
1264 assert_eq!(got, Some(5.0));
1265 }
1266
1267 /// Record-backed consumer half of the source-coalesced stale-tail rule.
1268 ///
1269 /// `DbSubscription::next_event` shares the `coalesce_consume` ordering
1270 /// rule with `PvSubscription`: a record-field monitor whose bounded
1271 /// queue overflows mid-burst must converge on the newest value and
1272 /// never step back to an older queued one. Each non-pp `VAL` put posts
1273 /// exactly one DBE_VALUE monitor with the put value and does NOT
1274 /// reprocess (see `ca_put_to_non_pp_val_posts_monitor`), so 80 distinct
1275 /// puts produce a strictly increasing 1..=80 stream; with no consumer
1276 /// draining, 1..=64 fill the queue and the newest (80) lands in the
1277 /// coalesce slot.
1278 ///
1279 /// Before the fix `next_event` returned the coalesced `80` and then
1280 /// replayed the stale tail `1..=64` (`80, 1, 2, ...`) — value time
1281 /// going backwards.
1282 #[tokio::test]
1283 async fn r0604_db_overflow_never_delivers_newest_then_old() {
1284 use crate::server::database::db_access::DbSubscription;
1285 use crate::server::records::calc::CalcRecord;
1286
1287 let db = PvDatabase::new();
1288 db.add_record("CALC1", Box::new(CalcRecord::new("0")))
1289 .await
1290 .unwrap();
1291 let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
1292 .await
1293 .expect("subscribe to CALC1.VAL");
1294
1295 for i in 1..=80u32 {
1296 db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(i as f64))
1297 .await
1298 .expect("CA put to CALC1.VAL must succeed");
1299 }
1300
1301 // Drain every immediately-available delivery; the recv past the
1302 // last event has nothing queued and times out, ending collection.
1303 let mut seq = Vec::new();
1304 while let Ok(Some(v)) =
1305 tokio::time::timeout(std::time::Duration::from_millis(200), sub.recv_f64()).await
1306 {
1307 seq.push(v);
1308 }
1309 assert!(!seq.is_empty(), "consumer must observe at least one value");
1310 for w in seq.windows(2) {
1311 assert!(
1312 w[0] <= w[1],
1313 "record monitor delivery stepped backward {} -> {} (sequence {seq:?})",
1314 w[0],
1315 w[1],
1316 );
1317 }
1318 assert_eq!(
1319 *seq.last().unwrap(),
1320 80.0,
1321 "record consumer must converge on the newest produced value (sequence {seq:?})"
1322 );
1323 }
1324}