epics_base_rs/server/database/processing.rs
1use std::collections::HashSet;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::error::{CaError, CaResult};
6use crate::server::record::{
7 AuxPostMask, InputFetchPolicy, NotifyWaitSet, PactExit, RawSoftEntry, RecordInstance,
8};
9use crate::types::{DbFieldType, EpicsValue, PvString};
10
11use super::PvDatabase;
12
13/// C `sCalcoutRecord.c` `STRING_SIZE` (:198) — the 40-byte buffer behind every
14/// string field a string-input link writes into. The text therefore carries at
15/// most 39 bytes plus the NUL, which is what `epicsSnprintf(..., STRING_SIZE-1,
16/// ...)` and `epicsStrSnPrintEscaped(..., STRING_SIZE-1, ...)` enforce in C.
17const STRING_FIELD_MAX_LEN: usize = 39;
18
19/// **The single owner of "this record's processing cycle was refused."**
20///
21/// C publishes a refused cycle exactly once, in `dbProcess`'s `MAX_LOCK`
22/// branch (`dbAccess.c:544-556`):
23///
24/// ```c
25/// recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM, "Async in progress");
26/// monitor_mask = recGblResetAlarms(precord);
27/// monitor_mask |= DBE_VALUE|DBE_LOG;
28/// db_post_events(precord, ((char *)precord) + pdbFldDes->offset, monitor_mask);
29/// ```
30///
31/// so a refusal is never a silent success: the record carries SCAN_ALARM /
32/// INVALID with the reason in `AMSG`, and the transition is posted. The one
33/// refusal the port can make, C's `MAX_LOCK` re-entry, routes through here;
34/// like C, the port has no link-depth bound.
35///
36/// Returns the post set for the caller to hand to `notify_from_snapshot` after
37/// releasing the write guard, or `None` when the record already carries this
38/// refusal — C's `if (precord->stat == SCAN_ALARM) goto all_done`, which is
39/// what keeps a repeatedly refused record from re-posting every cycle.
40fn scan_alarm_refusal(
41 instance: &mut RecordInstance,
42 msg: &str,
43) -> Option<crate::server::record::ProcessSnapshot> {
44 use crate::server::recgbl::EventMask;
45 if instance.common.stat == crate::server::recgbl::alarm_status::SCAN_ALARM
46 && instance.common.sevr >= crate::server::record::AlarmSeverity::Invalid
47 {
48 return None;
49 }
50 crate::server::recgbl::rec_gbl_set_sevr_msg(
51 &mut instance.common,
52 crate::server::recgbl::alarm_status::SCAN_ALARM,
53 crate::server::record::AlarmSeverity::Invalid,
54 msg,
55 );
56 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
57 // Post VAL with VALUE|LOG|ALARM (C `db_post_events(prec, &VAL,
58 // DBE_VALUE|DBE_LOG)` plus recGblResetAlarms' `val_mask = DBE_ALARM` for
59 // the fresh transition). The alarm fields carry their C per-field masks
60 // (recGbl.c:202-222): this only runs on a fresh SCAN_ALARM/INVALID raise,
61 // so sevr AND stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post the
62 // shared `stat_mask` = DBE_ALARM|DBE_VALUE.
63 let stat_mask = EventMask::ALARM | EventMask::VALUE;
64 let mut changed_fields = Vec::new();
65 if let Some(val) = instance.record.val() {
66 changed_fields.push((
67 "VAL".to_string(),
68 val,
69 EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
70 ));
71 }
72 changed_fields.push((
73 "SEVR".to_string(),
74 EpicsValue::Short(instance.common.sevr as i16),
75 EventMask::VALUE,
76 ));
77 changed_fields.push((
78 "STAT".to_string(),
79 EpicsValue::Short(instance.common.stat as i16),
80 stat_mask,
81 ));
82 // Include AMSG so subscribers reading the alarm text observe the reason
83 // alongside the SCAN_ALARM transition (C `recGbl.c:210-211` posts STAT and
84 // AMSG together when `stat_mask` is non-zero).
85 changed_fields.push((
86 "AMSG".to_string(),
87 EpicsValue::String(instance.common.amsg.clone().into()),
88 stat_mask,
89 ));
90 Some(crate::server::record::ProcessSnapshot { changed_fields })
91}
92
93/// Cut a string-link value to the C field width (see [`STRING_FIELD_MAX_LEN`]).
94fn truncate_string_field(s: PvString) -> PvString {
95 let bytes = s.as_bytes();
96 if bytes.len() <= STRING_FIELD_MAX_LEN {
97 return s;
98 }
99 PvString::from_bytes(&bytes[..STRING_FIELD_MAX_LEN])
100}
101
102/// The DBR_STRING view of a [`Record::string_input_links`](crate::server::record::Record::string_input_links) source, C
103/// `sCalcoutRecord.c::fetch_values` (895-937).
104///
105/// A `DBF_CHAR`/`DBF_UCHAR` source of more than one element is the one type C
106/// does NOT read as DBR_STRING (which would render element 0 as a number):
107/// it reads the array as text and escapes it with `epicsStrSnPrintEscaped`
108/// (`epicsString.c:230-261`), which is how a string longer than a DBR_STRING —
109/// or one carrying control characters — reaches a string calc. C caps the
110/// request at `STRING_SIZE-1` elements before the get and treats the result as
111/// a C string (`strlen(tmpstr)`), so the source is cut at 39 bytes and at the
112/// first NUL. Every other source type takes the plain `dbGetLink(DBR_STRING)`
113/// branch, i.e. the framework's own `DbFieldType::String` coercion.
114fn string_link_text(value: &EpicsValue) -> PvString {
115 let char_array_bytes = match value {
116 EpicsValue::CharArray(b) | EpicsValue::UCharArray(b) if b.len() > 1 => Some(b),
117 _ => None,
118 };
119 if let Some(bytes) = char_array_bytes {
120 let src = &bytes[..bytes.len().min(STRING_FIELD_MAX_LEN)];
121 let src = &src[..src.iter().position(|&b| b == 0).unwrap_or(src.len())];
122 let mut out = String::with_capacity(src.len());
123 for &b in src {
124 match b {
125 0x07 => out.push_str("\\a"),
126 0x08 => out.push_str("\\b"),
127 0x0c => out.push_str("\\f"),
128 b'\n' => out.push_str("\\n"),
129 b'\r' => out.push_str("\\r"),
130 b'\t' => out.push_str("\\t"),
131 0x0b => out.push_str("\\v"),
132 b'\\' => out.push_str("\\\\"),
133 b'\'' => out.push_str("\\'"),
134 b'"' => out.push_str("\\\""),
135 // C `isprint` in the "C" locale: ASCII 0x20..0x7e. Everything
136 // else — including the high half — is escaped `\xHH`.
137 _ if b.is_ascii_graphic() || b == b' ' => out.push(b as char),
138 _ => out.push_str(&format!("\\x{b:02x}")),
139 }
140 }
141 return truncate_string_field(PvString::from(out));
142 }
143 match value.convert_to(DbFieldType::String) {
144 EpicsValue::String(s) => truncate_string_field(s),
145 _ => PvString::new(),
146 }
147}
148
149/// A cancellable, generation-gated handle that re-enters an async record's
150/// `process()` exactly once.
151///
152/// C parity: epics-base `callbackRequest` / `callbackRequestDelayed`
153/// (`callback.c`) post a one-shot callback that later runs the record's
154/// `(*prset->process)(precord)` directly, bypassing `dbProcess`'s PACT
155/// entry guard. Here, firing the token re-enters via
156/// [`PvDatabase::process_record_continuation`] (the owner-driven
157/// continuation that also bypasses the PACT guard).
158///
159/// # Cancellation is structural, not a runtime check
160///
161/// The record owns a monotonic generation counter (`reprocess_generation`).
162/// Minting a token snapshots that counter as the token's `epoch` *after*
163/// bumping it, so:
164///
165/// - minting a newer token for the same record (C `callbackRequestDelayed`
166/// replacing an outstanding delayed callback), or
167/// - [`PvDatabase::cancel_async_reentry`] (C `callbackCancelDelayed`),
168///
169/// each advance the counter past every outstanding token's `epoch`. A
170/// stale token therefore re-enters *nothing*: [`AsyncToken::fire`] is the
171/// sole re-entry path, the epoch comparison is owned in one place, and the
172/// token is consumed (`self` by value) so it cannot fire twice. A consumer
173/// never writes an `if generation == ...` guard — it holds the token and
174/// calls `fire`; the no-op-when-stale is guaranteed by construction.
175pub struct AsyncToken {
176 /// Canonical record name to re-enter.
177 name: String,
178 /// Shared generation counter owned by the record
179 /// (`RecordInstance::reprocess_generation`).
180 generation: Arc<AtomicU64>,
181 /// Generation value captured at mint time. The token is current iff
182 /// `generation == epoch`.
183 epoch: u64,
184}
185
186impl AsyncToken {
187 /// The record this token re-enters.
188 pub fn record_name(&self) -> &str {
189 &self.name
190 }
191
192 /// True iff this token is still the current generation — no newer
193 /// token was minted and no [`PvDatabase::cancel_async_reentry`] has
194 /// run for the record since this token was minted. Read-only.
195 pub fn is_current(&self) -> bool {
196 self.generation.load(Ordering::Acquire) == self.epoch
197 }
198
199 /// Cancel this token (C `callbackCancelDelayed` for the holder's own
200 /// pending re-entry): advance the generation so this and any other
201 /// outstanding token for the record become stale, then consume the
202 /// token. Use when the holder itself decides not to re-enter; use
203 /// [`PvDatabase::cancel_async_reentry`] to cancel a token already
204 /// handed to a timer / notify task.
205 pub fn cancel(self) {
206 self.generation.fetch_add(1, Ordering::AcqRel);
207 }
208
209 /// Fire the continuation: if still current, re-enter the record's
210 /// `process()` via [`PvDatabase::process_record_continuation`]. A
211 /// stale (superseded / cancelled) token is a no-op. Consumes the
212 /// token so it cannot fire twice.
213 pub async fn fire(self, db: &PvDatabase) -> CaResult<()> {
214 if self.generation.load(Ordering::Acquire) != self.epoch {
215 return Ok(());
216 }
217 let mut visited = HashSet::new();
218 db.process_record_continuation(&self.name, &mut visited)
219 .await
220 }
221}
222
223/// A cycle-free handle for driving async-side database updates from
224/// OUTSIDE a record's `process()` cycle.
225///
226/// Wraps a [`std::sync::Weak`] reference to the database: a record stashes
227/// it (via [`crate::server::record::Record::set_async_context`]) without
228/// creating an ownership cycle — the database owns the record, so a strong
229/// `Arc<PvDatabaseInner>` stored on the record would leak the whole
230/// database. Every call upgrades the `Weak` to a temporary [`PvDatabase`];
231/// once the last strong owner drops, the upgrade fails and the call is a
232/// no-op (nothing is stranded).
233///
234/// This is the out-of-band counterpart to the in-band re-entry
235/// [`crate::server::record::ProcessAction`]s: a driver / callback thread
236/// (asyn TRACE post, AQR cancel, motor intermediate readback) holds the
237/// handle and pushes field updates or wires a completion-driven re-entry
238/// without going through `process()`. It exposes exactly the c401e2f0
239/// PACT primitive surface, each call guarded by the live-database check.
240#[derive(Clone)]
241pub struct AsyncDbHandle {
242 inner: std::sync::Weak<super::PvDatabaseInner>,
243}
244
245impl AsyncDbHandle {
246 /// Upgrade to a temporary owning [`PvDatabase`], or `None` if the
247 /// database has been dropped.
248 fn db(&self) -> Option<PvDatabase> {
249 self.inner.upgrade().map(|inner| PvDatabase { inner })
250 }
251
252 /// True while the backing database is still alive.
253 pub fn is_alive(&self) -> bool {
254 self.inner.strong_count() > 0
255 }
256
257 /// Out-of-band field post — see [`PvDatabase::post_fields`]. Returns an
258 /// empty `Vec` (no-op) if the database has been dropped.
259 pub fn post_fields(
260 &self,
261 name: &str,
262 fields: Vec<(String, EpicsValue)>,
263 ) -> CaResult<Vec<String>> {
264 match self.db() {
265 Some(db) => db.post_fields(name, fields),
266 None => Ok(Vec::new()),
267 }
268 }
269
270 /// Out-of-band field post under the caller's own event mask — see
271 /// [`PvDatabase::post_fields_with_mask`]. Returns an empty `Vec` (no-op)
272 /// if the database has been dropped.
273 pub(crate) fn post_fields_with_mask(
274 &self,
275 name: &str,
276 fields: Vec<(String, EpicsValue)>,
277 mask: crate::server::recgbl::EventMask,
278 ) -> CaResult<Vec<String>> {
279 match self.db() {
280 Some(db) => db.post_fields_with_mask(name, fields, mask),
281 None => Ok(Vec::new()),
282 }
283 }
284
285 /// C `dbCaPutLinkCallback`'s return status, asked before the put is
286 /// issued: would a put-WITH-completion to `link` be admitted right now?
287 ///
288 /// The gate is `if (!pca->isConnected || !pca->hasWriteAccess) return -1;`
289 /// (`dbCa.c:529-532`), and `PvDatabase::external_put_admitted` is the same
290 /// owner [`Self::put_link_notify`]'s write path consults, so the two cannot
291 /// disagree. Non-blocking and does no I/O — it reads the link set's cached
292 /// connection state, which is why it can be asked from inside `process()`
293 /// while the put itself must be deferred.
294 ///
295 /// A link whose target is a LOCAL record is C's non-`CA_LINK` case, which
296 /// never reaches that gate (`dbPutLink`, no callback): `true`. So is a
297 /// database that has been dropped — nothing is left to refuse.
298 pub fn put_link_admitted(&self, link: &str) -> bool {
299 let Some(db) = self.db() else {
300 return true;
301 };
302 match crate::server::record::parse_output_link_v2(link) {
303 crate::server::record::ParsedLink::Db(target) => {
304 // C `dbInitLink` locality (`dbLink.c:118-130`): a record this
305 // IOC does not hold is a CA link, and the port routes its write
306 // through the same external path.
307 if db.has_name_no_resolve(&target.target().record) {
308 return true;
309 }
310 db.external_put_admitted(&target.pvname()).is_ok()
311 }
312 other => match other.external_pv_name() {
313 Some(name) => db.external_put_admitted(&name).is_ok(),
314 // Constant / empty: C's switch makes no put at all, so there is
315 // no status to read.
316 None => true,
317 },
318 }
319 }
320
321 /// Resolve a link's target field type for the sseq link-status
322 /// diagnostics — see `PvDatabase::link_target_field_type`. `None` if
323 /// the link is constant / external / unresolvable, or the database is
324 /// gone. (Distinct from the free `server::record::link_field_type`,
325 /// which returns the link *class* `LinkType`, not the target's type.)
326 pub fn link_target_field_type(&self, link: &str) -> Option<crate::types::DbFieldType> {
327 match self.db() {
328 Some(db) => db.link_target_field_type(link),
329 None => None,
330 }
331 }
332
333 /// Schedule a record's link-status classification — see
334 /// `PvDatabase::schedule_record_init`. This is the ONE owner every
335 /// record's `refresh_link_status` goes through: during the LOAD phase the
336 /// classification is queued for `iocInit` (so it never reads a half-built
337 /// database, and its result is final when `iocInit` returns), and on a
338 /// complete database it is spawned at once. Dropped, unrun, if the database
339 /// is gone.
340 pub fn schedule_record_init(
341 &self,
342 record: &str,
343 init: impl std::future::Future<Output = ()> + Send + 'static,
344 ) {
345 if let Some(db) = self.db() {
346 db.schedule_record_init(record, init);
347 }
348 }
349
350 /// Read a link's value WITHOUT processing its source record — the C
351 /// `dbGetLink` semantics. Parses `link` and reads it via
352 /// `PvDatabase::read_link_value_no_process`; `None` if the link is
353 /// constant-less / external-unresolvable or the database has been
354 /// dropped. Used by module-crate records (e.g. std `throttle` SYNC →
355 /// `SINP`→`VAL`) that must pull an input link from `special()` without
356 /// triggering a process cycle.
357 pub async fn read_link_value(&self, link: &str) -> Option<EpicsValue> {
358 let db = self.db()?;
359 let parsed = crate::server::record::parse_link_v2(link);
360 db.read_link_value_no_process(&parsed)
361 }
362
363 /// Out-of-band `dbPutField` on any record field, common fields included —
364 /// see [`PvDatabase::put_pv`]. `Ok(())` (no-op) if the database has been
365 /// dropped.
366 ///
367 /// Unlike [`Self::post_fields`] (which writes through `put_field_internal`
368 /// and only posts), this is the full put path: a `SCAN` write moves the
369 /// record between scan buckets and fires the `get_ioint_info` hook. C
370 /// records call `dbPutField` on their own fields exactly this way — asynRecord's
371 /// `cancelIOInterruptScan` does `dbPutField(&scanAddr, DBR_LONG,
372 /// &passiveScan, 1)` on its own `.SCAN` (asynRecord.c:794-806).
373 pub async fn put_pv(&self, name: &str, value: EpicsValue) -> CaResult<()> {
374 match self.db() {
375 Some(db) => db.put_pv(name, value).await,
376 None => Ok(()),
377 }
378 }
379
380 /// Mint an async re-entry token — see [`PvDatabase::mint_async_token`].
381 /// `None` if the record is absent or the database has been dropped.
382 pub fn mint_async_token(&self, name: &str) -> Option<AsyncToken> {
383 match self.db() {
384 Some(db) => db.mint_async_token(name),
385 None => None,
386 }
387 }
388
389 /// Cancel an outstanding async re-entry — see
390 /// [`PvDatabase::cancel_async_reentry`]. No-op if the database is gone.
391 pub fn cancel_async_reentry(&self, name: &str) {
392 if let Some(db) = self.db() {
393 db.cancel_async_reentry(name);
394 }
395 }
396
397 /// Arm a put-notify wait-set — see [`PvDatabase::new_put_notify`].
398 /// Database-independent (re-exported associated fn).
399 pub fn new_put_notify() -> (
400 Arc<NotifyWaitSet>,
401 crate::runtime::sync::oneshot::Receiver<()>,
402 ) {
403 PvDatabase::new_put_notify()
404 }
405
406 /// Wire a completion oneshot to an async re-entry — see
407 /// [`PvDatabase::reprocess_on_notify`]. `None` if the database is gone
408 /// (the `completion` receiver is dropped, stranding nothing).
409 pub fn reprocess_on_notify(
410 &self,
411 token: AsyncToken,
412 completion: crate::runtime::sync::oneshot::Receiver<()>,
413 ) -> Option<crate::runtime::task::BackgroundTaskHandle<()>> {
414 self.db()
415 .map(|db| db.reprocess_on_notify(token, completion))
416 }
417
418 /// Issue a non-blocking put-with-completion to an OUT link — see
419 /// [`PvDatabase::put_link_notify`]. `None` if the database is gone or
420 /// the source record is missing.
421 pub async fn put_link_notify(
422 &self,
423 record_name: &str,
424 link_field: &str,
425 link_str: &str,
426 value: EpicsValue,
427 ) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
428 match self.db() {
429 Some(db) => {
430 db.put_link_notify(record_name, link_field, link_str, value)
431 .await
432 }
433 None => None,
434 }
435 }
436}
437
438/// C `dbNotifyCompletion` (`dbNotify.c:445`) reached the way a process cycle
439/// reaches it — through `recGblFwdLink` (`recGbl.c:295`), record support's only
440/// route to it. Take this record's wait-set membership and leave; the
441/// completion oneshot fires on the `leave` that empties the set.
442///
443/// # Invariant (CONTRACT)
444///
445/// A cycle completes an outstanding put-notify IF AND ONLY IF it runs
446/// `recGblFwdLink`. Two things stop it, and **both are read here** so no cycle
447/// tail can consult one and forget the other:
448///
449/// - [`Record::is_put_complete`](crate::server::record::Record::is_put_complete)
450/// — device support took the write async
451/// (`if (!pact && prec->pact) return(0)`), so this pass never reaches the
452/// tail.
453/// - [`Record::should_fire_forward_link`](crate::server::record::Record::should_fire_forward_link)
454/// — the tail was reached and the record type declined it.
455/// `dbNotifyCompletion` sits INSIDE the skipped call, so a suppressed
456/// forward link withholds the `ca_put_callback` too.
457/// `busy` is the clearest case: `busyRecord.c:271` runs the tail only for
458/// `val == 0 || oval == 0`, which is why `caput -c` on a busy record that
459/// stays at 1 is meant to hang until something writes "Done".
460///
461/// Reading them together HERE, rather than at each cycle tail, is what keeps a
462/// record type's C gate to one override: a type states its gate in
463/// `should_fire_forward_link` and gets the notify behaviour for free.
464///
465/// The SDIS-disable bail is C's OTHER `dbNotifyCompletion` caller
466/// (`dbAccess.c:623`), outside `recGblFwdLink` and so deliberately ungated —
467/// it open-codes the take/leave in `process_record_with_links_inner` and does
468/// not come through here.
469///
470/// Idempotent: a record in no put-notify is a no-op.
471fn complete_put_notify(inst: &mut RecordInstance) {
472 if !inst.record.is_put_complete() || !inst.record.should_fire_forward_link() {
473 return;
474 }
475 if let Some(ws) = inst.notify.take() {
476 ws.leave();
477 }
478}
479
480/// Result of an aSub LFLG=READ subroutine re-resolution
481/// (C `aSubRecord.c::fetch_values`). Computed outside the record's process
482/// lock (the SUBL link read may touch another record) and applied inside it.
483struct AsubDynamicSub {
484 /// SNAM read from the SUBL link this cycle — written back to the record
485 /// (C `dbGetLink` writes SNAM every READ cycle). `None` only when the
486 /// link read failed (C `if (status) return status`), leaving SNAM as-is.
487 snam: Option<String>,
488 /// `Some` → swap the live subroutine and set ONAM to `snam` (the name
489 /// changed and was found in the registry).
490 swap: Option<Arc<crate::server::record::SubroutineFn>>,
491 /// `true` → do not run the subroutine this cycle, matching C skipping
492 /// `do_sub`: the link read failed, or the changed name was not registered
493 /// (`S_db_BadSub`).
494 skip_run: bool,
495}
496
497/// Apply an aSub LFLG=READ resolution (from
498/// [`PvDatabase::resolve_asub_dynamic_subroutine`]) to a locked record: write
499/// the read-back SNAM, swap the subroutine + set ONAM when the name changed,
500/// and arm the one-shot suppress flag when the name was bad. The single apply
501/// owner, shared by the engine path ([`PvDatabase::process_record_with_links_inner`])
502/// and the foreign path ([`PvDatabase::process_record`]); the skip is consumed
503/// uniformly by `RecordInstance::run_registered_subroutine`.
504fn apply_asub_dynamic_sub(instance: &mut RecordInstance, ds: &AsubDynamicSub) {
505 if let Some(snam) = &ds.snam {
506 let _ = instance
507 .record
508 .put_field("SNAM", EpicsValue::String(snam.as_str().into()));
509 }
510 if let Some(func) = &ds.swap {
511 instance.subroutine = Some(func.clone());
512 if let Some(snam) = &ds.snam {
513 let _ = instance
514 .record
515 .put_field("ONAM", EpicsValue::String(snam.as_str().into()));
516 }
517 }
518 instance.suppress_subroutine_run = ds.skip_run;
519}
520
521/// If a CA TSEL link's pvname targets a record's `.TIME` field, return
522/// the record name with the `.TIME` suffix stripped; otherwise `None`.
523///
524/// Mirrors C `TSEL_modified` (dbLink.c:80-86): a `PV_LINK` tsel whose
525/// pvname contains `.TIME` is flagged `DBLINK_FLAG_TSELisTIME` and the
526/// name is truncated at `.TIME` to address the record. Matched on the
527/// `.TIME` suffix (the realistic spelling) case-insensitively, to stay
528/// consistent with the DB branch's `field.eq_ignore_ascii_case("TIME")`.
529fn ca_tsel_time_record(pv: &str) -> Option<&str> {
530 let idx = pv.len().checked_sub(".TIME".len())?;
531 pv[idx..]
532 .eq_ignore_ascii_case(".TIME")
533 .then_some(&pv[..idx])
534}
535
536/// Convert an lset `(seconds_past_epoch, nanos, userTag)` timestamp
537/// triple into the record-side `(SystemTime, userTag)` pair, clamping
538/// seconds/nanos to the valid `Duration` range. Shared by the TSEL
539/// `.TIME` Ca arm and the non-local Db arm — both read a `ca://` `.TIME`
540/// source through `external_link_time` and adopt the result identically.
541fn ext_time_pair((secs, ns, utag): (i64, i32, u64)) -> (std::time::SystemTime, u64) {
542 let secs = secs.max(0) as u64;
543 let ns = (ns.max(0) as u32).min(999_999_999);
544 (
545 std::time::UNIX_EPOCH + std::time::Duration::new(secs, ns),
546 utag,
547 )
548}
549
550/// The alarm-field events `recGblResetAlarms` posts (recGbl.c:202-222), each
551/// with its own per-field mask:
552///
553/// * `SEVR` — `DBE_VALUE`, ONLY when `prev_sevr != new_sevr`.
554/// * `STAT`/`AMSG` — `stat_mask` = `DBE_ALARM` (on sevr- or amsg-change) |
555/// `DBE_VALUE` (on stat-change).
556/// * `ACKS` — `DBE_VALUE`, only when `stat_mask != 0` and `recGblResetAlarms`
557/// raised it.
558///
559/// NOT the single owner of these masks, despite an earlier comment here that
560/// claimed so. Two of the five `recGblResetAlarms` post sites call this helper
561/// — the synchronous process epilogue (`process_record_with_links_inner`) and
562/// the `CompleteAlarmOnly` cycle that skips that epilogue (transform
563/// IVLA="Do Nothing"). The other three still open-code the identical mask
564/// arithmetic and can therefore drift from it:
565///
566/// * `complete_async_record_inner` — the async-completion epilogue;
567/// * `sim_process_tail` — the SIMM-mode input tail;
568/// * `RecordInstance::process_local` — the foreign-process / QSRV-group path.
569///
570/// (The SDIS-disable post in `process_record_with_links_inner` and the
571/// fanout/seq SELN post in `links::apply_selm_alarm` are NOT clients: they
572/// carry C's `dbAccess.c:586-593` and `fanoutRecord.c:116` masks, not
573/// `recGblResetAlarms`'.)
574pub(crate) fn alarm_field_posts(
575 common: &crate::server::record::CommonFields,
576 alarm_result: &crate::server::recgbl::AlarmResetResult,
577) -> Vec<(&'static str, crate::server::recgbl::EventMask)> {
578 use crate::server::recgbl::EventMask;
579
580 let sevr_changed = common.sevr != alarm_result.prev_sevr;
581 let stat_changed = common.stat != alarm_result.prev_stat;
582 let stat_mask = {
583 let mut m = EventMask::NONE;
584 if sevr_changed || alarm_result.amsg_changed {
585 m |= EventMask::ALARM;
586 }
587 if stat_changed {
588 m |= EventMask::VALUE;
589 }
590 m
591 };
592 let mut posts: Vec<(&'static str, EventMask)> = Vec::new();
593 if sevr_changed {
594 posts.push(("SEVR", EventMask::VALUE));
595 }
596 if !stat_mask.is_empty() {
597 posts.push(("STAT", stat_mask));
598 posts.push(("AMSG", stat_mask));
599 }
600 if alarm_result.acks_posted {
601 posts.push(("ACKS", EventMask::VALUE));
602 }
603 posts
604}
605
606/// What one process cycle hands to its forward-link tail.
607///
608/// C `processTarget` (dbDbLink.c:460-474) carries `psrc->putf` and
609/// `psrc->ppn` to each target as a unit — the PUTF bit and the put-notify
610/// wait-set always travel together — and the CP/CPP dispatch at the same tail
611/// needs what the cycle PUBLISHED (see [`CyclePosts`]). Bundled so the tail
612/// threads one value rather than three loose arguments.
613#[derive(Clone, Copy)]
614struct TailCtx<'a> {
615 putf: bool,
616 notify: Option<&'a Arc<NotifyWaitSet>>,
617 posts: CyclePosts,
618}
619
620/// What one process cycle published to monitors: the union of every `DBE_*`
621/// class it posted, across the value snapshot and the `recGblResetAlarms`
622/// fields.
623///
624/// This exists so the CP/CPP trigger reads a *post*, never a *process*. C
625/// serves every CP/CPP link — local target or not — through a CA
626/// subscription taken with `DBE_VALUE | DBE_ALARM` (`dbCa.c:1225-1229` →
627/// `cadef.h:2010-2011`), and only its `eventCallback` adds `CA_DBPROCESS`
628/// (`dbCa.c:955-963`, run at `:1249-1257`). A cycle that posts nothing —
629/// an unchanged value inside `MDEL`, no alarm movement — therefore leaves
630/// the holder unprocessed. Passing this value into the forward-link tail is
631/// what makes "dispatch a CP edge without a post" unrepresentable at the
632/// call site: there is no argument-less way to reach
633/// [`PvDatabase::dispatch_cp_targets`].
634#[derive(Clone, Copy)]
635struct CyclePosts(crate::server::recgbl::EventMask);
636
637impl CyclePosts {
638 /// The classes a value snapshot published.
639 fn of(snapshot: &crate::server::record::ProcessSnapshot) -> Self {
640 Self(snapshot.published_mask())
641 }
642
643 /// Fold in one more posted field (the `recGblResetAlarms` posts, which
644 /// are emitted outside the snapshot).
645 fn with(self, mask: crate::server::recgbl::EventMask) -> Self {
646 Self(self.0 | mask)
647 }
648
649 /// True when this cycle published a class a CP/CPP subscription selects.
650 fn triggers_cp(self) -> bool {
651 use crate::server::recgbl::EventMask;
652 self.0.intersects(EventMask::VALUE | EventMask::ALARM)
653 }
654}
655
656/// Result of the simulation-mode check.
657///
658/// C handles simulation entirely inside `readValue()` / `writeValue()` —
659/// the device-I/O step — and `process()` ALWAYS runs the rest of the body
660/// (`convert`/OROC/the record's own state machine) plus
661/// `checkAlarms`/`monitor`/`recGblFwdLink(prec)`. SIMM replaces ONLY the
662/// device read/write with the SIOL link, never the record-support body.
663/// The two substitution points differ by direction: an INPUT record's
664/// `readValue()` runs at the START of `process()` (before the body), so
665/// [`SimOutcome::Simulated`] does the SIOL read here and short-circuits;
666/// an OUTPUT record's `writeValue()` runs at the END (after the body has
667/// computed OVAL / armed bo HIGH), so [`SimOutcome::RedirectOutputToSiol`]
668/// lets the uniform flow run the body and redirects only the final write.
669enum SimOutcome {
670 /// SIMM disabled / no simulation link configured: run the record
671 /// body normally.
672 NotSimulated,
673 /// Simulated INPUT record: the SIOL read + convert already ran here
674 /// (`readValue` precedes the body). The caller must still run the
675 /// forward-link / CP / RPRO tail exactly as `recGblFwdLink` does for a
676 /// real process cycle, but skips the (already-substituted) body.
677 ///
678 /// Carries the cycle's [`CyclePosts`] because `sim_process_tail` already
679 /// published this cycle's monitors here; only this arm has a post set to
680 /// report, which is why it is on the variant rather than on the tuple.
681 Simulated(CyclePosts),
682 /// Simulated record whose simulation replaces only the INPUT STAGE of its
683 /// body ([`Record::simulation_substitutes_input_stage`](crate::server::record::Record::simulation_substitutes_input_stage)) — swait. The SIOL
684 /// read, the `VAL = SVAL` / `UDF = FALSE` write and the SIMM_ALARM raise
685 /// have already happened here (C `swaitRecord.c:415-422`, which precedes the
686 /// OOPT switch); the caller runs the record body with its input-link fetch
687 /// suppressed, then the ordinary alarm/monitor/forward-link tail — none of
688 /// which C's simulation branch skips.
689 SimulatedInputStage,
690 /// The `default:` arm of C's `switch (prec->simm)` — a SIMM value outside
691 /// the record's own menu (`SimMode::Illegal`):
692 ///
693 /// ```c
694 /// default:
695 /// recGblSetSevr(prec, SOFT_ALARM, INVALID_ALARM);
696 /// status = -1;
697 /// ```
698 ///
699 /// SOFT_ALARM/INVALID is already raised into the record's PENDING alarm by
700 /// `check_simulation_mode`. What is left is what C's `readValue`/
701 /// `writeValue` does NOT do on this arm: no device read, no device write, no
702 /// SIOL round-trip, no SIMM_ALARM, no VAL/UDF change. The `-1` it returns is
703 /// not a control-flow abort — the record's `process()` ignores it and still
704 /// runs `checkAlarms`, `monitor` and `recGblFwdLink` — so the cycle's tail
705 /// runs either way. The two record shapes differ only in where the
706 /// suppressed I/O sat: an INPUT's `readValue` precedes the body (nothing of
707 /// the body is left to run), an OUTPUT's `writeValue` follows it (the body
708 /// runs, only the write is suppressed).
709 IllegalMode { is_output: bool },
710 /// The SIML read FAILED and the record's support ABORTS on it — C
711 /// `writeValue` returns before performing any I/O
712 /// ([`Record::aborts_on_failed_siml_read`](crate::server::record::Record::aborts_on_failed_siml_read); `busy` is the only one):
713 ///
714 /// ```c
715 /// status=dbGetLink(&prec->siml,DBR_USHORT, &prec->simm,0,0);
716 /// if (status)
717 /// return(status); /* before write_busy AND before the SIOL dbPutLink */
718 /// ```
719 ///
720 /// Like [`Self::IllegalMode`] with `is_output`, this suppresses the cycle's
721 /// output and nothing else: the body runs and `process()` still does
722 /// `checkAlarms` / `monitor` / `recGblFwdLink`. It differs in the alarm — the
723 /// LINK_ALARM that `dbGetLink`'s `setLinkAlarm` already raised is the only
724 /// one; no SOFT_ALARM and no SIMM_ALARM is added, because C never reaches the
725 /// `switch (prec->simm)` that would raise them.
726 AbortedBeforeWrite,
727 /// Simulated OUTPUT record (`SIMM`=YES/RAW, not deferring). C
728 /// `writeValue` substitutes the device write with
729 /// `dbPutLink(&prec->siol, ..., &prec->oval)` — but at the END of
730 /// `process()`, AFTER the body (OROC, bo HIGH momentary reset, OVAL).
731 /// Unlike the input read, the output write cannot be done up-front, so
732 /// the caller runs the uniform record body and redirects only the final
733 /// output write to SIOL. Carries the SIOL link, the SIMS severity, and
734 /// the RAW-mode flag (write RVAL vs OVAL).
735 RedirectOutputToSiol {
736 siol: crate::server::record::ParsedLink,
737 sims: i16,
738 raw_mode: bool,
739 },
740 /// Asynchronous simulation: `SIMM`=YES/RAW with `SDLY` >= 0 on the
741 /// fresh (non-continuation) cycle. C `aiRecord.c::readValue` (488-508)
742 /// / `aoRecord.c::writeValue` (571-587) `callbackRequestProcessCallbackDelayed`:
743 /// hold PACT, schedule a re-process `SDLY` seconds out, and post nothing
744 /// this cycle (C `process()` returns 0 on the async-start pass). The
745 /// SIOL round-trip + alarm/monitor tail run on the continuation, which
746 /// re-enters with `is_continuation = true` and takes the synchronous
747 /// branch. The wrapped [`Duration`](std::time::Duration) is the `SDLY` delay.
748 DeferRead(std::time::Duration),
749}
750
751impl PvDatabase {
752 /// Process a record by name (process_local + notify).
753 /// Alias-aware (epics-base PR #336).
754 pub async fn process_record(&self, name: &str) -> CaResult<()> {
755 // Delegate to the canonical engine path so a direct process fetches
756 // input links (DOL/INPx), runs the record body, evaluates alarms,
757 // writes outputs and dispatches FLNK exactly as a C `dbProcess` does.
758 // The reduced `process_local` path this used to call fetched no links,
759 // so a direct process of a calc/sub/aSub used stale A..U inputs; that
760 // path now exists only as an internal record-body unit-test helper.
761 // Acquires the entry record's advisory write gate (foreign caller).
762 let mut visited = HashSet::new();
763 self.process_record_with_links(name, &mut visited).await
764 }
765
766 /// `process_record` variant for a caller that already
767 /// owns the record's advisory write gate — the QSRV atomic group
768 /// PUT applying a `+proc` member. The gate is not
769 /// reentrant; the atomic group path MUST use this entry. See
770 /// [`crate::server::database::PvDatabase::lock_records`].
771 pub async fn process_record_already_locked(&self, name: &str) -> CaResult<()> {
772 // Same delegation as [`Self::process_record`], but to the gate-held
773 // engine entry since the caller already owns the advisory write gate.
774 let mut visited = HashSet::new();
775 self.process_record_with_links_already_locked(name, &mut visited)
776 }
777
778 /// Process a record with full link handling (INP -> process -> alarms -> OUT -> FLNK).
779 /// Uses the visited set for cycle detection.
780 ///
781 /// Foreign-caller entry: FLNK dispatch, scan loop, scan_event, CA put,
782 /// process(PROC=1) etc. Hits the PACT entry guard (mirrors C `dbProcess`
783 /// at `dbAccess.c:537-559`) when the record is mid-async.
784 ///
785 /// this is a *foreign* full-processing entry, so it acquires
786 /// the record's advisory write gate (`dbScanLock` analogue) for the
787 /// entry record before processing. A QSRV atomic group or pvalink
788 /// atomic scan-on-update epoch that holds `lock_records` over the
789 /// same record blocks a foreign scan/event/FLNK-dispatch caller
790 /// here, and vice versa — restoring the `DBManyLock` exclusion. The
791 /// recursive FLNK / OUT / CP fan-out within one chain does NOT
792 /// re-acquire the gate (`process_record_with_links_recursive`),
793 /// mirroring C `processTarget` (`dbDbLink.c:436`) which asserts the
794 /// target's lock set is already owned by the calling thread; the
795 /// `visited` cycle guard prevents re-processing the entry record.
796 pub fn process_record_with_links<'a>(
797 &'a self,
798 name: &'a str,
799 visited: &'a mut HashSet<String>,
800 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
801 Box::pin(async move {
802 self.process_record_with_links_inner(name, visited, false, true, false)
803 .await
804 })
805 }
806
807 /// Driver-callback (`asyn:READBACK`) full-processing entry.
808 ///
809 /// The single owner of this entry is the I/O Intr wiring
810 /// (`crate::server::ioc_app::setup_io_intr` and its `ioc_builder`
811 /// twin): the spawned task processes a record because the driver
812 /// fired an interrupt callback, not because of a client put / FLNK /
813 /// scan. `device_callback = true` tells
814 /// `Self::process_record_with_links_inner` that, for an *output*
815 /// record, this cycle must READ the callback value back into VAL and
816 /// MUST NOT write it to the driver — C `devAsynInt32.c::processBo`
817 /// (and `processAo`/`processLongout`/…) take the readback branch when
818 /// `newOutputCallbackValue` is set, never `processCallbackOutput`'s
819 /// `write()`. Without this, the readback re-asserts the setpoint and
820 /// re-triggers the driver (e.g. AD `Acquire` looping). Input records
821 /// (`!can_device_write`) are unaffected: their read stage already
822 /// runs, and the no-write gate is keyed on the record being an output.
823 ///
824 /// Acquires the entry record's advisory write gate exactly like
825 /// [`Self::process_record_with_links`] — the callback task is a
826 /// foreign caller w.r.t. any QSRV atomic group / pvalink epoch.
827 pub fn process_record_readback<'a>(
828 &'a self,
829 name: &'a str,
830 visited: &'a mut HashSet<String>,
831 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
832 Box::pin(async move {
833 // C `devAsynInt32.c::outputCallbackCallback` (asyn devEpics):
834 // arm the output-callback "expected pop" before dbProcess, then
835 // reconcile after. If this pass never reaches the device read
836 // stage — the PACT entry guard bails because a put / FLNK cycle
837 // still owns the record (e.g. the readback racing the bo's own
838 // put that started the driver) — the callback ring would keep the
839 // entry forever and desync the wakeup count from the pop count.
840 // The AD `Acquire` bo getting stuck at 1 after a fast acquire is
841 // exactly that: the start callback's readback bails on PACT, the
842 // finalize callback's pop then consumes the stale start value, and
843 // the finalize 0 is never popped. reconcile discards the stale
844 // entry (C fallback `getCallbackValue`) so 1 callback == 1 pop.
845 self.arm_readback_callback(name);
846 let result = self
847 .process_record_with_links_inner(name, visited, false, true, true)
848 .await;
849 self.reconcile_readback_callback(name);
850 result
851 })
852 }
853
854 /// Arm the entry record's output driver-callback cycle before a readback
855 /// process pass — see [`crate::server::device_support::DeviceSupport::arm_readback_callback`].
856 fn arm_readback_callback(&self, name: &str) {
857 let canonical = self.resolve_alias(name);
858 let key: &str = canonical.as_deref().unwrap_or(name);
859 // Collect-then-act: clone the instance handle under a brief map read,
860 // then drop the map lock before taking the per-record write. Never
861 // hold `records.read()` across `rec.write()` — same lock discipline
862 // as `add_breaktables` / `all_record_names`.
863 let rec = {
864 let records = self.inner.records.read();
865 records.get(key).cloned()
866 };
867 if let Some(rec) = rec {
868 if let Some(dev) = rec.write().device.as_mut() {
869 dev.arm_readback_callback();
870 }
871 }
872 }
873
874 /// Reconcile the entry record's output driver-callback cycle after a
875 /// readback process pass — see
876 /// [`crate::server::device_support::DeviceSupport::reconcile_readback_callback`].
877 fn reconcile_readback_callback(&self, name: &str) {
878 let canonical = self.resolve_alias(name);
879 let key: &str = canonical.as_deref().unwrap_or(name);
880 // Collect-then-act: clone the handle under a brief map read, drop the
881 // map lock, then take the per-record write — see `arm_readback_callback`.
882 let rec = {
883 let records = self.inner.records.read();
884 records.get(key).cloned()
885 };
886 if let Some(rec) = rec {
887 if let Some(dev) = rec.write().device.as_mut() {
888 dev.reconcile_readback_callback();
889 }
890 }
891 }
892
893 /// full-processing entry for a caller that already owns the
894 /// record's advisory write gate via [`PvDatabase::lock_records`] —
895 /// the QSRV atomic group GET/PUT and the pvalink atomic
896 /// scan-on-update epoch. The advisory gate is not
897 /// reentrant; a transaction owner holding `lock_records` over the
898 /// member set MUST use this entry to scan a member record, or it
899 /// would deadlock against its own epoch guard. Foreign (non-owner)
900 /// callers must use [`Self::process_record_with_links`] so the gate
901 /// is taken.
902 ///
903 /// Synchronous: the gate is already held by the caller, so this entry has
904 /// nothing to wait for. It goes straight to
905 /// `process_record_with_links_body`, which is where the H6
906 /// no-suspension contract lives.
907 pub fn process_record_with_links_already_locked(
908 &self,
909 name: &str,
910 visited: &mut HashSet<String>,
911 ) -> CaResult<()> {
912 self.run_process_frame(name, visited, false, false, false)
913 }
914
915 /// One record's process frame: entry bookkeeping, the optional advisory
916 /// write gate, the cycle, and the unwind that takes this frame's cycle
917 /// marker back out of `visited`.
918 ///
919 /// **Invariant:** a name is in `visited` exactly while its frame is on the
920 /// CURRENT PROCESS STACK — never "somewhere earlier in this cascade".
921 /// Both of C's equivalents are stack conditions and nothing else:
922 /// `processTarget` claims `procThread` at `dbDbLink.c:502-504` and clears
923 /// it at `:521-526`, around one `dbProcess`; `dbProcess` itself tests
924 /// `precord->pact` (`dbAccess.c:537`), set for the duration of a cycle.
925 /// There is no set of already-processed records anywhere in C, and
926 /// `dbProcess(pdst)` at `dbDbLink.c:511` is unconditional.
927 ///
928 /// **Owner/gate:** this function. [`Self::process_entry_prelude`]
929 /// returning `Some` means THIS frame inserted the name, and this is the
930 /// only place that takes it out again. A `Some` returning through any
931 /// other path would leave a marker outliving the stack it describes, and
932 /// the guard would start refusing records C processes again — which is
933 /// exactly what a diamond FLNK (`F` → `A`,`B`; `A` → `C`; `B` → `C`) hit.
934 fn run_process_frame(
935 &self,
936 name: &str,
937 visited: &mut HashSet<String>,
938 acquire_gate: bool,
939 is_continuation: bool,
940 device_callback: bool,
941 ) -> CaResult<()> {
942 // A `None` here found the name already present, so the marker is the
943 // outer frame's and there is nothing to unwind.
944 let Some((name, rec)) = self.process_entry_prelude(name, visited)? else {
945 return Ok(());
946 };
947
948 // Breakpoint hook, C `dbAccess.c:504-515`:
949 //
950 // if (lset_stack_count != 0) {
951 // if (dbBkpt(precord)) goto all_done;
952 // }
953 //
954 // guarding both the hook and its "skip record support" answer. Here
955 // the guard is the `ArcSwapOption` load: `None` for a database nobody
956 // is debugging, so this costs one relaxed atomic per processed record
957 // where C costs one comparison.
958 //
959 // Placed BEFORE the gate is taken, because a stop parks the calling
960 // thread: C likewise drops `dbScanLock` before `epicsThreadSuspendSelf`
961 // (`dbBkpt.c:794-796`) so `dbb`/`dbd`/`dbc`/`dbs` keep working while a
962 // record is stopped. The thread that parks here is never a runtime
963 // worker — the hook hands foreign processing to the lock set's own
964 // continuation thread and returns `Skip`, and only that thread reaches
965 // the parking arm.
966 let breakpoints = self.breakpoints();
967 if let Some(table) = breakpoints.as_ref() {
968 if table.before_process(self, &name)
969 == crate::server::database::breakpoint::Before::Skip
970 {
971 // C's `goto all_done`, which unwinds the same way the normal
972 // path does. `visited` was inserted by the prelude above and
973 // this frame owns it, so it comes out here as it would below.
974 visited.remove(&name);
975 return Ok(());
976 }
977 }
978
979 // advisory write gate (`dbScanLock(precord)` analogue).
980 // A foreign full-processing entry (scan loop, scan_event, FLNK
981 // dispatch from another chain, CA put, PINI/startup) acquires
982 // the entry record's gate so it cannot interleave with a QSRV
983 // atomic group or a pvalink atomic scan epoch holding
984 // `lock_records` over the same record. `name` is already the
985 // alias-resolved canonical name, the same key `lock_records`
986 // uses. Not acquired when `acquire_gate` is false: either a
987 // transaction owner already holds the gate via `lock_records`
988 // (`process_record_with_links_already_locked`), or this is a
989 // recursive FLNK/OUT/CP call within one chain
990 // (`process_record_with_links_recursive`) — C `processTarget`
991 // processes a link target under the lock set the caller already
992 // owns, and re-acquiring would deadlock the non-reentrant gate.
993 let _record_gate = if acquire_gate {
994 Some(self.lock_record(&name))
995 } else {
996 None
997 };
998
999 // NO `.await` may appear below this line while `_record_gate` is
1000 // live — see the module note on `process_record_with_links_body`.
1001 let result = self.process_record_with_links_body(
1002 &name,
1003 &rec,
1004 visited,
1005 is_continuation,
1006 device_callback,
1007 );
1008
1009 // Breakpoint auto-print, C `dbAccess.c:614-616` — after record
1010 // support, under the same `lset_stack_count` guard. Reloaded rather
1011 // than reusing the handle above: a `dbd` during this record's own
1012 // processing can have retired the observer, and C re-tests the count.
1013 if let Some(table) = self.breakpoints() {
1014 table.after_process(self, &name);
1015 }
1016
1017 // The unwind. C `dbDbLink.c:521-526`, `if (claim_dst)
1018 // dbRec2Pvt(pdst)->procThread = NULL;` — after `dbProcess`, whatever
1019 // it returned.
1020 visited.remove(&name);
1021 result
1022 }
1023
1024 /// One entry point processed by a breakpoint continuation thread — C
1025 /// `dbBkptCont`'s `dbScanLock(precord); dbProcess(pqe->entrypoint);
1026 /// dbScanUnlock(precord);` (`dbBkpt.c:604-606`).
1027 ///
1028 /// The gate is acquired here, as for any other foreign entry, and the
1029 /// chain below may park inside the breakpoint hook. That is legal on this
1030 /// call and on no other: the caller is the lock set's dedicated thread,
1031 /// which exists to be parked, never a runtime worker.
1032 pub(crate) fn process_record_for_breakpoint(&self, name: &str) -> CaResult<()> {
1033 self.run_process_frame(name, &mut HashSet::new(), true, false, false)
1034 }
1035
1036 /// recursive FLNK / OUT / CP fan-out entry within a single
1037 /// processing chain. Does NOT re-acquire the advisory write gate:
1038 /// the chain is one transaction whose entry record's gate is
1039 /// already held by the foreign entry, and C `processTarget`
1040 /// (`dbDbLink.c:436`) processes a link target under the lock set
1041 /// already owned by the calling thread. Re-acquiring per chain
1042 /// member would also create a lock-ordering deadlock between
1043 /// reverse FLNK chains.
1044 ///
1045 /// Synchronous, and recursive as a plain call: the chain runs inside the
1046 /// entry record's gate-held region, so it must not suspend. C's
1047 /// `processTarget` is likewise a direct call under the caller's lock set.
1048 pub(crate) fn process_record_with_links_recursive(
1049 &self,
1050 name: &str,
1051 visited: &mut HashSet<String>,
1052 ) -> CaResult<()> {
1053 self.run_process_frame(name, visited, false, false, false)
1054 }
1055
1056 /// Owner-driven continuation re-entry — bypasses the PACT entry guard.
1057 ///
1058 /// Used by `ProcessAction::ReprocessAfter` timer fires: the spawned
1059 /// re-entry task IS the owner of the async cycle, equivalent to C
1060 /// `callbackRequestDelayed`'s direct call to the record's `process()`
1061 /// (which bypasses `dbProcess`). Foreign callers must still go through
1062 /// `process_record_with_links` so FLNK / scan / CA put cannot race
1063 /// during the wait window.
1064 ///
1065 /// the timer fire is a fresh task — the original cycle's
1066 /// advisory gate was released when `process_record_with_links`
1067 /// returned async-pending. In C, `callbackRequestDelayed` dispatches
1068 /// through a callback that re-takes `dbScanLock(precord)` for the
1069 /// completion `process()`. This entry therefore re-acquires the
1070 /// advisory write gate, so the continuation cannot interleave with a
1071 /// QSRV atomic group or another foreign scan of the same record.
1072 pub fn process_record_continuation<'a>(
1073 &'a self,
1074 name: &'a str,
1075 visited: &'a mut HashSet<String>,
1076 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
1077 Box::pin(async move {
1078 self.process_record_with_links_inner(name, visited, true, true, false)
1079 .await
1080 })
1081 }
1082
1083 /// A cycle-free [`AsyncDbHandle`] for this database, handed to each
1084 /// record via [`crate::server::record::Record::set_async_context`] at
1085 /// registration. Holds only a `Weak` reference, so a record stashing
1086 /// it never keeps the database alive.
1087 pub fn async_handle(&self) -> AsyncDbHandle {
1088 AsyncDbHandle {
1089 inner: Arc::downgrade(&self.inner),
1090 }
1091 }
1092
1093 /// Mint a fresh async re-entry [`AsyncToken`] for `name`.
1094 ///
1095 /// Minting advances the record's generation counter, so any
1096 /// previously-minted token for the same record is superseded — its
1097 /// [`AsyncToken::fire`] becomes a structural no-op. This mirrors C
1098 /// `callbackRequestDelayed` replacing an outstanding delayed callback
1099 /// for a record. `name` must be the canonical record name (the value
1100 /// of `RecordInstance::name`). Returns `None` if the record is absent.
1101 pub fn mint_async_token(&self, name: &str) -> Option<AsyncToken> {
1102 let records = self.inner.records.read();
1103 let rec = records.get(name)?;
1104 let generation = rec.read().reprocess_generation.clone();
1105 let epoch = generation.fetch_add(1, Ordering::AcqRel) + 1;
1106 Some(AsyncToken {
1107 name: name.to_string(),
1108 generation,
1109 epoch,
1110 })
1111 }
1112
1113 /// Cancel any outstanding async re-entry token for `name` (C
1114 /// `callbackCancelDelayed`): advance the record's generation counter so
1115 /// every previously-minted [`AsyncToken`] for it becomes stale and its
1116 /// `fire` is a no-op. A subsequent [`Self::mint_async_token`] produces a
1117 /// fresh, current token. No-op if the record is absent.
1118 pub fn cancel_async_reentry(&self, name: &str) {
1119 let records = self.inner.records.read();
1120 if let Some(rec) = records.get(name) {
1121 rec.read()
1122 .reprocess_generation
1123 .fetch_add(1, Ordering::AcqRel);
1124 }
1125 }
1126
1127 /// The callback band `name`'s `PRIO` selects — C
1128 /// `callbackSetPriority(prec->prio, &pcb->callback)` (`seqRecord.c:146`).
1129 ///
1130 /// For the deferral sites that hold a record *name* rather than a locked
1131 /// instance. Takes the record's read lock, so it must not be called from
1132 /// inside that record's own `process()`/`special()` — those run under the
1133 /// instance write lock and read the band off
1134 /// [`ProcessContext::callback_priority`](crate::server::record::ProcessContext)
1135 /// instead. A record that is gone answers `Low`, the band an unwritten
1136 /// `PRIO` already has; the work being scheduled for it is a no-op anyway.
1137 pub fn record_callback_priority(&self, name: &str) -> crate::runtime::task::CallbackPriority {
1138 let records = self.inner.records.read();
1139 match records.get(name) {
1140 Some(rec) => rec.read().common.callback_priority(),
1141 None => crate::runtime::task::CallbackPriority::Low,
1142 }
1143 }
1144
1145 /// Schedule a delayed re-process of `name` — the single owner of the
1146 /// "mint a fresh [`AsyncToken`], sleep, then fire" pattern. Used by both
1147 /// [`ProcessAction::ReprocessAfter`](crate::server::record::ProcessAction::ReprocessAfter) (record-driven owner re-entry: ODLY
1148 /// output delay, swait, sequence DLYn) and the `SDLY` async-simulation
1149 /// defer ([`SimOutcome::DeferRead`]). Minting advances the record's
1150 /// generation so a newer schedule supersedes any pending one; a stale
1151 /// token's `fire` is a structural no-op. No-op if the record is absent.
1152 fn schedule_delayed_reprocess(&self, name: &str, delay: std::time::Duration) {
1153 let token = match self.mint_async_token(name) {
1154 Some(t) => t,
1155 None => return,
1156 };
1157 let prio = self.record_callback_priority(name);
1158 let db = self.clone();
1159 crate::runtime::task::spawn_background(prio, async move {
1160 crate::runtime::task::sleep_background(delay).await;
1161 let _ = token.fire(&db).await;
1162 });
1163 }
1164
1165 /// Schedule C `callbackRequestDelayed` with a record-owned handler body —
1166 /// the single owner of [`ProcessAction::DelayedCallbackAfter`](crate::server::record::ProcessAction::DelayedCallbackAfter)
1167 /// and the port of `boRecord.c::myCallbackFunc` (:105-118).
1168 ///
1169 /// The fire takes the record gate (C `dbScanLock`), runs
1170 /// [`Record::delayed_callback_fire`](crate::server::record::Record::delayed_callback_fire)
1171 /// and only then re-enters `process()`. The handler's mutation is therefore
1172 /// reachable from the timer alone: no record flag survives the arm, so no
1173 /// other process cycle can consume the one-shot. Re-arming mints a fresh
1174 /// token, exactly as C's re-`callbackRequestDelayed` replaces the pending
1175 /// delayed callback.
1176 fn schedule_delayed_callback(&self, name: &str, delay: std::time::Duration) {
1177 let Some(token) = self.mint_async_token(name) else {
1178 return;
1179 };
1180 let prio = self.record_callback_priority(name);
1181 let db = self.clone();
1182 let name = name.to_string();
1183 crate::runtime::task::spawn_background(prio, async move {
1184 let mut token = token;
1185 let mut delay = delay;
1186 loop {
1187 crate::runtime::task::sleep_background(delay).await;
1188 // A newer arm (or a cancel) superseded this timer while it
1189 // slept — the same `AsyncToken` gate `ReprocessAfter` uses.
1190 if !token.is_current() {
1191 return;
1192 }
1193 let outcome = {
1194 let records = db.inner.records.read();
1195 let Some(rec) = records.get(&name) else {
1196 return;
1197 };
1198 let rec = rec.clone();
1199 drop(records);
1200 let mut instance = rec.write();
1201 let pact = instance.is_processing();
1202 instance.record.delayed_callback_fire(pact)
1203 };
1204 match outcome {
1205 crate::server::record::DelayedCallbackOutcome::Reprocess => {
1206 let _ = token.fire(&db).await;
1207 return;
1208 }
1209 crate::server::record::DelayedCallbackOutcome::Rearm(again) => {
1210 let Some(fresh) = db.mint_async_token(&name) else {
1211 return;
1212 };
1213 token = fresh;
1214 delay = again;
1215 }
1216 crate::server::record::DelayedCallbackOutcome::Drop => return,
1217 }
1218 }
1219 });
1220 }
1221
1222 /// (Re)arm a record's monitor watchdog — the single owner of the
1223 /// [`Record::watchdog_interval`](crate::server::record::Record::watchdog_interval) / [`Record::watchdog_fire`](crate::server::record::Record::watchdog_fire) tick, and the
1224 /// port of C `histogramRecord.c::wdogInit` + `wdogCallback` (:102-152).
1225 ///
1226 /// Called from exactly two places, C's own two `wdogInit` call sites: once
1227 /// per record at `iocInit` (C `init_record` pass 1, `:168`) and from
1228 /// [`ProcessAction::ArmWatchdog`](crate::server::record::ProcessAction::ArmWatchdog), which a record's `special()` emits when
1229 /// a put changed the period (histogram SDEL, `:266-268`).
1230 ///
1231 /// Arming bumps the record's `watchdog_generation`, so a tick already in
1232 /// flight is superseded and simply exits — C's `callbackRequestDelayed`
1233 /// replacing an outstanding delayed callback. The task re-reads the
1234 /// interval on every iteration, so an SDEL put to 0 stops the watchdog at
1235 /// its next fire without a separate cancel path.
1236 ///
1237 /// The tick is NOT a process cycle: it takes the record lock (C
1238 /// `dbScanLock`), lets the record perform its own state change, stamps the
1239 /// record (C `recGblGetTimeStamp`) and posts `DBE_VALUE | DBE_LOG` monitors
1240 /// for the fields the record named — no `add_count`, no alarm tail, no
1241 /// FLNK. A record with no watchdog (`watchdog_interval() == None`) spawns
1242 /// nothing.
1243 pub(crate) fn arm_watchdog(&self, name: &str) {
1244 let (rec, generation, epoch, prio) = {
1245 let records = self.inner.records.read();
1246 let Some(rec) = records.get(name) else { return };
1247 let instance = rec.read();
1248 if instance.record.watchdog_interval().is_none() {
1249 // Bumping the generation still cancels a watchdog left running
1250 // by an earlier arm — an SDEL put to 0 comes through here.
1251 instance
1252 .watchdog_generation
1253 .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1254 return;
1255 }
1256 let generation = instance.watchdog_generation.clone();
1257 let epoch = generation.fetch_add(1, std::sync::atomic::Ordering::AcqRel) + 1;
1258 let prio = instance.common.callback_priority();
1259 (rec.clone(), generation, epoch, prio)
1260 };
1261
1262 let is_soft = {
1263 let instance = rec.read();
1264 instance.device.is_none()
1265 };
1266 // C `histogramRecord.c::wdogCallback` stamps with `recGblGetTimeStamp`
1267 // (`:113`), TSEL and all, so the tick owes the TSEL read too. Weak, so
1268 // a live watchdog never keeps the database alive — the tick simply
1269 // stamps without TSEL if the database is already gone.
1270 let db = self.async_handle();
1271 crate::runtime::task::spawn_background(prio, async move {
1272 loop {
1273 let interval = {
1274 let instance = rec.read();
1275 match instance.record.watchdog_interval() {
1276 Some(d) => d,
1277 // C: `if (prec->sdel > 0)` fails -> no re-arm.
1278 None => return,
1279 }
1280 };
1281 crate::runtime::task::sleep_background(interval).await;
1282 // A newer arm superseded this task while it slept.
1283 if generation.load(std::sync::atomic::Ordering::Acquire) != epoch {
1284 return;
1285 }
1286 let fields = {
1287 let mut instance = rec.write();
1288 instance.record.watchdog_fire()
1289 };
1290 if fields.is_empty() {
1291 // C `wdogCallback`: `mcnt == 0` -> no stamp, no post; the
1292 // timer still re-arms. C tests `prec->mcnt` before it even
1293 // takes `dbScanLock` (`histogramRecord.c:111-112`), so no
1294 // TSEL read happens on an empty tick either.
1295 continue;
1296 }
1297 // Between the two guards, like every other stamp point: the
1298 // TSEL read takes its own locks.
1299 let tsel = match db.db() {
1300 Some(db) => db.read_tsel(&rec),
1301 None => super::TselStamp::None,
1302 };
1303 let mut instance = rec.write();
1304 let inst = &mut *instance;
1305 tsel.stamp(&inst.name, &mut inst.common, is_soft);
1306 for field in fields {
1307 instance.notify_field(
1308 field,
1309 crate::server::recgbl::EventMask::VALUE
1310 | crate::server::recgbl::EventMask::LOG,
1311 );
1312 }
1313 }
1314 });
1315 }
1316
1317 /// Post an async-side field update for `name` — the C `db_post_events`
1318 /// analogue called from device-support / async-callback context.
1319 ///
1320 /// Each `(field, value)` is written through the internal put (bypassing
1321 /// the read-only field gate, like a record's own `process()` writes)
1322 /// and a monitor event is posted with `DBE_VALUE | DBE_LOG` — the mask C
1323 /// device support uses for an out-of-process value post
1324 /// (`db_post_events(precord, &prec->field, DBE_VALUE | DBE_LOG)`).
1325 /// Metadata-class writes invalidate the metadata cache via
1326 /// `notify_field_written`, honouring the snapshot-cache contract.
1327 ///
1328 /// Unlike [`Self::complete_async_record`], this runs *no* alarm /
1329 /// timestamp / FLNK tail: it is the immediate "push these fields to
1330 /// monitors now" primitive (e.g. asyn TRACE info, motor intermediate
1331 /// readback) that is independent of any process cycle. Returns the
1332 /// field names actually posted, or [`CaError::ChannelNotFound`] if the
1333 /// record is absent.
1334 pub fn post_fields(
1335 &self,
1336 name: &str,
1337 fields: Vec<(String, EpicsValue)>,
1338 ) -> CaResult<Vec<String>> {
1339 self.post_fields_with_mask(
1340 name,
1341 fields,
1342 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
1343 )
1344 }
1345
1346 /// Out-of-band PROPERTY-class post — the C
1347 /// `db_post_events(precord, &precord->val, DBE_PROPERTY)` analogue used
1348 /// for enum-string table re-propagation (asyn `callbackEnum`,
1349 /// devAsynInt32.c:712-766). Stores [`crate::server::device_support::PropertyPost::writes`] through the
1350 /// internal put, invalidates the metadata cache, and posts a single
1351 /// `DBE_PROPERTY` event on [`crate::server::device_support::PropertyPost::post_field`] so subscribers
1352 /// re-read enum choices / control metadata.
1353 ///
1354 /// The written fields are NOT posted on: C's `setEnums` re-keys
1355 /// ZRST/ZRVL/ZRSV… silently and the one `db_post_events` names
1356 /// `&pr->val`. See [`crate::server::device_support::PropertyPost`] for why the two sets are separate.
1357 ///
1358 /// Unlike [`Self::post_fields`] (which posts `DBE_VALUE | DBE_LOG`) this
1359 /// signals a *property* change, not a value change: a driver that re-keys
1360 /// its enum strings has not produced a new reading, only new choice
1361 /// labels. Returns the field names actually written.
1362 pub fn post_property(
1363 &self,
1364 name: &str,
1365 post: crate::server::device_support::PropertyPost,
1366 ) -> CaResult<Vec<String>> {
1367 let rec = {
1368 let records = self.inner.records.read();
1369 records.get(name).cloned()
1370 };
1371 let rec = rec.ok_or_else(|| CaError::ChannelNotFound(name.to_string()))?;
1372 let link_backing = self.resolve_link_backed_metadata(&rec);
1373 let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
1374 let mut inst = rec.write();
1375 let mut written = Vec::with_capacity(post.writes.len());
1376 for (field, value) in post.writes {
1377 inst.record.put_field_internal(&field, value)?;
1378 // Snapshot-cache contract: a metadata-class write must invalidate
1379 // the cache before the monitor snapshot below is built, or the
1380 // property event would carry the pre-change enum choices.
1381 inst.notify_field_written(&field);
1382 written.push(field);
1383 }
1384 inst.notify_field_backed(
1385 &post.post_field,
1386 crate::server::recgbl::EventMask::PROPERTY,
1387 link_backing,
1388 );
1389 Ok(written)
1390 }
1391
1392 /// Shared body of [`Self::post_fields`] and the record-owned posters:
1393 /// write+notify each field under one record-write lock, posting `mask`.
1394 ///
1395 /// Reachable from the records module because a record's own
1396 /// `db_post_events` mask is the record's to choose — see
1397 /// [`crate::server::records::link_status::post_link_status`], where three
1398 /// records post `DBE_VALUE` and a fourth posts `DBE_VALUE|DBE_LOG`.
1399 pub(crate) fn post_fields_with_mask(
1400 &self,
1401 name: &str,
1402 fields: Vec<(String, EpicsValue)>,
1403 mask: crate::server::recgbl::EventMask,
1404 ) -> CaResult<Vec<String>> {
1405 let rec = {
1406 let records = self.inner.records.read();
1407 records.get(name).cloned()
1408 };
1409 let rec = rec.ok_or_else(|| CaError::ChannelNotFound(name.to_string()))?;
1410 // A link-backed field reaches this poster: `seq` posts `DOn` here
1411 // (`links.rs`, C `seqRecord.c:266-268`) and `DOn`'s metadata comes
1412 // from `DOLn`. Resolved before the write guard, as everywhere.
1413 let link_backing = self.resolve_link_backed_metadata(&rec);
1414 let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
1415 let mut inst = rec.write();
1416 let mut posted = Vec::with_capacity(fields.len());
1417 for (field, value) in fields {
1418 inst.record.put_field_internal(&field, value)?;
1419 // Snapshot-cache contract: a metadata-class write must
1420 // invalidate the cache before the monitor snapshot is built.
1421 inst.notify_field_written(&field);
1422 inst.notify_field_backed(&field, mask, link_backing);
1423 posted.push(field);
1424 }
1425 Ok(posted)
1426 }
1427
1428 /// The single owner of [`crate::server::record::ProcessOutcome::post_write_fields`]: apply the
1429 /// field stores a `process()` withheld until its queued link writes had
1430 /// run, and post each at `DBE_VALUE`.
1431 ///
1432 /// Called on every arm that leaves a process cycle, immediately after that
1433 /// arm has executed the cycle's [`crate::server::record::ProcessAction::WriteDbLink`] and before
1434 /// its snapshot notification — which is where C's single `dbScanLock`
1435 /// makes the clear visible (`sseqRecord.c::asyncFinish` after
1436 /// `processCallback`'s `dbPutLink`s; `scalerRecord.c:370` under the same
1437 /// lock as `:457`/`:463`). A reader that takes the record between
1438 /// `process()` returning and this call sees the flag still SET, which is
1439 /// the conservative half of C's two observable states.
1440 ///
1441 /// Each field is applied independently. The group is one transition and a
1442 /// field that fails to store must not strand the rest of it — a partial
1443 /// apply that abandoned `BUSY` would leave the record busy forever.
1444 ///
1445 /// `DBE_VALUE` alone. C's masks, measured: `asyncFinish` (`sseqRecord.c:461`)
1446 /// posts `abort` at `:481`, `aborting` at `:482` and `busy` at `:505`, all
1447 /// three with `MonitorMask` — `DBE_VALUE | recGblResetAlarms(pR)` (`:471`),
1448 /// i.e. `DBE_VALUE` plus the alarm bit only when the alarm changed. Bare
1449 /// `DBE_VALUE` is what the other posts use: `waiting` (`:343`, `:559`,
1450 /// `:728`, `:1185` — never inside `asyncFinish`), the second `aborting`
1451 /// post (`:1192`), and `scalerRecord.c:372` (`cnt`). So a member published
1452 /// in the same cycle as an alarm transition omits a `DBE_ALARM` C sets.
1453 pub(crate) fn publish_post_write_fields(&self, name: &str, fields: Vec<(String, EpicsValue)>) {
1454 if fields.is_empty() {
1455 return;
1456 }
1457 let Some(rec) = self.get_record(name) else {
1458 return;
1459 };
1460 let mut inst = rec.write();
1461 for (field, value) in fields {
1462 if let Err(e) = inst.record.put_field_internal(&field, value) {
1463 eprintln!("{name}.{field}: post-write publication failed: {e:?}");
1464 continue;
1465 }
1466 // Snapshot-cache contract, as `post_fields_with_mask`: invalidate
1467 // before the monitor snapshot is built.
1468 inst.notify_field_written(&field);
1469 inst.notify_field(&field, crate::server::recgbl::EventMask::VALUE);
1470 }
1471 }
1472
1473 /// Resolve a link's target field [`DbFieldType`] for a LOCAL `DB_LINK`,
1474 /// or `None` for a constant / external / unresolvable link.
1475 ///
1476 /// Parity of C `dbGetLinkDBFtype` as `sseqRecord.c:checkLinks`
1477 /// (sseqRecord.c:884-941) uses it to fill the `DTn`/`LTn` diagnostics:
1478 /// a `DB_LINK` whose target record is on this IOC reports its addressed
1479 /// field's type (C `dbNameToAddr` → `pAddr->field_type`). A constant or
1480 /// `CA`/`PVA` (external) link returns `None` — epics-base-rs has no
1481 /// client-side introspection of a remote field's type, so the caller
1482 /// renders those as the `DBF_unknown` sentinel.
1483 pub(crate) fn link_target_field_type(&self, link: &str) -> Option<crate::types::DbFieldType> {
1484 let db = match crate::server::record::parse_link_v2(link) {
1485 crate::server::record::ParsedLink::Db(db) => db,
1486 _ => return None,
1487 };
1488 // Through the split: a filtered link's raw halves name a record that
1489 // does not exist (`SRC.VAL[0]` whole, field `VAL`), so `get_record`
1490 // missed and every filtered DB link reported no type at all.
1491 let addressed = db.target();
1492 let rec = self.get_record(&addressed.record)?;
1493 let inst = rec.read();
1494 let field = if addressed.field.is_empty() {
1495 "VAL"
1496 } else {
1497 addressed.field.as_str()
1498 };
1499 crate::server::record::record_instance::declared_field_type_of(inst.record.as_ref(), field)
1500 }
1501
1502 /// Create a put-notify wait-set for a downstream operation a record is
1503 /// about to drive, returning the wait-set (to attach to the downstream
1504 /// target instance's `notify`) and the completion receiver.
1505 ///
1506 /// C `dbNotify.c` `processNotify`: the set arms `pending = 1` for the
1507 /// downstream operation and fires the oneshot when that slot (plus any
1508 /// FLNK/OUT chain members that `enter` it) drains to zero — i.e. on
1509 /// `dbNotifyCompletion`. Pair with [`Self::reprocess_on_notify`] to
1510 /// re-enter a waiting record when the downstream completes (SSEQ
1511 /// `WAITn`).
1512 pub fn new_put_notify() -> (
1513 Arc<NotifyWaitSet>,
1514 crate::runtime::sync::oneshot::Receiver<()>,
1515 ) {
1516 let (tx, rx) = crate::runtime::sync::oneshot::channel();
1517 (NotifyWaitSet::new(tx), rx)
1518 }
1519
1520 /// Wire a downstream put-notify completion to an async re-entry: spawn a
1521 /// task that awaits `completion` (the oneshot from
1522 /// [`Self::new_put_notify`], fired on `dbNotifyCompletion`) and then
1523 /// `token.fire`s, re-entering the waiting record's `process()`. A
1524 /// superseded / cancelled token re-enters nothing. Returns the spawned
1525 /// task handle; fire-and-forget callers may drop it.
1526 pub fn reprocess_on_notify(
1527 &self,
1528 token: AsyncToken,
1529 completion: crate::runtime::sync::oneshot::Receiver<()>,
1530 ) -> crate::runtime::task::BackgroundTaskHandle<()> {
1531 let prio = self.record_callback_priority(token.record_name());
1532 let db = self.clone();
1533 crate::runtime::task::spawn_background(prio, async move {
1534 // `Err` means the sender was dropped without firing (the
1535 // downstream op vanished); treat it the same as completion so a
1536 // waiting record is never stranded — `fire` is a no-op if the
1537 // token was meanwhile superseded.
1538 let _ = completion.await;
1539 let _ = token.fire(&db).await;
1540 })
1541 }
1542
1543 /// Issue a put-WITH-completion to an OUT link and hand the caller only
1544 /// the completion receiver — the non-blocking sibling of
1545 /// [`Self::reprocess_on_notify`].
1546 ///
1547 /// Each call mints its own put-notify wait-set (C `dbProcessNotify`),
1548 /// writes the link through it with the source record's committed PUTF /
1549 /// alarm propagated (C `recGblInheritSevrMsg`), releases the initiator
1550 /// count, and returns the oneshot that fires on `dbNotifyCompletion`.
1551 /// The caller owns when (and whether) to await each receiver, so several
1552 /// puts can be outstanding at once — unlike
1553 /// [`crate::server::record::ProcessAction::WriteDbLinkNotify`], which wires the completion
1554 /// straight to a single superseding async re-entry token and so allows
1555 /// only one outstanding put per record. This is the seam C
1556 /// `calcApp/src/sseqRecord.c` needs to run multiple `WAITn` put-callbacks
1557 /// concurrently in flight (`processNextLink`).
1558 ///
1559 /// `record_name` is the source whose PUTF/alarm propagate into the
1560 /// target, `link_str` the already-resolved OUT link spelling, `value`
1561 /// the value to write. `None` if the source record is gone; an empty
1562 /// `link_str` returns a receiver that fires immediately (nothing joined
1563 /// the set).
1564 pub async fn put_link_notify(
1565 &self,
1566 record_name: &str,
1567 link_field: &str,
1568 link_str: &str,
1569 value: EpicsValue,
1570 ) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
1571 let rec = {
1572 let records = self.inner.records.read();
1573 records.get(record_name)?.clone()
1574 };
1575 let (src_putf, src_alarm) = {
1576 let instance = rec.read();
1577 // sseq's WAITn puts run from its async machine while the record
1578 // is still PACT — C `sseqRecord.c` issues `dbPutLink` in
1579 // `processCallback` (:734/756/787) and commits the alarm only in
1580 // `asyncFinish` (`recGblResetAlarms`, :471). The put therefore
1581 // inherits the source's PENDING alarm.
1582 (
1583 instance.common.putf,
1584 super::links::LinkAlarm::pending(&instance.common),
1585 )
1586 };
1587 let (waitset, completion) = Self::new_put_notify();
1588 if !link_str.is_empty() {
1589 let parsed = crate::server::record::parse_output_link_v2(link_str);
1590 // Seed the cycle-guard with the source so a target linking back
1591 // does not re-process it, exactly as a top-level OUT-link write
1592 // does (`process_record_with_links_inner` inserts its own name).
1593 let mut visited = HashSet::new();
1594 visited.insert(record_name.to_string());
1595 // Through the put owner: C `dbPutLinkAsync` raises the source's
1596 // LINK_ALARM/INVALID on a failed put exactly as the synchronous
1597 // `dbPutLink` does (dbLink.c:469-471).
1598 self.write_out_link_value(
1599 &rec,
1600 &parsed,
1601 value,
1602 super::links::OutLinkSrc {
1603 putf: src_putf,
1604 notify: Some(&waitset),
1605 alarm: &src_alarm,
1606 field: link_field,
1607 },
1608 &mut visited,
1609 );
1610 }
1611 // Release the initiator's own count (C `dbProcessNotify` holds one
1612 // count for the requester and drops it after issuing the put). The
1613 // set then drains — firing `completion` — when the downstream
1614 // target(s) that joined via `join_put_notify` finish, or immediately
1615 // when the link was empty / the target completed synchronously.
1616 waitset.leave();
1617 Some(completion)
1618 }
1619
1620 /// aSub LFLG=READ: read the subroutine name from the SUBL link and, when
1621 /// it changed, re-resolve the function from the registry. C
1622 /// `aSubRecord.c::fetch_values`. Returns `None` for any record that is
1623 /// not an aSub in READ mode (the common case), so the caller pays only a
1624 /// single brief read lock. Run BEFORE the process write lock so the SUBL
1625 /// link read cannot deadlock against this record.
1626 fn resolve_asub_dynamic_subroutine(
1627 &self,
1628 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
1629 ) -> Option<AsubDynamicSub> {
1630 let (subl, onam, snam) = {
1631 let inst = rec.read();
1632 if inst.record.record_type() != "aSub" {
1633 return None;
1634 }
1635 // LFLG: IGNORE=0 (static, resolved at init), READ=1 (dynamic).
1636 let lflg = inst
1637 .record
1638 .get_field("LFLG")
1639 .and_then(|v| v.to_f64())
1640 .unwrap_or(0.0) as i16;
1641 if lflg != 1 {
1642 return None;
1643 }
1644 let read_str = |f: &str| match inst.record.get_field(f) {
1645 Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
1646 _ => String::new(),
1647 };
1648 (read_str("SUBL"), read_str("ONAM"), read_str("SNAM"))
1649 };
1650
1651 // C `aSubRecord.c:256`: `dbGetLink(&prec->subl, DBR_STRING,
1652 // prec->snam, 0, 0)` — a plain read into SNAM. A CONSTANT (or unset)
1653 // SUBL delivers NOTHING here, so SNAM keeps the name
1654 // `recGblInitConstantLink(&subl, DBF_STRING, prec->snam)`
1655 // (`aSubRecord.c:126`) loaded at init — which is also what a `caput
1656 // REC.SNAM other` leaves in place.
1657 use crate::server::recgbl::simm::LinkFetch;
1658 let name: Option<String> =
1659 match self.db_get_link(rec, "SUBL", &crate::server::record::parse_link_v2(&subl)) {
1660 LinkFetch::Value(v) => Some(match v {
1661 EpicsValue::String(s) => s.as_str_lossy().into_owned(),
1662 o => o.to_f64().map(|f| f.to_string()).unwrap_or_default(),
1663 }),
1664 LinkFetch::NoData => Some(snam),
1665 LinkFetch::Failed => None,
1666 };
1667
1668 let Some(name) = name else {
1669 // Link read failed — C `if (status) return status` skips do_sub.
1670 return Some(AsubDynamicSub {
1671 snam: None,
1672 swap: None,
1673 skip_run: true,
1674 });
1675 };
1676
1677 // Re-resolve only when the name changed (C `strcmp(snam, onam)`); an
1678 // empty name never resolves (do_sub's `snam[0]==0` short-circuit).
1679 if !name.is_empty() && name != onam {
1680 match self.find_subroutine_named(&name) {
1681 Some(f) => Some(AsubDynamicSub {
1682 snam: Some(name),
1683 swap: Some(f),
1684 skip_run: false,
1685 }),
1686 // Name changed but not registered — C returns S_db_BadSub,
1687 // skipping do_sub; ONAM is left unchanged so it retries.
1688 None => Some(AsubDynamicSub {
1689 snam: Some(name),
1690 swap: None,
1691 skip_run: true,
1692 }),
1693 }
1694 } else {
1695 Some(AsubDynamicSub {
1696 snam: Some(name),
1697 swap: None,
1698 skip_run: false,
1699 })
1700 }
1701 }
1702
1703 /// The entry bookkeeping every process entry shares, before the advisory
1704 /// write gate is (or is not) taken: alias normalisation, the `visited`
1705 /// cycle guard and the records-map lookup.
1706 ///
1707 /// Factored out so the gate-taking entry
1708 /// ([`Self::process_record_with_links_inner`]) and the two gate-free
1709 /// entries (`process_record_with_links_body`'s direct callers)
1710 /// run it in the SAME order relative to the gate: bail decisions are made
1711 /// before any waiting, exactly as they were when this was open-coded.
1712 ///
1713 /// `Ok(None)` is "this entry did not run"; `Err` is C's `S_db_notFound`.
1714 ///
1715 /// A non-run is not silent: the cycle guard goes through
1716 /// [`Self::count_refused_active_entry`], which is C's already-active arm,
1717 /// so it cannot be written as a bare `return Ok(None)` here. There is no
1718 /// other non-run — C's `dbProcess` (`dbAccess.c:485`) has no link-depth
1719 /// counter, and neither does the port.
1720 ///
1721 /// Every `Ok(None)` is built by [`Self::entry_did_not_run`], which is
1722 /// also where the put-notify wait-set is released, so a non-run cannot
1723 /// strand a CA `WRITE_NOTIFY`.
1724 fn process_entry_prelude(
1725 &self,
1726 name: &str,
1727 visited: &mut HashSet<String>,
1728 ) -> CaResult<Option<(String, Arc<parking_lot::RwLock<RecordInstance>>)>> {
1729 // Normalise to the canonical record name once at entry — both
1730 // for cycle-detection (`visited` would otherwise treat alias
1731 // and canonical as distinct entries) and for the records-map
1732 // lookup below. Mirrors epics-base PR #336.
1733 let name: String = self.resolve_alias(name).unwrap_or_else(|| name.to_string());
1734
1735 let rec = {
1736 let records = self.inner.records.read();
1737 records.get(&name).cloned()
1738 };
1739
1740 if !visited.insert(name.clone()) {
1741 // The name is already on the CURRENT STACK, so this is a genuine
1742 // cycle. C reaches the same decision through PACT: `processTarget`
1743 // forces `psrc->pact = TRUE` before it calls `dbProcess(pdst)`
1744 // (`dbDbLink.c:457`/`:512` at R7.0.10), and a record whose own
1745 // cycle is on the stack has had PACT set by its record support
1746 // anyway, so `dbProcess` takes its already-active arm
1747 // (`dbAccess.c:536-556`). That arm is NOT silent: it counts the
1748 // refused entry in LCNT and, past `MAX_LOCK`, raises
1749 // SCAN_ALARM/INVALID "Async in progress". The port sets PACT only
1750 // for an async defer, so this marker is the synchronous half of
1751 // C's `precord->pact` — and it owes the same arm.
1752 //
1753 // The marker belongs to the OUTER frame — only that frame may
1754 // remove it, which is why this path must not.
1755 //
1756 // Re-reaching a record that has already FINISHED elsewhere in the
1757 // cascade is a different thing entirely and does NOT arrive here:
1758 // its frame took its marker back out on unwind, so the diamond
1759 // processes twice exactly as C's unconditional
1760 // `dbProcess(pdst)` (`dbDbLink.c:512`) does.
1761 if let Some(rec) = rec.as_ref() {
1762 self.count_refused_active_entry(rec);
1763 }
1764 return self.entry_did_not_run(rec.as_ref());
1765 }
1766
1767 match rec {
1768 Some(r) => Ok(Some((name, r))),
1769 None => Err(CaError::ChannelNotFound(name)),
1770 }
1771 }
1772
1773 /// C `dbProcess`'s already-active arm (`dbAccess.c:536-556` at R7.0.10)
1774 /// — the ONE place LCNT moves and the ONE place "Async in progress" is
1775 /// raised.
1776 ///
1777 /// C needs one test for "active" because its record support sets
1778 /// `precord->pact = TRUE` at the top of every `process()`, so PACT covers
1779 /// both the async wait and a cycle that is merely on the stack. The port
1780 /// sets PACT only for an async defer ([`RecordInstance::enter_pact`]), so
1781 /// "active" is two tests here: [`RecordInstance::is_processing`] for the
1782 /// async half, and the `visited` marker in
1783 /// [`Self::process_entry_prelude`] for the synchronous half. Two tests,
1784 /// one arm — both call this, so neither can decline more quietly than C.
1785 fn count_refused_active_entry(&self, rec: &Arc<parking_lot::RwLock<RecordInstance>>) {
1786 const MAX_LOCK: i16 = 10;
1787 let mut instance = rec.write();
1788
1789 // C `dbAccess.c:539-541` — when TPRO is set on a record whose PACT is
1790 // true, print the diagnostic line before the bail decision. The C path
1791 // emits "%s: dbProcess of Active '%s' with RPRO=%d", mirroring the
1792 // context format the regular trace path uses (thread/client name +
1793 // record name + current RPRO bit). Without this, an operator debugging
1794 // a stuck async record sees NO sign that the entry guard is firing —
1795 // they only notice the eventual SCAN_ALARM after MAX_LOCK=10 attempts.
1796 if instance.common.tpro != 0 {
1797 eprintln!(
1798 "[TPRO] {}: dbProcess of Active '{}' with RPRO={}",
1799 instance.name, instance.name, instance.common.rpro,
1800 );
1801 }
1802
1803 // C `dbAccess.c:544-546`:
1804 // if ((precord->stat == SCAN_ALARM) ||
1805 // (precord->lcnt++ < MAX_LOCK) ||
1806 // (precord->sevr >= INVALID_ALARM)) goto all_done;
1807 // The increment is in the test, so it happens on every refusal.
1808 let already_invalid = instance.common.sevr >= crate::server::record::AlarmSeverity::Invalid;
1809 let already_scan_alarm =
1810 instance.common.stat == crate::server::recgbl::alarm_status::SCAN_ALARM;
1811 let lcnt_before = instance.common.lcnt;
1812 instance.common.lcnt = lcnt_before.saturating_add(1);
1813 if already_scan_alarm || lcnt_before < MAX_LOCK || already_invalid {
1814 return;
1815 }
1816
1817 let snapshot = scan_alarm_refusal(&mut instance, "Async in progress");
1818 drop(instance);
1819 if let Some(snapshot) = snapshot {
1820 // Between the write guard's drop and the read guard's take: the one
1821 // window where a link target's lock is reachable. The refusal posts
1822 // STAT/SEVR/VAL, none of which any type link-backs, but the resolve
1823 // is the record's own answer rather than this caller's claim about
1824 // it — see `RecordInstance::make_monitor_snapshot`.
1825 let backing = self.resolve_link_backed_metadata(rec);
1826 let backing = crate::server::database::LinkBacking::resolved(&backing);
1827 let inst = rec.read();
1828 inst.notify_from_snapshot(&snapshot, backing);
1829 }
1830 }
1831
1832 /// The prelude's ONE "this entry did not run its cycle" exit — C
1833 /// `dbProcess`'s `all_done` with `callNotifyCompletion = TRUE`.
1834 ///
1835 /// `join_put_notify` (C `dbNotifyAdd`) is called by the link dispatcher
1836 /// on the will-process branch, *before* the recursion enters the prelude:
1837 ///
1838 /// ```text
1839 /// links.rs:1561 let pact = tg.is_processing();
1840 /// links.rs:1562 if !pact { tg.common.putf = src_putf;
1841 /// links.rs:1564 tg.join_put_notify(src_notify); } // ws.enter()
1842 /// links.rs:1575 self.process_record_with_links_recursive(target, visited)
1843 /// ```
1844 ///
1845 /// So by the time the cycle guard decides the entry will not run, the
1846 /// target is already counted in the wait-set — and nothing
1847 /// downstream will ever `leave` for it, because the only `leave`s are on
1848 /// paths that ran a cycle. The set never drains, the completion oneshot
1849 /// never fires, and the client's `CA_PROTO_WRITE_NOTIFY` gets no reply
1850 /// (measured on x86_64-wrs-vxworks while the port still refused entries
1851 /// past a 16-hop depth bound: the first put into a longer chain never
1852 /// replied over 90s, and `RTEMS:E8:L16` was left
1853 /// holding a wait-set that could never drain — after which every later put
1854 /// completed, because `join_put_notify`'s `notify.is_none()` guard stops a
1855 /// record that already holds a stale set from joining a live one).
1856 ///
1857 /// C decides this per exit path with one flag and one finalizer
1858 /// (`dbAccess.c:494` `callNotifyCompletion = FALSE`, `:576` disabled,
1859 /// `:598` no RSET, `:619-622` `all_done`), and the pact branch
1860 /// (`:551-555`) deliberately does NOT set it: a record whose own cycle is
1861 /// running owns its completion. The same split holds here — hence the
1862 /// `is_processing` test, which is C's `if (precord->pact)`, not a guard
1863 /// bolted on.
1864 fn entry_did_not_run(
1865 &self,
1866 rec: Option<&Arc<parking_lot::RwLock<RecordInstance>>>,
1867 ) -> CaResult<Option<(String, Arc<parking_lot::RwLock<RecordInstance>>)>> {
1868 if let Some(rec) = rec {
1869 let notify = {
1870 let mut instance = rec.write();
1871 if instance.is_processing() {
1872 None
1873 } else {
1874 instance.notify.take()
1875 }
1876 };
1877 // `leave` fires the completion oneshot when it empties the set, so
1878 // it runs outside the record lock — same as the SDIS-disable bail.
1879 if let Some(ws) = notify {
1880 ws.leave();
1881 }
1882 }
1883 Ok(None)
1884 }
1885
1886 /// The gate-taking entry — the ONLY `.await` in the whole H6 chain.
1887 ///
1888 /// Everything after the guard is bound lives in
1889 /// `process_record_with_links_body`, which is a plain `fn`: the
1890 /// L1 gate-held region contains zero suspension points by construction,
1891 /// which is what C's `dbProcess` gives for free (`dbScanLock` is a
1892 /// blocking mutex and the whole cycle between lock and unlock is
1893 /// straight-line C).
1894 async fn process_record_with_links_inner(
1895 &self,
1896 name: &str,
1897 visited: &mut HashSet<String>,
1898 is_continuation: bool,
1899 acquire_gate: bool,
1900 // This cycle is driven by a driver interrupt callback
1901 // (`asyn:READBACK` / SCAN="I/O Intr" output), not a put/FLNK/scan.
1902 // For an output record it forces the read-back-no-write contract
1903 // (C `devAsynInt32.c::processBo` `newOutputCallbackValue` branch).
1904 // Always `false` for client/FLNK/scan entries.
1905 device_callback: bool,
1906 ) -> CaResult<()> {
1907 self.run_process_frame(
1908 name,
1909 visited,
1910 acquire_gate,
1911 is_continuation,
1912 device_callback,
1913 )
1914 }
1915
1916 /// C `dbGetTimeStampTag` (`dbLink.c:420-432`) — the single owner of "read
1917 /// a link's source timestamp", dispatched to the target's lset.
1918 /// `dbDbGetTimeStampTag` (`dbDbLink.c`) copies the source record's `time`
1919 /// and `utag`; the CA lset answers from its cached monitor and the CA wire
1920 /// carries no userTag, so it contributes 0.
1921 ///
1922 /// The tag is always returned; C's callers differ only in whether they ask
1923 /// for it. `recGbl.c:317` passes `&prec->utag`, while every `std/dev` soft
1924 /// input dset reaches this through the `dbGetTimeStamp` macro
1925 /// (`dbLink.c:415-418`), which passes NULL — so those callers DROP the tag,
1926 /// and this port drops it at the same call sites C does.
1927 ///
1928 /// `None` is C's non-zero return (`S_db_noLSET`, or an unresolvable
1929 /// target). A `pvalink` is deliberately absent: pvxs gates its lset's
1930 /// timestamp behind the link's own `time=true` option, which reaches the
1931 /// record through [`Self::external_link_time`] instead.
1932 fn db_get_time_stamp_tag(
1933 &self,
1934 link: &crate::server::record::ParsedLink,
1935 ) -> Option<(std::time::SystemTime, u64)> {
1936 match link {
1937 crate::server::record::ParsedLink::Db(l) => {
1938 self.record_time_stamp_tag(&l.target().record)
1939 }
1940 crate::server::record::ParsedLink::Ca(ca) => self
1941 .external_link_time(&format!("ca://{}", ca.pv))
1942 .map(ext_time_pair),
1943 // `lnkCalc_getTimestampTag` (`lnkCalc.c:749-762`) answers from
1944 // `clink->time`/`clink->utag`, and the only thing that ever fills
1945 // those is `lnkCalc_getValue`/`lnkCalc_putValue` reading the
1946 // `time:"X"` input through `dbGetTimeStampTag` on that child link
1947 // (`:571-576`, `:651-656`). A calc link's timestamp is therefore
1948 // its time-input's, resolved by the same locality rule as any
1949 // other link — which is why this recurses into the owner instead
1950 // of re-deriving it. `tinp < 0` (no `time` key) is C's `return
1951 // -1` at `:761`.
1952 //
1953 // C caches the pair on the link at read time and answers later
1954 // reads from that cache; this port holds no per-link state, so it
1955 // resolves the source live. The two differ only when the source
1956 // is restamped between the calc read and the timestamp fetch —
1957 // microseconds apart inside one `process_record_with_links_body`.
1958 crate::server::record::ParsedLink::Calc(calc) => {
1959 let idx = (calc.time_source? as u8 - b'A') as usize;
1960 let arg = calc.args.get(idx)?;
1961 // `args[i]` names a record only when it is a link; a numeric
1962 // literal has no timestamp to adopt, and C's `readLocked`
1963 // runs it against a zeroed child link, leaving `clink->time`
1964 // at its `calloc` zero (`lnkCalc.c:571-575`). The `.FIELD`
1965 // suffix is stripped because the timestamp belongs to the
1966 // RECORD either way, as `dbDbGetTimeStampTag`
1967 // (`dbDbLink.c:362-370`) reads `dbChannelRecord(chan)->time`
1968 // and not the addressed field's.
1969 let record = Self::calc_time_source_record(arg)?;
1970 self.record_time_stamp_tag(&record)
1971 }
1972 _ => None,
1973 }
1974 }
1975
1976 /// The locality half of [`Self::db_get_time_stamp_tag`], shared by every
1977 /// link class that names a record: `dbInitLink` (`dbLink.c:115-130`)
1978 /// makes a DB-style link naming a record this IOC does not hold a CA
1979 /// link, so its timestamp comes from the CA lset's cached monitor and
1980 /// carries no userTag.
1981 fn record_time_stamp_tag(&self, record: &str) -> Option<(std::time::SystemTime, u64)> {
1982 if self.has_name_no_resolve(record) {
1983 let src = self.get_record(record)?;
1984 let g = src.read();
1985 Some((g.common.time, g.common.utag))
1986 } else {
1987 self.external_link_time(&format!("ca://{record}"))
1988 .map(ext_time_pair)
1989 }
1990 }
1991
1992 /// C `recGblGetTimeStampSimm`'s TSEL half (`recGbl.c:315-323`): read the
1993 /// record's `TSEL` link as the `.TIME` form (`TIME`/`UTAG`) or as a `TSE`
1994 /// source (every other form).
1995 ///
1996 /// Reads only — the store and the `TSE`→`TIME` lookup that follows it in C
1997 /// are `TselStamp::stamp`, which cannot be reached without the value this
1998 /// returns. Call it at the record's stamp point, not at the head of the
1999 /// cycle: C reads `TSEL` inside `recGblGetTimeStamp`, so a `.TIME` TSEL
2000 /// sees whatever the cycle has already done to its source — `calcRecord.c`
2001 /// runs `fetch_values` (`:120`) before the stamp (`:127`), so an `INPn PP`
2002 /// that reprocessed the TSEL source moves the stamp this record adopts.
2003 /// The link read takes its own locks (a failed `dbGetLink` writes
2004 /// `LINK_ALARM` into this record), so it must not run under the caller's
2005 /// data guard — which is the whole reason C's single function is two here.
2006 fn read_tsel(&self, rec: &Arc<parking_lot::RwLock<RecordInstance>>) -> super::TselStamp {
2007 let tsel_link = {
2008 let instance = rec.read();
2009 instance.parsed_tsel.clone()
2010 };
2011 // A TSEL link pointing at a `.TIME` field copies that record's
2012 // timestamp+utag into `time`/`utag`, and the TSE→TIME half does not
2013 // run at all — C returns before it, leaving TSE alone.
2014 // C `TSEL_modified`
2015 // (dbLink.c:71-87) sets `DBLINK_FLAG_TSELisTIME` for ANY
2016 // `PV_LINK` tsel whose pvname contains `.TIME`, set BEFORE the
2017 // DB-vs-CA decision (dbLink.c:118) — so a local-DB link AND a
2018 // CA link both qualify. `recGblGetTimeStampSimm`
2019 // (recGbl.c:316-321) then copies the link's time+utag via
2020 // `dbGetTimeStampTag` and RETURNS, never loading TSE from the
2021 // value (even when the read fails). A pva link is a
2022 // `JSON_LINK` and returns early from `dbInitLink`
2023 // (dbLink.c:107) before `TSEL_modified`, so C never flags it;
2024 // pva TSEL `.TIME` is intentionally excluded here.
2025 //
2026 // The field comes from the SPLIT, not from the link's raw halves: C
2027 // truncates the pvname at `.TIME` (`strstr` then `*pfieldname = 0`,
2028 // dbLink.c:81-85), so `TSEL="SRC.TIME[0]"` is flagged TSELisTIME and
2029 // the filter is discarded with the rest of the tail. The raw halves
2030 // leave that link as record `SRC.TIME[0]` with field `VAL`, which is
2031 // neither `.TIME` nor a record — the flag was never set and the
2032 // record stamped itself.
2033 let tsel_is_time = match &tsel_link {
2034 crate::server::record::ParsedLink::Db(link) => {
2035 link.target().field.eq_ignore_ascii_case("TIME")
2036 }
2037 crate::server::record::ParsedLink::Ca(ca) => ca_tsel_time_record(&ca.pv).is_some(),
2038 _ => false,
2039 };
2040 if tsel_is_time {
2041 // C `dbGetTimeStampTag(plink, &prec->time, &prec->utag)`
2042 // (recGbl.c:317) copies BOTH the link's time AND utag —
2043 // through the owner, which returns the pair as one
2044 // consistent snapshot of the source.
2045 //
2046 // `TSEL_modified` strips `.TIME` from the pvname BEFORE the
2047 // DB-vs-CA decision (dbLink.c:115-118), so the link the
2048 // owner reads is the one addressing the source RECORD, not
2049 // its `.TIME` field.
2050 let src_time = match &tsel_link {
2051 crate::server::record::ParsedLink::Db(_) => self.db_get_time_stamp_tag(&tsel_link),
2052 crate::server::record::ParsedLink::Ca(ca) => match ca_tsel_time_record(&ca.pv) {
2053 Some(rec_name) => self.db_get_time_stamp_tag(
2054 &crate::server::record::ParsedLink::Ca(crate::server::record::CaLink {
2055 pv: rec_name.to_string(),
2056 ..ca.clone()
2057 }),
2058 ),
2059 None => None,
2060 },
2061 _ => None,
2062 };
2063 // C returns after the TSELisTIME branch even when the read
2064 // fails (recGbl.c:317-320): keep the record's current time
2065 // rather than falling through to load TSE from the value.
2066 match src_time {
2067 Some((src_time, src_utag)) => super::TselStamp::Time(src_time, src_utag),
2068 None => super::TselStamp::None,
2069 }
2070 } else if let Some(val) = self.db_get_link(rec, "TSEL", &tsel_link).value() {
2071 // Non-`.TIME` TSEL: C `dbGetLink(&tsel, DBR_SHORT,
2072 // &prec->tse)` loads TSE from the link regardless of its
2073 // type. The pre-fix port only read a `ParsedLink::Db`
2074 // TSEL, ignoring a CA/PVA TSE source — and then over-corrected
2075 // by handing back a CONSTANT TSEL's text every cycle, which C
2076 // never does: `recGblGetTimeStampSimm` (`recGbl.c:315`) is
2077 // wrapped in `if (!dbLinkIsConstant(plink))`, so a constant
2078 // TSEL is skipped outright and TSE keeps its own value. Through the
2079 // coercion owner: the conversion routine is C's, chosen by the
2080 // SOURCE type (see the DISA read above).
2081 super::TselStamp::Tse(val.to_dbf_i16().unwrap_or(0))
2082 } else {
2083 super::TselStamp::None
2084 }
2085 }
2086
2087 /// C `recGblGetTimeStamp` (`recGbl.c:305-308`) in full — the TSEL read
2088 /// followed by the TSE→TIME event lookup, for a soft record.
2089 ///
2090 /// The pair is spelled out at each stamp point that has its own data guard
2091 /// open; this is the entry for the callers that do not — `seq`, whose C
2092 /// `process` calls `recGblGetTimeStamp` once per link group
2093 /// (`seqRecord.c:261`).
2094 pub(crate) fn rec_gbl_get_time_stamp(&self, rec: &Arc<parking_lot::RwLock<RecordInstance>>) {
2095 let tsel = self.read_tsel(rec);
2096 let mut instance = rec.write();
2097 let inst = &mut *instance;
2098 tsel.stamp(&inst.name, &mut inst.common, /* is_soft */ true);
2099 }
2100
2101 /// The record process cycle itself — C `dbProcess`'s body
2102 /// (`dbAccess.c:537-700`), entered with the record's advisory write gate
2103 /// already held (or deliberately not held, for the recursive /
2104 /// already-locked entries).
2105 ///
2106 /// **This function and everything it calls is synchronous.** That is the
2107 /// H6 contract: the gate-held region must contain no suspension point,
2108 /// because the gate is about to become a blocking priority-inheritance
2109 /// mutex and a suspended task holding it would deadlock the executor.
2110 /// Where C's `dbProcess`
2111 /// cannot finish inline it sets `PACT` and RETURNS, releasing
2112 /// `dbScanLock`, and the device callback re-takes the lock later
2113 /// (`dbAccess.c:611-628`, `dbNotify.c:252-264`); every deferred step here
2114 /// does the same — it stages work on a queue or spawns a task and returns.
2115 #[allow(clippy::too_many_arguments)]
2116 fn process_record_with_links_body(
2117 &self,
2118 name: &str,
2119 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
2120 visited: &mut HashSet<String>,
2121 is_continuation: bool,
2122 device_callback: bool,
2123 ) -> CaResult<()> {
2124 let rec = rec.clone();
2125
2126 // 0a. PACT entry guard — C `dbProcess`'s PACT test (dbAccess.c:536,
2127 // 557-558 at R7.0.10). If the record is currently mid-async, do NOT
2128 // re-enter the body; hand the refusal to `count_refused_active_entry`,
2129 // which owns the counting and the alarm for both of the port's
2130 // "active" tests.
2131 //
2132 // Without this guard, FLNK / scan-loop / event scans dispatched onto
2133 // a record whose first cycle is still pending (async device support,
2134 // CA put_notify on PUTF) would re-enter `record.process()` while the
2135 // device's first response is still in flight — corrupting the
2136 // record's internal state machine and bypassing the C-parity
2137 // contract that callers see for `dbProcess`. This is where the port
2138 // decides what an ASYNC-active record does with a foreign process
2139 // request; `process_one_cp_target` used to pre-empt it with an
2140 // RPRO-and-skip of its own, which is how a starved CP target got an
2141 // extra device write instead of C's SCAN_ALARM.
2142 if !is_continuation {
2143 let active = {
2144 let mut instance = rec.write();
2145 if instance.is_processing() {
2146 true
2147 } else {
2148 // Not pact: reset lcnt (C `else { precord->lcnt = 0; }`
2149 // at dbAccess.c:558) so the next async cycle starts clean.
2150 instance.common.lcnt = 0;
2151 false
2152 }
2153 };
2154 if active {
2155 self.count_refused_active_entry(&rec);
2156 return Ok(());
2157 }
2158 }
2159
2160 // C reads a link-backed field's metadata live inside the rset, under
2161 // the TARGET record's lock (`dbDbLink.c:240-261`). A poster here holds
2162 // THIS record's lock and cannot reach for a second one, so the cycle
2163 // resolves once at this point — where it holds no record lock — and
2164 // hands every poster below the borrowed result. The borrow is what
2165 // makes "the metadata a monitor carries was resolved during this
2166 // cycle" true by construction: there is nowhere to keep it.
2167 //
2168 // Empty, after one uncontended read lock, for every record type that
2169 // backs no field's metadata with a link — all but calc, calcout, sub,
2170 // aSub and seq.
2171 let link_backing = self.resolve_link_backed_metadata(&rec);
2172 let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
2173
2174 // 0. SDIS disable check — C parity dbAccess.c:562-592.
2175 //
2176 // When the SDIS link evaluates to a value equal to DISV, the
2177 // record is disabled and bails before record support runs. C
2178 // ALWAYS clears rpro/putf and triggers dbNotifyCompletion at
2179 // this point — regardless of whether the alarm transition
2180 // fires — because a disabled record must not leave behind
2181 // pending reprocess requests or stranded put_notify completion
2182 // callbacks. Pre-fix the Rust port only reset
2183 // nsta/nsev and updated the alarm state, leaking rpro/putf
2184 // into the next cycle and stalling CA WRITE_NOTIFY callers
2185 // (the put_notify_tx never fired so the CA dispatcher waited
2186 // until socket disconnect to release the operation).
2187 {
2188 let (sdis_link, disv, diss) = {
2189 let instance = rec.read();
2190 (
2191 instance.parsed_sdis.clone(),
2192 instance.common.disv,
2193 instance.common.diss,
2194 )
2195 };
2196
2197 // C `dbGetLink(&precord->sdis, DBR_SHORT, &precord->disa, 0, 0)`
2198 // (`dbAccess.c:566`) reads the SDIS link regardless of its type
2199 // (DB / CA / PVA / constant) via the lset — so it goes through the
2200 // one classifier. A CONSTANT SDIS delivers NOTHING
2201 // (`dbConstGetValue`), and dbCommon has no `recGblInitConstantLink`
2202 // for SDIS, so DISA keeps its `initial(0)`: `field(SDIS,"3")` with
2203 // `DISV=3` does NOT disable the record in C (softIoc-verified).
2204 // Handing back the constant here disabled it forever.
2205 if let Some(val) = self.db_get_link(&rec, "SDIS", &sdis_link).value() {
2206 // C `dbGetLink(&prec->sdis, DBR_SHORT, &prec->disa)` — the routine
2207 // is picked by the SOURCE type, so this goes through the coercion
2208 // owner, not `c_cast` direct (an integer SDIS source takes C's
2209 // defined modular conversion; only a float source takes the UB
2210 // cast).
2211 let disa_val = val.to_dbf_i16().unwrap_or(0);
2212 let mut instance = rec.write();
2213 instance.common.disa = disa_val;
2214 }
2215
2216 let disa = rec.read().common.disa;
2217 if disa == disv {
2218 let notify = {
2219 let mut instance = rec.write();
2220 // C `dbAccess.c:575-577` — clear rpro/putf and arm
2221 // notifyCompletion BEFORE the alarm check. Disabled
2222 // records skip processing entirely, so any pending
2223 // reprocess request is dropped (the next non-
2224 // disabled cycle will pick up fresh state) and the
2225 // CA put-notify caller must be released. A disabled
2226 // record drives no FLNK/OUT chain, so leaving the
2227 // wait-set here is its whole contribution.
2228 instance.common.rpro = 0;
2229 instance.common.putf = false;
2230 let notify = instance.notify.take();
2231
2232 // Reset nsta/nsev so stale alarm state doesn't bleed
2233 // into a subsequent (re-enabled) cycle. C resets
2234 // them after the sevr/stat transition; doing it
2235 // first here is observationally identical because
2236 // the SDIS bail short-circuits any record-support
2237 // path that could read them.
2238 instance.common.nsta = 0;
2239 instance.common.nsev = crate::server::record::AlarmSeverity::NoAlarm;
2240
2241 // C `dbAccess.c:580-581` — if already in
2242 // DISABLE_ALARM, the alarm post is skipped entirely
2243 // (the alarm cycle is debounced). The rpro/putf
2244 // clear above still ran, matching C's pre-`goto
2245 // all_done` ordering.
2246 if instance.common.stat != crate::server::recgbl::alarm_status::DISABLE_ALARM {
2247 use crate::server::recgbl::EventMask;
2248 instance.common.sevr =
2249 crate::server::record::AlarmSeverity::from_u16(diss as u16);
2250 instance.common.stat = crate::server::recgbl::alarm_status::DISABLE_ALARM;
2251 // C `dbAccess.c:586-593` posts each field with
2252 // its own mask:
2253 // db_post_events(&stat, DBE_VALUE);
2254 // db_post_events(&sevr, DBE_VALUE);
2255 // db_post_events(&val, DBE_VALUE|DBE_ALARM);
2256 // STAT/SEVR get DBE_VALUE only — a DBE_ALARM-only
2257 // subscriber on `.STAT`/`.SEVR` must NOT receive
2258 // this disable event. Only the value field
2259 // carries DBE_ALARM.
2260 instance.notify_field("STAT", EventMask::VALUE);
2261 instance.notify_field("SEVR", EventMask::VALUE);
2262 instance.notify_field("VAL", EventMask::VALUE | EventMask::ALARM);
2263 }
2264 notify
2265 };
2266 // Fire dbNotifyCompletion outside the record lock —
2267 // C `dbAccess.c:622-623` runs it at `all_done` after
2268 // the disable bail. Without this, a CA WRITE_NOTIFY
2269 // landing on a disabled record stalls until socket
2270 // disconnect. `leave` fires the completion oneshot when
2271 // this empties the wait-set.
2272 if let Some(ws) = notify {
2273 ws.leave();
2274 }
2275 return Ok(());
2276 }
2277 }
2278
2279 // 0.4. The dset gate — the FIRST statement of every C `process()`
2280 // that needs device support:
2281 //
2282 // ```c
2283 // if( (pdset==NULL) || (pdset->read_ai==NULL) ) {
2284 // prec->pact=TRUE;
2285 // recGblRecordError(S_dev_missingSup, prec, "read_ai");
2286 // return(S_dev_missingSup);
2287 // }
2288 // ```
2289 // (`aiRecord.c:143-147`, and the same four lines in 19 more
2290 // `<rec>Record.c` files.) It sits here, after `dbProcess`'s PACT test
2291 // and the SDIS disable bail and before anything of the body, because
2292 // that is where C's is: `dbProcess` reaches `prset->process` only past
2293 // those two, and `process` refuses on its first line.
2294 //
2295 // This is not a message. The PACT it takes is never released — the
2296 // only release is a cycle tail this record never reaches — so the
2297 // record is inert from its first process attempt onward, exactly as it
2298 // is in C, and every later attempt is turned away by the PACT guard
2299 // above without a second report. Reporting without taking PACT would
2300 // have printed C's line over a record that then went on processing:
2301 // measured against `softIoc` R7.0.10 on `asyn`'s `testErrors` IOC, C
2302 // leaves `testErrors:AoInt32` at `PACT 1`, `STAT UDF`, `TIME
2303 // <undefined>` where this port left it `PACT 0`, `STAT NO_ALARM` and
2304 // stamped.
2305 //
2306 // The gate is `dev_sup_process_refusal`, which is `None` for every
2307 // record type whose C `process()` has no dset test — `calc`, `sub`,
2308 // `fanout`, and `calcout`, which refuses only at init.
2309 {
2310 let refusal = {
2311 let instance = rec.read();
2312 if crate::server::device_support::is_soft_dtyp(&instance.common.dtyp)
2313 || instance.device.is_some()
2314 {
2315 None
2316 } else {
2317 crate::server::recgbl::dev_sup_process_refusal(instance.record.record_type())
2318 }
2319 };
2320 if let Some(message) = refusal {
2321 let notify = {
2322 let mut instance = rec.write();
2323 instance.enter_pact();
2324 // C returns from `process()` without reaching
2325 // `recGblFwdLink`, so its `dbNotifyCompletion` never fires
2326 // and a put-notify parked on such a record waits for a
2327 // cycle that will never come. Releasing the wait-set is
2328 // the same thing the SDIS bail above does, and for the
2329 // same reason: a CA WRITE_NOTIFY caller must not be held
2330 // to a socket timeout by a record that has already decided
2331 // not to run.
2332 instance.notify.take()
2333 };
2334 crate::server::recgbl::rec_gbl_record_error(
2335 &crate::server::recgbl::DevSupStatus::MissingSup.text(),
2336 name,
2337 message,
2338 );
2339 if let Some(ws) = notify {
2340 ws.leave();
2341 }
2342 return Ok(());
2343 }
2344 }
2345
2346 // 0.5. Simulation mode check.
2347 //
2348 // C handles simulation inside `readValue()` / `writeValue()` — the
2349 // device-I/O step — then `process()` ALWAYS runs the rest of the
2350 // body (`convert` / OROC / the record's own state machine) plus
2351 // `checkAlarms` / `monitor` / `recGblFwdLink(prec)`. SIMM replaces
2352 // ONLY the device read/write, never the body. The substitution
2353 // point differs by direction: an INPUT `readValue()` precedes the
2354 // body, so `Simulated` does the SIOL read here and short-circuits;
2355 // an OUTPUT `writeValue()` follows the body, so
2356 // `RedirectOutputToSiol` falls through to run the uniform body and
2357 // redirects only the final output write to SIOL (see below). Either
2358 // way the forward-link / CP / RPRO tail still runs — returning early
2359 // without it would silently break every FLNK / CP chain downstream
2360 // of any record in SIMM mode.
2361 //
2362 // `sim_output` carries the OUTPUT redirect (SIOL link, SIMS, RAW
2363 // flag) from this point to the OUT stage / alarm epilogue below;
2364 // `None` for a non-simulated record or a simulated INPUT.
2365 // The cycle's simulation state, pushed to the record before the body —
2366 // the twin of `set_fetch_gate_failed`. Written on EVERY cycle of a record
2367 // that declares the input-stage shape (`false` included), so the flag
2368 // cannot outlive the cycle it belongs to.
2369 let mut sim_input_stage = false;
2370 // C `writeValue` returned before performing ANY output. `writeValue`
2371 // runs at the END of C `process()`, so the body has already run and
2372 // only the device / OUT-link / SIOL write is lost. Two C paths reach
2373 // it, and both mean exactly this one thing:
2374 // * `switch (prec->simm)` `default:` — `recGblSetSevr(SOFT_ALARM,
2375 // INVALID_ALARM); return -1;` (`SimOutcome::IllegalMode`)
2376 // * a failed SIML read — `if (status) return status;`
2377 // (`SimOutcome::AbortedBeforeWrite`, busyRecord.c:399-401)
2378 let mut sim_write_aborted = false;
2379 // The PACT the SDLY defer held, released by the SIM continuation arms —
2380 // carried to whichever `recGblFwdLink` tail this cycle ends at, so the
2381 // put-notify parked on that window is replayed there (C
2382 // `dbNotifyCompletion`) instead of being stranded.
2383 let (sim_outcome, sim_pact_exit) = self.check_simulation_mode(&rec);
2384 // Every exit below this line owes C's `recGblFwdLink` tail. The guard
2385 // owns that debt so no path can leave without either paying it or
2386 // saying, at the site, that it is handing the cycle to someone else.
2387 let mut cycle_end = CycleEndGuard::new(self, name, &rec);
2388 cycle_end.merge_in(sim_pact_exit);
2389 let sim_output = match sim_outcome {
2390 SimOutcome::NotSimulated => None,
2391 SimOutcome::Simulated(posts) => {
2392 self.run_forward_link_tail(name, &rec, posts, visited);
2393 self.end_process_cycle(name, &rec, cycle_end.take());
2394 return Ok(());
2395 }
2396 SimOutcome::AbortedBeforeWrite => {
2397 // C busy `writeValue`: `status = dbGetLink(&prec->siml, ...);
2398 // if (status) return status;` — the SIML read failed, so the
2399 // routine returns before `write_busy` AND before the SIOL
2400 // redirect. `dbGetLink` has already raised LINK_ALARM/INVALID.
2401 sim_write_aborted = true;
2402 None
2403 }
2404 SimOutcome::IllegalMode { is_output } => {
2405 if is_output {
2406 // `writeValue` follows the body, so only the write is lost.
2407 sim_write_aborted = true;
2408 None
2409 } else {
2410 // `readValue` precedes the body and IS the body's input, so
2411 // nothing of the body is left to run. SOFT_ALARM/INVALID is
2412 // already pending; commit it, post the monitors and fire the
2413 // forward link — C `process()` runs `checkAlarms`,
2414 // `monitor()` and `recGblFwdLink()` regardless of the -1.
2415 let tsel = self.read_tsel(&rec);
2416 let posts = {
2417 let mut instance = rec.write();
2418 sim_process_tail(&mut instance, tsel, false, link_backing)
2419 };
2420 self.run_forward_link_tail(name, &rec, posts, visited);
2421 self.end_process_cycle(name, &rec, cycle_end.take());
2422 return Ok(());
2423 }
2424 }
2425 SimOutcome::SimulatedInputStage => {
2426 sim_input_stage = true;
2427 None
2428 }
2429 SimOutcome::DeferRead(delay) => {
2430 // C `readValue`/`writeValue` async path: hold PACT and
2431 // schedule the SIOL round-trip `SDLY` seconds out. Post
2432 // nothing this cycle — C `process()` returns 0 on the
2433 // async-start pass (`if (!pact && prec->pact) return 0`), so
2434 // no value, no alarm, no monitor, no forward link. The
2435 // continuation re-enters via `process_record_continuation`
2436 // (`is_continuation = true`) and runs the synchronous branch
2437 // + tail. The PACT hold is gated on the scheduled re-entry
2438 // that releases it, the same construction-time invariant as
2439 // the `ReprocessAfter` ODLY defers.
2440 {
2441 let instance = rec.write();
2442 instance.enter_pact();
2443 }
2444 self.schedule_delayed_reprocess(name, delay);
2445 // This arm is reachable only with PACT clear on entry, so nothing
2446 // can be queued; run the check through the single owner anyway so
2447 // no path drops a token blind.
2448 self.apply_pact_exit(name, &rec, cycle_end.take());
2449 return Ok(());
2450 }
2451 SimOutcome::RedirectOutputToSiol {
2452 siol,
2453 sims,
2454 raw_mode,
2455 } => Some((siol, sims, raw_mode)),
2456 };
2457 {
2458 let mut instance = rec.write();
2459 if instance.record.simulation_substitutes_input_stage() {
2460 instance.record.set_simulation_active(sim_input_stage);
2461 }
2462 }
2463
2464 // 1. Read INP link value and DOL link (outside lock)
2465 let (inp_parsed, is_soft, wants_source_time, dol_info) = {
2466 let instance = rec.read();
2467
2468 let inp = instance.parsed_inp.clone();
2469 let is_soft = crate::server::device_support::is_soft_dtyp(&instance.common.dtyp);
2470
2471 // C `vt.ptime = (dbLinkIsConstant(&prec->tsel) &&
2472 // prec->tse == epicsTimeEventDeviceTime) ? &prec->time : NULL`
2473 // — `devAiSoft.c:73-74`, and byte-for-byte the same in every one of
2474 // the 23 soft input dsets. TSE=-2 says "the device stamps this
2475 // record", and for a soft channel the device IS the INP link, so
2476 // `recGblGetTimeStampSimm` (recGbl.c:324-342) deliberately leaves
2477 // `time` alone and the dset is the only thing that fills it.
2478 //
2479 // The TSEL half is read here, ahead of the stamp point where
2480 // `read_tsel` runs, for the reason C can read it before
2481 // `recGblGetTimeStampSimm` does: this tests only whether the link
2482 // is CONSTANT, and a CONSTANT tsel is never loaded into TSE by
2483 // either — `recGbl.c:315` gates the `dbGetLink` on
2484 // `!dbLinkIsConstant` — so the two orders cannot disagree.
2485 let wants_source_time = instance.common.tse == -2
2486 && crate::server::recgbl::simm::is_constant(&instance.parsed_tsel);
2487
2488 // DOL link info for the records that perform C's SCALAR
2489 // closed-loop DOL fetch. Which records those are is
2490 // `Record::fetches_dol_closed_loop`, whose doc carries the C
2491 // citations and names the OMSL-bearing records that answer false.
2492 let dol = if instance.record.fetches_dol_closed_loop() {
2493 let omsl = instance
2494 .record
2495 .get_field("OMSL")
2496 .and_then(|v| v.to_menu_index())
2497 .unwrap_or(0);
2498 let oif = instance
2499 .record
2500 .get_field("OIF")
2501 .and_then(|v| v.to_menu_index())
2502 .unwrap_or(0);
2503 if omsl == 1 {
2504 let dol_parsed = instance
2505 .record
2506 .get_field("DOL")
2507 .and_then(|v| {
2508 if let EpicsValue::String(s) = v {
2509 Some(s)
2510 } else {
2511 None
2512 }
2513 })
2514 .map(|s| crate::server::record::parse_link_v2(s.as_str_lossy().as_ref()))
2515 .unwrap_or(crate::server::record::ParsedLink::None);
2516 // C `!dbLinkIsConstant(&prec->dol)` gates the per-cycle
2517 // DOL fetch in every OMSL record (e.g.
2518 // `aoRecord.c:181`, `boRecord.c:192`,
2519 // `dfanoutRecord.c:117`): a *constant* DOL is applied to
2520 // VAL exactly once at init via `recGblInitConstantLink`
2521 // and never re-sourced at process — so a client caput to
2522 // VAL is not clobbered every cycle. Only a real
2523 // (DB/CA/PVA) link is fetched here. The per-record init
2524 // application lives in each record's `init_record`.
2525 if matches!(dol_parsed, crate::server::record::ParsedLink::Constant(_)) {
2526 None
2527 } else {
2528 Some((dol_parsed, oif))
2529 }
2530 } else {
2531 None
2532 }
2533 } else {
2534 None
2535 };
2536
2537 (inp, is_soft, wants_source_time, dol)
2538 };
2539
2540 // 1.1. Pre-input-link actions: actions a record needs the
2541 // framework to execute BEFORE any input-link fetch this cycle.
2542 //
2543 // C `devEpidSoftCallback.c:120-151`: a DB-type readback-trigger
2544 // (TRIG) link is written with `dbPutLink` — which synchronously
2545 // processes the triggered source — and only then does
2546 // `dbGetLink(&pepid->inp, ...)` read CVAL. The trigger write
2547 // must land before the `INP -> CVAL` fetch, in the same pass.
2548 // `pre_process_actions` runs too late (after the input-link
2549 // fetch below), so `pre_input_link_actions` is a strictly
2550 // earlier hook. The record needs `dtyp` to decide whether the
2551 // callback DSET is active, so push the process context first.
2552 //
2553 // The ReadDbLink actions of this stage go through the reporting owner
2554 // (`execute_read_db_links`), not the fire-and-forget one: a failed read
2555 // here is a `dbGetLink` failure like any other, and the record must be
2556 // able to see it. C `aaoRecord.c::process` (167-168) aborts the whole
2557 // cycle when its closed-loop DOL fetch fails —
2558 // `if ((status = fetchValue(prec, 0))) return status;` returns BEFORE
2559 // `writeValue`, `monitor` and `recGblFwdLink` — which it can only do
2560 // because `fetchValue`'s `dbGetLink` status reaches it. Discarding the
2561 // outcome (as this stage did) let a dead DOL write a stale VAL to OUT,
2562 // post monitors and fire the forward link, every cycle, with no alarm.
2563 let mut pre_input_resolved: Vec<&'static str> = Vec::new();
2564 {
2565 let pre_input_actions = {
2566 let mut instance = rec.write();
2567 let ctx = instance.common.process_context();
2568 instance.record.set_process_context(&ctx);
2569 instance.record.pre_input_link_actions()
2570 };
2571 if !pre_input_actions.is_empty() {
2572 let (reads, others): (Vec<_>, Vec<_>) =
2573 pre_input_actions.into_iter().partition(|a| {
2574 matches!(a, crate::server::record::ProcessAction::ReadDbLink { .. })
2575 });
2576 if !reads.is_empty() {
2577 pre_input_resolved = self.execute_read_db_links(name, &rec, &reads, visited);
2578 }
2579 if !others.is_empty() {
2580 self.execute_process_actions(name, &rec, others, visited);
2581 }
2582 }
2583 }
2584
2585 // Read INP value, converted to the record's declared `dbrType`
2586 // request (stringin/lsi ask for `DBR_STRING`/`dbGetLinkLS` —
2587 // `devSiSoft.c:53`, `devLsiSoft.c:32` — so an ENUM/MENU source
2588 // delivers its state label, not the index).
2589 let inp_value = self
2590 .read_link_value_soft(&inp_parsed, is_soft, visited)
2591 .and_then(|v| self.typed_input_value(&rec, "INP", &inp_parsed, v));
2592
2593 // C `readLocked` (`devAiSoft.c:54-63`): the same `dbLinkDoLocked` that
2594 // read the value reads the source's timestamp, under the source's lock
2595 // and gated on the read having succeeded — `if (!status && pvt->ptime)
2596 // dbGetTimeStamp(pinp, pvt->ptime)`. The tag half is dropped because
2597 // `dbGetTimeStamp` passes NULL for it (`dbLink.c:415-418`).
2598 //
2599 // A `lnkCalc` INP is the one class where the tag DOES arrive: the
2600 // adoption is not the dset's at all but the link's own, and
2601 // `lnkCalc_getValue` writes `prec->time` AND `prec->utag`
2602 // (`lnkCalc.c:580-581`) under the identical `dbLinkIsConstant(&prec
2603 // ->tsel) && prec->tse == epicsTimeEventDeviceTime` gate that
2604 // `wants_source_time` already carries. So the pair the owner returns
2605 // is adopted whole for a calc link and time-only otherwise.
2606 let (inp_source_time, inp_source_utag): (Option<std::time::SystemTime>, Option<u64>) =
2607 if is_soft && wants_source_time && inp_value.is_some() {
2608 match self.db_get_time_stamp_tag(&inp_parsed) {
2609 Some((t, tag))
2610 if matches!(inp_parsed, crate::server::record::ParsedLink::Calc(_)) =>
2611 {
2612 (Some(t), Some(tag))
2613 }
2614 Some((t, _tag)) => (Some(t), None),
2615 None => (None, None),
2616 }
2617 } else {
2618 (None, None)
2619 };
2620
2621 // epics-base PR #d0cf47c: single-INP MS-class link must also
2622 // propagate the source record's STAT/SEVR/AMSG just like the
2623 // multi-input fetch loop below does. Previously the INPA..L
2624 // path (calc/sub/aSub/sel) propagated alarms but plain single
2625 // INP (ai/bi/longin/mbbi/stringin) silently dropped them —
2626 // downstream MSS readers saw NoAlarm even when the source was
2627 // INVALID. Only fires for soft-channel records: hardware-driver
2628 // alarms travel through device-support's own last_alarm path.
2629 //
2630 // B2: a soft INP that is an external `pva://` / `ca://` link
2631 // also propagates the lset's alarm. The link string carries
2632 // no `MonitorSwitch` (the `?sevr=MS` modifier is stripped by
2633 // the parser before epics-base-rs sees it), so the lset has
2634 // already applied the MS/NMS/MSI gate — a `Some` LinkAlarm
2635 // here is one the lset decided to propagate. We fold it in as
2636 // `MaximizeStatus` so the gated severity AND message both
2637 // reach `LINK_ALARM`, matching `pvxs/ioc/pvalink_lset.cpp`
2638 // `recGblSetSevrMsg`.
2639 let inp_link_alarm: Option<(
2640 crate::server::record::MonitorSwitch,
2641 super::links::LinkAlarm,
2642 )> = if is_soft {
2643 let (_v, alarm) = self.read_link_with_alarm(&inp_parsed);
2644 self.input_link_inheritance(name, &inp_parsed, alarm)
2645 } else {
2646 None
2647 };
2648
2649 // if the single-INP link is an external `pva://` /
2650 // `ca://` link configured with `time=true`, the lset returns
2651 // the latched upstream NT timestamp here and we adopt it
2652 // into the owning record's `common.time` and `common.utag`. The
2653 // lset gates the option internally (returns `None` unless
2654 // `time=true`), so a bare connected link without the flag still
2655 // produces local processing time. Mirrors pvxs
2656 // `pvxs/ioc/pvalink_lset.cpp:577-593`.
2657 let inp_link_remote_time: Option<(i64, i32, u64)> = match inp_parsed.external_pv_name() {
2658 Some(name) => self.external_link_time(&name),
2659 None => None,
2660 };
2661
2662 // Read DOL value. Through the input-fetch owner, so C's
2663 // `dbDbGetValue` inheritance tail runs on it like every other
2664 // process-time read: `field(DOL,"SRC MS")` on an OMSL=closed_loop
2665 // ao/bo/dfanout raises the READER to the source's severity
2666 // (softIoc: SRC in MAJOR -> A1 SEVR MAJOR, STAT LINK). A constant DOL
2667 // never reaches here (`dol_info` excludes it — the constant is seeded
2668 // once at init), so the PP-aware fetch is the right one.
2669 //
2670 // The three outcomes stay APART here. C's DOL read is a `dbGetLink`
2671 // whose non-zero status has effects beyond "no value arrived":
2672 // `setLinkAlarm` raises LINK/INVALID (owned by `db_get_input_link`),
2673 // and every OMSL record then gates its own body on the status —
2674 // `if(!status) convert(prec, value)` (aoRecord.c:188,
2675 // longoutRecord.c:155, int64outRecord.c:146) or `goto CONTINUE`
2676 // (mbboRecord.c:206, mbboDirectRecord.c:186). Collapsing `Failed` into
2677 // "no value" with `LinkFetch::value()` dropped BOTH: a dead DOL left
2678 // the client's last `caput` sitting in VAL, ran the forward convert on
2679 // it, and drove it to the output with no alarm at all.
2680 let dol_fetch: Option<crate::server::recgbl::simm::LinkFetch> =
2681 dol_info.as_ref().map(|(dol_parsed, _oif)| {
2682 // Converted to the record's declared request: stringout reads
2683 // DOL with `DBR_STRING` (`stringoutRecord.c:141`), lso via
2684 // `dbGetLinkLS` (`lsoRecord.c:114`) — an ENUM/MENU DOL source
2685 // delivers its label, not the index.
2686 let fetch = self.db_get_input_link(&rec, "DOL", dol_parsed, visited);
2687 self.convert_link_fetch(&rec, "DOL", dol_parsed, fetch).0
2688 });
2689 // C's `if (status)` on the closed-loop DOL read, read twice below: once
2690 // by the record's own failure arm at the DOL-apply site, once by the
2691 // timestamp gate (mbbo/mbboDirect's `goto CONTINUE` jumps past
2692 // `recGblGetTimeStampSimm`, mbboRecord.c:221).
2693 let dol_read_failed = matches!(
2694 dol_fetch,
2695 Some(crate::server::recgbl::simm::LinkFetch::Failed)
2696 );
2697
2698 // 1.45. Sel NVL link: resolve NVL -> SELN BEFORE the input fetch.
2699 // C `selRecord.c::fetch_values` reads NVL into SELN first, then in
2700 // `Specified` mode fetches ONLY INP[SELN] (lines 421-432) — the
2701 // non-selected inputs are never read. Resolving the selector here
2702 // (rather than after the fetch) lets `select_input_links` restrict
2703 // the fetch list, so non-selected links raise no monitors and no
2704 // spurious link-alarm SEVR.
2705 // A CONSTANT NVL is not a failed read: C `selRecord.c:99` seeds SELN
2706 // from it once at init (`recGblInitConstantLink(&nvl, DBF_USHORT,
2707 // &seln)`) and `dbGetLink` then delivers nothing every cycle, so
2708 // `fetch_values` succeeds and `do_sel` runs on the seeded SELN.
2709 let mut sel_nvl_read_failed = false;
2710 let sel_nvl_value: Option<EpicsValue> = {
2711 // Extract the NVL link spec under a scoped read guard, releasing it
2712 // (the parking_lot guard is !Send) before the async input fetch.
2713 let nvl_str = {
2714 let instance = rec.read();
2715 // C reads NVL ONLY in `Specified` mode: the `dbGetLink(&nvl,
2716 // ...)` at `selRecord.c:423` sits inside `if (prec->selm ==
2717 // selSELM_Specified)` and the all-inputs loop below it never
2718 // touches the link. So in High/Low/Median a dead NVL processes
2719 // no PP source and raises no `setLinkAlarm`, and SELN keeps
2720 // its value.
2721 if instance.record.record_type() == "sel"
2722 && matches!(instance.record.get_field("SELM"), Some(EpicsValue::Enum(0)))
2723 {
2724 instance
2725 .record
2726 .get_field("NVL")
2727 .and_then(|v| {
2728 if let EpicsValue::String(s) = v {
2729 Some(s)
2730 } else {
2731 None
2732 }
2733 })
2734 .unwrap_or_default()
2735 } else {
2736 Default::default()
2737 }
2738 };
2739 if !nvl_str.is_empty() {
2740 let parsed = crate::server::record::parse_link_v2(nvl_str.as_str_lossy().as_ref());
2741 let fetch = self.db_get_input_link(&rec, "NVL", &parsed, visited);
2742 sel_nvl_read_failed = !fetch.is_ok();
2743 fetch.value()
2744 } else {
2745 None
2746 }
2747 };
2748 // Selector index for `select_input_links`: the freshly-resolved NVL
2749 // value when present, else `None` (the hook falls back to the
2750 // record's current SELN).
2751 let sel_selector: Option<u16> = sel_nvl_value
2752 .as_ref()
2753 .and_then(|v| v.get_convert_f64())
2754 .map(|f| f as u16);
2755
2756 // 1.5. Multi-input link fetch (calc/calcout/sel/sub)
2757 // Also collect alarm info from source records for MS/NMS propagation.
2758 // (value field, value, store-raw) — `store_raw` marks a value a
2759 // string-class declared request produced, which must reach the field
2760 // as-is instead of through the numeric store funnel below.
2761 let multi_input_values: Vec<(String, EpicsValue, bool)>;
2762 let mut link_alarms: Vec<(
2763 crate::server::record::MonitorSwitch,
2764 super::links::LinkAlarm,
2765 )> = Vec::new();
2766 // Link fields whose fetch actually produced a value this cycle —
2767 // pushed to the record via `set_resolved_input_links` so its
2768 // `process()` can observe link-fetch success (C
2769 // `RTN_SUCCESS(dbGetLink(...))`). ONE list per cycle, covering every
2770 // framework-run input read: the pre-input stage (aao DOL, sseq SELL),
2771 // the `multi_input_links` fetch, and the pre-process ReadDbLink reads.
2772 let mut resolved_link_fields: Vec<&'static str> = pre_input_resolved;
2773 // This cycle's `fetch_values()` outcome — non-zero status in C, i.e.
2774 // "the record body must not run". Derived from the record's declared
2775 // `InputFetchPolicy` (see the loop below) and folded with the sel gate
2776 // into ONE boolean, which is then delivered to its single consumer:
2777 // `Record::set_fetch_gate_failed` for records that compute in their own
2778 // `process()` (calc/calcout/scalcout/acalcout/swait/sel), and
2779 // `RecordInstance::suppress_subroutine_run` for the two whose body is
2780 // the framework-dispatched subroutine (sub/aSub).
2781 let mut fetch_values_failed = false;
2782 // C `fetch_values`' `status` local, for the record types that return
2783 // it rather than an early/first-failure fold: it is assigned on EVERY
2784 // pass of the loop, so at `return(status)` it holds the LAST link's
2785 // status and an empty/constant link counts as a success.
2786 let mut last_input_read_failed = false;
2787 {
2788 let input_fetch_policy;
2789 // C `printfRecord.c:49-52` (`GET_PRINT`) is the ONE record whose
2790 // input fetch re-runs `recGblInitConstantLink` on every process, so
2791 // its constants DO deliver every cycle. Every other record fetches
2792 // with a plain `dbGetLink`, where a constant delivers nothing.
2793 let constants_deliver_at_process;
2794 // Whether a failed read here owes C's `setLinkAlarm`: true for every
2795 // record whose `fetch_values` is `dbGetLink`, false for swait's
2796 // `recDynLinkGet`, which answers a failure with READ_ALARM instead.
2797 let multi_input_is_db_get_link;
2798 let link_info: Vec<(String, &'static str, String, bool)> = {
2799 let instance = rec.read();
2800 input_fetch_policy = instance.record.input_fetch_policy();
2801 constants_deliver_at_process = instance.record.constant_inputs_deliver_at_process();
2802 multi_input_is_db_get_link = instance.record.multi_input_fetch_is_db_get_link();
2803 // Restrict to the record's active inputs this cycle (sel
2804 // `Specified` → only INP[SELN]); `None` = fetch every link.
2805 let links = instance
2806 .record
2807 .select_input_links(sel_selector)
2808 .unwrap_or_else(|| instance.record.multi_input_links().to_vec());
2809 links
2810 .iter()
2811 .map(|(lf, vf)| {
2812 let link_str = instance
2813 .record
2814 .get_field(lf)
2815 .and_then(|v| {
2816 if let EpicsValue::String(s) = v {
2817 Some(s)
2818 } else {
2819 None
2820 }
2821 })
2822 .unwrap_or_default();
2823 (
2824 link_str.as_str_lossy().into_owned(),
2825 *lf,
2826 vf.to_string(),
2827 instance.record.input_link_failure_is_inert(lf),
2828 )
2829 })
2830 .collect()
2831 }; // read lock dropped
2832 let mut results = Vec::new();
2833 for (link_str, link_field, val_field, failure_is_inert) in &link_info {
2834 // C assigns `status` on every pass; an unset link is a
2835 // `dbConstGetValue` success, so entering the pass clears it.
2836 last_input_read_failed = false;
2837 if !link_str.is_empty() {
2838 let parsed = crate::server::record::parse_link_v2(link_str);
2839 // C `dbGetLink`: a `ProcessPassive` DB input link
2840 // processes its passive source record before the
2841 // value is read. `read_link_with_alarm` does a bare
2842 // `get_pv`, so process the source here first —
2843 // matching the single-INP `read_link_value_soft`
2844 // path. Without this, calc/sel/sub/aSub INPA..INPL
2845 // PP links read a stale source value.
2846 if let crate::server::record::ParsedLink::Db(ref db) = parsed {
2847 self.process_passive_db_source(db, visited);
2848 }
2849 // The record's declared per-link request (printf reads a
2850 // `%s` slot with `DBR_STRING`, `printfRecord.c:291`) is
2851 // applied inside the owner, and a conversion miss is a
2852 // failed read there.
2853 let (fetch, alarm, store_raw) =
2854 if multi_input_is_db_get_link && !*failure_is_inert {
2855 self.db_get_link_deferred(&rec, link_field, &parsed)
2856 } else {
2857 self.db_try_get_link_deferred(&rec, link_field, &parsed)
2858 };
2859 let read_failed = !fetch.is_ok();
2860 // C never asked this link (`Record::input_link_failure_is_inert`),
2861 // so its failure is not a status: no alarm above, no gate
2862 // below, and it does not become the LAST status either.
2863 let skipped = read_failed && *failure_is_inert;
2864 last_input_read_failed = read_failed && !skipped;
2865 // `NoData` (a CONSTANT link) delivers nothing — the value
2866 // field keeps what the init-seed owner
2867 // (`rec_gbl_init_constant_links`) loaded into it, so a
2868 // client's `caput REC.A 99` survives every later process.
2869 // printf is the declared exception (see above).
2870 let value = match fetch {
2871 crate::server::recgbl::simm::LinkFetch::Value(v) => Some(v),
2872 crate::server::recgbl::simm::LinkFetch::NoData
2873 if constants_deliver_at_process =>
2874 {
2875 crate::server::recgbl::simm::constant_load_value(&parsed)
2876 }
2877 _ => None,
2878 };
2879 if let Some(value) = value {
2880 results.push((val_field.clone(), value, store_raw));
2881 }
2882 // "Resolved" is C's `RTN_SUCCESS(dbGetLink(...))` — status
2883 // 0 — which a CONSTANT link satisfies (it delivers nothing
2884 // and returns success). So a constant input counts as
2885 // resolved even though it wrote no value: `epidRecord.c:191`
2886 // clears UDF on exactly that, and `motorRecord.cc:1994`
2887 // does not fail its DOL pass on it.
2888 if !read_failed {
2889 resolved_link_fields.push(link_field);
2890 }
2891 // Multi-input alarm propagation, through the inheritance
2892 // owner (which applies the MS class and C's self-link
2893 // exclusion).
2894 if let Some(pair) = self.input_link_inheritance(name, &parsed, alarm) {
2895 link_alarms.push(pair);
2896 }
2897 // The record's declared fetch shape decides what a failed
2898 // read means. The failed link's own alarm is already folded
2899 // above in every shape: C's `dbGetLink` raises the MS
2900 // severity for the link it failed on before returning.
2901 if read_failed && !skipped {
2902 match input_fetch_policy {
2903 // C `transformRecord.c::process` (534-547): read on,
2904 // and compute anyway.
2905 InputFetchPolicy::ReadAll => {}
2906 // C `calcRecord.c::fetch_values` (427-443):
2907 // `if (status == 0) status = newStatus;` — the loop
2908 // runs to the end, so the inputs behind the failure
2909 // still refresh (and post), but the first failing
2910 // status is what `process` (:120) gates the calc on.
2911 InputFetchPolicy::ReadAllGateOnFailure => {
2912 fetch_values_failed = true;
2913 }
2914 // C `subRecord.c::fetch_values` (407-418):
2915 // `if (dbGetLink(plink, ...)) return -1;` — the loop
2916 // stops dead at the first failing link. Every input
2917 // behind it is never read, so its value field keeps
2918 // the previous cycle's value (no monitor, no PP of
2919 // that source, no link-alarm inheritance), and the
2920 // record body is skipped below.
2921 InputFetchPolicy::AbortOnFirstFailure => {
2922 fetch_values_failed = true;
2923 break;
2924 }
2925 // C `selRecord.c:434-436` keeps reading and lets
2926 // the LAST link decide; `last_input_read_failed`
2927 // carries that decision to the gate after the loop.
2928 InputFetchPolicy::ReadAllGateOnLastFailure => {}
2929 }
2930 }
2931 }
2932 }
2933 multi_input_values = results;
2934
2935 // C `selRecord.c::fetch_values` returns the status of its LAST
2936 // `dbGetLink` (`:434-437` assigns `status` unguarded every pass),
2937 // and `process` (`:114-116`) gates `do_sel` on it in EVERY mode.
2938 // The gate is "the last link read FAILED" — never "a link
2939 // delivered no value": `dbGetLink` on an unset OR constant link
2940 // returns success (`dbConstGetValue`), and the field it would have
2941 // written keeps its init-seeded value, which flows into `do_sel`.
2942 if matches!(
2943 input_fetch_policy,
2944 InputFetchPolicy::ReadAllGateOnLastFailure
2945 ) {
2946 fetch_values_failed = last_input_read_failed;
2947 }
2948 // `Specified` mode returns early on a failed NVL read
2949 // (`selRecord.c:423-425`), before any INP is touched. Only `sel`
2950 // reads NVL, so this needs no record-type test.
2951 fetch_values_failed |= sel_nvl_read_failed;
2952 }
2953 // 1.6. String-input link fetch — C `sCalcoutRecord.c::fetch_values`'s
2954 // SECOND loop (890-942), over INAA..INLL → AA..LL. It is a separate
2955 // loop here for the same reason it is one in C: it does not feed the
2956 // fetch gate (`return(0)` at :943, so a failing string link never
2957 // suppresses sCalcPerform), a failed read writes a diagnostic INTO the
2958 // value field instead of leaving it alone, and a multi-element
2959 // DBF_CHAR/DBF_UCHAR source is read as escaped text. See
2960 // `Record::string_input_links`.
2961 let string_input_values: Vec<(String, EpicsValue)>;
2962 {
2963 let link_info: Vec<(String, &'static str, &'static str)> = {
2964 let instance = rec.read();
2965 instance
2966 .record
2967 .string_input_links()
2968 .iter()
2969 .map(|(lf, vf)| {
2970 let link_str = instance
2971 .record
2972 .get_field(lf)
2973 .and_then(|v| {
2974 if let EpicsValue::String(s) = v {
2975 Some(s)
2976 } else {
2977 None
2978 }
2979 })
2980 .unwrap_or_default();
2981 (link_str.as_str_lossy().into_owned(), *lf, *vf)
2982 })
2983 .collect()
2984 }; // read lock dropped
2985 let mut results = Vec::with_capacity(link_info.len());
2986 for (link_str, link_field, val_field) in &link_info {
2987 // C (:895-911): an unset link is neither CA_LINK nor DB_LINK, so
2988 // neither `dbGetLink` branch runs, `status` stays 0, and the
2989 // string field keeps whatever was last put to it.
2990 if link_str.is_empty() {
2991 continue;
2992 }
2993 let parsed = crate::server::record::parse_link_v2(link_str);
2994 if let crate::server::record::ParsedLink::Db(ref db) = parsed {
2995 self.process_passive_db_source(db, visited);
2996 }
2997 // C `sCalcoutRecord.c:916` / `:934` read these with `dbGetLink`
2998 // like every other input, so a failed one raises `setLinkAlarm`
2999 // (LINK/INVALID, AMSG `field INAA`) even though `fetch_values`
3000 // itself returns 0 (`:941`) and never gates `sCalcPerform`.
3001 let (fetch, alarm, _raw) = self.db_get_link_deferred(&rec, link_field, &parsed);
3002 if let Some(pair) = self.input_link_inheritance(name, &parsed, alarm) {
3003 link_alarms.push(pair);
3004 }
3005 let text = match fetch {
3006 crate::server::recgbl::simm::LinkFetch::Value(value) => {
3007 string_link_text(&value)
3008 }
3009 // C (:894-911) only reads a CA_LINK or a DB_LINK; a
3010 // CONSTANT string link is never read and never seeded:
3011 // the `if (i < MAX_FIELDS)` gate around the seed
3012 // (`sCalcoutRecord.c:257-260`, under the comment "Don't
3013 // InitConstantLink the string links" at `:256`) skips
3014 // every string link, so `status` stays 0 and the string
3015 // field keeps what was last put to it — no diagnostic.
3016 crate::server::recgbl::simm::LinkFetch::NoData => continue,
3017 // C (:939-940): `epicsSnprintf(*psvalue, STRING_SIZE-1,
3018 // "%s:fetch(%s) failed", pcalc->name, sFldnames[i])` — the
3019 // failed fetch REPLACES the value with the diagnostic; the
3020 // previous string is not kept, and the record still computes.
3021 crate::server::recgbl::simm::LinkFetch::Failed => truncate_string_field(
3022 PvString::from(format!("{name}:fetch({val_field}) failed")),
3023 ),
3024 };
3025 results.push((val_field.to_string(), EpicsValue::String(text)));
3026 }
3027 string_input_values = results;
3028 }
3029
3030 // PR #d0cf47c continued: feed the INP alarm (if any) into the
3031 // same `link_alarms` list the lock-section iterates over. Order
3032 // doesn't matter — `rec_gbl_set_sevr_msg` takes the maximum
3033 // severity across all sources.
3034 if let Some(pair) = inp_link_alarm {
3035 link_alarms.push(pair);
3036 }
3037
3038 // aSub LFLG=READ: re-read the subroutine name from the SUBL link and,
3039 // if it changed, re-resolve the function — computed here, before the
3040 // process write lock, so the SUBL link read cannot deadlock against
3041 // this record (C `aSubRecord.c::fetch_values`). `None` for everything
3042 // that is not an aSub in READ mode.
3043 let asub_dynamic = self.resolve_asub_dynamic_subroutine(&rec);
3044
3045 // 2. Lock record, apply INP/DOL, process, evaluate alarms, build snapshot
3046 let (
3047 snapshot,
3048 flnk_name,
3049 process_actions,
3050 alarm_posts,
3051 result_is_defer_output,
3052 restamps_after,
3053 ) = 'epilogue: {
3054 // Segment A (guarded): apply DOL/INP/multi-input values, run the
3055 // device read, and collect pre-process ReadDbLink actions. The data
3056 // guard is released at the segment boundary below so the following
3057 // link-I/O awaits hold no `!Send` parking_lot guard (the record stays
3058 // claimed by the `processing` gate meanwhile — the signed-off
3059 // momentary release, uniform with the async paths that already
3060 // release the data lock across link I/O here).
3061 let (
3062 pre_actions,
3063 deferred_device_actions,
3064 is_soft,
3065 device_did_compute,
3066 read_produced_no_value,
3067 device_read_computed,
3068 ) = {
3069 let mut instance = rec.write();
3070 // One discriminant for "this cycle sourced no value", set by
3071 // either source: a failed soft-INP read, and a device support
3072 // returning C's negative `read_ai()` status (-1, -2). Both miss
3073 // C's `if (status == 0)` gate identically, so the UDF re-derive
3074 // below tests one condition rather than a per-source exception.
3075 let mut read_produced_no_value = false;
3076 // C `return 2` specifically: the dset wrote VAL. Kept apart
3077 // from `device_did_compute`, which the soft-INP branch also
3078 // sets — there the framework IS the dset (it is the port of
3079 // `devBiSoft.c::readLocked`) and owns the UDF clear, so the
3080 // record's dset-owns-UDF rule must not fire for it.
3081 let mut device_read_computed = false;
3082
3083 // Apply the closed-loop DOL read (OMSL=CLOSED_LOOP), keeping C's
3084 // three outcomes apart.
3085 //
3086 // `Failed` is C's non-zero `dbGetLink` status: the LINK/INVALID
3087 // alarm already rode in with the read, and the record's own
3088 // failure arm — `AoRecord::closed_loop_dol_read_failed` reverting
3089 // VAL to PVAL, every convert-bearing OMSL record suppressing this
3090 // cycle's convert — runs here.
3091 //
3092 // `NoData` is status 0 with the buffer untouched. A CONSTANT DOL
3093 // never reaches here at all (`dol_info` excludes it), so this is
3094 // the reader's own `default:` arm (no declared request for this
3095 // source class): nothing is attempted and nothing changes.
3096 if let Some(crate::server::recgbl::simm::LinkFetch::Failed) = dol_fetch {
3097 instance.record.closed_loop_dol_read_failed();
3098 }
3099 if let Some(crate::server::recgbl::simm::LinkFetch::Value(dol_val)) = dol_fetch {
3100 let oif = dol_info.as_ref().map(|(_, oif)| *oif).unwrap_or(0);
3101 if oif == 1 {
3102 // Incremental: C `fetch_value` (aoRecord.c:447-455) sets
3103 // `prec->val = prec->pval` first ("don't allow dbputs to
3104 // val field"), then `*pvalue += prec->val`, so the
3105 // increment is relative to PVAL — the last actual output —
3106 // not the current VAL a client may have just caput. OIF is
3107 // an ao-only field, so this branch always carries a PVAL.
3108 if let (Some(pval), Some(dol_f)) = (
3109 instance.record.get_field("PVAL").and_then(|v| v.to_f64()),
3110 dol_val.to_f64(),
3111 ) {
3112 let _ = instance.record.set_val(EpicsValue::Double(pval + dol_f));
3113 }
3114 } else {
3115 // Full: VAL = DOL value
3116 let _ = instance.record.set_val(dol_val);
3117 }
3118 // The closed-loop DOL read DEFINES the record — C sets UDF from
3119 // the value it just fetched, in the DOL branch itself:
3120 // `prec->udf = isnan(value)` (aoRecord.c:147, dfanoutRecord.c:121)
3121 // / `prec->udf = FALSE` (boRecord.c:162). For ao/bo this repeats
3122 // what the per-cycle clear below does; for dfanout — whose
3123 // `process()` touches UDF nowhere else — it is the ONLY definer,
3124 // which is why dfanout can opt out of the per-cycle clear.
3125 instance.common.udf = instance.record.value_is_undefined() as u8;
3126 }
3127
3128 // Apply INP value. "Soft Channel" sets VAL directly
3129 // (C `read_xxx` return 2, skip RVAL→VAL conversion).
3130 // "Raw Soft Channel" is a DIFFERENT DSET (`devXxxSoftRaw.c`): its
3131 // `read_xxx` puts the value in RVAL, applies the dset's MASK and
3132 // returns 0, so the record's own RVAL→VAL convert runs. Whether
3133 // that dset exists is the record type's answer, given by
3134 // `Record::raw_soft_input` returning `Some` — the dset table, not a
3135 // separate boolean that could disagree with it.
3136 let had_inp_value = inp_value.is_some();
3137 let mut soft_inp_applied = false;
3138 if let Some(inp_val) = inp_value {
3139 let raw = if crate::server::device_support::classify_soft(&instance.common.dtyp)
3140 == Some(crate::server::device_support::SoftDtyp::Raw)
3141 {
3142 instance
3143 .record
3144 .raw_soft_input(RawSoftEntry::Read, inp_val.clone())
3145 } else {
3146 None
3147 };
3148 match raw {
3149 // SoftRaw: value landed in RVAL; the record's RVAL->VAL
3150 // convert runs in `process()`, so VAL was NOT set here.
3151 Some(res) => {
3152 let _ = res;
3153 }
3154 None => {
3155 // The soft dset's `read_xxx` body. Only a
3156 // soft-channel record has one: a `lnkCalc` INP is
3157 // delivered above whatever the DTYP is
3158 // (`read_link_value_soft`), and a device record's
3159 // own dset has already run its filter.
3160 let _ = if is_soft {
3161 instance.record.soft_input_read(Some(inp_val))
3162 } else {
3163 instance.record.set_val(inp_val)
3164 };
3165 soft_inp_applied = true;
3166 }
3167 }
3168 }
3169 if !had_inp_value
3170 && is_soft
3171 && crate::server::recgbl::simm::is_constant(&inp_parsed)
3172 {
3173 // C `dbLinkIsConstant(&prec->inp)` at process. The load-once
3174 // rule (a constant delivers nothing here — it was loaded at
3175 // init) is the default and stays the default; the ONE soft
3176 // device support that re-reads its constant INP every process
3177 // is `devSASoft.c::read_sa` (subArray), which also re-subsets
3178 // on an EMPTY INP. `Record::read_constant_inp` is that
3179 // device-support-layer exception: every other record's default
3180 // returns false and nothing happens, exactly as before.
3181 let constant = crate::server::recgbl::simm::constant_load_value(&inp_parsed);
3182 if instance.record.read_constant_inp(constant) {
3183 soft_inp_applied = true;
3184 }
3185 } else if !had_inp_value
3186 && is_soft
3187 && matches!(
3188 inp_parsed,
3189 crate::server::record::ParsedLink::Db(_)
3190 | crate::server::record::ParsedLink::Ca(_)
3191 | crate::server::record::ParsedLink::Pva(_)
3192 | crate::server::record::ParsedLink::PvaJson(_)
3193 )
3194 {
3195 // A soft-channel `read_xxx` is a plain `dbGetLink` on INP
3196 // (`devAiSoft.c::read_ai` -> `dbGetLink(&prec->inp, ...)`), so a
3197 // failed read runs `setLinkAlarm` (dbLink.c:322) —
3198 // `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "field INP")`.
3199 // Route it through the `setLinkAlarm` owner so it carries C's
3200 // message: raising the severity without the AMSG text left the
3201 // operator with an INVALID/LINK record and a blank `.AMSG`.
3202 // ParsedLink::None and Constant don't reach this branch — the
3203 // former is "no link configured", the latter has its own
3204 // None-as-no-value semantics.
3205 crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, "INP");
3206 // C's failure arm — `devAiSoft.c:92` drops the dset's
3207 // "a read has completed" state so the next good reading is
3208 // taken unsmoothed.
3209 let _ = instance.record.soft_input_read(None);
3210 // …and tell the record, so "no value was sourced" stops
3211 // being indistinguishable from "no link is configured".
3212 // C `devSASoft.c::read_sa` (118-120) skips `subset()` on a
3213 // non-zero status and `subArrayRecord.c:148` turns that
3214 // status into UDF; without the report the record could only
3215 // see its own stale buffer and called itself defined.
3216 instance.record.soft_input_read_failed();
3217 read_produced_no_value = true;
3218 }
3219
3220 // Apply multi-input values (INPA..INPL -> A..L).
3221 //
3222 // Uses `put_field_internal`, not `put_field`: this is the
3223 // framework writing a resolved input-link value into a
3224 // record field, exactly like the `ReadDbLink` apply
3225 // (`execute_read_db_links` / `execute_process_actions`),
3226 // which already routes through `put_field_internal`. Some
3227 // records map an input link to a normally read-only field
3228 // — e.g. the epid record's `INP -> CVAL` — and `put_field`
3229 // rejects those with `ReadOnlyField`, silently dropping the
3230 // value. `put_field_internal` defaults to `put_field`, so
3231 // records with writable targets (calc/sub `A..L`) are
3232 // unaffected.
3233 // An ARRAY-valued link value is offered to the target field whole:
3234 // C's `fetch_values` hands `dbGetLink` a pointer to the target FIELD,
3235 // so the field decides how much of the source it takes. An array
3236 // field takes `nRequest` = its own element count with the tail
3237 // zero-filled (aCalcoutRecord.c:1097-1102 for INAA..INLL -> AA..LL);
3238 // a scalar field is a one-element destination, so it takes element 0
3239 // (`dbGetLink(..., DBR_DOUBLE, pvalue, 0, 0)`, calcRecord.c:434).
3240 // The numeric view answers None for every array variant, so routing
3241 // every value through it dropped array-valued links outright —
3242 // AA..LL never populated and the record calculated on an empty
3243 // array. The view is `get_convert_f64`, C's DBR_DOUBLE get row,
3244 // not `to_f64`: the two disagree on an empty DBF_STRING source.
3245 for (val_field, value, store_raw) in &multi_input_values {
3246 if *store_raw {
3247 // A string-class declared request (printf `%s`)
3248 // already produced the value the record asked for —
3249 // the numeric funnel below is the OTHER records'
3250 // `DBR_DOUBLE` request, not a store rule.
3251 let _ = instance.record.put_field_internal(val_field, value.clone());
3252 } else if value.is_array() {
3253 if instance
3254 .record
3255 .put_field_internal(val_field, value.clone())
3256 .is_ok()
3257 {
3258 continue;
3259 }
3260 // The target is a scalar field: element 0, as C's
3261 // one-element destination takes.
3262 if let Some(f) = value.first_element().and_then(|v| v.get_convert_f64()) {
3263 let _ = instance
3264 .record
3265 .put_field_internal(val_field, EpicsValue::Double(f));
3266 }
3267 } else if let Some(f) = value.get_convert_f64() {
3268 let _ = instance
3269 .record
3270 .put_field_internal(val_field, EpicsValue::Double(f));
3271 }
3272 }
3273
3274 // The set_resolved_input_links report is deferred until after
3275 // the pre-process ReadDbLink reads below, so the record sees
3276 // ONE per-cycle resolution list covering both fetch paths —
3277 // records reset per-cycle resolution state in that hook, so
3278 // it must not run twice with partial lists.
3279
3280 // Apply sel NVL -> SELN. SELN is DBF_USHORT (selRecord.dbd.pod:295),
3281 // an unsigned 0..65535 index. Carry the native unsigned value so a
3282 // link value in 32768..65535 is not lost to f64->i16 saturation
3283 // before it reaches the field's put.
3284 if let Some(nvl_val) = sel_nvl_value {
3285 // Same one-element-destination rule as the multi-input loop
3286 // above: C reads NVL with `dbGetLink(..., DBR_USHORT, &pse->seln,
3287 // 0, 0)` (selRecord.c), so an array-valued source contributes its
3288 // element 0 rather than being dropped by `to_f64`.
3289 let scalar = if nvl_val.is_array() {
3290 nvl_val.first_element()
3291 } else {
3292 Some(nvl_val)
3293 };
3294 if let Some(f) = scalar.and_then(|v| v.get_convert_f64()) {
3295 let _ = instance
3296 .record
3297 .put_field("SELN", EpicsValue::UShort(f as u16));
3298 }
3299 }
3300
3301 // Apply the string-input values (scalcout INAA..INLL -> AA..LL),
3302 // fetched in step 1.6 above. `put_field_internal` is the coercion
3303 // owner: it converts to the target field's declared `DbFieldType`,
3304 // which is `String` for every one of these.
3305 for (val_field, value) in string_input_values {
3306 let _ = instance.record.put_field_internal(&val_field, value);
3307 }
3308
3309 // Device support read (input records only, not output records).
3310 // Shadows the outer `is_soft` on purpose: that one asks "does
3311 // the framework own this record" (all three soft flavours),
3312 // this one asks "does the input dset return 2, do-not-convert"
3313 // — which is Plain and Async but NOT Raw. See
3314 // `device_support::SoftDtyp`.
3315 let is_soft = matches!(
3316 crate::server::device_support::classify_soft(&instance.common.dtyp),
3317 Some(
3318 crate::server::device_support::SoftDtyp::Plain
3319 | crate::server::device_support::SoftDtyp::Async
3320 )
3321 );
3322 let is_output = instance.record.can_device_write();
3323 let mut device_actions: Vec<crate::server::record::ProcessAction> = Vec::new();
3324 // C `devAiSoft.c:65` `read_ai` (and the other soft-channel
3325 // input `read_xxx`) ALWAYS returns 2 ("don't convert") for a
3326 // Soft-Channel input record — whether the value arrived via
3327 // an INP link or the INP link is constant/unset
3328 // (`dbLinkIsConstant` → `return 2`). Only `aiRecord.c:158`'s
3329 // `if (status==0) convert(prec)` runs RVAL→VAL conversion, so
3330 // for a plain Soft-Channel input record `convert()` must be
3331 // skipped unconditionally. Without this, a soft ai with no
3332 // INP would run `convert()` and clobber a preset VAL — e.g.
3333 // a preset NaN would be rewritten to 0.0, then the framework
3334 // UDF check (`value_is_undefined()`) would see a defined 0.0
3335 // and wrongly clear UDF. `SoftDtyp::Raw` is excluded above —
3336 // `devAiSoftRaw` returns 0 and deliberately wants the RVAL→VAL
3337 // convert.
3338 //
3339 // Gated on `soft_channel_skips_convert()` so this only
3340 // suppresses an `RVAL → VAL` convert step. Records such as
3341 // `epid` also override `set_device_did_compute` but treat it
3342 // as "skip the whole built-in compute" (the PID loop); they
3343 // return `false` here so a Soft-Channel `epid` still runs
3344 // `do_pid()` in `process()`.
3345 let soft_input_skips_convert =
3346 is_soft && !is_output && instance.record.soft_channel_skips_convert();
3347 let mut device_did_compute =
3348 (soft_inp_applied && is_soft) || soft_input_skips_convert;
3349 // Input records read every cycle (`!is_output`). An OUTPUT record
3350 // reads only on a driver-callback (`asyn:READBACK`) cycle: it pulls
3351 // the callback value into VAL here and the OUT stage below skips the
3352 // write — C `devAsynInt32.c::processBo` `getCallbackValue` readback
3353 // branch. A put/FLNK/scan cycle (`device_callback == false`) leaves
3354 // the output untouched here and writes below.
3355 if !is_soft && (!is_output || device_callback) {
3356 if let Some(mut dev) = instance.device.take() {
3357 // Push framework-owned common state (PHAS/TSE/TSEL/
3358 // UDF) so device support's read() can see it — C
3359 // device support reads `dbCommon` directly
3360 // (`devTimeOfDay.c:122` uses `psi->phas`).
3361 dev.set_process_context(&instance.common.process_context());
3362 match dev.read(&mut *instance.record) {
3363 Ok(read_outcome) => {
3364 let status = read_outcome.status;
3365 device_did_compute = status.skips_conversion();
3366 if status.read_failed() {
3367 read_produced_no_value = true;
3368 }
3369 device_read_computed = matches!(
3370 status,
3371 crate::server::device_support::DeviceReadStatus::Computed
3372 );
3373 // A C dset writes `prec->udf` itself, before
3374 // its `return` — `devBiSoft.c::readLocked` and
3375 // `devBiDbState.c:67` clear it, `devAsynInt32.c
3376 // :902` sets it. Ours cannot reach `dbCommon`
3377 // through the `&mut dyn Record` it holds, so the
3378 // framework — the single owner of the UDF
3379 // transition — applies what the outcome states,
3380 // HERE, before the record's own rule below: C's
3381 // order is dset first, `process()` second, and
3382 // for the record types that re-derive
3383 // unconditionally the second write is what wins.
3384 use crate::server::device_support::DeviceUdf;
3385 match read_outcome.udf() {
3386 DeviceUdf::Untouched => {}
3387 DeviceUdf::Defined => instance.common.udf = 0,
3388 DeviceUdf::Undefined => instance.common.udf = 1,
3389 }
3390 device_actions = read_outcome.actions;
3391 }
3392 Err(e) => {
3393 eprintln!("device read error on {}: {e}", instance.name);
3394 use crate::server::recgbl::{alarm_status, rec_gbl_set_sevr};
3395 rec_gbl_set_sevr(
3396 &mut instance.common,
3397 alarm_status::READ_ALARM,
3398 crate::server::record::AlarmSeverity::Invalid,
3399 );
3400 }
3401 }
3402 instance.device = Some(dev);
3403 }
3404 }
3405
3406 // Pre-process actions: execute ReadDbLink from device support and
3407 // record's pre_process_actions() BEFORE process() so the values
3408 // are immediately available. Matches C dbGetLink() semantics.
3409 let mut pre_actions = instance.record.pre_process_actions();
3410 // Also collect ReadDbLink from device actions
3411 let mut deferred_device_actions = Vec::new();
3412 for action in device_actions {
3413 if matches!(
3414 action,
3415 crate::server::record::ProcessAction::ReadDbLink { .. }
3416 ) {
3417 pre_actions.push(action);
3418 } else {
3419 deferred_device_actions.push(action);
3420 }
3421 }
3422 (
3423 pre_actions,
3424 deferred_device_actions,
3425 is_soft,
3426 device_did_compute,
3427 read_produced_no_value,
3428 device_read_computed,
3429 )
3430 };
3431
3432 // await 1 (guard-free): pre-process ReadDbLink resolution. `name` is
3433 // the record's resolved canonical name (== `instance.name`).
3434 if !pre_actions.is_empty() {
3435 let pre_resolved = self.execute_read_db_links(name, &rec, &pre_actions, visited);
3436 resolved_link_fields.extend(pre_resolved);
3437 }
3438
3439 // Segment B (guarded): apply resolved inputs, run the subroutine and
3440 // `process()`, and classify the outcome. The guard is released before
3441 // the branch-specific async work below (parking_lot guards are
3442 // `!Send`); each branch re-acquires the data lock as it needs it. The
3443 // Segment-A mutations were committed under that guard and are visible
3444 // through this fresh acquisition (same `Arc`).
3445 let (
3446 process_result,
3447 process_actions,
3448 post_write_fields,
3449 result_is_defer_output,
3450 result_is_alarm_only,
3451 ) = {
3452 let mut instance = rec.write();
3453
3454 // Tell the record which input link fields actually resolved
3455 // a value this cycle — the union of the multi-input fetch and
3456 // the pre-process ReadDbLink reads; the framework analogue of
3457 // C device support inspecting `RTN_SUCCESS(dbGetLink(...))`
3458 // (`epidRecord.c:191-193`, `motorRecord.cc:3687-3698`).
3459 instance
3460 .record
3461 .set_resolved_input_links(&resolved_link_fields);
3462
3463 // The cycle's single `fetch_values()` outcome: a link read that
3464 // failed under a gating `InputFetchPolicy`, or sel's Specified-mode
3465 // selected-input read that did not resolve (C `selRecord.c::process`
3466 // (114) skips `do_sel` on it). Every C record that gates its body on
3467 // `if (fetch_values(prec) == 0)` reads it from here — one boolean,
3468 // one hook — and a record with no gate ignores it (default no-op).
3469 let fetch_gate_failed = fetch_values_failed;
3470 instance.record.set_fetch_gate_failed(fetch_gate_failed);
3471
3472 // Note: C EPICS LCNT prevents reentrant processing of the same
3473 // record within a single processing chain. In Rust, this is handled
3474 // by the `visited` HashSet (cycle detection) and the `processing`
3475 // AtomicBool guard. LCNT is not needed as a separate mechanism
3476 // because async processing with visited sets already prevents
3477 // the runaway loops that LCNT guards against in C.
3478
3479 // Tell the record whether device support already computed.
3480 // Records that override set_device_did_compute() use this to
3481 // skip their built-in computation (e.g., ai skips RVAL->VAL).
3482 // Note: field_io.rs may have already called set_device_did_compute(true)
3483 // for CA puts to VAL. We only set true here, never reset to false.
3484 if device_did_compute {
3485 instance.record.set_device_did_compute(true);
3486 } else if instance.record.skips_forward_convert_when_undefined()
3487 && instance.common.udf != 0
3488 {
3489 // C output-record `else if (prec->udf) goto CONTINUE`
3490 // (mbboRecord.c:210-213): an output record whose VAL is still
3491 // undefined and had no value source this cycle (no VAL put —
3492 // which clears UDF in `field_io` — and no closed-loop DOL fetch,
3493 // which clears UDF at the DOL-apply site above) SKIPS the
3494 // forward VAL->RVAL convert. Without this a `caput REC.RVAL 1`
3495 // on a bare mbbo is clobbered by `convert()` recomputing
3496 // `RVAL = VAL(=0)`. Same vehicle as the device-compute skip:
3497 // `set_device_did_compute(true)` sets the record's own
3498 // convert-skip flag, which `process()` consumes and clears. The
3499 // per-cycle UDF clear below stays gated on `clears_udf()` /
3500 // `device_did_compute` (both false here), so UDF stays 1 —
3501 // matching C's `goto CONTINUE` leaving `prec->udf` untouched.
3502 instance.record.set_device_did_compute(true);
3503 }
3504
3505 // TPRO: trace processing (C EPICS dbProcess prints context when TPRO>0)
3506 if instance.common.tpro != 0 {
3507 eprintln!(
3508 "[TPRO] {}: process (SCAN={:?}, PACT={})",
3509 instance.name,
3510 instance.common.scan,
3511 instance.is_processing()
3512 );
3513 }
3514
3515 // MS-class alarm propagation from input links. Mirrors C
3516 // `recGblInheritSevrMsg` (recGbl.c:263-281):
3517 //
3518 // * NMS — do nothing.
3519 // * MS — DEST gets `LINK_ALARM` (NOT the source stat),
3520 // max-raised sevr, NO amsg propagation.
3521 // * MSI — same as MS, but only when source.sevr == INVALID.
3522 // * MSS — DEST gets source stat, max-raised sevr, source amsg
3523 // (PR d0cf47c is the only branch that propagates msg).
3524 //
3525 // Folded BEFORE the record body, not after: C raises the link
3526 // severity inside `dbGetLink` (recGbl.c `recGblInheritSevr` is
3527 // called from the link's `getValue`), i.e. during the record's
3528 // input-fetch phase, so the body already sees it in `prec->nsev`.
3529 // `transformRecord.c:554` branches on exactly that
3530 // (`nsev >= INVALID_ALARM && ivla == DO_NOTHING`), and
3531 // `ProcessContext::nsev` below is that same `common.nsev` — one
3532 // owner, no second severity accumulator for records to consult.
3533 // Folding it here also gives C's tie-break: with equal severities
3534 // the link's LINK_ALARM lands first and `rec_gbl_set_sevr`'s
3535 // strict-greater test keeps it, exactly as in C where `dbGetLink`
3536 // precedes the record's own `recGblSetSevr` calls.
3537 for (ms, alarm) in &link_alarms {
3538 super::links::inherit_sevr_msg(&mut instance.common, *ms, alarm);
3539 }
3540
3541 // Push framework-owned common state (UDF/UDFS/NSEV/PHAS/TSE/TSEL) so
3542 // the record's process() can see it — C records read
3543 // `dbCommon` directly (`epidRecord.c:195` checks
3544 // `pepid->udf`, `timestampRecord.c:90` checks `tse`,
3545 // `transformRecord.c:554` checks `ptran->nsev`).
3546 {
3547 let ctx = instance.common.process_context();
3548 instance.record.set_process_context(&ctx);
3549 }
3550 // Tell the record whether this is its own scheduled re-entry
3551 // (the `ReprocessAfter` timer, a put-notify completion) or a
3552 // fresh cycle. Only this path can be a continuation; the
3553 // `process_local` and simulated-read paths always run a fresh
3554 // `process()`, which is the hook's default.
3555 instance.record.set_process_continuation(is_continuation);
3556
3557 // Apply the aSub LFLG=READ resolution computed above (outside the
3558 // lock). The single apply owner; the bad-sub skip is carried on the
3559 // instance and consumed by `run_registered_subroutine`.
3560 if let Some(ds) = &asub_dynamic {
3561 apply_asub_dynamic_sub(&mut instance, ds);
3562 }
3563
3564 // C `subRecord.c:144`+`:147` / `aSubRecord.c:216-218`:
3565 // status = fetch_values(prec);
3566 // if (status == 0) status = do_sub(prec);
3567 // A failed input link means the subroutine does not run this cycle
3568 // — VAL (and aSub's VALA..VALU) freeze, and none of `do_sub`'s
3569 // alarms (BAD_SUB / SOFT at BRSV) or its `udf = isnan(val)` update
3570 // happen. Same one-shot flag the aSub bad-SNAM skip arms, consumed
3571 // by the single owner `run_registered_subroutine`; OR-ed in so
3572 // whichever reason fired first still suppresses the run. Same
3573 // `fetch_values()` outcome the `set_fetch_gate_failed` hook above
3574 // carries — sub/aSub differ only in WHERE their body runs.
3575 if fetch_gate_failed {
3576 instance.suppress_subroutine_run = true;
3577 }
3578
3579 // Invoke the registered subroutine (sub/aSub SNAM) before the
3580 // record body, on the same dispatch path as process_local. The
3581 // framework owns the SubroutineFn registry (the record's own
3582 // process() is a no-op for sub/aSub), so without this the main
3583 // engine path — SCAN, event, CA-put-to-PP, FLNK — never ran the
3584 // subroutine and VAL/VALA..VALU/OUTA..OUTU never updated.
3585 instance.run_registered_subroutine()?;
3586
3587 // Process
3588 let mut outcome = instance.record.process()?;
3589 // Merge deferred device actions into process outcome actions
3590 outcome.actions.extend(deferred_device_actions);
3591 let process_result = outcome.result;
3592 let process_actions = outcome.actions;
3593 let post_write_fields = outcome.post_write_fields;
3594 // Captured before the `AsyncPendingNotify` `if let` below moves
3595 // `process_result`; consulted after the monitor epilogue to defer
3596 // the OUT/OEVT/FLNK tail (swait ODLY — see `CompleteDeferOutput`).
3597 let result_is_defer_output = process_result
3598 == crate::server::record::RecordProcessResult::CompleteDeferOutput;
3599 // Alarm-epilogue-only cycle (C `transformRecord.c:554-560`): the
3600 // alarm/timestamp commit below runs, the value side does not. See
3601 // `RecordProcessResult::CompleteAlarmOnly` and the `'epilogue`
3602 // break after `apply_timestamp`.
3603 let result_is_alarm_only =
3604 process_result == crate::server::record::RecordProcessResult::CompleteAlarmOnly;
3605
3606 (
3607 process_result,
3608 process_actions,
3609 post_write_fields,
3610 result_is_defer_output,
3611 result_is_alarm_only,
3612 )
3613 };
3614
3615 if process_result == crate::server::record::RecordProcessResult::AsyncPending {
3616 // C `dbProcess` contract: when device support / record body
3617 // signals "async pending", `pact` MUST be true so subsequent
3618 // dbProcess attempts on the same record bail at the entry
3619 // guard. Previous Rust port assumed `process_local` had
3620 // already set it via the swap-true at function entry, but
3621 // this main path bypasses `process_local` and calls
3622 // `record.process()` directly — leaving `processing=false`.
3623 // Mirrors `aiRecord.c:122` and similar: `prec->pact = TRUE;
3624 // return 0;` before async work.
3625 {
3626 let instance = rec.write();
3627 instance.enter_pact();
3628 }
3629
3630 // PACT stays set; skip alarm/timestamp/snapshot/OUT/FLNK.
3631 // But still execute any actions (e.g., ReprocessAfter for delayed re-entry).
3632 self.execute_process_actions(name, &rec, process_actions, visited);
3633 // After every action this arm runs, so the ordering rule holds
3634 // whatever the outcome carried. The `ReprocessAfter` a pending
3635 // cycle usually arms cannot overtake this: the continuation
3636 // enters through `process_record_continuation`, which acquires
3637 // the same per-record gate this body still holds.
3638 self.publish_post_write_fields(name, post_write_fields);
3639 // The SIM continuation released the SDLY PACT and the body then
3640 // went async again: still run the restart check, which finds the
3641 // record busy again and leaves the queue head where it is (the
3642 // deferral is closed under its own restart).
3643 self.apply_pact_exit(name, &rec, cycle_end.take());
3644 return Ok(());
3645 }
3646 if process_result == crate::server::record::RecordProcessResult::CompleteNoEmit {
3647 // C `compressRecord.c:365` `if (status != 1)`: the record
3648 // completed synchronously but emitted no new value this cycle
3649 // (a compress still accumulating toward its next compressed
3650 // sample). C runs none of `prec->udf = FALSE`,
3651 // `recGblGetTimeStamp`, `monitor`, nor `recGblFwdLink` — so the
3652 // entire value-publication epilogue (UDF clear / alarm commit /
3653 // timestamp / monitor / FLNK) is skipped. PACT is already clear
3654 // on this synchronous path (only the async branches set it), so
3655 // there is nothing to release. `complete_no_emit()` carries no
3656 // actions and compress is soft (no deferred device actions), so
3657 // there is nothing to run — return without awaiting
3658 // `execute_process_actions`, which would enlarge this hot
3659 // recursive function's async frame (the FLNK chain nests one
3660 // poll frame per hop, unbounded as in C; the write guard
3661 // `instance` is released on return).
3662 debug_assert!(
3663 process_actions.is_empty(),
3664 "CompleteNoEmit must carry no process actions"
3665 );
3666 // No actions means no link writes to order against, so the rule
3667 // is satisfied here; the arm still publishes, so the mechanism
3668 // has no arm-shaped hole.
3669 self.publish_post_write_fields(name, post_write_fields);
3670 // The record is idle (this path sets no PACT), so a notify queued
3671 // on a released SDLY window replays straight away.
3672 self.apply_pact_exit(name, &rec, cycle_end.take());
3673 return Ok(());
3674 }
3675 if let crate::server::record::RecordProcessResult::AsyncPendingNotify(fields) =
3676 process_result
3677 {
3678 // Intermediate notification (e.g. DMOV=0 at move start).
3679 // Execute device write first so the move command reaches the
3680 // driver, then fire the record's link writes, then flush
3681 // DMOV=0 etc. to monitors. This mirrors the C ordering on an
3682 // async (pact=1) pass: `motorRecord.cc:1491` runs `do_work`
3683 // (the device move), `motorRecord.cc:1495` then fires
3684 // `dbPutLink(&pmr->rlnk, ...)` UNCONDITIONALLY — on every pass
3685 // including the move-start pass where DMOV just went 0 — and
3686 // only `motorRecord.cc:1507` afterwards calls `monitor()`. So
3687 // the requested `WriteDbLink`/`WriteDbLinkNotify` actions must
3688 // run on the pending cycle as well; a put processes a PP target
3689 // even when the value is unchanged, so dropping them changes
3690 // downstream process counts (motor RLNK, asyn async writes).
3691 // The forward link stays deferred: C runs `recGblFwdLink` only
3692 // when `pmr->dmov != 0` (motorRecord.cc:1509), i.e. on async
3693 // completion, not on this pending pass.
3694 // Guarded: device write, timestamp, and the changed-field
3695 // snapshot. The data guard is released before the link-write /
3696 // notify awaits below (parking_lot guards are `!Send`).
3697 let tsel = self.read_tsel(&rec);
3698 let snapshot = {
3699 let mut instance = rec.write();
3700 if !is_soft {
3701 if let Some(mut dev) = instance.device.take() {
3702 let _ = dev.write(&mut *instance.record);
3703 instance.device = Some(dev);
3704 }
3705 }
3706 let inst = &mut *instance;
3707 tsel.stamp(&inst.name, &mut inst.common, is_soft);
3708 // Filter out fields that haven't changed, update MLST/last_posted.
3709 // Each intermediate post carries DBE_VALUE|DBE_LOG — C motor's
3710 // mid-move `db_post_events` calls use `DBE_VAL_LOG`
3711 // (motorRecord.cc:2606 DMOV, and every other do_work post);
3712 // no alarm transition ran on this pending pass, so no
3713 // DBE_ALARM bit.
3714 let mut changed_fields = Vec::new();
3715 for (name, val) in fields {
3716 let changed = match instance.posted_value(&name) {
3717 Some(prev) => prev != &val,
3718 None => true,
3719 };
3720 if changed {
3721 if name == "VAL" {
3722 if let Some(f) = val.to_f64() {
3723 instance.put_coerced("MLST", EpicsValue::Double(f));
3724 instance.common.mlst = Some(f);
3725 }
3726 }
3727 instance.record_value_post(&name, val.clone());
3728 changed_fields.push((
3729 name,
3730 val,
3731 crate::server::recgbl::EventMask::VALUE
3732 | crate::server::recgbl::EventMask::LOG,
3733 ));
3734 }
3735 }
3736 // C parity (calcoutRecord.c:277-282, sCalcoutRecord.c:400-404):
3737 // a record that defers its output by ODLY via a timer
3738 // (`callbackRequestProcessCallbackDelayed`) keeps `pact=TRUE`
3739 // across the whole delay — it `return 0`s with pact still set,
3740 // so the record stays ACTIVE and a concurrent `dbProcess`
3741 // bails; the delayed callback re-enters (`pact==TRUE`, `dlya`
3742 // branch) and clears pact. Mirror that: when this notify
3743 // schedules a `ReprocessAfter` (the continuation that clears
3744 // PACT at the `is_continuation` arm below), hold PACT now.
3745 //
3746 // The gate is the `ReprocessAfter` itself, not a flag: holding
3747 // PACT is sound ONLY because a continuation is scheduled to
3748 // release it. A notify WITHOUT a `ReprocessAfter` (motor's
3749 // DMOV-pulse pass, which completes via its device callback and
3750 // returns Complete on later passes — no timer continuation)
3751 // gets no PACT-clearing re-entry, so it must NOT hold PACT or
3752 // it would stick forever (spurious SCAN_ALARM). Tying the hold
3753 // to the presence of its own release keeps the invariant by
3754 // construction and leaves motor's path untouched.
3755 let holds_pact_until_continuation = process_actions.iter().any(|a| {
3756 matches!(a, crate::server::record::ProcessAction::ReprocessAfter(_))
3757 });
3758 if holds_pact_until_continuation {
3759 instance.enter_pact();
3760 }
3761 crate::server::record::ProcessSnapshot { changed_fields }
3762 };
3763 // Partition exactly as the synchronous Complete path: link
3764 // writes fire here (C `dbPutLink` precedes `monitor()`);
3765 // delayed-reprocess / device-command actions run after the
3766 // notify (the Complete path runs them after the FLNK tail,
3767 // which is deferred to async completion on this pending pass).
3768 let (link_writes, deferred_actions): (Vec<_>, Vec<_>) =
3769 process_actions.into_iter().partition(|a| {
3770 matches!(
3771 a,
3772 crate::server::record::ProcessAction::WriteDbLink { .. }
3773 | crate::server::record::ProcessAction::WriteDbLinkNotify { .. }
3774 )
3775 });
3776 self.execute_process_actions(name, &rec, link_writes, visited);
3777 self.publish_post_write_fields(name, post_write_fields);
3778 {
3779 let inst = rec.read();
3780 inst.notify_from_snapshot(&snapshot, link_backing);
3781 }
3782 self.execute_process_actions(name, &rec, deferred_actions, visited);
3783 // Same as the `AsyncPending` arm: run the restart check through the
3784 // single drain owner, which is a no-op if this pass re-took PACT.
3785 self.apply_pact_exit(name, &rec, cycle_end.take());
3786 return Ok(());
3787 }
3788
3789 // Async-completion PACT clear for the `ReprocessAfter`
3790 // continuation path. C parity `dbAccess.c:583` —
3791 // `prset->process(precord)` for a record whose first cycle
3792 // returned async-pending is the *completion* re-entry; the
3793 // record support clears `pact` itself inside `process()`
3794 // (e.g. `aiRecord.c` second pass sets `prec->pact = FALSE`).
3795 //
3796 // A record that returns `AsyncPending` AND emits a
3797 // `ProcessAction::ReprocessAfter` is re-entered here via
3798 // `process_record_continuation` (`is_continuation == true`,
3799 // PACT entry guard skipped). Reaching this point means the
3800 // continuation's `process()` did NOT return async-pending
3801 // again (both async branches above return early), so the
3802 // async cycle is genuinely complete. The non-continuation
3803 // async-device path clears `processing` in
3804 // `complete_async_record_inner`; the continuation path has
3805 // no such callback, so without this clear `processing`
3806 // stays `true` forever — every later foreign
3807 // `process_record_with_links` then trips the PACT entry
3808 // guard, counts to MAX_LOCK, and raises a spurious
3809 // SCAN_ALARM. Clearing here (record still write-locked,
3810 // before the OUT/FLNK tail) mirrors the C ordering where
3811 // `pact` is already `FALSE` when `recGblFwdLink` runs.
3812 //
3813 // The release is carried to this cycle's `recGblFwdLink` tail below
3814 // as the `PactExit`, which is where C runs the restart check
3815 // (`recGbl.c:295` → `dbNotifyCompletion` → `restartCheck`).
3816 // Restarting at the `pact = FALSE` store instead — before the
3817 // OUT/FLNK tail — would let the replayed put process the record
3818 // concurrently with the tail it is still running.
3819 // dfanout's SELL read sits between `recGblGetTimeStamp` and
3820 // `checkAlarms` (`dfanoutRecord.c:126-127`), so it must run before
3821 // Segment C: a failed read is a `setLinkAlarm`, and the line after
3822 // it is the `nsev < INVALID_ALARM` test that decides between
3823 // `push_values` and the IVOA branch. Taken outside the write guard
3824 // below because the read takes its own locks. The owner ignores
3825 // every record whose C reads SELL elsewhere.
3826 self.read_sell_into_seln(&rec, super::links::SellPhase::BeforeAlarms);
3827
3828 // The TSEL half of this cycle's `recGblGetTimeStampSimm`, read here
3829 // because the store below happens under Segment C's data guard and
3830 // the link read cannot. It is the record's own input stage —
3831 // `fetch_values` / `readValue`, both already done — that C lets move
3832 // the TSEL source before this read, so reading it here and storing
3833 // it at the stamp point is C's order with the lock split out.
3834 let tsel = self.read_tsel(&rec);
3835
3836 // Segment C (guarded): the alarm / UDF / timestamp epilogue, the IVOA
3837 // output veto, and the output-time-link read list. Re-acquire the data
3838 // lock (Segments A/B committed their writes under their own guards).
3839 // On the alarm-only path this segment `break`s the whole `'epilogue`.
3840 let (restamps_after, skip_out, out_time_reads) = {
3841 let mut instance = rec.write();
3842 // Folded into the guard the moment it is minted, and never
3843 // threaded onward by value: one carrier, so the exits between
3844 // here and the tail — the `?` on the device write, the
3845 // async-output `write_begin` early return, the `break 'epilogue`
3846 // — all release it without a site of their own.
3847 cycle_end.merge_in(if is_continuation {
3848 instance.leave_pact()
3849 } else {
3850 instance.pact_exit_without_release()
3851 });
3852
3853 // NOTE: the MS-class input-link alarm propagation
3854 // (`inherit_sevr_msg`) already ran BEFORE the record body — see the
3855 // fold site above `set_process_context`. C raises it inside
3856 // `dbGetLink`, so the body must be able to read the resulting
3857 // `nsev` (transform IVLA="Do Nothing").
3858
3859 // UDF update — C parity (aiRecord.c:285, calcRecord.c
3860 // checkAlarms, int64inRecord.c:144): clear UDF only when
3861 // this cycle produced a *defined* value. A NaN computed
3862 // value (calc divide-by-zero) or a failed link read that
3863 // left VAL un-updated must keep UDF true so the following
3864 // `recGblCheckUDF` raises UDF_ALARM at severity UDFS.
3865 //
3866 // This MUST run before `evaluate_alarms()` (which calls
3867 // `rec_gbl_check_udf`): C records set `prec->udf` inside
3868 // `process()` before `checkAlarms()` runs.
3869 //
3870 // The re-derive fires only when a value was actually SOURCED or
3871 // RECOMPUTED this cycle — the C invariant. Two record classes
3872 // reach it:
3873 // * `clears_udf()` true: records whose C `process()` re-derives
3874 // UDF UNCONDITIONALLY every cycle, whatever the read did
3875 // (`aiRecord.c:161` `if(status==0) prec->udf = isnan(val)`,
3876 // with a soft read's `status==2` folded to 0 — so a constant
3877 // INP still re-derives). ai/ao/bi/longin/calc/mbbi… .
3878 // * `device_did_compute`: a value was sourced this cycle — a
3879 // real soft-channel INP read landed a value, or device
3880 // support's `read()` computed one. This is how the
3881 // sourced-only records (`clears_udf()` false: stringin, bo,
3882 // longout, …) get their UDF cleared on a genuine read, exactly
3883 // like C `devSiSoft.c::read_stringin` clears UDF only inside
3884 // the `!dbLinkIsConstant` read branch.
3885 //
3886 // A cycle that sources nothing — e.g. a `caput UDF x` that drove
3887 // processing on a Passive record with a constant/empty INP — must
3888 // NOT re-derive UDF on a sourced-only record: the client's UDF put
3889 // stands (softIoc-verified: `caput REC.UDF 1` keeps UDF=1 for
3890 // stringin/lso/bo/longout, unlike ai/longin which re-derive to 0).
3891 // DOL-sourced output records clear UDF in their own DOL branch
3892 // above; the subroutine records (aSub) clear it in the subroutine
3893 // run (C `do_sub`), so neither needs `device_did_compute` here.
3894 //
3895 // …and it is gated on the READ STATUS, which is C's own shape:
3896 // `if (status == 0) prec->udf = <derive>` (aiRecord.c:161,
3897 // mbbiDirectRecord.c:155-164). A cycle whose soft INP read
3898 // failed sourced nothing, so it re-derives nothing and UDF
3899 // stands — that is what leaves `if (prec->udf) recGblSetSevr(
3900 // prec, UDF_ALARM, ...)` reachable. The array records and
3901 // compress are the documented exceptions
3902 // ([`Record::derives_udf_on_read_failure`]).
3903 //
3904 // A DEVICE read that produced no value is the same case and
3905 // takes the same arm: C's `-1`/`-2` returns miss `if(status==0)`
3906 // just as a failed soft read does, so the gate is one condition
3907 // over both sources rather than a per-record-type exception at
3908 // the ai site ([`DeviceReadStatus::read_failed`]).
3909 let derive_udf = if read_produced_no_value {
3910 instance.record.derives_udf_on_read_failure()
3911 } else if device_read_computed {
3912 // C `return 2` from a DEVICE dset. Whether the record
3913 // re-derives on top of what the dset already wrote is the
3914 // record's own rule and is not uniform: `aiRecord.c:158-161`
3915 // folds 2 into 0 first and re-derives, `biRecord.c:136-141`
3916 // and its four twins keep the assignment inside
3917 // `if (status == 0)` and never reach it.
3918 instance.record.rederives_udf_on_computed_read()
3919 } else {
3920 instance.record.clears_udf() || device_did_compute
3921 };
3922 if derive_udf {
3923 instance.common.udf = instance.record.value_is_undefined() as u8;
3924 }
3925
3926 // Per-record alarm hook — record-type-specific STATE / COS
3927 // / limit / SOFT alarms (C `checkAlarms()`). Records that
3928 // have migrated their alarm logic here raise into
3929 // `nsta`/`nsev`; the rest fall back to the framework's
3930 // centralised `evaluate_alarms` match below.
3931 {
3932 let inst = &mut *instance;
3933 inst.record.check_alarms(&mut inst.common);
3934 }
3935
3936 // Evaluate alarms (accumulates into nsta/nsev)
3937 instance.evaluate_alarms();
3938
3939 // Device support alarm/timestamp override
3940 if !is_soft {
3941 let (dev_alarm, dev_ts, dev_utag) = if let Some(ref dev) = instance.device {
3942 (dev.last_alarm(), dev.last_timestamp(), dev.last_utag())
3943 } else {
3944 (None, None, None)
3945 };
3946 if let Some((stat, sevr)) = dev_alarm {
3947 use crate::server::recgbl::rec_gbl_set_sevr;
3948 rec_gbl_set_sevr(
3949 &mut instance.common,
3950 stat,
3951 crate::server::record::AlarmSeverity::from_u16(sevr),
3952 );
3953 }
3954 if let Some(ts) = dev_ts {
3955 instance.common.time = ts;
3956 }
3957 // C device support writes `prec->utag` directly during
3958 // `read()` — the event-system pulse-id path, since
3959 // `epicsTimeStamp` carries no tag. Adopt the device's
3960 // userTag when it supplies one; read in the same `dev`
3961 // borrow as the timestamp above so the time/tag pair is a
3962 // single consistent device snapshot.
3963 if let Some(utag) = dev_utag {
3964 instance.common.utag = utag;
3965 }
3966 }
3967
3968 // The soft-channel half of the same override: for a `Soft
3969 // Channel` record the dset IS the device, and the timestamp it
3970 // supplies is the INP source's (`devAiSoft.c:59-60`). `None`
3971 // unless the read succeeded under C's TSE=-2 + constant-TSEL
3972 // gate, so a record that is not asking for device time, or
3973 // whose read failed, keeps whatever `apply_timestamp` gives it.
3974 if let Some(ts) = inp_source_time {
3975 instance.common.time = ts;
3976 }
3977 // The calc half of the same adoption (`lnkCalc.c:581`) — see
3978 // where `inp_source_utag` is built for why only that link
3979 // class supplies one.
3980 if let Some(tag) = inp_source_utag {
3981 instance.common.utag = tag;
3982 }
3983
3984 // pvalink `time=true` adopts the latched upstream timestamp
3985 // into the owning record. `external_link_time` returned
3986 // `None` unless the lset signalled the option, so a `Some`
3987 // here is the operator-requested remote timestamp: the remote
3988 // NT `timeStamp` while connected, or the disconnect-event time
3989 // while the subscription is down (pvxs `snap_time = e.time`,
3990 // adopted on the invalid read — `pvxs/ioc/pvalink_lset.cpp:268-270`).
3991 // Apply BEFORE `apply_timestamp` so the upstream value
3992 // survives the soft-channel TSE=0 default (`apply_timestamp`
3993 // would otherwise stamp wall-clock-now on top).
3994 if let Some((secs, ns, utag)) = inp_link_remote_time {
3995 let secs = secs.max(0) as u64;
3996 let ns = ns.max(0) as u32;
3997 instance.common.time =
3998 std::time::UNIX_EPOCH + std::time::Duration::new(secs, ns.min(999_999_999));
3999 // adopt the upstream `timeStamp.userTag` alongside the
4000 // time, mirroring pvxs PR-added `precord->utag = snap_tag`
4001 // next to `precord->time = snap_time` in the `time=true`
4002 // branch. The tag is already widened without sign
4003 // extension by the lset; `0` when the source carries
4004 // none. `apply_timestamp` never touches `utag`, so this
4005 // survives regardless of the TSE branch below.
4006 instance.common.utag = utag;
4007 // Whether the adopted time SURVIVES is the record's
4008 // declared TSE, not something to arrange here: pvxs writes
4009 // `precord->time` and `precord->utag` and nothing else
4010 // (`pvalink_lset.cpp:269-272`), so a `time=true` link needs
4011 // `field(TSE,"-2")` for `recGblGetTimeStamp` to leave the
4012 // pair alone — which is exactly what pvxs's own test
4013 // database declares (`test/testpvalink.db:140,230`).
4014 // Writing -2 here instead made the field report a value the
4015 // database never declared.
4016 }
4017
4018 // IVOA gate severity for a redirected SIMM output. C decides
4019 // `if (prec->nsev < INVALID_ALARM)` at the `writeValue` call
4020 // (aoRecord.c:197) using the severity `checkAlarms` produced —
4021 // BEFORE `writeValue` raises SIMM_ALARM. Snapshot the real
4022 // (pre-SIMM) pending severity here so a `SIMS=INVALID` never flips
4023 // the IVOA decision: with a finite, in-range VAL the IVOA veto must
4024 // NOT fire and C still writes OVAL to SIOL. For a non-simulated
4025 // record no SIMM_ALARM is raised below, so `nsev` here equals the
4026 // committed `sevr`, leaving the IVOA gate unchanged.
4027 let real_sev = instance.common.nsev;
4028
4029 // SIMM simulation severity on a redirected OUTPUT record. C
4030 // `writeValue` raises `recGblSetSevr(prec, SIMM_ALARM, prec->sims)`
4031 // AFTER `checkAlarms` (aoRecord.c:196 -> :570 / boRecord.c:219 ->
4032 // :436), so a coincident limit/state alarm of equal severity keeps
4033 // its stat/amsg (set first; `rec_gbl_set_sevr` is strict-greater).
4034 // A simulated INPUT instead raises this inside
4035 // `check_simulation_mode` before its body, because `readValue`
4036 // precedes the body. Raised here (after the alarm hooks, before the
4037 // commit) it still folds into this cycle's committed SEVR.
4038 if let Some((_, sims, _)) = &sim_output {
4039 let sev = crate::server::record::AlarmSeverity::from_u16(*sims as u16);
4040 crate::server::recgbl::rec_gbl_set_sevr(
4041 &mut instance.common,
4042 crate::server::recgbl::alarm_status::SIMM_ALARM,
4043 sev,
4044 );
4045 }
4046
4047 // Apply timestamp based on TSE. BEFORE the output stage: C
4048 // `aoRecord.c:190` stamps the record before `writeValue` "so it
4049 // will be up to date if any downstream records fetch it via TSEL".
4050 //
4051 // A `restamps_time_after_completion` record (sseq) restamps at the
4052 // very END of its completion instead — C `sseqRecord.c::asyncFinish`
4053 // posts VAL (`:474`) and runs `recGblFwdLink` (`:499`) BEFORE
4054 // `recGblGetTimeStamp` (`:501`). Skip the pre-output restamp here so
4055 // this cycle's VAL monitor carries the record's pre-update
4056 // timestamp; the deferred restamp after the forward-link tail
4057 // advances TIME for the BUSY post and the next cycle.
4058 //
4059 // mbbo/mbboDirect are a second exception: C `mbboRecord.c:210-221`
4060 // takes `else if (prec->udf) goto CONTINUE`, jumping PAST this
4061 // pre-output `recGblGetTimeStampSimm`. So a soft (sync) UDF
4062 // mbbo/mbboDirect never stamps here; TIME stays at the epoch until
4063 // VAL is defined. Only the SYNC first-pass stamp is skipped — the
4064 // async-completion re-entry (`complete_async_record_inner`) stamps
4065 // unconditionally, matching C's `if (pact)` re-stamp
4066 // (mbboRecord.c:256-258).
4067 let restamps_after = instance.record.restamps_time_after_completion();
4068 // Either way into C's `goto CONTINUE` skips the same
4069 // `recGblGetTimeStampSimm`: `else if (prec->udf)`
4070 // (mbboRecord.c:210) and the failed closed-loop DOL read
4071 // (mbboRecord.c:205) jump to the identical label.
4072 let skips_ts_undef = instance.record.skips_timestamp_when_undefined()
4073 && (instance.common.udf != 0 || dol_read_failed);
4074 if !restamps_after && !skips_ts_undef {
4075 let inst = &mut *instance;
4076 tsel.stamp(&inst.name, &mut inst.common, is_soft);
4077 }
4078 // NOTE: UDF was already updated before `evaluate_alarms`
4079 // above — keyed on `value_is_undefined()` so a NaN result
4080 // keeps UDF true and UDF_ALARM is raised this cycle. Do
4081 // NOT clear UDF unconditionally here.
4082
4083 // C `transformRecord.c:554-560` — the record body asked for the
4084 // ALARM epilogue only (IVLA="Do Nothing" on an INVALID input):
4085 // `recGblGetTimeStamp` + `checkAlarms` + `recGblResetAlarms` have
4086 // now run, and C `return`s here. Everything below is C's
4087 // `monitor()` + output + `recGblFwdLink()` — none of it happens on
4088 // that cycle. The SEVR/STAT/AMSG/ACKS posts `recGblResetAlarms`
4089 // itself makes are the only events the cycle emits; VAL and the
4090 // value fields are NOT posted and their last-posted trackers stay
4091 // put (C leaves `LA..LP` un-updated), so the next publishing cycle
4092 // re-detects the change.
4093 //
4094 // This is C's OTHER `recGblResetAlarms` call site — the record
4095 // body's own, not `monitor()`'s — and the cycle performs no output,
4096 // so the commit happens here and the path returns.
4097 if result_is_alarm_only {
4098 // This path performs no output — it drops the cycle's
4099 // actions by design — so a withheld store would have
4100 // nothing to be ordered against and nothing to publish it.
4101 debug_assert!(
4102 post_write_fields.is_empty(),
4103 "CompleteAlarmOnly runs no outputs and must carry no post-write fields"
4104 );
4105 let alarm_result =
4106 crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
4107 let alarm_posts = alarm_field_posts(&instance.common, &alarm_result);
4108 break 'epilogue (
4109 crate::server::record::ProcessSnapshot {
4110 changed_fields: Vec::new(),
4111 },
4112 None,
4113 Vec::new(),
4114 alarm_posts,
4115 false,
4116 restamps_after,
4117 );
4118 }
4119
4120 // **The IVOA owner** — the single site that decides what an INVALID
4121 // cycle does with its outputs, for EVERY output path of this
4122 // record: its own OUT, the SIOL redirect, the generic multi-output
4123 // pairs, and the dfanout `OUTn` push. Each of those consumes the
4124 // decision (`skip_out`, plus the IVOV the record has by then
4125 // stored in its own output field); none re-derives it.
4126 //
4127 // C makes the decision exactly once, BEFORE any output — at the
4128 // `writeValue` call (`if (prec->nsev < INVALID_ALARM)`,
4129 // aoRecord.c:197) and at dfanout's push (`dfanoutRecord.c:128`).
4130 // An output path that re-reads `nsev` after the writes have begun
4131 // reads an alarm the writes THEMSELVES raised (a failed put's
4132 // LINK_ALARM/INVALID, dbLink.c:444-446) and acts on a decision C
4133 // never made — e.g. overwriting VAL with IVOV on a cycle whose only
4134 // INVALID came from the failed push.
4135 //
4136 // Gate on the real (pre-SIMM) severity `real_sev` snapshotted above
4137 // — C decides IVOA before `writeValue` raises SIMM_ALARM, so a
4138 // `SIMS=INVALID` simulation severity does not trigger the veto (the
4139 // committed `sevr` may be INVALID from SIMM while the record's own
4140 // alarm is not).
4141 let skip_out = if real_sev == crate::server::record::AlarmSeverity::Invalid {
4142 let ivoa = instance
4143 .record
4144 .get_field("IVOA")
4145 .and_then(|v| v.to_menu_index())
4146 .unwrap_or(0);
4147 match ivoa {
4148 1 => true, // Don't drive outputs
4149 2 => {
4150 // Set output to IVOV. Each record type knows
4151 // which field its OUT writeback consumes — see
4152 // [`Record::apply_invalid_output_value`]. The
4153 // earlier path special-cased `calcout`
4154 // (OVAL) and fell back to `set_val` (VAL) for
4155 // every other record. That hid a real bug:
4156 // ao/lso/bo/mbbo/busy left their OVAL/RVAL
4157 // staging field stale, so the OUT writeback —
4158 // which reads `OVAL.or(VAL)` — sent the
4159 // pre-IVOA value to the linked record. Per-type
4160 // overrides now apply IVOV to the field that
4161 // matches the C convention.
4162 // C's IVOA=2 arm cannot fail. It is a plain store into the
4163 // record's own fields — `prec->val = prec->ivov`
4164 // plus the mask conversion (`boRecord.c:231-238`),
4165 // `strncpy(prec->val, prec->ivov, sizv-1)` plus
4166 // `len` (`lsoRecord.c:131-137`) — with C's only
4167 // failure arm reserved for an ILLEGAL IVOA choice
4168 // (`boRecord.c:241-244`), which this `match` has
4169 // already excluded. So an `Err` here is a port bug
4170 // in the record's `apply_invalid_output_value` /
4171 // `put_field` pair, never a runtime condition, and
4172 // discarding it silently is what let lso's arm be a
4173 // complete no-op for a whole round: `put_field` had
4174 // no `"OVAL"` case, so the `?` returned
4175 // `FieldNotFound` before VAL was ever written and
4176 // the record kept its stale value with no monitor.
4177 // Loud in test/debug; release behaviour unchanged,
4178 // because C has no alarm for this case to copy.
4179 if let Some(ivov) = instance.record.get_field("IVOV") {
4180 let applied = instance.record.apply_invalid_output_value(ivov);
4181 debug_assert!(
4182 applied.is_ok(),
4183 "{}: IVOA=Set_output_to_IVOV could not apply IVOV: {:?}",
4184 instance.record.record_type(),
4185 applied.err()
4186 );
4187 }
4188 false
4189 }
4190 _ => false, // Continue normally
4191 }
4192 } else {
4193 false
4194 };
4195
4196 // Output-time input links (swait DOL). C
4197 // `swaitRecord.c::execOutput` (763-772) fetches DOL through
4198 // `recDynLinkGet` at OUTPUT time — not in the input-fetch phase —
4199 // and only on a cycle whose output actually fires, so DOLD carries
4200 // the value the link holds at the moment of the write (ODLY
4201 // delay-end included) and a non-firing cycle neither refreshes nor
4202 // posts it. Run here, after the IVOA veto and before the OUT stage
4203 // composes `out_info`, so the fresh value is the one written and
4204 // the changed field still reaches this cycle's snapshot.
4205 //
4206 // The write lock is released across the read (the link may target
4207 // another record) and re-taken, the same way the pre-process
4208 // `ReadDbLink` stage above does it; the record stays claimed by the
4209 // `processing` guard meanwhile.
4210 let out_time_links = instance.record.output_time_input_links();
4211 let out_time_reads: Vec<(String, &'static str)> =
4212 if !skip_out && !out_time_links.is_empty() && instance.record.should_output() {
4213 out_time_links
4214 .iter()
4215 .filter_map(|(link_field, value_field)| {
4216 let link = match instance.record.get_field(link_field) {
4217 Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
4218 _ => return None,
4219 };
4220 (!link.is_empty()).then_some((link, *value_field))
4221 })
4222 .collect()
4223 } else {
4224 Vec::new()
4225 };
4226
4227 (restamps_after, skip_out, out_time_reads)
4228 };
4229
4230 // await 2 (guard-free): output-time input-link (swait DOL) reads. The
4231 // write lock is released across the reads (a link may target another
4232 // record); the record stays claimed by the `processing` gate.
4233 let mut out_time_fetched: Vec<(&'static str, EpicsValue)> = Vec::new();
4234 for (link, value_field) in out_time_reads {
4235 // A bare read, no `process_passive_db_source`: C's DOL is a
4236 // `recDynLink` (CA-style) input, which never process-passives its
4237 // source. `NoData` (constant DOL) writes nothing — the value field
4238 // keeps what it holds, as in C where a swait DOL that is not a PV
4239 // name never registers a recDynLink and so never delivers.
4240 let parsed = crate::server::record::parse_link_v2(&link);
4241 if let Some(value) = self.db_try_get_link(&rec, &parsed).value() {
4242 out_time_fetched.push((value_field, value));
4243 }
4244 }
4245
4246 // Segment D (guarded): apply the output-time reads, queue OEVT, compose
4247 // the OUT-stage `out_info` plan, and capture the OUT-link source fields.
4248 // Yields those; the guard then closes so the output-write awaits below
4249 // hold no `!Send` guard (a self/cyclic OUT link would also dead-lock the
4250 // non-reentrant gate). The async device-write branch inside the
4251 // `out_info` match returns straight from the function.
4252 let (out_info, src_putf, src_notify, src_alarm) = {
4253 let mut instance = rec.write();
4254 for (field, value) in out_time_fetched {
4255 let _ = instance.record.put_field(field, value);
4256 }
4257
4258 // OEVT: queue the output event when the output fires — the
4259 // event-subsystem twin of the OUT write, gated by the SAME IVOA
4260 // Don't_drive veto (`skip_out`). C
4261 // `calcout`/`sCalcout`/`aCalcout` `execOutput` posts
4262 // `postEvent(epvt)` / `post_event(oevt)` right after `writeValue`
4263 // in every OUT-driving branch and never on Don't_drive;
4264 // `output_event()` folds in the record's own OOPT/calc-fail/ODLY
4265 // output-fire decision. Spawned (not inline) like
4266 // `dispatch_event_record` so the woken `SCAN="Event"` records run
4267 // on the callback path, not recursively inside this cycle.
4268 if !skip_out {
4269 if let Some(event_name) = instance.record.output_event() {
4270 let db = self.clone();
4271 // Middle band, not this record's PRIO: C `postEvent`
4272 // fires one `callbackRequest` per non-empty band and
4273 // each carries the *scanned* record's priority
4274 // (`dbScan.c:513-527`), a fan-out the port's single
4275 // Event list cannot express (`scan_index.rs`
4276 // `post_event_named`). The poster's own PRIO is not
4277 // the answer, so this keeps `callbackRequest`'s
4278 // general band (`callback.h:42`).
4279 crate::runtime::task::spawn_background(
4280 crate::runtime::task::CallbackPriority::Medium,
4281 async move {
4282 db.post_event_named(&event_name).await;
4283 },
4284 );
4285 }
4286 }
4287
4288 // OUT stage: soft channel -> link put, non-soft -> device.write()
4289 // Must run BEFORE check_deadband_ext so MLST is not prematurely
4290 // updated for async writes that return early.
4291 let can_dev_write = instance.record.can_device_write();
4292 // The soft OUT-link value THIS DTYP's dset would put — VAL/OVAL for
4293 // "Soft Channel", RVAL for "Raw Soft Channel". `None` = not a soft
4294 // output dset. See `RecordInstance::soft_output_value`.
4295 let soft_out = instance.soft_output_value();
4296 let record_should_output = instance.record.should_output();
4297 let out_info = if sim_output.is_some() {
4298 // Simulated OUTPUT record: C `writeValue` redirects the output
4299 // to SIOL (`dbPutLink(&prec->siol, ..., &prec->oval)`) INSTEAD
4300 // of the real device write / soft OUT-link write. The redirect
4301 // is applied from the OUT epilogue by `write_simulated_output_siol`
4302 // (it reads the post-body OVAL/RVAL), so the normal device/OUT
4303 // write is suppressed here.
4304 None
4305 } else if sim_write_aborted {
4306 // C `writeValue` returned before writing — either the
4307 // `default:` arm (`recGblSetSevr(SOFT_ALARM, INVALID_ALARM);
4308 // status = -1;`) or a failed SIML read. Both return BEFORE the
4309 // device write and BEFORE the SIOL redirect, so this cycle
4310 // performs no output at all.
4311 None
4312 } else if skip_out {
4313 None
4314 } else if !can_dev_write {
4315 // Non-output records (calcout, etc.) may still have a
4316 // soft OUT link (DB or external ca://`/`pva://`).
4317 // Write OVAL to OUT when the record says should_output().
4318 if record_should_output && instance.parsed_out.is_writable_out_link() {
4319 let out_val = instance.record.output_link_value();
4320 out_val.map(|v| (instance.parsed_out.clone(), v))
4321 } else {
4322 None
4323 }
4324 } else if let Some(out_val) = soft_out {
4325 if !record_should_output {
4326 // epics-base 7.0.8 OOPT: gate the soft OUT-link
4327 // write on the record's `should_output()`. For
4328 // longout/calcout with OOPT != 0 this lets a
4329 // condition-not-met cycle silently skip the link
4330 // write without disturbing alarms / monitors.
4331 None
4332 } else if instance.parsed_out.is_writable_out_link() {
4333 out_val.map(|v| (instance.parsed_out.clone(), v))
4334 } else {
4335 None
4336 }
4337 } else if device_callback
4338 && instance
4339 .device
4340 .as_ref()
4341 .is_some_and(|d| d.output_callback_readback())
4342 {
4343 // Driver-callback (`asyn:READBACK`) cycle on a hardware output
4344 // whose device support takes the callback-readback branch: the
4345 // new value was read back into VAL by the read stage above;
4346 // writing it here would re-assert the setpoint to the driver and
4347 // re-trigger it (the AD `Acquire` loop). C
4348 // `devAsynInt32.c::processBo` takes the `newOutputCallbackValue`
4349 // readback branch and never calls `processCallbackOutput`'s
4350 // `write()` on a callback cycle. Devices without that contract
4351 // (`output_callback_readback` false — devMotorAsyn) run their
4352 // output stage on callback cycles like any other C `dbProcess`:
4353 // the motor record's retry / backlash / NTM-stop commands are
4354 // emitted on exactly these passes.
4355 None
4356 } else if !record_should_output {
4357 // OOPT gating for hardware outputs (longout DTYP=...).
4358 // Skip the device write when the OOPT predicate is
4359 // not satisfied; the record's val/timestamp/snapshot
4360 // path still runs so monitor consumers see the value
4361 // change even on a non-output cycle.
4362 None
4363 } else {
4364 if let Some(mut dev) = instance.device.take() {
4365 // Try async write_begin() first
4366 match dev.write_begin(&mut *instance.record) {
4367 Ok(Some(completion)) => {
4368 // Async write submitted -- set PACT, return early.
4369 // complete_async_record will handle deadband, snapshot,
4370 // notification, and FLNK when the write completes.
4371 instance.enter_pact();
4372 instance.device = Some(dev);
4373 let rec_name = instance.name.clone();
4374 let timeout = std::time::Duration::from_secs(5);
4375 let db = self.clone();
4376 let prio = instance.common.callback_priority();
4377 crate::runtime::task::spawn_background(prio, async move {
4378 let _ = crate::runtime::task::spawn_blocking_background(
4379 prio,
4380 move || completion.wait(timeout),
4381 )
4382 .await;
4383 let _ = db.complete_async_record(&rec_name).await;
4384 });
4385 // Not an end: `complete_async_record_inner`
4386 // owns this cycle's tail now, and mints its own
4387 // token from the record when the write lands.
4388 cycle_end.hand_off_to_async_completion();
4389 return Ok(());
4390 }
4391 Ok(None) => {
4392 // No async support -- fall back to synchronous write
4393 if let Err(e) = dev.write(&mut *instance.record) {
4394 eprintln!("device write error on {}: {e}", instance.name);
4395 // C device support raises the write failure
4396 // through `recGblSetSevr` (a PENDING alarm),
4397 // and `process()`'s `monitor()` commits it in
4398 // the same cycle — the commit now follows this
4399 // output stage, so the pending raise is what
4400 // reaches SEVR/STAT (a direct `stat`/`sevr`
4401 // poke would be overwritten by the commit).
4402 crate::server::recgbl::rec_gbl_set_sevr(
4403 &mut instance.common,
4404 crate::server::recgbl::alarm_status::WRITE_ALARM,
4405 crate::server::record::AlarmSeverity::Invalid,
4406 );
4407 }
4408 }
4409 Err(e) => {
4410 eprintln!("device write_begin error on {}: {e}", instance.name);
4411 crate::server::recgbl::rec_gbl_set_sevr(
4412 &mut instance.common,
4413 crate::server::recgbl::alarm_status::WRITE_ALARM,
4414 crate::server::record::AlarmSeverity::Invalid,
4415 );
4416 }
4417 }
4418 instance.device = Some(dev);
4419 }
4420 None
4421 };
4422
4423 // PUTF / put-notify wait-set / source alarm for every write of this
4424 // cycle. C `dbDbPutValue` (dbDbLink.c:382-383) inherits the source's
4425 // PENDING alarm (`psrce->nsta/nsev/namsg`) — this is the point in the
4426 // cycle C reads them, before the commit. Captured under the Segment-D
4427 // guard, which then closes.
4428 let src_putf = instance.common.putf;
4429 let src_notify = instance.notify.clone();
4430 let src_alarm = super::links::LinkAlarm::pending(&instance.common);
4431 (out_info, src_putf, src_notify, src_alarm)
4432 };
4433
4434 // C `writeValue` reaches `conditional_write` — whose epilogue
4435 // advances PVAL — on every cycle except the three that return
4436 // before the switch: SIMM simulation (`longoutRecord.c:411-424`
4437 // redirects to SIOL), a failed SIML read or a bad SIMM
4438 // (`:400-403`, `:428-430`), and the IVOA Don't_drive veto, which
4439 // skips the `writeValue` call site altogether (`:169-171`).
4440 let reached_conditional_write = sim_output.is_none() && !sim_write_aborted && !skip_out;
4441
4442 // C `process()` runs every output of the cycle BEFORE `monitor()`,
4443 // and `monitor()` is where `recGblResetAlarms` commits the cycle's
4444 // alarm (aoRecord.c:196-232 → aoRecord.c `monitor`). A failed
4445 // `dbPutLink` raises LINK_ALARM/INVALID from INSIDE the put
4446 // (`setLinkAlarm`, dbLink.c:434-448) — so the write alarm must land
4447 // in THIS cycle's committed SEVR and this cycle's monitor posts,
4448 // not the next one. Every link-carried output of the cycle
4449 // therefore runs here, before the commit below:
4450 //
4451 // * the soft OUT link (`out_info`),
4452 // * the record's multi-output pairs (scalcout / acalcout OUT),
4453 // * the SIMM SIOL redirect,
4454 // * the record's own `WriteDbLink` actions (transform OUTn,
4455 // scaler COUTP, throttle OUT — C writes them before
4456 // `monitor()`/`recGblFwdLink` too).
4457 //
4458 // The record's write gate is released across the writes (a
4459 // self/cyclic OUT link would otherwise dead-lock on the
4460 // non-reentrant gate, exactly as the FLNK tail already runs
4461 // unlocked) and re-acquired for the commit. The put owner raises
4462 // the LINK_ALARM on the record itself, so nothing has to be
4463 // threaded back here.
4464 let (link_writes, deferred_actions): (Vec<_>, Vec<_>) =
4465 process_actions.into_iter().partition(|a| {
4466 matches!(
4467 a,
4468 crate::server::record::ProcessAction::WriteDbLink { .. }
4469 | crate::server::record::ProcessAction::WriteDbLinkNotify { .. }
4470 )
4471 });
4472 let process_actions = deferred_actions;
4473 // await 3 (guard-free): the cycle's link-carried outputs run with the
4474 // data guard released (the put owner raises any LINK_ALARM on the
4475 // record itself). SEG E re-acquires for the alarm commit.
4476 let dispatched = {
4477 let src = super::links::OutLinkSrc {
4478 putf: src_putf,
4479 notify: src_notify.as_ref(),
4480 alarm: &src_alarm,
4481 field: "OUT",
4482 };
4483 if let Some((ref link, ref out_val)) = out_info {
4484 self.write_out_link_value(&rec, link, out_val.clone(), src, visited);
4485 }
4486 // C `longoutRecord.c:492-493`, OUTSIDE `if (doDevSupWrite)`:
4487 // the OOPT reference advances on a suppressed cycle too, which
4488 // is the only reason a transition can ever be detected.
4489 if reached_conditional_write {
4490 rec.write().record.after_output_decision();
4491 }
4492 self.dispatch_multi_output_values(&rec, src, skip_out, visited);
4493 // The value-putting multi-output records — dfanout `OUTn`, seq
4494 // `LNKn` — push HERE, with the record's other outputs, so the
4495 // whole output stage sits between `checkAlarms` and the alarm
4496 // commit exactly as C's does (`dfanoutRecord.c:128-146`
4497 // push_values → monitor; `seqRecord.c:264` dbPutLink →
4498 // asyncFinish's `recGblResetAlarms`, :227). A failed put's
4499 // LINK_ALARM therefore folds into THIS cycle's committed SEVR,
4500 // and the push reads the VAL the IVOA owner already settled.
4501 // The fanout dispatch stays in the forward-link tail: its
4502 // `LNKn` are `DBF_FWDLINK` (dbScanFwdLink), driving no value.
4503 let dispatched = self.dispatch_multi_output(
4504 &rec,
4505 super::links::MultiOutPhase::Output { skip_out },
4506 visited,
4507 );
4508 self.write_simulated_output_siol(&rec, &sim_output, skip_out, src, visited);
4509 self.execute_process_actions(name, &rec, link_writes, visited);
4510 // Every link-carried output of the cycle has now run, so the
4511 // withheld stores become visible here — still ahead of Segment
4512 // E, which therefore change-detects against the published value
4513 // and does not post it a second time.
4514 self.publish_post_write_fields(name, post_write_fields);
4515 dispatched
4516 };
4517
4518 // The seq record armed its delayed group chain: C `process` has
4519 // set `pact = TRUE` and returned through `processNextLink`
4520 // (`seqRecord.c:143`, `:196`), so THIS cycle commits nothing. The
4521 // alarm/timestamp/monitor/FLNK epilogue is `asyncFinish`'s
4522 // (`:219-241`), reached from the chain's last hop via
4523 // `complete_async_record`. Same shape as the `AsyncPending` arm
4524 // above; PACT was set by the dispatch before it spawned, so the
4525 // chain cannot complete ahead of it.
4526 if dispatched.went_async {
4527 self.execute_process_actions(name, &rec, process_actions, visited);
4528 self.apply_pact_exit(name, &rec, cycle_end.take());
4529 return Ok(());
4530 }
4531 let push_alarm = dispatched.alarm;
4532
4533 // Segment E (guarded): commit alarms, build the snapshot, resolve the
4534 // FLNK target, and yield the `'epilogue` tuple. Re-acquire the data lock.
4535 let mut instance = rec.write();
4536 if let Some((stat, sevr)) = push_alarm {
4537 crate::server::recgbl::rec_gbl_set_sevr(&mut instance.common, stat, sevr);
4538 }
4539
4540 // C `monitor()`: `recGblResetAlarms` transfers nsta/nsev ->
4541 // sevr/stat and detects the alarm change — AFTER every output of
4542 // the cycle, so a failed put's LINK_ALARM is committed here.
4543 let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
4544
4545 // Compute per-field posting masks (after OUT stage so async
4546 // writes don't update MLST/ALST prematurely before returning
4547 // early)
4548 use crate::server::recgbl::EventMask;
4549
4550 // The primary-value VALUE/LOG gate, through the single owner so it
4551 // holds identically on every processing path (`fanout`/`seq`
4552 // trigger-VAL suppression included).
4553 let (include_val, include_archive) = instance.value_include_classes();
4554 // C `recGblResetAlarms` returns `val_mask = DBE_ALARM`
4555 // (recGbl.c:194/203/212) when the severity/status OR the
4556 // alarm message moved — every monitored-value post this
4557 // cycle carries DBE_ALARM so a `DBE_ALARM`-only subscriber
4558 // sees the value at the moment the alarm changed.
4559 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
4560 EventMask::ALARM
4561 } else {
4562 EventMask::NONE
4563 };
4564
4565 // Build snapshot
4566 let mut changed_fields = Vec::new();
4567 // The deadband-tracked field posts with the classes that
4568 // actually fired: MDEL crossing → DBE_VALUE, ADEL crossing
4569 // → DBE_LOG, alarm movement → DBE_ALARM — and nothing else
4570 // (C `monitor()` per-field masks: motorRecord.cc:3476-3507
4571 // RBV, aiRecord.c VAL). For most records the tracked field
4572 // IS the primary value; a record like motor deadbands its
4573 // readback, and its VAL routes through the generic
4574 // change-detection loop below — an unchanged setpoint is
4575 // not re-posted on every readback poll.
4576 let deadband_field = instance.record.monitor_deadband_field();
4577 // The mask every change-detected aux field posts with — owned by
4578 // `AuxPostMask`, the single resolver of the record's declared
4579 // narrowings of C's default `monitor_mask | DBE_VALUE | DBE_LOG`.
4580 let aux_post = AuxPostMask::of(instance.record.as_ref());
4581 // The deadband field's post — mask owned by `deadband_post`, the
4582 // single assembler for C's `db_post_events(&prec->val, monitor_mask)`.
4583 let deadband = instance.deadband_post(alarm_bits, include_val, include_archive);
4584 let deadband_mask = deadband.mask;
4585 if let Some((field, value)) = deadband.field {
4586 changed_fields.push((field, value, deadband_mask));
4587 }
4588 // The cycle's subscriber posts — assembled by the single owner
4589 // `RecordInstance::collect_subscriber_posts`, shared by every
4590 // processing path so no rule can hold on one path and not another.
4591 changed_fields.extend(instance.collect_subscriber_posts(
4592 deadband_field,
4593 deadband_mask,
4594 alarm_bits,
4595 aux_post,
4596 include_val,
4597 ));
4598 // C waveform/aai/aao `monitor()` posts HASH with a literal
4599 // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
4600 // 317-319), independent of the VAL post mask. `array_hash_changed`
4601 // was set by `check_deadband_ext` this cycle.
4602 if instance.array_hash_changed {
4603 if let Some(h) = instance.resolve_field("HASH") {
4604 changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
4605 }
4606 }
4607 // The SEVR/STAT/AMSG/ACKS posts `recGblResetAlarms` makes, each
4608 // with its own C mask — see `alarm_field_posts`. Deferred to
4609 // dedicated `notify_field` calls fired after the snapshot notify
4610 // below. The `CompleteAlarmOnly` break above uses the same helper,
4611 // so the alarm-post masks have a single owner.
4612 let alarm_posts = alarm_field_posts(&instance.common, &alarm_result);
4613 // NO `.UDF` post. C `monitor()` never posts UDF, and neither does
4614 // `recGblResetAlarms` (recGbl.c:202-222 posts SEVR/STAT/AMSG/ACKS
4615 // only): `db_post_events(..., &prec->udf, ...)` appears nowhere in
4616 // EPICS base or the modules. UDF reaches a `.UDF` subscriber only
4617 // through the generic put path (C `dbPut` posts the field it
4618 // wrote, dbAccess.c:1411-1413) — a processing cycle that redefines
4619 // VAL emits no `.UDF` event.
4620 let snapshot = crate::server::record::ProcessSnapshot { changed_fields };
4621
4622 let flnk_name = if instance.record.should_fire_forward_link() {
4623 if let crate::server::record::ParsedLink::Db(ref l) = instance.parsed_flnk {
4624 Some(l.target().record)
4625 } else {
4626 None
4627 }
4628 } else {
4629 None
4630 };
4631
4632 // Put-notify completion is NOT fired here. Firing before the
4633 // OUT/FLNK/process-action tail (below) would report the
4634 // WRITE_NOTIFY done while the chain it triggers — including
4635 // an async FLNK target — is still running (C `dbNotify.c`
4636 // keeps the originating record in the waitList until the
4637 // chain settles). The originating record instead `leave`s
4638 // the wait-set at the END of this function, after every PP
4639 // target it drives has joined. See `complete_put_notify`
4640 // at the tail.
4641
4642 (
4643 snapshot,
4644 flnk_name,
4645 process_actions,
4646 alarm_posts,
4647 result_is_defer_output,
4648 restamps_after,
4649 )
4650 };
4651
4652 // 3. Notify subscribers (outside lock)
4653 let posts = {
4654 // Write guard: a value-class post advances the record's
4655 // already-published state (`RecordInstance::record_value_post`),
4656 // so posting is a `&mut` operation.
4657 let mut instance = rec.write();
4658 instance.notify_from_snapshot(&snapshot, link_backing);
4659 // Post the alarm fields (SEVR/STAT/AMSG/ACKS) with their
4660 // individual C masks — see recGblResetAlarms above.
4661 let mut posts = CyclePosts::of(&snapshot);
4662 for &(field, mask) in &alarm_posts {
4663 instance.notify_field(field, mask);
4664 posts = posts.with(mask);
4665 }
4666 posts
4667 };
4668
4669 // C `swaitRecord.c::process` (lines 425-481): `schedOutput` armed the
4670 // ODLY watchdog (`async=TRUE`), so `process` ran `monitor()` — the
4671 // value-publication epilogue above just posted VAL + the alarm fields at
4672 // the START of the delay — but SKIPPED the `if(!async){recGblFwdLink;
4673 // pact=FALSE;}` tail. The OUT write / OEVT are already gated out this
4674 // cycle by `should_output()==false`; `recGblFwdLink` is NOT
4675 // should_output-gated, so the forward-link tail below is skipped when
4676 // deferring (`result_is_defer_output`). The deferred `execOutput` — the
4677 // scheduled `ReprocessAfter` reprocess at delay-END — runs the OUT write
4678 // + OEVT + FLNK. Hold PACT across the wait so a foreign `dbProcess` bails
4679 // at the entry guard (C keeps the record ACTIVE on the watchdog,
4680 // swaitRecord.c:716); the hold is gated on the `ReprocessAfter` that
4681 // releases it (the same by-construction invariant as the
4682 // `AsyncPendingNotify` ODLY defer above). The `ReprocessAfter` itself is
4683 // dispatched by the shared deferred-actions site at the tail, NOT a
4684 // separate `execute_process_actions().await` here — adding one would
4685 // enlarge this hot recursive function's async frame (see the
4686 // `CompleteNoEmit` note above; it overflowed the stack in the deep-chain
4687 // tests).
4688 // Holding `processing=true` also makes the tail's putf-clear (gated on
4689 // `!is_processing()`) a no-op, leaving putf for the continuation.
4690 if result_is_defer_output {
4691 let holds_pact_until_continuation = process_actions
4692 .iter()
4693 .any(|a| matches!(a, crate::server::record::ProcessAction::ReprocessAfter(_)));
4694 if holds_pact_until_continuation {
4695 let instance = rec.write();
4696 instance.enter_pact();
4697 }
4698 }
4699
4700 // Snapshot source PUTF + put-notify wait-set for the C
4701 // `processTarget` / `dbNotifyAdd` invariants (see
4702 // `write_db_link_value` doc), for the FLNK tail below. The cycle's
4703 // value-carrying writes already ran pre-commit (they must, so a failed
4704 // put's LINK_ALARM lands in this cycle's alarm — see the output stage
4705 // above); this is the forward-link half.
4706 let (src_putf, src_notify) = {
4707 let guard = rec.read();
4708 (guard.common.putf, guard.notify.clone())
4709 };
4710
4711 // 4.5 - 7. Multi-output / event / generic-multi-out / FLNK /
4712 // CP / RPRO tail. Shared with the simulation-mode path so a
4713 // simulated record runs the exact same `recGblFwdLink`
4714 // equivalent (C `aiRecord.c:168`).
4715 //
4716 // Skipped on a `CompleteDeferOutput` (swait ODLY) delaying cycle: the
4717 // multi-output / OEVT are already gated out by `should_output()==false`,
4718 // and `recGblFwdLink` runs only at delay-END (C `execOutput`) — the
4719 // continuation drives the whole tail. The deferred-actions site below
4720 // still runs (it dispatches this cycle's `ReprocessAfter`).
4721 if !result_is_defer_output {
4722 self.run_forward_link_tail_with_putf(
4723 name,
4724 &rec,
4725 flnk_name.as_deref(),
4726 TailCtx {
4727 putf: src_putf,
4728 notify: src_notify.as_ref(),
4729 posts,
4730 },
4731 visited,
4732 );
4733 }
4734
4735 // Deferred restamp for a `restamps_time_after_completion` record (sseq):
4736 // C `sseqRecord.c::asyncFinish` calls `recGblGetTimeStamp` (`:501`)
4737 // AFTER the VAL post (`:474`) and `recGblFwdLink` (`:499`). The VAL
4738 // monitor + forward link above therefore carried the record's
4739 // pre-update timestamp; restamp now so TIME advances for the following
4740 // BUSY post (sseq's out-of-band `post_fields`) and the next cycle. Soft
4741 // record (no device support), so `apply_timestamp` resolves TSE→TIME
4742 // the same as the pre-output site it replaces.
4743 if restamps_after {
4744 // Its own TSEL read, not the one Segment C took: C's stamp here is
4745 // a whole `recGblGetTimeStamp` running AFTER `recGblFwdLink`, so a
4746 // `.TIME` TSEL adopts whatever the forward-link chain just did to
4747 // its source.
4748 self.rec_gbl_get_time_stamp(&rec);
4749 }
4750
4751 // 8. Execute the deferred ProcessActions after the FLNK tail:
4752 // `ReprocessAfter` schedules a later reprocess (the current
4753 // cycle's FLNK must proceed first) and `DeviceCommand` posts its
4754 // own monitors after this cycle's snapshot. The record's link writes
4755 // are NOT here — they ran pre-commit with the rest of the cycle's
4756 // output (C `transformRecord.c:605-621` / `scalerRecord.c:457-480`
4757 // put before `monitor()` + `recGblFwdLink()`), so a downstream FLNK
4758 // target still reads the freshly written value.
4759 self.execute_process_actions(name, &rec, process_actions, visited);
4760
4761 // 9. C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` at the
4762 // tail of every synchronous process cycle, NOT just on the
4763 // foreign-entry path. When this record was driven through an
4764 // OUT-link propagation (write_db_link_value set our putf), the
4765 // target record's own process cycle must clear it before
4766 // returning — same lifecycle as the source record's PUTF
4767 // (which `put_record_field_from_ca` separately clears at the
4768 // foreign-entry boundary, and the async branch clears in
4769 // `complete_async_record_inner`). Async-pending records skip
4770 // this clear: their FLNK / putf-clear happens later in
4771 // `complete_async_record_inner` once the device round-trip
4772 // completes.
4773 // The guard holds both releases — `check_simulation_mode`'s SDLY/SIM
4774 // continuation and the `is_continuation` arm's — merged. At most one of
4775 // them can carry the parked put. Taking it here disarms the guard, so
4776 // the release happens once whether the cycle reaches this line or leaves
4777 // by one of the exits above.
4778 self.end_process_cycle(name, &rec, cycle_end.take());
4779
4780 Ok(())
4781 }
4782
4783 /// The end of a synchronous process cycle — C `recGblFwdLink`'s tail
4784 /// (`recGbl.c:295-302`), after `dbScanFwdLink`:
4785 ///
4786 /// ```c
4787 /// if (pdbc->ppn) dbNotifyCompletion(pdbc); /* leave the wait-set; queue the restart */
4788 /// ...
4789 /// pdbc->putf = FALSE;
4790 /// ```
4791 ///
4792 /// The single owner of both halves, so no cycle end can skip them. Open-coded
4793 /// at the tail of `process_record_with_links_inner` alone, it was jumped over
4794 /// by the two simulation early-returns: a put-notify on a SIMM record never
4795 /// left its wait-set (the callback never fired) and PUTF leaked into the next
4796 /// scan.
4797 fn end_process_cycle(
4798 &self,
4799 name: &str,
4800 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
4801 exit: PactExit,
4802 ) {
4803 {
4804 let mut guard = rec.write();
4805 // C `recGblFwdLink:302` clears `putf = FALSE` at the tail of every
4806 // synchronous cycle, NOT just the foreign-entry path: a record driven
4807 // through an OUT-link propagation (`write_db_link_value` set its
4808 // putf) must clear it before returning. Async-pending records skip
4809 // the clear — their FLNK / putf-clear happen later, in
4810 // `complete_async_record_inner`, once the device round-trip
4811 // completes.
4812 if !guard.is_processing() {
4813 guard.common.putf = false;
4814 }
4815 // The record `leave`s the wait-set only here, after its full
4816 // OUT/FLNK/process-action tail has run — so every PP target it drove
4817 // has already joined (`enter`ed). Whether this cycle may leave at
4818 // all is `complete_put_notify`'s decision, not this site's: a record
4819 // reporting more work (motor mid-move) or declining its forward link
4820 // (busy at VAL=1) keeps its membership and leaves on the later cycle
4821 // that reaches C's `recGblFwdLink`.
4822 complete_put_notify(&mut guard);
4823 }
4824 self.apply_pact_exit(name, rec, exit);
4825 }
4826
4827 /// C `restartCheck` (`dbNotify.c:149-170`), reached from
4828 /// `dbNotifyCompletion` (`:445-475`) via `recGblFwdLink` (`recGbl.c:295`)
4829 /// at the tail of the cycle that released the record.
4830 ///
4831 /// **The single owner of the restart-list drain.** Every cycle end routes
4832 /// through it — including cycles that released no PACT, because a notify
4833 /// queued behind an in-flight wait-set on an idle record is freed by
4834 /// `complete_put_notify` above, not by a PACT release.
4835 ///
4836 /// Queued, not recursed — the same `scanOnce` shape as the RPRO restart.
4837 /// The pop itself happens inside `restart_next_notify_put`, under the
4838 /// record's advisory write gate, so a client put racing this spawn cannot
4839 /// take the record between the pop and the replay and thereby overtake a
4840 /// notify that has been waiting longer.
4841 ///
4842 /// `rec` is the record the restart re-enters. It is a parameter, and not a
4843 /// `get_record(name)` inside, because the consumer must be free to read the
4844 /// record: every caller must therefore already have let the record's DATA
4845 /// lock go, which a handle in hand makes visible at the call and a name
4846 /// lookup would hide. `parking_lot::RwLock` is not reentrant, so a caller
4847 /// still holding `rec.write()` would deadlock, not fail.
4848 pub(super) fn apply_pact_exit(
4849 &self,
4850 name: &str,
4851 _rec: &Arc<parking_lot::RwLock<RecordInstance>>,
4852 exit: PactExit,
4853 ) {
4854 // NO record lock here, deliberately. This runs from cycle tails and
4855 // from a `Drop` that can fire while a `rec.write()` guard is still
4856 // alive in the same scope; parking_lot is not reentrant, so a read
4857 // here would deadlock on drop order. The bit was minted under the
4858 // releasing site's own lock instead — see `PactExit`.
4859 if !exit.restart_pending() {
4860 return;
4861 }
4862 let db = self.clone();
4863 let put_name = name.to_string();
4864 // C pins every put-notify callback to the low band —
4865 // `callbackSetPriority(priorityLow, &pnotifyPvt->callback)`
4866 // (`dbNotify.c:131`) — regardless of the record's PRIO.
4867 crate::runtime::task::spawn_background(
4868 crate::runtime::task::CallbackPriority::Low,
4869 async move {
4870 db.restart_next_notify_put(&put_name).await;
4871 },
4872 );
4873 }
4874
4875 /// Forward-link / CP / RPRO tail for the simulation-mode path.
4876 ///
4877 /// C `aiRecord.c:151-168`: a record in SIMM mode handles the value
4878 /// inside `readValue()`, then `process()` still runs `monitor` +
4879 /// `recGblFwdLink(prec)`. The simulation path in
4880 /// `process_record_with_links_inner` does its own monitor posting,
4881 /// so this drives the forward-link / CP / RPRO tail that
4882 /// `recGblFwdLink` would. `flnk_name` and `src_putf` are derived
4883 /// fresh from the record (a simulated cycle does not change FLNK,
4884 /// and SIOL reads/writes do not carry a foreign PUTF into the
4885 /// chain).
4886 fn run_forward_link_tail(
4887 &self,
4888 name: &str,
4889 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
4890 posts: CyclePosts,
4891 visited: &mut std::collections::HashSet<String>,
4892 ) {
4893 let (flnk_name, src_putf, src_notify) = {
4894 let instance = rec.read();
4895 let flnk = if instance.record.should_fire_forward_link() {
4896 if let crate::server::record::ParsedLink::Db(ref l) = instance.parsed_flnk {
4897 Some(l.target().record)
4898 } else {
4899 None
4900 }
4901 } else {
4902 None
4903 };
4904 (flnk, instance.common.putf, instance.notify.clone())
4905 };
4906 self.run_forward_link_tail_with_putf(
4907 name,
4908 rec,
4909 flnk_name.as_deref(),
4910 TailCtx {
4911 putf: src_putf,
4912 notify: src_notify.as_ref(),
4913 posts,
4914 },
4915 visited,
4916 );
4917 }
4918
4919 /// Steps 4.5 - 7 of the process chain: multi-output dispatch,
4920 /// event-record posting, generic OUTA..OUTP links, FLNK forward
4921 /// link, CP-target dispatch, and RPRO reprocess. Shared by the
4922 /// main process path and the simulation-mode path so both run the
4923 /// identical `recGblFwdLink` equivalent.
4924 fn run_forward_link_tail_with_putf(
4925 &self,
4926 name: &str,
4927 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
4928 flnk_name: Option<&str>,
4929 src: TailCtx<'_>,
4930 visited: &mut std::collections::HashSet<String>,
4931 ) {
4932 // 4.5. Multi-output dispatch, forward-link phase: fanout only. Its
4933 // `LNK0..LNKF` are `DBF_FWDLINK` — `dbScanFwdLink`, no value, no put
4934 // status, so the tail is where they belong. dfanout `OUTn` and seq
4935 // `LNKn` carry a value through `dbPutLink` and dispatch pre-commit in
4936 // `process_record_with_links_inner`, so a failed put's LINK_ALARM
4937 // folds into the same cycle's SEVR; the `ForwardLink` phase argument
4938 // skips them here (`multi_out_phase_of`).
4939 let _ = self.dispatch_multi_output(rec, super::links::MultiOutPhase::ForwardLink, visited);
4940
4941 // 4.55. event record: post the named software event.
4942 self.dispatch_event_record(rec);
4943
4944 // The generic multi-output OUT writes (scalcout / acalcout OUT->OVAL)
4945 // are NOT part of this tail: C performs a record's output writes inside
4946 // `process()` BEFORE `monitor()` commits the cycle's alarm, so they run
4947 // pre-commit in `dispatch_multi_output_values` (see R14-62). This tail
4948 // is C's `recGblFwdLink` equivalent only.
4949
4950 // 5. FLNK — C `dbScanFwdLink` → `dbScanPassive` → `processTarget`,
4951 // through the single owner that holds the Passive gate.
4952 if let Some(flnk) = flnk_name {
4953 self.process_target(
4954 flnk,
4955 super::links::ProcessTargetGate::ScanPassive,
4956 src.putf,
4957 src.notify,
4958 visited,
4959 );
4960 }
4961
4962 // 5b. FLNK whose target is external (`pva://`/`ca://`): C
4963 // `dbScanFwdLink` dispatches it through the link set's
4964 // `scanForward` (pvalink `pvaScanForward`), a process-only trigger
4965 // of the remote target. The `flnk_name` above only ever names a
4966 // local DB target, so a non-DB FLNK is forwarded here through the
4967 // single owner.
4968 self.dispatch_external_forward_link(rec);
4969
4970 // 6. CP link targets -- holders of a CP/CPP link on this record,
4971 // driven by what this cycle POSTED (see `CyclePosts`), not by the
4972 // fact that it processed.
4973 self.dispatch_cp_targets(name, src.posts, visited);
4974
4975 // 7. RPRO: if reprocess requested, clear flag and queue a
4976 // fresh process pass.
4977 //
4978 // C `recGblFwdLink` (recGbl.c:296-300) consumes RPRO via
4979 // `scanOnce(pdbc)` — the record is QUEUED on the scanOnce ring
4980 // buffer and reprocessed in a separate pass with a fresh lock
4981 // cycle AFTER the current process chain fully unwinds. It does
4982 // NOT recurse inline within the current link chain.
4983 //
4984 // Spawning a detached task is the Rust equivalent of the
4985 // scanOnce queue: the reprocess runs on its own task, so it must
4986 // carry its own `visited` — the current
4987 // chain's set is a `&mut` local to that stack and cannot be
4988 // shared. That is now the ONLY reason for the fresh set. It used
4989 // to be doing double duty as an escape hatch from the cycle
4990 // guard, which over-blocked; the guard is frame-scoped now
4991 // ([`Self::run_process_frame`]), so there is nothing to escape.
4992 {
4993 let needs_rpro = {
4994 let mut instance = rec.write();
4995 if instance.common.rpro != 0 {
4996 instance.common.rpro = 0;
4997 true
4998 } else {
4999 false
5000 }
5001 };
5002 if needs_rpro {
5003 let db = self.clone();
5004 let rpro_name = name.to_string();
5005 // Middle band, not the record's PRIO: C `recGblFwdLink` hands
5006 // RPRO to `scanOnce` (`recGbl.c`), whose single "scanOnce"
5007 // thread runs at `epicsThreadPriorityScanLow + nPeriodic`
5008 // (`dbScan.c:770-779`) and is not a callback band at all.
5009 crate::runtime::task::spawn_background(
5010 crate::runtime::task::CallbackPriority::Medium,
5011 async move {
5012 let mut fresh_visited = std::collections::HashSet::new();
5013 let _ = db
5014 .process_record_with_links(&rpro_name, &mut fresh_visited)
5015 .await;
5016 },
5017 );
5018 }
5019 }
5020 }
5021
5022 /// Fire a non-DB (external `pva://`/`ca://`) forward link (FLNK).
5023 ///
5024 /// C `recGblFwdLink` → `dbScanFwdLink` (`dbLink.c:475-480`) dispatches
5025 /// every FLNK uniformly through `plink->lset->scanForward`: a DB lset
5026 /// runs `scanOnce(target)` — handled directly by the local FLNK §5
5027 /// path — while the pvalink/calink lset runs `pvaScanForward`, a
5028 /// process-only trigger of the remote target. The DB-only `flnk_name`
5029 /// filter at the three `should_fire_forward_link` sites dropped every
5030 /// external FLNK; this is the single owner that forwards them, so the
5031 /// dispatch is not open-coded per site (each FLNK tail calls only
5032 /// this).
5033 ///
5034 /// On a non-retry, disconnected link the lset returns `Err`; pvxs
5035 /// raises `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")` on
5036 /// the owning record (`pvxs/ioc/pvalink_lset.cpp:677-680`). This raises
5037 /// the same *pending* LINK/INVALID alarm via [`rec_gbl_set_sevr_msg`](crate::server::recgbl::rec_gbl_set_sevr_msg),
5038 /// promoted by the next `recGblResetAlarms` — exactly as the C late-set
5039 /// inside `recGblFwdLink` (after the record's own alarm/monitor stage)
5040 /// is.
5041 fn dispatch_external_forward_link(&self, rec: &Arc<parking_lot::RwLock<RecordInstance>>) {
5042 let target = {
5043 let instance = rec.read();
5044 if !instance.record.should_fire_forward_link() {
5045 return;
5046 }
5047 match &instance.parsed_flnk {
5048 crate::server::record::ParsedLink::Pva(_)
5049 | crate::server::record::ParsedLink::PvaJson(_)
5050 | crate::server::record::ParsedLink::Ca(_) => instance
5051 .parsed_flnk
5052 .external_pv_name()
5053 .map(|s| s.to_string()),
5054 // A DB FLNK is processed by the local §5 scanOnce path;
5055 // every other kind (Constant/Hw/Calc/None) carries no
5056 // forward action.
5057 _ => None,
5058 }
5059 };
5060 let Some(target) = target else {
5061 return;
5062 };
5063 if let Err(e) = self.scan_forward_external_pv(&target) {
5064 let _ = e;
5065 let mut instance = rec.write();
5066 crate::server::recgbl::rec_gbl_set_sevr_msg(
5067 &mut instance.common,
5068 crate::server::recgbl::alarm_status::LINK_ALARM,
5069 crate::server::record::AlarmSeverity::Invalid,
5070 "Disconn",
5071 );
5072 }
5073 }
5074
5075 /// One record-declared input link read — the framework's `dbGetLink`.
5076 ///
5077 /// The value goes into `target_field`; the return is C's
5078 /// `RTN_SUCCESS(dbGetLink(...))` and nothing finer, because C's callers have
5079 /// nothing finer: `dbGetLink` hands back one `long status`, and every reader
5080 /// of it — `motorRecord.cc:3687`, `epidRecord.c:191`, `aaoRecord.c`'s
5081 /// `fetchValue` — asks only whether it was zero.
5082 ///
5083 /// `true` is that zero, and it covers the reads that delivered NO value as
5084 /// well as the ones that did: an empty link, a CONSTANT link
5085 /// (`dbConstGetValue`, `dbConstLink.c:219-225`, sets `*pnRequest = 0` and
5086 /// returns 0), and the source class the record has no case for (C's
5087 /// `default:` — `dbGetLink` is never called, so `status` keeps the 0 it was
5088 /// initialised with). `false` is the non-zero status: a dead DB target, a
5089 /// disconnected CA link, a value the target field rejects.
5090 ///
5091 /// Returning `Option<bool>` here — "nothing attempted" apart from "no
5092 /// value" — invited [`Self::execute_read_db_links`] to report only
5093 /// `Some(true)` as resolved, which made a CONSTANT link indistinguishable
5094 /// from a failed one to every record reading that report. A motor with a
5095 /// constant `RDBL` stopped its own axis (`motorRecord.cc:3690-3697`) on a
5096 /// read C calls successful. The multi-input fetch loop, reading the same
5097 /// links on the same records, always used C's rule.
5098 ///
5099 /// On the `false` side C `dbGetLink` (`dbLink.c:316-323`) runs
5100 /// `setLinkAlarm(plink)`, i.e. `recGblSetSevrMsg(precord, LINK_ALARM,
5101 /// INVALID_ALARM, "%s", dbLinkFieldName(plink))` — so the failure raises
5102 /// LINK/INVALID carrying the link's field name as the AMSG, right here, as
5103 /// an effect of the read itself. Every caller inherits it; none can forget
5104 /// it.
5105 ///
5106 /// A HEALTHY read is the other half of the same C function: `dbDbGetValue`
5107 /// ends with `recGblInheritSevrMsg` (`dbDbLink.c:228-232`), so an
5108 /// `field(INP,"SRC MS")` on a compress / aao-DOL / epid link raises the
5109 /// READER to the source's severity. That inheritance runs here too, through
5110 /// `input_link_inheritance` — the same owner the multi-input
5111 /// fetch uses.
5112 ///
5113 /// The DBR class of the read is the RECORD's
5114 /// ([`Record::input_link_read_as`](crate::server::record::Record::input_link_read_as), C's `dbGetLink` `dbrType` argument),
5115 /// resolved from the SOURCE's metadata by the same owner the OUT side uses
5116 /// ([`Self::resolve_out_target`]): a record that switches on the source's
5117 /// DBF class (sseq `DOLn`, `sseqRecord.c:640-705`) gets the value C's
5118 /// `dbGetLink` would deliver — an `ENUM`/`MENU` source's LABEL, a `CHAR`
5119 /// array's bytes — instead of a native value it would have to guess at.
5120 /// `None` from the record is C's `default: break`: no read, no alarm.
5121 fn read_db_link_into_field(
5122 &self,
5123 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
5124 link_field: &'static str,
5125 target_field: &'static str,
5126 visited: &mut HashSet<String>,
5127 ) -> bool {
5128 let (reader_name, link_str) = {
5129 let instance = rec.read();
5130 let link_str = instance
5131 .record
5132 .get_field(link_field)
5133 .and_then(|v| {
5134 if let EpicsValue::String(s) = v {
5135 Some(s)
5136 } else {
5137 None
5138 }
5139 })
5140 .unwrap_or_default();
5141 (instance.name.clone(), link_str)
5142 };
5143 // An empty link IS a CONSTANT link in C (`dbConstLink.c`'s lset with a
5144 // NULL string), and `dbConstGetValue` returns 0 for it.
5145 if link_str.is_empty() {
5146 return true;
5147 }
5148 let parsed = crate::server::record::parse_link_v2(link_str.as_str_lossy().as_ref());
5149 // The source's DBF class + element count (C `dbGetLinkDBFtype` /
5150 // `dbGetNelements` — the same lset accessors the OUT side asks of a
5151 // destination), resolved with NO record lock held: a self-referencing
5152 // link would otherwise re-enter this record's own gate.
5153 let source = self.resolve_out_target(&parsed);
5154 let read_as = {
5155 let instance = rec.read();
5156 instance.record.input_link_read_as(link_field, &source)
5157 };
5158 // C's `default:` arm — the record's switch has no case for this source
5159 // class, so `dbGetLink` is never called: nothing is attempted, the
5160 // untouched `status` raises no link alarm, and it is still zero.
5161 let Some(read_as) = read_as else {
5162 return true;
5163 };
5164 use crate::server::recgbl::simm::LinkFetch;
5165 match self.read_link_value_as(&parsed, read_as, visited) {
5166 // C `dbConstGetValue`: SUCCESS with nothing written. The target
5167 // field keeps what it holds (a client's `caput SELN 5` survives a
5168 // `field(SELL,"3")`), no LINK alarm is raised, and the link did NOT
5169 // deliver. The constant reached the record once, at init, via
5170 // `rec_gbl_init_constant_links`. Status 0 all the same, so the
5171 // record is told the read SUCCEEDED — C's `dbGetLink` on a constant
5172 // returns 0, and `motorRecord.cc:3690` stops the axis on non-zero.
5173 LinkFetch::NoData => true,
5174 LinkFetch::Value(value) => {
5175 // C `dbDbGetValue` tail (dbDbLink.c:228-232): a healthy read
5176 // folds the SOURCE's committed alarm into the READER per the
5177 // link's MS class. The source has already been processed above
5178 // (a PP link), so its alarm is the one this cycle sees.
5179 let inheritance = {
5180 let alarm = self.read_link_with_alarm(&parsed).1;
5181 self.input_link_inheritance(&reader_name, &parsed, alarm)
5182 };
5183 let mut instance = rec.write();
5184 // A value the target field REJECTS is a failed read, not a
5185 // silent no-op: C `dbGetLink`'s conversion failure comes back as
5186 // a non-zero status and takes the `setLinkAlarm` path
5187 // (`dbLink.c:316-323`) exactly like a dead target. Discarding it
5188 // left the target field holding its previous value with no
5189 // alarm to say so.
5190 let stored = instance
5191 .record
5192 .put_field_internal(target_field, value)
5193 .is_ok();
5194 if !stored {
5195 crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, link_field);
5196 return false;
5197 }
5198 if let Some((ms, alarm)) = inheritance {
5199 super::links::inherit_sevr_msg(&mut instance.common, ms, &alarm);
5200 }
5201 true
5202 }
5203 LinkFetch::Failed => {
5204 let mut instance = rec.write();
5205 crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, link_field);
5206 false
5207 }
5208 }
5209 }
5210
5211 /// Execute the ReadDbLink actions of a stage, and report which
5212 /// `link_field`s C would call a SUCCESSFUL `dbGetLink` — see
5213 /// [`Self::read_db_link_into_field`], which owns the read (and its
5214 /// LINK/INVALID alarm on failure).
5215 ///
5216 /// One list, one meaning: the multi-input fetch loop feeds the same
5217 /// `set_resolved_input_links` report on the same predicate
5218 /// ([`LinkFetch::is_ok`](crate::server::recgbl::simm::LinkFetch::is_ok), C's
5219 /// `status == 0`), so a record deriving "this link failed" from absence gets
5220 /// the same answer whichever path read it.
5221 fn execute_read_db_links(
5222 &self,
5223 _record_name: &str,
5224 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
5225 actions: &[crate::server::record::ProcessAction],
5226 visited: &mut HashSet<String>,
5227 ) -> Vec<&'static str> {
5228 use crate::server::record::ProcessAction;
5229 let mut resolved = Vec::new();
5230 for action in actions {
5231 match action {
5232 ProcessAction::ReadDbLink {
5233 link_field,
5234 target_field,
5235 } => {
5236 if self.read_db_link_into_field(rec, link_field, target_field, visited) {
5237 resolved.push(*link_field);
5238 }
5239 }
5240 // The OUT-link twin: resolve the target's class and hand it to
5241 // the record, so its `process()` can branch on it (C's
5242 // `checkLinks`-cached `lnk_field_type`).
5243 ProcessAction::ResolveOutTarget { link_field } => {
5244 self.resolve_out_target_into_record(rec, link_field);
5245 }
5246 _ => {}
5247 }
5248 }
5249 resolved
5250 }
5251
5252 /// Resolve one OUT link's TARGET and hand it to the record ahead of
5253 /// `process()` — [`ProcessAction::ResolveOutTarget`](crate::server::record::ProcessAction::ResolveOutTarget).
5254 ///
5255 /// The record's own link string is the input, so an empty/constant `LNKn`
5256 /// resolves to [`OutTarget::UNRESOLVED`](crate::server::record::OutTarget::UNRESOLVED) and the record sees "no target",
5257 /// which is the answer C's `default:` arm acts on.
5258 fn resolve_out_target_into_record(
5259 &self,
5260 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
5261 link_field: &'static str,
5262 ) {
5263 let link_str = match rec.read().record.get_field(link_field) {
5264 Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
5265 _ => String::new(),
5266 };
5267 let parsed = crate::server::record::parse_output_link_v2(&link_str);
5268 let target = self.resolve_out_target(&parsed);
5269 rec.write()
5270 .record
5271 .set_resolved_out_target(link_field, target);
5272 }
5273
5274 /// Execute ProcessActions returned by a record's process() call.
5275 ///
5276 /// Actions are executed in order:
5277 /// - ReadDbLink: reads a linked PV value and writes it into a record field
5278 /// (bypasses read-only checks via put_field_internal)
5279 /// - WriteDbLink: writes a value to a linked PV
5280 /// - ReprocessAfter: schedules a delayed re-process via tokio::spawn
5281 pub(super) fn execute_process_actions(
5282 &self,
5283 record_name: &str,
5284 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
5285 actions: Vec<crate::server::record::ProcessAction>,
5286 visited: &mut HashSet<String>,
5287 ) {
5288 use crate::server::record::ProcessAction;
5289
5290 for action in actions {
5291 match action {
5292 ProcessAction::ReadDbLink {
5293 link_field,
5294 target_field,
5295 } => {
5296 // The read (and the LINK/INVALID alarm a failed one raises,
5297 // C `dbGetLink` -> `setLinkAlarm`) belongs to ONE owner, so
5298 // an input link cannot fail silently on one stage and
5299 // loudly on another.
5300 let _ = self.read_db_link_into_field(rec, link_field, target_field, visited);
5301 }
5302 // A pre-process action (the record asks for the target BEFORE it
5303 // decides), so it is a no-op if it reaches the post-process
5304 // stage — the resolve here would be too late to change anything.
5305 ProcessAction::ResolveOutTarget { .. } => {}
5306 ProcessAction::WriteDbLink { link_field, value } => {
5307 // 1. Get the link string (record fields → common fields)
5308 // and the source PUTF for processTarget propagation,
5309 // plus the PENDING alarm for `recGblInheritSevrMsg`
5310 // MS-class propagation into the OUT-link target — this
5311 // write stage runs before the cycle's
5312 // `rec_gbl_reset_alarms`, exactly where C reads
5313 // `psrce->nsta/nsev/namsg` ([`LinkAlarm::pending`]).
5314 let (link_str, src_putf, src_notify, src_alarm) = {
5315 let instance = rec.read();
5316 let link = instance
5317 .resolve_field(link_field)
5318 .and_then(|v| {
5319 if let EpicsValue::String(s) = v {
5320 Some(s)
5321 } else {
5322 None
5323 }
5324 })
5325 .unwrap_or_default();
5326 (
5327 link,
5328 instance.common.putf,
5329 instance.notify.clone(),
5330 super::links::LinkAlarm::pending(&instance.common),
5331 )
5332 };
5333 if link_str.is_empty() {
5334 // No link to put through: C `dbPutLink` on an
5335 // unresolved link is a failure, and the emitter is
5336 // told so — every emitted action reports exactly once,
5337 // so a record deriving a field from the result cannot
5338 // be left holding a stale one.
5339 rec.write()
5340 .record
5341 .set_out_link_write_status(link_field, &value, true);
5342 continue;
5343 }
5344 // 2. Parse and write to the linked PV — DB *or*
5345 // external `ca://`/`pva://`. A record's `process()`
5346 // emits `WriteDbLink` to drive an OUT-link field
5347 // (transform `OUTn`, throttle/scaler `COUTP`, epid
5348 // `TRIG`/`OUTL`); that field may resolve to a CA/PVA
5349 // link, which C `dbPutLink` routes through the link
5350 // set's `putValue` identically to a DB link
5351 // (dbLink.c:434-448). The field is a `DBF_OUTLINK`, so it
5352 // carries the OUT modifier mask (`dbStaticLib.c:2382-2387`).
5353 let parsed = crate::server::record::parse_output_link_v2(
5354 link_str.as_str_lossy().as_ref(),
5355 );
5356 let failed = self.write_out_link_value(
5357 rec,
5358 &parsed,
5359 value.clone(),
5360 super::links::OutLinkSrc {
5361 putf: src_putf,
5362 notify: src_notify.as_ref(),
5363 alarm: &src_alarm,
5364 field: link_field,
5365 },
5366 visited,
5367 );
5368 // The record-owned half of the put's outcome. The alarm
5369 // half was already raised by `write_out_link_value`; this
5370 // is what lets a record keep a C-truthful status field
5371 // (throttle STS) instead of committing its own intent.
5372 rec.write()
5373 .record
5374 .set_out_link_write_status(link_field, &value, failed);
5375 }
5376 ProcessAction::DeviceCommand { command, ref args } => {
5377 let mut instance = rec.write();
5378 if let Some(mut dev) = instance.device.take() {
5379 // `handle_command` runs after the process snapshot
5380 // was already built/notified, so any record field
5381 // it mutated needs an explicit monitor post. The
5382 // returned field names are posted with DBE_VALUE,
5383 // mirroring the C record's `db_post_events` calls
5384 // from inside `process()` (scalerRecord.c:425-430).
5385 let changed = dev
5386 .handle_command(&mut *instance.record, command, args)
5387 .unwrap_or_default();
5388 instance.device = Some(dev);
5389 for field in changed {
5390 instance.notify_field(field, crate::server::recgbl::EventMask::VALUE);
5391 }
5392 }
5393 }
5394 ProcessAction::DelayedCallbackAfter(delay) => {
5395 // C `callbackRequestDelayed` whose handler mutates the
5396 // record before `dbProcess` (bo/busy HIGH one-shot). The
5397 // mutation lives in `delayed_callback_fire`, not in
5398 // `process()`, so only this timer can perform it.
5399 self.schedule_delayed_callback(record_name, delay);
5400 }
5401 ProcessAction::ReprocessAfter(delay) => {
5402 // Owner-driven delayed re-entry, mirroring C
5403 // `callbackRequestDelayed` dispatching to
5404 // `(*prset->process)(prec)` directly (callback.c). The
5405 // mint-token + delayed-fire is the single
5406 // `schedule_delayed_reprocess` owner, shared with the
5407 // SDLY async-simulation defer.
5408 self.schedule_delayed_reprocess(record_name, delay);
5409 }
5410 ProcessAction::ArmWatchdog => {
5411 // C `wdogInit` from `special()` (histogram SDEL,
5412 // histogramRecord.c:266-268). The arm owner supersedes any
5413 // tick already in flight.
5414 self.arm_watchdog(record_name);
5415 }
5416 ProcessAction::ScanOnce => {
5417 // C `scanOnce(precord)`. The `if (precord->scan)` guard C
5418 // writes at every `special()` call site (scalerRecord.c:655,
5419 // :667) is owned HERE: a Passive record is already processed
5420 // by the put's own `pp(TRUE)` path (dbAccess.c:1265-1268), so
5421 // scanning it again would double-process; a non-Passive
5422 // record gets no process from the put at all, which is the
5423 // whole reason C makes the call — without it the state
5424 // change waits for the next periodic scan.
5425 let passive = {
5426 let instance = rec.read();
5427 instance.common.scan == crate::server::record::ScanType::Passive
5428 };
5429 if !passive {
5430 // Queued, not awaited: C's `scanOnce` hands the record
5431 // to the scan-once thread, which takes `dbScanLock` —
5432 // the process lands after the putting thread leaves
5433 // `dbPutField` and releases the record gate this call is
5434 // still holding.
5435 let db = self.clone();
5436 let name = record_name.to_string();
5437 // Middle band, not the record's PRIO: `scanOnce` is a
5438 // dedicated thread in C (`dbScan.c:770-779`), not one
5439 // of the three callback queues.
5440 crate::runtime::task::spawn_background(
5441 crate::runtime::task::CallbackPriority::Medium,
5442 async move {
5443 let mut visited = HashSet::new();
5444 let _ = db.process_record_with_links(&name, &mut visited).await;
5445 },
5446 );
5447 }
5448 }
5449 ProcessAction::WriteDbLinkNotify { link_field, value } => {
5450 // C `sseqRecord.c` WAITn put-callback dependency: write
5451 // the OUT link as a put-WITH-completion and re-enter THIS
5452 // record's process() once the downstream record (plus its
5453 // FLNK/OUT chain) finishes. Same OUT-link write a plain
5454 // WriteDbLink performs, wrapped in the c401e2f0 put-notify
5455 // wait-set + async re-entry primitive.
5456 let (link_str, src_putf, src_alarm) = {
5457 let instance = rec.read();
5458 let link = instance
5459 .resolve_field(link_field)
5460 .and_then(|v| {
5461 if let EpicsValue::String(s) = v {
5462 Some(s)
5463 } else {
5464 None
5465 }
5466 })
5467 .unwrap_or_default();
5468 (
5469 link,
5470 instance.common.putf,
5471 super::links::LinkAlarm::pending(&instance.common),
5472 )
5473 };
5474 // Mint the re-entry token BEFORE issuing the put so a
5475 // synchronous downstream completion cannot fire the
5476 // oneshot before the waiter is wired. The mint supersedes
5477 // any prior pending re-entry for this record (newer
5478 // token), exactly like ReprocessAfter.
5479 let token = match self.mint_async_token(record_name) {
5480 Some(t) => t,
5481 None => continue,
5482 };
5483 let (waitset, completion) = Self::new_put_notify();
5484 if !link_str.is_empty() {
5485 // `DBF_OUTLINK` field — OUT modifier mask applies
5486 // (`dbStaticLib.c:2382-2387`).
5487 let parsed = crate::server::record::parse_output_link_v2(
5488 link_str.as_str_lossy().as_ref(),
5489 );
5490 self.write_out_link_value(
5491 rec,
5492 &parsed,
5493 value,
5494 super::links::OutLinkSrc {
5495 putf: src_putf,
5496 notify: Some(&waitset),
5497 alarm: &src_alarm,
5498 field: link_field,
5499 },
5500 visited,
5501 );
5502 }
5503 // Release the initiator's own wait-set count (C
5504 // `dbProcessNotify` holds one count for the requester and
5505 // drops it after issuing the put). The set then drains —
5506 // and fires the completion — when the downstream
5507 // target(s) that joined via `join_put_notify` finish, or
5508 // immediately when the link was empty / the target
5509 // completed synchronously.
5510 waitset.leave();
5511 self.reprocess_on_notify(token, completion);
5512 }
5513 ProcessAction::CancelReprocess => {
5514 // C `callbackCancelDelayed` for `sseq` ABORT: advance the
5515 // record's re-entry generation so any pending DLYn timer
5516 // or WAITn notify re-entry becomes a structural no-op (the
5517 // AsyncToken gate), with no runtime is-aborted check on
5518 // the re-entry path.
5519 self.cancel_async_reentry(record_name);
5520 }
5521 }
5522 }
5523 }
5524
5525 /// Complete an asynchronous record's post-process steps.
5526 /// Call after device support signals completion (clears PACT, runs alarms, snapshot, OUT, FLNK).
5527 ///
5528 /// # The completion RE-TAKES the gate
5529 ///
5530 /// This is the other half of C's async-device shape. `dbProcess` released
5531 /// `dbScanLock` when it set `pact` and returned; the completion runs on the
5532 /// callback task, which takes the record's lock again for the epilogue —
5533 /// C `callback.c:379-388` `ProcessCallback`:
5534 ///
5535 /// ```c
5536 /// dbScanLock(pRec);
5537 /// (*pRec->rset->process)(pRec);
5538 /// dbScanUnlock(pRec);
5539 /// ```
5540 ///
5541 /// So the epilogue below — alarm commit, snapshot, OUT writes, FLNK — runs
5542 /// under the SAME exclusion as the cycle that started it, and a put that
5543 /// arrived during the async window has either already been serialised
5544 /// ahead of it or waits behind it. Every caller reaches this from a
5545 /// completion task holding no gate (the device-write completion spawn
5546 /// above, the seq DLYn chain, the tests); nothing calls it with the gate
5547 /// held, which would dead-lock on the non-reentrant gate.
5548 pub fn complete_async_record<'a>(
5549 &'a self,
5550 name: &'a str,
5551 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
5552 Box::pin(async move {
5553 let canonical: String = self.resolve_alias(name).unwrap_or_else(|| name.to_string());
5554 let _record_gate = self.lock_record(&canonical);
5555 let mut visited = HashSet::new();
5556 self.complete_async_record_inner(name, &mut visited)
5557 })
5558 }
5559
5560 fn complete_async_record_inner(
5561 &self,
5562 name: &str,
5563 visited: &mut HashSet<String>,
5564 ) -> CaResult<()> {
5565 // Alias-aware entry — same pattern as
5566 // `process_record_with_links_inner`. `name` may arrive as an
5567 // alias from an async device-support callback that captured
5568 // the original record name; normalise to canonical so the
5569 // records-map lookup, the `visited` cycle set, and downstream
5570 // FLNK/OUT dispatches all see the same canonical name.
5571 let canonical_owned;
5572 let name: &str = if let Some(target) = self.resolve_alias(name) {
5573 canonical_owned = target;
5574 &canonical_owned
5575 } else {
5576 name
5577 };
5578
5579 let rec = {
5580 let records = self.inner.records.read();
5581 records
5582 .get(name)
5583 .cloned()
5584 .ok_or_else(|| CaError::ChannelNotFound(name.to_string()))?
5585 };
5586
5587 // Seed the cycle guard with this record's own name — mirrors
5588 // the synchronous main path ([`Self::run_process_frame`] does
5589 // `visited.insert(name)` before the body). Without this
5590 // the async-completion FLNK / OUT / CP dispatch can re-enter
5591 // the just-completed record: an async FLNK chain that loops
5592 // back (A async -> completes -> FLNK -> B -> FLNK -> A) would
5593 // re-process A unbounded, because PACT is cleared below before
5594 // the FLNK dispatch and nothing else blocks the re-entry.
5595 //
5596 // This is a frame like any other, so it owes the same unwind at the
5597 // tail — see the invariant on [`Self::run_process_frame`].
5598 if !visited.insert(name.to_string()) {
5599 return Ok(()); // Already on this stack, skip
5600 }
5601
5602 // The async completion is the tail of a cycle, and it posts; it owes
5603 // the same one resolve, at the same no-lock-held point, as the
5604 // synchronous body — see `process_record_with_links_body`.
5605 let link_backing = self.resolve_link_backed_metadata(&rec);
5606 let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
5607
5608 // This pass IS C's `process()` re-entry, so it runs the whole
5609 // `recGblGetTimeStampSimm` again — TSEL read included, before the guard.
5610 let tsel = self.read_tsel(&rec);
5611
5612 let (snapshot, flnk_name, alarm_posts, pact_exit) = {
5613 // Phase 1 — first write guard, confined to this scope so the
5614 // (!Send) parking_lot guard is released before the async OUT
5615 // writes below. Yields the output work plus the put-notify
5616 // source fields those writes consume.
5617 let (out_info, skip_out, src_putf, src_notify, src_alarm) = {
5618 let mut instance = rec.write();
5619
5620 // UDF update before alarm evaluation (C parity — see the
5621 // sync process path). A NaN/undefined value keeps UDF true
5622 // so `recGblCheckUDF` raises UDF_ALARM this cycle.
5623 if instance.record.clears_udf() {
5624 instance.common.udf = instance.record.value_is_undefined() as u8;
5625 }
5626 // Per-record alarm hook (C `checkAlarms()`).
5627 {
5628 let inst = &mut *instance;
5629 inst.record.check_alarms(&mut inst.common);
5630 }
5631
5632 // Evaluate alarms
5633 instance.evaluate_alarms();
5634
5635 // Any soft flavour: the framework owns the transfer, so there
5636 // is no device to take an alarm, time stamp or user tag from.
5637 let is_soft = crate::server::device_support::is_soft_dtyp(&instance.common.dtyp);
5638
5639 // Device support alarm/timestamp override
5640 if !is_soft {
5641 let (dev_alarm, dev_ts, dev_utag) = if let Some(ref dev) = instance.device {
5642 (dev.last_alarm(), dev.last_timestamp(), dev.last_utag())
5643 } else {
5644 (None, None, None)
5645 };
5646 if let Some((stat, sevr)) = dev_alarm {
5647 crate::server::recgbl::rec_gbl_set_sevr(
5648 &mut instance.common,
5649 stat,
5650 crate::server::record::AlarmSeverity::from_u16(sevr),
5651 );
5652 }
5653 if let Some(ts) = dev_ts {
5654 instance.common.time = ts;
5655 }
5656 // C device support writes `prec->utag` directly during
5657 // `read()` — the event-system pulse-id path, since
5658 // `epicsTimeStamp` carries no tag. Adopt the device's
5659 // userTag when it supplies one; read in the same `dev`
5660 // borrow as the timestamp above so the time/tag pair is a
5661 // single consistent device snapshot.
5662 if let Some(utag) = dev_utag {
5663 instance.common.utag = utag;
5664 }
5665 }
5666
5667 // BEFORE the output stage — C `aoRecord.c:190` stamps the record
5668 // ahead of `writeValue` so a downstream TSEL fetch sees this
5669 // cycle's time.
5670 let inst = &mut *instance;
5671 tsel.stamp(&inst.name, &mut inst.common, is_soft);
5672 // UDF was already updated before `evaluate_alarms` above.
5673
5674 // ---- Output stage. C `process()` performs the record's output
5675 // BEFORE `monitor()`, and `monitor()` is where `recGblResetAlarms`
5676 // commits the cycle's alarm — the async-completion re-entry runs
5677 // that same `process()` body. A failed `dbPutLink` raises
5678 // LINK_ALARM/INVALID inside the put (`setLinkAlarm`,
5679 // dbLink.c:434-448), so the commit MUST follow the writes for the
5680 // alarm to land in this cycle's SEVR and monitor posts.
5681
5682 // IVOA check — on the PENDING severity, which is what C's
5683 // `writeValue` call site tests (`if (prec->nsev < INVALID_ALARM)`,
5684 // aoRecord.c:196).
5685 let skip_out =
5686 if instance.common.nsev == crate::server::record::AlarmSeverity::Invalid {
5687 let ivoa = instance
5688 .record
5689 .get_field("IVOA")
5690 .and_then(|v| v.to_menu_index())
5691 .unwrap_or(0);
5692 match ivoa {
5693 1 => true,
5694 2 => {
5695 // See the IVOA=2 comment in
5696 // `process_record_with_links_inner` — IVOA=2
5697 // delegates to the per-record
5698 // `apply_invalid_output_value` so OVAL/RVAL/VAL
5699 // get the C-convention values.
5700 // The same "cannot fail in C" contract as the
5701 // sync arm above; see its note.
5702 if let Some(ivov) = instance.record.get_field("IVOV") {
5703 let applied = instance.record.apply_invalid_output_value(ivov);
5704 debug_assert!(
5705 applied.is_ok(),
5706 "{}: IVOA=Set_output_to_IVOV could not apply IVOV: {:?}",
5707 instance.record.record_type(),
5708 applied.err()
5709 );
5710 }
5711 false
5712 }
5713 _ => false,
5714 }
5715 } else {
5716 false
5717 };
5718
5719 // OEVT: queue the output event when the output fires — same
5720 // IVOA-gated event-twin of the OUT write as
5721 // `process_record_with_links_inner`.
5722 if !skip_out {
5723 if let Some(event_name) = instance.record.output_event() {
5724 let db = self.clone();
5725 // Middle band, not this record's PRIO: C `postEvent`
5726 // fires one `callbackRequest` per non-empty band and
5727 // each carries the *scanned* record's priority
5728 // (`dbScan.c:513-527`), a fan-out the port's single
5729 // Event list cannot express (`scan_index.rs`
5730 // `post_event_named`). The poster's own PRIO is not
5731 // the answer, so this keeps `callbackRequest`'s
5732 // general band (`callback.h:42`).
5733 crate::runtime::task::spawn_background(
5734 crate::runtime::task::CallbackPriority::Medium,
5735 async move {
5736 db.post_event_named(&event_name).await;
5737 },
5738 );
5739 }
5740 }
5741
5742 let can_dev_write = instance.record.can_device_write();
5743 // Same single owner of the DTYP -> soft dset mapping as the
5744 // synchronous OUT stage (`RecordInstance::soft_output_value`).
5745 let soft_out = instance.soft_output_value();
5746 let record_should_output = instance.record.should_output();
5747 let out_info = if skip_out {
5748 None
5749 } else if !can_dev_write {
5750 // Non-output records (calcout, etc.) with soft OUT link
5751 // (DB or external `ca://`/`pva://`).
5752 if record_should_output && instance.parsed_out.is_writable_out_link() {
5753 let out_val = instance.record.output_link_value();
5754 out_val.map(|v| (instance.parsed_out.clone(), v))
5755 } else {
5756 None
5757 }
5758 } else if let Some(out_val) = soft_out {
5759 if instance.parsed_out.is_writable_out_link() {
5760 out_val.map(|v| (instance.parsed_out.clone(), v))
5761 } else {
5762 None
5763 }
5764 } else {
5765 // Non-soft output: the async device write already completed
5766 // (that's why we're in complete_async_record). Don't re-do
5767 // write_begin -- it would start another async cycle.
5768 None
5769 };
5770
5771 // PUTF / put-notify wait-set / source PENDING alarm — the
5772 // values C `dbDbPutValue` reads at the put (dbDbLink.c:382-383
5773 // takes `psrce->nsta/nsev/namsg`). Captured here and returned
5774 // so the OUT writes run with NO record guard held (a self /
5775 // cyclic OUT link would dead-lock on the non-reentrant gate);
5776 // a fresh guard is re-taken below for the commit.
5777 let src_putf = instance.common.putf;
5778 let src_notify = instance.notify.clone();
5779 let src_alarm = super::links::LinkAlarm::pending(&instance.common);
5780 (out_info, skip_out, src_putf, src_notify, src_alarm)
5781 };
5782
5783 // Phase 2 — async OUT writes, no record guard held.
5784 let src = super::links::OutLinkSrc {
5785 putf: src_putf,
5786 notify: src_notify.as_ref(),
5787 alarm: &src_alarm,
5788 field: "OUT",
5789 };
5790 if let Some((ref link, ref out_val)) = out_info {
5791 self.write_out_link_value(&rec, link, out_val.clone(), src, visited);
5792 }
5793 // Same `conditional_write` epilogue as the synchronous stage. C
5794 // runs it on the async device's first pass as well (the record
5795 // returns at `longoutRecord.c:187` only AFTER `writeValue`), and
5796 // the port's first pass returns from `write_begin` before this
5797 // point — so an async longout latched on no pass at all.
5798 if !skip_out {
5799 rec.write().record.after_output_decision();
5800 }
5801 self.dispatch_multi_output_values(&rec, src, skip_out, visited);
5802
5803 // Phase 3 — fresh write guard for the alarm commit + monitor tail.
5804 let mut instance = rec.write();
5805
5806 // C `monitor()`: commit the cycle's alarm — after every output.
5807 let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
5808
5809 // Clear PACT. The release hands back the put-notify parked on this
5810 // window; it is carried to the tail below (C `recGblFwdLink` →
5811 // `dbNotifyCompletion`), never replayed here — the OUT/FLNK chain
5812 // this cycle still owes has not run yet.
5813 let pact_exit = instance.leave_pact();
5814
5815 // Put-notify completion is NOT fired here. The async device
5816 // round-trip has finished, but the OUT/FLNK/process-action
5817 // tail it drives (below) may itself reach an async target;
5818 // firing now would report WRITE_NOTIFY done while that chain
5819 // still runs. The originating record `leave`s the wait-set at
5820 // the END of this function, after every PP target it drives
5821 // has joined. See `complete_put_notify` at the tail.
5822
5823 use crate::server::recgbl::EventMask;
5824 // The primary-value VALUE/LOG gate, through the single owner so it
5825 // holds identically on every processing path (`fanout`/`seq`
5826 // trigger-VAL suppression included).
5827 let (include_val, include_archive) = instance.value_include_classes();
5828 // C `recGblResetAlarms` `val_mask = DBE_ALARM`
5829 // (recGbl.c:194/203/212) — same parity rule as the main
5830 // process path above (see comment there).
5831 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
5832 EventMask::ALARM
5833 } else {
5834 EventMask::NONE
5835 };
5836
5837 let mut changed_fields = Vec::new();
5838 // Same deadband-field routing and per-field mask as the main
5839 // process path: the tracked field posts the classes that
5840 // actually fired (MDEL → DBE_VALUE, ADEL → DBE_LOG, alarm
5841 // movement → DBE_ALARM); a non-primary deadband field
5842 // (motor RBV) leaves VAL to the generic change-detection
5843 // loop below.
5844 let deadband_field = instance.record.monitor_deadband_field();
5845 // The mask every change-detected aux field posts with — owned by
5846 // `AuxPostMask`, the single resolver of the record's declared
5847 // narrowings of C's default `monitor_mask | DBE_VALUE | DBE_LOG`.
5848 let aux_post = AuxPostMask::of(instance.record.as_ref());
5849 // The deadband field's post — mask owned by `deadband_post`, the
5850 // single assembler for C's `db_post_events(&prec->val, monitor_mask)`.
5851 let deadband = instance.deadband_post(alarm_bits, include_val, include_archive);
5852 let deadband_mask = deadband.mask;
5853 if let Some((field, value)) = deadband.field {
5854 changed_fields.push((field, value, deadband_mask));
5855 }
5856 // C `recGblResetAlarms` (recGbl.c:202-222) posts each alarm
5857 // field with its OWN per-field mask. Mirror the synchronous
5858 // link path (`process_record_with_links_inner`) and
5859 // `process_local` exactly: SEVR=DBE_VALUE on a sevr change;
5860 // STAT/AMSG share `stat_mask` which carries DBE_ALARM when
5861 // sevr OR amsg moved and DBE_VALUE on a stat change;
5862 // ACKS=DBE_VALUE only when an alarm field moved AND
5863 // recGblResetAlarms raised it. Collapsing these into
5864 // `changed_fields` would post them all on one shared mask —
5865 // losing C's per-field granularity for `.SEVR`/`.STAT`-only
5866 // subscribers.
5867 let sevr_changed = instance.common.sevr != alarm_result.prev_sevr;
5868 let stat_changed = instance.common.stat != alarm_result.prev_stat;
5869 let stat_mask = {
5870 let mut m = EventMask::NONE;
5871 if sevr_changed || alarm_result.amsg_changed {
5872 m |= EventMask::ALARM;
5873 }
5874 if stat_changed {
5875 m |= EventMask::VALUE;
5876 }
5877 m
5878 };
5879 let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
5880 if sevr_changed {
5881 alarm_posts.push(("SEVR", EventMask::VALUE));
5882 }
5883 if !stat_mask.is_empty() {
5884 alarm_posts.push(("STAT", stat_mask));
5885 alarm_posts.push(("AMSG", stat_mask));
5886 }
5887 // C parity (recGbl.c:214-217): ACKS is posted (DBE_VALUE) whenever
5888 // the alarm-acknowledge rule fires — `acks_posted` already folds in
5889 // C's `if (stat_mask)` guard, and the post carries no value-change
5890 // test.
5891 if alarm_result.acks_posted {
5892 alarm_posts.push(("ACKS", EventMask::VALUE));
5893 }
5894 // The cycle's subscriber posts — assembled by the single owner
5895 // `RecordInstance::collect_subscriber_posts`. Without change
5896 // detection here, every async-completion cycle would re-send every
5897 // subscribed auxiliary field even when unchanged; without the shared
5898 // owner, this path would drift from the scan path on which unchanged
5899 // fields C still posts.
5900 changed_fields.extend(instance.collect_subscriber_posts(
5901 deadband_field,
5902 deadband_mask,
5903 alarm_bits,
5904 aux_post,
5905 include_val,
5906 ));
5907 // C waveform/aai/aao `monitor()` posts HASH with a literal
5908 // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
5909 // 317-319), independent of the VAL post mask. `array_hash_changed`
5910 // was set by `check_deadband_ext` this cycle.
5911 if instance.array_hash_changed {
5912 if let Some(h) = instance.resolve_field("HASH") {
5913 changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
5914 }
5915 }
5916 // No `.UDF` post — see the main process path (C posts UDF from no
5917 // monitor() and from no recGblResetAlarms).
5918 let snapshot = crate::server::record::ProcessSnapshot { changed_fields };
5919
5920 let flnk_name = if instance.record.should_fire_forward_link() {
5921 if let crate::server::record::ParsedLink::Db(ref l) = instance.parsed_flnk {
5922 Some(l.target().record)
5923 } else {
5924 None
5925 }
5926 } else {
5927 None
5928 };
5929
5930 (snapshot, flnk_name, alarm_posts, pact_exit)
5931 };
5932
5933 // Notify subscribers
5934 let posts = {
5935 // Write guard: a value-class post advances the record's
5936 // already-published state (`RecordInstance::record_value_post`),
5937 // so posting is a `&mut` operation.
5938 let mut instance = rec.write();
5939 instance.notify_from_snapshot(&snapshot, link_backing);
5940 // Post the alarm fields (SEVR/STAT/AMSG/ACKS) with their
5941 // individual C masks — see recGblResetAlarms above.
5942 let mut posts = CyclePosts::of(&snapshot);
5943 for &(field, mask) in &alarm_posts {
5944 instance.notify_field(field, mask);
5945 posts = posts.with(mask);
5946 }
5947 posts
5948 };
5949
5950 // Snapshot source PUTF + put-notify wait-set for processTarget /
5951 // dbNotifyAdd propagation (see `write_db_link_value` doc). For the
5952 // async-completion path PUTF would have been set when the put
5953 // landed on the record; it (and wait-set membership) must
5954 // propagate through the (now-completing) FLNK chain so an async
5955 // target reached here also defers WRITE_NOTIFY completion.
5956 let (src_putf, src_notify) = {
5957 let guard = rec.read();
5958 (guard.common.putf, guard.notify.clone())
5959 };
5960
5961 // The record's own OUT link and its generic multi-output pairs were
5962 // written in the pre-commit output stage above — C `process()` runs
5963 // `writeValue` before `monitor()`, and a failed `dbPutLink` must be
5964 // able to raise LINK_ALARM into the alarm this cycle commits
5965 // (dbLink.c:434-448). Only the fanout/seq dispatch and the FLNK tail
5966 // remain here.
5967
5968 // Multi-output dispatch, forward-link phase (fanout). The
5969 // `ForwardLink` phase skips dfanout and seq here, which is correct:
5970 // their value-carrying `OUTn`/`LNKn` are driven pre-commit on the
5971 // processing path. seq DOES reach this function as an async
5972 // completion — it is C's `asyncFinish` for the DLYn group chain
5973 // (`seqRecord.c:219-241`) — and its groups have already run, so
5974 // re-dispatching them here would drive every LNKn twice.
5975 let _ = self.dispatch_multi_output(&rec, super::links::MultiOutPhase::ForwardLink, visited);
5976
5977 // event record: post the named software event.
5978 self.dispatch_event_record(&rec);
5979
5980 // FLNK — the async-completion tail's copy of the same C path, through
5981 // the same single owner (C `dbScanFwdLink` → `dbScanPassive` →
5982 // `processTarget`).
5983 if let Some(ref flnk) = flnk_name {
5984 self.process_target(
5985 flnk,
5986 super::links::ProcessTargetGate::ScanPassive,
5987 src_putf,
5988 src_notify.as_ref(),
5989 visited,
5990 );
5991 }
5992
5993 // FLNK whose target is external (`pva://`/`ca://`): forwarded
5994 // through the same single owner as the synchronous tail (C
5995 // `dbScanFwdLink` → lset `scanForward`). `flnk_name` above only
5996 // names a local DB target.
5997 self.dispatch_external_forward_link(&rec);
5998
5999 // CP link targets — gated on what this cycle posted, as on the
6000 // synchronous tail.
6001 self.dispatch_cp_targets(name, posts, visited);
6002
6003 // RPRO: C `recGblFwdLink` consumes a pending reprocess via
6004 // `scanOnce` — queued, not recursed. Mirror the synchronous
6005 // path: spawn a fresh process pass (clean `visited`).
6006 {
6007 let needs_rpro = {
6008 let mut guard = rec.write();
6009 if guard.common.rpro != 0 {
6010 guard.common.rpro = 0;
6011 true
6012 } else {
6013 false
6014 }
6015 };
6016 if needs_rpro {
6017 let db = self.clone();
6018 let rpro_name = name.to_string();
6019 // Middle band, not the record's PRIO: C `recGblFwdLink` hands
6020 // RPRO to `scanOnce` (`recGbl.c`), whose single "scanOnce"
6021 // thread runs at `epicsThreadPriorityScanLow + nPeriodic`
6022 // (`dbScan.c:770-779`) and is not a callback band at all.
6023 crate::runtime::task::spawn_background(
6024 crate::runtime::task::CallbackPriority::Medium,
6025 async move {
6026 let mut fresh_visited = std::collections::HashSet::new();
6027 let _ = db
6028 .process_record_with_links(&rpro_name, &mut fresh_visited)
6029 .await;
6030 },
6031 );
6032 }
6033 }
6034
6035 // C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` after
6036 // the forward-link dispatch. The same clearing must happen
6037 // at the tail of the async-completion path (this is the moral
6038 // equivalent of the synchronous completion path in
6039 // `put_record_field_from_ca` which clears after
6040 // `process_record_with_links` returns). Without this, a
6041 // record that completed an async write triggered by a
6042 // CA put would keep `putf=1` forever, leaking into every
6043 // subsequent scan-driven process cycle.
6044 {
6045 let mut guard = rec.write();
6046 guard.common.putf = false;
6047 }
6048
6049 // Put-notify completion: the async device round-trip is done and
6050 // the full OUT/FLNK/process-action tail above has run, so every PP
6051 // target it drove has joined the wait-set. The originating record
6052 // now `leave`s; the completion oneshot fires on the `leave` that
6053 // empties the set (i.e. once every joined async target has also
6054 // completed). `complete_put_notify` `take`s the membership, so a
6055 // motor re-entering `complete_async_record_inner` over several
6056 // device cycles leaves exactly once — matching the old fire site,
6057 // which `take`d its oneshot.
6058 {
6059 let mut guard = rec.write();
6060 complete_put_notify(&mut guard);
6061 }
6062
6063 // C `dbNotifyCompletion` (dbNotify.c:459-473) → `restartCheck`: the
6064 // put-notifies that arrived while this record was PACT wrote nothing and
6065 // queued. PACT is clear and this cycle's wait-set has drained, so the
6066 // record is now the idle record the queue head was meant to see — replay
6067 // it whole (value + process + callback), through the single drain owner.
6068 self.apply_pact_exit(name, &rec, pact_exit);
6069
6070 // The unwind for the seed above: this frame is leaving the stack, so
6071 // its marker goes with it (C `dbDbLink.c:521-526`).
6072 visited.remove(name);
6073 Ok(())
6074 }
6075
6076 /// Dispatch CP-link targets that take a CP/CPP input link from `name`,
6077 /// when this cycle published a class the CP subscription selects.
6078 ///
6079 /// **The trigger is a monitor post, never a process.** C serves every
6080 /// CP/CPP link as a CA link — `dbInitLink` tests the modifier BEFORE
6081 /// locality and short-circuits `dbDbInitLink` entirely, so a CP link to a
6082 /// record in this very IOC is still a CA link (`dbLink.c:118-122`; the
6083 /// `isLocal` at `:128` is computed only to pick the init-callback hint).
6084 /// That subscription is taken with `DBE_VALUE | DBE_ALARM`
6085 /// (`dbCa.c:1225-1229` → `cadef.h:2010-2011`), and only its
6086 /// `eventCallback` adds `CA_DBPROCESS` (`dbCa.c:955-963`), which the
6087 /// worker runs as a bare `db_process` (`:1249-1257`). A source cycle that
6088 /// posts nothing — an unchanged value inside `MDEL`, no alarm movement —
6089 /// therefore leaves every CP holder unprocessed.
6090 ///
6091 /// The port keeps a local CP target as a `Db` link rather than routing it
6092 /// through the CA client (the `ca` link set lives in another crate and is
6093 /// optional, so C's literal structure would silently disable local CP
6094 /// links in a bare `epics-base-rs` IOC). `posts` is what restores the C
6095 /// rule on top of that shape: the same `DBE_VALUE|DBE_ALARM` gate the
6096 /// cross-IOC path gets from its remote monitor
6097 /// ([`Self::dispatch_external_cp_targets`]), so "CP dispatch" means one
6098 /// thing on both paths.
6099 ///
6100 /// The dispatch itself is the moral equivalent of dbCaTask's
6101 /// `CA_DBPROCESS` handler invoking `db_process(prec)` and nothing else —
6102 /// no PUTF, no RPRO. Already-visited targets (current process chain) are
6103 /// skipped via the `visited` cycle guard.
6104 fn dispatch_cp_targets(
6105 &self,
6106 name: &str,
6107 posts: CyclePosts,
6108 visited: &mut std::collections::HashSet<String>,
6109 ) {
6110 if !posts.triggers_cp() {
6111 return;
6112 }
6113 let cp_targets = self.get_cp_targets(name);
6114 for target in cp_targets {
6115 self.process_one_cp_target(&target, visited);
6116 }
6117 }
6118
6119 /// Process a single CP/CPP target edge, applying the CPP passive gate.
6120 /// This is the single owner of the scan-time CP-dispatch decision, shared
6121 /// by the local-source path ([`Self::dispatch_cp_targets`]) and the
6122 /// cross-IOC path ([`Self::dispatch_external_cp_targets`]) so both honour
6123 /// the same `dbCa.c` semantics.
6124 ///
6125 /// The passive gate is the ONLY thing decided here. C's `CA_DBPROCESS`
6126 /// worker (`dbCa.c:1249-1257`) is bare `dbScanLock` / `db_process` /
6127 /// `dbScanUnlock`, so an active target is handled by `dbProcess` itself —
6128 /// which the port models once, in the PACT entry guard of
6129 /// [`Self::process_record_with_links_body`]. Deciding PACT a second time
6130 /// here is what let this path diverge from that owner.
6131 fn process_one_cp_target(
6132 &self,
6133 target: &super::CpTarget,
6134 visited: &mut std::collections::HashSet<String>,
6135 ) {
6136 if visited.contains(&target.record) {
6137 return;
6138 }
6139 let target_rec = {
6140 let records = self.inner.records.read();
6141 records.get(&target.record).cloned()
6142 };
6143 let skip = match target_rec {
6144 // CPP gate (`dbCa.c:823-828`, `:958-962`, `:1032-1037`): a CPP link adds
6145 // `CA_DBPROCESS` only when the link-holder's SCAN is Passive. A
6146 // non-Passive target is reached by its own periodic/event scan, so
6147 // it is not dispatched here. A CP link (`passive_only == false`)
6148 // never takes this branch and always dispatches.
6149 //
6150 // epics-base PR #3fb10b6: PUTF must remain false on CP-driven
6151 // targets — only the record directly receiving the dbPut reports
6152 // PUTF=1 to dbNotify/onChange observers, so we deliberately do NOT
6153 // set PUTF here.
6154 Some(t) => {
6155 let tg = t.read();
6156 target.passive_only && tg.common.scan != crate::server::record::ScanType::Passive
6157 }
6158 None => false,
6159 };
6160 if skip {
6161 return;
6162 }
6163 // recursive CP-target fan-out within one chain —
6164 // gate already held by the foreign entry record.
6165 let _ = self.process_record_with_links_recursive(&target.record, visited);
6166 }
6167
6168 /// Process every holder of an EXTERNAL CP/CPP link to `external_pv` —
6169 /// the cross-IOC twin of `Self::dispatch_cp_targets`. Called by the
6170 /// calink/pvalink CA monitor callback on every remote change, this is
6171 /// the Rust equivalent of C `dbCa.c eventCallback` adding
6172 /// `CA_DBPROCESS` for a CP (or Passive CPP) link (`dbCa.c:958-962`)
6173 /// and the worker thread running `db_process(prec)` (`dbCa.c:1255`).
6174 /// A cross-IOC source never processes locally, so this callback is the
6175 /// only trigger; without it a `CP`/`CPP` link's holder never processes
6176 /// on a remote change.
6177 ///
6178 /// A fresh `visited` set starts a new process chain —
6179 /// the monitor event is an independent external trigger, like a scan,
6180 /// not a continuation of an in-flight local chain.
6181 pub fn dispatch_external_cp_targets(&self, external_pv: &str) {
6182 let targets = self.get_external_cp_targets(external_pv);
6183 if targets.is_empty() {
6184 return;
6185 }
6186 let mut visited = std::collections::HashSet::new();
6187 for target in targets {
6188 self.process_one_cp_target(&target, &mut visited);
6189 }
6190 }
6191
6192 /// Apply the SIMM-mode OUTPUT redirect (the `writeValue` half of
6193 /// simulation). C `writeValue` substitutes the device write with
6194 /// `dbPutLink(&prec->siol, DBR_DOUBLE, &prec->oval, 1)` (aoRecord.c:574,
6195 /// `DBR_LONG`/`&prec->rval` in SIMM=RAW at :577), so this runs from the OUT
6196 /// epilogue after the body computed OVAL/RVAL.
6197 ///
6198 /// SIOL is a `DBF_OUTLINK` (aoRecord.dbd) driven by the SAME `dbPutLink`
6199 /// as the record's OUT: it is not a bare field poke. Routing it through
6200 /// [`Self::write_out_link_value`] — the put owner — is what gives the
6201 /// simulated write everything C's `dbDbPutValue` (dbDbLink.c:372-393) does
6202 /// and the old open-coded `put_pv_already_locked` did not: MS-class alarm
6203 /// inheritance into the SIOL target, `PP`/`.PROC` `processTarget`, PUTF and
6204 /// put-notify propagation — and the failed-put `LINK_ALARM`/`INVALID`
6205 /// raised BY the owner rather than by this caller (which violated
6206 /// `write_out_link_value`'s own single-raise invariant).
6207 ///
6208 /// `sim_output` is `None` for a non-simulated record or a simulated INPUT
6209 /// (whose `readValue` ran up-front); `skip_out` carries the IVOA
6210 /// Don't_drive veto so the SIOL write is suppressed exactly as the real
6211 /// device write would be.
6212 ///
6213 /// Kept as its own `async fn` so the `EpicsValue` it reads out of the
6214 /// record never enters `process_record_with_links_inner`'s async state —
6215 /// that future is polled one frame deeper per FLNK hop, unbounded as in C,
6216 /// and bloating it overflows the stack sooner (the deep-chain tests).
6217 fn write_simulated_output_siol(
6218 &self,
6219 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
6220 sim_output: &Option<(crate::server::record::ParsedLink, i16, bool)>,
6221 skip_out: bool,
6222 src: super::links::OutLinkSrc<'_>,
6223 visited: &mut std::collections::HashSet<String>,
6224 ) {
6225 let Some((siol, _sims, raw_mode)) = sim_output else {
6226 return;
6227 };
6228 // IVOA Don't_drive veto (C skips `writeValue` entirely) and a
6229 // non-writable SIOL (empty / constant — C `dbPutLink` no-op) both
6230 // suppress the write.
6231 if skip_out || !siol.is_writable_out_link() {
6232 return;
6233 }
6234 // The record's own OUT value (RAW: RVAL) — matching C `writeValue`
6235 // (`dbPutLink(&prec->siol, ..., &prec->oval)`), so the SIOL redirect
6236 // sends exactly what the real OUT link would have.
6237 let value = {
6238 let instance = rec.read();
6239 if *raw_mode {
6240 instance
6241 .record
6242 .get_field("RVAL")
6243 .or_else(|| instance.record.val())
6244 } else {
6245 instance.record.output_link_value()
6246 }
6247 };
6248 if let Some(value) = value {
6249 self.write_out_link_value(
6250 rec,
6251 siol,
6252 value,
6253 super::links::OutLinkSrc {
6254 field: "SIOL",
6255 ..src
6256 },
6257 visited,
6258 );
6259 }
6260 }
6261
6262 /// **C `dbTryGetLink`** (`dbLink.c:307-315`) — the bare `lset->getValue`
6263 /// dispatch, classified into the three outcomes C's `(status, buffer)` pair
6264 /// can carry (see [`crate::server::recgbl::simm::LinkFetch`]) and carrying
6265 /// the source-alarm tail, but WITHOUT `setLinkAlarm`.
6266 ///
6267 /// Only the two readers whose C really is `dbTryGetLink`-shaped call this
6268 /// directly ([`Self::rec_gbl_get_simm`] and swait's `recDynLinkGet` DOL);
6269 /// every other process-time read is a C `dbGetLink` and goes through
6270 /// [`Self::db_get_link`], which owns the failure alarm.
6271 ///
6272 /// The raw [`Self::read_link_value_no_process`] collapses two of them: it
6273 /// hands back the CONSTANT link's parsed text as if the link had delivered
6274 /// it this cycle, and `None` both for "constant with nothing to give" and
6275 /// for "the read failed". C keeps them apart — `dbConstGetValue`
6276 /// (`dbConstLink.c:219-225`) returns SUCCESS and writes nothing, because a
6277 /// constant's value was already loaded into the record's buffer at
6278 /// `init_record`. Every gate downstream (simulation mode, DISA, TSE, SELN)
6279 /// hangs off that distinction, so every one of them reads through here and
6280 /// the constant reaches the record only through the init-seed owner
6281 /// ([`Self::rec_gbl_init_constant_links`] / [`Self::rec_gbl_init_simm`]).
6282 /// The read CARRIES the source alarm: C's `dbGetLink` on a DB link ends in
6283 /// `dbDbGetValue`'s inheritance tail (`dbDbLink.c:228-232`), so every link a
6284 /// record reads at process time — INP, DOL, SDIS, TSEL, SELL, SIML, SIOL —
6285 /// folds an `MS` source's severity into the reader. That tail runs HERE, in
6286 /// the read primitive itself, through the single inheritance owner
6287 /// ([`Self::input_link_inheritance`]): a caller cannot drop it, because a
6288 /// caller never sees the alarm. Dropping it is exactly how DOL, SIML and
6289 /// SIOL came to lose MS while INP kept it.
6290 ///
6291 /// softIoc (`SRC0` in MAJOR): `SDIS="SRC0 MS"`, `TSEL="SRC0 MS"`,
6292 /// `SIML="SRC0 MS"`, `SIOL="SRC0 MS"` and `DOL="SRC0 MS"` (closed-loop) all
6293 /// leave the reader MAJOR/LINK; without `MS`, all leave it NO_ALARM. The
6294 /// one read C does NOT run the tail on is the `TSEL="SRC.TIME"` form
6295 /// (`recGbl.c:316-321` calls `dbGetTimeStampTag`, not `dbGetLink`) — and
6296 /// that branch does not come through here. EVERY other TSEL form falls
6297 /// through to `dbGetLink` at `recGbl.c:322`, so it does.
6298 pub(crate) fn db_try_get_link(
6299 &self,
6300 reader: &Arc<parking_lot::RwLock<RecordInstance>>,
6301 link: &crate::server::record::ParsedLink,
6302 ) -> crate::server::recgbl::simm::LinkFetch {
6303 let (fetch, alarm) = self.read_link_with_alarm(link);
6304 self.inherit_link_severity(reader, link, alarm);
6305 fetch
6306 }
6307
6308 /// **C `dbGetLink`** (`dbLink.c:324-340`) — [`Self::db_try_get_link`] plus the
6309 /// failure effect C attaches to it, because in C the two are ONE function:
6310 ///
6311 /// ```c
6312 /// status = dbTryGetLink(plink, dbrType, pbuffer, pnRequest);
6313 /// if (status == S_db_noLSET) return -1;
6314 /// if (status) setLinkAlarm(plink);
6315 /// ```
6316 ///
6317 /// `setLinkAlarm` is `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "field %s",
6318 /// dbLinkFieldName(plink))` — unconditional on failure, independent of the
6319 /// link's `MS` class, and carrying the LINK FIELD's own name as the AMSG. It
6320 /// is NOT the severity-inheritance tail [`Self::inherit_link_severity`] runs:
6321 /// that propagates the SOURCE's severity on a SUCCESSFUL read, and a link
6322 /// with no `MS` inherits nothing at all.
6323 ///
6324 /// The alarm lives HERE, in the read, and not in the caller, because that is
6325 /// where C puts it. Leaving it to each caller is what let SDIS, TSEL, DOL,
6326 /// NVL, SELL and SUBL go silent on a dead link while SIML, SIOL, INP and the
6327 /// `ReadDbLink` executor — the callers that happened to remember — did not.
6328 /// One uniform rule replaces six chances to forget.
6329 ///
6330 /// `link_field` is C's `dbLinkFieldName(plink)`: a `struct link` knows its own
6331 /// field name, a [`ParsedLink`](crate::server::record::ParsedLink) does not, so
6332 /// the caller spells it.
6333 ///
6334 /// Use [`Self::db_try_get_link`] for the reads whose C is NOT `dbGetLink` —
6335 /// `recGblGetSimm`'s SIML read (`dbTryGetLink`, which bypasses `setLinkAlarm`
6336 /// and writes `nsta` itself, `recGbl.c:453-454`) and swait's output-time DOL
6337 /// (`recDynLinkGet`, `swaitRecord.c:767`).
6338 pub(crate) fn db_get_link(
6339 &self,
6340 reader: &Arc<parking_lot::RwLock<RecordInstance>>,
6341 link_field: &str,
6342 link: &crate::server::record::ParsedLink,
6343 ) -> crate::server::recgbl::simm::LinkFetch {
6344 let fetch = self.db_try_get_link(reader, link);
6345 if matches!(fetch, crate::server::recgbl::simm::LinkFetch::Failed) {
6346 let mut instance = reader.write();
6347 crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, link_field);
6348 }
6349 fetch
6350 }
6351
6352 /// [`Self::db_get_link`] for an INPUT link — same classification, same
6353 /// `setLinkAlarm`, but the PP rule applies first: C `dbGetLink` on a
6354 /// `ProcessPassive` DB link processes the passive source before reading it.
6355 /// Used by sel's NVL→SELN read and the closed-loop DOL read.
6356 pub(crate) fn db_get_input_link(
6357 &self,
6358 reader: &Arc<parking_lot::RwLock<RecordInstance>>,
6359 link_field: &str,
6360 link: &crate::server::record::ParsedLink,
6361 visited: &mut HashSet<String>,
6362 ) -> crate::server::recgbl::simm::LinkFetch {
6363 if let crate::server::record::ParsedLink::Db(db) = link {
6364 self.process_passive_db_source(db, visited);
6365 }
6366 self.db_get_link(reader, link_field, link)
6367 }
6368
6369 /// Apply the reader's declared `dbrType` request
6370 /// ([`Record::input_link_read_as`](crate::server::record::Record::input_link_read_as))
6371 /// to one delivered link value — C's `dbGetLink(plink, dbrType, ...)`
6372 /// second argument, which the generic fetch paths never passed: they
6373 /// delivered the source's native value and let the target field coerce
6374 /// blind, turning a `DBR_STRING` request at an ENUM/MENU source into
6375 /// index digits (epics-base#183).
6376 ///
6377 /// The source is resolved with NO record lock held (the
6378 /// [`Self::read_db_link_into_field`] rule: a self-referencing link
6379 /// would otherwise re-enter this record's own gate), and only when the
6380 /// fetch actually delivered a value. `None` from the record is C's
6381 /// `default: break` — no read — mapped to `NoData`; a conversion the
6382 /// source cannot satisfy is a FAILED read (C's non-zero status).
6383 ///
6384 /// The second return is whether the reader asked for a STRING class: such a
6385 /// value bypasses the store's `to_f64` funnel, because that funnel IS the
6386 /// `DBR_DOUBLE` request of the calc-class records (`calcRecord.c:434`), not
6387 /// a rule of the store.
6388 fn convert_link_fetch(
6389 &self,
6390 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
6391 link_field: &str,
6392 link: &crate::server::record::ParsedLink,
6393 fetch: crate::server::recgbl::simm::LinkFetch,
6394 ) -> (crate::server::recgbl::simm::LinkFetch, bool) {
6395 use crate::server::recgbl::simm::LinkFetch;
6396 use crate::server::record::LinkReadAs;
6397 let LinkFetch::Value(value) = fetch else {
6398 return (fetch, false);
6399 };
6400 let source = self.resolve_out_target(link);
6401 let read_as = {
6402 let instance = rec.read();
6403 instance.record.input_link_read_as(link_field, &source)
6404 };
6405 match read_as {
6406 None => (LinkFetch::NoData, false),
6407 Some(read_as) => {
6408 let raw = matches!(
6409 read_as,
6410 LinkReadAs::String | LinkReadAs::CharArrayAsString { .. }
6411 );
6412 match self.apply_link_read_as(link, read_as, value) {
6413 Some(v) => (LinkFetch::Value(v), raw),
6414 None => (LinkFetch::Failed, false),
6415 }
6416 }
6417 }
6418 }
6419
6420 /// **C `dbGetLink` for a caller that folds its MS tail in later** —
6421 /// [`Self::db_get_link`] read, converted and alarmed, but with the
6422 /// source alarm handed back instead of applied.
6423 ///
6424 /// The multi-input fetch loops (INPA..INPL and sCalcout's INAA..INLL) read
6425 /// many links with the record's write lock released and apply their MS
6426 /// inheritance together at the end, so they cannot use the inline owner.
6427 /// They can still not be the place the `setLinkAlarm` decision lives: that
6428 /// is what left `record(calc,"C"){field(INPA,"NOSUCH")}` publishing
6429 /// NO_ALARM where C publishes INVALID/LINK with AMSG `field INPA`.
6430 ///
6431 /// Returns `(fetch, source alarm, reader-asked-for-a-string-class)`.
6432 fn db_get_link_deferred(
6433 &self,
6434 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
6435 link_field: &str,
6436 link: &crate::server::record::ParsedLink,
6437 ) -> (
6438 crate::server::recgbl::simm::LinkFetch,
6439 Option<super::links::LinkAlarm>,
6440 bool,
6441 ) {
6442 let (fetch, alarm, store_raw) = self.db_try_get_link_deferred(rec, link_field, link);
6443 if matches!(fetch, crate::server::recgbl::simm::LinkFetch::Failed) {
6444 let mut instance = rec.write();
6445 crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, link_field);
6446 }
6447 (fetch, alarm, store_raw)
6448 }
6449
6450 /// The `dbTryGetLink` twin of [`Self::db_get_link_deferred`] — same read
6451 /// and conversion, no `setLinkAlarm`. swait's `fetch_values`
6452 /// (`swaitRecord.c:702`) reads INAA..INPL with `recDynLinkGet`, which has
6453 /// no such effect; its failure is answered by `recGblSetSevr(READ_ALARM,
6454 /// INVALID_ALARM)` at `swaitRecord.c:413`.
6455 fn db_try_get_link_deferred(
6456 &self,
6457 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
6458 link_field: &str,
6459 link: &crate::server::record::ParsedLink,
6460 ) -> (
6461 crate::server::recgbl::simm::LinkFetch,
6462 Option<super::links::LinkAlarm>,
6463 bool,
6464 ) {
6465 let (fetch, alarm) = self.read_link_with_alarm(link);
6466 let (fetch, store_raw) = self.convert_link_fetch(rec, link_field, link, fetch);
6467 (fetch, alarm, store_raw)
6468 }
6469
6470 /// The `Option`-shaped twin of [`Self::convert_link_fetch`] for the
6471 /// single-INP soft path, whose reader deals in `Option<EpicsValue>`:
6472 /// a conversion (or declaration) miss is `None`, which that path
6473 /// already classifies as a failed read of a real link (LINK alarm,
6474 /// VAL untouched — C `read_si` returning `dbGetLink`'s status).
6475 fn typed_input_value(
6476 &self,
6477 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
6478 link_field: &str,
6479 link: &crate::server::record::ParsedLink,
6480 value: EpicsValue,
6481 ) -> Option<EpicsValue> {
6482 let source = self.resolve_out_target(link);
6483 let read_as = {
6484 let instance = rec.read();
6485 instance.record.input_link_read_as(link_field, &source)
6486 }?;
6487 self.apply_link_read_as(link, read_as, value)
6488 }
6489
6490 /// C `dbDbGetValue`'s tail, applied to the reader: the ONE place a
6491 /// process-time link read folds its source's alarm in. Computes the
6492 /// `(MS class, source alarm)` pair through the inheritance owner with no
6493 /// record lock held, then applies it under a brief write lock.
6494 fn inherit_link_severity(
6495 &self,
6496 reader: &Arc<parking_lot::RwLock<RecordInstance>>,
6497 link: &crate::server::record::ParsedLink,
6498 alarm: Option<super::links::LinkAlarm>,
6499 ) {
6500 let reader_name = reader.read().name.clone();
6501 if let Some((ms, src)) = self.input_link_inheritance(&reader_name, link, alarm) {
6502 let mut instance = reader.write();
6503 super::links::inherit_sevr_msg(&mut instance.common, ms, &src);
6504 }
6505 }
6506
6507 /// C `recGblGetSimm` (`recGbl.c:448-457`) — **the single owner of the
6508 /// SIMM transition at process time**, and the only site allowed to write
6509 /// SIMM from SIML.
6510 ///
6511 /// ```c
6512 /// recGblSaveSimm(*psscn, poldsimm, *psimm);
6513 /// status = dbTryGetLink(psiml, DBR_USHORT, psimm, 0);
6514 /// if (status && !pcommon->nsev) pcommon->nsta = LINK_ALARM;
6515 /// recGblCheckSimm(pcommon, psscn, *poldsimm, *psimm);
6516 /// ```
6517 ///
6518 /// Called from `check_simulation_mode` on every `pact == FALSE` entry —
6519 /// C's `if (!prec->pact)` guard around it (aiRecord.c:475).
6520 ///
6521 /// Returns the SIML-read status the record's `readValue`/`writeValue` sees:
6522 /// `true` when the read FAILED. Only a record that declares
6523 /// [`Record::aborts_on_failed_siml_read`](crate::server::record::Record::aborts_on_failed_siml_read) (busy) acts on it — see that hook
6524 /// for why the other two families do not.
6525 pub(crate) fn rec_gbl_get_simm(
6526 &self,
6527 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
6528 siml: &crate::server::record::ParsedLink,
6529 ) -> bool {
6530 use crate::server::recgbl::simm::LinkFetch;
6531 // `recGblSaveSimm(*psscn, poldsimm, *psimm)` — latch the outgoing mode
6532 // BEFORE the SIML read can move SIMM.
6533 {
6534 let mut instance = rec.write();
6535 instance.rec_gbl_save_simm();
6536 }
6537 // `dbTryGetLink`: a CONSTANT (or unset) SIML delivers NOTHING here —
6538 // its value was loaded into SIMM once, at init (`rec_gbl_init_simm`).
6539 // So a `caput REC.SIMM YES` on a record with a constant SIML STAYS
6540 // YES; re-reading the constant every cycle (the pre-fix behaviour of
6541 // `read_link_value_no_process`) would stomp the operator's put back to
6542 // the constant on the very next process.
6543 let fetch = self.db_try_get_link(rec, siml);
6544 let failed = matches!(fetch, LinkFetch::Failed);
6545 match fetch {
6546 LinkFetch::Value(v) => {
6547 // `dbGetLink(&prec->siml, DBR_USHORT, &prec->simm)` — through the
6548 // coercion owner, source-type-chosen (see the DISA read above);
6549 // SIMM's storage here is the i16 carrier.
6550 let simm = v.to_dbf_i16().unwrap_or(0);
6551 let mut instance = rec.write();
6552 let _ = instance
6553 .record
6554 .put_field_internal("SIMM", EpicsValue::Short(simm));
6555 }
6556 // status 0, nothing written — SIMM keeps what init loaded.
6557 LinkFetch::NoData => {}
6558 // The read FAILED. Two C shapes, keyed on which SIML reader the
6559 // record's support uses (`Record::uses_recgbl_simm_helpers`):
6560 LinkFetch::Failed => {
6561 let mut instance = rec.write();
6562 if instance.record.uses_recgbl_simm_helpers() {
6563 // `recGblGetSimm` (recGbl.c:453-454):
6564 // if (status && !pcommon->nsev) pcommon->nsta = LINK_ALARM;
6565 // `dbTryGetLink` does NOT call `setLinkAlarm`, and this is a
6566 // DIRECT write of `nsta` — NOT `recGblSetSevr`. So the record
6567 // publishes STAT=LINK_ALARM with SEVR still NO_ALARM. That
6568 // asymmetry is C's, quirk and all; reproduce it exactly.
6569 if instance.common.nsev == crate::server::record::AlarmSeverity::NoAlarm {
6570 instance.common.nsta = crate::server::recgbl::alarm_status::LINK_ALARM;
6571 }
6572 } else {
6573 // `busyRecord.c:399` / `swaitRecord.c:402` read SIML with a
6574 // plain `dbGetLink`, whose failure path calls `setLinkAlarm`
6575 // (dbLink.c:318-323) — a full
6576 // `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "field %s")`.
6577 crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, "SIML");
6578 }
6579 }
6580 }
6581 // `recGblCheckSimm(pcommon, psscn, *poldsimm, *psimm)` — a SIML-driven
6582 // SIMM transition swaps SCAN with SSCN exactly like a `caput REC.SIMM`
6583 // does. C runs it even on a FAILED read (recGbl.c:455 is past the
6584 // LINK_ALARM line), so the swap is not conditional on the status.
6585 self.apply_simm_scan_swap(rec);
6586 failed
6587 }
6588
6589 /// Run C `recGblCheckSimm` on a record and hand the resulting scan move to
6590 /// the scan-index owner (`update_scan_index`) — the `scanDelete`/`scanAdd`
6591 /// pair inside it. The record lock is taken and released here: the
6592 /// scan-index update re-enters the database.
6593 pub(crate) fn apply_simm_scan_swap(&self, rec: &Arc<parking_lot::RwLock<RecordInstance>>) {
6594 use crate::server::record::CommonFieldPutResult;
6595 let (name, result) = {
6596 let mut instance = rec.write();
6597 let name = instance.name.clone();
6598 let result = instance.rec_gbl_check_simm();
6599 (name, result)
6600 };
6601 if let CommonFieldPutResult::ScanChanged {
6602 old_scan,
6603 new_scan,
6604 phas,
6605 } = result
6606 {
6607 self.update_scan_index(&name, old_scan, new_scan, phas, phas);
6608 }
6609 }
6610
6611 /// C `recGblInitSimm` (`recGbl.c:439-446`) plus the
6612 /// `recGblInitConstantLink(&prec->siol, …, &prec->sval)` that every
6613 /// SIML/SIOL-bearing `init_record` pairs with it (longinRecord.c:99-100,
6614 /// aiRecord.c:103-104, busyRecord.c:138, swaitRecord.c:663-670).
6615 ///
6616 /// A CONSTANT link hands its value to the record exactly ONCE, here, via
6617 /// `dbLoadLink` — at process time `dbGetLink` on a constant delivers
6618 /// nothing. This is the other half of the rule
6619 /// `Self::fetch_link` enforces; without it a `field(SIOL, "42")`
6620 /// would never reach SVAL at all.
6621 ///
6622 /// Must be called once per record, after its fields are applied — the
6623 /// `init_record(1)` sites (`ioc_builder`, `dbLoadRecords`).
6624 /// C `recGblInitConstantLink(&prec->inp, …, &prec->val)` /
6625 /// `dbLoadLinkArray(&prec->inp, prec->ftvl, prec->bptr, &nRequest)` — the
6626 /// ONE place a constant INP reaches a record.
6627 ///
6628 /// Every soft-channel INPUT device support runs this in its
6629 /// `init_record`: `devAiSoft.c:44`, `devLiSoft.c`, `devBiSoft.c`,
6630 /// `devI64inSoft.c`, `devMbbiSoft.c`, `devSiSoft.c`, `devEventSoft.c`
6631 /// (scalars, via `recGblInitConstantLink`), and `devAaiSoft.c:57`,
6632 /// `devWfSoft.c:42`, `devSASoft.c` (arrays, via `dbLoadLinkArray`). The
6633 /// raw variants (`devAiSoftRaw.c`, `devBiSoftRaw.c`, `devMbbiSoftRaw.c`)
6634 /// load into RVAL instead and let the record's own RVAL→VAL conversion
6635 /// run — hence the [`Record::raw_soft_input`](crate::server::record::Record::raw_soft_input) arm, the same sink the
6636 /// process-time path uses for `Raw Soft Channel`.
6637 ///
6638 /// This is the other half of the rule
6639 /// [`PvDatabase::read_link_value_soft`](super::PvDatabase::read_link_value_soft) enforces (a constant
6640 /// delivers NOTHING at process): without the init load a `field(INP, "5")`
6641 /// ai would never see 5 at all; without the process-time skip the constant
6642 /// would clobber the record's VAL on every scan.
6643 ///
6644 /// Gated on soft DTYP because a hardware record's INP is a device ADDRESS,
6645 /// not a value — C only ever loads it in soft dev support.
6646 ///
6647 /// **This is THE init-seed owner.** Beyond the device-support INP above it
6648 /// applies the record's own `recGblInitConstantLink` table,
6649 /// [`Record::constant_init_links`](crate::server::record::Record::constant_init_links) — calc/calcout/sub/sel/aSub/scalcout/
6650 /// acalcout/transform `INPA..L → A..L`, sel `NVL → SELN`, fanout/dfanout/
6651 /// seq `SELL → SELN`, seq `DOLn → DOn`, aSub `SUBL → SNAM`, and the
6652 /// `DOL → VAL` seeds that also clear UDF. Every one of those links is
6653 /// dead at process time (the link layer returns `LinkFetch::NoData` for a
6654 /// constant), so this is the only place their values can arrive.
6655 ///
6656 /// Must be called once per record, after its fields are applied and both
6657 /// `init_record` passes have run (the record needs its final NELM/FTVL
6658 /// buffer before an array constant can land in it) — the `init_record(1)`
6659 /// sites (`ioc_builder`, `dbLoadRecords`). It also runs from
6660 /// `PvDatabase::add_record`, the creation sink every other path funnels
6661 /// through, so a record built programmatically (no `IocBuilder`) still has
6662 /// its constants seeded: in C there is no record in the database that
6663 /// `init_record` did not touch. Seeding twice is a no-op — both calls
6664 /// happen before any client can put.
6665 pub(crate) fn rec_gbl_init_constant_links(
6666 &self,
6667 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
6668 ) {
6669 let mut instance = rec.write();
6670 seed_constant_links(&mut instance);
6671 }
6672}
6673
6674/// The body of the init-seed owner, over a locked record — shared by
6675/// [`PvDatabase::rec_gbl_init_constant_links`] and `PvDatabase::add_record`.
6676pub(crate) fn seed_constant_links(instance: &mut RecordInstance) {
6677 // The SECOND seat of C's `init_record` body, and so it takes the same
6678 // opening test: every step below sits BELOW `if (!pdset) { … return
6679 // S_dev_noDSET; }` in the C source it ports — the soft dset's constant
6680 // load, the record's own `recGblInitConstantLink` table (`aoRecord.c:112`),
6681 // and the tail plus tracker seed at `aoRecord.c:156-161`. A record whose
6682 // dset is NULL reaches none of them, which is why softIoc reads `MLST: 0`
6683 // on an `ai` whose DTYP nobody registered where the port read its VAL.
6684 if !instance.init_record_reaches_body() {
6685 return;
6686 }
6687
6688 // 0. The long-string load, C `dbLoadLinkLS` — a lset entry of its own, NOT
6689 // `recGblInitConstantLink`, and the only one that can write a
6690 // long-string VAL: `lso` runs it on DOL (lsoRecord.c:82), `lsi`'s soft
6691 // device support on INP (devLsiSoft.c:24). It replaces the scalar seeds
6692 // below for those records — a long-string VAL takes no scalar put.
6693 if let Some(link_field) = instance.record.constant_ls_link() {
6694 // C binds `loadLS` to the INP link through the SOFT device support, so
6695 // a hardware DTYP loads nothing; DOL is in the record itself and is
6696 // never gated.
6697 let gated = link_field != "INP"
6698 || crate::server::device_support::is_soft_dtyp(&instance.common.dtyp);
6699 let text = if link_field == "INP" {
6700 instance.common.inp.clone()
6701 } else {
6702 match instance.record.get_field(link_field) {
6703 Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
6704 _ => String::new(),
6705 }
6706 };
6707 if gated {
6708 if let Some(load) = crate::server::record::load_link_ls(&text) {
6709 // C's lso/lsi init tail: `if (prec->len) { … prec->udf = FALSE; }`
6710 // — a link that loaded (even the number case, whose LEN is 1
6711 // with an empty VAL) DEFINES the record.
6712 if instance.record.apply_ls_load(load) != 0 {
6713 instance.common.udf = 0;
6714 }
6715 }
6716 }
6717 instance.record.init_record_tail();
6718 instance.record.seed_deadband_tracking();
6719 return;
6720 }
6721
6722 // 1. The soft-channel device support's INP → VAL/RVAL load. It is DEVICE
6723 // SUPPORT's `init_record` (`devAiSoft.c` &c), so it runs only on records
6724 // that HAVE a DSET — `Record::input_read_by_device_support`. A record
6725 // that reads its own INP (compress) gets no init load in C, and its
6726 // constant therefore never reaches the record at all.
6727 if crate::server::device_support::is_soft_dtyp(&instance.common.dtyp)
6728 && instance.record.input_read_by_device_support()
6729 {
6730 let inp = crate::server::record::parse_link_v2(&instance.common.inp);
6731 let mut loaded = false;
6732 if let Some(value) = crate::server::recgbl::simm::constant_load_value(&inp) {
6733 // Same sink the per-cycle soft-input apply uses, so the constant
6734 // lands in the field the link would have written: RVAL for `Raw
6735 // Soft Channel` (the record converts RVAL→VAL), VAL otherwise.
6736 // `RawSoftEntry::InitConstant` — the SoftRaw dsets do NOT mask the
6737 // init load (`devBiSoftRaw.c:57` calls `recGblInitConstantLink`
6738 // straight into RVAL; only `read_bi` applies MASK).
6739 let raw = if crate::server::device_support::classify_soft(&instance.common.dtyp)
6740 == Some(crate::server::device_support::SoftDtyp::Raw)
6741 {
6742 instance
6743 .record
6744 .raw_soft_input(RawSoftEntry::InitConstant, value.clone())
6745 } else {
6746 None
6747 };
6748 loaded = match raw {
6749 Some(res) => res.is_ok(),
6750 None => instance.record.set_val(value).is_ok(),
6751 };
6752 // C: `if (recGblInitConstantLink(...)) prec->udf = FALSE;` — a
6753 // record whose value came from a constant link is DEFINED.
6754 if loaded {
6755 instance.common.udf = 0;
6756 }
6757 }
6758 // The FAILURE arm of the same dset `init_record`. `devWfSoft.c:39-51`
6759 // does not just skip a link it could not load — it ZEROES the element
6760 // count:
6761 //
6762 // ```c
6763 // status = dbLoadLinkArray(&prec->inp, prec->ftvl, prec->bptr, &nelm);
6764 // if (!status) { prec->nord = nelm; prec->udf = FALSE; }
6765 // else prec->nord = 0;
6766 // ```
6767 //
6768 // so the record's own `nord = (nelm == 1)` seed does not survive a
6769 // waveform whose INP is a real link or unset. Defaulted no-op.
6770 instance.record.soft_input_dset_init(loaded);
6771 }
6772
6773 // 2. The record's own `recGblInitConstantLink` table, through the shared
6774 // owner of "a CONSTANT link's text becomes the target field's value"
6775 // (`record::rec_gbl_init_constant_link`) — the SAME load a runtime put to
6776 // the link field re-runs from `special()`, so the two cannot drift.
6777 for seed in instance.record.constant_init_links() {
6778 let Some(value) =
6779 crate::server::record::rec_gbl_init_constant_link(&mut *instance.record, &seed)
6780 else {
6781 continue;
6782 };
6783 // C's UDF rule for a successful constant load is per record, and the two
6784 // shapes differ only in the NaN case:
6785 // aoRecord.c:112-113 / dfanoutRecord.c:105-106 — `udf = isnan(val)`
6786 // longoutRecord.c:113 / mbboRecord.c:133 / int64outRecord.c:110 —
6787 // `udf = FALSE`
6788 // A NaN cannot survive the conversion into an integer target, so the
6789 // isnan test covers both: the value that reached the field is defined
6790 // unless it is NaN.
6791 let is_nan = value.to_f64().is_some_and(f64::is_nan);
6792 if seed.clears_udf && !is_nan {
6793 instance.common.udf = 0;
6794 }
6795 }
6796
6797 // 3. C's `init_record` TAIL, which every record runs immediately AFTER its
6798 // `recGblInitConstantLink` calls (`aoRecord.c:156-161`: `oval = pval =
6799 // val; mlst = alst = lalm = val; oraw = rval; orbv = rbv`). It re-derives
6800 // the record's init-time tracking state from the value the seed just
6801 // loaded — a constant DOL of 5 leaves C's ao at OVAL=5, not 0
6802 // (softIoc-verified) — so it belongs to the seed owner, not to a caller
6803 // that may or may not remember it (the iocsh `dbLoadRecords` path did
6804 // not).
6805 instance.record.init_record_tail();
6806 instance.record.seed_deadband_tracking();
6807
6808 // C's init-time `db_post_events` run during iocInit, before any client can
6809 // subscribe, so they are observable by nobody. A seed put that made the
6810 // record MARK a field (sseq: seeding `STRn` re-derives `DOn`) must not leave
6811 // that mark standing for the first process cycle to emit — that would turn a
6812 // no-op C post into a real, late event. Drop the init-time marks.
6813 let _ = instance.record.take_cycle_posted_fields();
6814}
6815
6816impl PvDatabase {
6817 pub(crate) fn rec_gbl_init_simm(&self, rec: &Arc<parking_lot::RwLock<RecordInstance>>) {
6818 // The data guard is released (block close) before the scan-swap await
6819 // below (parking_lot guards are `!Send`).
6820 let siml_is_constant = {
6821 let mut instance = rec.write();
6822 // No SIMM field -> no simulation block -> nothing to init.
6823 if instance.resolve_field("SIMM").is_none() {
6824 return;
6825 }
6826 let link_of = |instance: &RecordInstance, field: &str| {
6827 instance.resolve_field(field).and_then(|v| {
6828 if let EpicsValue::String(s) = v {
6829 Some(crate::server::record::parse_link_v2(
6830 s.as_str_lossy().as_ref(),
6831 ))
6832 } else {
6833 None
6834 }
6835 })
6836 };
6837 // C `recGblInitSimm` (`recGbl.c:441-445`) is one `if
6838 // (dbLinkIsConstant(psiml))` around ALL THREE steps — the
6839 // `recGblSaveSimm` latch, the `dbLoadLink`, and the
6840 // `recGblCheckSimm` scan swap. A record whose SIML names a PV gets
6841 // none of them: OLDSIMM keeps its dbd initial and SCAN is left
6842 // alone until the first `recGblGetSimm`. Guarding only the load
6843 // would be worse than guarding nothing — with the latch still
6844 // taken, `field(SIMM,"YES")` in the `.db` would then read
6845 // `simm != oldsimm` at the tail and swap a scan C never swaps.
6846 let siml = link_of(&instance, "SIML");
6847 // An unset SIML is a CONSTANT link (`dbConstLink.c`'s lset with a
6848 // NULL string), which is what a missing field means here.
6849 let siml_is_constant = siml
6850 .as_ref()
6851 .is_none_or(crate::server::recgbl::simm::is_constant);
6852 if siml_is_constant {
6853 instance.rec_gbl_save_simm();
6854 if let Some(v) = siml
6855 .as_ref()
6856 .and_then(crate::server::recgbl::simm::constant_load_value)
6857 {
6858 let _ = instance.record.put_field_internal("SIMM", v);
6859 }
6860 }
6861 // `recGblInitConstantLink(&prec->siol, DBF_<sval>, &prec->sval)` — the
6862 // records with no SVAL (waveform/aai read into `bptr`, lsi into `val`)
6863 // load nothing here, exactly as their C `init_record` does.
6864 if instance.record.get_field("SVAL").is_some() {
6865 if let Some(siol) = link_of(&instance, "SIOL") {
6866 if let Some(v) = crate::server::recgbl::simm::constant_load_value(&siol) {
6867 let _ = instance.record.put_field_internal("SVAL", v);
6868 }
6869 }
6870 }
6871 // `recGblCheckSimm(pcommon, psscn, *poldsimm, *psimm)`: a record loaded
6872 // with `field(SIML,"1")` starts in simulation, so its SCAN and SSCN are
6873 // already swapped by the time the IOC reaches runtime.
6874 siml_is_constant
6875 };
6876 if siml_is_constant {
6877 self.apply_simm_scan_swap(rec);
6878 }
6879 }
6880
6881 /// Check simulation mode for a record. Returns
6882 /// `SimOutcome::Simulated` when a simulated INPUT handled the value (the
6883 /// caller still runs the forward-link tail),
6884 /// `SimOutcome::RedirectOutputToSiol` when a simulated OUTPUT needs the
6885 /// uniform body to run first, or `SimOutcome::NotSimulated` when normal
6886 /// processing should proceed.
6887 ///
6888 /// The SIM/SDLY continuation arms release the PACT the SDLY defer held (C
6889 /// `readValue`/`writeValue` continue with `pact = FALSE`), so the call also
6890 /// hands back the [`PactExit`] for that release — the put-notify parked on
6891 /// the SDLY window. The caller carries it to the cycle's `recGblFwdLink`
6892 /// tail; the release cannot silently drop it (`#[must_use]`), which is what
6893 /// stranded it here before.
6894 fn check_simulation_mode(
6895 &self,
6896 rec: &Arc<parking_lot::RwLock<RecordInstance>>,
6897 ) -> (SimOutcome, crate::server::record::PactExit) {
6898 // Read SIML, SIMM, SIOL, SIMS, SDLY from the record
6899 let (siml_link, siol_link, sims, sdly, _rtype, is_input, input_stage, pact_held) = {
6900 let instance = rec.read();
6901 let rtype = instance.record.record_type().to_string();
6902 // swait: the simulation replaces the record's input STAGE, not its
6903 // whole cycle. Declared by the record, not by a type-name list —
6904 // the classification is a property of where C put the SIOL read.
6905 let input_stage = instance.record.simulation_substitutes_input_stage();
6906 // C `prec->pact` at process entry — the value every readValue/
6907 // writeValue simulation guard keys on. The framework holds the
6908 // `processing` flag across an async wait owned by PACT (the SDLY
6909 // defer, the ODLY/swait ReprocessAfter), and the entry guard in
6910 // `process_record_with_links_inner` lets only such a held
6911 // continuation reach this point with the flag set. A fresh cycle
6912 // reads `false`; so does a `pact=FALSE` delayed re-trigger that does
6913 // NOT own PACT (e.g. the bo HIGH one-shot, which re-enters via the
6914 // same token mechanism but returned `Complete`). So `is_processing()`
6915 // is the faithful analog of `prec->pact` — finer than "re-entered via
6916 // a token" (`is_continuation`), which conflates the PACT-owning
6917 // continuation with the pact=FALSE re-trigger.
6918 let pact_held = instance.is_processing();
6919 // Every input record whose DBD declares SIML/SIOL/SIMM/SIMS.
6920 // `mbbi`/`mbbiDirect` are input records: `mbbiRecord.c:125-126`
6921 // (and mbbiDirectRecord.c) declare SIML+SIOL, and
6922 // `mbbiRecord.c:388-394` reads `dbGetLink(&prec->siol,
6923 // DBR_ULONG, &prec->sval)` then `rval = sval` — input
6924 // semantics. Omitting them sent a simulated mbbi down the
6925 // OUTPUT branch, which writes VAL out to SIOL instead of
6926 // reading the value in from it.
6927 //
6928 // `waveform`/`histogram` are also `readValue` inputs: both call
6929 // `readValue` at the START of `process()` and read SIOL in
6930 // (`waveformRecord.c:139`->`:351` `dbGetLink(&siol, ftvl, bptr)`;
6931 // `histogramRecord.c:209`->`:384` `dbGetLink(&siol, DBR_DOUBLE,
6932 // &sval)`). They are classified as inputs so a simulated cycle
6933 // reads SIOL rather than running the real device read and writing
6934 // VAL back out. Each lands the value where its own C `readValue`
6935 // lands it, through `Record::land_simulated_value`: `waveform` puts
6936 // the SIOL array in VAL (the default `set_val`), `histogram` puts
6937 // the scalar in SGNL and bins it (`histogramRecord.c:385` +
6938 // `:219` `add_count`), because its VAL is the bin-count array.
6939 //
6940 // `aai` is also a SIOL-reading input, but the SIOL read lives in
6941 // its soft DEVICE support, not the record support. `aaiRecord.c::
6942 // readValue` (:342) raises SIMM_ALARM then calls `read_aai`, and
6943 // `devAaiSoft.c::read_aai` (:89) reads
6944 // `simm == YES ? &prec->siol : &prec->inp` — i.e. SIMM=YES reads
6945 // the SIOL array into VAL, observably identical to `waveform`. (The
6946 // record-support `readValue` alone looks device-only, which is
6947 // misleading: the soft device is what redirects to SIOL, exactly as
6948 // `devAaoSoft.c::write_aao` (:56) writes `simm == YES ? &siol :
6949 // &out` for the `aao` OUTPUT twin.) So `aai` is classified as an
6950 // input alongside `waveform`; its SIOL array lands in VAL via the
6951 // same `set_val` path. `aao` is correctly EXCLUDED: its soft device
6952 // writes VAL out to SIOL, which the OUTPUT redirect (`!is_input` ->
6953 // `RedirectOutputToSiol` -> `write_simulated_output_siol`, VAL array
6954 // -> SIOL) already reproduces.
6955 let is_input = input_stage
6956 || matches!(
6957 rtype.as_str(),
6958 "ai" | "bi"
6959 | "mbbi"
6960 | "mbbiDirect"
6961 | "longin"
6962 | "int64in"
6963 | "stringin"
6964 | "lsi"
6965 | "event"
6966 | "waveform"
6967 | "histogram"
6968 | "aai"
6969 // synApps `mca`: `mcaRecord.c:1097` `readValue` reads
6970 // SIOL IN (`dbGetLink(&siol, ftvl, bptr, NULL,
6971 // &nRequest)` with `nRequest = nmax`), exactly as
6972 // `waveform` does. Omitting it sent a simulated mca
6973 // down the OUTPUT branch, which writes VAL out to SIOL.
6974 | "mca"
6975 );
6976
6977 // Resolve the SIM-block fields through the INSTANCE, not through
6978 // `Record::get_field`. A record need not model every field its
6979 // `.dbd` declares, and `mca` deliberately does not model
6980 // SIML/SIOL — it leaves them to the framework
6981 // (`mca-rs/src/record/mod.rs:896-902`) — so their link text lives
6982 // in the instance's declared-override store and `record.get_field`
6983 // answers `None`. That read an empty SIOL on every simulated mca.
6984 // `resolve_field` is the single owner of "what does this field read
6985 // as": record state, dbCommon, virtual, override, `.dbd` initial.
6986 let siml = instance
6987 .resolve_field("SIML")
6988 .and_then(|v| {
6989 if let EpicsValue::String(s) = v {
6990 Some(s)
6991 } else {
6992 None
6993 }
6994 })
6995 .unwrap_or_default();
6996 let siol = instance
6997 .resolve_field("SIOL")
6998 .and_then(|v| {
6999 if let EpicsValue::String(s) = v {
7000 Some(s)
7001 } else {
7002 None
7003 }
7004 })
7005 .unwrap_or_default();
7006 // SIMS is `DBF_MENU` (`mcaRecord.dbd:391`, `aiRecord.dbd.pod:511`
7007 // and every other), so read the INDEX and not one chosen carrier:
7008 // base record types answer `EpicsValue::Short` (`records/ai.rs:310`)
7009 // while `mca` answers `EpicsValue::Enum`
7010 // (`mca-rs/src/record/mod.rs:679`). Narrowing on `Short` here read
7011 // `mca`'s SIMS as the `unwrap_or(0)` default, so a simulated mca
7012 // raised `SIMM_ALARM` at NO_ALARM whatever the database asked for
7013 // — silently, since a menu index of 0 is a legal value.
7014 let sims = instance
7015 .resolve_field("SIMS")
7016 .and_then(|v| v.to_menu_index())
7017 .unwrap_or(0);
7018 // SDLY ("Sim. Mode Async Delay", DBF_DOUBLE, dbd initial
7019 // "-1.0"). Absent on record types whose SIMM group Rust does not
7020 // yet fully model — default to -1.0 (synchronous) so the async
7021 // branch is a no-op there, exactly as a record with the C default
7022 // behaves.
7023 let sdly = instance
7024 .resolve_field("SDLY")
7025 .and_then(|v| v.to_f64())
7026 .unwrap_or(-1.0);
7027
7028 // The entry gate is the SIM BLOCK's own marker — the SIMM field.
7029 // C's `readValue`/`writeValue` exists only on a record whose dbd
7030 // declares SIMM, and it dispatches on SIMM alone; the SIML/SIOL
7031 // links are read INSIDE that dispatch, never as a precondition for
7032 // it. Gating on "SIML and SIOL are both empty" (the pre-fix gate)
7033 // made `caput REC.SIMM 1` + `caput REC.SVAL 42` — simulate against
7034 // a constant, the standard idiom — a complete no-op on every
7035 // record, because an unset SIOL is exactly the case C serves from
7036 // SVAL (R12-61).
7037 if instance.resolve_field("SIMM").is_none() {
7038 // no simulation block
7039 return (
7040 SimOutcome::NotSimulated,
7041 instance.pact_exit_without_release(),
7042 );
7043 }
7044
7045 let siml_parsed = crate::server::record::parse_link_v2(siml.as_str_lossy().as_ref());
7046 // SIOL is `DBF_INLINK` on an input record (`aiRecord.dbd.pod:492`)
7047 // and `DBF_OUTLINK` on an output one (`aoRecord.dbd.pod:551`), so
7048 // its modifier mask (`dbStaticLib.c:2380-2391`) follows the same
7049 // direction split — CP/CPP is discarded on the output side.
7050 let siol_parsed = crate::server::record::parse_link_field(
7051 siol.as_str_lossy().as_ref(),
7052 if is_input {
7053 crate::server::record::LinkFieldType::In
7054 } else {
7055 crate::server::record::LinkFieldType::Out
7056 },
7057 );
7058
7059 (
7060 siml_parsed,
7061 siol_parsed,
7062 sims,
7063 sdly,
7064 rtype,
7065 is_input,
7066 input_stage,
7067 pact_held,
7068 )
7069 };
7070
7071 // Read SIML -> update SIMM, but only when PACT is not held. C resolves
7072 // the simulation mode in `recGblGetSimm` (`dbGetLink(&prec->siml,
7073 // DBR_USHORT, &prec->simm, 0, 0)`, reads the SIML link for any type)
7074 // guarded by `if (!prec->pact)` (aiRecord.c:475 / aoRecord.c:558): SIMM
7075 // is latched whenever the record re-enters with PACT held and is
7076 // re-resolved on every `pact=FALSE` entry. Gate the re-read on
7077 // `!pact_held` to match exactly: on the SDLY async continuation (PACT
7078 // held) the latch holds, so a SIML source that flips during the delay
7079 // cannot switch the deferred SIOL round-trip into a real device read;
7080 // on a `pact=FALSE` delayed re-trigger (the bo HIGH one-shot) the
7081 // re-resolve runs, matching C's fresh `recGblGetSimm`. The non-held
7082 // entry persists SIMM via `put_field` below, so a later held
7083 // continuation reads it back latched. (The pre-fix port only read a
7084 // `ParsedLink::Db` SIML, ignoring a CA/PVA/constant source.)
7085 //
7086 // The read itself goes through the SIMM transition owner
7087 // (`rec_gbl_get_simm`, C `recGblGetSimm`), which is the ONLY site that
7088 // writes SIMM.
7089 if !pact_held {
7090 let siml_read_failed = self.rec_gbl_get_simm(rec, &siml_link);
7091 // W10-E5. `busyRecord.c:399-401` returns from `writeValue` on a
7092 // failed SIML read — BEFORE `write_busy` and before the SIOL
7093 // `dbPutLink`. So C never reaches the `switch (prec->simm)` below:
7094 // no device write, no SIOL redirect, no SIMM_ALARM. The LINK_ALARM
7095 // that `dbGetLink`'s `setLinkAlarm` raised inside `rec_gbl_get_simm`
7096 // is the cycle's only simulation alarm.
7097 //
7098 // Only a record that declares it aborts takes this path — busy. The
7099 // recGblGetSimm records' equivalent `if (status) return status;` is
7100 // dead code (recGbl.c:456 always returns 0) and swait never tests
7101 // the status (swaitRecord.c:402), so both fall through to the switch
7102 // with SIMM at whatever value it already held.
7103 if siml_read_failed {
7104 let aborts = {
7105 let instance = rec.read();
7106 instance.record.aborts_on_failed_siml_read()
7107 };
7108 if aborts {
7109 // Reachable only under `!pact_held`, so no PACT to release.
7110 let exit = rec.read().pact_exit_without_release();
7111 return (SimOutcome::AbortedBeforeWrite, exit);
7112 }
7113 }
7114 }
7115
7116 // Check SIMM. The dispatch is the record's own C `switch (prec->simm)`,
7117 // whose legal arms are the choices of ITS SIMM menu — `resolve_sim_mode`
7118 // is the single owner of that fact.
7119 let mode = {
7120 let instance = rec.read();
7121 crate::server::recgbl::simm::resolve_sim_mode(&*instance.record)
7122 };
7123
7124 if !mode.is_simulated() {
7125 // PACT, if held, belongs to the continuation arm of the uniform
7126 // body — released there, with its park.
7127 let exit = rec.read().pact_exit_without_release();
7128 return (SimOutcome::NotSimulated, exit); // menuSimmNO
7129 }
7130
7131 // C `default:` arm — `recGblSetSevr(prec, SOFT_ALARM, INVALID_ALARM)`
7132 // and NOTHING else: the device is not substituted, SIOL is never read or
7133 // written, SIMM_ALARM is not raised and VAL/UDF are untouched. Raise the
7134 // alarm here (into the PENDING pair, so the body/tail maximizes against
7135 // it exactly as C does) and tell the caller to suppress the record's I/O
7136 // stage. This is the arm a `SIMM = 2` (RAW) reaches on the 13 records
7137 // whose SIMM is `menu(menuYesNo)` — R11-C12 — and the arm ANY
7138 // out-of-menu SIMM reaches on all of them, since `recGblGetSimm`'s
7139 // `dbTryGetLink` writes SIMM with no menu validation at all.
7140 if mode == crate::server::recgbl::simm::SimMode::Illegal {
7141 let mut instance = rec.write();
7142 crate::server::recgbl::rec_gbl_set_sevr(
7143 &mut instance.common,
7144 crate::server::recgbl::alarm_status::SOFT_ALARM,
7145 crate::server::record::AlarmSeverity::Invalid,
7146 );
7147 // Reachable with PACT held only on an SDLY continuation whose SIMM
7148 // was made illegal (by a `caput`) during the delay: C's `readValue`
7149 // re-reads SIMM only when `!pact`, so the continuation's switch sees
7150 // the new value and takes `default:` — which does NOT clear `pact`,
7151 // but the record's `process()` ends with `prec->pact = FALSE` on the
7152 // way out. Release it here for the same reason the YES/RAW branches
7153 // do (below and at the `Simulated` tail): the cycle ends, so the
7154 // record must be left idle. The release carries the put-notify
7155 // parked on the SDLY window out to the caller's tail.
7156 let exit = if pact_held {
7157 instance.leave_pact()
7158 } else {
7159 instance.pact_exit_without_release()
7160 };
7161 let is_output = !is_input;
7162 drop(instance);
7163 return (SimOutcome::IllegalMode { is_output }, exit);
7164 }
7165
7166 // epics-base 7.0.7 (SIMM menu):
7167 // 1 = YES — read/write via SIOL using the cooked VAL
7168 // 2 = RAW — read/write via SIOL using the raw RVAL when the
7169 // record carries one (ai/ao only); falls back to
7170 // VAL when no RVAL is present. Mirrors the C
7171 // implementation, which treats records lacking
7172 // a raw value as "YES" since there's nothing
7173 // else to copy.
7174 let raw_mode = mode == crate::server::recgbl::simm::SimMode::Raw;
7175
7176 // SDLY async simulation — C `aiRecord.c::readValue` (488) /
7177 // `aoRecord.c::writeValue` (571): `if (prec->pact || prec->sdly < 0)`
7178 // takes the synchronous SIOL branch; otherwise (`!pact && sdly >= 0`)
7179 // it schedules `callbackRequestProcessCallbackDelayed(..., sdly)` and
7180 // sets `pact = TRUE`. Key the defer on the same `!pact_held && sdly >= 0`
7181 // as C: a non-held entry (fresh cycle, or a `pact=FALSE` re-trigger)
7182 // with a non-negative SDLY defers the whole SIOL round-trip (input read
7183 // OR output write — both C paths share this branch) by `SDLY` seconds
7184 // and holds PACT; the resulting PACT-held continuation falls through to
7185 // the synchronous branch below.
7186 if !pact_held && sdly >= 0.0 {
7187 // Reachable only under `!pact_held`: this is the arm that TAKES PACT.
7188 let exit = rec.read().pact_exit_without_release();
7189 return (
7190 SimOutcome::DeferRead(crate::runtime::time::duration_from_secs(sdly)),
7191 exit,
7192 );
7193 }
7194
7195 // INPUT-STAGE record (swait). C `swaitRecord.c:415-422`:
7196 //
7197 // ```c
7198 // } else { /* SIMULATION MODE */
7199 // status = dbGetLink(&(pwait->siol),DBR_DOUBLE,&(pwait->sval),0,0);
7200 // if (status==0) {
7201 // pwait->val=pwait->sval;
7202 // pwait->udf=FALSE;
7203 // }
7204 // recGblSetSevr(pwait,SIMM_ALARM,pwait->sims);
7205 // }
7206 // ```
7207 //
7208 // The read substitutes `fetch_values()` + `calcPerform()` and nothing
7209 // else, so this performs exactly those four lines and hands the cycle
7210 // back: the OOPT switch, `execOutput`, the monitors and the forward link
7211 // all still come from the record's own `process()`. SIMM_ALARM goes into
7212 // the PENDING alarm (`rec_gbl_set_sevr` is C's MAXIMIZE) before the body
7213 // runs, so a body-raised alarm maximizes against it exactly as in C.
7214 if input_stage {
7215 // C `swaitRecord.c:416` reads SIOL with a plain `dbGetLink`, so a
7216 // FAILED read runs `setLinkAlarm` (dbLink.c:322) inside the read —
7217 // LINK_ALARM/INVALID with AMSG "field SIOL", raised BEFORE the
7218 // SIMM_ALARM below because that is swait's order (`dbGetLink` at
7219 // `swaitRecord.c:416`, then `recGblSetSevr(SIMM_ALARM, sims)` at
7220 // `:421`) — the opposite of the base records. `rec_gbl_set_sevr*` is
7221 // strict-greater, so with `SIMS = INVALID` the LINK_ALARM raised
7222 // first WINS the tie here and swait publishes
7223 // STAT=LINK/AMSG="field SIOL", where a longin publishes STAT=SIMM.
7224 // Compiled C confirms both.
7225 let fetch = self.db_get_link(rec, "SIOL", &siol_link);
7226 let mut instance = rec.write();
7227 // C `:417-420` — `if (status == 0) { val = sval; udf = FALSE; }`.
7228 // A CONSTANT (or unset) SIOL is `status == 0` with SVAL untouched
7229 // (`dbConstGetValue`), so it still copies SVAL into VAL; only a
7230 // FAILED read changes neither VAL nor UDF. The SIMM_ALARM below is
7231 // unconditional either way.
7232 if fetch.is_ok() {
7233 if let crate::server::recgbl::simm::LinkFetch::Value(v) = fetch {
7234 let sval = EpicsValue::Double(v.to_f64().unwrap_or(0.0));
7235 let _ = instance.record.put_field_internal("SVAL", sval);
7236 }
7237 if let Some(sval) = instance.record.get_field("SVAL") {
7238 let _ = instance.record.land_simulated_value(sval);
7239 }
7240 instance.common.udf = 0;
7241 }
7242 let sev = crate::server::record::AlarmSeverity::from_u16(sims as u16);
7243 crate::server::recgbl::rec_gbl_set_sevr(
7244 &mut instance.common,
7245 crate::server::recgbl::alarm_status::SIMM_ALARM,
7246 sev,
7247 );
7248 // swait keeps the cycle going through the uniform body; a held PACT
7249 // is released at its continuation arm, with its park. Mint the
7250 // token from the write guard already held — parking_lot is not
7251 // reentrant, so a fresh `rec.read()` here deadlocks.
7252 let exit = instance.pact_exit_without_release();
7253 return (SimOutcome::SimulatedInputStage, exit);
7254 }
7255
7256 // OUTPUT record: C `writeValue` substitutes the device write with the
7257 // SIOL write, but it runs at the END of `process()` — after the body
7258 // has computed OVAL (OROC) and armed any record state machine (bo HIGH
7259 // momentary reset). The output write therefore CANNOT be done here, up
7260 // front, the way the input read can: doing so would write the stale
7261 // pre-body VAL and skip the body entirely (the divergence this path
7262 // closes). Hand the redirect back so the uniform flow runs the body and
7263 // the OUT-stage epilogue writes the fresh OVAL/RVAL to SIOL. Clear the
7264 // SDLY-held PACT first (C `writeValue` sets `pact = FALSE` on the sync
7265 // continuation) so the body runs on an idle record.
7266 if !is_input {
7267 let exit = if pact_held {
7268 let mut instance = rec.write();
7269 instance.leave_pact()
7270 } else {
7271 rec.read().pact_exit_without_release()
7272 };
7273 return (
7274 SimOutcome::RedirectOutputToSiol {
7275 siol: siol_link,
7276 sims,
7277 raw_mode,
7278 },
7279 exit,
7280 );
7281 }
7282
7283 // SIMM=YES(1) / SIMM=RAW(2): read the SIOL link into VAL/RVAL. C
7284 // `readValue` for a SIMM-mode INPUT record goes through `dbGetLink`,
7285 // which dispatches by link type — a local DB target, a CA target (a
7286 // bare non-local name or an explicit `CA`/`ca://` link), or a
7287 // constant. The pre-fix port special-cased a local `ParsedLink::Db`
7288 // SIOL only, so a non-local or external SIOL never read yet still
7289 // returned `Simulated` — the record froze with no value and no alarm.
7290 // Dispatch uniformly through the same link read owner as every other
7291 // link; the alarm/timestamp/notify tail below now runs for every SIOL
7292 // link type.
7293 //
7294 // Output records returned `RedirectOutputToSiol` above (the output
7295 // write follows the body), so only an INPUT record reaches here — its
7296 // `readValue` precedes the body, so the SIOL read + convert are done
7297 // in place and the caller short-circuits.
7298 let sim_posts = {
7299 // C `readValue` raises the SIMM severity at the TOP of the
7300 // `case menuYesNoYES:` arm — BEFORE the SIOL read
7301 // (`longinRecord.c:414` `recGblSetSevr(prec, SIMM_ALARM, prec->sims)`,
7302 // then `:416` `dbGetLink(&prec->siol, ...)`); likewise ai, mbbi,
7303 // histogram, waveform. That ORDER is load-bearing, not cosmetic:
7304 // `recGblSetSevr` is strict-greater, so when the SIOL read fails and
7305 // raises LINK_ALARM/INVALID (below), an already-pending
7306 // SIMM_ALARM/INVALID (`SIMS = INVALID`) WINS the tie and the record
7307 // publishes STAT=SIMM_ALARM — while with the default
7308 // `SIMS = NO_ALARM` nothing is pending, so LINK_ALARM/INVALID lands
7309 // and the broken SIOL is reported.
7310 //
7311 // Not every record raises it first, so the ORDER is the record's to
7312 // declare, not this site's: `mca` reads SIOL and only then raises
7313 // (`mcaRecord.c:1118` then `:1129`), so with `SIMS = INVALID` the
7314 // LINK_ALARM wins there and C publishes STAT=LINK_ALARM. That is
7315 // [`Record::raises_simm_after_read`]; the default is C's base-record
7316 // order and lands here, before the read.
7317 let raise_simm = |common: &mut crate::server::record::CommonFields| {
7318 let sev = crate::server::record::AlarmSeverity::from_u16(sims as u16);
7319 crate::server::recgbl::rec_gbl_set_sevr(
7320 common,
7321 crate::server::recgbl::alarm_status::SIMM_ALARM,
7322 sev,
7323 );
7324 };
7325 let simm_after_read = rec.read().record.raises_simm_after_read();
7326 if !simm_after_read {
7327 raise_simm(&mut rec.write().common);
7328 }
7329
7330 // Read from SIOL -> SVAL -> VAL/RVAL. Uniform across Db (with
7331 // locality fallback) / Ca / Pva / constant via `fetch_link`
7332 // (C `dbGetLink`), which keeps C's three outcomes apart: a value,
7333 // a CONSTANT link's "status 0 with the buffer untouched", and a
7334 // failure. Converted to the record's declared request: stringin
7335 // reads SIOL with `DBR_STRING` (`stringinRecord.c:208`), lsi via
7336 // `dbGetLinkLS` (`lsiRecord.c:244`).
7337 let fetch = self.db_get_link(rec, "SIOL", &siol_link);
7338 let (fetch, _raw) = self.convert_link_fetch(rec, "SIOL", &siol_link, fetch);
7339 // Resolved before the write guard below, which reaches
7340 // `sim_process_tail`'s posts — see the same resolve at the head of
7341 // `process_record_with_links_body`.
7342 let link_backing = self.resolve_link_backed_metadata(rec);
7343 let link_backing = crate::server::database::LinkBacking::resolved(&link_backing);
7344 // The read itself raised C's `setLinkAlarm` (dbLink.c:321 ->
7345 // `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "field SIOL")`) on a
7346 // FAILED fetch. For a base record that is AFTER the SIMM_ALARM
7347 // above (`longinRecord.c:414` then `:416`), so with
7348 // `SIMS = INVALID` the equal-severity LINK_ALARM loses the tie and
7349 // STAT stays SIMM.
7350 //
7351 // The tail's `recGblGetTimeStampSimm` owes its TSEL read here, after
7352 // the SIOL read that stands in for the device read and before the
7353 // guard the store runs under.
7354 let tsel = self.read_tsel(rec);
7355 let mut instance = rec.write();
7356
7357 // The other order (`mcaRecord.c:1118` then `:1129`): the read has
7358 // happened, so its LINK_ALARM is already pending and the
7359 // equal-severity SIMM_ALARM now loses the tie instead.
7360 if simm_after_read {
7361 raise_simm(&mut instance.common);
7362 }
7363
7364 // C's SIOL read buffer is `&prec->sval` on every scalar SIML/SIOL
7365 // record (`longinRecord.c:416` `dbGetLink(&prec->siol, DBR_LONG,
7366 // &prec->sval)`, then `prec->val = prec->sval`). The records with
7367 // no SVAL field read straight into the value —
7368 // `waveform`/`aai` into `bptr` (waveformRecord.c:351), `lsi` into
7369 // `val` (lsiRecord.c:244) — so for them the fetched value IS the
7370 // landed value and a constant SIOL lands nothing.
7371 //
7372 // Routing the read through SVAL is what makes `caput REC.SIMM 1;
7373 // caput REC.SVAL 42` work (R12-61): the unset SIOL delivers no
7374 // data (status 0), and C's `val = sval` then publishes the SVAL
7375 // the operator wrote.
7376 let has_sval = instance.record.get_field("SVAL").is_some();
7377 let landed: Option<EpicsValue> = match &fetch {
7378 crate::server::recgbl::simm::LinkFetch::Value(v) => {
7379 if has_sval {
7380 // `put_field_internal` is the DBR-coercion owner
7381 // (C `dbGetLink(DBF_<sval>)`).
7382 let _ = instance.record.put_field_internal("SVAL", v.clone());
7383 instance.record.get_field("SVAL")
7384 } else {
7385 Some(v.clone())
7386 }
7387 }
7388 crate::server::recgbl::simm::LinkFetch::NoData => {
7389 if has_sval {
7390 instance.record.get_field("SVAL")
7391 } else {
7392 None
7393 }
7394 }
7395 crate::server::recgbl::simm::LinkFetch::Failed => None,
7396 };
7397
7398 if let Some(siol_val) = landed {
7399 let target_supports_raw = raw_mode && instance.record.get_field("RVAL").is_some();
7400 if target_supports_raw {
7401 // PR #ac92e3e follow-up: SIMM=RAW on records
7402 // with RVAL (ai/ao/etc.) writes the raw value
7403 // into RVAL and runs the record's own
7404 // process() so the LINR / ESLO / EOFF / ASLO
7405 // / AOFF conversion chain computes VAL. The
7406 // pre-fix path additionally called set_val
7407 // here, which overwrote VAL with the raw
7408 // count and silently bypassed conversion —
7409 // the visible failure mode was "SIMM=RAW
7410 // simulation returns counts instead of EGU".
7411 //
7412 // Coerce to RVAL's native DBR type before
7413 // put_field — ai.RVAL is Long, but SIOL on a
7414 // soft channel typically yields Double. Without
7415 // the coerce step the put_field rejects with
7416 // TypeMismatch and leaves RVAL at 0, so
7417 // process() computes VAL = 0*ESLO + EOFF
7418 // (the offset only), not the intended
7419 // RAW*ESLO + EOFF.
7420 let rval_type = crate::server::record::record_instance::declared_field_type_of(
7421 instance.record.as_ref(),
7422 "RVAL",
7423 )
7424 .unwrap_or(crate::types::DbFieldType::Long);
7425 // C parity (aiRecord.c:495): `rval = (long)floor(sval)`.
7426 // Rust `convert_to(Long)` truncates toward zero,
7427 // diverging for negative bipolar-ADC raw values
7428 // (sval=-1.5 → C: -2, Rust as-cast: -1).
7429 // Floor explicitly when narrowing a float to
7430 // an integer RVAL.
7431 let coerced = match (&siol_val, rval_type) {
7432 (EpicsValue::Double(d), crate::types::DbFieldType::Long) => {
7433 EpicsValue::Long(d.floor() as i32)
7434 }
7435 (EpicsValue::Double(d), crate::types::DbFieldType::Int64) => {
7436 EpicsValue::Int64(d.floor() as i64)
7437 }
7438 (EpicsValue::Float(d), crate::types::DbFieldType::Long) => {
7439 EpicsValue::Long((*d as f64).floor() as i32)
7440 }
7441 (EpicsValue::Float(d), crate::types::DbFieldType::Int64) => {
7442 EpicsValue::Int64((*d as f64).floor() as i64)
7443 }
7444 _ if siol_val.db_field_type() != rval_type => {
7445 siol_val.convert_to(rval_type)
7446 }
7447 _ => siol_val,
7448 };
7449 let _ = instance.record.put_field("RVAL", coerced);
7450 let ctx = instance.common.process_context();
7451 instance.record.set_process_context(&ctx);
7452 let _ = instance.record.process();
7453 } else {
7454 // Records without RVAL fall back to SIMM=YES semantics: the
7455 // SIOL value lands where C's `readValue` lands it — VAL for
7456 // the base records (`longinRecord.c:417` `val = sval`), SGNL
7457 // plus the bin increment for `histogram`
7458 // (`histogramRecord.c:385` + `:219`). `land_simulated_value`
7459 // is the single owner of that assignment; no conversion to
7460 // run either way.
7461 let _ = instance.record.land_simulated_value(siol_val);
7462 }
7463 }
7464
7465 // Simulation alarm + per-field monitor tail — see
7466 // `sim_process_tail`. C raises `recGblSetSevr(prec, SIMM_ALARM,
7467 // prec->sims)` at the TOP of the SIMM branch, BEFORE the SIOL read
7468 // (longinRecord.c:413-414), and `process()` runs its
7469 // timestamp/alarm/monitor/forward-link tail whatever the read
7470 // returned — so the tail is unconditional, not gated on a value
7471 // having landed (R12-61). UDF is the one part C does gate on the
7472 // read's status (`if (status == 0) prec->udf = FALSE`), and a
7473 // constant SIOL is status 0.
7474 sim_process_tail(&mut instance, tsel, fetch.is_ok(), link_backing)
7475 };
7476
7477 // C `readValue`/`writeValue` clears `pact` on the synchronous branch
7478 // (`prec->pact = FALSE`, aiRecord.c:496 / aoRecord.c:578). On the
7479 // SDLY continuation this releases the PACT held across the delay so the
7480 // forward-link tail and any subsequent foreign process see the record
7481 // idle (C posts `monitor()` + `recGblFwdLink` with pact already
7482 // FALSE). An entry that never held PACT (a fresh `sdly < 0` cycle, or a
7483 // `pact=FALSE` re-trigger) has nothing to release, so the clear is gated
7484 // on `pact_held` to avoid a needless write-lock there.
7485 let exit = if pact_held {
7486 let mut instance = rec.write();
7487 instance.leave_pact()
7488 } else {
7489 rec.read().pact_exit_without_release()
7490 };
7491
7492 (SimOutcome::Simulated(sim_posts), exit)
7493 }
7494}
7495
7496/// Shared tail of a simulated (`SIMM` != NO) process cycle — the part of
7497/// C `process()` that still runs when `readValue`/`writeValue` divert to
7498/// the SIOL (`aiRecord.c` and every SIML/SIMM-bearing record):
7499/// `checkAlarms`, `recGblResetAlarms` and `monitor()`, so the simulated value
7500/// still trips its own limit/state alarms and the alarms the SIMM branch
7501/// already raised maximize against them.
7502///
7503/// The tail raises NO alarm of its own. Every alarm a simulated cycle can
7504/// raise — SIMM_ALARM at SIMS on the YES/RAW arms, LINK_ALARM on a failed SIOL
7505/// `dbGetLink`, SOFT_ALARM/INVALID on the `default:` arm — is raised by
7506/// `check_simulation_mode` at the point C raises it, because
7507/// `recGblSetSevr` is a strict-greater MAXIMIZE and the ORDER of those calls
7508/// decides equal-severity ties (W10-E4). Folding the SIMM raise in here instead
7509/// silently reordered it after the SIOL read.
7510///
7511/// The posting masks are per-field, identical to the async-completion
7512/// path (`complete_async_record`) and `process_local`:
7513///
7514/// * the deadband-tracked field (default `VAL`) posts the classes that
7515/// actually fired — MDEL → `DBE_VALUE`, ADEL → `DBE_LOG`, alarm
7516/// movement → `DBE_ALARM` (C `recGblResetAlarms` `val_mask`); the
7517/// lsi/lso explicit change gate, MPST/APST always-post override, and
7518/// binary always-post route through the same hooks as those paths;
7519/// * `SEVR` posts `DBE_VALUE` only on a sevr change; `STAT`/`AMSG`
7520/// share a mask carrying `DBE_ALARM` (sevr/amsg moved) and/or
7521/// `DBE_VALUE` (stat moved); `ACKS` posts `DBE_VALUE` when the reset
7522/// raised it (recGbl.c:202-222);
7523/// * subscribed auxiliary fields post on value change with
7524/// `DBE_VALUE|DBE_LOG` plus the cycle's alarm bits (C change-detected
7525/// posts in each record's `monitor()`, e.g. ai `oraw != rval`), and
7526/// `UDF` rides along with the union of the cycle's posted classes.
7527///
7528/// The pre-fix tails (duplicated across the input and output SIMM
7529/// branches) pushed `VAL`/`SEVR`/`STAT` unconditionally with one shared
7530/// `DBE_VALUE|DBE_ALARM` mask and discarded the `rec_gbl_reset_alarms`
7531/// result — every simulated cycle re-sent unchanged alarm fields,
7532/// stamped `DBE_ALARM` on cycles whose alarm state never moved, and
7533/// bypassed the MDEL/ADEL deadband entirely.
7534fn sim_process_tail(
7535 instance: &mut RecordInstance,
7536 tsel: super::TselStamp,
7537 clear_udf: bool,
7538 backing: crate::server::database::LinkBacking<'_>,
7539) -> CyclePosts {
7540 use crate::server::recgbl::EventMask;
7541
7542 let inst = &mut *instance;
7543 tsel.stamp(&inst.name, &mut inst.common, true);
7544 // C clears UDF only on a `status == 0` SIOL read (`longinRecord.c:418`) —
7545 // for most records a failed read leaves the record undefined. The array
7546 // records are the exception: their `process()` clears UDF itself, after
7547 // `readValue` returns and whatever its status (waveformRecord.c:144,
7548 // aaiRecord.c:174, aaoRecord.c:165). They declare that with
7549 // `clears_udf_unconditionally`, which is the record's own C, not a
7550 // framework choice.
7551 if clear_udf || instance.record.clears_udf_unconditionally() {
7552 instance.common.udf = 0;
7553 }
7554
7555 {
7556 let inst = &mut *instance;
7557 inst.record.check_alarms(&mut inst.common);
7558 }
7559 instance.evaluate_alarms();
7560 let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
7561
7562 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
7563 EventMask::ALARM
7564 } else {
7565 EventMask::NONE
7566 };
7567
7568 // The primary-value VALUE/LOG gate, through the single owner (see
7569 // `RecordInstance::value_include_classes`) so trigger-VAL suppression and
7570 // the deadband/change gates hold identically on every processing path.
7571 let (include_val, include_archive) = instance.value_include_classes();
7572 let deadband_field = instance.record.monitor_deadband_field();
7573 // The mask every change-detected aux field posts with — owned by
7574 // `AuxPostMask`, the single resolver of the record's declared narrowings of
7575 // C's default `monitor_mask | DBE_VALUE | DBE_LOG`.
7576 let aux_post = AuxPostMask::of(instance.record.as_ref());
7577 // The deadband field's post — mask owned by `deadband_post`, the single
7578 // assembler for C's `db_post_events(&prec->val, monitor_mask)`.
7579 let deadband = instance.deadband_post(alarm_bits, include_val, include_archive);
7580 let deadband_mask = deadband.mask;
7581 let mut changed_fields = Vec::new();
7582 if let Some((field, value)) = deadband.field {
7583 changed_fields.push((field, value, deadband_mask));
7584 }
7585
7586 let sevr_changed = instance.common.sevr != alarm_result.prev_sevr;
7587 let stat_changed = instance.common.stat != alarm_result.prev_stat;
7588 let stat_mask = {
7589 let mut m = EventMask::NONE;
7590 if sevr_changed || alarm_result.amsg_changed {
7591 m |= EventMask::ALARM;
7592 }
7593 if stat_changed {
7594 m |= EventMask::VALUE;
7595 }
7596 m
7597 };
7598
7599 // The cycle's subscriber posts — assembled by the single owner
7600 // `RecordInstance::collect_subscriber_posts`. The simulation path is a
7601 // process cycle like any other, so it obeys the same rules (this copy used
7602 // to omit the `process_posted_fields` gate; the shared owner applies it).
7603 changed_fields.extend(instance.collect_subscriber_posts(
7604 deadband_field,
7605 deadband_mask,
7606 alarm_bits,
7607 aux_post,
7608 include_val,
7609 ));
7610 // C waveform/aai/aao `monitor()` posts HASH with a literal `DBE_VALUE`
7611 // only on a content-hash change (waveformRecord.c:317-319), independent
7612 // of the VAL post mask. `array_hash_changed` was set by
7613 // `check_deadband_ext` this cycle.
7614 if instance.array_hash_changed {
7615 if let Some(h) = instance.resolve_field("HASH") {
7616 changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
7617 }
7618 }
7619 // No `.UDF` post — see the main process path (C posts UDF from no
7620 // monitor() and from no recGblResetAlarms).
7621
7622 let snapshot = crate::server::record::ProcessSnapshot { changed_fields };
7623 instance.notify_from_snapshot(&snapshot, backing);
7624 let mut posts = CyclePosts::of(&snapshot);
7625 if sevr_changed {
7626 instance.notify_field("SEVR", EventMask::VALUE);
7627 posts = posts.with(EventMask::VALUE);
7628 }
7629 if !stat_mask.is_empty() {
7630 instance.notify_field("STAT", stat_mask);
7631 instance.notify_field("AMSG", stat_mask);
7632 posts = posts.with(stat_mask);
7633 }
7634 if alarm_result.acks_posted {
7635 instance.notify_field("ACKS", EventMask::VALUE);
7636 posts = posts.with(EventMask::VALUE);
7637 }
7638 posts
7639}
7640
7641/// The single finalizer for a process cycle, for every path that can end one.
7642///
7643/// **Invariant:** a cycle that ENDS runs [`PvDatabase::end_process_cycle`]
7644/// exactly once — C reaches `recGblFwdLink` (`recGbl.c:295-302`) on every path
7645/// that ends a cycle, and only there does `putf` clear, the wait-set `leave`,
7646/// and the next queued `processNotify` restart. A non-zero record status does
7647/// not exempt a cycle: `subRecord.c:145-167` runs the whole tail on any status
7648/// but the documented async `1`.
7649///
7650/// A guard and not a call because the tail sits BELOW fallible exits —
7651/// `run_registered_subroutine()?` and `record.process()?` — that no explicit
7652/// site covers. `#[must_use]` on [`PactExit`] cannot stand in for it: that
7653/// lint fires on an unused *expression*, and each of those paths drops a
7654/// `let`-bound token, which warns about nothing.
7655///
7656/// Declared BEFORE any `rec.write()` in the cycle body, so Rust's
7657/// reverse-declaration drop order puts the record's DATA lock down first and
7658/// this second; `end_process_cycle` takes that lock itself, and
7659/// `parking_lot::RwLock` is not reentrant.
7660///
7661/// Two ways to leave without the `Drop` firing, both explicit at the site:
7662/// [`Self::take`] for a site that ends the cycle its own way, and
7663/// [`Self::hand_off_to_async_completion`] for the async-output early return,
7664/// which does not end the cycle at all — `complete_async_record_inner` does,
7665/// later, from its own token.
7666struct CycleEndGuard<'a> {
7667 db: &'a PvDatabase,
7668 name: &'a str,
7669 rec: &'a Arc<parking_lot::RwLock<RecordInstance>>,
7670 exit: Option<crate::server::record::PactExit>,
7671}
7672
7673impl<'a> CycleEndGuard<'a> {
7674 fn new(
7675 db: &'a PvDatabase,
7676 name: &'a str,
7677 rec: &'a Arc<parking_lot::RwLock<RecordInstance>>,
7678 ) -> Self {
7679 Self {
7680 db,
7681 name,
7682 rec,
7683 exit: None,
7684 }
7685 }
7686
7687 /// Fold a release into the cycle's token at the moment it is minted, so the
7688 /// exits between here and the tail carry it without a site of their own.
7689 fn merge_in(&mut self, other: crate::server::record::PactExit) {
7690 self.exit = Some(match self.exit.take() {
7691 Some(held) => held.merge(other),
7692 None => other,
7693 });
7694 }
7695
7696 /// Disarm and hand the token to a site that ends the cycle itself.
7697 fn take(&mut self) -> crate::server::record::PactExit {
7698 self.exit
7699 .take()
7700 .unwrap_or_else(|| crate::server::record::PactExit::new(false))
7701 }
7702
7703 /// Disarm because this cycle is NOT ending: the async-output `write_begin`
7704 /// re-entered PACT and spawned the completion, so
7705 /// `complete_async_record_inner` owns the tail and mints its own token from
7706 /// the record when the device write lands.
7707 fn hand_off_to_async_completion(&mut self) {
7708 self.exit = None;
7709 }
7710}
7711
7712impl Drop for CycleEndGuard<'_> {
7713 fn drop(&mut self) {
7714 if let Some(exit) = self.exit.take() {
7715 self.db.end_process_cycle(self.name, self.rec, exit);
7716 }
7717 }
7718}