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