Skip to main content

epics_base_rs/server/database/
processing.rs

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