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