Skip to main content

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