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