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