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