Skip to main content

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