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:2540-2551), 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/// [`check_no_mod`] as `dbPut` runs it — the refusal that also SPEAKS.
93///
94/// C's SPC_NOMOD arm is not a bare `return`: it reports before it returns.
95///
96/// ```c
97/// /* dbAccess.c:122-127, dbPutSpecial(paddr, 0) */
98/// if ((special == SPC_NOMOD) && (pass == 0)) {
99///     status = S_db_noMod;
100///     recGblDbaddrError(status, paddr, "dbPut");
101///     return status;
102/// }
103/// ```
104///
105/// `errSymLookup(S_db_noMod)` is `"Attempt to modify noMod field"`
106/// (`dbAccessDefs.h:179`), so the console line is exactly
107/// `recGblDbaddrError: dbPut Attempt to modify noMod field PV: REC.FIELD` —
108/// measured on softIoc @`R7.0.10` for `dbpf T:SUB.INAM` and `dbpf T:SEL.VAL`,
109/// where stdout still shows the unchanged read-back and the refusal is
110/// announced only here. A port that returns the status and says nothing loses
111/// the whole report, because `dbpf` itself prints no diagnostic of its own.
112///
113/// INVARIANT: every `dbPut`-layer SPC_NOMOD refusal MUST write this line, and
114/// no other layer may. The gate above stays silent so
115/// [`PvDatabase::check_external_put_preconditions`] — pvxs `doPreProcessing`,
116/// which refuses ABOVE `dbPut` and never reaches `dbPutSpecial` — cannot emit
117/// a line C does not write, nor double it on the routes that go on to put.
118fn check_no_mod_in_db_put(
119    instance: &crate::server::record::RecordInstance,
120    field: &str,
121) -> CaResult<()> {
122    check_no_mod(instance, field).inspect_err(|_| {
123        crate::server::recgbl::rec_gbl_dbaddr_error(
124            "Attempt to modify noMod field",
125            &instance.name,
126            field,
127            "dbPut",
128        );
129    })
130}
131
132/// C `dbPut`'s link-field refusal (`field_type > DBF_DEVICE` →
133/// `S_db_badDbrtype`, `dbAccess.c:1340-1347`): only `dbPutField` may change a
134/// DBF_INLINK/OUTLINK/FWDLINK field — it routes them through `dbPutFieldLink`
135/// (`dbAccess.c:1261-1262`) — so every `dbPut`-analogue body refuses them
136/// before converting anything. This is what stops a record's DB OUT link
137/// (`dbPutLink` → `dbDbPutValue` → `dbPut`) from silently rewiring another
138/// record's link field on every process. The port's `dbPutField` analogue is
139/// the `put_record_field_from_ca` family, whose ordinary write path re-parses
140/// the link (`RecordInstance::put_common_field`'s INP/OUT/FLNK arms);
141/// `put_pv_no_process` is the autosave-restore entry whose C analogue is
142/// likewise `dbPutField` (`reboot_restore`), so it stays link-writable.
143///
144/// `field` must already be upper-cased.
145fn check_not_link_field(
146    instance: &crate::server::record::RecordInstance,
147    field: &str,
148) -> CaResult<()> {
149    if crate::types::dbf_link_class(instance.record.record_type(), field).is_some() {
150        return Err(CaError::BadDbrType(format!(
151            "dbPut: {field} is a link field; only dbPutField changes link fields"
152        )));
153    }
154    Ok(())
155}
156
157/// C `dbPutFieldLink`'s parse-and-type gate (`dbAccess.c:1098-1135`) — the
158/// half of `dbPutField` that only a link field reaches.
159///
160/// `dbPutField` routes on the DECLARED DBF class and on nothing else: `if
161/// (dbfType >= DBF_INLINK && dbfType <= DBF_FWDLINK) return
162/// dbPutFieldLink(...)` (`dbAccess.c:1259-1260`). `dbPutFieldLink` then parses
163/// the text (`dbParseLink`, `:1098`) and holds it to `dbCanSetLink` (`:1131`)
164/// against the device support the record's CURRENT `DTYP` binds — only `INP`
165/// and `OUT` have one, so every other link field is held to `CONSTANT`, which
166/// `dbCanSetLink` treats as interchangeable with `PV_LINK` and `JSON_LINK`
167/// (`dbStaticLib.c:2408-2416`). A mismatch is `S_dbLib_badField`: the field
168/// keeps the text it had, the record is not touched, and NOTHING is printed —
169/// the refusal reaches the caller only as the put's status.
170///
171/// The port ran this rule from `put_common_field`'s `INP` and `OUT` arms, and
172/// a pair of arms is a name list: `SDIS`, `TSEL`, `FLNK`, `SIML`, `SIOL`,
173/// `calc.INPA`, `ao.DOL` and `fanout.LNK1` all stored what C refuses, through
174/// `dbpf` and through CA alike. Keyed on the class here, beside
175/// [`check_not_link_field`], both `dbPutField`-analogue bodies take the rule
176/// from one owner and a link field the generator adds is covered the day it is
177/// declared rather than the day someone remembers it.
178///
179/// Runs ABOVE [`check_no_mod`], because C's link route leaves `dbPutField`
180/// before `dbPut` is called at all (`dbAccess.c:1259` vs `:1262`) and the
181/// `SPC_NOMOD` refusal a link field can still take arrives later, from
182/// `dbPutSpecial(paddr, 0)` at `dbAccess.c:1174`. `aSub.SUBL` is the one field
183/// in the vendored population where that order is visible — `DBF_INLINK` and
184/// `special(SPC_NOMOD)` both (`aSubRecord.dbd.pod:148-153`), pinned by
185/// `asub_subl_is_the_only_read_only_link_field` — and it is visible as the
186/// STATUS: C answers `S_dbLib_badField` to `dbpf ASUB.SUBL "@instio p"`, where
187/// a no-mod-first order would answer `S_db_noMod`. A type-valid text on the
188/// same field reaches the no-mod gate and is refused there, as in C.
189///
190/// Takes the record's DECLARATION pair rather than the instance, because that
191/// is the whole of C's input here — `dbPutFieldLink` reads `paddr->precord`
192/// only for its `DTYP`-bound `devSup` — and because a rule stated over
193/// `(record_type, dtyp)` can be swept over every type the generator emits
194/// without instantiating any of them, which is what
195/// `every_declared_link_class_field_is_gated_by_class` does.
196///
197/// Returns the value to STORE rather than a bare verdict, because in C the
198/// text the gate parses and the text the field ends up holding are the same
199/// buffer — `dbParseLink` and `dbSetLink` both read `pstring`
200/// (`dbAccess.c:1098`, `:1176`). Answering only "may this proceed?" and
201/// letting the store path re-derive its own text is what let a
202/// `DBR_CHAR` NUL — C's way of spelling "clear this link" — land as the
203/// literal `"0"`. `None` means `field` is not a link field and the caller's
204/// own value stands.
205///
206/// `dtyp` is the record's `DTYP` text, empty when the `.db` never spelled it;
207/// `field` must already be upper-cased.
208fn check_link_put(
209    record_type: &str,
210    dtyp: &str,
211    field: &str,
212    value: &EpicsValue,
213) -> CaResult<Option<EpicsValue>> {
214    if crate::types::dbf_link_class(record_type, field).is_none() {
215        return Ok(None);
216    }
217    // C's request-type switch (`dbAccess.c:1084-1096`), which is the whole of
218    // what a link field accepts: `DBR_STRING`, or a `DBR_CHAR`/`DBR_UCHAR`
219    // buffer whose LAST element is the NUL — `pstring[nRequest - 1] != '\0'`
220    // is `S_db_badDbrtype`, and so is every other request type. Measured
221    // against softIoc R7.0.10 on SDIS, INPA, OUT and LNK1: a `DBR_DOUBLE`,
222    // `DBR_LONG`, `DBR_SHORT` or `DBR_ENUM` put fails the channel write and
223    // leaves the link alone, where the same put to a plain `DBF_STRING` field
224    // (`DESC`) is converted and stored.
225    let bad_type = || {
226        CaError::BadDbrType(format!(
227            "dbPutFieldLink: {field} takes a string or a NUL-terminated char array"
228        ))
229    };
230    let text = match value {
231        EpicsValue::String(s) => s.as_str_lossy().into_owned(),
232        // `DBR_STRING` with `nRequest > 1`: C reads `pstring` and so takes the
233        // first string, without objecting to the count.
234        EpicsValue::StringArray(v) => v
235            .first()
236            .map(|s| s.as_str_lossy().into_owned())
237            .unwrap_or_default(),
238        // `nRequest == 1`, so the one byte IS `pstring[nRequest - 1]` and must
239        // be the terminator; the link text is then empty, which is how a CA
240        // client clears a link with a single NUL.
241        EpicsValue::Char(b) | EpicsValue::UChar(b) => {
242            if *b != 0 {
243                return Err(bad_type());
244            }
245            String::new()
246        }
247        EpicsValue::CharArray(b) | EpicsValue::UCharArray(b) => {
248            if b.last() != Some(&0) {
249                return Err(bad_type());
250            }
251            let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
252            String::from_utf8_lossy(&b[..end]).into_owned()
253        }
254        _ => return Err(bad_type()),
255    };
256    crate::server::record::check_link_assignment(record_type, Some(dtyp), field, &text)?;
257    Ok(Some(EpicsValue::String(text.into())))
258}
259
260/// Does an *external* put to `field` drive a processing cycle on this record?
261///
262/// C `dbPutField` (`dbAccess.c:1263-1268`) and pvxs `IOCSource::
263/// doPostProcessing` (`iocsource.cpp:397-403`) ask the same question with the
264/// same terms as C `processNotifyCommon` (dbNotify.c:243-246): the `PROC` field
265/// always, else a `pp(TRUE)` field on a Passive record. (`dbrType <
266/// DBR_PUT_ACKT` is subsumed: the alarm-ack fields are not `pp(TRUE)`.) A caller
267/// that FORCES processing (`record._options.process=true`) does not consult this
268/// at all — force is the caller's own term, not the record's.
269///
270/// `PROC` and `UDF` are the ONLY two `dbCommon` `pp(TRUE)` fields
271/// (`dbCommon.dbd.pod`: PROC line 243, UDF line 552); every other `pp(TRUE)`
272/// field is declared per record TYPE and reached through
273/// [`Record::processes_after_put`](crate::server::record::Record::processes_after_put). Because the two `dbCommon` fields are NOT in
274/// any type's `process_passive_fields()` table, they are named here directly:
275/// `PROC` unconditionally (force-process on any SCAN), `UDF` on the Passive
276/// branch (an ordinary `pp` field, so it processes only when `SCAN == 0`, unlike
277/// PROC). Both `dbCommon` `pp(TRUE)` fields are thus handled at this one owner
278/// gate, uniformly for every record type. A put to UDF is accepted+stored by
279/// `put_common_field`; this gate only adds the process cycle, after which the
280/// record recomputes alarms → NO_ALARM (C ends STAT/SEVR=NO_ALARM likewise).
281///
282/// Single owner of the rule: the single-record put route
283/// ([`PvDatabase::put_record_field_from_ca`]) tests it while it already holds
284/// the instance, and the QSRV group PUT — whose C twin is `doPostProcessing` —
285/// reaches it through [`PvDatabase::put_drives_processing`]. Neither can drift
286/// from C or from the other.
287///
288/// `field` must already be upper-cased.
289pub(crate) fn put_drives_processing_of(
290    instance: &crate::server::record::RecordInstance,
291    field: &str,
292) -> bool {
293    field == "PROC"
294        || (instance.common.scan == crate::server::record::ScanType::Passive
295            && (field == "UDF" || instance.record.processes_after_put(field)))
296}
297
298/// Drain the record's per-cycle post marks ([`Record::take_cycle_posted_fields`](crate::server::record::Record::take_cycle_posted_fields))
299/// into monitor posts — the put-path counterpart of the `db_post_events` calls a
300/// C `special()` makes by hand.
301///
302/// One owner, so the two put-path drains (the normal tail, and the failing
303/// `special()` above) cannot disagree about the mask mapping.
304fn emit_cycle_posts(
305    instance: &mut crate::server::record::RecordInstance,
306    backing: crate::server::database::LinkBacking<'_>,
307) {
308    use crate::server::record::{CyclePostMask, EventMask};
309    for (sf, cycle_mask) in instance.record.take_cycle_posted_fields() {
310        let mask = match cycle_mask {
311            CyclePostMask::Value => EventMask::VALUE,
312            // No `monitor_mask` exists on a put path (no alarm transition is
313            // being resolved), so both LOG-carrying variants reduce to C's
314            // literal `DBE_VALUE|DBE_LOG`.
315            CyclePostMask::ValueLog | CyclePostMask::MonitorValueLog => {
316                EventMask::VALUE | EventMask::LOG
317            }
318        };
319        instance.notify_field_backed(sf, mask, backing);
320    }
321}
322
323/// The registry half of a SNAM `special()` — C `subRecord.c::special`
324/// (`:170-194`) and `aSubRecord.c::special` (`:552-578`), which resolve
325/// `prec->snam` through `registryFunctionFind` and assign `prec->sadr`.
326///
327/// C runs it as `dbPutSpecial(paddr, 1)`, AFTER the field is stored
328/// (`dbAccess.c:1350-1403`), so the name resolved is the STORED one:
329/// `putStringString` truncates a DBF_STRING put to `field_size - 1` — 39 for
330/// sub's `size(40)`, 40 for aSub's `size(41)` — and C looks up whatever
331/// survived that. Resolving the value the caller handed in would bind a
332/// routine whose name the record does not hold.
333///
334/// `sadr` takes the lookup result UNCONDITIONALLY, NULL included, and only then
335/// does the status decide: a non-empty unregistered name is `S_db_BadSub`,
336/// which `dbPut` adopts as the put's status (`if (status2) status = status2;`)
337/// and whose `goto done` skips the UDF clear, the field's monitor post and — in
338/// `dbPutField` — the process. The name stays stored either way.
339///
340/// INVARIANT: `RecordInstance::subroutine` is the registry resolution of the
341/// record's current SNAM, C's `prec->sadr`. Three owners perform that
342/// transition and nothing else under `src/` writes the field:
343///
344/// - `IocApp` (`ioc_app.rs`) and `IocBuilder` (`ioc_builder.rs`) at iocInit —
345///   C's `init_record`, a different function running the same lookup.
346/// - `apply_asub_dynamic_sub` (`processing.rs`) for aSub `LFLG=READ`, whose C
347///   rule deliberately differs: `fetch_values` (`aSubRecord.c:262-266`) returns
348///   `S_db_BadSub` BEFORE assigning `sadr`, so READ mode KEEPS the old routine
349///   on an unregistered name. Routing it through this owner would clear it.
350/// - this function, for every `dbPut` route, because [`special_after_put`] is
351///   the one caller and every route goes through that.
352///
353/// `RecordInstance::new` initialises the field to `None`, which is the initial
354/// state and not a transition. `put_pv_no_process` (the autosave-restore entry)
355/// used to write SNAM while running NEITHER `special()` pass and so left the
356/// binding stale; it reaches this owner through [`special_after_put`] like
357/// every other put route. The remaining writers are `#[test]` fixtures binding
358/// a routine without a registry.
359fn snam_special_after_put(
360    db: &PvDatabase,
361    instance: &mut crate::server::record::RecordInstance,
362    field: &str,
363) -> CaResult<()> {
364    if !instance.record.is_subroutine_name_field(field) {
365        return Ok(());
366    }
367    let Some(EpicsValue::String(stored)) = instance.record.get_field(field) else {
368        return Ok(());
369    };
370    let name = stored.as_str_lossy();
371    if name.is_empty() {
372        // aSub: `pfunc = 0` with no error, so `caput X.SNAM ""` unbinds and
373        // succeeds (`aSubRecord.c:560-561`, stored at `:575`). sub's C returns
374        // at `subRecord.c:182-186` — `epicsPrintf`, `pact = TRUE` — WITHOUT
375        // reaching the `sadr` assignment, so the routine stays bound and the
376        // park is what stops it running. `parks_pact()` is that distinction:
377        // the record answering yes has just entered the parked state this put
378        // created. Clearing there too would make `RecordInstance::subroutine`
379        // mean either "nothing bound" or "a parked record's retained routine"
380        // depending on the record type.
381        if instance.record.parks_pact() {
382            // The other half of that same C branch, and the reason a client
383            // can see for the park: `epicsPrintf("%s.SNAM is empty\n",
384            // prec->name)` (`subRecord.c:183`), which is `errlogPrintf`
385            // (`errlog.h:90`) — the errlog, not stderr. It fires on EVERY put
386            // that leaves SNAM empty, including empty -> empty, because pass 0
387            // released the park before the store and this pass re-takes it.
388            //
389            // `parks_pact()` gates the line for the same reason it gates the
390            // retained binding above: in C the print and `pact = TRUE` are one
391            // block, and the only other record whose `special()` reaches an
392            // empty subroutine name is aSub, which is silent AND park-free
393            // (`aSubRecord.c:560-561`). A record that answers `parks_pact()`
394            // therefore owes this line; `enter_pact()` in `special_after_put`
395            // takes the park itself, on the same predicate, straight after.
396            crate::runtime::log::errlog_printf(&format!("{}.SNAM is empty\n", instance.name));
397        } else {
398            instance.subroutine = None;
399        }
400        return Ok(());
401    }
402    let resolved = db.find_subroutine_named(name.as_ref());
403    let bad_sub = resolved.is_none();
404    instance.subroutine = resolved;
405    if bad_sub {
406        return Err(CaError::BadField("SNAM: Subroutine not found".into()));
407    }
408    Ok(())
409}
410
411/// C `dbPutSpecial(paddr, 1)` — the after-put `special()`, paired with the drain
412/// of the link writes it queued ([`Record::take_special_actions`](crate::server::record::Record::take_special_actions)).
413///
414/// The pairing is the point: `special()` and its drain are ONE step, so a queued
415/// action cannot survive the put that queued it — not even when `special()`
416/// returns an error (C's `dbPut` `goto done`), because the drain happens before
417/// the status is propagated. The caller executes `out` once the record lock is
418/// released, ahead of the put-driven process cycle, which is where C runs it
419/// (inside `dbPut`, before `dbPutField`'s `dbProcess`).
420///
421/// Every `dbPut` path in this module goes through here; nothing else may call
422/// `Record::special(field, true)`.
423///
424/// It runs whether or not the store landed. C brackets it with
425/// `/* Always do special processing if needed */` (`dbAccess.c:1398`) and
426/// `if (status2) status = status2;` (`:1401-1402`), so on a refused store the
427/// pass still runs and its status REPLACES the store's; `if (status) goto done`
428/// (`:1404`) then skips the UDF clear and the field's monitor post. That is
429/// also what keeps the pair balanced: [`special_before_put`] has already
430/// latched OLDSIMM through `recGblSaveSimm`, and a store error returning
431/// between the two would leave the latch with nothing to consume it.
432///
433/// Returns the scan-index delta the after-put pass produced — non-`NoChange`
434/// only for the SIMM↔SSCN swap below, which the caller applies through
435/// `update_scan_index` once the record lock is down.
436fn special_after_put(
437    db: &PvDatabase,
438    instance: &mut crate::server::record::RecordInstance,
439    field: &str,
440    out: &mut Vec<crate::server::record::ProcessAction>,
441    backing: crate::server::database::LinkBacking<'_>,
442) -> CaResult<crate::server::record::CommonFieldPutResult> {
443    let mut status = instance.record.special(field, true);
444    // The record's half above and the registry half here are ONE C function,
445    // `special(paddr, 1)`, so they share the action drain, the error-path post
446    // emission and the status. The record cannot do the lookup itself — the
447    // function registry belongs to the database, not to the record.
448    if status.is_ok() {
449        status = snam_special_after_put(db, instance, field);
450    }
451    out.extend(instance.record.take_special_actions());
452    // C's `prec->udf = FALSE` written from inside `special()` — histogram's
453    // `clear_histogram` (`histogramRecord.c:361`) is the only one. Drained here
454    // and unconditionally for the same reason the actions above are: C performs
455    // the assignment before any status can divert `dbPut`.
456    if instance.record.take_udf_clear() {
457        instance.common.udf = 0;
458    }
459    if status.is_err() {
460        // The POSTS `special()` made are drained on the failing path for the same
461        // reason its ACTIONS are: C's `special()` calls `db_post_events` BEFORE it
462        // returns nonzero — aCalcout's NUSE arm posts the clamped value with
463        // `DBE_VALUE` and only then `return (-1)` (`aCalcoutRecord.c:495-499`) —
464        // and `dbPut`'s `goto done` skips only dbPut's OWN post. A refused put
465        // that repaired a field must still tell the subscribers what it repaired.
466        emit_cycle_posts(instance, backing);
467    }
468    status?;
469
470    // C `special()`'s CONSTANT-link re-seed (`calcoutRecord.c:373-378`,
471    // `sCalcoutRecord.c:513-518`, `aCalcoutRecord.c:533-538`,
472    // `transformRecord.c:715-723` — the four records whose C `special()` calls
473    // `recGblInitConstantLink`): a put that leaves an input link constant
474    // re-runs the load into that input's value field and posts it. Without it a
475    // constant link is load-once dead state — the link layer delivers nothing
476    // for a constant at process time, so `caput CO.INPB 7` would store the text
477    // and leave `B` at its `.db` value forever.
478    //
479    // The record only DECLARES the pairs (`special_reseed_input_links`); the
480    // load itself is the shared `rec_gbl_init_constant_link` owner, the same one
481    // the init seed uses. Records whose C `special()` does not re-seed (calc,
482    // sub, sel, aSub, swait, …) declare nothing and are untouched.
483    if let Some(value_field) =
484        crate::server::record::reseed_constant_input_link(&mut *instance.record, field)
485    {
486        // The mask is the C call site's, and they disagree — calcout posts a
487        // literal DBE_VALUE, transform DBE_VALUE|DBE_LOG. The record carries it.
488        let mask = instance.record.special_reseed_post_mask();
489        // `value_field` is the calc-class value slot behind the link just
490        // written (`INPA` -> `A`), i.e. exactly a link-backed field.
491        instance.notify_field_backed(value_field, mask, backing);
492    }
493
494    // C `special(SPC_MOD)` pass 1 on SIMM (`longinRecord.c:171-177` and the
495    // identical arm in all 21 SSCN-bearing records):
496    //   `recGblCheckSimm((dbCommon *)prec, &prec->sscn, prec->oldsimm, prec->simm);`
497    // Paired with `special_before_put`'s pass 0 (`recGblSaveSimm`), and gated
498    // per record type by `Record::uses_recgbl_simm_helpers`.
499    // C `subRecord.c::special` pass 1 (`:189-193`): the value is stored, so ask
500    // again — an SNAM that is still empty re-parks PACT, a real one leaves the
501    // record released by `special_before_put`. Only `enter_pact` here; C's pass 1
502    // never clears PACT, and neither may this.
503    if pact_park_field(&*instance.record, field) && instance.record.parks_pact() {
504        instance.enter_pact();
505    }
506
507    Ok(if field == "SIMM" {
508        instance.rec_gbl_check_simm()
509    } else {
510        crate::server::record::CommonFieldPutResult::NoChange
511    })
512}
513
514/// C `dbPutSpecial(paddr, 0)` — the before-put pass, run while the record lock
515/// is held and BEFORE the field's new value is stored.
516///
517/// The only `SPC_MOD` field in the record framework whose pass-0 does work is
518/// SIMM: `recGblSaveSimm` latches the outgoing simulation mode into OLDSIMM so
519/// the after-put pass can see the transition. Paired with
520/// [`special_after_put`]; every `dbPut` path in this module calls both.
521///
522/// The other pass-0 body is `subRecord.c::special`'s park release:
523/// `if (prec->snam[0] == 0 && prec->pact) { prec->pact = FALSE; prec->rpro =
524/// FALSE; }` (`subRecord.c:175-178`) — the record is parked exactly while it
525/// cannot process, so the store about to happen gets a clean slate and
526/// [`special_after_put`] re-takes the park if the NEW value still leaves the
527/// record unable to run. The record cannot reach PACT, so it answers
528/// [`Record::parks_pact`](crate::server::record::Record::parks_pact) and this performs the transition.
529///
530/// The release returns NOTHING to the caller, deliberately. C's `special` here
531/// is the two stores and no more — it does not call `dbNotifyCompletion`, and
532/// nothing else on a `dbPut` path does either. `restartCheck` has six call sites
533/// in `dbNotify.c`, and none is reachable from a put body: `:461` and `:465` are
534/// `dbNotifyCompletion`, the cycle tail; `:430` and `:434` are `dbNotifyCancel`
535/// (the symbol is `dbNotifyCancel`, `:385` — there is no `dbProcessNotifyCancel`
536/// in base) and `:290` is `notifyCallback`'s `cancelWait` branch, so a cancel;
537/// `:266` is neither — it sits in `processNotifyCommon` on the
538/// `notifyRestartCallbackRequested` arm, which is entered only from the callback
539/// (`:298`, `first == 0`) or from `dbProcessNotify` (`:382`, `first == 1`) and
540/// never from a field store. A put-notify parked on this record therefore stays
541/// parked across the release and is replayed by the record's NEXT cycle tail,
542/// through `PvDatabase::end_process_cycle` → `apply_pact_exit`, the single drain
543/// of `RecordInstance::notify_restart_list`.
544///
545/// So the `PactExit` this mints is dropped on purpose and loses nothing: the
546/// queue is on the record, and `RecordInstance::pact_exit_without_release`
547/// re-derives the bit at that tail. Do not re-add a put-body consumer for it —
548/// arming here is what made a `caput SUB.SNAM` complete a put-callback C leaves
549/// outstanding, and the cycle-ordering patch that contained the damage
550/// (a caller-declared `RestartOwner`) promised a cycle two frames before
551/// `write_db_link_value` decides whether one runs.
552fn special_before_put(instance: &mut crate::server::record::RecordInstance, field: &str) {
553    if field == "SIMM" {
554        instance.rec_gbl_save_simm();
555    }
556    if pact_park_field(&*instance.record, field)
557        && instance.record.parks_pact()
558        && instance.is_processing()
559    {
560        instance.common.rpro = 0;
561        let _ = instance.leave_pact();
562    }
563}
564
565/// Is `field` one C marks `special(SPC_MOD)` for this record's PACT park?
566fn pact_park_field(record: &dyn crate::server::record::Record, field: &str) -> bool {
567    record
568        .pact_park_fields()
569        .iter()
570        .any(|f| f.eq_ignore_ascii_case(field))
571}
572
573/// The `recGblResetAlarms` half of a C `monitor()` that a `special()` invokes
574/// (compress SPC_RESET, [`crate::server::record::Record::special_commits_alarms`]).
575///
576/// C's `monitor()` opens with `recGblResetAlarms(prec)` (compressRecord.c:103),
577/// committing `nsta`/`nsev` into `stat`/`sevr` — this is what clears the
578/// born-UDF alarm of a never-processed record the moment a reset field is put.
579/// Commits the alarm, posts any STAT/SEVR/AMSG/ACKS transition through the one
580/// owner ([`crate::server::database::processing::alarm_field_posts`]), and
581/// returns the `DBE_ALARM` mask C ORs into the value posts (`val_mask`,
582/// recGbl.c:212 — set iff any alarm-class field moved this cycle).
583///
584/// Shared by `dbPut`'s success tail and its rejected-conversion path: C runs
585/// `dbPutSpecial(paddr, 1)` on BOTH (dbAccess.c:1398-1404, "Always do special
586/// processing if needed", before the `goto done` that bails on a failed put) —
587/// a conversion that sets `status` at :1362/:1386 does NOT jump over it.
588fn commit_special_reset_alarm(
589    instance: &mut crate::server::record::RecordInstance,
590) -> crate::server::recgbl::EventMask {
591    use crate::server::recgbl::EventMask;
592    let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
593    for (af, mask) in
594        crate::server::database::processing::alarm_field_posts(&instance.common, &alarm_result)
595    {
596        instance.notify_field(af, mask);
597    }
598    if alarm_result.alarm_changed || alarm_result.amsg_changed {
599        EventMask::ALARM
600    } else {
601        EventMask::NONE
602    }
603}
604
605/// Coerce a write `value` to a record field's stored `target` type — C
606/// `dbConvert.c`'s `dbFastPutConvertRoutine[dbrType][field_type]` table.
607///
608/// The client-`dbPut` entry
609/// ([`crate::server::record::dbput_coerce_value`]), which renders the request
610/// in the destination field's shape and then runs the type row; the
611/// internal-delivery entry is `put_field_internal_default`. A `DBR_STRING` write to a `DBF_MENU` or
612/// `DBF_ENUM` field has a converter of its own in C (`putStringMenu`,
613/// `putStringEnum`) and must not fall through to `EpicsValue::convert_to`,
614/// which is field-blind and turns any unrecognised string into index 0 —
615/// C stores nothing and fails the put with `S_db_badChoice`.
616fn coerce_write_value(
617    record: &dyn crate::server::record::Record,
618    field: &str,
619    target: crate::types::DbFieldType,
620    value: EpicsValue,
621) -> CaResult<crate::types::c_parse::Converted> {
622    crate::server::record::dbput_coerce_value(record, field, target, value)
623}
624
625/// What a `dbPut` of a given value means for a given field — the single owner
626/// of C `dbPut`'s value branch (`dbAccess.c:1350-1391`, tag `R7.0.10`).
627enum PutRequest {
628    /// Write this value; already coerced to the field's native type.
629    Write(EpicsValue),
630    /// C stored nothing and the put still returns **success** — `status` stays
631    /// 0 either way. Two converters reach it: the ZERO-element request below,
632    /// and `cvt_st_ul`'s skipped store
633    /// ([`crate::types::c_parse::Converted::Unchanged`]), where the double
634    /// fallback parsed but landed outside `0..=UINT_MAX`.
635    ///
636    /// `alarm` is the SCALAR arm's `recGblSetSevr(precord, LINK_ALARM,
637    /// INVALID_ALARM)` (`dbAccess.c:1370-1371`, commit `12cfd418d`, whose
638    /// subject is "fix dbPut to *set* the target to INVALID/LINK alarm when
639    /// writing empty arrays into scalars" — not to reject the put). The ARRAY
640    /// arm never reaches that line, so a zero-length request into a
641    /// `special(SPC_DBADDR)` field raises nothing.
642    StoreNothing { alarm: bool },
643}
644
645/// Resolve a put into its C `dbPut` branch.
646///
647/// C picks the arm at `dbAccess.c:1350`:
648/// `if (nRequest>1 || paddr->pfldDes->special == SPC_DBADDR)`. Two independent
649/// tests, and neither is `no_elements`, which appears only in the ARRAY arm's
650/// clamp at `:1360` (`if (no_elements < nRequest) nRequest = no_elements;`).
651/// The `SPC_DBADDR` disjunct is the half that matters here: it sends a
652/// ZERO-length request into the array arm, where `dbPutConvertRoutine`
653/// (`:1362`) copies nothing, `put_array_info(paddr, 0)` (`:1367`) drops the
654/// valid length, `status` stays 0 and the put falls through to the UDF clear.
655/// The scalar arm's `recGblSetSevr(LINK_ALARM, INVALID_ALARM)` at `:1371` is
656/// therefore unreachable for an `SPC_DBADDR` field.
657///
658/// So the branch asks [`crate::server::record::FieldDeclaration::field_is_dbaddr`], the port's owner of
659/// the `.dbd` declaration. Keying it on the destination's CURRENT VALUE SHAPE
660/// instead is what made `caput -a MBBO.VAL 0` — `mbbo.VAL` is
661/// `special(SPC_DBADDR)` (`mbboRecord.dbd.pod:194`) but stored as a scalar —
662/// take the scalar arm and come back LINK/INVALID where the C IOC returns
663/// NO_ALARM.
664///
665/// The clamp keeps the value-shape probe, because that is the question C asks
666/// there: `no_elements` is the destination's capacity, and a field this port
667/// stores as a scalar has capacity 1. C's array arm with `nRequest > 1` into
668/// such a field clamps to one element and writes element 0, which is what
669/// reducing the array to [`EpicsValue::first_element`] produces.
670fn dbput_request(
671    record: &dyn crate::server::record::Record,
672    field: &str,
673    value: EpicsValue,
674) -> CaResult<PutRequest> {
675    use crate::server::record::FieldDeclaration;
676    // C `no_elements` for this destination: the count the ARRAY arm clamps to.
677    let dest_is_array = record.get_field(field).is_some_and(|v| v.is_array());
678    if value.is_empty_array() {
679        if !record.field_is_dbaddr(field) {
680            return Ok(PutRequest::StoreNothing { alarm: true });
681        }
682        // Array arm, `nRequest == 0`. A field this port stores as a Vec models
683        // `put_array_info(paddr, 0)` by storing the empty array — its NORD
684        // follows the value — and falls through to the coercion below. A
685        // `special(SPC_DBADDR)` field stored as a SCALAR (`mbbo.VAL`, an `aSub`
686        // channel at `NOA == 1`) has no valid length to drop, so it keeps the
687        // value C's zero-element copy left untouched.
688        if !dest_is_array {
689            return Ok(PutRequest::StoreNothing { alarm: false });
690        }
691    }
692    // The coercion target is the type the record STORES, not the type it
693    // SERVES — `put_field`'s arms match on what is stored. A `menu()` field is
694    // declared `DBF_MENU` and served as `DBR_ENUM` with its choices, but held as
695    // a bare `Short` choice index; coercing an incoming `Short` up to `Enum`
696    // because the `.dbd` says `DBF_MENU` would make its `Short` arm unreachable.
697    // Same rule as `put_field_internal_default` and `db_loader::apply_fields`.
698    // The `.dbd` type is the fallback for a field with no current value.
699    let target = record
700        .get_field(field)
701        .map(|v| v.db_field_type())
702        .or_else(|| crate::server::record::record_instance::declared_field_type_of(record, field));
703
704    // Both remaining halves of the branch belong to `coerce_write_value`, and
705    // this body must not re-derive either of them: the request is rendered in
706    // the destination field's shape and then converted, in that order, by the
707    // one `dbPut` entry (`record::dbput_coerce_value`).
708    //
709    // UNCONDITIONALLY. Skipping the converter when the request already carried
710    // the destination's DBF is what let a scalar reach a buffer field
711    // unrendered — the shape row lives inside the entry, so the type match says
712    // nothing about whether there is work to do — and a String target had to be
713    // named as an exception for the same reason (`putStringString` truncates to
714    // `field_size - 1`, so it is not a no-op on a type match either). With no
715    // gate there is no exception list to keep in step.
716    match target {
717        Some(target) => match coerce_write_value(record, field, target, value)? {
718            crate::types::c_parse::Converted::Stored(v) => Ok(PutRequest::Write(v)),
719            // C's `cvt_st_ul` returned success without storing: the same
720            // "nothing stored, status still 0" arm the zero-element request
721            // takes, and with no `recGblSetSevr` — the converter never reaches
722            // `dbAccess.c:1371`.
723            crate::types::c_parse::Converted::Unchanged => {
724                Ok(PutRequest::StoreNothing { alarm: false })
725            }
726        },
727        // Neither a current value nor a declaration says what this field takes,
728        // so there is no destination to render or convert against.
729        None => Ok(PutRequest::Write(value)),
730    }
731}
732
733/// C `dbAccess.c:1409-1410` — `isValueField = dbIsValueField(pfldDes);
734/// if (isValueField) precord->udf = FALSE;`.
735///
736/// The single owner of `dbPut`'s UDF clear, because in C there is one: the two
737/// lines sit AFTER the value branch has joined, so the arm that converted zero
738/// elements reaches them exactly as the arm that stored a value does. The only
739/// thing that skips them is a non-zero `status` — the `goto done` at `:1404`,
740/// which in this port is the early return out of `put_field` /
741/// `special_after_put`.
742///
743/// It clears `udf` and NOTHING else: stat/sevr keep their old UDF_ALARM until
744/// the record's own process cycle recomputes them (`rec_gbl_check_udf` no
745/// longer raises it once udf is clear, `rec_gbl_reset_alarms` commits the new
746/// state). A value put that drives no process therefore leaves the stale UDF
747/// alarm standing, as C does.
748fn clear_udf_on_value_put(instance: &mut crate::server::record::RecordInstance, field: &str) {
749    if instance.record.is_udf_defining_put(field) {
750        instance.common.udf = 0;
751    }
752}
753
754/// Apply the SCALAR arm's [`PutRequest::StoreNothing`] alarm: the field is left
755/// untouched and the record is driven to `LINK_ALARM`/`INVALID_ALARM`
756/// (`dbAccess.c:1371` `recGblSetSevr(precord, LINK_ALARM, INVALID_ALARM)`).
757fn set_empty_request_alarm(instance: &mut crate::server::record::RecordInstance) {
758    crate::server::recgbl::rec_gbl_set_sevr(
759        &mut instance.common,
760        crate::server::recgbl::alarm_status::LINK_ALARM,
761        crate::server::record::AlarmSeverity::Invalid,
762    );
763}
764
765/// What the put entry point owes the caller in the way of completion — the
766/// `dbPutField` / `dbPutNotify` split, plus the restart C's `dbNotify` state
767/// machine performs on a put-notify that had to wait for a PACT record.
768///
769/// The completion *sender* travels with the request: a restarted put must
770/// signal the ORIGINAL caller's receiver, which was handed out when the put
771/// first arrived and was deferred, so the restart cannot mint a fresh channel.
772enum NotifyRequest {
773    /// C `dbPutField` — process the record, build no `putNotify`.
774    None,
775    /// C `dbPutNotify` arriving fresh from a client: mint the wait-set channel.
776    New,
777    /// C `dbNotify.c:213-224` restart: the client's receiver already exists,
778    /// this replay carries its sender.
779    Deferred(crate::runtime::sync::oneshot::Sender<()>),
780}
781
782/// Which of C `processNotifyCommon`'s two entries a put-notify arrives on
783/// (its `first` argument, dbNotify.c:207).
784///
785/// The two differ only in what defers them, and that difference is why the
786/// entry test cannot live inside the install owner: a fresh arrival must not
787/// jump a queue, a replay IS the queue head.
788#[derive(Clone, Copy)]
789enum NotifyArrival {
790    /// `dbProcessNotify` -> `processNotifyCommon(ppn, precord, 1)`: reaching
791    /// the record for the first time.
792    Fresh,
793    /// `notifyCallback` -> `processNotifyCommon(ppn, precord, 0)`: already
794    /// popped off the restart list by `take_next_notify_restart`, C
795    /// `restartCheck` (dbNotify.c:149-170).
796    Replay,
797}
798
799impl NotifyArrival {
800    /// Must this arrival queue rather than take the slot?
801    ///
802    /// A FRESH notify defers on the full test (owned, PACT, or someone already
803    /// queued) -- C `processNotifyCommon:213` plus the PACT arm at `:225`, and
804    /// the restart-list term so a notify arriving between a completion and the
805    /// restart check cannot jump the queue. A REPLAY defers only on an
806    /// occupied slot: it must take the record with its successors still queued
807    /// behind it, exactly as `restartCheck` assigns `precord->ppn = pfirst`
808    /// while leaving the rest of `restartList` in place.
809    fn defers(self, record: &crate::server::record::RecordInstance) -> bool {
810        match self {
811            NotifyArrival::Fresh => record.notify_put_is_owned(),
812            NotifyArrival::Replay => record.notify.is_some(),
813        }
814    }
815}
816
817impl NotifyRequest {
818    fn wants_notify(&self) -> bool {
819        !matches!(self, NotifyRequest::None)
820    }
821
822    /// C `pnotifyPvt->state == notifyRestartCallbackRequested` (dbNotify.c:213)
823    /// — this put already owns the record, so the ownership test that stops a
824    /// fresh arrival does not apply to it.
825    fn is_restart(&self) -> bool {
826        matches!(self, NotifyRequest::Deferred(_))
827    }
828
829    /// The completion sender, plus the receiver to hand back — `Some` only for
830    /// a fresh request; a restart's receiver went to the client at deferral.
831    #[allow(clippy::type_complexity)]
832    fn into_completion(
833        self,
834    ) -> Option<(
835        crate::runtime::sync::oneshot::Sender<()>,
836        Option<crate::runtime::sync::oneshot::Receiver<()>>,
837    )> {
838        match self {
839            NotifyRequest::None => None,
840            NotifyRequest::New => {
841                let (tx, rx) = crate::runtime::sync::oneshot::channel();
842                Some((tx, Some(rx)))
843            }
844            NotifyRequest::Deferred(tx) => Some((tx, None)),
845        }
846    }
847}
848
849/// The teardown owner for a blocking external client PUT — the PVA/QSRV
850/// counterpart of C `rsrvFreePutNotify` (`camessage.c:1630-1638`), the CA
851/// server's own client-teardown call into `dbNotifyCancel`.
852///
853/// # Invariant (CONTRACT)
854///
855/// A blocking external PUT whose awaiting future is dropped before the
856/// completion arrives MUST release the record's put-notify slot before it
857/// goes. Leaving that to the next put's arrival-time sweep is not enough: a
858/// client already parked on `notify_restart_list` is not an arrival, so it
859/// waits behind a completion that can never come.
860///
861/// pvxs has no separate hook to mirror because a blocking put IS the
862/// operation's future there: the native PVA server spawns the PUT EXEC body
863/// and stores its abort handle on the op (`server_native::tcp`,
864/// `finish_exec_data_task`), so DESTROY_CHANNEL and connection teardown alike
865/// drop `ChannelState::ops`, abort the task and drop this future mid-await.
866/// The source's `notify_channel_close` is deliberately NOT the owner: an
867/// abort only *marks* the task, and the future is dropped whenever the
868/// executor next reaches it, so a sweep run from the close callback can find
869/// the receiver still alive and release nothing.
870///
871/// The receiver is owned HERE and closed by hand because `Drop::drop` runs
872/// BEFORE a struct's own fields drop — asking the database first would find
873/// `Sender::is_closed()` still false and sweep nothing.
874struct ClientAwaitingNotify<'a> {
875    db: &'a PvDatabase,
876    record: &'a str,
877    rx: crate::runtime::sync::oneshot::Receiver<()>,
878    /// Set once the await returns, which disarms the release: the completion
879    /// path owns the slot from that point.
880    answered: bool,
881}
882
883impl Drop for ClientAwaitingNotify<'_> {
884    fn drop(&mut self) {
885        if self.answered {
886            return;
887        }
888        self.rx.close();
889        self.db.cancel_unanswerable_notify(self.record);
890    }
891}
892
893/// The one claim on a record's put-notify slot for the whole gate-held CA put
894/// body, and the single finalizer every exit path out of it passes through.
895///
896/// C takes the ownership test (`if (precord->ppn ...)`, dbNotify.c:213) and
897/// the install (`precord->ppn = ppn`, `:257`) inside one `dbScanLock` region
898/// held across `putCallback`, and a record's whole link chain is in that same
899/// lock set, so nothing can observe the interval between them. This port's L1
900/// gate is per-RECORD, so `join_put_notify` — reached from ANOTHER record's
901/// chain (`links.rs:1517`, C `dbNotifyAdd`) — does not take it and could slip
902/// into that interval and take the slot. Claiming in the same critical section
903/// as the test closes the window: the slot is occupied from the test onward,
904/// which is why the "somebody took it while we were writing" arm the two
905/// dispatched park sites used to carry is gone rather than tested.
906///
907/// **Release is the default.** The wait-set is on the record from the claim
908/// onward — ahead of the DISP gate, the SPC_NOMOD gate, the conversion,
909/// `special()`, and every `?` in them — so a path that abandons the put must
910/// clear it or the record stays owned with no owner. `Drop` does that, and
911/// [`NotifyClaim::commit`] is the only way to keep it: none of the early
912/// returns carries a line of cleanup, and a new one cannot forget to.
913#[must_use = "dropping the claim releases the record's put-notify slot"]
914struct NotifyClaim<'a> {
915    rec: &'a std::sync::Arc<parking_lot::RwLock<crate::server::record::RecordInstance>>,
916    /// `None` once committed — the record processes under the set and owns its
917    /// release from then on (`complete_put_notify`, C `dbNotifyCompletion`).
918    set: Option<std::sync::Arc<crate::server::record::NotifyWaitSet>>,
919}
920
921impl NotifyClaim<'_> {
922    /// The put reached its process cycle: hand the wait-set over and stop
923    /// releasing it. From here the record's completion owns it
924    /// (`complete_put_notify`, C `dbNotifyCompletion`).
925    fn commit(mut self) -> std::sync::Arc<crate::server::record::NotifyWaitSet> {
926        self.set
927            .take()
928            .expect("a claim is committed exactly once, by value")
929    }
930
931    /// Same commit, for the two paths that drive a process cycle and then hand
932    /// the client an `Err` instead of the completion: a rejected PROC
933    /// conversion and "Cause B", a rejected write on the notify route.
934    ///
935    /// Committing is not a courtesy here, it is C: `putCallback` returns
936    /// `didPut = 1` even when the write failed (`dbNotify.c:528-530`), so
937    /// `processNotifyCommon` reaches `doProcess`, assigns `precord->ppn = ppn`
938    /// and processes (`:243-256`). The record therefore owns the set and its
939    /// own completion releases it; releasing here as well would clear a slot
940    /// the cycle is using.
941    fn commit_without_waiting(self) {
942        let _ = self.commit();
943    }
944}
945
946impl Drop for NotifyClaim<'_> {
947    fn drop(&mut self) {
948        if let Some(set) = self.set.take() {
949            self.rec.write().abandon_put_notify(&set);
950        }
951    }
952}
953
954/// Snapshot NORD before a `dbPut` writes the value field — C `put_array_info`
955/// opens with `epicsUInt32 nord = prec->nord;` (`waveformRecord.c:202-216`).
956///
957/// `None` for a put to any field but VAL, and for a record type that has no
958/// NORD (the comparison in [`post_array_info`] then reduces to "unchanged").
959fn array_nord_before_put(
960    instance: &crate::server::record::RecordInstance,
961    field: &str,
962) -> Option<EpicsValue> {
963    if field == "VAL" {
964        instance.record.get_field("NORD")
965    } else {
966        None
967    }
968}
969
970/// The tail of C `put_array_info`, and the SINGLE owner of the NORD post:
971///
972/// ```c
973/// if (nord != prec->nord)
974///     db_post_events(prec, &prec->nord, DBE_VALUE | DBE_LOG);
975/// ```
976///
977/// `put_array_info` is called from `dbPut`, so it is reached by EVERY put
978/// route — CA, `dbPutLink`, internal — and by none of them conditionally. The
979/// port's array records (waveform/aai/aao/subArray) re-derive NORD inside
980/// `put_field("VAL")`; this is the post half, and every `dbPut` body in this
981/// module calls it after the value write, passing the snapshot taken by
982/// [`array_nord_before_put`].
983///
984/// Note this post is NOT the process cycle's monitor post: the compiled softIoc
985/// on a 10-second-SCAN waveform posts `NORD = 3` the instant `caput -a WP 3
986/// 1 2 3` lands, and posts no VAL at all (waveform VAL is `pp(TRUE)`, so C
987/// suppresses the value-field post in `dbPut` and the scan is 10 seconds away).
988fn post_array_info(
989    instance: &mut crate::server::record::RecordInstance,
990    old_nord: &Option<EpicsValue>,
991    origin: u64,
992    backing: crate::server::database::LinkBacking<'_>,
993) {
994    let Some(old) = old_nord else { return };
995    let moved = instance
996        .record
997        .get_field("NORD")
998        .is_some_and(|new| new != *old);
999    if moved {
1000        instance.notify_field_with_origin(
1001            "NORD",
1002            crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
1003            origin,
1004            backing,
1005        );
1006    }
1007}
1008
1009/// C `dbPut`'s field-monitor tail (`dbAccess.c:1408-1418`) — **the one post
1010/// rule every `dbPut` body shares**, reached by every put route (CA,
1011/// `dbPutLink`, internal `put_pv`):
1012///
1013/// ```c
1014/// if (precord->mlis.count &&
1015///     !(isValueField && pfldDes->process_passive))
1016///     db_post_events(precord, pfieldsave, DBE_VALUE | DBE_LOG);
1017/// ```
1018///
1019/// The immediate post is suppressed for the value field ONLY when that field
1020/// is `pp(TRUE)`: for the routes that then process (`dbPutField`'s pp gate,
1021/// a ` PP` OUT link's `processTarget`), the cycle re-posts it via the
1022/// deadband snapshot with a fresh timestamp; for the routes that do not (an
1023/// NPP OUT link, a bare `dbPut` from driver code), C is silent until the
1024/// next scan — measured against softIoc in `array_put_posts_nord.rs`'s
1025/// header. For a value field that is NOT `pp` (calc/calcout/aSub VAL), this
1026/// post is the only one there is.
1027///
1028/// The suppression is a static property of the field, never of the caller's
1029/// intent — C's `pfldDes->process_passive` is DBD data — which is what makes
1030/// it shareable: `put_pv` (which never processes) and the CA route (which
1031/// may) apply the identical rule, exactly as C's one `dbPut` serves both
1032/// `dbPutLink` and `dbPutField`. `process_passive_fields()` is total and
1033/// fail-safe: any field of an unmodeled type (`&[]`) posts.
1034fn dbput_post_put_field(
1035    instance: &mut crate::server::record::RecordInstance,
1036    field: &str,
1037    backing: crate::server::database::LinkBacking<'_>,
1038) {
1039    let suppress = field == instance.record.primary_field()
1040        && instance
1041            .record
1042            .process_passive_fields()
1043            .iter()
1044            .any(|f| f.eq_ignore_ascii_case(field));
1045    if !suppress {
1046        instance.cleanup_subscribers();
1047        instance.notify_field_backed(
1048            field,
1049            crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
1050            backing,
1051        );
1052    }
1053}
1054
1055/// Does `record_type` declare a field called `field`?
1056///
1057/// C's `dbFindFieldPart` searches `papFldDes`, which is every field the
1058/// `.dbd` declared including the spliced-in dbCommon and the `DBF_NOACCESS`
1059/// internals — exactly what `record_declaration_order` carries. A record type
1060/// the generated table does not know declares nothing, which is what C's
1061/// `!precordType` guard answers too.
1062fn declares_field(record_type: &str, field: &str) -> bool {
1063    crate::server::record::dbd_generated::record_declaration_order(record_type)
1064        .is_some_and(|names| names.contains(&field))
1065}
1066
1067impl PvDatabase {
1068    /// Get a PV value synchronously, from a thread that cannot `await`.
1069    ///
1070    /// Works from a plain thread with no runtime entered (an iocsh thread, a
1071    /// driver's own thread) and from a multi-threaded runtime worker; see
1072    /// [`crate::runtime::task::block_on_sync`] for which mechanism is used
1073    /// where.
1074    ///
1075    /// Kept as a distinct entry point for source compatibility with the
1076    /// callers that predate [`Self::get_pv`] becoming a `fn`; there is no
1077    /// blocking left to do, so there is no current-thread-runtime failure mode
1078    /// either. C `dbGetField` is likewise a plain call from any thread.
1079    pub fn get_pv_blocking(&self, name: &str) -> CaResult<EpicsValue> {
1080        self.get_pv(name)
1081    }
1082
1083    /// Get the current value of a PV or record field.
1084    /// Uses resolve_field for records (3-level priority).
1085    pub fn get_pv(&self, name: &str) -> CaResult<EpicsValue> {
1086        let (base, field) = super::parse_pv_name(name);
1087        let field = field.to_ascii_uppercase();
1088
1089        // Check simple PVs first (exact match)
1090        let simple = self.inner.simple_pvs.lock().get(name).cloned();
1091        if let Some(pv) = simple {
1092            return Ok(pv.get());
1093        }
1094
1095        // Records — alias-aware via `get_record` (epics-base PR #336).
1096        if let Some(rec) = self.get_record(base) {
1097            let instance = rec.read();
1098            // C `pvNameLookup` (`dbChannel.c:311-329`) resolves a field name
1099            // against the record type's DECLARED field list first and falls
1100            // through to `dbGetAttributePart` only on
1101            // `S_dbLib_fieldNotFound`. So a record type that declares a field
1102            // of the attribute's name — `motor.VERS` — shadows the attribute,
1103            // and `RTYP`, which no record type declares, never is shadowed.
1104            //
1105            // The two tests are in this order because the attribute map holds
1106            // two entries per type and the declared list holds hundreds: the
1107            // cheap lookup decides whether the expensive one is needed at all.
1108            let record_type = instance.record.record_type();
1109            if let Some(value) = self.record_type_attribute(record_type, &field)
1110                && !declares_field(record_type, &field)
1111            {
1112                return Ok(EpicsValue::String(value.into()));
1113            }
1114            if let Some(value) = instance.resolve_field(&field) {
1115                return Ok(value);
1116            }
1117            // Resolve-ok-but-read-fail is a state of its own, and one
1118            // `ChannelNotFound` cannot hold it. C `dbNameToAddr` resolves any
1119            // field the `.dbd` declares, `DBF_NOACCESS` ones included, so a
1120            // declared field's read reaches `dbGet` and fails THERE — its
1121            // validity gate refuses `field_type > DBF_DEVICE` with
1122            // `S_db_badDbrtype` — while an undeclared name never resolves at
1123            // all. `dbgf` shows the two apart: `dbgf REC.TIME` prints a type
1124            // header and then `failed.` (`dbTest.c:994-997`, reached only
1125            // because the address resolved), where `dbgf REC.NOSUCH` prints
1126            // "not found".
1127            //
1128            // The rule is the declaration, not the `DBF_NOACCESS` class: a
1129            // field this record type declares but does not serve is the same
1130            // state — present, unreadable — and C would likewise resolve it
1131            // and fail the get. Naming the class here instead would put a
1132            // second rule at the boundary.
1133            return Err(if declares_field(record_type, &field) {
1134                CaError::BadDbrType(format!(
1135                    "dbGet: {name} is declared but has no readable value"
1136                ))
1137            } else {
1138                CaError::ChannelNotFound(name.to_string())
1139            });
1140        }
1141
1142        Err(CaError::ChannelNotFound(name.to_string()))
1143    }
1144
1145    /// Set a PV value or record field — the C `dbPut` analogue
1146    /// (`dbAccess.c:1316-1419`), whole: value write + `special`/`on_put`, the
1147    /// value-field UDF clear, and the field's `DBE_VALUE|DBE_LOG` monitor
1148    /// post (`dbput_post_put_field`, suppressed only for a `pp(TRUE)` value
1149    /// field exactly as C's tail suppresses it). Tries record `put_field`
1150    /// first, then `put_common_field` as fallback.
1151    ///
1152    /// Does NOT process the record — `dbPutField`'s pp gate is
1153    /// [`Self::put_record_field_from_ca`] — so, as with a bare C `dbPut`, a
1154    /// `pp(TRUE)` value field (ai/ao/waveform VAL …) posts nothing here and a
1155    /// caller that needs a monitor on such a field must either drive a
1156    /// process or use [`Self::put_pv_and_post`].
1157    ///
1158    /// Acquires the record's advisory write gate.
1159    pub async fn put_pv(&self, name: &str, value: EpicsValue) -> CaResult<()> {
1160        let _record_gate = self.acquire_put_gate(name);
1161        self.put_pv_already_locked(name, value)
1162    }
1163
1164    /// `put_pv` variant for a caller already holding the
1165    /// record's advisory write gate (QSRV atomic group PUT). See
1166    /// [`Self::put_record_field_from_ca_already_locked`].
1167    ///
1168    /// This is the whole `dbPut` body — the gate-held region, and it is a
1169    /// `fn`. See `acquire_put_gate`.
1170    pub fn put_pv_already_locked(&self, name: &str, value: EpicsValue) -> CaResult<()> {
1171        self.put_pv_body(name, value)
1172    }
1173
1174    /// Take the L1 advisory write gate a put to `name` needs, if any.
1175    ///
1176    /// The gate boundary of the whole put family: EVERY `_already_locked`
1177    /// entry is the body below this line and contains no `.await`, so an
1178    /// caller that already owns the gate calls the body directly and a caller
1179    /// that does not calls this first. C's shape exactly — `dbPutField` does
1180    /// `dbScanLock(precord)` … `dbScanUnlock(precord)` around a `dbPut` that
1181    /// itself never blocks (`dbAccess.c:1246-1300`).
1182    ///
1183    /// `None` when `name` names no record: a simple PV has no `dbCommon` and
1184    /// therefore no `dbScanLock` in C either. The record lookup is repeated by
1185    /// the body — a map read, and records are never removed once loaded.
1186    fn acquire_put_gate(&self, name: &str) -> Option<super::record_lock::RecordWriteGuard> {
1187        let (base, _) = super::parse_pv_name(name);
1188        self.get_record(base)?;
1189        let canonical: String = self.resolve_alias(base).unwrap_or_else(|| base.to_string());
1190        Some(self.lock_record(&canonical))
1191    }
1192
1193    /// C `IOCSource::doPreProcessing` gate (pvxs `iocsource.cpp:363-375`).
1194    ///
1195    /// Reject an *external* put (a PVA/CA client put routed through QSRV)
1196    /// that C refuses before any write: a put to a `DISP=1` record's
1197    /// non-DISP field (`S_db_putDisabled`) or to a read-only / `SPC_NOMOD`
1198    /// field (`S_db_noMod`). No value is written — this is a precondition
1199    /// check only. It mirrors the two gates inside
1200    /// [`Self::put_record_field_from_ca`] (the Passive route) so the QSRV
1201    /// `Force`/`Inhibit` routes — which go through [`Self::put_pv`] — enforce
1202    /// the same preconditions. `put_pv` itself is the internal `dbPut`
1203    /// analogue and deliberately does not gate DISP (internal
1204    /// link/processing puts must bypass it), so the gate lives at the
1205    /// external put boundary, exactly as C places `doPreProcessing` in the
1206    /// source layer rather than in `dbPut`.
1207    pub async fn check_external_put_preconditions(
1208        &self,
1209        record_name: &str,
1210        field: &str,
1211    ) -> CaResult<()> {
1212        let field_upper = field.to_ascii_uppercase();
1213        // A missing record is not a DISP/read-only precondition violation:
1214        // stay silent and let the downstream put report the not-found (for
1215        // QSRV, inside its own `asTrapWrite` bracket). C's `doPreProcessing`
1216        // only runs against an established channel — the record is
1217        // guaranteed present there — and a `BridgeChannel` likewise always
1218        // binds a real record in production.
1219        let Some(rec) = self.get_record(record_name) else {
1220            return Ok(());
1221        };
1222        let instance = rec.read();
1223        // Read-only / SPC_NOMOD field, through the one gate owner
1224        // ([`check_no_mod`]). C tests SPC_ATTRIBUTE *before* `disp`
1225        // (iocsource.cpp:365-369), so a read-only field on a DISP=1 record
1226        // reports S_db_noMod, not S_db_putDisabled; the two errors carry
1227        // different wire text.
1228        check_no_mod(&instance, &field_upper)?;
1229        // DISP=1 blocks a put to any field except DISP itself — the shared
1230        // gate owner, identical to the one the CA route crosses.
1231        check_put_disabled(&instance, &field_upper)?;
1232        Ok(())
1233    }
1234
1235    /// pvxs `IOCSource::doPostProcessing`'s record-side terms
1236    /// (`iocsource.cpp:397-403`): does a put to `record_name.field` drive a
1237    /// processing cycle on its own?
1238    ///
1239    /// The QSRV group PUT asks this for a member whose write bypassed
1240    /// [`Self::put_record_field_from_ca`] (a `+type:"proc"` trigger, or a
1241    /// member that is `changing` but has no writable leaf), so that route
1242    /// applies the SAME gate as a plain field put instead of processing
1243    /// unconditionally. `false` for an unknown record — there is nothing to
1244    /// process. Force (`record._options.process=true`) is the caller's term
1245    /// and is not asked about here; see `put_drives_processing_of`.
1246    pub fn put_drives_processing(&self, record_name: &str, field: &str) -> bool {
1247        let field_upper = field.to_ascii_uppercase();
1248        let Some(rec) = self.get_record(record_name) else {
1249            return false;
1250        };
1251        let instance = rec.read();
1252        put_drives_processing_of(&instance, &field_upper)
1253    }
1254
1255    /// Is `record_name.field` a DBF link field (INLINK/OUTLINK/FWDLINK)?
1256    ///
1257    /// The one owner of the classification lookup
1258    /// ([`crate::types::dbf_link_class`] keyed by the record's type). C
1259    /// callers split on it before choosing a put entry: `dbPutField` sends
1260    /// link fields to `dbPutFieldLink` (`dbAccess.c:1261`), `dbProcessNotify`
1261    /// short-circuits them past the notify machinery (`dbNotify.c:337-354`),
1262    /// and pvxs QSRV picks `dbChannelPutField` over `dbChannelPut` for them
1263    /// (`iocsource.cpp:451-458`). Port callers with a dbPutField-shaped
1264    /// entry make the same split against the `dbPut`-analogue bodies' refusal
1265    /// (`check_not_link_field`). `false` for an unknown record or a non-link
1266    /// field.
1267    pub fn is_dbf_link_field(&self, record_name: &str, field: &str) -> bool {
1268        let field_upper = field.to_ascii_uppercase();
1269        let Some(rec) = self.get_record(record_name) else {
1270            return false;
1271        };
1272        let guard = rec.read();
1273        crate::types::dbf_link_class(guard.record.record_type(), &field_upper).is_some()
1274    }
1275
1276    fn put_pv_body(&self, name: &str, value: EpicsValue) -> CaResult<()> {
1277        let (base, field) = super::parse_pv_name(name);
1278        let field = field.to_ascii_uppercase();
1279        // C `dbPutFieldLink` (`dbAccess.c:1261`): a write to a DBF link field
1280        // relinks the target's lock set. The obligation is taken out here, so
1281        // that every exit path below discharges it; see
1282        // `record_lock.rs`'s `LinkFieldWrite`.
1283        let _relink = self.link_field_write(base, &field);
1284
1285        // Check simple PVs first. The lookup is its own statement so the
1286        // `!Send` directory guard is down before `pv.set(…).await` — see the
1287        // `simple_pvs` field doc in `database/mod.rs`.
1288        let simple = self.inner.simple_pvs.lock().get(name).cloned();
1289        if let Some(pv) = simple {
1290            pv.set(value);
1291            return Ok(());
1292        }
1293
1294        // Records — alias-aware (epics-base PR #336).
1295        if let Some(rec) = self.get_record(base) {
1296            // `base` may be an alias; resolve to the canonical record
1297            // name so scan-index updates target the right entry.
1298            let canonical_base: String =
1299                self.resolve_alias(base).unwrap_or_else(|| base.to_string());
1300            // The caller holds the advisory write gate (`dbScanLock`
1301            // analogue) for `canonical_base` — see `acquire_put_gate`.
1302            // It is held to the `return Ok(())` below, and that is the point:
1303            // C's `dbScanLock` covers `dbPut` *including*
1304            // `dbPutSpecial(paddr, 1)` and the scan-list move, so the tails
1305            // below are inside the exclusion window in C and must be inside it
1306            // here. Shrinking the window to end at the value write would
1307            // re-open exactly the interleaving `lock_records` exists to close
1308            // (`record_lock.rs`, "Rust port").
1309            // Scoped guard: everything the put commits happens under this
1310            // write guard, which ends (releasing the `!Send` parking_lot
1311            // guard) before the tails below, which re-enter the database.
1312            // Yields the owned outputs those tails consume. Note this is the
1313            // record's DATA lock coming down, not the advisory gate above.
1314            use crate::server::record::CommonFieldPutResult;
1315            // C reads a link-backed field's metadata live inside the rset,
1316            // under the TARGET record's lock; every poster below holds THIS
1317            // record's lock and cannot reach for a second one. Resolved here,
1318            // where no record lock is held, and handed down as a borrowed
1319            // value that dies with this body — so a `caput CALC.A` carries the
1320            // metadata `INPA`'s target has NOW, not what it had when the
1321            // source last processed. Empty after one read lock for every
1322            // record type but calc, calcout, sub, aSub and seq.
1323            let link_backing = self.resolve_link_backed_metadata(&rec);
1324            let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
1325            let (common_result, special_actions) = {
1326                let mut instance = rec.write();
1327
1328                // C `dbPut` refuses an SPC_NOMOD / SPC_ATTRIBUTE field before it
1329                // converts anything (`dbAccess.c:1330-1332`). `put_pv` IS the
1330                // `dbPut` analogue — it sits below `dbPutLink`, so this is what
1331                // stops a record's OUT link from truncating a waveform's NELM.
1332                // The refusal is returned to the caller; `write_out_link_value`
1333                // (C `dbPutLink`) turns it into the writer's LINK/INVALID alarm.
1334                check_no_mod_in_db_put(&instance, &field)?;
1335
1336                // C `dbPut` refuses a link-field target the same way, before
1337                // conversion (`dbAccess.c:1340`) — see `check_not_link_field`.
1338                check_not_link_field(&instance, &field)?;
1339
1340                let request = dbput_request(&*instance.record, &field, value)?;
1341
1342                // Pre-write special hook (C EPICS dbPutSpecial pass=0).
1343                // C `dbPut` runs it on EVERY entry path — dbPutField and
1344                // dbPutLink alike (dbAccess.c) — so the OUT-link route
1345                // through this body must call it too (motor's drive-field
1346                // DMOV blink, motorRecord.cc:2591-2620, fires on put-links
1347                // in C). A non-zero status aborts the put like C.
1348                instance.record.special(&field, false)?;
1349
1350                // Capture the pre-put value so the metadata-cache
1351                // invalidation (and the downstream `DBE_PROPERTY`
1352                // emission) can be skipped when the put is a no-op —
1353                // epics-base faac1df1.
1354                let prev_value = instance.record.get_field(&field);
1355                let old_nord = array_nord_before_put(&instance, &field);
1356
1357                // Link writes the record's `special()` makes itself (C runs them
1358                // inside `dbPut`); executed below, once the record lock is released.
1359                let mut special_actions = Vec::new();
1360
1361                // put_pv is C EPICS dbPut: write value + special/on_put, clear
1362                // UDF on a value-field put, and post the field's DBE_VALUE|
1363                // DBE_LOG monitor per `dbPut`'s tail (dbAccess.c:1408-1418).
1364                // Does NOT trigger processing (that is `dbPutField`'s pp gate,
1365                // this port's `put_record_field_from_ca`), so — exactly like a
1366                // bare C `dbPut` — a pp(TRUE) value field's post stays
1367                // suppressed and its stale UDF *alarm* stands until a process
1368                // cycle recomputes stat/sevr.
1369                let common_result = match request {
1370                    // C `dbAccess.c:1362` converted zero elements — nothing is
1371                    // stored and the put is accepted. `alarm` is the scalar arm's
1372                    // `:1371` only.
1373                    PutRequest::StoreNothing { alarm } => {
1374                        if alarm {
1375                            set_empty_request_alarm(&mut instance);
1376                        }
1377                        clear_udf_on_value_put(&mut instance, &field);
1378                        CommonFieldPutResult::NoChange
1379                    }
1380                    PutRequest::Write(value) => {
1381                        special_before_put(&mut instance, &field);
1382                        match instance.record.put_field(&field, value.clone()) {
1383                            Ok(()) => {
1384                                instance.record.on_put(&field);
1385                                // C `dbPut` (dbAccess.c:1399-1405) keeps the value
1386                                // it already stored but RETURNS the after-put
1387                                // `dbPutSpecial(paddr, 1)` status, skipping the
1388                                // field's monitor post and (in `dbPutField`) the
1389                                // `pp(TRUE)` process. `calcRecord::special` uses
1390                                // that to refuse an uncompilable CALC with
1391                                // S_db_badField, so the status must not be dropped.
1392                                let result = special_after_put(
1393                                    self,
1394                                    &mut instance,
1395                                    &field,
1396                                    &mut special_actions,
1397                                    link_backing,
1398                                )?;
1399                                clear_udf_on_value_put(&mut instance, &field);
1400                                result
1401                            }
1402                            Err(CaError::FieldNotFound(_)) => {
1403                                instance.put_common_field(&field, value)?
1404                            }
1405                            // A refused store does NOT skip the pass above:
1406                            // C runs it either way (`dbAccess.c:1398`) and lets
1407                            // its status win, then `goto done` — the `return`.
1408                            Err(e) => {
1409                                special_after_put(
1410                                    self,
1411                                    &mut instance,
1412                                    &field,
1413                                    &mut special_actions,
1414                                    link_backing,
1415                                )?;
1416                                return Err(e);
1417                            }
1418                        }
1419                    }
1420                };
1421
1422                // C `dbPut` runs `dbPutSpecial(paddr, 1)` regardless of caller entry
1423                // path, so a put via this internal route commits/writes the same
1424                // `special()`-driven alarm the CA route does. State only — unlike
1425                // the CA path these commit the alarm without the STAT/SEVR posts
1426                // (C's bare `dbPut` runs no `monitor()`, so it posts no alarm
1427                // transition either).
1428                //
1429                // compress SPC_RESET: `monitor()`'s `recGblResetAlarms` commits the
1430                // born-UDF alarm (compressRecord.c:103).
1431                if instance.record.special_commits_alarms(&field) {
1432                    let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
1433                }
1434                // histogram SGNL SPC_MOD: `add_count` raises the inverted-limits
1435                // alarm (histogramRecord.c:329-334). The port routes it through
1436                // `nsta`/`nsev` (CBUG-F12 refused), so this monitor-less special
1437                // path must commit it — check then reset — for the SOFT/INVALID to
1438                // be observable, matching the process path.
1439                if instance.record.special_checks_alarms(&field) {
1440                    let inst = &mut *instance;
1441                    inst.record.check_alarms(&mut inst.common);
1442                    let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
1443                }
1444
1445                // Invalidate metadata cache only if the metadata-class
1446                // field's value actually changed (faac1df1).
1447                instance.notify_field_written_if_changed(&field, prev_value.as_ref(), link_backing);
1448
1449                // C `dbPut:1408-1413`'s field-monitor post, through the one owner
1450                // (`dbput_post_put_field`, shared with the CA route). `put_pv`
1451                // is the `dbPutLink` route's `dbPut` and the internal driver-put
1452                // entry, and C posts DBE_VALUE|DBE_LOG from *every* `dbPut` —
1453                // an NPP OUT link writing a calc's A, an autosave restore, a
1454                // status pusher, all post immediately. Pre-fix this body posted
1455                // nothing at all, so camonitor on anything written via `put_pv`
1456                // went silent forever.
1457                dbput_post_put_field(&mut instance, &field, link_backing);
1458
1459                // C's `put_array_info` is likewise reached from every `dbPut` —
1460                // an OUT link that shortens a waveform posts NORD in C even when
1461                // the link is NPP and the target never processes, and the
1462                // value-field post above is suppressed for a waveform (VAL is
1463                // `pp(TRUE)`); NORD has no such second path.
1464                post_array_info(&mut instance, &old_nord, 0, link_backing);
1465
1466                (common_result, special_actions)
1467            };
1468            // No put-notify is armed here: C's restart owner is the cycle
1469            // tail (`end_process_cycle`), never the `pact = FALSE` store.
1470            // The record DATA lock is now down (scope ended above) before the
1471            // scan-index update and the `special()` link writes below, which
1472            // re-enter the database (they can process their target). The
1473            // advisory gate is still held — see its comment above.
1474
1475            // Update scan index if SCAN or PHAS changed. Synchronous as of
1476            // step 4, so this half of the tail adds no suspension point to the
1477            // gate-held window; C reaches `scanDelete`/`scanAdd` from inside
1478            // `dbScanLock` the same way.
1479            match common_result {
1480                CommonFieldPutResult::ScanChanged {
1481                    old_scan,
1482                    new_scan,
1483                    phas,
1484                } => {
1485                    self.update_scan_index(&canonical_base, old_scan, new_scan, phas, phas);
1486                }
1487                CommonFieldPutResult::PhasChanged {
1488                    scan: s,
1489                    old_phas,
1490                    new_phas,
1491                } => {
1492                    self.update_scan_index(&canonical_base, s, s, old_phas, new_phas);
1493                }
1494                CommonFieldPutResult::NoChange => {}
1495            }
1496
1497            // C `dbPut` runs `dbPutSpecial(paddr, 1)` to completion — the
1498            // `dbPutLink` calls a `special()` makes included — before it returns
1499            // to `dbPutField`. This is the last statement of the `dbPut`
1500            // analogue, so it is that point.
1501            self.run_special_actions(&canonical_base, &rec, special_actions);
1502
1503            // mirror the CA-write path's ASG-field notifier so
1504            // restore scripts / autosave / admin tools that go via
1505            // `put_pv` (not `put_record_field_from_ca`) also trigger
1506            // per-client `reeval_access_rights`. C `dbAccess.c::
1507            // dbPutSpecial` invokes the SPC_AS callback from dbPut
1508            // regardless of caller entry path.
1509            if field == "ASG" {
1510                crate::server::access_security::notify_asg_field_changed();
1511            }
1512
1513            return Ok(());
1514        }
1515
1516        Err(CaError::ChannelNotFound(name.to_string()))
1517    }
1518
1519    /// Write a value and post monitor events if changed.
1520    /// Equivalent to C EPICS `dbPut` + `db_post_events(DBE_VALUE|DBE_LOG)`.
1521    ///
1522    /// Use for readback/status mirror PVs that are written by sequencer-style
1523    /// code and need to be visible to CA monitors without triggering record
1524    /// processing. Clears UDF/UDF_ALARM on primary field write.
1525    ///
1526    /// `origin`: writer ID for self-write filtering. Subscribers with the
1527    /// same `ignore_origin` will skip this event. Pass 0 to disable.
1528    pub async fn put_pv_and_post(&self, name: &str, value: EpicsValue) -> CaResult<()> {
1529        self.put_pv_and_post_with_origin(name, value, 0).await
1530    }
1531
1532    /// Push a monitor event holding the simple PV's *current* value
1533    /// but with explicit alarm severity/status. Used by the gateway
1534    /// to surface upstream-disconnect to downstream monitor
1535    /// subscribers without dropping the shadow PV (which would force
1536    /// downstream clients into ECA_DISCONN reconnect storms on every
1537    /// transient hiccup). Returns `ChannelNotFound` for record-backed
1538    /// PVs — those carry their own `common.sevr/stat` in record
1539    /// processing.
1540    pub fn post_alarm(&self, name: &str, severity: u16, status: u16) -> CaResult<()> {
1541        let simple = self.inner.simple_pvs.lock().get(name).cloned();
1542        if let Some(pv) = simple {
1543            pv.post_alarm(severity, status);
1544            return Ok(());
1545        }
1546        Err(crate::error::CaError::ChannelNotFound(name.to_string()))
1547    }
1548
1549    /// Propagate a full upstream snapshot (value + alarm status/severity +
1550    /// IOC timestamp) to a simple shadow PV and fan out to downstream
1551    /// monitor subscribers. Used by the CA gateway forwarding task to avoid
1552    /// discarding the upstream alarm and timestamp decoded from the incoming
1553    /// `DBR_TIME_*` frame. Returns `ChannelNotFound` for record-backed PVs
1554    /// (those carry their own alarm engine and are not shadow PVs).
1555    pub async fn put_pv_and_post_snapshot(&self, name: &str, snapshot: Snapshot) -> CaResult<()> {
1556        let simple = self.inner.simple_pvs.lock().get(name).cloned();
1557        if let Some(pv) = simple {
1558            pv.set_snapshot(snapshot);
1559            return Ok(());
1560        }
1561        Err(CaError::ChannelNotFound(name.to_string()))
1562    }
1563
1564    /// Install upstream `DBR_CTRL_*` metadata (display / control limits,
1565    /// enum labels) on a shadow simple PV WITHOUT posting an event.
1566    ///
1567    /// The CA gateway calls this once on upstream connect, after its initial
1568    /// `DBR_CTRL_*` get, so a later downstream `DBR_CTRL_*` / `DBR_GR_*` read
1569    /// returns the real limits instead of zeroed ones. No `DBE_PROPERTY`
1570    /// monitor event fires — nothing has *changed* yet, this only seeds the
1571    /// attribute cache. Mirrors C `gatePvData::getCB` → `runDataCB` →
1572    /// `vc->setPvData(dd)` (`gatePv.cc:1693-1695`), which seeds the property
1573    /// cache from the initial control get in both cache modes before any
1574    /// monitor is enabled.
1575    ///
1576    /// Returns `ChannelNotFound` for record-backed PVs — those own their own
1577    /// metadata via record processing and are not gateway shadow PVs.
1578    pub async fn set_pv_metadata(&self, name: &str, snapshot: &Snapshot) -> CaResult<()> {
1579        let simple = self.inner.simple_pvs.lock().get(name).cloned();
1580        if let Some(pv) = simple {
1581            pv.set_metadata(metadata_from_snapshot(snapshot));
1582            return Ok(());
1583        }
1584        Err(CaError::ChannelNotFound(name.to_string()))
1585    }
1586
1587    /// Refresh a shadow simple PV's upstream metadata AND post a
1588    /// `DBE_PROPERTY` monitor event carrying `snapshot` to downstream
1589    /// property subscribers.
1590    ///
1591    /// `snapshot` is the decoded upstream `DBR_CTRL_*` property event: it
1592    /// carries the control value and the upstream `status` / `severity`,
1593    /// and (because control DBR structs carry no timestamp) an undefined
1594    /// timestamp the caller must NOT replace with a fresh wall-clock. The
1595    /// gateway's property monitor calls this on every upstream
1596    /// `DBE_PROPERTY` event, mirroring C `gatePvData::propEventCB` →
1597    /// `runDataCB` + `setPvData` + `runValueDataCB` +
1598    /// `vcPostEvent(propertyEventMask())` (`gatePv.cc:1571-1607`): the
1599    /// attribute cache is refreshed and a property event is posted with the
1600    /// upstream alarm state preserved (`setStatSevr`) and the undefined
1601    /// control-DBR timestamp left as-is (`gatePv.cc:1594-1595`).
1602    ///
1603    /// Returns `ChannelNotFound` for record-backed PVs.
1604    pub async fn post_pv_property(&self, name: &str, snapshot: Snapshot) -> CaResult<()> {
1605        let simple = self.inner.simple_pvs.lock().get(name).cloned();
1606        if let Some(pv) = simple {
1607            pv.set_metadata(metadata_from_snapshot(&snapshot));
1608            pv.post_property(snapshot).await;
1609            return Ok(());
1610        }
1611        Err(CaError::ChannelNotFound(name.to_string()))
1612    }
1613
1614    /// Like `put_pv_and_post` but with explicit origin tag.
1615    pub async fn put_pv_and_post_with_origin(
1616        &self,
1617        name: &str,
1618        value: EpicsValue,
1619        origin: u64,
1620    ) -> CaResult<()> {
1621        let (base, field) = super::parse_pv_name(name);
1622        let field = field.to_ascii_uppercase();
1623        // C `dbPutFieldLink` (`dbAccess.c:1261`): a write to a DBF link field
1624        // relinks the target's lock set. The obligation is taken out here, so
1625        // that every exit path below discharges it; see
1626        // `record_lock.rs`'s `LinkFieldWrite`.
1627        let _relink = self.link_field_write(base, &field);
1628
1629        // Simple-PV path: PVs registered via `add_pv` (e.g. CA gateway
1630        // shadow PVs, IOCsh stats PVs) are stored in `simple_pvs`,
1631        // not `records`. Without this branch the function would
1632        // silently return `ChannelNotFound` for every gateway-mirrored
1633        // PV — `ProcessVariable::set_with_origin` already does the
1634        // notify-subscribers fan-out internally (tagging the event with
1635        // `origin`, same self-write contract as the record branch) so
1636        // all we need here is to delegate.
1637        let simple = self.inner.simple_pvs.lock().get(name).cloned();
1638        if let Some(pv) = simple {
1639            pv.set_with_origin(value, origin);
1640            return Ok(());
1641        }
1642
1643        if let Some(rec) = self.get_record(base) {
1644            // `put_pv_and_post` is a public record-write API —
1645            // it must take the same advisory write gate
1646            // (`dbScanLock` analogue) as `put_pv` /
1647            // `put_record_field_from_ca`, or a gateway/sequencer
1648            // write through this helper can still land between the
1649            // member writes of a QSRV atomic group or a pvalink
1650            // atomic scan epoch holding `lock_records`. `base` is
1651            // alias-resolved to the canonical record name so an alias
1652            // and its target share one gate. Held until return — same
1653            // reasoning as `put_pv_inner`'s gate: C's `dbScanLock` covers
1654            // `dbPut` including `dbPutSpecial(paddr, 1)` and the scan-list
1655            // move, so both tails below stay inside the window.
1656            let canonical_base: String =
1657                self.resolve_alias(base).unwrap_or_else(|| base.to_string());
1658            let _record_gate = self.lock_record(&canonical_base);
1659
1660            // Guarded: the value write + monitor post. The record's DATA guard
1661            // is released at the block close before the tails below, which
1662            // re-enter the database (`parking_lot` guards are `!Send`); the
1663            // advisory `_record_gate` still holds the processing-exclusion
1664            // window across the whole helper.
1665            use crate::server::record::CommonFieldPutResult;
1666            // C reads a link-backed field's metadata live inside the rset,
1667            // under the TARGET record's lock; every poster below holds THIS
1668            // record's lock and cannot reach for a second one. Resolved here,
1669            // where no record lock is held, and handed down as a borrowed
1670            // value that dies with this body — so a `caput CALC.A` carries the
1671            // metadata `INPA`'s target has NOW, not what it had when the
1672            // source last processed. Empty after one read lock for every
1673            // record type but calc, calcout, sub, aSub and seq.
1674            let link_backing = self.resolve_link_backed_metadata(&rec);
1675            let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
1676            let (common_result, special_actions) = {
1677                let mut instance = rec.write();
1678
1679                // Same `dbPut` gate as `put_pv` — this is the third `dbPut` body
1680                // (value + monitor post), and C has ONE.
1681                check_no_mod_in_db_put(&instance, &field)?;
1682                check_not_link_field(&instance, &field)?;
1683
1684                let request = dbput_request(&*instance.record, &field, value)?;
1685
1686                // Pre-write special hook (C EPICS dbPutSpecial pass=0) —
1687                // C `dbPut` runs it on every entry path; this is the third
1688                // `dbPut` body and must match the other two.
1689                instance.record.special(&field, false)?;
1690
1691                let old_value = instance.record.get_field(&field);
1692                let old_stat = instance.common.stat;
1693                let old_sevr = instance.common.sevr;
1694                let old_nord = array_nord_before_put(&instance, &field);
1695
1696                // Link writes the record's `special()` makes itself (C runs them
1697                // inside `dbPut`); executed below, once the record lock is released.
1698                let mut special_actions = Vec::new();
1699
1700                // Write value + special/on_put
1701                let common_result = match request {
1702                    // C `dbAccess.c:1362` converted zero elements — nothing is
1703                    // stored and the put is accepted. `alarm` is the scalar arm's
1704                    // `:1371` only.
1705                    PutRequest::StoreNothing { alarm } => {
1706                        if alarm {
1707                            set_empty_request_alarm(&mut instance);
1708                        }
1709                        clear_udf_on_value_put(&mut instance, &field);
1710                        CommonFieldPutResult::NoChange
1711                    }
1712                    PutRequest::Write(value) => {
1713                        special_before_put(&mut instance, &field);
1714                        match instance.record.put_field(&field, value.clone()) {
1715                            Ok(()) => {
1716                                instance.record.on_put(&field);
1717                                // C returns the after-put special() status from
1718                                // `dbPut` (dbAccess.c:1399-1405) — before the UDF
1719                                // clear and the monitor post below, both of which
1720                                // `goto done` skips on a non-zero status.
1721                                let result = special_after_put(
1722                                    self,
1723                                    &mut instance,
1724                                    &field,
1725                                    &mut special_actions,
1726                                    link_backing,
1727                                )?;
1728                                clear_udf_on_value_put(&mut instance, &field);
1729                                result
1730                            }
1731                            Err(CaError::FieldNotFound(_)) => {
1732                                instance.put_common_field(&field, value)?
1733                            }
1734                            // A refused store does NOT skip the pass above:
1735                            // C runs it either way (`dbAccess.c:1398`) and lets
1736                            // its status win, then `goto done` — the `return`.
1737                            Err(e) => {
1738                                special_after_put(
1739                                    self,
1740                                    &mut instance,
1741                                    &field,
1742                                    &mut special_actions,
1743                                    link_backing,
1744                                )?;
1745                                return Err(e);
1746                            }
1747                        }
1748                    }
1749                };
1750
1751                // Invalidate metadata cache only if a metadata-class
1752                // field actually changed value (faac1df1 — DBE_PROPERTY
1753                // fires on real changes, not no-op writes).
1754                instance.notify_field_written_if_changed(&field, old_value.as_ref(), link_backing);
1755
1756                // Post monitor events if value or alarm changed
1757                let new_value = instance.record.get_field(&field);
1758                let value_changed = old_value != new_value;
1759                let alarm_changed =
1760                    old_stat != instance.common.stat || old_sevr != instance.common.sevr;
1761                let nord_changed =
1762                    old_nord.is_some() && instance.record.get_field("NORD") != old_nord;
1763                if value_changed || alarm_changed || nord_changed {
1764                    // Update timestamp so the snapshot carries current time
1765                    instance.common.time = crate::runtime::general_time::get_current();
1766                    instance.cleanup_subscribers();
1767                    if value_changed || alarm_changed {
1768                        instance.notify_field_with_origin(
1769                            &field,
1770                            crate::server::recgbl::EventMask::VALUE
1771                                | crate::server::recgbl::EventMask::LOG
1772                                | crate::server::recgbl::EventMask::ALARM,
1773                            origin,
1774                            link_backing,
1775                        );
1776                    }
1777                    // The NORD post, through the one owner. Without it a CA
1778                    // gateway forwarding upstream waveform monitors via
1779                    // put_pv_and_post would update VAL on the shadow PV but
1780                    // leave downstream NORD subscribers stuck at their last
1781                    // seen length — a frozen-element-count bug that surfaces
1782                    // in PyDM image views and similar consumers that compute
1783                    // height = element_count / width.
1784                    post_array_info(&mut instance, &old_nord, origin, link_backing);
1785                }
1786
1787                // The `special()` link writes re-enter the database, so the record
1788                // lock goes down first (the block close releases it). C makes them
1789                // inside `dbPut`, before it returns to its caller.
1790                (common_result, special_actions)
1791            };
1792            // No put-notify is armed here: C's restart owner is the cycle
1793            // tail (`end_process_cycle`), never the `pact = FALSE` store.
1794
1795            // Same scan-index owner every other `dbPut` path routes through:
1796            // a SCAN put and the SIMM↔SSCN swap (`recGblCheckSimm`) both move
1797            // the record between scan lists and must reach `update_scan_index`.
1798            match common_result {
1799                CommonFieldPutResult::ScanChanged {
1800                    old_scan,
1801                    new_scan,
1802                    phas,
1803                } => {
1804                    self.update_scan_index(&canonical_base, old_scan, new_scan, phas, phas);
1805                }
1806                CommonFieldPutResult::PhasChanged {
1807                    scan: s,
1808                    old_phas,
1809                    new_phas,
1810                } => {
1811                    self.update_scan_index(&canonical_base, s, s, old_phas, new_phas);
1812                }
1813                CommonFieldPutResult::NoChange => {}
1814            }
1815
1816            self.run_special_actions(&canonical_base, &rec, special_actions);
1817
1818            // same SPC_AS parity as `put_pv` / `put_pv_no_process`
1819            // / the CA-write path — a gateway mirroring `.ASG` via
1820            // `put_pv_and_post` must still trigger per-client
1821            // re-eval.
1822            if field == "ASG" {
1823                crate::server::access_security::notify_asg_field_changed();
1824            }
1825
1826            return Ok(());
1827        }
1828
1829        Err(CaError::ChannelNotFound(name.to_string()))
1830    }
1831
1832    /// Execute the link writes a record's `special()` queued
1833    /// ([`Record::take_special_actions`](crate::server::record::Record::take_special_actions)).
1834    ///
1835    /// The single consumer: every `dbPut` path in this module calls it once, at
1836    /// the end of the put and before any `pp(TRUE)` process cycle, which is
1837    /// where C runs them (`dbPut` → `dbPutSpecial(paddr, 1)` → `dbPutLink`,
1838    /// with `dbProcess` still ahead in `dbPutField`). The put is the root of the
1839    /// chain these writes start, so they get a fresh visited set, exactly like a
1840    /// client put entering `process_record_with_links`.
1841    ///
1842    /// Must be called with no record lock held: a `WriteDbLink` can process its
1843    /// target, which re-enters the database.
1844    ///
1845    /// # Synchronous, and it has to stay that way
1846    ///
1847    /// This whole tail runs inside the caller's L1 gate window, and L1 is a
1848    /// blocking priority-inheritance mutex whose guard is `!Send`
1849    /// (`server::database::record_lock`). An `.await` anywhere reachable from
1850    /// here is therefore a compile error at the spawn sites, not a review
1851    /// finding.
1852    ///
1853    /// The one call that used to make it a genuine suspension is gone:
1854    /// `write_out_link_value` → `write_external_pv` does not call
1855    /// `LinkSet::put_value` from this thread. It stages the write on the
1856    /// database's link-put queue and returns, exactly as C `dbCaPutLink`
1857    /// stages into `pca->pputNative`, calls `addAction` and returns
1858    /// (`dbCa.c:515-602`); the `ca://` / `pva://` round trip runs on the
1859    /// queue's owner task, C's `dbCaTask` (`dbCa.c:1161-1183`). See
1860    /// [`super::link_put_queue`]. What is left inside the window is the
1861    /// re-entrant database work C also does under `dbScanLock`:
1862    /// `execute_process_actions` → `write_out_link_value` →
1863    /// `write_db_link_value` re-entering `put_pv_already_locked` and
1864    /// `process_target`, plus the cached-state `LinkSet::put_admission` probe
1865    /// both production lsets answer from a map lookup and an atomic — C's
1866    /// `if (!pca->isConnected …)` (`dbCa.c:529-532`).
1867    fn run_special_actions(
1868        &self,
1869        record_name: &str,
1870        rec: &std::sync::Arc<parking_lot::RwLock<crate::server::record::RecordInstance>>,
1871        actions: Vec<crate::server::record::ProcessAction>,
1872    ) {
1873        if actions.is_empty() {
1874            return;
1875        }
1876        let mut visited = HashSet::new();
1877        self.execute_process_actions(record_name, rec, actions, &mut visited, 0);
1878    }
1879
1880    /// CA client's unified entry point for record field put.
1881    /// Handles DISP/PROC/PACT/LCNT checks, field put, device write, and Passive process.
1882    ///
1883    /// Acquires the record's advisory write gate
1884    /// (`dbScanLock` analogue) for the duration of the write.
1885    pub async fn put_record_field_from_ca(
1886        &self,
1887        record_name: &str,
1888        field: &str,
1889        value: EpicsValue,
1890    ) -> CaResult<crate::server::record::ProcessCompletion> {
1891        let _record_gate = self.acquire_put_gate(record_name);
1892        self.put_record_field_from_ca_body(record_name, field, value, NotifyRequest::New)
1893    }
1894
1895    /// Variant for a caller that already owns the target
1896    /// record's advisory write gate — the QSRV atomic group PUT,
1897    /// which acquired every member-record gate up-front via
1898    /// [`Self::lock_records`]. The per-record gate is NOT reentrant, so the
1899    /// atomic group path MUST use this `_already_locked` entry to avoid
1900    /// dead-locking on its own `ManyRecordWriteGuard`.
1901    pub fn put_record_field_from_ca_already_locked(
1902        &self,
1903        record_name: &str,
1904        field: &str,
1905        value: EpicsValue,
1906    ) -> CaResult<crate::server::record::ProcessCompletion> {
1907        self.put_record_field_from_ca_body(record_name, field, value, NotifyRequest::New)
1908    }
1909
1910    /// Fire-and-forget variant — C `dbPutField` semantics: the put
1911    /// processes the record but creates NO put-notify wait-set (C
1912    /// builds a `putNotify` only in `dbPutNotify`, i.e. for
1913    /// WRITE_NOTIFY). A caller that does not await the returned
1914    /// receiver MUST use this entry: parking a wait-set whose receiver
1915    /// is dropped occupies `RecordInstance::notify` until the record's
1916    /// async work ends (a motor's whole motion), failing every
1917    /// legitimate WRITE_NOTIFY on the record with ECA_PUTCBINPROG in
1918    /// the meantime.
1919    pub async fn put_record_field_from_ca_no_notify(
1920        &self,
1921        record_name: &str,
1922        field: &str,
1923        value: EpicsValue,
1924    ) -> CaResult<()> {
1925        self.put_record_field_from_ca_no_notify_with_origin(record_name, field, value, 0)
1926            .await
1927    }
1928
1929    /// [`Self::put_record_field_from_ca_no_notify`] for an in-process writer
1930    /// with a self-write-filtering origin (a ported SNL state machine's
1931    /// `DbChannel`): every event the put's synchronous process cascade posts
1932    /// is tagged with `origin`, so the writer's own filtered subscriptions
1933    /// skip them. The ambient scope is sound here because the body below is
1934    /// fully synchronous — there is no await between entering the scope and
1935    /// the cascade's last post.
1936    pub async fn put_record_field_from_ca_no_notify_with_origin(
1937        &self,
1938        record_name: &str,
1939        field: &str,
1940        value: EpicsValue,
1941        origin: u64,
1942    ) -> CaResult<()> {
1943        let _record_gate = self.acquire_put_gate(record_name);
1944        let _origin_scope = crate::server::record::ambient_write_origin_scope(origin);
1945        self.put_record_field_from_ca_body(record_name, field, value, NotifyRequest::None)
1946            .map(|_| ())
1947    }
1948
1949    /// The one router for an EXTERNAL client PUT that carries pvxs's
1950    /// `record._options.process` / `.block` terms — QSRV's whole
1951    /// `onPut` decision tree (`ioc/singlesource.cpp:346-384`,
1952    /// `ioc/iocsource.cpp:397-419`) in one place, so the QSRV bridge
1953    /// channel and the native PVA source cannot disagree about what
1954    /// `process=false` or `block=true` means.
1955    ///
1956    /// A DBF link field ignores the requested mode: pvxs sends it down
1957    /// `dbPutField` whatever the client asked (`iocsource.cpp:451-458`,
1958    /// `dbNotify.c:337-354`), and the `dbPut`-analogue bodies refuse link
1959    /// fields outright, so the Passive route is the only one that can
1960    /// carry it.
1961    ///
1962    /// `doPreProcessing`'s two gates (`SPC_ATTRIBUTE` → `S_db_noMod`,
1963    /// `DISP` → `S_db_putDisabled`) run here for every mode. The Passive
1964    /// route re-checks them inside `put_record_field_from_ca`, but the
1965    /// Force / Inhibit routes go through `put_pv` — the internal `dbPut`
1966    /// analogue, which by design does not gate `DISP` — so the gate has
1967    /// to be at this boundary for the invariant to hold by construction.
1968    /// A caller that needs the rejection to precede its own ACF check
1969    /// (pvxs runs `doPreProcessing` before `doFieldPreProcessing`) still
1970    /// calls [`Self::check_external_put_preconditions`] itself; the check
1971    /// is idempotent.
1972    pub async fn put_field_from_client(
1973        &self,
1974        record_name: &str,
1975        field: &str,
1976        value: EpicsValue,
1977        process: ProcessMode,
1978        block: bool,
1979    ) -> CaResult<()> {
1980        let process = if self.is_dbf_link_field(record_name, field) {
1981            ProcessMode::Passive
1982        } else {
1983            process
1984        };
1985        self.check_external_put_preconditions(record_name, field)
1986            .await?;
1987        match process {
1988            ProcessMode::Inhibit => self.put_pv(&format!("{record_name}.{field}"), value).await,
1989            ProcessMode::Passive => {
1990                if block {
1991                    let completion = self
1992                        .put_record_field_from_ca(record_name, field, value)
1993                        .await?;
1994                    self.await_completion(record_name, completion).await;
1995                    Ok(())
1996                } else {
1997                    self.put_record_field_from_ca_no_notify(record_name, field, value)
1998                        .await
1999                }
2000            }
2001            ProcessMode::Force => {
2002                self.put_pv(&format!("{record_name}.{field}"), value)
2003                    .await?;
2004                if block {
2005                    // A blocking forced put is C `dbProcessNotify`
2006                    // (`singlesource.cpp:360-369`): the reply waits for the
2007                    // whole chain, async device completion included. The
2008                    // bare `process_record_with_links` returns as soon as
2009                    // the record goes PACT.
2010                    let completion = self.process_record_with_notify(record_name).await?;
2011                    self.await_completion(record_name, completion).await;
2012                    Ok(())
2013                } else {
2014                    // `doPostProcessing(forceProcessing == True)`
2015                    // (`iocsource.cpp:404-419`) splits on PACT: an
2016                    // async-active record takes `rpro = TRUE` and does not
2017                    // process, an idle one takes `putf = TRUE` and does.
2018                    // `put_driven_process` is that transition's owner.
2019                    self.put_driven_process(record_name).await
2020                }
2021            }
2022        }
2023    }
2024
2025    /// Await a blocking external PUT's completion under
2026    /// [`ClientAwaitingNotify`], so a client that goes away mid-put hands the
2027    /// record back instead of wedging every put queued behind it.
2028    async fn await_completion(
2029        &self,
2030        record_name: &str,
2031        completion: crate::server::record::ProcessCompletion,
2032    ) {
2033        let crate::server::record::ProcessCompletion::Async(rx) = completion else {
2034            return;
2035        };
2036        let mut pending = ClientAwaitingNotify {
2037            db: self,
2038            record: record_name,
2039            rx,
2040            answered: false,
2041        };
2042        // Either outcome ends the wait. `Err` means the sender was dropped
2043        // without sending, and only the completion path does that — it takes
2044        // the sender out of the set first, so the sweep would no-op anyway.
2045        let _ = (&mut pending.rx).await;
2046        pending.answered = true;
2047    }
2048
2049    /// C `dbPut`'s alarm-acknowledge interception (`dbAccess.c:1331-1335`) —
2050    /// the ONLY route that may change ACKS/ACKT at runtime.
2051    ///
2052    /// ```c
2053    /// if (dbrType == DBR_PUT_ACKT && field_type <= DBF_DEVICE)
2054    ///     return putAckt(paddr, pbuffer, 1, 1, 0);
2055    /// else if (dbrType == DBR_PUT_ACKS && field_type <= DBF_DEVICE)
2056    ///     return putAcks(paddr, pbuffer, 1, 1, 0);
2057    /// ```
2058    ///
2059    /// The dispatch is on the DBR *request type*, not on the field: a CA client
2060    /// acknowledges by sending `DBR_PUT_ACKS` down its ordinary `REC` (VAL)
2061    /// channel. It sits ABOVE the `SPC_NOMOD` gate, which is why
2062    /// `caput REC.ACKS 2` is refused by C ("Write access denied", verified on
2063    /// softIoc 7.0.10) while `ca_put(DBR_PUT_ACKS, REC)` clears the alarm.
2064    ///
2065    /// The put-disable gate is still crossed: C tests `precord->disp` in
2066    /// `dbPutField`, above `dbPut` (`dbAccess.c:1255-1257`), so an ack to a
2067    /// `DISP=1` record is refused. `field` is the channel's field — only the
2068    /// DISP gate looks at it, exactly as in C.
2069    ///
2070    /// No process cycle: `dbPut` returns straight from `putAckt`/`putAcks`, and
2071    /// `dbPutField`'s reprocess condition requires `dbrType < DBR_PUT_ACKT`.
2072    pub async fn put_alarm_ack_from_ca(
2073        &self,
2074        record_name: &str,
2075        field: &str,
2076        ack: crate::server::record::AlarmAck,
2077        value: u16,
2078    ) -> CaResult<()> {
2079        let field_upper = field.to_ascii_uppercase();
2080        let rec = self
2081            .get_record(record_name)
2082            .ok_or_else(|| CaError::ChannelNotFound(record_name.to_string()))?;
2083        let canonical: String = self
2084            .resolve_alias(record_name)
2085            .unwrap_or_else(|| record_name.to_string());
2086        let _record_gate = self.lock_record(&canonical);
2087
2088        // Resolved before the record lock: the record-wide `DBE_ALARM` post
2089        // below reaches every subscribed field, a link-backed one included.
2090        let backing = self.resolve_link_backed_metadata(&rec);
2091        let backing = crate::server::database::LinkBacking::resolved(&backing);
2092
2093        let mut instance = rec.write();
2094        check_put_disabled(&instance, &field_upper)?;
2095        match ack {
2096            crate::server::record::AlarmAck::Transient => instance.put_ackt(value, backing),
2097            crate::server::record::AlarmAck::Severity => instance.put_acks(value, backing),
2098        }
2099        Ok(())
2100    }
2101
2102    /// Fire-and-forget + caller-held gate: see
2103    /// [`Self::put_record_field_from_ca_no_notify`] and
2104    /// [`Self::put_record_field_from_ca_already_locked`].
2105    pub fn put_record_field_from_ca_no_notify_already_locked(
2106        &self,
2107        record_name: &str,
2108        field: &str,
2109        value: EpicsValue,
2110    ) -> CaResult<()> {
2111        self.put_record_field_from_ca_body(record_name, field, value, NotifyRequest::None)
2112            .map(|_| ())
2113    }
2114
2115    /// Process a record UNCONDITIONALLY with a put-notify wait-set, returning
2116    /// the completion receiver — the QSRV `record[process=true,block=true]`
2117    /// (Force + block) barrier.
2118    ///
2119    /// C `dbProcessNotify`: pvxs routes a blocking forced put through
2120    /// `dbProcessNotify` (`singlesource.cpp:360-369`), whose completion fires
2121    /// only after the record's whole processing chain — including async device
2122    /// work (a motor move, an asyn-backed AO) — settles. The value is written
2123    /// by the caller's preceding [`Self::put_pv`] (the `dbPut` analogue, no
2124    /// process); this entry then mints the wait-set, registers it into the
2125    /// record's `notify` slot so PACT records join it, and runs the full
2126    /// unconditional [`Self::process_record_with_links`] cycle (C `dbProcess`,
2127    /// the Force analogue). A fully synchronous chain returns `Ok(None)` (the
2128    /// wait-set already drained); an async record returns `Ok(Some(rx))` for
2129    /// the caller to await. A notify already in flight on the record does not
2130    /// refuse this one: it joins the record's restart queue and replays when
2131    /// the record frees, which is C's only outcome here.
2132    pub async fn process_record_with_notify(
2133        &self,
2134        record_name: &str,
2135    ) -> CaResult<crate::server::record::ProcessCompletion> {
2136        let (completion_tx, completion_rx) = crate::runtime::sync::oneshot::channel();
2137        // The gate wraps the install AND the cycle it arms, as C's does:
2138        // `dbProcessNotify` takes `dbScanLock(precord)` (dbNotify.c:355) and
2139        // `processNotifyCommon` assigns `precord->ppn` and calls `dbProcess`
2140        // before the matching `dbScanUnlock` (`:257-262`). Installing ahead of
2141        // the gate let a gate-holding put's cycle reach `complete_put_notify`
2142        // on a slot it did not fill, `take` this client's wait-set and `leave`
2143        // it -- firing a `block=true` completion for a cycle the client never
2144        // requested, and leaving this one to run unarmed.
2145        let installed = {
2146            let _record_gate = self.acquire_put_gate(record_name);
2147            self.install_notify_and_process_already_locked(
2148                record_name,
2149                completion_tx,
2150                NotifyArrival::Fresh,
2151            )
2152        }?;
2153        // The wait-set fires the oneshot only after the whole FLNK/OUT chain
2154        // (sync + async) settles. Already-completed ==> fully synchronous ==>
2155        // report immediate success. Queued (`None`) or still pending ==> hand
2156        // the receiver back to await the deferred completion.
2157        match installed {
2158            Some(notify) if notify.completed() => {
2159                Ok(crate::server::record::ProcessCompletion::Sync)
2160            }
2161            _ => Ok(crate::server::record::ProcessCompletion::Async(
2162                completion_rx,
2163            )),
2164        }
2165    }
2166
2167    /// C `dbPutField`'s put-driven process decision (`dbAccess.c:1264-1277`).
2168    ///
2169    /// Reached once the put has selected the record for processing — the `PROC`
2170    /// field, or a `pp(TRUE)` field on a Passive record. C then splits on PACT:
2171    ///
2172    /// * **async-active** — C sets `rpro = TRUE` and does NOT call `dbProcess`.
2173    ///   `recGblFwdLink` (`recGbl.c:296-300`) consumes RPRO when the device
2174    ///   round trip completes and queues `scanOnce`, so the value this put just
2175    ///   wrote still reaches the device, one cycle later. Calling `dbProcess`
2176    ///   here instead lands in dbProcess's own PACT guard, which bumps LCNT and
2177    ///   after MAX_LOCK raises SCAN_ALARM — an alarm C never raises for a client
2178    ///   put — while dropping the deferred reprocess entirely: on two rapid
2179    ///   puts to a Passive async output, C writes both values to the device and
2180    ///   the port wrote only the first.
2181    /// * **idle** — C sets `putf = TRUE` (the put-driven marker, cleared at the
2182    ///   tail of the process cycle / in `complete_async_record_inner`, both the
2183    ///   `recGblFwdLink:302` analogue) and calls `dbProcess`.
2184    ///
2185    /// Single owner of that decision for every external put: the `PROC`
2186    /// intercept and the `pp`-field route in
2187    /// [`Self::put_record_field_from_ca`] both go through it, so neither can
2188    /// drift from C's rule or from each other. The DB-link propagation path
2189    /// applies the same PACT→RPRO rule at its own targets
2190    /// (`processing.rs:4781-4790`, `:5783-5792`, `links.rs:1519`).
2191    ///
2192    /// Two entries, one rule. `put_driven_process` acquires `record_name`'s
2193    /// advisory write gate (the `dbScanLock` analogue) itself;
2194    /// [`Self::put_driven_process_already_locked`] is for a caller that
2195    /// already owns it — `_record_gate` on the CA put path, or the QSRV
2196    /// atomic group's `lock_records` epoch. The gate is not
2197    /// reentrant, so a caller holding it MUST take the `_already_locked`
2198    /// entry.
2199    ///
2200    /// QSRV's group PUT is the second external caller (pvxs
2201    /// `IOCSource::doPostProcessing`, `iocsource.cpp:397-420`, whose PACT
2202    /// branch is this same RPRO deferral): it reaches the decision by its own
2203    /// route — `record._options.process`, a `+type:"proc"` member, a `pp` field
2204    /// — but once the answer is "process", the transition is this owner's, in
2205    /// both gate modes.
2206    /// The PACT (RPRO) branch is success, as it is in C: `dbPutField` returns
2207    /// the `dbProcess` status only on the branch that ran it.
2208    pub async fn put_driven_process(&self, record_name: &str) -> CaResult<()> {
2209        let _record_gate = self.acquire_put_gate(record_name);
2210        self.put_driven_process_already_locked(record_name)
2211    }
2212
2213    /// [`Self::put_driven_process`] for a caller that already owns the
2214    /// record's advisory write gate. The gate-held body — no `.await`.
2215    pub fn put_driven_process_already_locked(&self, record_name: &str) -> CaResult<()> {
2216        {
2217            let Some(rec) = self.get_record(record_name) else {
2218                return Ok(());
2219            };
2220            let mut instance = rec.write();
2221            if instance.is_processing() {
2222                instance.common.rpro = 1;
2223                return Ok(());
2224            }
2225            instance.common.putf = true;
2226        }
2227        let mut visited = HashSet::new();
2228        self.process_record_with_links_already_locked(record_name, &mut visited, 0)
2229    }
2230
2231    /// C `restartCheck` (dbNotify.c:149-170) plus the restarted
2232    /// `processNotifyCommon` it queues: pop the oldest put-notify waiting on
2233    /// `record_name` and replay it whole — value, process, callback.
2234    ///
2235    /// The pop happens **after** the record's advisory write gate is taken and
2236    /// **before** the replay releases it, so the promoted put owns the record
2237    /// across the whole promotion, as C's `precord->ppn = pfirst` does. Without
2238    /// that, a client put arriving in the gap would take the record and the
2239    /// longer-waiting notify would end up behind it.
2240    ///
2241    /// The replay goes back through the ordinary put entry, so if the record has
2242    /// ALREADY gone active again (a scan fired between the completion and this
2243    /// replay), the same test queues it once more rather than writing into a
2244    /// busy record — the deferral is closed under its own restart. Called only
2245    /// from `PvDatabase::apply_pact_exit`, the single drain owner.
2246    pub(crate) async fn restart_next_notify_put(&self, record_name: &str) {
2247        // The clients already hold their receivers; a failure here (record gone,
2248        // field refused) must still release them, which dropping the senders
2249        // does — the same completion a `dbNotifyCancel` gives the C client.
2250        let _record_gate = self.acquire_put_gate(record_name);
2251        let Some(rec) = self.get_record(record_name) else {
2252            return;
2253        };
2254        let Some(queued) = rec.write().take_next_notify_restart() else {
2255            return;
2256        };
2257        match queued {
2258            crate::server::record::DeferredNotify::Put(
2259                crate::server::record::DeferredNotifyPut {
2260                    field,
2261                    value,
2262                    completion,
2263                },
2264            ) => {
2265                let _ = self.put_record_field_from_ca_body(
2266                    record_name,
2267                    &field,
2268                    value,
2269                    NotifyRequest::Deferred(completion),
2270                );
2271            }
2272            // C `processGetRequest` on restart: `processNotifyCommon` re-enters
2273            // with no `putCallback`, so the replay is the process alone. The
2274            // gate is already held, so this takes the already-locked entry.
2275            crate::server::record::DeferredNotify::Process { completion } => {
2276                let _ = self.install_notify_and_process_already_locked(
2277                    record_name,
2278                    completion,
2279                    NotifyArrival::Replay,
2280                );
2281            }
2282        }
2283    }
2284
2285    /// Arm `record_name`'s wait-set around `completion` and drive one process
2286    /// cycle -- the gate-held body behind every put-notify entry in this file,
2287    /// and the only one that reaches the record's `notify` slot.
2288    ///
2289    /// **The caller MUST hold `record_name`'s advisory write gate.** That is
2290    /// the whole of C's gated region: both entries take the record lock before
2291    /// `processNotifyCommon` runs -- `dbProcessNotify` at dbNotify.c:355 for a
2292    /// fresh arrival, `notifyCallback` at `:282` for a replay -- and hold it
2293    /// across the `precord->ppn` assignment and the `dbProcess` it arms
2294    /// (`:257-262`). Being an `_already_locked` body this contains no `.await`,
2295    /// so the gate cannot be held across a suspension.
2296    ///
2297    /// Returns the wait-set so the caller can ask whether the chain settled
2298    /// synchronously. `Ok(None)` means this call drove nothing -- the notify
2299    /// queued behind the record's current owner, and the replay is what
2300    /// processes, so processing here too would run one client request twice.
2301    fn install_notify_and_process_already_locked(
2302        &self,
2303        record_name: &str,
2304        completion: crate::runtime::sync::oneshot::Sender<()>,
2305        arrival: NotifyArrival,
2306    ) -> CaResult<Option<std::sync::Arc<crate::server::record::NotifyWaitSet>>> {
2307        // Collect-then-act: clone the handle under a brief map read, drop the
2308        // map lock before taking the per-record write lock.
2309        //
2310        // Resolved, not raw: C addresses a notify by `dbCommon *`, so an alias
2311        // is the same record (`dbNotify.c:492-499` and the park test at
2312        // `:225-232` compare record pointers). The map is keyed by the
2313        // canonical name, and the caller's `acquire_put_gate` already locked
2314        // THAT name -- a raw lookup here missed the record whose gate it was
2315        // standing behind.
2316        let rec_arc = self
2317            .get_record(record_name)
2318            .ok_or_else(|| CaError::ChannelNotFound(record_name.to_string()))?;
2319        self.cancel_unanswerable_notify(record_name);
2320        let notify = {
2321            let mut guard = rec_arc.write();
2322            if arrival.defers(&guard) {
2323                guard.queue_notify_put(crate::server::record::DeferredNotify::Process {
2324                    completion,
2325                });
2326                return Ok(None);
2327            }
2328            // Through the one install owner. Assigning the slot here instead
2329            // would drop the prior client's Sender, and its receiver then wakes
2330            // with the RecvError the CA dispatcher reads as success.
2331            match guard.install_or_queue_notify(completion) {
2332                Some(notify) => notify,
2333                None => return Ok(None),
2334            }
2335        };
2336        let mut visited = HashSet::new();
2337        self.process_record_with_links_already_locked(record_name, &mut visited, 0)?;
2338        Ok(Some(notify))
2339    }
2340
2341    /// C `dbNotifyCancel` (`dbNotify.c:385-430`) as `rsrvFreePutNotify` reaches
2342    /// it (`camessage.c:1630-1638`): a put-notify whose client is gone leaves
2343    /// EVERY record it owns, and each record's restart-list head takes it
2344    /// (`restartCheck`).
2345    ///
2346    /// # Invariant (CONTRACT)
2347    ///
2348    /// A wait-set that can never answer MUST NOT leave any record's put-notify
2349    /// slot occupied. **This is the single owner of that release.** Without it
2350    /// a record type that legitimately withholds its `ca_put_callback` (`busy`
2351    /// at VAL=1, `mca` mid-acquisition) is wedged by the first client that
2352    /// gives up: the slot stays taken forever and every later put queues behind
2353    /// it unwritten.
2354    ///
2355    /// The sweep is over the set's own membership
2356    /// ([`NotifyWaitSet::joined_records`]), not over the entry record, because
2357    /// C's is (`dbNotify.c:428-430` empties the whole wait list, and only then
2358    /// does `:433` deal with the entry). An entry-only release left the same
2359    /// wedge one hop down the chain, on the record most likely to be holding a
2360    /// set it will never leave: an FLNK target that is itself a `busy` at
2361    /// VAL=1 declines its own `recGblFwdLink` by contract, so its membership
2362    /// outlives the client that started the chain.
2363    ///
2364    /// Takes no lock of its own on entry, and holds only one record's write
2365    /// lock at a time, so it can be called from a client-teardown `Drop` and
2366    /// cannot deadlock against a chain that locks records in the other order.
2367    ///
2368    /// [`NotifyWaitSet::joined_records`]: crate::server::record::NotifyWaitSet
2369    pub fn cancel_unanswerable_notify(&self, record_name: &str) {
2370        let Some(rec) = self.get_record(record_name) else {
2371            return;
2372        };
2373        let Some(dead) = rec.read().unanswerable_notify() else {
2374            return;
2375        };
2376        // C `dbNotifyCancel`: the whole wait list, then the entry. `joined`
2377        // already carries the entry, so one pass covers both arms.
2378        for name in dead.joined_records() {
2379            let Some(member) = self.get_record(&name) else {
2380                continue;
2381            };
2382            let exit = {
2383                let mut guard = member.write();
2384                if !guard.release_notify(&dead) {
2385                    continue;
2386                }
2387                guard.pact_exit_without_release()
2388            };
2389            // `apply_pact_exit` takes no record lock, by construction — the
2390            // drain it spawns takes the record's write gate itself.
2391            self.apply_pact_exit(&name, &member, exit);
2392        }
2393    }
2394
2395    /// Claim `record_name`'s put-notify slot for this put, in the SAME
2396    /// critical section that just tested it.
2397    ///
2398    /// The caller has already established that the record is free — the whole
2399    /// point is that the test and the claim are one critical section, so this
2400    /// takes the guard it tested under rather than re-reading the record.
2401    fn claim_put_notify<'a>(
2402        rec: &'a std::sync::Arc<parking_lot::RwLock<crate::server::record::RecordInstance>>,
2403        guard: &mut crate::server::record::RecordInstance,
2404        completion: crate::runtime::sync::oneshot::Sender<()>,
2405    ) -> NotifyClaim<'a> {
2406        // Through the one install owner, which is also what makes the
2407        // `expect` safe: it queues instead of installing only when the slot is
2408        // occupied, and the caller's test — under this same guard — said it is
2409        // not.
2410        let set = guard
2411            .install_or_queue_notify(completion)
2412            .expect("the ownership test ran in this critical section, so the slot is free");
2413        NotifyClaim {
2414            rec,
2415            set: Some(set),
2416        }
2417    }
2418
2419    /// The gate-held body of the whole CA field-put family — a `fn`, so
2420    /// nothing in it can suspend while the L1 gate is held. The gate is the
2421    /// caller's; see `acquire_put_gate`.
2422    fn put_record_field_from_ca_body(
2423        &self,
2424        record_name: &str,
2425        field: &str,
2426        mut value: EpicsValue,
2427        notify_request: NotifyRequest,
2428    ) -> CaResult<crate::server::record::ProcessCompletion> {
2429        let field = field.to_ascii_uppercase();
2430        let want_notify = notify_request.wants_notify();
2431        // C `dbPutFieldLink` (`dbAccess.c:1261`): a write to a DBF link field
2432        // relinks the target's lock set. The obligation is taken out here, so
2433        // that every exit path below discharges it; see
2434        // `record_lock.rs`'s `LinkFieldWrite`.
2435        let _relink = self.link_field_write(record_name, &field);
2436
2437        // Get record Arc — alias-aware (epics-base PR #336) so a CA
2438        // client that connects via an alias name can put fields on
2439        // the canonical record.
2440        let rec = self
2441            .get_record(record_name)
2442            .ok_or_else(|| CaError::ChannelNotFound(record_name.to_string()))?;
2443        // Normalise to the canonical name for the rest of this
2444        // function — every subsequent call (PACT/LCNT lookup,
2445        // `process_record_with_links`, `update_scan_index`) uses the
2446        // raw records map and would miss when `record_name` is an
2447        // alias. Resolve once up front.
2448        let canonical_owned;
2449        let record_name: &str = if let Some(target) = self.resolve_alias(record_name) {
2450            canonical_owned = target;
2451            &canonical_owned
2452        } else {
2453            record_name
2454        };
2455
2456        // The caller holds the record's advisory write gate — the
2457        // `dbScanLock(precord)` analogue, taken by `acquire_put_gate`
2458        // or (QSRV atomic group PUT) by `lock_records` over the whole member
2459        // set. While it is held a plain write to the same record blocks, so a
2460        // direct backing-record write cannot land between member writes of an
2461        // atomic group transaction. It is held until the function returns.
2462
2463        // Special field intercepts (read lock, then drop)
2464        {
2465            let instance = rec.read();
2466
2467            // C `dbPutField` gate order (`dbAccess.c:1252-1277`): the DISP
2468            // put-disable gate runs BEFORE `dbPut` — hence before the
2469            // SPC_NOMOD rejection of PACT/LCNT/PUTF (`dbAccess.c:123`) — and
2470            // BEFORE the PROC-driven `dbProcess`. So on a `DISP=1` record
2471            // EVERY non-DISP field, PROC included, is refused with
2472            // `S_db_putDisabled` and the record does not process.
2473            check_put_disabled(&instance, &field)?;
2474
2475            // C `dbPutField` hands a DBF link field to `dbPutFieldLink`
2476            // instead of `dbPut` (`dbAccess.c:1259-1260`), which parses the
2477            // text and holds it to `dbCanSetLink` — see [`check_link_put`].
2478            // ABOVE the no-mod gate because that is where C puts it: the link
2479            // route never enters `dbPut`, and the `SPC_NOMOD` refusal a link
2480            // field can still take comes from `dbPutSpecial(paddr, 0)`
2481            // (`dbAccess.c:1174` -> `:124`), which runs AFTER `dbCanSetLink`.
2482            if let Some(text) = check_link_put(
2483                instance.record.record_type(),
2484                instance.common.dtyp.as_str(),
2485                &field,
2486                &value,
2487            )? {
2488                value = text;
2489            }
2490
2491            // SPC_NOMOD / read-only fields: rejected inside C's `dbPut`, i.e.
2492            // after the DISP gate above and before the PROC-driven process
2493            // below. One gate owner for every route
2494            // ([`check_no_mod_in_db_put`]).
2495            check_no_mod_in_db_put(&instance, &field)?;
2496        }
2497
2498        // C `processNotifyCommon` (dbNotify.c:225-232) tests PACT ABOVE the
2499        // put — `if (precord->pact) { ... pnotify->state =
2500        // notifyRestartCallbackRequested; ... return; }` — so a put-notify that
2501        // lands on a busy record writes NOTHING: no value, no RPRO, no join of
2502        // the in-flight cycle's wait-set. The whole put is replayed by the
2503        // `PactExit` the record's PACT release hands to its `recGblFwdLink`
2504        // tail (C `dbNotifyCompletion`). Joining the running cycle instead
2505        // completed the callback one cycle early, on work that never saw this
2506        // value.
2507        //
2508        // The ownership test and the enqueue are ONE critical section (C holds
2509        // `dbScanLock` across both): a put queued onto a record that went idle
2510        // in between would wait for a restart check that already ran. A record
2511        // that goes idle in the window falls through and takes the put the
2512        // ordinary way.
2513        //
2514        // A second put-notify onto a record that already owns one is C's
2515        // "another processNotify owns the record" (dbNotify.c:213-220): it joins
2516        // the SAME queue, at the back (`ellSafeAdd`). Both tests are one arm
2517        // here because both have the same answer — the put waits, unwritten.
2518        // Refusing it instead (`S_db_Blocked` / `ECA_PUTCBINPROG`) drops the
2519        // client's value, and C never sends that status from this path.
2520        //
2521        // A fire-and-forget `dbPutField` is NOT deferred: it writes and raises
2522        // RPRO (dbAccess.c:1263-1277). Only the notify route waits.
2523        //
2524        // C `dbProcessNotify` (dbNotify.c:337-354) handles a put-notify to a
2525        // DBF link field (INLINK/OUTLINK/FWDLINK) as a dedicated early case,
2526        // ABOVE the PACT logic and the whole `processNotifyCommon` machinery:
2527        // "Only dbPutField will change link fields. Also the record is not
2528        // processed as a result." It writes the value via `dbPutField`
2529        // (`putFieldType`) and fires the done callback IMMEDIATELY — it never
2530        // reaches the PACT test, never processes, never defers. So a link
2531        // field always takes the value even on a busy or permanently-parked
2532        // record: a bare `sub` (empty `SNAM`) parks PACT=TRUE forever
2533        // (subRecord.c:119-122), and parking its link-field put on a `PactExit`
2534        // that never comes drops the value — `caput <sub>.INPA '0'` then reads
2535        // back "" instead of C's "0". The ordinary write path below already
2536        // reproduces C's link semantics for these fields (writes the value;
2537        // `put_drives_processing_of` is false — no link field is `pp` or PROC —
2538        // so it processes nothing and returns immediate completion), so the
2539        // only correction the special case needs is to keep a link field OUT
2540        // of the notify PACT-defer park.
2541        let is_dbf_link_field = self.is_dbf_link_field(record_name, &field);
2542        // The claim on this record's put-notify slot, held for the rest of the
2543        // body. `None` for a fire-and-forget put, which parks nothing — C
2544        // builds a `putNotify` only in `dbPutNotify`. See [`NotifyClaim`] for
2545        // why the claim is taken HERE, in the same critical section as the
2546        // ownership test below, and not at the process cycle it arms.
2547        let mut claim: Option<(
2548            NotifyClaim<'_>,
2549            Option<crate::runtime::sync::oneshot::Receiver<()>>,
2550        )> = None;
2551        if want_notify {
2552            let is_restart = notify_request.is_restart();
2553            self.cancel_unanswerable_notify(record_name);
2554            let mut guard = rec.write();
2555            // A restart is already the record's owner, so only PACT can stop it
2556            // (C skips dbNotify.c:213 for it and falls straight to :225).
2557            let must_wait = if is_restart {
2558                guard.is_processing()
2559            } else if is_dbf_link_field {
2560                // Ownership only — see `notify_put_has_owner`. Keeping link
2561                // fields out of the whole decision (not just the PACT arm) left
2562                // an owned record's link put falling through to the wait-set
2563                // install below, whose only answer to an occupied slot was
2564                // a refusal, deleted since along with its `CaError` variant.
2565                guard.notify_put_has_owner()
2566            } else {
2567                guard.notify_put_is_owned()
2568            };
2569            if must_wait {
2570                let Some((completion, completion_rx)) = notify_request.into_completion() else {
2571                    // Unreachable: `want_notify` is exactly "this request
2572
2573                    // carries a completion".
2574                    return Ok(crate::server::record::ProcessCompletion::Sync);
2575                };
2576                let put = crate::server::record::DeferredNotifyPut {
2577                    field,
2578                    value,
2579                    completion,
2580                };
2581                let put = crate::server::record::DeferredNotify::Put(put);
2582                if is_restart {
2583                    guard.requeue_notify_put(put);
2584                } else {
2585                    guard.queue_notify_put(put);
2586                }
2587                // A queued put-notify IS async: it replays and completes on the
2588                // restart check that frees it (C `notifyRestartInProgress` /
2589                // `notifyWaitForRestart`, dbNotify.c:213-220, 225-232). `Deferred` replays
2590                // carry only the sender, so `completion_rx` is `None` there and
2591                // this maps to `Sync` — but that path is the internal restart,
2592                // not a fresh client put.
2593                return Ok(crate::server::record::ProcessCompletion::from_signal(
2594                    completion_rx,
2595                ));
2596            }
2597            // Not deferred, so this put owns the record — take the slot now,
2598            // under the guard that just said it is free. Everything below runs
2599            // with the wait-set installed, and every way out of this function
2600            // that is not a process cycle drops the claim.
2601            if let Some((completion, completion_rx)) = notify_request.into_completion() {
2602                claim = Some((
2603                    Self::claim_put_notify(&rec, &mut guard, completion),
2604                    completion_rx,
2605                ));
2606            }
2607        }
2608
2609        // PROC intercept: trigger processing on any SCAN.
2610        // Falls through to the put_notify_tx registration below
2611        // so async records (motor, asyn-backed AO) signal real
2612        // completion; otherwise WRITE_NOTIFY would return ECA_NORMAL
2613        // before the device move actually finished.
2614        //
2615        // C `dbPutField` (dbAccess.c:1265) matches the proc field by pointer
2616        // with NO value check: any write to PROC — including 0 — processes the
2617        // record (when !pact). The standard `caput REC.PROC 0` / `dbpf REC.PROC
2618        // 0` force-process idiom must therefore not be skipped for a zero value.
2619        if field == "PROC" {
2620            // C `dbCommon.dbd` declares `field(PROC,DBF_UCHAR){ pp(TRUE) }`, so
2621            // a put to PROC does BOTH: `dbPut` stores the raw byte in
2622            // `prec->proc` (retained — C never resets it), AND `pp(TRUE)` drives
2623            // the reprocess below. The prior port kept only the reprocess and
2624            // dropped the byte, so `caput REC.PROC v; caget REC.PROC` always
2625            // read 0. Store the byte through the SAME `DBF_UCHAR` common-field
2626            // path DISP/RPRO use (coercion + signed readback: `caput PROC 255` →
2627            // `caget` = -1) in its own brief write lock so both the notify and
2628            // fire-and-forget paths take it, then fall through to force-process.
2629            // C `dbPut:1413` posts DBE_VALUE|DBE_LOG for the put field (PROC is
2630            // not the record's value field, so the pp-suppression never applies).
2631            // Store the raw PROC byte (C `dbChannelPut`). A bad conversion
2632            // (`caput REC.PROC 256` / non-numeric) refuses the store AND the
2633            // client's put — but, exactly as C's `putCallback` returns
2634            // `didPut = 1` while setting `notifyError` (`dbNotify.c:528-530`),
2635            // the PROC `pp(TRUE)`-driven `dbProcess` (`dbNotify.c:243-264`) still
2636            // runs on the NOTIFY path. This is the SAME rule the general put path
2637            // applies for a rejected pp-field conversion (`field_io.rs:1748-1806`,
2638            // "Cause B"); mirror it here so PROC does not diverge from UDF: carry
2639            // the refusal, force-process when `want_notify`, then hand the Err
2640            // back so the client still sees `ECA_PUTFAIL`.
2641            let proc_store: CaResult<()> = {
2642                let rec_arc = {
2643                    let recs = self.inner.records.read();
2644                    recs.get(record_name).cloned()
2645                };
2646                if let Some(rec_arc) = rec_arc {
2647                    let mut guard = rec_arc.write();
2648                    match guard.put_common_field("PROC", value) {
2649                        Ok(_) => {
2650                            guard.notify_field(
2651                                "PROC",
2652                                crate::server::recgbl::EventMask::VALUE
2653                                    | crate::server::recgbl::EventMask::LOG,
2654                            );
2655                            Ok(())
2656                        }
2657                        Err(e) => Err(e),
2658                    }
2659                } else {
2660                    Ok(())
2661                }
2662            };
2663            if let Err(e) = proc_store {
2664                // `want_notify` ⇒ C `ca_put_callback`: the PROC process runs
2665                // despite the rejected conversion (`didPut == 1`). Fire-and-forget
2666                // ⇒ C plain `dbPutField`, which returns before `dbProcess` on a
2667                // non-zero `dbPut` status (`dbAccess.c:1263-1264`), so it must NOT
2668                // process. Either way the client is answered `ECA_PUTFAIL`.
2669                if want_notify {
2670                    // The cycle runs under this put's wait-set, so the claim is
2671                    // committed before it starts — see `commit_without_waiting`.
2672                    if let Some((c, _rx)) = claim.take() {
2673                        c.commit_without_waiting();
2674                    }
2675                    let _ = self.put_driven_process_already_locked(record_name);
2676                }
2677                return Err(e);
2678            }
2679            // A fire-and-forget caller parks nothing — C `dbPutField` on PROC
2680            // processes the record with no putNotify. A notify caller commits
2681            // the claim it has held since the entry gate; there is no third
2682            // answer to reach, because nothing could have taken the slot from
2683            // under it.
2684            let parked = claim.take().map(|(c, rx)| (c.commit(), rx));
2685            // C `dbPutField:1264-1277`: PROC is one of the two fields that
2686            // selects the record for the put-driven process — with the same
2687            // PACT→RPRO deferral as a `pp` field. Both go through the single
2688            // owner (R19-43).
2689            //
2690            // The ALREADY-LOCKED entry, unconditionally — NOT `acquire_gate`
2691            // passed through. By the time control reaches here the record's
2692            // advisory gate is held on both paths: this function took it above
2693            // when `acquire_gate`, and the caller (an atomic group PUT) holds it
2694            // when not. The gate is not reentrant, so acquiring it again
2695            // here deadlocks every PROC put.
2696            let _ = self.put_driven_process_already_locked(record_name);
2697            // The wait-set fires the oneshot only after the whole
2698            // FLNK/OUT chain (sync + async) settles. If it has
2699            // already completed the chain was fully synchronous —
2700            // report immediate success; otherwise hand the receiver
2701            // to the CA layer to await the deferred completion.
2702            return match parked {
2703                Some((notify, completion_rx)) => {
2704                    if notify.completed() {
2705                        Ok(crate::server::record::ProcessCompletion::Sync)
2706                    } else {
2707                        Ok(crate::server::record::ProcessCompletion::from_signal(
2708                            completion_rx,
2709                        ))
2710                    }
2711                }
2712                None => Ok(crate::server::record::ProcessCompletion::Sync),
2713            };
2714        }
2715
2716        // Normal field put (write lock) — C `dbPut`, which does NOT touch
2717        // `putf`: the marker is raised only where C raises it, at the
2718        // put-driven process decision (`put_driven_process`).
2719        //
2720        // Link writes the record's `special()` makes itself. C runs them inside
2721        // `dbPut`, so they land BEFORE the `pp(TRUE)` process below — a record
2722        // wired to scaler `.COUTP` is processed with the scaler not yet armed
2723        // (scalerRecord.c:623-624, before the `:637` REQSTART).
2724        let mut special_actions = Vec::new();
2725        // Scoped guard: the put body (closure + failure handling) runs under
2726        // this write guard, whose scope ends (releasing the !Send parking_lot
2727        // guard) before the notify-process / scan-index awaits below. Yields
2728        // either the success `CommonFieldPutResult`, or `(error, should_process)`
2729        // so the notify-driven process on a rejected put runs guard-free.
2730        // C reads a link-backed field's metadata live inside the rset,
2731        // under the TARGET record's lock; every poster below holds THIS
2732        // record's lock and cannot reach for a second one. Resolved here,
2733        // where no record lock is held, and handed down as a borrowed
2734        // value that dies with this body — so a `caput CALC.A` carries the
2735        // metadata `INPA`'s target has NOW, not what it had when the
2736        // source last processed. Empty after one read lock for every
2737        // record type but calc, calcout, sub, aSub and seq.
2738        let link_backing = self.resolve_link_backed_metadata(&rec);
2739        let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
2740        let outcome: Result<crate::server::record::CommonFieldPutResult, (CaError, bool)> = {
2741            let mut instance = rec.write();
2742
2743            // C `db_put_process` (db_access.c:1025-1043) returns 1 (didPut) even
2744            // when the internal `dbChannelPut` FAILS — a rejected conversion, an
2745            // SPC_NOMOD refusal, or an after-put `special()` error all set
2746            // `ppn->status = notifyError` yet still `return 1` — so
2747            // `processNotifyCommon` (dbNotify.c:243-246) still runs `dbProcess`
2748            // when the gate passes. The whole put write is therefore wrapped so
2749            // that ANY failure inside it — `dbput_request`, `special()` pass 0,
2750            // `put_field`, `special_after_put`, `put_common_field` — is caught at
2751            // ONE place below: on the notify path we evaluate the SAME process gate
2752            // the success path uses and process the record as a side effect, then
2753            // hand the original Err back to the client. On the failing conversion
2754            // path no field is written — `dbChannelPut` wrote nothing either.
2755            //
2756            // On SUCCESS this closure is just C `dbPut`: the monitor posts at its
2757            // tail run only when the put fully succeeded (C's `goto done` skips
2758            // them on failure).
2759            let block_result: CaResult<crate::server::record::CommonFieldPutResult> = (|| {
2760                // Coerce value to the field's native DBR type (e.g. String → Double for ao.VAL).
2761                // This matches C EPICS db_put_field() which converts from the CA client's type
2762                // to the record field's native type.
2763                let request = dbput_request(&*instance.record, &field, value)?;
2764
2765                // Pre-write special hook (C EPICS dbPutSpecial pass=0)
2766                instance.record.special(&field, false)?;
2767                special_before_put(&mut instance, &field);
2768
2769                // Capture pre-put value for faac1df1 idempotent-write suppression.
2770                let prev_value = instance.record.get_field(&field);
2771                let old_nord = array_nord_before_put(&instance, &field);
2772
2773                // Try record-specific field first; fall back to common on FieldNotFound.
2774                // For record-owned fields, call on_put() and special() after successful put,
2775                // matching what put_common_field() does for common fields.
2776                use crate::server::record::CommonFieldPutResult;
2777                let common_result = match request {
2778                    // C `dbAccess.c:1362` converted zero elements: nothing is
2779                    // written and `dbPut` returns 0, so the client's put SUCCEEDS.
2780                    // On the SCALAR arm it also drives the record to LINK/INVALID
2781                    // (`:1371`) and the process cycle below commits and posts that
2782                    // alarm — which is how a C IOC surfaces `caput -a` of an empty
2783                    // array into a scalar. The ARRAY arm raises nothing.
2784                    PutRequest::StoreNothing { alarm } => {
2785                        if alarm {
2786                            set_empty_request_alarm(&mut instance);
2787                        }
2788                        clear_udf_on_value_put(&mut instance, &field);
2789                        CommonFieldPutResult::NoChange
2790                    }
2791                    PutRequest::Write(value) => {
2792                        match instance.record.put_field(&field, value.clone()) {
2793                            Ok(()) => {
2794                                instance.record.on_put(&field);
2795                                // C returns the after-put special() status from
2796                                // `dbPut` (dbAccess.c:1399-1405); `if (status)
2797                                // goto done` then skips both the UDF clear below
2798                                // and the field's monitor post, and `dbPutField`
2799                                // skips the process. Propagating the error here
2800                                // reproduces all three.
2801                                let result = special_after_put(
2802                                    self,
2803                                    &mut instance,
2804                                    &field,
2805                                    &mut special_actions,
2806                                    link_backing,
2807                                )?;
2808                                // The clear happens BEFORE `dbProcess` runs, so
2809                                // any reader between the put and the process cycle
2810                                // sees the new value with a consistent udf=false.
2811                                clear_udf_on_value_put(&mut instance, &field);
2812                                result
2813                            }
2814                            Err(CaError::FieldNotFound(_)) => {
2815                                instance.put_common_field(&field, value)?
2816                            }
2817                            // A refused store does NOT skip the pass above:
2818                            // C runs it either way (`dbAccess.c:1398`) and lets
2819                            // its status win, then `goto done` — the `return`.
2820                            Err(e) => {
2821                                special_after_put(
2822                                    self,
2823                                    &mut instance,
2824                                    &field,
2825                                    &mut special_actions,
2826                                    link_backing,
2827                                )?;
2828                                return Err(e);
2829                            }
2830                        }
2831                    }
2832                };
2833
2834                // C `add_count` raises the inverted-limits alarm during a SGNL
2835                // SPC_MOD `special()` (histogramRecord.c:329-334). The port raises
2836                // it through `nsta`/`nsev` (CBUG-F12 refused, not C's direct write),
2837                // so this monitor-less special path commits it — check then reset —
2838                // to make STAT=SOFT/INVALID observable, matching the process path.
2839                // Gated on `special_checks_alarms` (histogram SGNL only). No STAT
2840                // post: C's `add_count` posts nothing, and the special path has no
2841                // monitor — the alarm shows on the next caget's field read.
2842                if instance.record.special_checks_alarms(&field) {
2843                    let inst = &mut *instance;
2844                    inst.record.check_alarms(&mut inst.common);
2845                    let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
2846                }
2847
2848                // Invalidate metadata cache only if the metadata-class
2849                // field's value actually changed (faac1df1).
2850                instance.notify_field_written_if_changed(&field, prev_value.as_ref(), link_backing);
2851
2852                // `putf` is neither set nor cleared anywhere in this block: C's
2853                // `dbPut` does not touch it. It is raised in `put_driven_process`
2854                // (C `dbAccess.c:1275`) immediately before `dbProcess`, stays TRUE
2855                // for the whole process cycle — including an async device round
2856                // trip — and is cleared by the `recGblFwdLink:302` analogue at the
2857                // cycle's tail (`processing.rs:5346` / `complete_async_record_inner`)
2858                // or by the disable-alarm bail (`dbAccess.c:575`).
2859
2860                // C `dbPut:1408-1413`'s field-monitor post, through the one
2861                // owner (`dbput_post_put_field`, shared with `put_pv`). On
2862                // this route the pp-value-field suppression pairs with the
2863                // `should_process` gate below: a suppressed field is exactly
2864                // one the reprocess cycle re-posts via the deadband snapshot.
2865                // (ACKT/ACKS have no arm here: they are SPC_NOMOD, refused by
2866                // the gate above. Alarm acknowledgement arrives as a DBR
2867                // request type, through [`Self::put_alarm_ack_from_ca`].)
2868                dbput_post_put_field(&mut instance, &field, link_backing);
2869
2870                // The NORD post, through the one owner — C reaches `put_array_info`
2871                // from `dbPut`, so the CA route posts it exactly like the internal
2872                // one. It is NOT covered by the value-field post above: for a
2873                // waveform that post is suppressed (VAL is `pp(TRUE)`), and it is
2874                // not covered by the process cycle either — a `caput -a` to a
2875                // slow-scanned or passive-but-unprocessed waveform posts NORD now
2876                // and VAL only at the next scan.
2877                post_array_info(&mut instance, &old_nord, 0, link_backing);
2878
2879                // Fields a `special()` changed as a side effect of this put
2880                // (e.g. compress RES reset zeroing NUSE/VAL) get their monitors
2881                // posted here, mirroring the explicit `db_post_events` a C
2882                // `special()` makes — these fields are not pp(TRUE), so no
2883                // process cycle would otherwise post them. Each post carries
2884                // VALUE|LOG unless the record names the field in
2885                // `value_only_change_fields()` — a record whose C `special()`
2886                // posts the field with a literal `DBE_VALUE` (e.g. table SET,
2887                // tableRecord.c:659) gets the LOG bit stripped, honoring the
2888                // same value-only contract as the change-detection path.
2889                //
2890                // C's `monitor()` runs `recGblResetAlarms(prec)` BEFORE those
2891                // `db_post_events`, and OR-adds the alarm bit it returns into the
2892                // value posts (compressRecord.c:103-110). The port mirrors that
2893                // order: commit the alarm here (posting any STAT/SEVR/AMSG/ACKS
2894                // transition through the one owner, `alarm_field_posts`) and carry
2895                // the resulting DBE_ALARM into the side-effect posts below. Records
2896                // whose `special()` does not run `monitor()` return false and skip
2897                // this entirely (no spurious alarm commit on an unrelated put).
2898                let side_effect_alarm_mask = if instance.record.special_commits_alarms(&field) {
2899                    commit_special_reset_alarm(&mut instance)
2900                } else {
2901                    crate::server::recgbl::EventMask::NONE
2902                };
2903
2904                let side_effect_value_only = instance.record.value_only_change_fields();
2905                for sf in instance.record.monitor_side_effect_fields(&field) {
2906                    use crate::server::recgbl::EventMask;
2907                    let mask = if side_effect_value_only
2908                        .iter()
2909                        .any(|f| f.eq_ignore_ascii_case(sf))
2910                    {
2911                        EventMask::VALUE
2912                    } else {
2913                        EventMask::VALUE | EventMask::LOG
2914                    };
2915                    instance.notify_field(sf, mask | side_effect_alarm_mask);
2916                }
2917
2918                // The same `special()` posts, but named by the WRITER instead of
2919                // by a static table: a record whose put handler re-derived a
2920                // partner field marks it — with the mask of the C call site that
2921                // posts it, and only when that field's own comparison moved
2922                // (sseq `special()` posts the re-rendered `STRn` after a `DOn`
2923                // put, `DBE_VALUE`, `only if (strcmp(str, plinkGroup->s))`,
2924                // sseqRecord.c:1108-1116). A static field-name list cannot
2925                // express "only if it changed", so it over-posts; the mark can.
2926                emit_cycle_posts(&mut instance, link_backing);
2927
2928                Ok(common_result)
2929            })(
2930            );
2931
2932            // Cause B: a put-NOTIFY whose write was rejected must still process.
2933            // C `db_put_process` returned 1 (didPut) despite the failure above, so
2934            // `processNotifyCommon` runs `dbProcess` whenever the gate passes.
2935            // Reuse the SAME `put_drives_processing_of` gate the success tail uses,
2936            // process the record as a side effect, then return the ORIGINAL Err —
2937            // the CA layer maps it to PUTFAIL (C `notifyError`) and `put_accepted`
2938            // stays False, while STAT/SEVR recompute to match C. The notify path
2939            // ONLY: a plain `dbPutField` failure processes nothing (dbAccess.c:1263
2940            // processes only when `dbPut` status==0), so `want_notify == false`
2941            // keeps its Err-without-process behavior. The instance write lock must
2942            // drop before `put_driven_process_already_locked` re-acquires it.
2943            match block_result {
2944                Ok(cr) => Ok(cr),
2945                Err(e) => {
2946                    // C `dbAccess.c:1398-1403` runs `dbPutSpecial(paddr, 1)` UNCONDITIONALLY
2947                    // ("Always do special processing if needed") — even when the
2948                    // conversion above failed — before the `goto done` that skips
2949                    // the udf clear and the field's monitor post. For a compress
2950                    // SPC_RESET field that means `special()` still runs `monitor()`
2951                    // → `recGblResetAlarms`, committing the born-UDF alarm to
2952                    // NO_ALARM though the RES/N put is rejected (a caget then sees
2953                    // stat/sevr=NO_ALARM with udf still 1, matching C softIoc).
2954                    // Run the after-put `special()` and its alarm commit here, then
2955                    // hand back the ORIGINAL Err so the client still sees PUTFAIL.
2956                    // Gated on `special_commits_alarms` (compress only) so no other
2957                    // special record runs its after-put hook on a failed conversion.
2958                    if instance.record.special_commits_alarms(&field) {
2959                        let _ = instance.record.special(&field, true);
2960                        let alarm_mask = commit_special_reset_alarm(&mut instance);
2961                        let value_only = instance.record.value_only_change_fields();
2962                        for sf in instance.record.monitor_side_effect_fields(&field) {
2963                            use crate::server::recgbl::EventMask;
2964                            let mask = if value_only.iter().any(|f| f.eq_ignore_ascii_case(sf)) {
2965                                EventMask::VALUE
2966                            } else {
2967                                EventMask::VALUE | EventMask::LOG
2968                            };
2969                            instance.notify_field(sf, mask | alarm_mask);
2970                        }
2971                    }
2972                    // The same `dbPutSpecial(paddr, 1)`-on-reject rule for a field
2973                    // whose special() raises the inverted-limits alarm (histogram
2974                    // SGNL → add_count): C still runs add_count when the SGNL
2975                    // conversion fails, so STAT=SOFT/INVALID appears even for a
2976                    // rejected `caput .SGNL notanumber`. The port raises it through
2977                    // `nsta`/`nsev` (CBUG-F12 refused) and commits it here — check
2978                    // then reset — since no process follows.
2979                    if instance.record.special_checks_alarms(&field) {
2980                        let inst = &mut *instance;
2981                        inst.record.check_alarms(&mut inst.common);
2982                        let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut inst.common);
2983                    }
2984                    // The same `dbPutSpecial(paddr, 1)`-on-reject rule for a field
2985                    // whose special() clears UDF: C `mbboDirectRecord.c::special`
2986                    // (after==1, B0..B1F, line 290) sets `prec->udf = FALSE`, and
2987                    // that special runs UNCONDITIONALLY in `dbPut` (dbAccess.c:1398-1400,
2988                    // "Always do special processing") even when the value conversion
2989                    // failed — BEFORE the `if (status) goto done`. So a rejected
2990                    // `caput -c mbboDirect.Bn 256`/`notanumber` still clears UDF, and
2991                    // the notify-process that follows recomputes STAT/SEVR to
2992                    // NO_ALARM instead of the born-UDF INVALID (verified live against
2993                    // the C softIoc: fresh record → rejected Bn put → NO_ALARM,
2994                    // udf=0). The success path clears UDF for this same field set via
2995                    // `is_udf_defining_put` (the `udf = 0` at the tail of the put
2996                    // body). The primary VAL field is EXCLUDED here: its UDF clear is
2997                    // `isValueField` (dbAccess.c:1409-1410), which runs AFTER the status
2998                    // check, so a rejected VAL put keeps UDF — matching C. Only
2999                    // mbboDirect overrides `is_udf_defining_put` to add non-primary
3000                    // fields, so this is a no-op for every other record type.
3001                    if instance.record.is_udf_defining_put(&field)
3002                        && field != instance.record.primary_field()
3003                    {
3004                        instance.common.udf = 0;
3005                    }
3006                    let should_process = want_notify && put_drives_processing_of(&instance, &field);
3007                    Err((e, should_process))
3008                }
3009            }
3010        };
3011        // No put-notify is armed here: C's restart owner is the cycle tail
3012        // (`end_process_cycle`), never the `pact = FALSE` store.
3013
3014        let common_result = match outcome {
3015            Ok(cr) => cr,
3016            Err((e, should_process)) => {
3017                if should_process {
3018                    // Same rule as the PROC refusal above: the cycle runs under
3019                    // the wait-set, so commit before driving it.
3020                    if let Some((c, _rx)) = claim.take() {
3021                        c.commit_without_waiting();
3022                    }
3023                    let _ = self.put_driven_process_already_locked(record_name);
3024                }
3025                return Err(e);
3026            }
3027        };
3028        // ASG-field change re-evaluation hook. C
3029        // `asDbLib.c:107-110` `asSpcAsCallback` (registered at `:144`)
3030        // invokes `asChangeGroup` → `asAddMemberPvt` → `asComputePvt` for
3031        // every `ASGCLIENT` on `dbPut record.ASG NEW_ASG`. Pre-fix
3032        // Rust mutated `common.asg` directly with no notification,
3033        // so the wire ACCESS_RIGHTS the client saw still reflected
3034        // the OLD ASG until something else triggered re-eval. Now we
3035        // fire a process-wide notifier that the CA server folds into
3036        // its per-client `reeval_access_rights` path.
3037        if field == "ASG" {
3038            crate::server::access_security::notify_asg_field_changed();
3039        }
3040        // record lock released
3041
3042        // C `dbPutField` reaches `dbProcess` only after `dbPut` — and therefore
3043        // after `dbPutSpecial(paddr, 1)` and every `dbPutLink` it made — has run
3044        // to completion. Execute them here, ahead of the `pp(TRUE)` process.
3045        self.run_special_actions(record_name, &rec, std::mem::take(&mut special_actions));
3046
3047        // Update scan index if SCAN or PHAS changed
3048        match common_result {
3049            crate::server::record::CommonFieldPutResult::ScanChanged {
3050                old_scan,
3051                new_scan,
3052                phas,
3053            } => {
3054                self.update_scan_index(record_name, old_scan, new_scan, phas, phas);
3055            }
3056            crate::server::record::CommonFieldPutResult::PhasChanged {
3057                scan: s,
3058                old_phas,
3059                new_phas,
3060            } => {
3061                self.update_scan_index(record_name, s, s, old_phas, new_phas);
3062            }
3063            crate::server::record::CommonFieldPutResult::NoChange => {}
3064        }
3065
3066        // C `dbAccess.c::dbPutField:1263-1268` re-processes the
3067        // record on a put only when the put field is `pp(TRUE)` AND the
3068        // record is Passive (`SCAN == 0`). (The `PROC` field has its own
3069        // always-process intercept above, matching C's
3070        // `pfield == &precord->proc`; alarm-ack fields like ACKT/ACKS are
3071        // not `pp(TRUE)` so they fall out here, matching C's
3072        // `dbrType < DBR_PUT_ACKT`.) Processing on every put would
3073        // double-process scanned records and spuriously process puts to
3074        // non-`pp` fields (extra FLNK / monitors / device writes /
3075        // timestamps). `process_passive_fields()` is total and fail-safe: an
3076        // unmodeled type returns `&[]` (and warns once), so it processes on
3077        // `PROC` only — spurious processing is opt-in (a type must declare its
3078        // pp set), never the default.
3079        let should_process = {
3080            let instance = rec.read();
3081            put_drives_processing_of(&instance, &field)
3082        };
3083
3084        if !should_process {
3085            // No processing cycle, so C never raises `putf` (and this put did
3086            // not either). Report immediate (synchronous) completion to a
3087            // WRITE_NOTIFY caller.
3088            return Ok(crate::server::record::ProcessCompletion::Sync);
3089        }
3090
3091        // Set up the put-notify wait-set BEFORE processing. The wait-set
3092        // fires `completion_tx` only after the originating record AND
3093        // every FLNK/OUT chain target it triggers (sync or async) has
3094        // completed — C `dbNotify.c` `processNotify`/`dbNotifyCompletion`.
3095        // An occupied slot is QUEUED, never refused. C
3096        // `processNotifyCommon` answers "another processNotify owns the
3097        // record" with `ellSafeAdd` onto `restartList` (dbNotify.c:213-220)
3098        // and carries no refusal arm: `S_db_Blocked` is raised by a test
3099        // record's own put hook (test/ioc/db/xRecord.c:89), never by the
3100        // notify machinery, and `ECA_PUTCBINPROG` has exactly one sender in
3101        // all of base — the put-callback timeout in `write_notify_action`
3102        // (rsrv/camessage.c:1701 at R7.0.10).
3103        // Overwriting the slot instead would drop the prior Sender, waking
3104        // the prior caller's rx with RecvError that the CA dispatcher treats
3105        // as success, so the install goes through the one owner.
3106        //
3107        // A fire-and-forget put parks NOTHING — C builds a `putNotify`
3108        // only in `dbPutNotify`; `dbPutField` processes the record with
3109        // no notify state at all. It therefore neither conflicts with
3110        // nor disturbs a WRITE_NOTIFY already parked on the record.
3111        let parked = claim.take().map(|(c, rx)| (c.commit(), rx));
3112
3113        // When a CA put writes directly to VAL on an INPUT record whose
3114        // VAL is the engineering value, the built-in `RVAL → VAL`
3115        // `convert()` must be suppressed for the put-driven process —
3116        // re-deriving VAL from a stale RVAL would clobber the value the
3117        // operator just wrote (the soft ai preset-NaN case, processing.rs
3118        // ~line 677). The framework expresses this by calling
3119        // `set_device_did_compute(true)`.
3120        //
3121        // This MUST be gated on `soft_channel_skips_convert()`. Output
3122        // records (mbbo/mbbo_direct/bo/ao) implement
3123        // `set_device_did_compute` as "skip the VAL → RVAL output
3124        // convert" — the OPPOSITE direction. C `mbboRecord.c::process`
3125        // (line 217), `mbboDirectRecord.c::process` (line 198) and
3126        // `boRecord.c::process` (line 207) call `convert()`
3127        // unconditionally on every non-pact process; a CA VAL-put on an
3128        // output record MUST recompute RVAL/ORAW. Suppressing it there
3129        // left RVAL/ORAW/ORBV stale. Output records return the default
3130        // `false` from `soft_channel_skips_convert()`, so this gate
3131        // matches the identical gates in processing.rs (line 694) and
3132        // record_instance.rs (line 1381).
3133        if field == "VAL" {
3134            // Collect-then-act: clone the handle under a brief map read, drop
3135            // the map lock before the per-record write.
3136            let rec_arc = {
3137                let recs = self.inner.records.read();
3138                recs.get(record_name).cloned()
3139            };
3140            if let Some(rec_arc) = rec_arc {
3141                let mut guard = rec_arc.write();
3142                if guard.record.soft_channel_skips_convert() {
3143                    guard.record.set_device_did_compute(true);
3144                }
3145            }
3146        }
3147
3148        // Process the record after field put — through the single owner of C's
3149        // `dbPutField:1268-1277` decision, so an async-active record takes the
3150        // RPRO deferral instead of a doomed re-entrant `dbProcess`.
3151        let _ = self.put_driven_process_already_locked(record_name);
3152
3153        // Is the ORIGINATING record itself still async-pending? Its
3154        // wait-set membership is taken + `leave`d at its own completion
3155        // (sync-end, or later in `complete_async_record_inner`), so a
3156        // lingering `notify` on its instance means its device round-trip
3157        // is still in flight. This gates only the originating record's
3158        // PUTF clear — independent of whether downstream chain targets
3159        // are still pending.
3160        //
3161        // A fire-and-forget put parked nothing, and a `notify` it sees
3162        // on the instance belongs to some other caller's WRITE_NOTIFY —
3163        // not evidence about THIS put. Fall through to the guarded
3164        // clear; its `!is_processing()` gate already preserves PUTF
3165        // across an async-pending device round-trip.
3166        let originating_pending = want_notify && {
3167            let rec = self.inner.records.read();
3168            if let Some(rec_arc) = rec.get(record_name) {
3169                rec_arc.read().notify.is_some()
3170            } else {
3171                false
3172            }
3173        };
3174
3175        // C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` after
3176        // the forward-link dispatch — the marker only lives for the
3177        // duration of the put's processing cycle. For SYNCHRONOUS
3178        // completions (PACT was cleared by the time
3179        // `process_record_with_links` returns) clear it here. For
3180        // async-pending records, the clearing happens later in
3181        // `complete_async_record_inner` (which runs FLNK as part of
3182        // the completion path) so the PUTF marker survives the
3183        // device-write round trip.
3184        if !originating_pending {
3185            // Collect-then-act: clone the handle under a brief map read, drop
3186            // the map lock before the per-record write.
3187            let rec_arc = {
3188                let recs = self.inner.records.read();
3189                recs.get(record_name).cloned()
3190            };
3191            if let Some(rec_arc) = rec_arc {
3192                let mut guard = rec_arc.write();
3193                if !guard.is_processing() {
3194                    guard.common.putf = false;
3195                }
3196            }
3197        }
3198
3199        // CA completion gates on the WHOLE chain, not just the
3200        // originating record: the put-notify must not report
3201        // done until every FLNK/OUT target it drove — including an async
3202        // FLNK target that the originating record's sync cycle merely
3203        // kicked off — has settled. `completed()` is true iff the
3204        // wait-set drained to zero during this call (fully synchronous
3205        // chain); otherwise the receiver fires later from the last
3206        // chain member's `leave`.
3207        match parked {
3208            Some((notify, completion_rx)) => {
3209                if notify.completed() {
3210                    Ok(crate::server::record::ProcessCompletion::Sync)
3211                } else {
3212                    Ok(crate::server::record::ProcessCompletion::from_signal(
3213                        completion_rx,
3214                    ))
3215                }
3216            }
3217            None => Ok(crate::server::record::ProcessCompletion::Sync),
3218        }
3219    }
3220
3221    /// Put a PV value without triggering process (for restore).
3222    ///
3223    /// Unlike the `dbPut`-analogue bodies (`put_pv`, `put_pv_and_post`) this
3224    /// entry accepts DBF link fields: its production caller is the autosave
3225    /// restore, whose C analogue (`reboot_restore`) writes via `dbPutField` —
3226    /// and `dbPutField` on a link field is `dbPutFieldLink`, a re-parse
3227    /// write that never processes. That is exactly this body's behavior
3228    /// (`put_common_field`'s INP/OUT/FLNK arms, no process), so the
3229    /// `check_not_link_field` refusal does not apply here.
3230    pub async fn put_pv_no_process(&self, name: &str, mut value: EpicsValue) -> CaResult<()> {
3231        let (base, field) = super::parse_pv_name(name);
3232        let field = field.to_ascii_uppercase();
3233        // C `dbPutFieldLink` (`dbAccess.c:1261`): a write to a DBF link field
3234        // relinks the target's lock set. The obligation is taken out here, so
3235        // that every exit path below discharges it; see
3236        // `record_lock.rs`'s `LinkFieldWrite`.
3237        let _relink = self.link_field_write(base, &field);
3238
3239        let simple = self.inner.simple_pvs.lock().get(name).cloned();
3240        if let Some(pv) = simple {
3241            pv.set(value);
3242            return Ok(());
3243        }
3244
3245        // Records — alias-aware (epics-base PR #336).
3246        if let Some(rec) = self.get_record(base) {
3247            // `put_pv_no_process` is a public record-write API
3248            // (autosave restore). It must take the advisory write gate
3249            // (`dbScanLock` analogue) so an autosave restore cannot
3250            // land between the member writes of a QSRV atomic group or
3251            // a pvalink atomic scan epoch holding `lock_records`.
3252            // `base` is alias-resolved so an alias and its target
3253            // share one gate. Held until return.
3254            let canonical_base: String =
3255                self.resolve_alias(base).unwrap_or_else(|| base.to_string());
3256            let _record_gate = self.lock_record(&canonical_base);
3257
3258            // C reads a link-backed field's metadata live inside the rset,
3259            // under the TARGET record's lock; every poster below holds THIS
3260            // record's lock and cannot reach for a second one. Resolved here,
3261            // where no record lock is held, and handed down as a borrowed
3262            // value that dies with this body — so a `caput CALC.A` carries the
3263            // metadata `INPA`'s target has NOW, not what it had when the
3264            // source last processed. Empty after one read lock for every
3265            // record type but calc, calcout, sub, aSub and seq.
3266            let link_backing = self.resolve_link_backed_metadata(&rec);
3267            let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
3268            let mut special_actions = Vec::new();
3269
3270            let common_result = {
3271                let mut instance = rec.write();
3272
3273                // The SPC_NOMOD half of that same pass-0 call, refused before
3274                // the rset's `special()` is ever dispatched
3275                // (`dbPutSpecial`, `dbAccess.c:122-127`). `dbPutField` — the
3276                // call this body models — reaches it like every other entry,
3277                // through `dbPut` (`dbAccess.c:1265`), so an autosave restore
3278                // cannot write a field the `.dbd` declares immutable. This
3279                // route was the one dbPut-analogue body that skipped the gate,
3280                // which left asynRecord's twelve `special(SPC_NOMOD)` fields
3281                // (OMAX, AINP, TINP, IMAX, NORD, EOMR, I32INP, UI32INP, F64INP,
3282                // SPR, OPTR, IPTR) restorable, and dbCommon's with them.
3283                // An autosave restore is a `dbPutField` (`reboot_restore`), so
3284                // a saved link text is held to `dbCanSetLink` exactly as a
3285                // `caput` is — that arm of `dbStaticLib.c:2634-2642` is the
3286                // one autosave takes. Above the no-mod gate for the reason
3287                // given at the other body: C's link route is above `dbPut`.
3288                if let Some(text) = check_link_put(
3289                    instance.record.record_type(),
3290                    instance.common.dtyp.as_str(),
3291                    &field,
3292                    &value,
3293                )? {
3294                    value = text;
3295                }
3296                check_no_mod_in_db_put(&instance, &field)?;
3297                // C `dbPutSpecial(paddr, 0)` — the pre-store pass, which
3298                // `dbPut` runs on every entry path including the `dbPutField`
3299                // this body models (autosave's `reboot_restore`). A non-zero
3300                // status returns before the store (`dbAccess.c:1345-1348`), so a
3301                // restore cannot write a field the record is currently refusing
3302                // (mbboDirect B0..B1F while OMSL=closed_loop,
3303                // `mbboDirectRecord.c:263-269`).
3304                instance.record.special(&field, false)?;
3305                special_before_put(&mut instance, &field);
3306
3307                let prev_value = instance.record.get_field(&field);
3308                // C's `reboot_restore` writes through `dbPutField` → `dbPut`,
3309                // which renders the request in the destination field's shape
3310                // AND THEN runs the `dbPutConvertRoutine` type row
3311                // (`dbAccess.c:1350-1391`) — a saved one-element `histogram`
3312                // VAL is a buffer here, not a scalar the record's arm refuses,
3313                // and a saved `DBF_STRING` reaches a menu/enum/numeric field
3314                // through `putStringMenu`/`Enum`/`<numeric>` rather than the
3315                // field-blind `convert_to` that stored `0` for a label and
3316                // `32767` for a refused `PREC 32768`. A field the record
3317                // SERVES routes through the same single owner the client
3318                // `dbPut` path uses (`dbput_coerce_value` = shape THEN type);
3319                // a `dbCommon` field the record does not serve has no served
3320                // type to convert against, so it keeps the shape-only reshape
3321                // its `put_common_field` fallback needs.
3322                //
3323                // A refused store is HELD, not returned: C runs the after pass
3324                // below either way (`dbAccess.c:1398`) and lets its status win.
3325                let refused = match instance.record.get_field(&field).map(|v| v.db_field_type()) {
3326                    Some(target) => match crate::server::record::dbput_coerce_value(
3327                        &*instance.record,
3328                        &field,
3329                        target,
3330                        value,
3331                    ) {
3332                        Ok(crate::types::c_parse::Converted::Stored(v)) => {
3333                            match instance.record.put_field(&field, v.clone()) {
3334                                Ok(()) => None,
3335                                Err(CaError::FieldNotFound(_)) => {
3336                                    instance.put_common_field(&field, v)?;
3337                                    None
3338                                }
3339                                Err(e) => Some(e),
3340                            }
3341                        }
3342                        // C's converter returned success without storing
3343                        // (`cvt_st_ul`'s skipped store): the field keeps its
3344                        // old value and the restore still succeeds.
3345                        Ok(crate::types::c_parse::Converted::Unchanged) => None,
3346                        // A convert failure is HELD like a refused store, so the
3347                        // unconditional after pass still runs before it returns.
3348                        Err(e) => Some(e),
3349                    },
3350                    None => {
3351                        let value = crate::server::record::put_value_in_field_shape(
3352                            &*instance.record,
3353                            &field,
3354                            value,
3355                        );
3356                        match instance.record.put_field(&field, value.clone()) {
3357                            Ok(()) => None,
3358                            Err(CaError::FieldNotFound(_)) => {
3359                                instance.put_common_field(&field, value)?;
3360                                None
3361                            }
3362                            Err(e) => Some(e),
3363                        }
3364                    }
3365                };
3366
3367                // C `dbPutSpecial(paddr, 1)` — the after-store pass, which
3368                // `dbPut` runs UNCONDITIONALLY ("Always do special processing
3369                // if needed", `dbAccess.c:1398-1404`) and whose status it
3370                // ADOPTS (`if (status2) status = status2;`). It is the pass
3371                // that re-derives the field's DEPENDENT state: `calcRecord.c`
3372                // recompiles RPCL from CALC, `subRecord.c:188` /
3373                // `aSubRecord.c:563-575` re-resolve SNAM through the registry,
3374                // SIMM runs `recGblCheckSimm`. Running only pass 0 left a
3375                // restored CALC evaluating the old expression and a restored
3376                // SNAM running the old routine, with the restore reporting
3377                // success — the link route C takes for a DBF link field
3378                // (`dbPutFieldLink`, `dbAccess.c:1174,1178`) runs the same pair.
3379                let result = special_after_put(
3380                    self,
3381                    &mut instance,
3382                    &field,
3383                    &mut special_actions,
3384                    link_backing,
3385                )?;
3386
3387                // `if (status) goto done` (`dbAccess.c:1404`) — the refused
3388                // store's status stands where the pass above did not replace
3389                // it, and skips the UDF clear and the posts below.
3390                if let Some(e) = refused {
3391                    return Err(e);
3392                }
3393
3394                // An autosave restore of VAL defines the record in C, and a
3395                // record left UDF here reports the born UDF_ALARM on its first
3396                // process cycle.
3397                clear_udf_on_value_put(&mut instance, &field);
3398                // Invalidate metadata cache only if the metadata-class
3399                // field actually changed (faac1df1).
3400                instance.notify_field_written_if_changed(&field, prev_value.as_ref(), link_backing);
3401                // The drain of the posts the after pass queued — `special()`
3402                // and its post are ONE step, so a mark cannot outlive the put
3403                // that made it and be emitted by some later cycle.
3404                emit_cycle_posts(&mut instance, link_backing);
3405                result
3406            };
3407
3408            // No put-notify is armed here: C's restart owner is the cycle
3409            // tail (`end_process_cycle`), never the `pact = FALSE` store.
3410
3411            // A restored SCAN or PHAS moves the record between scan lists.
3412            match common_result {
3413                crate::server::record::CommonFieldPutResult::ScanChanged {
3414                    old_scan,
3415                    new_scan,
3416                    phas,
3417                } => {
3418                    self.update_scan_index(&canonical_base, old_scan, new_scan, phas, phas);
3419                }
3420                crate::server::record::CommonFieldPutResult::PhasChanged {
3421                    scan: s,
3422                    old_phas,
3423                    new_phas,
3424                } => {
3425                    self.update_scan_index(&canonical_base, s, s, old_phas, new_phas);
3426                }
3427                crate::server::record::CommonFieldPutResult::NoChange => {}
3428            }
3429
3430            // The link writes the after pass queued, run once the record lock
3431            // is down — C reaches them from inside `dbPut`.
3432            self.run_special_actions(&canonical_base, &rec, special_actions);
3433
3434            // same SPC_AS parity as `put_pv` / the CA-write
3435            // path — autosave-style restores writing `.ASG` at IOC
3436            // startup must still trigger per-client re-eval.
3437            if field == "ASG" {
3438                crate::server::access_security::notify_asg_field_changed();
3439            }
3440            return Ok(());
3441        }
3442
3443        Err(CaError::ChannelNotFound(name.to_string()))
3444    }
3445}
3446
3447/// Project a decoded `DBR_CTRL_*` / `DBR_GR_*` snapshot's metadata fields
3448/// (display / control limits, enum labels) into the shadow-PV
3449/// [`PvMetadata`](crate::server::pv::PvMetadata) the CA gateway installs.
3450/// A non-metadata (TIME/STS) snapshot carries `None` in all three, which
3451/// clears the shadow metadata — but the gateway only ever feeds this a
3452/// control-class snapshot, matching C `setPvData` replacing the attribute
3453/// gdd wholesale from the control get/event.
3454fn metadata_from_snapshot(snapshot: &Snapshot) -> crate::server::pv::PvMetadata {
3455    crate::server::pv::PvMetadata {
3456        display: snapshot.display.clone(),
3457        control: snapshot.control.clone(),
3458        enums: snapshot.enums.clone(),
3459    }
3460}
3461
3462#[cfg(test)]
3463mod tests {
3464    use super::super::PvDatabase;
3465    use super::NotifyArrival;
3466    use crate::types::EpicsValue;
3467
3468    /// A read of a DECLARED field that has no value is a read FAILURE, not a
3469    /// missing channel — and the two must leave `get_pv` by different doors.
3470    ///
3471    /// Measured against softIoc R7.0.10. `dbgf T:AI.TIME` and
3472    /// `dbgf T:AI.BKPT` resolve and then fail: C prints
3473    /// `recGblDbaddrError: … Illegal Database Request Type PV: T:AI.TIME` and
3474    /// a `failed.` line, because `dbNameToAddr` resolves every declared field
3475    /// — `dbCommon.dbd.pod:543-548` and `:564-569` declare `BKPT` and `TIME`
3476    /// as `DBF_NOACCESS` — and `dbGet`'s validity gate then refuses
3477    /// `field_type > DBF_DEVICE` with `S_db_badDbrtype`. `dbgf T:AI.NOSUCH`
3478    /// prints `PV 'T:AI.NOSUCH' not found` instead: nothing resolved.
3479    ///
3480    /// The port answered `ChannelNotFound` to all three, so `dbgf REC.TIME`
3481    /// was indistinguishable from a typo. Note this holds today WITHOUT a
3482    /// `FieldDesc` for `TIME`/`BKPT` — `record_declaration_order` already
3483    /// lists them, which is what `declares_field` reads.
3484    #[epics_macros_rs::epics_test]
3485    async fn a_declared_field_with_no_value_reads_as_a_failure_not_a_missing_channel() {
3486        use crate::error::CaError;
3487        use crate::server::records::ai::AiRecord;
3488
3489        let db = PvDatabase::new();
3490        db.add_record("T:AI", Box::new(AiRecord::new(1.5)))
3491            .await
3492            .unwrap();
3493
3494        for field in ["TIME", "BKPT"] {
3495            let name = format!("T:AI.{field}");
3496            match db.get_pv(&name) {
3497                Err(CaError::BadDbrType(msg)) => assert!(
3498                    msg.contains(&name),
3499                    "the status must name the field, got {msg:?}"
3500                ),
3501                other => panic!(
3502                    "{name} is declared and unreadable, so it must fail the READ; got {other:?}"
3503                ),
3504            }
3505        }
3506
3507        // Undeclared field, and a record that does not exist: both are
3508        // genuinely absent and keep C's `dbNameToAddr` answer.
3509        for name in ["T:AI.NOSUCH", "T:NOSUCHREC.VAL"] {
3510            assert!(
3511                matches!(db.get_pv(name), Err(CaError::ChannelNotFound(_))),
3512                "{name} resolves to nothing and must stay not-found, got {:?}",
3513                db.get_pv(name)
3514            );
3515        }
3516
3517        // The readable controls are untouched, including the `DBF_UINT64`
3518        // `UTAG` that sits between `TIME` and `BKPT` in `dbCommon` and reads
3519        // fine in both.
3520        assert_eq!(db.get_pv("T:AI.VAL").unwrap(), EpicsValue::Double(1.5));
3521        assert_eq!(db.get_pv("T:AI.UTAG").unwrap(), EpicsValue::UInt64(0));
3522        assert_eq!(
3523            db.get_pv("T:AI.NAME").unwrap(),
3524            EpicsValue::String("T:AI".into())
3525        );
3526        assert_eq!(
3527            db.get_pv("T:AI.RTYP").unwrap(),
3528            EpicsValue::String("ai".into())
3529        );
3530    }
3531
3532    /// Regression: prior to fixing B1, `put_pv_and_post` walked only
3533    /// `inner.records` and returned `ChannelNotFound` for everything
3534    /// `add_pv`-registered. The CA gateway's monitor forwarder uses
3535    /// `add_pv` then expects `put_pv_and_post` to fan-out to
3536    /// downstream subscribers — without the simple-PV branch, every
3537    /// upstream event was silently dropped and the gateway delivered
3538    /// no monitors.
3539    #[epics_macros_rs::epics_test]
3540    async fn put_pv_and_post_handles_simple_pv() {
3541        let db = PvDatabase::new();
3542        db.add_pv("gw:test", EpicsValue::Double(0.0)).await.unwrap();
3543
3544        // Should NOT return ChannelNotFound.
3545        db.put_pv_and_post("gw:test", EpicsValue::Double(42.0))
3546            .await
3547            .expect("simple PV put_pv_and_post must succeed");
3548
3549        // Value actually landed.
3550        let pv = db.find_pv("gw:test").await.expect("PV exists");
3551        assert!(matches!(pv.get(), EpicsValue::Double(v) if v == 42.0));
3552    }
3553
3554    /// Regression: `get_pv`, `put_pv`, `put_pv_and_post`,
3555    /// and `put_pv_no_process` all bypassed `get_record` and walked
3556    /// `self.inner.records` directly, so alias names from epics-base
3557    /// PR #336 silently returned `ChannelNotFound`. A later fix closed
3558    /// `get_record` but the same defect was hiding in field_io.rs.
3559    /// All four CA-server-and-bridge entry points must accept aliases.
3560    #[epics_macros_rs::epics_test]
3561    async fn field_io_entry_points_accept_aliases() {
3562        use crate::server::records::ai::AiRecord;
3563
3564        let db = PvDatabase::new();
3565        db.add_record("CANON", Box::new(AiRecord::new(0.0)))
3566            .await
3567            .unwrap();
3568        db.add_alias("ALT", "CANON").await.unwrap();
3569
3570        // get_pv via alias
3571        db.put_pv("CANON.VAL", EpicsValue::Double(1.5))
3572            .await
3573            .unwrap();
3574        let v = db.get_pv("ALT.VAL").unwrap();
3575        assert!(matches!(v, EpicsValue::Double(x) if x == 1.5));
3576
3577        // put_pv via alias
3578        db.put_pv("ALT.VAL", EpicsValue::Double(7.0)).await.unwrap();
3579        let v = db.get_pv("CANON.VAL").unwrap();
3580        assert!(matches!(v, EpicsValue::Double(x) if x == 7.0));
3581
3582        // put_pv_and_post via alias
3583        db.put_pv_and_post("ALT.VAL", EpicsValue::Double(11.0))
3584            .await
3585            .unwrap();
3586        let v = db.get_pv("CANON.VAL").unwrap();
3587        assert!(matches!(v, EpicsValue::Double(x) if x == 11.0));
3588
3589        // put_pv_no_process via alias
3590        db.put_pv_no_process("ALT.VAL", EpicsValue::Double(13.0))
3591            .await
3592            .unwrap();
3593        let v = db.get_pv("ALT.VAL").unwrap();
3594        assert!(matches!(v, EpicsValue::Double(x) if x == 13.0));
3595    }
3596
3597    /// A `DBR_STRING` menu label written to a `DBF_MENU` field resolves
3598    /// against THAT field's own menu (C `dbConvert` `putStringMenu`), not the
3599    /// field-blind global table that `EpicsValue::convert_to` would consult.
3600    /// Covers the `put_pv` (`put_pv_inner`) and `put_pv_and_post` coercion
3601    /// sites; the CA field-put path (`put_record_field_from_ca_inner`) shares
3602    /// the identical `coerce_write_value` helper.
3603    #[epics_macros_rs::epics_test]
3604    async fn write_path_menu_label_resolves_against_field_menu() {
3605        use crate::server::records::sel::SelRecord;
3606
3607        let db = PvDatabase::new();
3608        db.add_record("SEL", Box::new(SelRecord::default()))
3609            .await
3610            .unwrap();
3611
3612        // put_pv (put_pv_inner): "Specified" is selSELM index 0, NOT the
3613        // menuFanout index 1 the global table would have returned.
3614        db.put_pv("SEL.SELM", EpicsValue::String("Specified".into()))
3615            .await
3616            .unwrap();
3617        assert_eq!(db.get_pv("SEL.SELM").unwrap(), EpicsValue::Enum(0));
3618
3619        // put_pv_and_post: a later choice, proving the whole menu.
3620        db.put_pv_and_post("SEL.SELM", EpicsValue::String("High Signal".into()))
3621            .await
3622            .unwrap();
3623        assert_eq!(db.get_pv("SEL.SELM").unwrap(), EpicsValue::Enum(1));
3624
3625        // A bare numeric string still resolves (C epicsParseUInt16 fallback).
3626        db.put_pv("SEL.SELM", EpicsValue::String("2".into()))
3627            .await
3628            .unwrap();
3629        assert_eq!(db.get_pv("SEL.SELM").unwrap(), EpicsValue::Enum(2));
3630    }
3631
3632    /// `set_pv_metadata` installs the upstream `DBR_CTRL_*` metadata on a
3633    /// shadow simple PV WITHOUT posting any event (the CA gateway's
3634    /// connect-time seed). A later GET-class read must then see the
3635    /// installed limits/units, and a `DBE_PROPERTY` subscriber must NOT
3636    /// have received anything (nothing *changed* yet). An unknown / record
3637    /// name is rejected with `ChannelNotFound`.
3638    #[epics_macros_rs::epics_test]
3639    async fn set_pv_metadata_installs_without_posting() {
3640        use crate::error::CaError;
3641        use crate::server::snapshot::{DisplayInfo, Snapshot};
3642        use crate::types::DbFieldType;
3643        use std::time::SystemTime;
3644
3645        let db = PvDatabase::new();
3646        db.add_pv("gw:meta", EpicsValue::Double(0.0)).await.unwrap();
3647
3648        // A DBE_PROPERTY subscriber attached BEFORE the seed — it must stay
3649        // empty, because seeding metadata is not a property *change*.
3650        const DBE_PROPERTY: u16 = 8;
3651        let pv = db.find_pv("gw:meta").await.expect("PV exists");
3652        let mut prop_rx = pv
3653            .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
3654            .expect("subscriber added");
3655
3656        // Build a CTRL-class snapshot carrying display metadata.
3657        let mut ctrl = Snapshot::new(EpicsValue::Double(0.0), 0, 0, SystemTime::UNIX_EPOCH);
3658        ctrl.display = Some(DisplayInfo {
3659            units: "mm".into(),
3660            precision: 3,
3661            upper_disp_limit: 10.0,
3662            lower_disp_limit: -10.0,
3663            ..Default::default()
3664        });
3665
3666        db.set_pv_metadata("gw:meta", &ctrl)
3667            .await
3668            .expect("simple PV set_pv_metadata must succeed");
3669
3670        // The metadata landed on the shadow PV.
3671        let installed = pv.metadata();
3672        assert_eq!(
3673            installed.display.expect("display metadata installed").units,
3674            "mm"
3675        );
3676
3677        // No event was posted (seed != change).
3678        assert!(
3679            prop_rx.try_recv().is_err(),
3680            "set_pv_metadata must not post a DBE_PROPERTY event"
3681        );
3682
3683        // Unknown / non-simple PV is rejected.
3684        assert!(matches!(
3685            db.set_pv_metadata("no:such:pv", &ctrl).await,
3686            Err(CaError::ChannelNotFound(_))
3687        ));
3688    }
3689
3690    /// `post_pv_property` refreshes the shadow metadata AND posts a
3691    /// `DBE_PROPERTY` event carrying the supplied snapshot's metadata,
3692    /// upstream status/severity, and (undefined control-DBR) timestamp — to
3693    /// `DBE_PROPERTY` subscribers only. This is the DB-routing layer the
3694    /// gateway's property monitor drives on every upstream `DBE_PROPERTY`
3695    /// event. An unknown / record name is rejected with `ChannelNotFound`.
3696    #[epics_macros_rs::epics_test]
3697    async fn post_pv_property_refreshes_and_posts_property_event() {
3698        use crate::error::CaError;
3699        use crate::server::snapshot::{DisplayInfo, Snapshot};
3700        use crate::types::{DbFieldType, WallTime};
3701
3702        const DBE_PROPERTY: u16 = 8;
3703        const DBE_VALUE: u16 = 1;
3704        const MAJOR: u16 = 2;
3705        const HIGH: u16 = 3;
3706
3707        let db = PvDatabase::new();
3708        db.add_pv("gw:prop", EpicsValue::Double(0.0)).await.unwrap();
3709        let pv = db.find_pv("gw:prop").await.expect("PV exists");
3710
3711        let mut prop_rx = pv
3712            .add_subscriber(1, DbFieldType::Double, DBE_PROPERTY)
3713            .expect("property subscriber added");
3714        let mut val_rx = pv
3715            .add_subscriber(2, DbFieldType::Double, DBE_VALUE)
3716            .expect("value subscriber added");
3717
3718        // Upstream CTRL event: metadata + MAJOR/HIGH alarm + a fixed past
3719        // timestamp that is unmistakably not a fresh wall clock.
3720        let upstream_ts = WallTime::from_unix(2_000_000, 0);
3721        let mut ctrl = Snapshot::new(EpicsValue::Double(5.0), HIGH, MAJOR, upstream_ts);
3722        ctrl.display = Some(DisplayInfo {
3723            units: "V".into(),
3724            precision: 1,
3725            ..Default::default()
3726        });
3727
3728        db.post_pv_property("gw:prop", ctrl)
3729            .await
3730            .expect("simple PV post_pv_property must succeed");
3731
3732        // The metadata was refreshed on the shadow PV.
3733        assert_eq!(
3734            pv.metadata().display.expect("metadata refreshed").units,
3735            "V"
3736        );
3737
3738        // The DBE_PROPERTY subscriber received the metadata-bearing event,
3739        // with the upstream alarm and timestamp preserved.
3740        let ev = prop_rx
3741            .try_recv()
3742            .expect("DBE_PROPERTY subscriber receives the property event");
3743        assert_eq!(
3744            ev.snapshot
3745                .display
3746                .clone()
3747                .expect("event carries metadata")
3748                .units,
3749            "V"
3750        );
3751        assert_eq!(
3752            ev.snapshot.alarm.severity, MAJOR,
3753            "upstream severity preserved"
3754        );
3755        assert_eq!(ev.snapshot.alarm.status, HIGH, "upstream status preserved");
3756        assert_eq!(
3757            ev.snapshot.timestamp, upstream_ts,
3758            "control-DBR timestamp preserved, not a fresh wall clock"
3759        );
3760
3761        // The DBE_VALUE-only subscriber must NOT receive a property event.
3762        assert!(
3763            val_rx.try_recv().is_err(),
3764            "DBE_VALUE-only subscriber must not receive a property post"
3765        );
3766
3767        // Unknown / non-simple PV is rejected.
3768        let again = Snapshot::new(EpicsValue::Double(0.0), 0, 0, WallTime::UNIX_EPOCH);
3769        assert!(matches!(
3770            db.post_pv_property("no:such:pv", again).await,
3771            Err(CaError::ChannelNotFound(_))
3772        ));
3773    }
3774
3775    /// Regression: `put_record_field_from_ca` (the CA
3776    /// server's main put fast path) must accept aliases. Pre-fix it
3777    /// only consulted `inner.records` directly. Also exercises the
3778    /// canonical-name normalisation that protects subsequent
3779    /// `process_record_with_links` / `update_scan_index` calls.
3780    #[epics_macros_rs::epics_test]
3781    async fn put_record_field_from_ca_accepts_alias() {
3782        use crate::server::records::ai::AiRecord;
3783
3784        let db = PvDatabase::new();
3785        db.add_record("CANON", Box::new(AiRecord::new(0.0)))
3786            .await
3787            .unwrap();
3788        db.add_alias("ALT", "CANON").await.unwrap();
3789
3790        // Put VAL via the alias name.
3791        let _ = db
3792            .put_record_field_from_ca("ALT", "VAL", EpicsValue::Double(2.5))
3793            .await
3794            .expect("put via alias must succeed");
3795
3796        // Read back via canonical to confirm the value landed on the
3797        // right record.
3798        let v = db.get_pv("CANON.VAL").unwrap();
3799        assert!(matches!(v, EpicsValue::Double(x) if x == 2.5));
3800    }
3801
3802    /// Regression: a DBR_PUT_ACKT alarm-acknowledge put posts a record-wide
3803    /// DBE_ALARM (C `dbAccess.c:1299` putAckt
3804    /// `db_post_events(precord, NULL, DBE_ALARM)`), so an alarm-mask monitor
3805    /// on ANY field is notified — and a DBE_VALUE-only monitor is not.
3806    /// Pre-fix the ack field posted only itself with DBE_VALUE|DBE_LOG, so no
3807    /// alarm-mask subscriber observed the acknowledgement, and the post fired
3808    /// on every put regardless of whether `ackt` changed.
3809    #[epics_macros_rs::epics_test]
3810    async fn alarm_ack_put_posts_record_wide_dbe_alarm() {
3811        use crate::server::recgbl::EventMask;
3812        use crate::server::records::ai::AiRecord;
3813        use crate::types::DbFieldType;
3814
3815        let db = PvDatabase::new();
3816        db.add_record("A:REC", Box::new(AiRecord::new(1.0)))
3817            .await
3818            .unwrap();
3819        let rec = db.get_record("A:REC").expect("record exists");
3820
3821        let (mut alarm_rx, mut value_rx) = {
3822            let mut inst = rec.write();
3823            let a = inst
3824                .add_subscriber("VAL", 1, DbFieldType::Double, EventMask::ALARM.bits())
3825                .expect("alarm subscriber");
3826            let v = inst
3827                .add_subscriber("VAL", 2, DbFieldType::Double, EventMask::VALUE.bits())
3828                .expect("value subscriber");
3829            (a, v)
3830        };
3831
3832        // The client acknowledges through its ordinary VAL channel with a
3833        // DBR_PUT_ACKT request type (C `dbAccess.c:1331`). ACKT defaults YES
3834        // (true), so writing 0 (disable transient acknowledgement) is a real
3835        // change.
3836        db.put_alarm_ack_from_ca(
3837            "A:REC",
3838            "VAL",
3839            crate::server::record::AlarmAck::Transient,
3840            0,
3841        )
3842        .await
3843        .expect("ackt put");
3844
3845        // The alarm-mask monitor on VAL receives the record-wide DBE_ALARM.
3846        assert!(
3847            alarm_rx.try_recv().is_ok(),
3848            "DBE_ALARM subscriber must receive the record-wide alarm post"
3849        );
3850        // The DBE_VALUE-only monitor on VAL must NOT: VAL's value is unchanged.
3851        assert!(
3852            value_rx.try_recv().is_err(),
3853            "DBE_VALUE-only subscriber must not receive the alarm post"
3854        );
3855
3856        // Re-putting the same ACKT value is a no-op: C putAckt returns early
3857        // on an unchanged ackt, so no further alarm post fires.
3858        db.put_alarm_ack_from_ca(
3859            "A:REC",
3860            "VAL",
3861            crate::server::record::AlarmAck::Transient,
3862            0,
3863        )
3864        .await
3865        .expect("ackt re-put");
3866        assert!(
3867            alarm_rx.try_recv().is_err(),
3868            "unchanged ACKT must post nothing"
3869        );
3870    }
3871
3872    /// `post_property` writes the `setEnums` block silently and posts a
3873    /// single `DBE_PROPERTY` monitor on VAL — the C
3874    /// `db_post_events(precord, &precord->val, DBE_PROPERTY)` that asyn's
3875    /// runtime enum re-propagation drives (devAsynInt32.c callbackEnum). Two
3876    /// halves, and only the pair pins the shape: a `DBE_VALUE`-only VAL
3877    /// subscriber must NOT receive it (re-keying enum strings is a property
3878    /// change, not a new reading), and a `DBE_PROPERTY` subscriber on the
3879    /// written state field must not either (`setEnums` posts on nothing).
3880    #[epics_macros_rs::epics_test]
3881    async fn post_property_writes_the_block_and_posts_dbe_property_on_val() {
3882        use crate::server::device_support::PropertyPost;
3883        use crate::server::recgbl::EventMask;
3884        use crate::server::records::mbbi::MbbiRecord;
3885        use crate::types::DbFieldType;
3886
3887        let db = PvDatabase::new();
3888        db.add_record("M:ENUM", Box::new(MbbiRecord::new(0)))
3889            .await
3890            .unwrap();
3891        let rec = db.get_record("M:ENUM").expect("record exists");
3892
3893        let (mut val_prop_rx, mut val_value_rx, mut zrst_prop_rx) = {
3894            let mut inst = rec.write();
3895            let vp = inst
3896                .add_subscriber("VAL", 1, DbFieldType::Enum, EventMask::PROPERTY.bits())
3897                .expect("VAL property subscriber");
3898            let vv = inst
3899                .add_subscriber("VAL", 2, DbFieldType::Enum, EventMask::VALUE.bits())
3900                .expect("VAL value subscriber");
3901            let zp = inst
3902                .add_subscriber("ZRST", 3, DbFieldType::String, EventMask::PROPERTY.bits())
3903                .expect("ZRST property subscriber");
3904            (vp, vv, zp)
3905        };
3906
3907        let written = db
3908            .post_property(
3909                "M:ENUM",
3910                PropertyPost {
3911                    writes: vec![("ZRST".to_string(), EpicsValue::String("LABEL".into()))],
3912                    post_field: "VAL".to_string(),
3913                },
3914            )
3915            .expect("post_property succeeds");
3916        assert_eq!(written, vec!["ZRST".to_string()]);
3917
3918        // The field landed on the record.
3919        assert_eq!(
3920            db.get_pv("M:ENUM.ZRST").unwrap(),
3921            EpicsValue::String("LABEL".into())
3922        );
3923
3924        assert!(
3925            val_prop_rx.try_recv().is_ok(),
3926            "the DBE_PROPERTY VAL subscriber is the one C posts to"
3927        );
3928        assert!(
3929            val_value_rx.try_recv().is_err(),
3930            "DBE_VALUE-only subscriber must not receive a property post"
3931        );
3932        assert!(
3933            zrst_prop_rx.try_recv().is_err(),
3934            "setEnums rewrites the state fields and posts on none of them"
3935        );
3936    }
3937
3938    /// Regression: a direct CA put to a record whose value field VAL is NOT
3939    /// `pp(TRUE)` (calc / calcout / aSub) must still fire a DBE_VALUE monitor.
3940    /// C `dbAccess.c::dbPut:1408-1413` posts the value field immediately
3941    /// unless it is `pp(TRUE)`. The port previously suppressed the immediate
3942    /// post for every `VAL` and — with the `should_process` gate — skipped
3943    /// the reprocess cycle for a non-`pp` VAL, so the operator's write fired
3944    /// no monitor at all. calc's VAL is not in its `pp` field set, so the
3945    /// immediate post is the only event that can fire.
3946    #[epics_macros_rs::epics_test]
3947    async fn ca_put_to_non_pp_val_posts_monitor() {
3948        use crate::server::database::db_access::DbSubscription;
3949        use crate::server::records::calc::CalcRecord;
3950
3951        let db = PvDatabase::new();
3952        db.add_record("CALC1", Box::new(CalcRecord::new("0")))
3953            .await
3954            .unwrap();
3955
3956        let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
3957            .await
3958            .expect("subscribe to CALC1.VAL");
3959
3960        db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(5.0))
3961            .await
3962            .expect("CA put to CALC1.VAL must succeed");
3963
3964        let got = crate::runtime::task::timeout(std::time::Duration::from_secs(1), sub.recv_f64())
3965            .await
3966            .expect("a DBE_VALUE monitor must fire for a direct VAL put to a non-pp record");
3967        assert_eq!(got, Some(5.0));
3968    }
3969
3970    /// R8-22 (record-field path): a record monitor whose event queue runs short
3971    /// of room during a burst must receive its EARLIER DISTINCT queued updates
3972    /// and then a tail entry carrying the latest value. C `db_queue_event_log`
3973    /// replaces only `*pLastLog` (`dbEvent.c:812-827`); the earlier entries stay
3974    /// queued and each is delivered by `event_read`.
3975    ///
3976    /// Each non-pp `VAL` put posts exactly one DBE_VALUE monitor with the put
3977    /// value and does NOT reprocess (see `ca_put_to_non_pp_val_posts_monitor`),
3978    /// so N distinct puts produce a strictly increasing 1..=N stream.
3979    ///
3980    /// Before the fix the producer parked the newest value in a side coalesce
3981    /// slot and `next_event`, finding it set, discarded the whole queued backlog
3982    /// — the burst came out as one event instead of {1..=appended-1, N}.
3983    #[epics_macros_rs::epics_test]
3984    async fn r8_22_db_burst_keeps_earlier_distinct_updates() {
3985        use crate::server::database::db_access::DbSubscription;
3986        use crate::server::event_queue::{event_que_size, events_per_que};
3987        use crate::server::records::calc::CalcRecord;
3988
3989        let db = PvDatabase::new();
3990        db.add_record("CALC1", Box::new(CalcRecord::new("0")))
3991            .await
3992            .unwrap();
3993        let mut sub = DbSubscription::subscribe(&db, "CALC1.VAL")
3994            .await
3995            .expect("subscribe to CALC1.VAL");
3996
3997        // With no consumer draining, the first `appended` puts take ring entries
3998        // and every later put replaces the tail entry in place.
3999        let appended = event_que_size() - events_per_que();
4000        let burst = appended + 40;
4001        for i in 1..=burst {
4002            db.put_record_field_from_ca("CALC1", "VAL", EpicsValue::Double(i as f64))
4003                .await
4004                .expect("CA put to CALC1.VAL must succeed");
4005        }
4006
4007        // Drain every immediately-available delivery; the recv past the
4008        // last event has nothing queued and times out, ending collection.
4009        let mut seq = Vec::new();
4010        while let Ok(Some(v)) =
4011            crate::runtime::task::timeout(std::time::Duration::from_millis(200), sub.recv_f64())
4012                .await
4013        {
4014            seq.push(v);
4015        }
4016        let want: Vec<f64> = (1..appended)
4017            .map(|i| i as f64)
4018            .chain(std::iter::once(burst as f64))
4019            .collect();
4020        assert_eq!(
4021            seq, want,
4022            "record burst delivery must be {{earlier distinct backlog…, coalesced tail}}"
4023        );
4024    }
4025
4026    /// The notify slot has exactly two states when a restart replay installs
4027    /// on it, and both must be handled by the one install owner.
4028    ///
4029    /// C cannot reach the occupied one: `restartCheck` (dbNotify.c:149-170)
4030    /// pops the head and assigns `precord->ppn = pfirst` under a single lock,
4031    /// so a restarted notify already owns the record before its callback runs.
4032    /// The port pops under the put gate and installs a moment later, both
4033    /// through the one gate-held owner. Assigning over the slot would drop the
4034    /// prior client's
4035    /// Sender, and its receiver then wakes with `RecvError`, which the CA
4036    /// dispatcher reads as a successful put-callback: the client is told its
4037    /// write completed on a cycle that never ran.
4038    #[epics_macros_rs::epics_test]
4039    async fn a_replay_onto_a_taken_notify_slot_queues_instead_of_overwriting() {
4040        use crate::server::records::ai::AiRecord;
4041
4042        let db = PvDatabase::new();
4043        db.add_record("REPLAY:TAKEN", Box::new(AiRecord::new(0.0)))
4044            .await
4045            .unwrap();
4046        let rec = db.get_record("REPLAY:TAKEN").expect("record exists");
4047
4048        let (owner_tx, _owner_rx) = crate::runtime::sync::oneshot::channel();
4049        let owner = rec
4050            .write()
4051            .install_or_queue_notify(owner_tx)
4052            .expect("a free slot installs");
4053
4054        let (client_tx, _client_rx) = crate::runtime::sync::oneshot::channel();
4055        assert!(
4056            db.install_notify_and_process_already_locked(
4057                "REPLAY:TAKEN",
4058                client_tx,
4059                NotifyArrival::Replay
4060            )
4061            .expect("the record is loaded")
4062            .is_none(),
4063            "the slot is owned, so the replay must drive no process cycle"
4064        );
4065        assert!(
4066            rec.read()
4067                .notify
4068                .as_ref()
4069                .is_some_and(|n| std::sync::Arc::ptr_eq(n, &owner)),
4070            "the owner's wait-set must survive the replay"
4071        );
4072        assert!(
4073            rec.read().notify_restart_pending(),
4074            "the replay must be queued behind the owner, not dropped"
4075        );
4076    }
4077
4078    /// The claim's owner path: a put-notify that reaches its process cycle
4079    /// commits, so the wait-set on the record is the one the client waits on
4080    /// and nothing releases it behind the cycle's back.
4081    ///
4082    /// This replaces the three `park_put_notify` boundary tests. Two of their
4083    /// boundaries no longer exist to test: "the slot was taken while we were
4084    /// writing" cannot happen now that the claim is taken in the same critical
4085    /// section as the ownership test, and "the record vanished between the put
4086    /// and the park" cannot happen now that the record handle is resolved once
4087    /// at the top of the body and used throughout.
4088    #[epics_macros_rs::epics_test]
4089    async fn a_put_notify_that_processes_commits_its_claim() {
4090        use crate::server::records::ai::AiRecord;
4091
4092        let db = PvDatabase::new();
4093        db.add_record("CLAIM:PROC", Box::new(AiRecord::new(0.0)))
4094            .await
4095            .unwrap();
4096        let rec = db.get_record("CLAIM:PROC").expect("record exists");
4097
4098        let done = db
4099            .put_record_field_from_ca("CLAIM:PROC", "VAL", EpicsValue::Double(3.5))
4100            .await
4101            .expect("a VAL put is accepted");
4102        // A passive ai with no chain settles inside the cycle, so the wait-set
4103        // has already fired and the record no longer owns it — the completion
4104        // path (C `dbNotifyCompletion`) took it, not the claim.
4105        assert!(matches!(
4106            done,
4107            crate::server::record::ProcessCompletion::Sync
4108        ));
4109        assert!(
4110            !rec.read().has_notify(),
4111            "the completed cycle must leave the slot free"
4112        );
4113        assert_eq!(
4114            rec.read().record.get_field("VAL"),
4115            Some(EpicsValue::Double(3.5))
4116        );
4117    }
4118
4119    /// A formerly-bypassing early return: the put drives no process cycle, so
4120    /// the claim is never committed and `Drop` has to put the slot back.
4121    ///
4122    /// `DESC` is not `pp(TRUE)`, so `put_drives_processing_of` is false and the
4123    /// body returns `Ok(Sync)` at the last early return before the park. Before
4124    /// the claim moved to the entry gate nothing was installed on this path, so
4125    /// there was nothing to leak; now there is, and only the finalizer stops
4126    /// the record from being owned by a put that has already returned.
4127    #[epics_macros_rs::epics_test]
4128    async fn a_put_notify_that_drives_no_process_leaves_no_owner() {
4129        use crate::server::records::ai::AiRecord;
4130
4131        let db = PvDatabase::new();
4132        db.add_record("CLAIM:NOPROC", Box::new(AiRecord::new(0.0)))
4133            .await
4134            .unwrap();
4135        let rec = db.get_record("CLAIM:NOPROC").expect("record exists");
4136
4137        db.put_record_field_from_ca(
4138            "CLAIM:NOPROC",
4139            "DESC",
4140            EpicsValue::String("a description".into()),
4141        )
4142        .await
4143        .expect("a DESC put is accepted");
4144        assert!(
4145            !rec.read().has_notify(),
4146            "a put that processed nothing must not leave the record owned"
4147        );
4148        assert!(
4149            !rec.read().notify_restart_pending(),
4150            "nothing was queued, so nothing may be left on the restart list"
4151        );
4152        // The record is free for the next put-notify, which is the property the
4153        // leak would destroy: an owned record queues every later notify.
4154        db.put_record_field_from_ca("CLAIM:NOPROC", "VAL", EpicsValue::Double(1.0))
4155            .await
4156            .expect("the next put-notify must not be queued behind a stale owner");
4157        assert_eq!(
4158            rec.read().record.get_field("VAL"),
4159            Some(EpicsValue::Double(1.0))
4160        );
4161    }
4162
4163    /// The other formerly-bypassing early return: the write is refused, so the
4164    /// body returns `Err` — and because C still processes on a refused notify
4165    /// put (`didPut = 1`, dbNotify.c:528-530 → `:243-256`), the claim is
4166    /// committed to that cycle rather than released under it.
4167    ///
4168    /// Either way the record must not stay owned once the call returns; the
4169    /// two outcomes differ in WHO releases the set, not in whether it is
4170    /// released.
4171    #[epics_macros_rs::epics_test]
4172    async fn a_refused_put_notify_leaves_no_owner() {
4173        use crate::server::records::ai::AiRecord;
4174
4175        let db = PvDatabase::new();
4176        db.add_record("CLAIM:REFUSED", Box::new(AiRecord::new(0.0)))
4177            .await
4178            .unwrap();
4179        let rec = db.get_record("CLAIM:REFUSED").expect("record exists");
4180
4181        let refused = db
4182            .put_record_field_from_ca(
4183                "CLAIM:REFUSED",
4184                "VAL",
4185                EpicsValue::String("not a number".into()),
4186            )
4187            .await;
4188        assert!(refused.is_err(), "a non-numeric VAL put must be refused");
4189        assert!(
4190            !rec.read().has_notify(),
4191            "a refused put-notify must not leave the record owned"
4192        );
4193        assert!(
4194            !rec.read().notify_restart_pending(),
4195            "a refused put-notify must queue nothing"
4196        );
4197    }
4198
4199    /// The discriminator `a_refused_put_notify_leaves_no_owner` cannot supply.
4200    /// Commit-then-cycle and release-then-cycle agree only once the cycle has
4201    /// COMPLETED, so an end-state assertion cannot separate them. Give the
4202    /// cycle somewhere to stop — a calcout with `ODLY > 0` returns
4203    /// `AsyncPendingNotify` and finishes on the delayed callback — and they
4204    /// disagree while it is in flight.
4205    ///
4206    /// C is unambiguous: `putCallback` returns `didPut = 1` on the refused
4207    /// write (`dbNotify.c:528-530`), so `processNotifyCommon` assigns
4208    /// `precord->ppn = ppn` and calls `dbProcess` (`:243-256`) — the record
4209    /// processes UNDER the notify, and `dbNotifyCompletion` is what ends it.
4210    /// Releasing the claim first runs the same cycle with no notifier attached.
4211    #[epics_macros_rs::epics_test]
4212    async fn a_refused_put_notify_stays_installed_while_its_cycle_is_async() {
4213        use crate::server::records::calcout::CalcoutRecord;
4214
4215        let db = PvDatabase::new();
4216        db.add_record("CLAIM:ODLY", Box::new(CalcoutRecord::default()))
4217            .await
4218            .unwrap();
4219        let rec = db.get_record("CLAIM:ODLY").expect("record exists");
4220        // Long enough that the delayed continuation cannot land mid-test.
4221        db.put_record_field_from_ca("CLAIM:ODLY", "ODLY", EpicsValue::Double(3600.0))
4222            .await
4223            .expect("ODLY is settable");
4224        assert!(
4225            !rec.read().has_notify(),
4226            "the ODLY put drives no cycle, so it owns nothing"
4227        );
4228
4229        // C `caput REC.PROC <non-numeric>`: the store is refused and the
4230        // `pp(TRUE)` process still runs.
4231        let refused = db
4232            .put_record_field_from_ca("CLAIM:ODLY", "PROC", EpicsValue::String("nope".into()))
4233            .await;
4234        assert!(refused.is_err(), "a non-numeric PROC put must be refused");
4235        assert!(
4236            rec.read().is_processing(),
4237            "ODLY > 0 defers the calcout output, so the cycle is still in flight"
4238        );
4239        assert!(
4240            rec.read().has_notify(),
4241            "the async cycle runs under the refused put's wait-set: releasing \
4242             the claim before driving it leaves that cycle with no notifier"
4243        );
4244    }
4245
4246    /// The same boundary for the OTHER commit-before-the-cycle site: "Cause B",
4247    /// a refused conversion on a `pp` field that still drives the record. The
4248    /// PROC intercept above has its own copy of this rule, so a discriminator
4249    /// for one does not cover the other.
4250    #[epics_macros_rs::epics_test]
4251    async fn a_refused_pp_field_put_stays_installed_while_its_cycle_is_async() {
4252        use crate::server::records::calcout::CalcoutRecord;
4253
4254        let db = PvDatabase::new();
4255        db.add_record("CLAIM:ODLYB", Box::new(CalcoutRecord::default()))
4256            .await
4257            .unwrap();
4258        let rec = db.get_record("CLAIM:ODLYB").expect("record exists");
4259        db.put_record_field_from_ca("CLAIM:ODLYB", "ODLY", EpicsValue::Double(3600.0))
4260            .await
4261            .expect("ODLY is settable");
4262
4263        // `A` is `pp(TRUE)` for calcout (C `calcoutRecord.dbd`), so a refused
4264        // conversion on it takes the Cause-B arm rather than returning early.
4265        let refused = db
4266            .put_record_field_from_ca("CLAIM:ODLYB", "A", EpicsValue::String("nope".into()))
4267            .await;
4268        assert!(refused.is_err(), "a non-numeric A put must be refused");
4269        assert!(
4270            rec.read().is_processing(),
4271            "ODLY > 0 defers the calcout output, so the cycle is still in flight"
4272        );
4273        assert!(
4274            rec.read().has_notify(),
4275            "Cause B commits to its cycle for the same reason the PROC refusal \
4276             does: C reaches doProcess with didPut = 1 and assigns precord->ppn"
4277        );
4278    }
4279
4280    /// The other boundary value of the same slot: free, so the replay installs
4281    /// and drives the cycle.
4282    #[epics_macros_rs::epics_test]
4283    async fn a_replay_onto_a_free_notify_slot_installs_and_drives_the_cycle() {
4284        use crate::server::records::ai::AiRecord;
4285
4286        let db = PvDatabase::new();
4287        db.add_record("REPLAY:FREE", Box::new(AiRecord::new(0.0)))
4288            .await
4289            .unwrap();
4290        let rec = db.get_record("REPLAY:FREE").expect("record exists");
4291
4292        let (client_tx, _client_rx) = crate::runtime::sync::oneshot::channel();
4293        assert!(
4294            db.install_notify_and_process_already_locked(
4295                "REPLAY:FREE",
4296                client_tx,
4297                NotifyArrival::Replay
4298            )
4299            .expect("the record is loaded")
4300            .is_some(),
4301            "a free slot installs and processes"
4302        );
4303        assert!(
4304            !rec.read().notify_restart_pending(),
4305            "nothing is queued when the replay took the slot"
4306        );
4307    }
4308
4309    /// The gate is keyed on the DECLARED DBF class, so the claim to prove is
4310    /// over the CLASS and not over the ten fields the defect was found on.
4311    ///
4312    /// `#C0 S0 @p` parses to `VME_IO` (C `dbParseLink`'s `hwid == "CS"` arm,
4313    /// `dbStaticLib.c:2315`, which does not consult the field type), and no
4314    /// vendored `device()` line in this workspace declares a bus at all — the
4315    /// generated tables carry `CONSTANT` and `INST_IO` and nothing else. So C
4316    /// refuses that text on EVERY `DBF_INLINK`/`DBF_OUTLINK`/`DBF_FWDLINK`
4317    /// field of every record type, and every one of them must be refused here.
4318    ///
4319    /// The exempt set is named rather than counted loosely: a record type whose
4320    /// `.dbd` declares no `device()` has no `devSup` for `INP`/`OUT`, so
4321    /// `declared_link_type` answers `None` — nothing to compare, and C would
4322    /// have a device support there that this port does not (see
4323    /// `declared_link_type`'s own note). Those are the ONLY fields the sweep
4324    /// may skip, and they are all `INP`/`OUT`.
4325    #[test]
4326    fn every_declared_link_class_field_is_gated_by_class() {
4327        use crate::server::record::dbd_generated::RECORD_TYPES;
4328        use crate::server::record::{declared_fields, declared_link_type};
4329
4330        let hw = EpicsValue::String("#C0 S0 @p".into());
4331        let mut gated = 0usize;
4332        let mut exempt = Vec::new();
4333        let mut accepted = Vec::new();
4334        for record_type in RECORD_TYPES {
4335            for desc in declared_fields(record_type) {
4336                if crate::types::dbf_link_class(record_type, desc.name).is_none() {
4337                    continue;
4338                }
4339                if declared_link_type(record_type, None, desc.name).is_none() {
4340                    exempt.push(format!("{record_type}.{}", desc.name));
4341                    continue;
4342                }
4343                if super::check_link_put(record_type, "", desc.name, &hw).is_err() {
4344                    gated += 1;
4345                } else {
4346                    accepted.push(format!("{record_type}.{}", desc.name));
4347                }
4348            }
4349        }
4350        assert!(
4351            accepted.is_empty(),
4352            "a hardware link no vendored device() declares was accepted on {accepted:?}"
4353        );
4354        assert!(
4355            exempt
4356                .iter()
4357                .all(|f| f.ends_with(".INP") || f.ends_with(".OUT")),
4358            "only the device link of a type with no vendored device() may be exempt: {exempt:?}"
4359        );
4360        // The generator emits 265 DBF_INLINK, 103 DBF_OUTLINK and 17
4361        // DBF_FWDLINK declarations across its record tables, plus dbCommon's
4362        // TSEL/SDIS/FLNK once per type. A drop here means the sweep stopped
4363        // seeing the population, not that the population shrank.
4364        assert_eq!(
4365            gated + exempt.len(),
4366            LINK_CLASS_FIELD_COUNT,
4367            "the sweep must reach every declared link-class field"
4368        );
4369        assert!(gated > 400, "only {gated} fields reached the gate");
4370
4371        // The ten fields the defect was cited on are inside what was just
4372        // swept — the sample, shown to be part of the population.
4373        for (record_type, field) in [
4374            ("ai", "SDIS"),
4375            ("ai", "TSEL"),
4376            ("ai", "FLNK"),
4377            ("ai", "SIML"),
4378            ("ai", "SIOL"),
4379            ("ai", "INP"),
4380            ("calc", "INPA"),
4381            ("ao", "DOL"),
4382            ("ao", "OUT"),
4383            ("fanout", "LNK1"),
4384        ] {
4385            assert!(
4386                super::check_link_put(record_type, "", field, &hw).is_err(),
4387                "{record_type}.{field} must refuse a VME_IO link"
4388            );
4389        }
4390    }
4391
4392    /// The population the sweep above walks, counted once so a change in the
4393    /// generator's output shows up as one failure rather than as a silently
4394    /// smaller sweep.
4395    const LINK_CLASS_FIELD_COUNT: usize = 505;
4396
4397    /// The gate order is observable exactly where a link-class field is also
4398    /// `special(SPC_NOMOD)`: C runs `dbCanSetLink` first and reaches the
4399    /// no-mod refusal only from `dbPutSpecial(paddr, 0)` afterwards, so on
4400    /// such a field a type-invalid text answers `S_dbLib_badField` and a
4401    /// type-valid one answers `S_db_noMod`. `aSub.SUBL` is the only field in
4402    /// the vendored population that is both, and the count is pinned rather
4403    /// than assumed: a second one appearing changes which orders are
4404    /// equivalent, and this is the test that says so.
4405    #[test]
4406    fn asub_subl_is_the_only_read_only_link_field() {
4407        use crate::server::record::dbd_generated::RECORD_TYPES;
4408        use crate::server::record::{Special, declared_fields};
4409
4410        let mut offenders = Vec::new();
4411        for record_type in RECORD_TYPES {
4412            for desc in declared_fields(record_type) {
4413                if crate::types::dbf_link_class(record_type, desc.name).is_none() {
4414                    continue;
4415                }
4416                if desc.read_only
4417                    || desc.special == Special::NoMod
4418                    || desc.declared_special == Special::NoMod
4419                {
4420                    offenders.push(format!("{record_type}.{}", desc.name));
4421                }
4422            }
4423        }
4424        assert_eq!(
4425            offenders,
4426            ["aSub.SUBL"],
4427            "the set of link fields where the gate order is observable has changed"
4428        );
4429    }
4430
4431    /// The wiring: both `dbPutField`-analogue bodies refuse the link C refuses,
4432    /// across all three DBF classes and both storage kinds (a `dbCommon` link
4433    /// held in `CommonFields`, and one declared by the record type itself), and
4434    /// the refused field keeps the text it had.
4435    ///
4436    /// `@instio p` is the text to use because it is what a user actually types
4437    /// — every asyn device support takes that form — and because it is refused
4438    /// for the reason C gives: these fields have no `devSup`, so
4439    /// `dbCanSetLink` holds them to `CONSTANT` (`dbStaticLib.c:2403`).
4440    #[epics_macros_rs::epics_test]
4441    async fn both_db_put_field_bodies_refuse_a_device_link_on_every_class() {
4442        use crate::server::records::ao::AoRecord;
4443        use crate::server::records::calc::CalcRecord;
4444        use crate::server::records::fanout::FanoutRecord;
4445
4446        let db = PvDatabase::new();
4447        db.add_record("LK:CALC", Box::new(CalcRecord::new("A")))
4448            .await
4449            .unwrap();
4450        db.add_record("LK:AO", Box::new(AoRecord::new(0.0)))
4451            .await
4452            .unwrap();
4453        db.add_record("LK:FO", Box::new(FanoutRecord::new()))
4454            .await
4455            .unwrap();
4456
4457        // (record, field, DBF class, storage) — INLINK/OUTLINK/FWDLINK each
4458        // appear in both a `dbCommon` and a record-declared row.
4459        let cases = [
4460            ("LK:CALC", "SDIS"), // DBF_INLINK,   dbCommon
4461            ("LK:CALC", "TSEL"), // DBF_INLINK,   dbCommon
4462            ("LK:CALC", "INPA"), // DBF_INLINK,   record-declared
4463            ("LK:AO", "DOL"),    // DBF_INLINK,   record-declared
4464            ("LK:AO", "SIML"),   // DBF_INLINK,   record-declared
4465            ("LK:AO", "SIOL"),   // DBF_OUTLINK,  record-declared
4466            ("LK:AO", "OUT"),    // DBF_OUTLINK,  dbCommon
4467            ("LK:CALC", "FLNK"), // DBF_FWDLINK,  dbCommon
4468            ("LK:FO", "LNK1"),   // DBF_FWDLINK,  record-declared
4469        ];
4470
4471        for (name, field) in cases {
4472            let before = db
4473                .get_pv(&format!("{name}.{field}"))
4474                .unwrap_or_else(|e| panic!("{name}.{field} must be readable: {e}"));
4475
4476            let ca = db
4477                .put_record_field_from_ca_no_notify(
4478                    name,
4479                    field,
4480                    EpicsValue::String("@instio p".into()),
4481                )
4482                .await;
4483            assert!(
4484                ca.is_err(),
4485                "{name}.{field}: a caput of an INST_IO link must be refused, as C's \
4486                 dbPutFieldLink refuses it"
4487            );
4488
4489            let restore = db
4490                .put_pv_no_process(
4491                    &format!("{name}.{field}"),
4492                    EpicsValue::String("@instio p".into()),
4493                )
4494                .await;
4495            assert!(
4496                restore.is_err(),
4497                "{name}.{field}: an autosave restore is a dbPutField too"
4498            );
4499
4500            let after = db
4501                .get_pv(&format!("{name}.{field}"))
4502                .unwrap_or_else(|e| panic!("{name}.{field} must still be readable: {e}"));
4503            assert_eq!(
4504                format!("{before:?}"),
4505                format!("{after:?}"),
4506                "{name}.{field} must keep the text it had — C leaves the link untouched"
4507            );
4508        }
4509
4510        // The control: the same fields take a link of the type they DO expect,
4511        // so the gate refuses by type and not by class membership.
4512        db.put_record_field_from_ca_no_notify(
4513            "LK:CALC",
4514            "INPA",
4515            EpicsValue::String("SRC.VAL".into()),
4516        )
4517        .await
4518        .expect("a PV_LINK is what CONSTANT accepts (dbStaticLib.c:2408-2416)");
4519        let stored = db.get_pv("LK:CALC.INPA").unwrap();
4520        let EpicsValue::String(stored) = stored else {
4521            panic!("a link field serves as DBF_STRING, got {stored:?}");
4522        };
4523        assert!(
4524            stored.as_str_lossy().starts_with("SRC.VAL"),
4525            "the accepted link is the one that was written, got {stored:?}"
4526        );
4527    }
4528
4529    /// C's request-type switch for a link field (`dbAccess.c:1084-1096`):
4530    /// `DBR_STRING`, or `DBR_CHAR`/`DBR_UCHAR` whose last element is the NUL,
4531    /// and `S_db_badDbrtype` for everything else. Measured against softIoc
4532    /// R7.0.10 over CA on `SDIS`, `INPA`, `OUT` and `LNK1` — a `DBR_DOUBLE`,
4533    /// `DBR_LONG`, `DBR_SHORT` or `DBR_ENUM` put fails the channel write and
4534    /// leaves the link text alone, while a lone NUL byte succeeds and CLEARS
4535    /// the link. The port used to convert every one of them to a string and
4536    /// store it, so `caput` of a number turned a link into `"5"`.
4537    ///
4538    /// `DESC` is the control: it is `DBF_STRING` and not a link, so the same
4539    /// requests are converted and stored there, in C and here alike. That is
4540    /// what makes this a rule about the link route rather than about strings.
4541    #[epics_macros_rs::epics_test]
4542    async fn a_link_field_takes_only_a_string_or_a_nul_terminated_char_array() {
4543        use crate::server::records::calc::CalcRecord;
4544
4545        let db = PvDatabase::new();
4546        db.add_record("TY:CALC", Box::new(CalcRecord::new("A")))
4547            .await
4548            .unwrap();
4549        db.put_record_field_from_ca_no_notify(
4550            "TY:CALC",
4551            "SDIS",
4552            EpicsValue::String("SRC.VAL".into()),
4553        )
4554        .await
4555        .expect("a DBR_STRING link put is what C accepts");
4556
4557        for bad in [
4558            EpicsValue::Double(5.0),
4559            EpicsValue::Long(5),
4560            EpicsValue::Short(5),
4561            EpicsValue::Enum(1),
4562            EpicsValue::Float(5.0),
4563            EpicsValue::Int64(5),
4564            // `pstring[nRequest - 1] != '\0'` with `nRequest == 1`.
4565            EpicsValue::Char(b'S'),
4566            EpicsValue::UChar(b'S'),
4567            // Same test with a longer buffer: the LAST element must be the NUL,
4568            // an interior one does not save it.
4569            EpicsValue::CharArray(b"SRC.VAL".to_vec()),
4570            EpicsValue::CharArray(b"SRC\0VAL".to_vec()),
4571        ] {
4572            let ca = db
4573                .put_record_field_from_ca_no_notify("TY:CALC", "SDIS", bad.clone())
4574                .await;
4575            assert!(
4576                matches!(ca, Err(crate::server::database::CaError::BadDbrType(_))),
4577                "a {bad:?} put to a link field is S_db_badDbrtype in C, got {ca:?}"
4578            );
4579            let restore = db.put_pv_no_process("TY:CALC.SDIS", bad.clone()).await;
4580            assert!(
4581                matches!(
4582                    restore,
4583                    Err(crate::server::database::CaError::BadDbrType(_))
4584                ),
4585                "the autosave body is a dbPutField too, got {restore:?} for {bad:?}"
4586            );
4587            let held = db.get_pv("TY:CALC.SDIS").unwrap();
4588            assert!(
4589                matches!(&held, EpicsValue::String(s) if s.as_str_lossy().starts_with("SRC.VAL")),
4590                "a refused put must leave the link text alone, got {held:?}"
4591            );
4592        }
4593
4594        // The NUL-terminated char array is C's other accepted form, and the
4595        // text is the C string it holds — not the byte values, and not the
4596        // bytes past the terminator.
4597        db.put_record_field_from_ca_no_notify(
4598            "TY:CALC",
4599            "SDIS",
4600            EpicsValue::CharArray(b"OTHER.VAL\0".to_vec()),
4601        )
4602        .await
4603        .expect("a NUL-terminated DBR_CHAR buffer is accepted");
4604        let held = db.get_pv("TY:CALC.SDIS").unwrap();
4605        assert!(
4606            matches!(&held, EpicsValue::String(s) if s.as_str_lossy().starts_with("OTHER.VAL")),
4607            "the stored text is the C string in the buffer, got {held:?}"
4608        );
4609
4610        // A lone NUL is `nRequest == 1` with the terminator in place: accepted,
4611        // and the link text is empty. Storing the byte's decimal spelling
4612        // (`"0"`) is the bug this arm closes.
4613        db.put_record_field_from_ca_no_notify("TY:CALC", "SDIS", EpicsValue::Char(0))
4614            .await
4615            .expect("a lone NUL clears the link, as in C");
4616        assert_eq!(
4617            db.get_pv("TY:CALC.SDIS").unwrap(),
4618            EpicsValue::String("".into()),
4619            "C stores the empty C string, not the byte's decimal spelling"
4620        );
4621
4622        // Control: DESC is DBF_STRING and not a link, so C converts and stores.
4623        db.put_record_field_from_ca_no_notify("TY:CALC", "DESC", EpicsValue::Double(5.0))
4624            .await
4625            .expect("a non-link string field converts, in C and here");
4626        assert!(
4627            matches!(db.get_pv("TY:CALC.DESC").unwrap(),
4628                     EpicsValue::String(s) if s.as_str_lossy() == "5"),
4629            "the control must show the refusal is the link route, not the string type"
4630        );
4631    }
4632
4633    /// An autosave restore is a `dbPutField`, so it must run the
4634    /// `dbPutConvertRoutine` type row — not just the shape arm. A saved
4635    /// `DBF_DOUBLE` field stored as a string (`"3.5"`) has to reach the record
4636    /// through `putStringDouble`'s parse, the same row the client `dbPut` path
4637    /// runs, instead of being handed to `put_field` as a raw `String` that its
4638    /// numeric arm refuses. Before the type row was wired into
4639    /// `put_pv_no_process`, the positive restore below failed with
4640    /// `TypeMismatch` and the field kept its default `0.0`.
4641    #[epics_macros_rs::epics_test]
4642    async fn an_autosave_restore_runs_the_dbputconvertroutine_type_row() {
4643        use crate::server::records::calc::CalcRecord;
4644
4645        let db = PvDatabase::new();
4646        db.add_record("TY:CALC", Box::new(CalcRecord::new("A+1")))
4647            .await
4648            .unwrap();
4649
4650        // A served DBF_DOUBLE field restored from its saved string spelling is
4651        // PARSED through the type row, exactly as `caput CALC.A 3.5` would be.
4652        db.put_pv_no_process("TY:CALC.A", EpicsValue::String("3.5".into()))
4653            .await
4654            .expect("a numeric string restore parses through the convert row");
4655        assert!(
4656            matches!(db.get_pv("TY:CALC.A").unwrap(),
4657                     EpicsValue::Double(v) if (v - 3.5).abs() < 1e-9),
4658            "the restored A must be the parsed number 3.5, got {:?}",
4659            db.get_pv("TY:CALC.A")
4660        );
4661
4662        // And an unparseable one is REFUSED by `epicsParseFloat64`, leaving the
4663        // field alone — the field-blind path would have stored `0.0` instead.
4664        let bad = db
4665            .put_pv_no_process("TY:CALC.A", EpicsValue::String("not_a_number".into()))
4666            .await;
4667        assert!(
4668            bad.is_err(),
4669            "an unparseable numeric restore is refused, got {bad:?}"
4670        );
4671        assert!(
4672            matches!(db.get_pv("TY:CALC.A").unwrap(),
4673                     EpicsValue::Double(v) if (v - 3.5).abs() < 1e-9),
4674            "a refused restore leaves A at 3.5, got {:?}",
4675            db.get_pv("TY:CALC.A")
4676        );
4677    }
4678}