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