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