Skip to main content

epics_base_rs/server/database/
field_io.rs

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