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/// C `dbPutField`'s put-disable gate (`dbAccess.c:1255-1257`):
10/// `precord->disp && paddr->pfield != &precord->disp` → `S_db_putDisabled`.
11///
12/// This is the FIRST gate an *external* put crosses. It precedes `dbPut` —
13/// so it precedes the `SPC_NOMOD` rejection of PACT/LCNT/PUTF — and it
14/// precedes the PROC-driven `dbProcess` (`dbAccess.c:1265-1277`), so
15/// `caput REC.PROC 1` on a `DISP=1` record is refused, not force-processed.
16///
17/// Single owner for both external put boundaries: the CA / `dbpf` route
18/// ([`PvDatabase::put_record_field_from_ca`]) and the QSRV precondition
19/// check ([`PvDatabase::check_external_put_preconditions`]). Internal puts
20/// (`put_pv`, link and processing writes — the `dbPut` analogue) deliberately
21/// do not cross it.
22fn check_put_disabled(
23 instance: &crate::server::record::RecordInstance,
24 field_upper: &str,
25) -> CaResult<()> {
26 if instance.common.disp != 0 && field_upper != "DISP" {
27 return Err(CaError::PutDisabled(field_upper.to_string()));
28 }
29 Ok(())
30}
31
32/// C's `dbPut` no-modify gate — the put-side consumer of the SPC_NOMOD
33/// declaration.
34///
35/// Two C rejections, both inside `dbPut` and therefore BELOW every put entry
36/// point (`dbPutField` for CA/`dbpf`, `dbPutLink` for a record's OUT link,
37/// `dbPutSpecial` for an internal one):
38///
39/// ```c
40/// /* dbAccess.c:1330-1332 */
41/// if (special == SPC_ATTRIBUTE) return S_db_noMod;
42/// /* dbAccess.c:123-126, via dbPut -> dbPutSpecial(paddr, 0) */
43/// if ((special == SPC_NOMOD) && (pass == 0)) return S_db_noMod;
44/// ```
45///
46/// INVARIANT: a field declared `special(SPC_NOMOD)` (or `SPC_ATTRIBUTE`) MUST
47/// NOT be modified by ANY runtime write, whatever route it arrives on — CA put,
48/// `dbpf`, QSRV, an internal `put_pv`, or a record's OUT link. A record's own
49/// `put_field` is NOT a gate: the hand-written array records write
50/// NELM/FTVL/NORD there because the *load* path (`dbLoadRecords` →
51/// `Record::put_field`) must set them — C likewise writes them through
52/// `dbStaticLib`'s `dbPutString`, which never crosses `dbPut`.
53///
54/// The declaration itself lives in [`RecordInstance::is_no_mod`], which C
55/// exposes as `dbChannelSpecial(...) == SPC_NOMOD` and reads from TWO places:
56/// this gate (`dbPut`, dbAccess.c:123-126) and `rsrvCheckPut`
57/// (camessage.c:2540-2551), which feeds the CA ACCESS_RIGHTS write bit. This
58/// function is the first consumer; `epics-ca-rs`'s `compute_access` is the
59/// second.
60///
61/// The one thing that legitimately changes ACKS/ACKT is C's alarm
62/// acknowledgement, and it does NOT come through here: `dbPut` dispatches on the
63/// DBR *request type* (`DBR_PUT_ACKT`/`DBR_PUT_ACKS`, `dbAccess.c:1331-1335`)
64/// ABOVE this gate, into [`RecordInstance::put_ackt`] /
65/// [`RecordInstance::put_acks`]. The wire route for that is
66/// [`PvDatabase::put_alarm_ack_from_ca`].
67///
68/// `field` must already be upper-cased.
69fn check_no_mod(instance: &crate::server::record::RecordInstance, field: &str) -> CaResult<()> {
70 if instance.is_no_mod(field) {
71 return Err(CaError::ReadOnlyField(field.to_string()));
72 }
73 Ok(())
74}
75
76/// Does an *external* put to `field` drive a processing cycle on this record?
77///
78/// C `dbPutField` (`dbAccess.c:1263-1268`) and pvxs `IOCSource::
79/// doPostProcessing` (`iocsource.cpp:397-403`) ask the same question with the
80/// same terms as C `processNotifyCommon` (dbNotify.c:243-246): the `PROC` field
81/// always, else a `pp(TRUE)` field on a Passive record. (`dbrType <
82/// DBR_PUT_ACKT` is subsumed: the alarm-ack fields are not `pp(TRUE)`.) A caller
83/// that FORCES processing (`record._options.process=true`) does not consult this
84/// at all — force is the caller's own term, not the record's.
85///
86/// `PROC` and `UDF` are the ONLY two `dbCommon` `pp(TRUE)` fields
87/// (`dbCommon.dbd.pod`: PROC line 243, UDF line 552); every other `pp(TRUE)`
88/// field is declared per record TYPE and reached through
89/// [`Record::processes_after_put`]. Because the two `dbCommon` fields are NOT in
90/// any type's `process_passive_fields()` table, they are named here directly:
91/// `PROC` unconditionally (force-process on any SCAN), `UDF` on the Passive
92/// branch (an ordinary `pp` field, so it processes only when `SCAN == 0`, unlike
93/// PROC). Both `dbCommon` `pp(TRUE)` fields are thus handled at this one owner
94/// gate, uniformly for every record type. A put to UDF is accepted+stored by
95/// `put_common_field`; this gate only adds the process cycle, after which the
96/// record recomputes alarms → NO_ALARM (C ends STAT/SEVR=NO_ALARM likewise).
97///
98/// Single owner of the rule: the single-record put route
99/// ([`PvDatabase::put_record_field_from_ca`]) tests it while it already holds
100/// the instance, and the QSRV group PUT — whose C twin is `doPostProcessing` —
101/// reaches it through [`PvDatabase::put_drives_processing`]. Neither can drift
102/// from C or from the other.
103///
104/// `field` must already be upper-cased.
105pub(crate) fn put_drives_processing_of(
106 instance: &crate::server::record::RecordInstance,
107 field: &str,
108) -> bool {
109 field == "PROC"
110 || (instance.common.scan == crate::server::record::ScanType::Passive
111 && (field == "UDF" || instance.record.processes_after_put(field)))
112}
113
114/// Drain the record's per-cycle post marks ([`Record::take_cycle_posted_fields`])
115/// into monitor posts — the put-path counterpart of the `db_post_events` calls a
116/// C `special()` makes by hand.
117///
118/// One owner, so the two put-path drains (the normal tail, and the failing
119/// `special()` above) cannot disagree about the mask mapping.
120fn emit_cycle_posts(instance: &mut crate::server::record::RecordInstance) {
121 use crate::server::record::{CyclePostMask, EventMask};
122 for (sf, cycle_mask) in instance.record.take_cycle_posted_fields() {
123 let mask = match cycle_mask {
124 CyclePostMask::Value => EventMask::VALUE,
125 // No `monitor_mask` exists on a put path (no alarm transition is
126 // being resolved), so both LOG-carrying variants reduce to C's
127 // literal `DBE_VALUE|DBE_LOG`.
128 CyclePostMask::ValueLog | CyclePostMask::MonitorValueLog => {
129 EventMask::VALUE | EventMask::LOG
130 }
131 };
132 instance.notify_field(sf, mask);
133 }
134}
135
136/// C `dbPutSpecial(paddr, 1)` — the after-put `special()`, paired with the drain
137/// of the link writes it queued ([`Record::take_special_actions`]).
138///
139/// The pairing is the point: `special()` and its drain are ONE step, so a queued
140/// action cannot survive the put that queued it — not even when `special()`
141/// returns an error (C's `dbPut` `goto done`), because the drain happens before
142/// the status is propagated. The caller executes `out` once the record lock is
143/// released, ahead of the put-driven process cycle, which is where C runs it
144/// (inside `dbPut`, before `dbPutField`'s `dbProcess`).
145///
146/// Every `dbPut` path in this module goes through here; nothing else may call
147/// `Record::special(field, true)`.
148///
149/// Returns the scan-index delta the after-put pass produced — non-`NoChange`
150/// only for the SIMM↔SSCN swap below, which the caller applies through
151/// `update_scan_index` once the record lock is down.
152fn special_after_put(
153 instance: &mut crate::server::record::RecordInstance,
154 field: &str,
155 out: &mut Vec<crate::server::record::ProcessAction>,
156) -> CaResult<crate::server::record::CommonFieldPutResult> {
157 let status = instance.record.special(field, true);
158 out.extend(instance.record.take_special_actions());
159 if status.is_err() {
160 // The POSTS `special()` made are drained on the failing path for the same
161 // reason its ACTIONS are: C's `special()` calls `db_post_events` BEFORE it
162 // returns nonzero — aCalcout's NUSE arm posts the clamped value with
163 // `DBE_VALUE` and only then `return (-1)` (`aCalcoutRecord.c:495-499`) —
164 // and `dbPut`'s `goto done` skips only dbPut's OWN post. A refused put
165 // that repaired a field must still tell the subscribers what it repaired.
166 emit_cycle_posts(instance);
167 }
168 status?;
169
170 // C `special()`'s CONSTANT-link re-seed (`calcoutRecord.c:367-378`,
171 // `sCalcoutRecord.c:512-517`, `aCalcoutRecord.c:534-540`,
172 // `transformRecord.c:714-719` — the four records whose C `special()` calls
173 // `recGblInitConstantLink`): a put that leaves an input link constant
174 // re-runs the load into that input's value field and posts it. Without it a
175 // constant link is load-once dead state — the link layer delivers nothing
176 // for a constant at process time, so `caput CO.INPB 7` would store the text
177 // and leave `B` at its `.db` value forever.
178 //
179 // The record only DECLARES the pairs (`special_reseed_input_links`); the
180 // load itself is the shared `rec_gbl_init_constant_link` owner, the same one
181 // the init seed uses. Records whose C `special()` does not re-seed (calc,
182 // sub, sel, aSub, swait, …) declare nothing and are untouched.
183 if let Some(value_field) =
184 crate::server::record::reseed_constant_input_link(&mut *instance.record, field)
185 {
186 // The mask is the C call site's, and they disagree — calcout posts a
187 // literal DBE_VALUE, transform DBE_VALUE|DBE_LOG. The record carries it.
188 let mask = instance.record.special_reseed_post_mask();
189 instance.notify_field(value_field, mask);
190 }
191
192 // C `special(SPC_MOD)` pass 1 on SIMM (`longinRecord.c:171-177` and the
193 // identical arm in all 21 SSCN-bearing records):
194 // `recGblCheckSimm((dbCommon *)prec, &prec->sscn, prec->oldsimm, prec->simm);`
195 // Paired with `special_before_put`'s pass 0 (`recGblSaveSimm`), and gated
196 // per record type by `Record::uses_recgbl_simm_helpers`.
197 Ok(if field == "SIMM" {
198 instance.rec_gbl_check_simm()
199 } else {
200 crate::server::record::CommonFieldPutResult::NoChange
201 })
202}
203
204/// C `dbPutSpecial(paddr, 0)` — the before-put pass, run while the record lock
205/// is held and BEFORE the field's new value is stored.
206///
207/// The only `SPC_MOD` field in the record framework whose pass-0 does work is
208/// SIMM: `recGblSaveSimm` latches the outgoing simulation mode into OLDSIMM so
209/// the after-put pass can see the transition. Paired with
210/// [`special_after_put`]; every `dbPut` path in this module calls both.
211fn special_before_put(instance: &mut crate::server::record::RecordInstance, field: &str) {
212 if field == "SIMM" {
213 instance.rec_gbl_save_simm();
214 }
215}
216
217/// The `recGblResetAlarms` half of a C `monitor()` that a `special()` invokes
218/// (compress SPC_RESET, [`crate::server::record::Record::special_commits_alarms`]).
219///
220/// C's `monitor()` opens with `recGblResetAlarms(prec)` (compressRecord.c:103),
221/// committing `nsta`/`nsev` into `stat`/`sevr` — this is what clears the
222/// born-UDF alarm of a never-processed record the moment a reset field is put.
223/// Commits the alarm, posts any STAT/SEVR/AMSG/ACKS transition through the one
224/// owner ([`crate::server::database::processing::alarm_field_posts`]), and
225/// returns the `DBE_ALARM` mask C ORs into the value posts (`val_mask`,
226/// recGbl.c:213 — set iff any alarm-class field moved this cycle).
227///
228/// Shared by `dbPut`'s success tail and its rejected-conversion path: C runs
229/// `dbPutSpecial(paddr, 1)` on BOTH (dbAccess.c:83-88, "Always do special
230/// processing if needed", before the `goto done` that bails on a failed put).
231fn commit_special_reset_alarm(
232 instance: &mut crate::server::record::RecordInstance,
233) -> crate::server::recgbl::EventMask {
234 use crate::server::recgbl::EventMask;
235 let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
236 for (af, mask) in
237 crate::server::database::processing::alarm_field_posts(&instance.common, &alarm_result)
238 {
239 instance.notify_field(af, mask);
240 }
241 if alarm_result.alarm_changed || alarm_result.amsg_changed {
242 EventMask::ALARM
243 } else {
244 EventMask::NONE
245 }
246}
247
248/// Coerce a write `value` to a record field's stored `target` type — C
249/// `dbConvert.c`'s `dbFastPutConvertRoutine[dbrType][field_type]` table.
250///
251/// The client-`dbPut` half of the shared converter
252/// [`crate::server::record::coerce_put_value`]; the internal-delivery half is
253/// `put_field_internal_default`. A `DBR_STRING` write to a `DBF_MENU` or
254/// `DBF_ENUM` field has a converter of its own in C (`putStringMenu`,
255/// `putStringEnum`) and must not fall through to `EpicsValue::convert_to`,
256/// which is field-blind and turns any unrecognised string into index 0 —
257/// C stores nothing and fails the put with `S_db_badChoice`.
258fn coerce_write_value(
259 record: &dyn crate::server::record::Record,
260 field: &str,
261 target: crate::types::DbFieldType,
262 value: EpicsValue,
263) -> CaResult<EpicsValue> {
264 crate::server::record::coerce_put_value(record, field, target, value)
265}
266
267/// What a `dbPut` of a given value means for a given field — the single owner
268/// of C `dbPut`'s value branch (`dbAccess.c:1345-1372`).
269enum PutRequest {
270 /// Write this value; already coerced to the field's native type.
271 Write(EpicsValue),
272 /// A zero-element request (`nRequest < 1`) into a **scalar** destination.
273 ///
274 /// C writes nothing, raises `LINK_ALARM`/`INVALID_ALARM` on the record, and
275 /// returns **success** — `status` stays 0, so the put is accepted and the
276 /// record's next `recGblResetAlarms` publishes the new alarm
277 /// (`dbAccess.c:1370-1372`, commit `12cfd418d`, whose subject is "fix dbPut
278 /// to *set* the target to INVALID/LINK alarm when writing empty arrays into
279 /// scalars" — not to reject the put).
280 EmptyIntoScalar,
281}
282
283/// Resolve a put into its C `dbPut` branch.
284///
285/// C picks the branch from the **destination's** element count
286/// (`dbAccess.c:1345` `no_elements > 1`): an array field clamps `nRequest` and
287/// converts — a zero-length request copies nothing and succeeds silently — while
288/// a scalar field with `nRequest < 1` takes the alarm branch. The test is on the
289/// request count and the destination, never on whether a type conversion happens
290/// to be needed.
291///
292/// [`FieldDesc`](crate::server::record::FieldDesc) carries no element count, so
293/// the destination's current value is the probe: an array-valued field reads
294/// back as an array variant. A field the record does not own (a `dbCommon`
295/// field, reached via `put_common_field`) reads back as `None` and is scalar,
296/// which is also what its `DBF_*` descriptor says in C.
297fn dbput_request(
298 record: &dyn crate::server::record::Record,
299 field: &str,
300 value: EpicsValue,
301) -> CaResult<PutRequest> {
302 let dest_is_array = record.get_field(field).is_some_and(|v| v.is_array());
303 if value.is_empty_array() && !dest_is_array {
304 return Ok(PutRequest::EmptyIntoScalar);
305 }
306 // The coercion target is the type the record STORES, not the type it
307 // SERVES — `put_field`'s arms match on what is stored. A `menu()` field is
308 // declared `DBF_MENU` and served as `DBR_ENUM` with its choices, but held as
309 // a bare `Short` choice index; coercing an incoming `Short` up to `Enum`
310 // because the `.dbd` says `DBF_MENU` would make its `Short` arm unreachable.
311 // Same rule as `put_field_internal_default` and `db_loader::apply_fields`.
312 // The `.dbd` type is the fallback for a field with no current value.
313 let target = record
314 .get_field(field)
315 .map(|v| v.db_field_type())
316 .or_else(|| crate::server::record::record_instance::declared_field_type_of(record, field));
317
318 // C `dbPut` clamps the request to the destination's element count —
319 // `if (no_elements < nRequest) nRequest = no_elements;` (dbAccess.c:1359),
320 // then converts `nRequest` elements. A multi-element request into a
321 // one-element destination therefore writes element 0 and SUCCEEDS; the
322 // surplus elements are dropped, not an error. Reduce the array to its
323 // first element here, so the record's typed `put_field` arm — and every
324 // `put_common_field` arm — sees the scalar C would have written instead of
325 // rejecting the array with a `TypeMismatch`.
326 //
327 // One array shape is exempt: a `CharArray` into a `DBF_STRING` field is how
328 // this port carries the dbChannel `$` char-array view of a string field
329 // (`dbChannel.c:486-505` re-types it to `DBF_CHAR[field_size]`, i.e. an
330 // ARRAY destination in C — its element count is 40, not 1). The `$` flag
331 // lives on the CA channel and never reaches this layer, so the char view is
332 // recognised by its shape and left to `convert_to`, which decodes the bytes
333 // back into the string field.
334 let is_char_string_view = matches!(value, EpicsValue::CharArray(_))
335 && target == Some(crate::types::DbFieldType::String);
336 let value = if !dest_is_array && value.is_array() && !is_char_string_view {
337 value.first_element().unwrap_or(value)
338 } else {
339 value
340 };
341
342 match target {
343 // A String target always runs the converter even on a type match: C's
344 // `putStringString` is not a no-op, it truncates to `field_size - 1`
345 // (see `coerce_put_value`).
346 Some(target)
347 if value.db_field_type() != target || target == crate::types::DbFieldType::String =>
348 {
349 Ok(PutRequest::Write(coerce_write_value(
350 record, field, target, value,
351 )?))
352 }
353 _ => Ok(PutRequest::Write(value)),
354 }
355}
356
357/// Apply C's [`PutRequest::EmptyIntoScalar`] effect: the field is left
358/// untouched and the record is driven to `LINK_ALARM`/`INVALID_ALARM`
359/// (`dbAccess.c:1371` `recGblSetSevr(precord, LINK_ALARM, INVALID_ALARM)`).
360fn set_empty_request_alarm(instance: &mut crate::server::record::RecordInstance) {
361 crate::server::recgbl::rec_gbl_set_sevr(
362 &mut instance.common,
363 crate::server::recgbl::alarm_status::LINK_ALARM,
364 crate::server::record::AlarmSeverity::Invalid,
365 );
366}
367
368/// What the put entry point owes the caller in the way of completion — the
369/// `dbPutField` / `dbPutNotify` split, plus the restart C's `dbNotify` state
370/// machine performs on a put-notify that had to wait for a PACT record.
371///
372/// The completion *sender* travels with the request: a restarted put must
373/// signal the ORIGINAL caller's receiver, which was handed out when the put
374/// first arrived and was deferred, so the restart cannot mint a fresh channel.
375enum NotifyRequest {
376 /// C `dbPutField` — process the record, build no `putNotify`.
377 None,
378 /// C `dbPutNotify` arriving fresh from a client: mint the wait-set channel.
379 New,
380 /// C `dbNotify.c:207-231` restart: the client's receiver already exists,
381 /// this replay carries its sender.
382 Deferred(crate::runtime::sync::oneshot::Sender<()>),
383}
384
385impl NotifyRequest {
386 fn wants_notify(&self) -> bool {
387 !matches!(self, NotifyRequest::None)
388 }
389
390 /// The completion sender, plus the receiver to hand back — `Some` only for
391 /// a fresh request; a restart's receiver went to the client at deferral.
392 #[allow(clippy::type_complexity)]
393 fn into_completion(
394 self,
395 ) -> Option<(
396 crate::runtime::sync::oneshot::Sender<()>,
397 Option<crate::runtime::sync::oneshot::Receiver<()>>,
398 )> {
399 match self {
400 NotifyRequest::None => None,
401 NotifyRequest::New => {
402 let (tx, rx) = crate::runtime::sync::oneshot::channel();
403 Some((tx, Some(rx)))
404 }
405 NotifyRequest::Deferred(tx) => Some((tx, None)),
406 }
407 }
408}
409
410/// Snapshot NORD before a `dbPut` writes the value field — C `put_array_info`
411/// opens with `epicsUInt32 nord = prec->nord;` (`waveformRecord.c:202-216`).
412///
413/// `None` for a put to any field but VAL, and for a record type that has no
414/// NORD (the comparison in [`post_array_info`] then reduces to "unchanged").
415fn array_nord_before_put(
416 instance: &crate::server::record::RecordInstance,
417 field: &str,
418) -> Option<EpicsValue> {
419 if field == "VAL" {
420 instance.record.get_field("NORD")
421 } else {
422 None
423 }
424}
425
426/// The tail of C `put_array_info`, and the SINGLE owner of the NORD post:
427///
428/// ```c
429/// if (nord != prec->nord)
430/// db_post_events(prec, &prec->nord, DBE_VALUE | DBE_LOG);
431/// ```
432///
433/// `put_array_info` is called from `dbPut`, so it is reached by EVERY put
434/// route — CA, `dbPutLink`, internal — and by none of them conditionally. The
435/// port's array records (waveform/aai/aao/subArray) re-derive NORD inside
436/// `put_field("VAL")`; this is the post half, and every `dbPut` body in this
437/// module calls it after the value write, passing the snapshot taken by
438/// [`array_nord_before_put`].
439///
440/// Note this post is NOT the process cycle's monitor post: the compiled softIoc
441/// on a 10-second-SCAN waveform posts `NORD = 3` the instant `caput -a WP 3
442/// 1 2 3` lands, and posts no VAL at all (waveform VAL is `pp(TRUE)`, so C
443/// suppresses the value-field post in `dbPut` and the scan is 10 seconds away).
444fn post_array_info(
445 instance: &mut crate::server::record::RecordInstance,
446 old_nord: &Option<EpicsValue>,
447 origin: u64,
448) {
449 let Some(old) = old_nord else { return };
450 let moved = instance
451 .record
452 .get_field("NORD")
453 .is_some_and(|new| new != *old);
454 if moved {
455 instance.notify_field_with_origin(
456 "NORD",
457 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
458 origin,
459 );
460 }
461}
462
463impl PvDatabase {
464 /// Get a PV value synchronously, from a thread that cannot `await`.
465 ///
466 /// Works from a plain thread with no runtime entered (an iocsh thread, a
467 /// driver's own thread) and from a multi-threaded runtime worker; see
468 /// [`crate::runtime::task::block_on_sync`] for which mechanism is used
469 /// where.
470 ///
471 /// Returns an error on a **current-thread** runtime, where blocking cannot
472 /// be made sound: parking that runtime's only thread halts the task holding
473 /// the database lock this call awaits. Such callers must `await`
474 /// [`Self::get_pv`] instead.
475 pub fn get_pv_blocking(&self, name: &str) -> CaResult<EpicsValue> {
476 crate::runtime::task::block_on_sync(self.get_pv(name)).unwrap_or_else(|_| {
477 Err(CaError::InvalidValue(
478 "get_pv_blocking cannot block a current-thread runtime; await get_pv() instead"
479 .into(),
480 ))
481 })
482 }
483
484 /// Get the current value of a PV or record field.
485 /// Uses resolve_field for records (3-level priority).
486 pub async fn get_pv(&self, name: &str) -> CaResult<EpicsValue> {
487 let (base, field) = super::parse_pv_name(name);
488 let field = field.to_ascii_uppercase();
489
490 // Check simple PVs first (exact match)
491 if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
492 return Ok(pv.get().await);
493 }
494
495 // Records — alias-aware via `get_record` (epics-base PR #336).
496 if let Some(rec) = self.get_record(base).await {
497 let instance = rec.read().await;
498 return instance
499 .resolve_field(&field)
500 .ok_or_else(|| CaError::ChannelNotFound(name.to_string()));
501 }
502
503 Err(CaError::ChannelNotFound(name.to_string()))
504 }
505
506 /// Set a PV value or record field, notifying subscribers.
507 /// Tries record put_field first, then put_common_field as fallback.
508 ///
509 /// Acquires the record's advisory write gate.
510 pub async fn put_pv(&self, name: &str, value: EpicsValue) -> CaResult<()> {
511 self.put_pv_inner(name, value, true).await
512 }
513
514 /// `put_pv` variant for a caller already holding the
515 /// record's advisory write gate (QSRV atomic group PUT). See
516 /// [`Self::put_record_field_from_ca_already_locked`].
517 pub async fn put_pv_already_locked(&self, name: &str, value: EpicsValue) -> CaResult<()> {
518 self.put_pv_inner(name, value, false).await
519 }
520
521 /// C `IOCSource::doPreProcessing` gate (pvxs `iocsource.cpp:363-375`).
522 ///
523 /// Reject an *external* put (a PVA/CA client put routed through QSRV)
524 /// that C refuses before any write: a put to a `DISP=1` record's
525 /// non-DISP field (`S_db_putDisabled`) or to a read-only / `SPC_NOMOD`
526 /// field (`S_db_noMod`). No value is written — this is a precondition
527 /// check only. It mirrors the two gates inside
528 /// [`Self::put_record_field_from_ca`] (the Passive route) so the QSRV
529 /// `Force`/`Inhibit` routes — which go through [`Self::put_pv`] — enforce
530 /// the same preconditions. `put_pv` itself is the internal `dbPut`
531 /// analogue and deliberately does not gate DISP (internal
532 /// link/processing puts must bypass it), so the gate lives at the
533 /// external put boundary, exactly as C places `doPreProcessing` in the
534 /// source layer rather than in `dbPut`.
535 pub async fn check_external_put_preconditions(
536 &self,
537 record_name: &str,
538 field: &str,
539 ) -> CaResult<()> {
540 let field_upper = field.to_ascii_uppercase();
541 // A missing record is not a DISP/read-only precondition violation:
542 // stay silent and let the downstream put report the not-found (for
543 // QSRV, inside its own `asTrapWrite` bracket). C's `doPreProcessing`
544 // only runs against an established channel — the record is
545 // guaranteed present there — and a `BridgeChannel` likewise always
546 // binds a real record in production.
547 let Some(rec) = self.get_record(record_name).await else {
548 return Ok(());
549 };
550 let instance = rec.read().await;
551 // Read-only / SPC_NOMOD field, through the one gate owner
552 // ([`check_no_mod`]). C tests SPC_ATTRIBUTE *before* `disp`
553 // (iocsource.cpp:365-369), so a read-only field on a DISP=1 record
554 // reports S_db_noMod, not S_db_putDisabled; the two errors carry
555 // different wire text.
556 check_no_mod(&instance, &field_upper)?;
557 // DISP=1 blocks a put to any field except DISP itself — the shared
558 // gate owner, identical to the one the CA route crosses.
559 check_put_disabled(&instance, &field_upper)?;
560 Ok(())
561 }
562
563 /// pvxs `IOCSource::doPostProcessing`'s record-side terms
564 /// (`iocsource.cpp:397-403`): does a put to `record_name.field` drive a
565 /// processing cycle on its own?
566 ///
567 /// The QSRV group PUT asks this for a member whose write bypassed
568 /// [`Self::put_record_field_from_ca`] (a `+type:"proc"` trigger, or a
569 /// member that is `changing` but has no writable leaf), so that route
570 /// applies the SAME gate as a plain field put instead of processing
571 /// unconditionally. `false` for an unknown record — there is nothing to
572 /// process. Force (`record._options.process=true`) is the caller's term
573 /// and is not asked about here; see [`put_drives_processing_of`].
574 pub async fn put_drives_processing(&self, record_name: &str, field: &str) -> bool {
575 let field_upper = field.to_ascii_uppercase();
576 let Some(rec) = self.get_record(record_name).await else {
577 return false;
578 };
579 let instance = rec.read().await;
580 put_drives_processing_of(&instance, &field_upper)
581 }
582
583 async fn put_pv_inner(
584 &self,
585 name: &str,
586 value: EpicsValue,
587 acquire_gate: bool,
588 ) -> CaResult<()> {
589 let (base, field) = super::parse_pv_name(name);
590 let field = field.to_ascii_uppercase();
591
592 // Check simple PVs first
593 if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
594 pv.set(value).await;
595 return Ok(());
596 }
597
598 // Records — alias-aware (epics-base PR #336).
599 if let Some(rec) = self.get_record(base).await {
600 // `base` may be an alias; resolve to the canonical record
601 // name so scan-index updates target the right entry.
602 let canonical_base: String = self
603 .resolve_alias(base)
604 .await
605 .unwrap_or_else(|| base.to_string());
606 // advisory write gate (`dbScanLock` analogue) so a
607 // plain `put_pv` to a backing record cannot interleave
608 // with an atomic group transaction holding the same gate.
609 // Skipped when the caller already owns the gate.
610 let _record_gate = if acquire_gate {
611 Some(self.lock_record(&canonical_base).await)
612 } else {
613 None
614 };
615 let mut instance = rec.write().await;
616
617 // C `dbPut` refuses an SPC_NOMOD / SPC_ATTRIBUTE field before it
618 // converts anything (`dbAccess.c:1330-1332`). `put_pv` IS the
619 // `dbPut` analogue — it sits below `dbPutLink`, so this is what
620 // stops a record's OUT link from truncating a waveform's NELM.
621 // The refusal is returned to the caller; `write_out_link_value`
622 // (C `dbPutLink`) turns it into the writer's LINK/INVALID alarm.
623 check_no_mod(&instance, &field)?;
624
625 let request = dbput_request(&*instance.record, &field, value)?;
626
627 // Pre-write special hook (C EPICS dbPutSpecial pass=0).
628 // C `dbPut` runs it on EVERY entry path — dbPutField and
629 // dbPutLink alike (dbAccess.c) — so the OUT-link route
630 // through this body must call it too (motor's drive-field
631 // DMOV blink, motorRecord.cc:2582-2608, fires on put-links
632 // in C). A non-zero status aborts the put like C.
633 instance.record.special(&field, false)?;
634
635 // Capture the pre-put value so the metadata-cache
636 // invalidation (and the downstream `DBE_PROPERTY`
637 // emission) can be skipped when the put is a no-op —
638 // epics-base faac1df1.
639 let prev_value = instance.record.get_field(&field);
640 let old_nord = array_nord_before_put(&instance, &field);
641
642 // Link writes the record's `special()` makes itself (C runs them
643 // inside `dbPut`); executed below, once the record lock is released.
644 let mut special_actions = Vec::new();
645
646 // put_pv is C EPICS dbPut: write value + special/on_put.
647 // Does NOT post monitor events (use put_pv_and_post for that).
648 // Does NOT clear UDF or trigger processing.
649 use crate::server::record::CommonFieldPutResult;
650 let common_result = match request {
651 // C `dbAccess.c:1370-1372` — accept, write nothing, alarm.
652 PutRequest::EmptyIntoScalar => {
653 set_empty_request_alarm(&mut instance);
654 CommonFieldPutResult::NoChange
655 }
656 PutRequest::Write(value) => {
657 special_before_put(&mut instance, &field);
658 match instance.record.put_field(&field, value.clone()) {
659 Ok(()) => {
660 instance.record.on_put(&field);
661 // C `dbPut` (dbAccess.c:1399-1405) keeps the value
662 // it already stored but RETURNS the after-put
663 // `dbPutSpecial(paddr, 1)` status, skipping the
664 // field's monitor post and (in `dbPutField`) the
665 // `pp(TRUE)` process. `calcRecord::special` uses
666 // that to refuse an uncompilable CALC with
667 // S_db_badField, so the status must not be dropped.
668 special_after_put(&mut instance, &field, &mut special_actions)?
669 }
670 Err(CaError::FieldNotFound(_)) => {
671 instance.put_common_field(&field, value)?
672 }
673 Err(e) => return Err(e),
674 }
675 }
676 };
677
678 // C `dbPut` runs `dbPutSpecial(paddr, 1)` regardless of caller entry
679 // path, so a put via this internal route commits/writes the same
680 // `special()`-driven alarm the CA route does. State only — `put_pv`
681 // posts no monitors (its contract), so unlike the CA path these
682 // commit the alarm without the STAT/SEVR posts.
683 //
684 // compress SPC_RESET: `monitor()`'s `recGblResetAlarms` commits the
685 // born-UDF alarm (compressRecord.c:103).
686 if instance.record.special_commits_alarms(&field) {
687 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
688 }
689 // histogram SGNL SPC_MOD: `add_count` raises the inverted-limits
690 // alarm (histogramRecord.c:329-334). The port routes it through
691 // `nsta`/`nsev` (CBUG-F12 refused), so this monitor-less special
692 // path must commit it — check then reset — for the SOFT/INVALID to
693 // be observable, matching the process path.
694 if instance.record.special_checks_alarms(&field) {
695 let inst = &mut *instance;
696 inst.record.check_alarms(&mut inst.common);
697 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
698 }
699
700 // Invalidate metadata cache only if the metadata-class
701 // field's value actually changed (faac1df1).
702 instance.notify_field_written_if_changed(&field, prev_value.as_ref());
703
704 // The one post this body makes. `put_pv` is the `dbPutLink` route's
705 // `dbPut`, and C's `put_array_info` is reached from every `dbPut` —
706 // an OUT link that shortens a waveform posts NORD in C even when the
707 // link is NPP and the target never processes. The value-field post
708 // stays absent here (C suppresses it for a `pp(TRUE)` value field,
709 // and the port's other callers of `put_pv` rely on the process cycle
710 // for it); NORD has no such second path.
711 post_array_info(&mut instance, &old_nord, 0);
712
713 // The record lock must be down before the scan-index update and
714 // before the `special()` link writes below, which re-enter the
715 // database (they can process their target).
716 drop(instance);
717
718 // Update scan index if SCAN or PHAS changed
719 match common_result {
720 CommonFieldPutResult::ScanChanged {
721 old_scan,
722 new_scan,
723 phas,
724 } => {
725 self.update_scan_index(&canonical_base, old_scan, new_scan, phas, phas)
726 .await;
727 }
728 CommonFieldPutResult::PhasChanged {
729 scan: s,
730 old_phas,
731 new_phas,
732 } => {
733 self.update_scan_index(&canonical_base, s, s, old_phas, new_phas)
734 .await;
735 }
736 CommonFieldPutResult::NoChange => {}
737 }
738
739 // C `dbPut` runs `dbPutSpecial(paddr, 1)` to completion — the
740 // `dbPutLink` calls a `special()` makes included — before it returns
741 // to `dbPutField`. This is the last statement of the `dbPut`
742 // analogue, so it is that point.
743 self.run_special_actions(&canonical_base, &rec, special_actions)
744 .await;
745
746 // mirror the CA-write path's ASG-field notifier so
747 // restore scripts / autosave / admin tools that go via
748 // `put_pv` (not `put_record_field_from_ca`) also trigger
749 // per-client `reeval_access_rights`. C `dbAccess.c::
750 // dbPutSpecial` invokes the SPC_AS callback from dbPut
751 // regardless of caller entry path.
752 if field == "ASG" {
753 crate::server::access_security::notify_asg_field_changed();
754 }
755
756 return Ok(());
757 }
758
759 Err(CaError::ChannelNotFound(name.to_string()))
760 }
761
762 /// Write a value and post monitor events if changed.
763 /// Equivalent to C EPICS `dbPut` + `db_post_events(DBE_VALUE|DBE_LOG)`.
764 ///
765 /// Use for readback/status mirror PVs that are written by sequencer-style
766 /// code and need to be visible to CA monitors without triggering record
767 /// processing. Clears UDF/UDF_ALARM on primary field write.
768 ///
769 /// `origin`: writer ID for self-write filtering. Subscribers with the
770 /// same `ignore_origin` will skip this event. Pass 0 to disable.
771 pub async fn put_pv_and_post(&self, name: &str, value: EpicsValue) -> CaResult<()> {
772 self.put_pv_and_post_with_origin(name, value, 0).await
773 }
774
775 /// Push a monitor event holding the simple PV's *current* value
776 /// but with explicit alarm severity/status. Used by the gateway
777 /// to surface upstream-disconnect to downstream monitor
778 /// subscribers without dropping the shadow PV (which would force
779 /// downstream clients into ECA_DISCONN reconnect storms on every
780 /// transient hiccup). Returns `ChannelNotFound` for record-backed
781 /// PVs — those carry their own `common.sevr/stat` in record
782 /// processing.
783 pub async fn post_alarm(&self, name: &str, severity: u16, status: u16) -> CaResult<()> {
784 if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
785 pv.post_alarm(severity, status).await;
786 return Ok(());
787 }
788 Err(crate::error::CaError::ChannelNotFound(name.to_string()))
789 }
790
791 /// Propagate a full upstream snapshot (value + alarm status/severity +
792 /// IOC timestamp) to a simple shadow PV and fan out to downstream
793 /// monitor subscribers. Used by the CA gateway forwarding task to avoid
794 /// discarding the upstream alarm and timestamp decoded from the incoming
795 /// `DBR_TIME_*` frame. Returns `ChannelNotFound` for record-backed PVs
796 /// (those carry their own alarm engine and are not shadow PVs).
797 pub async fn put_pv_and_post_snapshot(&self, name: &str, snapshot: Snapshot) -> CaResult<()> {
798 if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
799 pv.set_snapshot(snapshot).await;
800 return Ok(());
801 }
802 Err(CaError::ChannelNotFound(name.to_string()))
803 }
804
805 /// Install upstream `DBR_CTRL_*` metadata (display / control limits,
806 /// enum labels) on a shadow simple PV WITHOUT posting an event.
807 ///
808 /// The CA gateway calls this once on upstream connect, after its initial
809 /// `DBR_CTRL_*` get, so a later downstream `DBR_CTRL_*` / `DBR_GR_*` read
810 /// returns the real limits instead of zeroed ones. No `DBE_PROPERTY`
811 /// monitor event fires — nothing has *changed* yet, this only seeds the
812 /// attribute cache. Mirrors C `gatePvData::getCB` → `runDataCB` →
813 /// `vc->setPvData(dd)` (`gatePv.cc:1693-1695`), which seeds the property
814 /// cache from the initial control get in both cache modes before any
815 /// monitor is enabled.
816 ///
817 /// Returns `ChannelNotFound` for record-backed PVs — those own their own
818 /// metadata via record processing and are not gateway shadow PVs.
819 pub async fn set_pv_metadata(&self, name: &str, snapshot: &Snapshot) -> CaResult<()> {
820 if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
821 pv.set_metadata(metadata_from_snapshot(snapshot));
822 return Ok(());
823 }
824 Err(CaError::ChannelNotFound(name.to_string()))
825 }
826
827 /// Refresh a shadow simple PV's upstream metadata AND post a
828 /// `DBE_PROPERTY` monitor event carrying `snapshot` to downstream
829 /// property subscribers.
830 ///
831 /// `snapshot` is the decoded upstream `DBR_CTRL_*` property event: it
832 /// carries the control value and the upstream `status` / `severity`,
833 /// and (because control DBR structs carry no timestamp) an undefined
834 /// timestamp the caller must NOT replace with a fresh wall-clock. The
835 /// gateway's property monitor calls this on every upstream
836 /// `DBE_PROPERTY` event, mirroring C `gatePvData::propEventCB` →
837 /// `runDataCB` + `setPvData` + `runValueDataCB` +
838 /// `vcPostEvent(propertyEventMask())` (`gatePv.cc:1571-1607`): the
839 /// attribute cache is refreshed and a property event is posted with the
840 /// upstream alarm state preserved (`setStatSevr`) and the undefined
841 /// control-DBR timestamp left as-is (`gatePv.cc:1594-1595`).
842 ///
843 /// Returns `ChannelNotFound` for record-backed PVs.
844 pub async fn post_pv_property(&self, name: &str, snapshot: Snapshot) -> CaResult<()> {
845 if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
846 pv.set_metadata(metadata_from_snapshot(&snapshot));
847 pv.post_property(snapshot).await;
848 return Ok(());
849 }
850 Err(CaError::ChannelNotFound(name.to_string()))
851 }
852
853 /// Like `put_pv_and_post` but with explicit origin tag.
854 pub async fn put_pv_and_post_with_origin(
855 &self,
856 name: &str,
857 value: EpicsValue,
858 origin: u64,
859 ) -> CaResult<()> {
860 let (base, field) = super::parse_pv_name(name);
861 let field = field.to_ascii_uppercase();
862
863 // Simple-PV path: PVs registered via `add_pv` (e.g. CA gateway
864 // shadow PVs, IOCsh stats PVs) are stored in `simple_pvs`,
865 // not `records`. Without this branch the function would
866 // silently return `ChannelNotFound` for every gateway-mirrored
867 // PV — `ProcessVariable::set` already does the
868 // notify-subscribers fan-out internally so all we need here is
869 // to delegate. The `origin` tag is a no-op for simple PVs
870 // because they don't yet plumb origin through `set`.
871 if let Some(pv) = self.inner.simple_pvs.read().await.get(name).cloned() {
872 let _ = origin; // simple PVs don't currently honor origin tagging
873 pv.set(value).await;
874 return Ok(());
875 }
876
877 if let Some(rec) = self.get_record(base).await {
878 // `put_pv_and_post` is a public record-write API —
879 // it must take the same advisory write gate
880 // (`dbScanLock` analogue) as `put_pv` /
881 // `put_record_field_from_ca`, or a gateway/sequencer
882 // write through this helper can still land between the
883 // member writes of a QSRV atomic group or a pvalink
884 // atomic scan epoch holding `lock_records`. `base` is
885 // alias-resolved to the canonical record name so an alias
886 // and its target share one gate. Held until return.
887 let canonical_base: String = self
888 .resolve_alias(base)
889 .await
890 .unwrap_or_else(|| base.to_string());
891 let _record_gate = self.lock_record(&canonical_base).await;
892
893 let mut instance = rec.write().await;
894
895 // Same `dbPut` gate as `put_pv` — this is the third `dbPut` body
896 // (value + monitor post), and C has ONE.
897 check_no_mod(&instance, &field)?;
898
899 let request = dbput_request(&*instance.record, &field, value)?;
900
901 // Pre-write special hook (C EPICS dbPutSpecial pass=0) —
902 // C `dbPut` runs it on every entry path; this is the third
903 // `dbPut` body and must match the other two.
904 instance.record.special(&field, false)?;
905
906 let old_value = instance.record.get_field(&field);
907 let old_stat = instance.common.stat;
908 let old_sevr = instance.common.sevr;
909 let old_nord = array_nord_before_put(&instance, &field);
910
911 // Link writes the record's `special()` makes itself (C runs them
912 // inside `dbPut`); executed below, once the record lock is released.
913 let mut special_actions = Vec::new();
914
915 // Write value + special/on_put
916 use crate::server::record::CommonFieldPutResult;
917 let common_result = match request {
918 // C `dbAccess.c:1370-1372` — accept, write nothing, alarm. UDF
919 // is NOT cleared: C clears it at `:1409` only when the value
920 // field was actually written, and this branch wrote nothing.
921 PutRequest::EmptyIntoScalar => {
922 set_empty_request_alarm(&mut instance);
923 CommonFieldPutResult::NoChange
924 }
925 PutRequest::Write(value) => {
926 special_before_put(&mut instance, &field);
927 match instance.record.put_field(&field, value.clone()) {
928 Ok(()) => {
929 instance.record.on_put(&field);
930 // C returns the after-put special() status from
931 // `dbPut` (dbAccess.c:1399-1405) — before the UDF
932 // clear and the monitor post below, both of which
933 // `goto done` skips on a non-zero status.
934 let result =
935 special_after_put(&mut instance, &field, &mut special_actions)?;
936 // C `dbAccess.c::dbPut:1411` clears ONLY `precord->udf
937 // = FALSE` on a value-field put, and nothing else. It
938 // does NOT touch stat/sevr: the UDF_ALARM stays until
939 // the record's own process cycle recomputes it
940 // (`rec_gbl_check_udf` no longer raises it now udf is
941 // clear, `rec_gbl_reset_alarms` commits the new state).
942 // A value put that does not drive a process therefore
943 // leaves the stale UDF alarm exactly as C does — the
944 // earlier synchronous stat/sevr clear here diverged
945 // from C and reported NO_ALARM where C keeps UDF/INVALID.
946 if instance.record.is_udf_defining_put(&field) {
947 instance.common.udf = 0;
948 }
949 result
950 }
951 Err(CaError::FieldNotFound(_)) => {
952 instance.put_common_field(&field, value)?
953 }
954 Err(e) => return Err(e),
955 }
956 }
957 };
958
959 // Invalidate metadata cache only if a metadata-class
960 // field actually changed value (faac1df1 — DBE_PROPERTY
961 // fires on real changes, not no-op writes).
962 instance.notify_field_written_if_changed(&field, old_value.as_ref());
963
964 // Post monitor events if value or alarm changed
965 let new_value = instance.record.get_field(&field);
966 let value_changed = old_value != new_value;
967 let alarm_changed =
968 old_stat != instance.common.stat || old_sevr != instance.common.sevr;
969 let nord_changed = old_nord.is_some() && instance.record.get_field("NORD") != old_nord;
970 if value_changed || alarm_changed || nord_changed {
971 // Update timestamp so the snapshot carries current time
972 instance.common.time = crate::runtime::general_time::get_current();
973 instance.cleanup_subscribers();
974 if value_changed || alarm_changed {
975 instance.notify_field_with_origin(
976 &field,
977 crate::server::recgbl::EventMask::VALUE
978 | crate::server::recgbl::EventMask::LOG
979 | crate::server::recgbl::EventMask::ALARM,
980 origin,
981 );
982 }
983 // The NORD post, through the one owner. Without it a CA
984 // gateway forwarding upstream waveform monitors via
985 // put_pv_and_post would update VAL on the shadow PV but
986 // leave downstream NORD subscribers stuck at their last
987 // seen length — a frozen-element-count bug that surfaces
988 // in PyDM image views and similar consumers that compute
989 // height = element_count / width.
990 post_array_info(&mut instance, &old_nord, origin);
991 }
992
993 // The `special()` link writes re-enter the database, so the record
994 // lock goes down first. C makes them inside `dbPut`, before it
995 // returns to its caller.
996 drop(instance);
997
998 // Same scan-index owner every other `dbPut` path routes through:
999 // a SCAN put and the SIMM↔SSCN swap (`recGblCheckSimm`) both move
1000 // the record between scan lists and must reach `update_scan_index`.
1001 match common_result {
1002 CommonFieldPutResult::ScanChanged {
1003 old_scan,
1004 new_scan,
1005 phas,
1006 } => {
1007 self.update_scan_index(&canonical_base, old_scan, new_scan, phas, phas)
1008 .await;
1009 }
1010 CommonFieldPutResult::PhasChanged {
1011 scan: s,
1012 old_phas,
1013 new_phas,
1014 } => {
1015 self.update_scan_index(&canonical_base, s, s, old_phas, new_phas)
1016 .await;
1017 }
1018 CommonFieldPutResult::NoChange => {}
1019 }
1020
1021 self.run_special_actions(&canonical_base, &rec, special_actions)
1022 .await;
1023
1024 // same SPC_AS parity as `put_pv` / `put_pv_no_process`
1025 // / the CA-write path — a gateway mirroring `.ASG` via
1026 // `put_pv_and_post` must still trigger per-client
1027 // re-eval.
1028 if field == "ASG" {
1029 crate::server::access_security::notify_asg_field_changed();
1030 }
1031
1032 return Ok(());
1033 }
1034
1035 Err(CaError::ChannelNotFound(name.to_string()))
1036 }
1037
1038 /// Execute the link writes a record's `special()` queued
1039 /// ([`Record::take_special_actions`](crate::server::record::Record::take_special_actions)).
1040 ///
1041 /// The single consumer: every `dbPut` path in this module calls it once, at
1042 /// the end of the put and before any `pp(TRUE)` process cycle, which is
1043 /// where C runs them (`dbPut` → `dbPutSpecial(paddr, 1)` → `dbPutLink`,
1044 /// with `dbProcess` still ahead in `dbPutField`). The put is the root of the
1045 /// chain these writes start, so they get a fresh visited set, exactly like a
1046 /// client put entering `process_record_with_links`.
1047 ///
1048 /// Must be called with no record lock held: a `WriteDbLink` can process its
1049 /// target, which re-enters the database.
1050 async fn run_special_actions(
1051 &self,
1052 record_name: &str,
1053 rec: &std::sync::Arc<crate::runtime::sync::RwLock<crate::server::record::RecordInstance>>,
1054 actions: Vec<crate::server::record::ProcessAction>,
1055 ) {
1056 if actions.is_empty() {
1057 return;
1058 }
1059 let mut visited = HashSet::new();
1060 // A `WriteDbLink` here can land back in a `dbPut` (its target's), which
1061 // is the function that called us: the async cycle needs one boxed edge.
1062 Box::pin(self.execute_process_actions(record_name, rec, actions, &mut visited, 0)).await;
1063 }
1064
1065 /// CA client's unified entry point for record field put.
1066 /// Handles DISP/PROC/PACT/LCNT checks, field put, device write, and Passive process.
1067 ///
1068 /// Acquires the record's advisory write gate
1069 /// (`dbScanLock` analogue) for the duration of the write.
1070 pub async fn put_record_field_from_ca(
1071 &self,
1072 record_name: &str,
1073 field: &str,
1074 value: EpicsValue,
1075 ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
1076 self.put_record_field_from_ca_inner(record_name, field, value, true, NotifyRequest::New)
1077 .await
1078 }
1079
1080 /// Variant for a caller that already owns the target
1081 /// record's advisory write gate — the QSRV atomic group PUT,
1082 /// which acquired every member-record gate up-front via
1083 /// [`Self::lock_records`]. The per-record `tokio::sync::Mutex`
1084 /// gate is NOT reentrant, so the atomic group path MUST use this
1085 /// `_already_locked` entry to avoid dead-locking on its own
1086 /// `ManyRecordWriteGuard`.
1087 pub async fn put_record_field_from_ca_already_locked(
1088 &self,
1089 record_name: &str,
1090 field: &str,
1091 value: EpicsValue,
1092 ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
1093 self.put_record_field_from_ca_inner(record_name, field, value, false, NotifyRequest::New)
1094 .await
1095 }
1096
1097 /// Fire-and-forget variant — C `dbPutField` semantics: the put
1098 /// processes the record but creates NO put-notify wait-set (C
1099 /// builds a `putNotify` only in `dbPutNotify`, i.e. for
1100 /// WRITE_NOTIFY). A caller that does not await the returned
1101 /// receiver MUST use this entry: parking a wait-set whose receiver
1102 /// is dropped occupies `RecordInstance::notify` until the record's
1103 /// async work ends (a motor's whole motion), failing every
1104 /// legitimate WRITE_NOTIFY on the record with ECA_PUTCBINPROG in
1105 /// the meantime.
1106 pub async fn put_record_field_from_ca_no_notify(
1107 &self,
1108 record_name: &str,
1109 field: &str,
1110 value: EpicsValue,
1111 ) -> CaResult<()> {
1112 self.put_record_field_from_ca_inner(record_name, field, value, true, NotifyRequest::None)
1113 .await
1114 .map(|_| ())
1115 }
1116
1117 /// C `dbPut`'s alarm-acknowledge interception (`dbAccess.c:1331-1335`) —
1118 /// the ONLY route that may change ACKS/ACKT at runtime.
1119 ///
1120 /// ```c
1121 /// if (dbrType == DBR_PUT_ACKT && field_type <= DBF_DEVICE)
1122 /// return putAckt(paddr, pbuffer, 1, 1, 0);
1123 /// else if (dbrType == DBR_PUT_ACKS && field_type <= DBF_DEVICE)
1124 /// return putAcks(paddr, pbuffer, 1, 1, 0);
1125 /// ```
1126 ///
1127 /// The dispatch is on the DBR *request type*, not on the field: a CA client
1128 /// acknowledges by sending `DBR_PUT_ACKS` down its ordinary `REC` (VAL)
1129 /// channel. It sits ABOVE the `SPC_NOMOD` gate, which is why
1130 /// `caput REC.ACKS 2` is refused by C ("Write access denied", verified on
1131 /// softIoc 7.0.10) while `ca_put(DBR_PUT_ACKS, REC)` clears the alarm.
1132 ///
1133 /// The put-disable gate is still crossed: C tests `precord->disp` in
1134 /// `dbPutField`, above `dbPut` (`dbAccess.c:1255-1257`), so an ack to a
1135 /// `DISP=1` record is refused. `field` is the channel's field — only the
1136 /// DISP gate looks at it, exactly as in C.
1137 ///
1138 /// No process cycle: `dbPut` returns straight from `putAckt`/`putAcks`, and
1139 /// `dbPutField`'s reprocess condition requires `dbrType < DBR_PUT_ACKT`.
1140 pub async fn put_alarm_ack_from_ca(
1141 &self,
1142 record_name: &str,
1143 field: &str,
1144 ack: crate::server::record::AlarmAck,
1145 value: u16,
1146 ) -> CaResult<()> {
1147 let field_upper = field.to_ascii_uppercase();
1148 let rec = self
1149 .get_record(record_name)
1150 .await
1151 .ok_or_else(|| CaError::ChannelNotFound(record_name.to_string()))?;
1152 let canonical: String = self
1153 .resolve_alias(record_name)
1154 .await
1155 .unwrap_or_else(|| record_name.to_string());
1156 let _record_gate = self.lock_record(&canonical).await;
1157
1158 let mut instance = rec.write().await;
1159 check_put_disabled(&instance, &field_upper)?;
1160 match ack {
1161 crate::server::record::AlarmAck::Transient => instance.put_ackt(value),
1162 crate::server::record::AlarmAck::Severity => instance.put_acks(value),
1163 }
1164 Ok(())
1165 }
1166
1167 /// Fire-and-forget + caller-held gate: see
1168 /// [`Self::put_record_field_from_ca_no_notify`] and
1169 /// [`Self::put_record_field_from_ca_already_locked`].
1170 pub async fn put_record_field_from_ca_no_notify_already_locked(
1171 &self,
1172 record_name: &str,
1173 field: &str,
1174 value: EpicsValue,
1175 ) -> CaResult<()> {
1176 self.put_record_field_from_ca_inner(record_name, field, value, false, NotifyRequest::None)
1177 .await
1178 .map(|_| ())
1179 }
1180
1181 /// Process a record UNCONDITIONALLY with a put-notify wait-set, returning
1182 /// the completion receiver — the QSRV `record[process=true,block=true]`
1183 /// (Force + block) barrier.
1184 ///
1185 /// C `dbProcessNotify`: pvxs routes a blocking forced put through
1186 /// `dbProcessNotify` (`singlesource.cpp:360-369`), whose completion fires
1187 /// only after the record's whole processing chain — including async device
1188 /// work (a motor move, an asyn-backed AO) — settles. The value is written
1189 /// by the caller's preceding [`Self::put_pv`] (the `dbPut` analogue, no
1190 /// process); this entry then mints the wait-set, registers it into the
1191 /// record's `notify` slot so PACT records join it, and runs the full
1192 /// unconditional [`Self::process_record_with_links`] cycle (C `dbProcess`,
1193 /// the Force analogue). A fully synchronous chain returns `Ok(None)` (the
1194 /// wait-set already drained); an async record returns `Ok(Some(rx))` for
1195 /// the caller to await. A concurrent put-callback already in flight on the
1196 /// record is rejected with `PutCallbackInProgress`, matching the PROC path.
1197 pub async fn process_record_with_notify(
1198 &self,
1199 record_name: &str,
1200 ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
1201 let (completion_tx, completion_rx) = crate::runtime::sync::oneshot::channel();
1202 let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
1203 {
1204 // Collect-then-act: clone the handle under a brief map read, drop
1205 // the map lock before taking the per-record write lock.
1206 let rec_arc = {
1207 let recs = self.inner.records.read().await;
1208 recs.get(record_name).cloned()
1209 };
1210 let Some(rec_arc) = rec_arc else {
1211 return Err(CaError::ChannelNotFound(record_name.to_string()));
1212 };
1213 let mut guard = rec_arc.write().await;
1214 if guard.notify.is_some() {
1215 return Err(CaError::PutCallbackInProgress(record_name.to_string()));
1216 }
1217 guard.notify = Some(notify.clone());
1218 }
1219 let mut visited = HashSet::new();
1220 self.process_record_with_links(record_name, &mut visited, 0)
1221 .await?;
1222 // The wait-set fires the oneshot only after the whole FLNK/OUT chain
1223 // (sync + async) settles. Already-completed ⟹ fully synchronous ⟹
1224 // report immediate success; otherwise hand the receiver back to await
1225 // the deferred async completion.
1226 if notify.completed() {
1227 Ok(None)
1228 } else {
1229 Ok(Some(completion_rx))
1230 }
1231 }
1232
1233 /// C `dbPutField`'s put-driven process decision (`dbAccess.c:1264-1277`).
1234 ///
1235 /// Reached once the put has selected the record for processing — the `PROC`
1236 /// field, or a `pp(TRUE)` field on a Passive record. C then splits on PACT:
1237 ///
1238 /// * **async-active** — C sets `rpro = TRUE` and does NOT call `dbProcess`.
1239 /// `recGblFwdLink` (`recGbl.c:296-300`) consumes RPRO when the device
1240 /// round trip completes and queues `scanOnce`, so the value this put just
1241 /// wrote still reaches the device, one cycle later. Calling `dbProcess`
1242 /// here instead lands in dbProcess's own PACT guard, which bumps LCNT and
1243 /// after MAX_LOCK raises SCAN_ALARM — an alarm C never raises for a client
1244 /// put — while dropping the deferred reprocess entirely: on two rapid
1245 /// puts to a Passive async output, C writes both values to the device and
1246 /// the port wrote only the first.
1247 /// * **idle** — C sets `putf = TRUE` (the put-driven marker, cleared at the
1248 /// tail of the process cycle / in `complete_async_record_inner`, both the
1249 /// `recGblFwdLink:302` analogue) and calls `dbProcess`.
1250 ///
1251 /// Single owner of that decision for every external put: the `PROC`
1252 /// intercept and the `pp`-field route in
1253 /// [`Self::put_record_field_from_ca`] both go through it, so neither can
1254 /// drift from C's rule or from each other. The DB-link propagation path
1255 /// applies the same PACT→RPRO rule at its own targets (`processing.rs:3225`,
1256 /// `:4220`, `links.rs:829`).
1257 ///
1258 /// Two entries, one rule. `put_driven_process` acquires `record_name`'s
1259 /// advisory write gate (the `dbScanLock` analogue) itself;
1260 /// [`Self::put_driven_process_already_locked`] is for a caller that
1261 /// already owns it — `_record_gate` on the CA put path, or the QSRV
1262 /// atomic group's `lock_records` epoch. The gate `Mutex` is not
1263 /// reentrant, so a caller holding it MUST take the `_already_locked`
1264 /// entry.
1265 ///
1266 /// QSRV's group PUT is the second external caller (pvxs
1267 /// `IOCSource::doPostProcessing`, `iocsource.cpp:397-420`, whose PACT
1268 /// branch is this same RPRO deferral): it reaches the decision by its own
1269 /// route — `record._options.process`, a `+type:"proc"` member, a `pp` field
1270 /// — but once the answer is "process", the transition is this owner's, in
1271 /// both gate modes.
1272 /// The PACT (RPRO) branch is success, as it is in C: `dbPutField` returns
1273 /// the `dbProcess` status only on the branch that ran it.
1274 pub async fn put_driven_process(&self, record_name: &str) -> CaResult<()> {
1275 self.put_driven_process_inner(record_name, true).await
1276 }
1277
1278 /// [`Self::put_driven_process`] for a caller that already owns the
1279 /// record's advisory write gate.
1280 pub async fn put_driven_process_already_locked(&self, record_name: &str) -> CaResult<()> {
1281 self.put_driven_process_inner(record_name, false).await
1282 }
1283
1284 async fn put_driven_process_inner(
1285 &self,
1286 record_name: &str,
1287 acquire_gate: bool,
1288 ) -> CaResult<()> {
1289 {
1290 let Some(rec) = self.get_record(record_name).await else {
1291 return Ok(());
1292 };
1293 let mut instance = rec.write().await;
1294 if instance.is_processing() {
1295 instance.common.rpro = 1;
1296 return Ok(());
1297 }
1298 instance.common.putf = true;
1299 }
1300 let mut visited = HashSet::new();
1301 if acquire_gate {
1302 self.process_record_with_links(record_name, &mut visited, 0)
1303 .await
1304 } else {
1305 self.process_record_with_links_already_locked(record_name, &mut visited, 0)
1306 .await
1307 }
1308 }
1309
1310 /// C `dbNotifyCompletion` → restart (dbNotify.c:207-231, state
1311 /// `notifyRestartInProgress`): replay a put-notify that landed on a PACT
1312 /// record, now that the record is idle. The single owner that consumes a
1313 /// [`DeferredNotifyPut`]; called only from the async-completion tail.
1314 ///
1315 /// The replay goes back through the ordinary put entry, so if the record
1316 /// has ALREADY gone active again (a scan fired between the completion and
1317 /// this replay), the same PACT test defers it once more rather than writing
1318 /// into a busy record — the deferral is closed under its own restart.
1319 pub(crate) async fn restart_deferred_notify_put(
1320 &self,
1321 record_name: &str,
1322 put: crate::server::record::DeferredNotifyPut,
1323 ) {
1324 let crate::server::record::DeferredNotifyPut {
1325 field,
1326 value,
1327 completion,
1328 } = put;
1329 // The client already holds the receiver; a failure here (record gone,
1330 // field refused) must still release it, which dropping the sender does.
1331 let _ = self
1332 .put_record_field_from_ca_inner(
1333 record_name,
1334 &field,
1335 value,
1336 true,
1337 NotifyRequest::Deferred(completion),
1338 )
1339 .await;
1340 }
1341
1342 async fn put_record_field_from_ca_inner(
1343 &self,
1344 record_name: &str,
1345 field: &str,
1346 value: EpicsValue,
1347 acquire_gate: bool,
1348 notify_request: NotifyRequest,
1349 ) -> CaResult<Option<crate::runtime::sync::oneshot::Receiver<()>>> {
1350 let field = field.to_ascii_uppercase();
1351 let want_notify = notify_request.wants_notify();
1352
1353 // Get record Arc — alias-aware (epics-base PR #336) so a CA
1354 // client that connects via an alias name can put fields on
1355 // the canonical record.
1356 let rec = self
1357 .get_record(record_name)
1358 .await
1359 .ok_or_else(|| CaError::ChannelNotFound(record_name.to_string()))?;
1360 // Normalise to the canonical name for the rest of this
1361 // function — every subsequent call (PACT/LCNT lookup,
1362 // `process_record_with_links`, `update_scan_index`) uses the
1363 // raw records map and would miss when `record_name` is an
1364 // alias. Resolve once up front.
1365 let canonical_owned;
1366 let record_name: &str = if let Some(target) = self.resolve_alias(record_name).await {
1367 canonical_owned = target;
1368 &canonical_owned
1369 } else {
1370 record_name
1371 };
1372
1373 // take the record's advisory write gate — the
1374 // `dbScanLock(precord)` analogue. While a QSRV atomic group
1375 // PUT/GET holds this record's gate via `lock_records`, this
1376 // plain write blocks here, so a direct backing-record write
1377 // can no longer land between member writes of an atomic group
1378 // transaction. Held until the function returns. Skipped when
1379 // the caller (atomic group PUT) already owns the gate — the
1380 // gate `Mutex` is not reentrant.
1381 let _record_gate = if acquire_gate {
1382 Some(self.lock_record(record_name).await)
1383 } else {
1384 None
1385 };
1386
1387 // Special field intercepts (read lock, then drop)
1388 {
1389 let instance = rec.read().await;
1390
1391 // C `dbPutField` gate order (`dbAccess.c:1252-1277`): the DISP
1392 // put-disable gate runs BEFORE `dbPut` — hence before the
1393 // SPC_NOMOD rejection of PACT/LCNT/PUTF (`dbAccess.c:123`) — and
1394 // BEFORE the PROC-driven `dbProcess`. So on a `DISP=1` record
1395 // EVERY non-DISP field, PROC included, is refused with
1396 // `S_db_putDisabled` and the record does not process.
1397 check_put_disabled(&instance, &field)?;
1398
1399 // SPC_NOMOD / read-only fields: rejected inside C's `dbPut`, i.e.
1400 // after the DISP gate above and before the PROC-driven process
1401 // below. One gate owner for every route ([`check_no_mod`]).
1402 check_no_mod(&instance, &field)?;
1403 }
1404
1405 // C `aSubRecord.c::special` / `subRecord.c::special` (SPC_MOD on SNAM):
1406 // the put owner resolves the subroutine name against the registry — the
1407 // record's `special()` cannot, having no DB handle. A non-empty,
1408 // unregistered name will make the after-put `special()` refuse the
1409 // write (`S_db_BadSub` → `ECA_PUTFAIL`) AFTER the value is stored, so
1410 // the lookup is done up front here and its verdict applied inside the
1411 // write below. An empty name names no routine and is accepted.
1412 let snam_registry_reject = {
1413 let name_to_resolve: Option<String> = {
1414 let guard = rec.read().await;
1415 if guard.record.is_subroutine_name_field(&field) {
1416 match &value {
1417 EpicsValue::String(s) => {
1418 let name = s.as_str_lossy();
1419 (!name.is_empty()).then(|| name.into_owned())
1420 }
1421 _ => None,
1422 }
1423 } else {
1424 None
1425 }
1426 };
1427 match name_to_resolve {
1428 Some(name) => self.find_subroutine_named(&name).await.is_none(),
1429 None => false,
1430 }
1431 };
1432
1433 // C `processNotifyCommon` (dbNotify.c:225-231) tests PACT ABOVE the
1434 // put — `if (precord->pact) { ... pnotify->state =
1435 // notifyRestartCallbackRequested; ... return; }` — so a put-notify that
1436 // lands on a busy record writes NOTHING: no value, no RPRO, no join of
1437 // the in-flight cycle's wait-set. The whole put is replayed by the
1438 // `PactExit` the record's PACT release hands to its `recGblFwdLink`
1439 // tail (C `dbNotifyCompletion`). Joining the running cycle instead
1440 // completed the callback one cycle early, on work that never saw this
1441 // value.
1442 //
1443 // The PACT test and the park are ONE critical section (C holds
1444 // `dbScanLock` across both): a park on a record that left PACT in
1445 // between would sit in a slot no PACT release will ever take, which is
1446 // precisely the strand the `PactExit` invariant forbids. A record that
1447 // goes idle in the window falls through and takes the put the ordinary
1448 // way.
1449 //
1450 // A second put-notify onto a record that already owns one is C's
1451 // "another processNotify owns the record" (dbNotify.c:213-217); the port
1452 // reports it to the client as `PutCallbackInProgress` (C `S_db_Blocked`
1453 // / `ECA_PUTCBINPROG`) rather than queueing a restart list.
1454 //
1455 // A fire-and-forget `dbPutField` is NOT deferred: it writes and raises
1456 // RPRO (dbAccess.c:1263-1277). Only the notify route waits.
1457 //
1458 // C `dbProcessNotify` (dbNotify.c:337-353) handles a put-notify to a
1459 // DBF link field (INLINK/OUTLINK/FWDLINK) as a dedicated early case,
1460 // ABOVE the PACT logic and the whole `processNotifyCommon` machinery:
1461 // "Only dbPutField will change link fields. Also the record is not
1462 // processed as a result." It writes the value via `dbPutField`
1463 // (`putFieldType`) and fires the done callback IMMEDIATELY — it never
1464 // reaches the PACT test, never processes, never defers. So a link
1465 // field always takes the value even on a busy or permanently-parked
1466 // record: a bare `sub` (empty `SNAM`) parks PACT=TRUE forever
1467 // (subRecord.c:119-122), and parking its link-field put on a `PactExit`
1468 // that never comes drops the value — `caput <sub>.INPA '0'` then reads
1469 // back "" instead of C's "0". The ordinary write path below already
1470 // reproduces C's link semantics for these fields (writes the value;
1471 // `put_drives_processing_of` is false — no link field is `pp` or PROC —
1472 // so it processes nothing and returns immediate completion), so the
1473 // only correction the special case needs is to keep a link field OUT
1474 // of the notify PACT-defer park.
1475 let is_dbf_link_field = {
1476 let guard = rec.read().await;
1477 crate::types::dbf_link_class(guard.record.record_type(), &field).is_some()
1478 };
1479 if want_notify && !is_dbf_link_field {
1480 let mut guard = rec.write().await;
1481 if guard.is_processing() {
1482 let Some((completion, completion_rx)) = notify_request.into_completion() else {
1483 // Unreachable: `want_notify` is exactly "this request
1484
1485 // carries a completion".
1486 return Ok(None);
1487 };
1488 guard
1489 .park_notify_put(crate::server::record::DeferredNotifyPut {
1490 field,
1491 value,
1492 completion,
1493 })
1494 .map_err(|_| CaError::PutCallbackInProgress(record_name.to_string()))?;
1495 return Ok(completion_rx);
1496 }
1497 }
1498
1499 // PROC intercept: trigger processing on any SCAN.
1500 // Falls through to the put_notify_tx registration below
1501 // so async records (motor, asyn-backed AO) signal real
1502 // completion; otherwise WRITE_NOTIFY would return ECA_NORMAL
1503 // before the device move actually finished.
1504 //
1505 // C `dbPutField` (dbAccess.c:1265) matches the proc field by pointer
1506 // with NO value check: any write to PROC — including 0 — processes the
1507 // record (when !pact). The standard `caput REC.PROC 0` / `dbpf REC.PROC
1508 // 0` force-process idiom must therefore not be skipped for a zero value.
1509 if field == "PROC" {
1510 // C `dbCommon.dbd` declares `field(PROC,DBF_UCHAR){ pp(TRUE) }`, so
1511 // a put to PROC does BOTH: `dbPut` stores the raw byte in
1512 // `prec->proc` (retained — C never resets it), AND `pp(TRUE)` drives
1513 // the reprocess below. The prior port kept only the reprocess and
1514 // dropped the byte, so `caput REC.PROC v; caget REC.PROC` always
1515 // read 0. Store the byte through the SAME `DBF_UCHAR` common-field
1516 // path DISP/RPRO use (coercion + signed readback: `caput PROC 255` →
1517 // `caget` = -1) in its own brief write lock so both the notify and
1518 // fire-and-forget paths take it, then fall through to force-process.
1519 // C `dbPut:1408` posts DBE_VALUE|DBE_LOG for the put field (PROC is
1520 // not the record's value field, so the pp-suppression never applies).
1521 // Store the raw PROC byte (C `dbChannelPut`). A bad conversion
1522 // (`caput REC.PROC 256` / non-numeric) refuses the store AND the
1523 // client's put — but, exactly as C's `putCallback` returns
1524 // `didPut = 1` while setting `notifyError` (`dbNotify.c:528-530`),
1525 // the PROC `pp(TRUE)`-driven `dbProcess` (`dbNotify.c:243-261`) still
1526 // runs on the NOTIFY path. This is the SAME rule the general put path
1527 // applies for a rejected pp-field conversion (`field_io.rs:1748-1806`,
1528 // "Cause B"); mirror it here so PROC does not diverge from UDF: carry
1529 // the refusal, force-process when `want_notify`, then hand the Err
1530 // back so the client still sees `ECA_PUTFAIL`.
1531 let proc_store: CaResult<()> = {
1532 let rec_arc = {
1533 let recs = self.inner.records.read().await;
1534 recs.get(record_name).cloned()
1535 };
1536 if let Some(rec_arc) = rec_arc {
1537 let mut guard = rec_arc.write().await;
1538 match guard.put_common_field("PROC", value) {
1539 Ok(_) => {
1540 guard.notify_field(
1541 "PROC",
1542 crate::server::recgbl::EventMask::VALUE
1543 | crate::server::recgbl::EventMask::LOG,
1544 );
1545 Ok(())
1546 }
1547 Err(e) => Err(e),
1548 }
1549 } else {
1550 Ok(())
1551 }
1552 };
1553 if let Err(e) = proc_store {
1554 // `want_notify` ⇒ C `ca_put_callback`: the PROC process runs
1555 // despite the rejected conversion (`didPut == 1`). Fire-and-forget
1556 // ⇒ C plain `dbPutField`, which returns before `dbProcess` on a
1557 // non-zero `dbPut` status (`dbAccess.c:1263-1264`), so it must NOT
1558 // process. Either way the client is answered `ECA_PUTFAIL`.
1559 if want_notify {
1560 let _ = self.put_driven_process_already_locked(record_name).await;
1561 }
1562 return Err(e);
1563 }
1564 // A fire-and-forget caller parks nothing — C `dbPutField` on PROC
1565 // processes the record with no putNotify.
1566 let parked = if let Some((completion_tx, completion_rx)) =
1567 notify_request.into_completion()
1568 {
1569 let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
1570 {
1571 // Collect-then-act: clone the handle under a brief map
1572 // read, drop the map lock before the per-record write.
1573 let rec_arc = {
1574 let recs = self.inner.records.read().await;
1575 recs.get(record_name).cloned()
1576 };
1577 if let Some(rec_arc) = rec_arc {
1578 let mut guard = rec_arc.write().await;
1579 if guard.notify.is_some() {
1580 return Err(CaError::PutCallbackInProgress(record_name.to_string()));
1581 }
1582 guard.notify = Some(notify.clone());
1583 }
1584 }
1585 Some((notify, completion_rx))
1586 } else {
1587 None
1588 };
1589 // C `dbPutField:1265-1277`: PROC is one of the two fields that
1590 // selects the record for the put-driven process — with the same
1591 // PACT→RPRO deferral as a `pp` field. Both go through the single
1592 // owner (R19-43).
1593 //
1594 // The ALREADY-LOCKED entry, unconditionally — NOT `acquire_gate`
1595 // passed through. By the time control reaches here the record's
1596 // advisory gate is held on both paths: this function took it above
1597 // when `acquire_gate`, and the caller (an atomic group PUT) holds it
1598 // when not. The gate `Mutex` is not reentrant, so acquiring it again
1599 // here deadlocks every PROC put.
1600 let _ = self.put_driven_process_already_locked(record_name).await;
1601 // The wait-set fires the oneshot only after the whole
1602 // FLNK/OUT chain (sync + async) settles. If it has
1603 // already completed the chain was fully synchronous —
1604 // report immediate success; otherwise hand the receiver
1605 // to the CA layer to await the deferred completion.
1606 return match parked {
1607 Some((notify, completion_rx)) => {
1608 if notify.completed() {
1609 Ok(None)
1610 } else {
1611 Ok(completion_rx)
1612 }
1613 }
1614 None => Ok(None),
1615 };
1616 }
1617
1618 // Normal field put (write lock) — C `dbPut`, which does NOT touch
1619 // `putf`: the marker is raised only where C raises it, at the
1620 // put-driven process decision (`put_driven_process`).
1621 //
1622 // Link writes the record's `special()` makes itself. C runs them inside
1623 // `dbPut`, so they land BEFORE the `pp(TRUE)` process below — a record
1624 // wired to scaler `.COUTP` is processed with the scaler not yet armed
1625 // (scalerRecord.c:623-624, before the `:637` REQSTART).
1626 let mut special_actions = Vec::new();
1627 let mut instance = rec.write().await;
1628
1629 // C `db_put_process` (db_access.c:1025-1043) returns 1 (didPut) even
1630 // when the internal `dbChannelPut` FAILS — a rejected conversion, an
1631 // SPC_NOMOD refusal, or an after-put `special()` error all set
1632 // `ppn->status = notifyError` yet still `return 1` — so
1633 // `processNotifyCommon` (dbNotify.c:243-246) still runs `dbProcess`
1634 // when the gate passes. The whole put write is therefore wrapped so
1635 // that ANY failure inside it — `dbput_request`, `special()` pass 0,
1636 // `put_field`, `special_after_put`, `put_common_field` — is caught at
1637 // ONE place below: on the notify path we evaluate the SAME process gate
1638 // the success path uses and process the record as a side effect, then
1639 // hand the original Err back to the client. On the failing conversion
1640 // path no field is written — `dbChannelPut` wrote nothing either.
1641 //
1642 // On SUCCESS this closure is just C `dbPut`: the monitor posts at its
1643 // tail run only when the put fully succeeded (C's `goto done` skips
1644 // them on failure).
1645 let block_result: CaResult<crate::server::record::CommonFieldPutResult> = (|| {
1646 // Coerce value to the field's native DBR type (e.g. String → Double for ao.VAL).
1647 // This matches C EPICS db_put_field() which converts from the CA client's type
1648 // to the record field's native type.
1649 let request = dbput_request(&*instance.record, &field, value)?;
1650
1651 // Pre-write special hook (C EPICS dbPutSpecial pass=0)
1652 instance.record.special(&field, false)?;
1653 special_before_put(&mut instance, &field);
1654
1655 // Capture pre-put value for faac1df1 idempotent-write suppression.
1656 let prev_value = instance.record.get_field(&field);
1657 let old_nord = array_nord_before_put(&instance, &field);
1658
1659 // Try record-specific field first; fall back to common on FieldNotFound.
1660 // For record-owned fields, call on_put() and special() after successful put,
1661 // matching what put_common_field() does for common fields.
1662 use crate::server::record::CommonFieldPutResult;
1663 let common_result = match request {
1664 // C `dbAccess.c:1370-1372` — a zero-element request into a
1665 // scalar field: nothing is written, the record is driven to
1666 // LINK/INVALID, and `dbPut` returns 0. The client's put
1667 // SUCCEEDS; the record's process cycle below commits the alarm
1668 // and posts it, which is how a C IOC surfaces `caput -a`
1669 // of an empty array.
1670 PutRequest::EmptyIntoScalar => {
1671 set_empty_request_alarm(&mut instance);
1672 CommonFieldPutResult::NoChange
1673 }
1674 PutRequest::Write(value) => {
1675 match instance.record.put_field(&field, value.clone()) {
1676 Ok(()) => {
1677 instance.record.on_put(&field);
1678 // C returns the after-put special() status from
1679 // `dbPut` (dbAccess.c:1399-1405); `if (status)
1680 // goto done` then skips both the UDF clear below
1681 // and the field's monitor post, and `dbPutField`
1682 // skips the process. Propagating the error here
1683 // reproduces all three.
1684 let result =
1685 special_after_put(&mut instance, &field, &mut special_actions)?;
1686 // C `aSubRecord.c::special` / `subRecord.c::special`
1687 // (SPC_MOD on SNAM): the name was stored by
1688 // `put_field` above (C keeps `prec->snam`), but an
1689 // unregistered name makes `special(after)` return
1690 // `S_db_BadSub`. The registry is the DB's,
1691 // unreachable from the record's `special()`, so the
1692 // lookup was performed up front and its refusal is
1693 // applied here — the same point C's `dbPut` returns
1694 // the after-put `special()` status: value kept, no
1695 // field monitor post, no `pp` process, client sees
1696 // `ECA_PUTFAIL` ("Channel write request failed").
1697 if snam_registry_reject {
1698 return Err(CaError::BadField("SNAM: Subroutine not found".into()));
1699 }
1700 // C `dbAccess.c::dbPut:1410-1411` clears
1701 // `precord->udf = FALSE` synchronously when the
1702 // put target is the record-type's primary value
1703 // field (`dbIsValueField`), and clears NOTHING
1704 // else. The clear happens BEFORE `dbProcess` runs,
1705 // so any reader between the put and the process
1706 // cycle sees the new value with a consistent
1707 // udf=false — but stat/sevr keep their old
1708 // UDF_ALARM until the process cycle recomputes
1709 // them. A value put that drives no process leaves
1710 // the stale UDF alarm, matching C; the process
1711 // path's own `rec_gbl_check_udf` (now a no-op with
1712 // udf clear) + `rec_gbl_reset_alarms` clears it
1713 // when the record does process. The earlier
1714 // synchronous stat/sevr clear here diverged from C.
1715 if instance.record.is_udf_defining_put(&field) {
1716 instance.common.udf = 0;
1717 }
1718 result
1719 }
1720 Err(CaError::FieldNotFound(_)) => {
1721 instance.put_common_field(&field, value)?
1722 }
1723 Err(e) => return Err(e),
1724 }
1725 }
1726 };
1727
1728 // C `add_count` raises the inverted-limits alarm during a SGNL
1729 // SPC_MOD `special()` (histogramRecord.c:329-334). The port raises
1730 // it through `nsta`/`nsev` (CBUG-F12 refused, not C's direct write),
1731 // so this monitor-less special path commits it — check then reset —
1732 // to make STAT=SOFT/INVALID observable, matching the process path.
1733 // Gated on `special_checks_alarms` (histogram SGNL only). No STAT
1734 // post: C's `add_count` posts nothing, and the special path has no
1735 // monitor — the alarm shows on the next caget's field read.
1736 if instance.record.special_checks_alarms(&field) {
1737 let inst = &mut *instance;
1738 inst.record.check_alarms(&mut inst.common);
1739 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
1740 }
1741
1742 // Invalidate metadata cache only if the metadata-class
1743 // field's value actually changed (faac1df1).
1744 instance.notify_field_written_if_changed(&field, prev_value.as_ref());
1745
1746 // `putf` is neither set nor cleared anywhere in this block: C's
1747 // `dbPut` does not touch it. It is raised in `put_driven_process`
1748 // (C `dbAccess.c:1274`) immediately before `dbProcess`, stays TRUE
1749 // for the whole process cycle — including an async device round
1750 // trip — and is cleared by the `recGblFwdLink:302` analogue at the
1751 // cycle's tail (`processing.rs:2997` / `complete_async_record_inner`)
1752 // or by the disable-alarm bail (`dbAccess.c:576`).
1753
1754 instance.cleanup_subscribers();
1755 // C `dbPut:1408-1414` posts DBE_VALUE|DBE_LOG for the put field
1756 // unless `(isValueField && pfldDes->process_passive)` — the
1757 // immediate post is suppressed for the value field ONLY when that
1758 // field is `pp(TRUE)`, because then the reprocess cycle
1759 // (`dbPutField:1265-1268`) re-posts it via the deadband snapshot.
1760 // For a value field that is NOT `pp` (calc/calcout/aSub VAL), C
1761 // posts here and does not reprocess; the port must do the same,
1762 // because the `should_process` gate below skips the cycle for a
1763 // non-`pp` value field — without this post a direct VAL put would
1764 // fire no monitor at all.
1765 // (ACKT/ACKS have no arm here: they are SPC_NOMOD, refused by the
1766 // gate above. Alarm acknowledgement arrives as a DBR request type,
1767 // through [`Self::put_alarm_ack_from_ca`].)
1768 //
1769 // Suppress the immediate value-field post only when this put
1770 // will itself drive a reprocess (the cycle re-posts the field).
1771 // `process_passive_fields()` is total/fail-safe: a put to a
1772 // non-pp field — including any field of an unmodeled type
1773 // (`&[]`) — does not reprocess, so it is not suppressed here.
1774 let suppress_value_field_post = field == instance.record.primary_field()
1775 && instance
1776 .record
1777 .process_passive_fields()
1778 .iter()
1779 .any(|f| f.eq_ignore_ascii_case(&field));
1780 if !suppress_value_field_post {
1781 instance.notify_field(
1782 &field,
1783 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
1784 );
1785 }
1786
1787 // The NORD post, through the one owner — C reaches `put_array_info`
1788 // from `dbPut`, so the CA route posts it exactly like the internal
1789 // one. It is NOT covered by the value-field post above: for a
1790 // waveform that post is suppressed (VAL is `pp(TRUE)`), and it is
1791 // not covered by the process cycle either — a `caput -a` to a
1792 // slow-scanned or passive-but-unprocessed waveform posts NORD now
1793 // and VAL only at the next scan.
1794 post_array_info(&mut instance, &old_nord, 0);
1795
1796 // Fields a `special()` changed as a side effect of this put
1797 // (e.g. compress RES reset zeroing NUSE/VAL) get their monitors
1798 // posted here, mirroring the explicit `db_post_events` a C
1799 // `special()` makes — these fields are not pp(TRUE), so no
1800 // process cycle would otherwise post them. Each post carries
1801 // VALUE|LOG unless the record names the field in
1802 // `value_only_change_fields()` — a record whose C `special()`
1803 // posts the field with a literal `DBE_VALUE` (e.g. table SET,
1804 // tableRecord.c:659) gets the LOG bit stripped, honoring the
1805 // same value-only contract as the change-detection path.
1806 //
1807 // C's `monitor()` runs `recGblResetAlarms(prec)` BEFORE those
1808 // `db_post_events`, and OR-adds the alarm bit it returns into the
1809 // value posts (compressRecord.c:103-110). The port mirrors that
1810 // order: commit the alarm here (posting any STAT/SEVR/AMSG/ACKS
1811 // transition through the one owner, `alarm_field_posts`) and carry
1812 // the resulting DBE_ALARM into the side-effect posts below. Records
1813 // whose `special()` does not run `monitor()` return false and skip
1814 // this entirely (no spurious alarm commit on an unrelated put).
1815 let side_effect_alarm_mask = if instance.record.special_commits_alarms(&field) {
1816 commit_special_reset_alarm(&mut instance)
1817 } else {
1818 crate::server::recgbl::EventMask::NONE
1819 };
1820
1821 let side_effect_value_only = instance.record.value_only_change_fields();
1822 for sf in instance.record.monitor_side_effect_fields(&field) {
1823 use crate::server::recgbl::EventMask;
1824 let mask = if side_effect_value_only
1825 .iter()
1826 .any(|f| f.eq_ignore_ascii_case(sf))
1827 {
1828 EventMask::VALUE
1829 } else {
1830 EventMask::VALUE | EventMask::LOG
1831 };
1832 instance.notify_field(sf, mask | side_effect_alarm_mask);
1833 }
1834
1835 // The same `special()` posts, but named by the WRITER instead of
1836 // by a static table: a record whose put handler re-derived a
1837 // partner field marks it — with the mask of the C call site that
1838 // posts it, and only when that field's own comparison moved
1839 // (sseq `special()` posts the re-rendered `STRn` after a `DOn`
1840 // put, `DBE_VALUE`, `only if (strcmp(str, plinkGroup->s))`,
1841 // sseqRecord.c:1108-1116). A static field-name list cannot
1842 // express "only if it changed", so it over-posts; the mark can.
1843 emit_cycle_posts(&mut instance);
1844
1845 Ok(common_result)
1846 })();
1847
1848 // Cause B: a put-NOTIFY whose write was rejected must still process.
1849 // C `db_put_process` returned 1 (didPut) despite the failure above, so
1850 // `processNotifyCommon` runs `dbProcess` whenever the gate passes.
1851 // Reuse the SAME `put_drives_processing_of` gate the success tail uses,
1852 // process the record as a side effect, then return the ORIGINAL Err —
1853 // the CA layer maps it to PUTFAIL (C `notifyError`) and `put_accepted`
1854 // stays False, while STAT/SEVR recompute to match C. The notify path
1855 // ONLY: a plain `dbPutField` failure processes nothing (dbAccess.c:1263
1856 // processes only when `dbPut` status==0), so `want_notify == false`
1857 // keeps its Err-without-process behavior. The instance write lock must
1858 // drop before `put_driven_process_already_locked` re-acquires it.
1859 let common_result = match block_result {
1860 Ok(cr) => {
1861 drop(instance);
1862 cr
1863 }
1864 Err(e) => {
1865 // C `dbPut:83-88` runs `dbPutSpecial(paddr, 1)` UNCONDITIONALLY
1866 // ("Always do special processing if needed") — even when the
1867 // conversion above failed — before the `goto done` that skips
1868 // the udf clear and the field's monitor post. For a compress
1869 // SPC_RESET field that means `special()` still runs `monitor()`
1870 // → `recGblResetAlarms`, committing the born-UDF alarm to
1871 // NO_ALARM though the RES/N put is rejected (a caget then sees
1872 // stat/sevr=NO_ALARM with udf still 1, matching C softIoc).
1873 // Run the after-put `special()` and its alarm commit here, then
1874 // hand back the ORIGINAL Err so the client still sees PUTFAIL.
1875 // Gated on `special_commits_alarms` (compress only) so no other
1876 // special record runs its after-put hook on a failed conversion.
1877 if instance.record.special_commits_alarms(&field) {
1878 let _ = instance.record.special(&field, true);
1879 let alarm_mask = commit_special_reset_alarm(&mut instance);
1880 let value_only = instance.record.value_only_change_fields();
1881 for sf in instance.record.monitor_side_effect_fields(&field) {
1882 use crate::server::recgbl::EventMask;
1883 let mask = if value_only.iter().any(|f| f.eq_ignore_ascii_case(sf)) {
1884 EventMask::VALUE
1885 } else {
1886 EventMask::VALUE | EventMask::LOG
1887 };
1888 instance.notify_field(sf, mask | alarm_mask);
1889 }
1890 }
1891 // The same `dbPutSpecial(paddr, 1)`-on-reject rule for a field
1892 // whose special() raises the inverted-limits alarm (histogram
1893 // SGNL → add_count): C still runs add_count when the SGNL
1894 // conversion fails, so STAT=SOFT/INVALID appears even for a
1895 // rejected `caput .SGNL notanumber`. The port raises it through
1896 // `nsta`/`nsev` (CBUG-F12 refused) and commits it here — check
1897 // then reset — since no process follows.
1898 if instance.record.special_checks_alarms(&field) {
1899 let inst = &mut *instance;
1900 inst.record.check_alarms(&mut inst.common);
1901 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
1902 }
1903 // The same `dbPutSpecial(paddr, 1)`-on-reject rule for a field
1904 // whose special() clears UDF: C `mbboDirectRecord.c::special`
1905 // (after==1, B0..B1F, line 290) sets `prec->udf = FALSE`, and
1906 // that special runs UNCONDITIONALLY in `dbPut` (dbAccess.c:1401,
1907 // "Always do special processing") even when the value conversion
1908 // failed — BEFORE the `if (status) goto done`. So a rejected
1909 // `caput -c mbboDirect.Bn 256`/`notanumber` still clears UDF, and
1910 // the notify-process that follows recomputes STAT/SEVR to
1911 // NO_ALARM instead of the born-UDF INVALID (verified live against
1912 // the C softIoc: fresh record → rejected Bn put → NO_ALARM,
1913 // udf=0). The success path clears UDF for this same field set via
1914 // `is_udf_defining_put` (the `udf = 0` at the tail of the put
1915 // body). The primary VAL field is EXCLUDED here: its UDF clear is
1916 // `isValueField` (dbAccess.c:1408), which runs AFTER the status
1917 // check, so a rejected VAL put keeps UDF — matching C. Only
1918 // mbboDirect overrides `is_udf_defining_put` to add non-primary
1919 // fields, so this is a no-op for every other record type.
1920 if instance.record.is_udf_defining_put(&field)
1921 && field != instance.record.primary_field()
1922 {
1923 instance.common.udf = 0;
1924 }
1925 if want_notify && put_drives_processing_of(&instance, &field) {
1926 drop(instance);
1927 let _ = self.put_driven_process_already_locked(record_name).await;
1928 }
1929 return Err(e);
1930 }
1931 };
1932 // ASG-field change re-evaluation hook. C
1933 // `asDbLib.c:107-110,144` `asSpcAsCallback` invokes
1934 // `asChangeGroup` → `asAddMemberPvt` → `asComputePvt` for
1935 // every `ASGCLIENT` on `dbPut record.ASG NEW_ASG`. Pre-fix
1936 // Rust mutated `common.asg` directly with no notification,
1937 // so the wire ACCESS_RIGHTS the client saw still reflected
1938 // the OLD ASG until something else triggered re-eval. Now we
1939 // fire a process-wide notifier that the CA server folds into
1940 // its per-client `reeval_access_rights` path.
1941 if field == "ASG" {
1942 crate::server::access_security::notify_asg_field_changed();
1943 }
1944 // record lock released
1945
1946 // C `dbPutField` reaches `dbProcess` only after `dbPut` — and therefore
1947 // after `dbPutSpecial(paddr, 1)` and every `dbPutLink` it made — has run
1948 // to completion. Execute them here, ahead of the `pp(TRUE)` process.
1949 self.run_special_actions(record_name, &rec, std::mem::take(&mut special_actions))
1950 .await;
1951
1952 // Update scan index if SCAN or PHAS changed
1953 match common_result {
1954 crate::server::record::CommonFieldPutResult::ScanChanged {
1955 old_scan,
1956 new_scan,
1957 phas,
1958 } => {
1959 self.update_scan_index(record_name, old_scan, new_scan, phas, phas)
1960 .await;
1961 }
1962 crate::server::record::CommonFieldPutResult::PhasChanged {
1963 scan: s,
1964 old_phas,
1965 new_phas,
1966 } => {
1967 self.update_scan_index(record_name, s, s, old_phas, new_phas)
1968 .await;
1969 }
1970 crate::server::record::CommonFieldPutResult::NoChange => {}
1971 }
1972
1973 // C `dbAccess.c::dbPutField:1263-1268` re-processes the
1974 // record on a put only when the put field is `pp(TRUE)` AND the
1975 // record is Passive (`SCAN == 0`). (The `PROC` field has its own
1976 // always-process intercept above, matching C's
1977 // `pfield == &precord->proc`; alarm-ack fields like ACKT/ACKS are
1978 // not `pp(TRUE)` so they fall out here, matching C's
1979 // `dbrType < DBR_PUT_ACKT`.) Processing on every put would
1980 // double-process scanned records and spuriously process puts to
1981 // non-`pp` fields (extra FLNK / monitors / device writes /
1982 // timestamps). `process_passive_fields()` is total and fail-safe: an
1983 // unmodeled type returns `&[]` (and warns once), so it processes on
1984 // `PROC` only — spurious processing is opt-in (a type must declare its
1985 // pp set), never the default.
1986 let should_process = {
1987 let instance = rec.read().await;
1988 put_drives_processing_of(&instance, &field)
1989 };
1990
1991 if !should_process {
1992 // No processing cycle, so C never raises `putf` (and this put did
1993 // not either). Report immediate (synchronous) completion to a
1994 // WRITE_NOTIFY caller.
1995 return Ok(None);
1996 }
1997
1998 // Set up the put-notify wait-set BEFORE processing. The wait-set
1999 // fires `completion_tx` only after the originating record AND
2000 // every FLNK/OUT chain target it triggers (sync or async) has
2001 // completed — C `dbNotify.c` `processNotify`/`dbNotifyCompletion`.
2002 // Refuse a second concurrent WRITE_NOTIFY on the same record:
2003 // C EPICS returns S_db_Blocked / ECA_PUTCBINPROG, and silently
2004 // overwriting the wait-set would drop the prior Sender, waking
2005 // the prior caller's rx with RecvError that the CA dispatcher
2006 // treats as success.
2007 //
2008 // A fire-and-forget put parks NOTHING — C builds a `putNotify`
2009 // only in `dbPutNotify`; `dbPutField` processes the record with
2010 // no notify state at all. It therefore neither conflicts with
2011 // nor disturbs a WRITE_NOTIFY already parked on the record.
2012 let parked = if let Some((completion_tx, completion_rx)) = notify_request.into_completion()
2013 {
2014 let notify = crate::server::record::NotifyWaitSet::new(completion_tx);
2015 {
2016 // Collect-then-act: clone the handle under a brief map read,
2017 // drop the map lock before the per-record write.
2018 let rec_arc = {
2019 let recs = self.inner.records.read().await;
2020 recs.get(record_name).cloned()
2021 };
2022 if let Some(rec_arc) = rec_arc {
2023 let mut guard = rec_arc.write().await;
2024 if guard.notify.is_some() {
2025 return Err(CaError::PutCallbackInProgress(record_name.to_string()));
2026 }
2027 guard.notify = Some(notify.clone());
2028 }
2029 }
2030 Some((notify, completion_rx))
2031 } else {
2032 None
2033 };
2034
2035 // When a CA put writes directly to VAL on an INPUT record whose
2036 // VAL is the engineering value, the built-in `RVAL → VAL`
2037 // `convert()` must be suppressed for the put-driven process —
2038 // re-deriving VAL from a stale RVAL would clobber the value the
2039 // operator just wrote (the soft ai preset-NaN case, processing.rs
2040 // ~line 677). The framework expresses this by calling
2041 // `set_device_did_compute(true)`.
2042 //
2043 // This MUST be gated on `soft_channel_skips_convert()`. Output
2044 // records (mbbo/mbbo_direct/bo/ao) implement
2045 // `set_device_did_compute` as "skip the VAL → RVAL output
2046 // convert" — the OPPOSITE direction. C `mbboRecord.c::process`
2047 // (line 217), `mbboDirectRecord.c::process` (line 198) and
2048 // `boRecord.c::process` (line 207) call `convert()`
2049 // unconditionally on every non-pact process; a CA VAL-put on an
2050 // output record MUST recompute RVAL/ORAW. Suppressing it there
2051 // left RVAL/ORAW/ORBV stale. Output records return the default
2052 // `false` from `soft_channel_skips_convert()`, so this gate
2053 // matches the identical gates in processing.rs (line 694) and
2054 // record_instance.rs (line 1381).
2055 if field == "VAL" {
2056 // Collect-then-act: clone the handle under a brief map read, drop
2057 // the map lock before the per-record write.
2058 let rec_arc = {
2059 let recs = self.inner.records.read().await;
2060 recs.get(record_name).cloned()
2061 };
2062 if let Some(rec_arc) = rec_arc {
2063 let mut guard = rec_arc.write().await;
2064 if guard.record.soft_channel_skips_convert() {
2065 guard.record.set_device_did_compute(true);
2066 }
2067 }
2068 }
2069
2070 // Process the record after field put — through the single owner of C's
2071 // `dbPutField:1269-1277` decision, so an async-active record takes the
2072 // RPRO deferral instead of a doomed re-entrant `dbProcess`.
2073 let _ = self.put_driven_process_already_locked(record_name).await;
2074
2075 // Is the ORIGINATING record itself still async-pending? Its
2076 // wait-set membership is taken + `leave`d at its own completion
2077 // (sync-end, or later in `complete_async_record_inner`), so a
2078 // lingering `notify` on its instance means its device round-trip
2079 // is still in flight. This gates only the originating record's
2080 // PUTF clear — independent of whether downstream chain targets
2081 // are still pending.
2082 //
2083 // A fire-and-forget put parked nothing, and a `notify` it sees
2084 // on the instance belongs to some other caller's WRITE_NOTIFY —
2085 // not evidence about THIS put. Fall through to the guarded
2086 // clear; its `!is_processing()` gate already preserves PUTF
2087 // across an async-pending device round-trip.
2088 let originating_pending = want_notify && {
2089 let rec = self.inner.records.read().await;
2090 if let Some(rec_arc) = rec.get(record_name) {
2091 rec_arc.read().await.notify.is_some()
2092 } else {
2093 false
2094 }
2095 };
2096
2097 // C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` after
2098 // the forward-link dispatch — the marker only lives for the
2099 // duration of the put's processing cycle. For SYNCHRONOUS
2100 // completions (PACT was cleared by the time
2101 // `process_record_with_links` returns) clear it here. For
2102 // async-pending records, the clearing happens later in
2103 // `complete_async_record_inner` (which runs FLNK as part of
2104 // the completion path) so the PUTF marker survives the
2105 // device-write round trip.
2106 if !originating_pending {
2107 // Collect-then-act: clone the handle under a brief map read, drop
2108 // the map lock before the per-record write.
2109 let rec_arc = {
2110 let recs = self.inner.records.read().await;
2111 recs.get(record_name).cloned()
2112 };
2113 if let Some(rec_arc) = rec_arc {
2114 let mut guard = rec_arc.write().await;
2115 if !guard.is_processing() {
2116 guard.common.putf = false;
2117 }
2118 }
2119 }
2120
2121 // CA completion gates on the WHOLE chain, not just the
2122 // originating record: the put-notify must not report
2123 // done until every FLNK/OUT target it drove — including an async
2124 // FLNK target that the originating record's sync cycle merely
2125 // kicked off — has settled. `completed()` is true iff the
2126 // wait-set drained to zero during this call (fully synchronous
2127 // chain); otherwise the receiver fires later from the last
2128 // chain member's `leave`.
2129 match parked {
2130 Some((notify, completion_rx)) => {
2131 if notify.completed() {
2132 Ok(None)
2133 } else {
2134 Ok(completion_rx)
2135 }
2136 }
2137 None => Ok(None),
2138 }
2139 }
2140
2141 /// Put a PV value without triggering process (for restore).
2142 pub async fn put_pv_no_process(&self, name: &str, value: EpicsValue) -> CaResult<()> {
2143 let (base, field) = super::parse_pv_name(name);
2144 let field = field.to_ascii_uppercase();
2145
2146 if let Some(pv) = self.inner.simple_pvs.read().await.get(name) {
2147 pv.set(value).await;
2148 return Ok(());
2149 }
2150
2151 // Records — alias-aware (epics-base PR #336).
2152 if let Some(rec) = self.get_record(base).await {
2153 // `put_pv_no_process` is a public record-write API
2154 // (autosave restore). It must take the advisory write gate
2155 // (`dbScanLock` analogue) so an autosave restore cannot
2156 // land between the member writes of a QSRV atomic group or
2157 // a pvalink atomic scan epoch holding `lock_records`.
2158 // `base` is alias-resolved so an alias and its target
2159 // share one gate. Held until return.
2160 let canonical_base: String = self
2161 .resolve_alias(base)
2162 .await
2163 .unwrap_or_else(|| base.to_string());
2164 let _record_gate = self.lock_record(&canonical_base).await;
2165
2166 let mut instance = rec.write().await;
2167 let prev_value = instance.record.get_field(&field);
2168 match instance.record.put_field(&field, value.clone()) {
2169 Ok(()) => {}
2170 Err(CaError::FieldNotFound(_)) => {
2171 instance.put_common_field(&field, value)?;
2172 }
2173 Err(e) => return Err(e),
2174 }
2175 // Invalidate metadata cache only if the metadata-class
2176 // field actually changed (faac1df1).
2177 instance.notify_field_written_if_changed(&field, prev_value.as_ref());
2178 // same SPC_AS parity as `put_pv` / the CA-write
2179 // path — autosave-style restores writing `.ASG` at IOC
2180 // startup must still trigger per-client re-eval.
2181 if field == "ASG" {
2182 crate::server::access_security::notify_asg_field_changed();
2183 }
2184 return Ok(());
2185 }
2186
2187 Err(CaError::ChannelNotFound(name.to_string()))
2188 }
2189}
2190
2191/// Project a decoded `DBR_CTRL_*` / `DBR_GR_*` snapshot's metadata fields
2192/// (display / control limits, enum labels) into the shadow-PV
2193/// [`PvMetadata`](crate::server::pv::PvMetadata) the CA gateway installs.
2194/// A non-metadata (TIME/STS) snapshot carries `None` in all three, which
2195/// clears the shadow metadata — but the gateway only ever feeds this a
2196/// control-class snapshot, matching C `setPvData` replacing the attribute
2197/// gdd wholesale from the control get/event.
2198fn metadata_from_snapshot(snapshot: &Snapshot) -> crate::server::pv::PvMetadata {
2199 crate::server::pv::PvMetadata {
2200 display: snapshot.display.clone(),
2201 control: snapshot.control.clone(),
2202 enums: snapshot.enums.clone(),
2203 }
2204}
2205
2206#[cfg(test)]
2207mod tests {
2208 use super::super::PvDatabase;
2209 use crate::types::EpicsValue;
2210
2211 /// Regression: prior to fixing B1, `put_pv_and_post` walked only
2212 /// `inner.records` and returned `ChannelNotFound` for everything
2213 /// `add_pv`-registered. The CA gateway's monitor forwarder uses
2214 /// `add_pv` then expects `put_pv_and_post` to fan-out to
2215 /// downstream subscribers — without the simple-PV branch, every
2216 /// upstream event was silently dropped and the gateway delivered
2217 /// no monitors.
2218 #[tokio::test]
2219 async fn put_pv_and_post_handles_simple_pv() {
2220 let db = PvDatabase::new();
2221 db.add_pv("gw:test", EpicsValue::Double(0.0)).await.unwrap();
2222
2223 // Should NOT return ChannelNotFound.
2224 db.put_pv_and_post("gw:test", EpicsValue::Double(42.0))
2225 .await
2226 .expect("simple PV put_pv_and_post must succeed");
2227
2228 // Value actually landed.
2229 let pv = db.find_pv("gw:test").await.expect("PV exists");
2230 assert!(matches!(pv.get().await, EpicsValue::Double(v) if v == 42.0));
2231 }
2232
2233 /// Regression: `get_pv`, `put_pv`, `put_pv_and_post`,
2234 /// and `put_pv_no_process` all bypassed `get_record` and walked
2235 /// `self.inner.records` directly, so alias names from epics-base
2236 /// PR #336 silently returned `ChannelNotFound`. A later fix closed
2237 /// `get_record` but the same defect was hiding in field_io.rs.
2238 /// All four CA-server-and-bridge entry points must accept aliases.
2239 #[tokio::test]
2240 async fn field_io_entry_points_accept_aliases() {
2241 use crate::server::records::ai::AiRecord;
2242
2243 let db = PvDatabase::new();
2244 db.add_record("CANON", Box::new(AiRecord::new(0.0)))
2245 .await
2246 .unwrap();
2247 db.add_alias("ALT", "CANON").await.unwrap();
2248
2249 // get_pv via alias
2250 db.put_pv("CANON.VAL", EpicsValue::Double(1.5))
2251 .await
2252 .unwrap();
2253 let v = db.get_pv("ALT.VAL").await.unwrap();
2254 assert!(matches!(v, EpicsValue::Double(x) if x == 1.5));
2255
2256 // put_pv via alias
2257 db.put_pv("ALT.VAL", EpicsValue::Double(7.0)).await.unwrap();
2258 let v = db.get_pv("CANON.VAL").await.unwrap();
2259 assert!(matches!(v, EpicsValue::Double(x) if x == 7.0));
2260
2261 // put_pv_and_post via alias
2262 db.put_pv_and_post("ALT.VAL", EpicsValue::Double(11.0))
2263 .await
2264 .unwrap();
2265 let v = db.get_pv("CANON.VAL").await.unwrap();
2266 assert!(matches!(v, EpicsValue::Double(x) if x == 11.0));
2267
2268 // put_pv_no_process via alias
2269 db.put_pv_no_process("ALT.VAL", EpicsValue::Double(13.0))
2270 .await
2271 .unwrap();
2272 let v = db.get_pv("ALT.VAL").await.unwrap();
2273 assert!(matches!(v, EpicsValue::Double(x) if x == 13.0));
2274 }
2275
2276 /// A `DBR_STRING` menu label written to a `DBF_MENU` field resolves
2277 /// against THAT field's own menu (C `dbConvert` `putStringMenu`), not the
2278 /// field-blind global table that `EpicsValue::convert_to` would consult.
2279 /// Covers the `put_pv` (`put_pv_inner`) and `put_pv_and_post` coercion
2280 /// sites; the CA field-put path (`put_record_field_from_ca_inner`) shares
2281 /// the identical `coerce_write_value` helper.
2282 #[tokio::test]
2283 async fn write_path_menu_label_resolves_against_field_menu() {
2284 use crate::server::records::sel::SelRecord;
2285
2286 let db = PvDatabase::new();
2287 db.add_record("SEL", Box::new(SelRecord::default()))
2288 .await
2289 .unwrap();
2290
2291 // put_pv (put_pv_inner): "Specified" is selSELM index 0, NOT the
2292 // menuFanout index 1 the global table would have returned.
2293 db.put_pv("SEL.SELM", EpicsValue::String("Specified".into()))
2294 .await
2295 .unwrap();
2296 assert_eq!(db.get_pv("SEL.SELM").await.unwrap(), EpicsValue::Enum(0));
2297
2298 // put_pv_and_post: a later choice, proving the whole menu.
2299 db.put_pv_and_post("SEL.SELM", EpicsValue::String("High Signal".into()))
2300 .await
2301 .unwrap();
2302 assert_eq!(db.get_pv("SEL.SELM").await.unwrap(), EpicsValue::Enum(1));
2303
2304 // A bare numeric string still resolves (C epicsParseUInt16 fallback).
2305 db.put_pv("SEL.SELM", EpicsValue::String("2".into()))
2306 .await
2307 .unwrap();
2308 assert_eq!(db.get_pv("SEL.SELM").await.unwrap(), EpicsValue::Enum(2));
2309 }
2310
2311 /// `set_pv_metadata` installs the upstream `DBR_CTRL_*` metadata on a
2312 /// shadow simple PV WITHOUT posting any event (the CA gateway's
2313 /// connect-time seed). A later GET-class read must then see the
2314 /// installed limits/units, and a `DBE_PROPERTY` subscriber must NOT
2315 /// have received anything (nothing *changed* yet). An unknown / record
2316 /// name is rejected with `ChannelNotFound`.
2317 #[tokio::test]
2318 async fn set_pv_metadata_installs_without_posting() {
2319 use crate::error::CaError;
2320 use crate::server::snapshot::{DisplayInfo, Snapshot};
2321 use crate::types::DbFieldType;
2322 use std::time::SystemTime;
2323
2324 let db = PvDatabase::new();
2325 db.add_pv("gw:meta", EpicsValue::Double(0.0)).await.unwrap();
2326
2327 // A DBE_PROPERTY subscriber attached BEFORE the seed — it must stay
2328 // empty, because seeding metadata is not a property *change*.
2329 const DBE_PROPERTY: u16 = 8;
2330 let pv = db.find_pv("gw:meta").await.expect("PV exists");
2331 let mut prop_rx = pv
2332 .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
2333 .await
2334 .expect("subscriber added");
2335
2336 // Build a CTRL-class snapshot carrying display metadata.
2337 let mut ctrl = Snapshot::new(EpicsValue::Double(0.0), 0, 0, SystemTime::UNIX_EPOCH);
2338 ctrl.display = Some(DisplayInfo {
2339 units: "mm".into(),
2340 precision: 3,
2341 upper_disp_limit: 10.0,
2342 lower_disp_limit: -10.0,
2343 ..Default::default()
2344 });
2345
2346 db.set_pv_metadata("gw:meta", &ctrl)
2347 .await
2348 .expect("simple PV set_pv_metadata must succeed");
2349
2350 // The metadata landed on the shadow PV.
2351 let installed = pv.metadata();
2352 assert_eq!(
2353 installed.display.expect("display metadata installed").units,
2354 "mm"
2355 );
2356
2357 // No event was posted (seed != change).
2358 assert!(
2359 prop_rx.try_recv().is_err(),
2360 "set_pv_metadata must not post a DBE_PROPERTY event"
2361 );
2362
2363 // Unknown / non-simple PV is rejected.
2364 assert!(matches!(
2365 db.set_pv_metadata("no:such:pv", &ctrl).await,
2366 Err(CaError::ChannelNotFound(_))
2367 ));
2368 }
2369
2370 /// `post_pv_property` refreshes the shadow metadata AND posts a
2371 /// `DBE_PROPERTY` event carrying the supplied snapshot's metadata,
2372 /// upstream status/severity, and (undefined control-DBR) timestamp — to
2373 /// `DBE_PROPERTY` subscribers only. This is the DB-routing layer the
2374 /// gateway's property monitor drives on every upstream `DBE_PROPERTY`
2375 /// event. An unknown / record name is rejected with `ChannelNotFound`.
2376 #[tokio::test]
2377 async fn post_pv_property_refreshes_and_posts_property_event() {
2378 use crate::error::CaError;
2379 use crate::server::snapshot::{DisplayInfo, Snapshot};
2380 use crate::types::{DbFieldType, WallTime};
2381
2382 const DBE_PROPERTY: u16 = 8;
2383 const DBE_VALUE: u16 = 1;
2384 const MAJOR: u16 = 2;
2385 const HIGH: u16 = 3;
2386
2387 let db = PvDatabase::new();
2388 db.add_pv("gw:prop", EpicsValue::Double(0.0)).await.unwrap();
2389 let pv = db.find_pv("gw:prop").await.expect("PV exists");
2390
2391 let mut prop_rx = pv
2392 .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
2393 .await
2394 .expect("property subscriber added");
2395 let mut val_rx = pv
2396 .add_subscriber(2, DbFieldType::Double, DBE_VALUE)
2397 .await
2398 .expect("value subscriber added");
2399
2400 // Upstream CTRL event: metadata + MAJOR/HIGH alarm + a fixed past
2401 // timestamp that is unmistakably not a fresh wall clock.
2402 let upstream_ts = WallTime::from_unix(2_000_000, 0);
2403 let mut ctrl = Snapshot::new(EpicsValue::Double(5.0), HIGH, MAJOR, upstream_ts);
2404 ctrl.display = Some(DisplayInfo {
2405 units: "V".into(),
2406 precision: 1,
2407 ..Default::default()
2408 });
2409
2410 db.post_pv_property("gw:prop", ctrl)
2411 .await
2412 .expect("simple PV post_pv_property must succeed");
2413
2414 // The metadata was refreshed on the shadow PV.
2415 assert_eq!(
2416 pv.metadata().display.expect("metadata refreshed").units,
2417 "V"
2418 );
2419
2420 // The DBE_PROPERTY subscriber received the metadata-bearing event,
2421 // with the upstream alarm and timestamp preserved.
2422 let ev = prop_rx
2423 .try_recv()
2424 .expect("DBE_PROPERTY subscriber receives the property event");
2425 assert_eq!(
2426 ev.snapshot.display.expect("event carries metadata").units,
2427 "V"
2428 );
2429 assert_eq!(
2430 ev.snapshot.alarm.severity, MAJOR,
2431 "upstream severity preserved"
2432 );
2433 assert_eq!(ev.snapshot.alarm.status, HIGH, "upstream status preserved");
2434 assert_eq!(
2435 ev.snapshot.timestamp, upstream_ts,
2436 "control-DBR timestamp preserved, not a fresh wall clock"
2437 );
2438
2439 // The DBE_VALUE-only subscriber must NOT receive a property event.
2440 assert!(
2441 val_rx.try_recv().is_err(),
2442 "DBE_VALUE-only subscriber must not receive a property post"
2443 );
2444
2445 // Unknown / non-simple PV is rejected.
2446 let again = Snapshot::new(EpicsValue::Double(0.0), 0, 0, WallTime::UNIX_EPOCH);
2447 assert!(matches!(
2448 db.post_pv_property("no:such:pv", again).await,
2449 Err(CaError::ChannelNotFound(_))
2450 ));
2451 }
2452
2453 /// Regression: `put_record_field_from_ca` (the CA
2454 /// server's main put fast path) must accept aliases. Pre-fix it
2455 /// only consulted `inner.records` directly. Also exercises the
2456 /// canonical-name normalisation that protects subsequent
2457 /// `process_record_with_links` / `update_scan_index` calls.
2458 #[tokio::test]
2459 async fn put_record_field_from_ca_accepts_alias() {
2460 use crate::server::records::ai::AiRecord;
2461
2462 let db = PvDatabase::new();
2463 db.add_record("CANON", Box::new(AiRecord::new(0.0)))
2464 .await
2465 .unwrap();
2466 db.add_alias("ALT", "CANON").await.unwrap();
2467
2468 // Put VAL via the alias name.
2469 let _ = db
2470 .put_record_field_from_ca("ALT", "VAL", EpicsValue::Double(2.5))
2471 .await
2472 .expect("put via alias must succeed");
2473
2474 // Read back via canonical to confirm the value landed on the
2475 // right record.
2476 let v = db.get_pv("CANON.VAL").await.unwrap();
2477 assert!(matches!(v, EpicsValue::Double(x) if x == 2.5));
2478 }
2479
2480 /// Regression: a DBR_PUT_ACKT alarm-acknowledge put posts a record-wide
2481 /// DBE_ALARM (C `dbAccess.c:1299` putAckt
2482 /// `db_post_events(precord, NULL, DBE_ALARM)`), so an alarm-mask monitor
2483 /// on ANY field is notified — and a DBE_VALUE-only monitor is not.
2484 /// Pre-fix the ack field posted only itself with DBE_VALUE|DBE_LOG, so no
2485 /// alarm-mask subscriber observed the acknowledgement, and the post fired
2486 /// on every put regardless of whether `ackt` changed.
2487 #[tokio::test]
2488 async fn alarm_ack_put_posts_record_wide_dbe_alarm() {
2489 use crate::server::recgbl::EventMask;
2490 use crate::server::records::ai::AiRecord;
2491 use crate::types::DbFieldType;
2492
2493 let db = PvDatabase::new();
2494 db.add_record("A:REC", Box::new(AiRecord::new(1.0)))
2495 .await
2496 .unwrap();
2497 let rec = db.get_record("A:REC").await.expect("record exists");
2498
2499 let (mut alarm_rx, mut value_rx) = {
2500 let mut inst = rec.write().await;
2501 let a = inst
2502 .add_subscriber("VAL", 1, DbFieldType::Double, EventMask::ALARM.bits())
2503 .expect("alarm subscriber");
2504 let v = inst
2505 .add_subscriber("VAL", 2, DbFieldType::Double, EventMask::VALUE.bits())
2506 .expect("value subscriber");
2507 (a, v)
2508 };
2509
2510 // The client acknowledges through its ordinary VAL channel with a
2511 // DBR_PUT_ACKT request type (C `dbAccess.c:1331`). ACKT defaults YES
2512 // (true), so writing 0 (disable transient acknowledgement) is a real
2513 // change.
2514 db.put_alarm_ack_from_ca(
2515 "A:REC",
2516 "VAL",
2517 crate::server::record::AlarmAck::Transient,
2518 0,
2519 )
2520 .await
2521 .expect("ackt put");
2522
2523 // The alarm-mask monitor on VAL receives the record-wide DBE_ALARM.
2524 assert!(
2525 alarm_rx.try_recv().is_ok(),
2526 "DBE_ALARM subscriber must receive the record-wide alarm post"
2527 );
2528 // The DBE_VALUE-only monitor on VAL must NOT: VAL's value is unchanged.
2529 assert!(
2530 value_rx.try_recv().is_err(),
2531 "DBE_VALUE-only subscriber must not receive the alarm post"
2532 );
2533
2534 // Re-putting the same ACKT value is a no-op: C putAckt returns early
2535 // on an unchanged ackt, so no further alarm post fires.
2536 db.put_alarm_ack_from_ca(
2537 "A:REC",
2538 "VAL",
2539 crate::server::record::AlarmAck::Transient,
2540 0,
2541 )
2542 .await
2543 .expect("ackt re-put");
2544 assert!(
2545 alarm_rx.try_recv().is_err(),
2546 "unchanged ACKT must post nothing"
2547 );
2548 }
2549
2550 /// `post_property_fields` writes each field through the internal put and
2551 /// posts a `DBE_PROPERTY` monitor — the C
2552 /// `db_post_events(precord, &precord->val, DBE_PROPERTY)` that asyn's
2553 /// runtime enum re-propagation drives (devAsynInt32.c callbackEnum). A
2554 /// `DBE_VALUE`-only subscriber on the same field must NOT receive it:
2555 /// re-keying enum strings is a property change, not a value change.
2556 #[tokio::test]
2557 async fn post_property_fields_writes_and_posts_dbe_property_only() {
2558 use crate::server::recgbl::EventMask;
2559 use crate::server::records::mbbi::MbbiRecord;
2560 use crate::types::DbFieldType;
2561
2562 let db = PvDatabase::new();
2563 db.add_record("M:ENUM", Box::new(MbbiRecord::new(0)))
2564 .await
2565 .unwrap();
2566 let rec = db.get_record("M:ENUM").await.expect("record exists");
2567
2568 let (mut prop_rx, mut val_rx) = {
2569 let mut inst = rec.write().await;
2570 let p = inst
2571 .add_subscriber("ZRST", 1, DbFieldType::String, EventMask::PROPERTY.bits())
2572 .expect("property subscriber");
2573 let v = inst
2574 .add_subscriber("ZRST", 2, DbFieldType::String, EventMask::VALUE.bits())
2575 .expect("value subscriber");
2576 (p, v)
2577 };
2578
2579 let posted = db
2580 .post_property_fields(
2581 "M:ENUM",
2582 vec![("ZRST".to_string(), EpicsValue::String("LABEL".into()))],
2583 )
2584 .await
2585 .expect("post_property_fields succeeds");
2586 assert_eq!(posted, vec!["ZRST".to_string()]);
2587
2588 // The field landed on the record.
2589 assert_eq!(
2590 db.get_pv("M:ENUM.ZRST").await.unwrap(),
2591 EpicsValue::String("LABEL".into())
2592 );
2593
2594 // The DBE_PROPERTY subscriber received the event; the DBE_VALUE-only
2595 // subscriber did not (mask 0x08 vs 0x01, no intersection).
2596 assert!(
2597 prop_rx.try_recv().is_ok(),
2598 "DBE_PROPERTY subscriber must receive the property post"
2599 );
2600 assert!(
2601 val_rx.try_recv().is_err(),
2602 "DBE_VALUE-only subscriber must not receive a property post"
2603 );
2604 }
2605
2606 /// Regression: a direct CA put to a record whose value field VAL is NOT
2607 /// `pp(TRUE)` (calc / calcout / aSub) must still fire a DBE_VALUE monitor.
2608 /// C `dbAccess.c::dbPut:1408-1414` posts the value field immediately
2609 /// unless it is `pp(TRUE)`. The port previously suppressed the immediate
2610 /// post for every `VAL` and — with the `should_process` gate — skipped
2611 /// the reprocess cycle for a non-`pp` VAL, so the operator's write fired
2612 /// no monitor at all. calc's VAL is not in its `pp` field set, so the
2613 /// immediate post is the only event that can fire.
2614 #[tokio::test]
2615 async fn ca_put_to_non_pp_val_posts_monitor() {
2616 use crate::server::database::db_access::DbSubscription;
2617 use crate::server::records::calc::CalcRecord;
2618
2619 let db = PvDatabase::new();
2620 db.add_record("CALC1", Box::new(CalcRecord::new("0")))
2621 .await
2622 .unwrap();
2623
2624 let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
2625 .await
2626 .expect("subscribe to CALC1.VAL");
2627
2628 db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(5.0))
2629 .await
2630 .expect("CA put to CALC1.VAL must succeed");
2631
2632 let got = tokio::time::timeout(std::time::Duration::from_secs(1), sub.recv_f64())
2633 .await
2634 .expect("a DBE_VALUE monitor must fire for a direct VAL put to a non-pp record");
2635 assert_eq!(got, Some(5.0));
2636 }
2637
2638 /// R8-22 (record-field path): a record monitor whose event queue runs short
2639 /// of room during a burst must receive its EARLIER DISTINCT queued updates
2640 /// and then a tail entry carrying the latest value. C `db_queue_event_log`
2641 /// replaces only `*pLastLog` (`dbEvent.c:812-820`); the earlier entries stay
2642 /// queued and each is delivered by `event_read`.
2643 ///
2644 /// Each non-pp `VAL` put posts exactly one DBE_VALUE monitor with the put
2645 /// value and does NOT reprocess (see `ca_put_to_non_pp_val_posts_monitor`),
2646 /// so N distinct puts produce a strictly increasing 1..=N stream.
2647 ///
2648 /// Before the fix the producer parked the newest value in a side coalesce
2649 /// slot and `next_event`, finding it set, discarded the whole queued backlog
2650 /// — the burst came out as one event instead of {1..=appended-1, N}.
2651 #[tokio::test]
2652 async fn r8_22_db_burst_keeps_earlier_distinct_updates() {
2653 use crate::server::database::db_access::DbSubscription;
2654 use crate::server::event_queue::{event_que_size, events_per_que};
2655 use crate::server::records::calc::CalcRecord;
2656
2657 let db = PvDatabase::new();
2658 db.add_record("CALC1", Box::new(CalcRecord::new("0")))
2659 .await
2660 .unwrap();
2661 let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
2662 .await
2663 .expect("subscribe to CALC1.VAL");
2664
2665 // With no consumer draining, the first `appended` puts take ring entries
2666 // and every later put replaces the tail entry in place.
2667 let appended = event_que_size() - events_per_que();
2668 let burst = appended + 40;
2669 for i in 1..=burst {
2670 db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(i as f64))
2671 .await
2672 .expect("CA put to CALC1.VAL must succeed");
2673 }
2674
2675 // Drain every immediately-available delivery; the recv past the
2676 // last event has nothing queued and times out, ending collection.
2677 let mut seq = Vec::new();
2678 while let Ok(Some(v)) =
2679 tokio::time::timeout(std::time::Duration::from_millis(200), sub.recv_f64()).await
2680 {
2681 seq.push(v);
2682 }
2683 let want: Vec<f64> = (1..appended)
2684 .map(|i| i as f64)
2685 .chain(std::iter::once(burst as f64))
2686 .collect();
2687 assert_eq!(
2688 seq, want,
2689 "record burst delivery must be {{earlier distinct backlog…, coalesced tail}}"
2690 );
2691 }
2692}