Skip to main content

epics_base_rs/server/database/
processing.rs

1/// The records whose `dbProcess` frame is live on the current chain.
2///
3/// C keeps this marker on the record — `processTarget` claims
4/// `dbRec2Pvt(pdst)->procThread` before `dbProcess(pdst)`
5/// (`dbDbLink.c:500-503`) and the frame that claimed it clears it on unwind
6/// (`:523-526`). The port cannot put it there: its frame has released the
7/// record lock by the time it unwinds, and re-taking it to clear a flag would
8/// cost more than the marker saves. So the marker travels with the chain.
9///
10/// What it travelled in was a `HashSet<Arc<str>>`, which hashed the record
11/// name on the way in and again on the way out and allocated a table to hold,
12/// at the depth a scan cycle actually reaches, one entry. The depth is the
13/// point: a scan of a record whose links are unwired is depth one, so the
14/// first claim lives in a field and only a real cascade allocates.
15///
16/// The entry is the record cell's identity, as C's marker is on the record
17/// itself: an alias claims the same entry as its target, and a claim costs no
18/// name clone or compare.
19#[derive(Debug, Default)]
20pub struct ProcStack {
21    /// Depth one.
22    head: Option<CellId>,
23    /// Depth two and beyond.
24    rest: Vec<CellId>,
25}
26
27/// A record cell's address, compared and never dereferenced. It stays unique
28/// while it is on the stack because the frame that claimed it holds the
29/// cell's `Arc` until it releases it.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31struct CellId(usize);
32
33impl CellId {
34    fn of(rec: &Arc<RecordCell>) -> Self {
35        CellId(Arc::as_ptr(rec) as usize)
36    }
37}
38
39impl ProcStack {
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Claim `name` for the calling frame. `false` when it is already on the
45    /// chain — C's cycle — and then the caller has claimed nothing and must
46    /// not release anything.
47    pub fn claim(&mut self, rec: &Arc<RecordCell>) -> bool {
48        if self.holds(rec) {
49            return false;
50        }
51        let id = CellId::of(rec);
52        match self.head {
53            None => self.head = Some(id),
54            Some(_) => self.rest.push(id),
55        }
56        true
57    }
58
59    /// Release what [`Self::claim`] took, on the frame's unwind.
60    pub(crate) fn release(&mut self, rec: &Arc<RecordCell>) {
61        let id = CellId::of(rec);
62        if let Some(i) = self.rest.iter().rposition(|n| *n == id) {
63            self.rest.remove(i);
64        } else if self.head == Some(id) {
65            self.head = None;
66        }
67    }
68
69    /// How many frames are live on this chain.
70    pub fn len(&self) -> usize {
71        usize::from(self.head.is_some()) + self.rest.len()
72    }
73
74    /// Whether no frame is live on this chain — the entry is the outermost.
75    pub fn is_empty(&self) -> bool {
76        self.head.is_none() && self.rest.is_empty()
77    }
78
79    /// Whether a frame for `rec` is live on this chain.
80    pub fn holds(&self, rec: &Arc<RecordCell>) -> bool {
81        let id = CellId::of(rec);
82        self.head == Some(id) || self.rest.contains(&id)
83    }
84}
85
86use std::sync::Arc;
87use std::sync::atomic::{AtomicU64, Ordering};
88
89use crate::error::{CaError, CaResult};
90use crate::server::record::{
91    InputFetchPolicy, NotifyWaitSet, PactExit, RawSoftEntry, RecordCell, RecordInstance,
92};
93use crate::types::{DbFieldType, EpicsValue, PvString};
94
95use super::{MetadataPlan, PvDatabase};
96
97/// C `sCalcoutRecord.c` `STRING_SIZE` (:198) — the 40-byte buffer behind every
98/// string field a string-input link writes into. The text therefore carries at
99/// most 39 bytes plus the NUL, which is what `epicsSnprintf(..., STRING_SIZE-1,
100/// ...)` and `epicsStrSnPrintEscaped(..., STRING_SIZE-1, ...)` enforce in C.
101const STRING_FIELD_MAX_LEN: usize = 39;
102
103/// **The single owner of "this record's processing cycle was refused."**
104///
105/// C publishes a refused cycle exactly once, in `dbProcess`'s `MAX_LOCK`
106/// branch (`dbAccess.c:544-556`):
107///
108/// ```c
109/// recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM, "Async in progress");
110/// monitor_mask = recGblResetAlarms(precord);
111/// monitor_mask |= DBE_VALUE|DBE_LOG;
112/// db_post_events(precord, ((char *)precord) + pdbFldDes->offset, monitor_mask);
113/// ```
114///
115/// so a refusal is never a silent success: the record carries SCAN_ALARM /
116/// INVALID with the reason in `AMSG`, and the transition is posted. The one
117/// refusal the port can make, C's `MAX_LOCK` re-entry, routes through here;
118/// like C, the port has no link-depth bound.
119///
120/// Returns the post set for the caller to hand to `notify_from_snapshot` after
121/// releasing the write guard, or `None` when the record already carries this
122/// refusal — C's `if (precord->stat == SCAN_ALARM) goto all_done`, which is
123/// what keeps a repeatedly refused record from re-posting every cycle.
124fn scan_alarm_refusal(
125    instance: &mut RecordInstance,
126    msg: &str,
127) -> Option<crate::server::record::ProcessSnapshot> {
128    use crate::server::recgbl::EventMask;
129    if instance.common.stat == crate::server::recgbl::alarm_status::SCAN_ALARM
130        && instance.common.sevr >= crate::server::record::AlarmSeverity::Invalid
131    {
132        return None;
133    }
134    crate::server::recgbl::rec_gbl_set_sevr_msg(
135        &mut instance.common,
136        crate::server::recgbl::alarm_status::SCAN_ALARM,
137        crate::server::record::AlarmSeverity::Invalid,
138        msg,
139    );
140    let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
141    // Post VAL with VALUE|LOG|ALARM (C `db_post_events(prec, &VAL,
142    // DBE_VALUE|DBE_LOG)` plus recGblResetAlarms' `val_mask = DBE_ALARM` for
143    // the fresh transition). The alarm fields carry their C per-field masks
144    // (recGbl.c:202-222): this only runs on a fresh SCAN_ALARM/INVALID raise,
145    // so sevr AND stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post the
146    // shared `stat_mask` = DBE_ALARM|DBE_VALUE.
147    let stat_mask = EventMask::ALARM | EventMask::VALUE;
148    let mut changed_fields = crate::server::record::ProcessSnapshot::new();
149    if let Some(val) = instance.record.val() {
150        changed_fields.push((
151            "VAL".into(),
152            val,
153            EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
154        ));
155    }
156    changed_fields.push((
157        "SEVR".into(),
158        EpicsValue::Short(instance.common.sevr as i16),
159        EventMask::VALUE,
160    ));
161    changed_fields.push((
162        "STAT".into(),
163        EpicsValue::Short(instance.common.stat as i16),
164        stat_mask,
165    ));
166    // Include AMSG so subscribers reading the alarm text observe the reason
167    // alongside the SCAN_ALARM transition (C `recGbl.c:210-211` posts STAT and
168    // AMSG together when `stat_mask` is non-zero).
169    changed_fields.push((
170        "AMSG".into(),
171        EpicsValue::String(instance.common.amsg.as_str().into()),
172        stat_mask,
173    ));
174    Some(changed_fields)
175}
176
177/// Cut a string-link value to the C field width (see [`STRING_FIELD_MAX_LEN`]).
178fn truncate_string_field(s: PvString) -> PvString {
179    let bytes = s.as_bytes();
180    if bytes.len() <= STRING_FIELD_MAX_LEN {
181        return s;
182    }
183    PvString::from_bytes(&bytes[..STRING_FIELD_MAX_LEN])
184}
185
186/// The DBR_STRING view of a [`Record::string_input_links`](crate::server::record::Record::string_input_links) source, C
187/// `sCalcoutRecord.c::fetch_values` (895-937).
188///
189/// A `DBF_CHAR`/`DBF_UCHAR` source of more than one element is the one type C
190/// does NOT read as DBR_STRING (which would render element 0 as a number):
191/// it reads the array as text and escapes it with `epicsStrSnPrintEscaped`
192/// (`epicsString.c:230-261`), which is how a string longer than a DBR_STRING —
193/// or one carrying control characters — reaches a string calc. C caps the
194/// request at `STRING_SIZE-1` elements before the get and treats the result as
195/// a C string (`strlen(tmpstr)`), so the source is cut at 39 bytes and at the
196/// first NUL. Every other source type takes the plain `dbGetLink(DBR_STRING)`
197/// branch, i.e. the framework's own `DbFieldType::String` coercion.
198fn string_link_text(value: &EpicsValue) -> PvString {
199    let char_array_bytes = match value {
200        EpicsValue::CharArray(b) | EpicsValue::UCharArray(b) if b.len() > 1 => Some(b),
201        _ => None,
202    };
203    if let Some(bytes) = char_array_bytes {
204        let src = &bytes[..bytes.len().min(STRING_FIELD_MAX_LEN)];
205        let src = &src[..src.iter().position(|&b| b == 0).unwrap_or(src.len())];
206        let mut out = String::with_capacity(src.len());
207        for &b in src {
208            match b {
209                0x07 => out.push_str("\\a"),
210                0x08 => out.push_str("\\b"),
211                0x0c => out.push_str("\\f"),
212                b'\n' => out.push_str("\\n"),
213                b'\r' => out.push_str("\\r"),
214                b'\t' => out.push_str("\\t"),
215                0x0b => out.push_str("\\v"),
216                b'\\' => out.push_str("\\\\"),
217                b'\'' => out.push_str("\\'"),
218                b'"' => out.push_str("\\\""),
219                // C `isprint` in the "C" locale: ASCII 0x20..0x7e. Everything
220                // else — including the high half — is escaped `\xHH`.
221                _ if b.is_ascii_graphic() || b == b' ' => out.push(b as char),
222                _ => out.push_str(&format!("\\x{b:02x}")),
223            }
224        }
225        return truncate_string_field(PvString::from(out));
226    }
227    match value.convert_to(DbFieldType::String) {
228        EpicsValue::String(s) => truncate_string_field(s),
229        _ => PvString::new(),
230    }
231}
232
233/// A cancellable, generation-gated handle that re-enters an async record's
234/// `process()` exactly once.
235///
236/// C parity: epics-base `callbackRequest` / `callbackRequestDelayed`
237/// (`callback.c`) post a one-shot callback that later runs the record's
238/// `(*prset->process)(precord)` directly, bypassing `dbProcess`'s PACT
239/// entry guard. Here, firing the token re-enters via
240/// [`PvDatabase::process_record_continuation`] (the owner-driven
241/// continuation that also bypasses the PACT guard).
242///
243/// # Cancellation is structural, not a runtime check
244///
245/// The record owns a monotonic generation counter (`reprocess_generation`).
246/// Minting a token snapshots that counter as the token's `epoch` *after*
247/// bumping it, so:
248///
249/// - minting a newer token for the same record (C `callbackRequestDelayed`
250///   replacing an outstanding delayed callback), or
251/// - [`PvDatabase::cancel_async_reentry`] (C `callbackCancelDelayed`),
252///
253/// each advance the counter past every outstanding token's `epoch`. A
254/// stale token therefore re-enters *nothing*: [`AsyncToken::fire`] is the
255/// sole re-entry path, the epoch comparison is owned in one place, and the
256/// token is consumed (`self` by value) so it cannot fire twice. A consumer
257/// never writes an `if generation == ...` guard — it holds the token and
258/// calls `fire`; the no-op-when-stale is guaranteed by construction.
259pub struct AsyncToken {
260    /// Canonical record name to re-enter.
261    name: String,
262    /// Shared generation counter owned by the record
263    /// (`RecordInstance::reprocess_generation`).
264    generation: Arc<AtomicU64>,
265    /// Generation value captured at mint time. The token is current iff
266    /// `generation == epoch`.
267    epoch: u64,
268}
269
270impl AsyncToken {
271    /// The record this token re-enters.
272    pub fn record_name(&self) -> &str {
273        &self.name
274    }
275
276    /// True iff this token is still the current generation — no newer
277    /// token was minted and no [`PvDatabase::cancel_async_reentry`] has
278    /// run for the record since this token was minted. Read-only.
279    pub fn is_current(&self) -> bool {
280        self.generation.load(Ordering::Acquire) == self.epoch
281    }
282
283    /// Cancel this token (C `callbackCancelDelayed` for the holder's own
284    /// pending re-entry): advance the generation so this and any other
285    /// outstanding token for the record become stale, then consume the
286    /// token. Use when the holder itself decides not to re-enter; use
287    /// [`PvDatabase::cancel_async_reentry`] to cancel a token already
288    /// handed to a timer / notify task.
289    pub fn cancel(self) {
290        self.generation.fetch_add(1, Ordering::AcqRel);
291    }
292
293    /// Fire the continuation: if still current, re-enter the record's
294    /// `process()` via [`PvDatabase::process_record_continuation`]. A
295    /// stale (superseded / cancelled) token is a no-op. Consumes the
296    /// token so it cannot fire twice.
297    pub async fn fire(self, db: &PvDatabase) -> CaResult<()> {
298        if self.generation.load(Ordering::Acquire) != self.epoch {
299            return Ok(());
300        }
301        let mut visited = ProcStack::new();
302        db.process_record_continuation(&self.name, &mut visited)
303            .await
304    }
305}
306
307/// A cycle-free handle for driving async-side database updates from
308/// OUTSIDE a record's `process()` cycle.
309///
310/// Wraps a [`std::sync::Weak`] reference to the database: a record stashes
311/// it (via [`crate::server::record::Record::set_async_context`]) without
312/// creating an ownership cycle — the database owns the record, so a strong
313/// `Arc<PvDatabaseInner>` stored on the record would leak the whole
314/// database. Every call upgrades the `Weak` to a temporary [`PvDatabase`];
315/// once the last strong owner drops, the upgrade fails and the call is a
316/// no-op (nothing is stranded).
317///
318/// This is the out-of-band counterpart to the in-band re-entry
319/// [`crate::server::record::ProcessAction`]s: a driver / callback thread
320/// (asyn TRACE post, AQR cancel, motor intermediate readback) holds the
321/// handle and pushes field updates or wires a completion-driven re-entry
322/// without going through `process()`. It exposes exactly the c401e2f0
323/// PACT primitive surface, each call guarded by the live-database check.
324#[derive(Clone)]
325pub struct AsyncDbHandle {
326    inner: std::sync::Weak<super::PvDatabaseInner>,
327}
328
329impl AsyncDbHandle {
330    /// Upgrade to a temporary owning [`PvDatabase`], or `None` if the
331    /// database has been dropped.
332    fn db(&self) -> Option<PvDatabase> {
333        self.inner.upgrade().map(|inner| PvDatabase { inner })
334    }
335
336    /// True while the backing database is still alive.
337    pub fn is_alive(&self) -> bool {
338        self.inner.strong_count() > 0
339    }
340
341    /// Out-of-band field post — see [`PvDatabase::post_fields`]. Returns an
342    /// empty `Vec` (no-op) if the database has been dropped.
343    pub fn post_fields(
344        &self,
345        name: &str,
346        fields: Vec<(String, EpicsValue)>,
347    ) -> CaResult<Vec<String>> {
348        match self.db() {
349            Some(db) => db.post_fields(name, fields),
350            None => Ok(Vec::new()),
351        }
352    }
353
354    /// Out-of-band field post under the caller's own event mask — see
355    /// [`PvDatabase::post_fields_with_mask`]. Returns an empty `Vec` (no-op)
356    /// if the database has been dropped.
357    pub(crate) fn post_fields_with_mask(
358        &self,
359        name: &str,
360        fields: Vec<(String, EpicsValue)>,
361        mask: crate::server::recgbl::EventMask,
362    ) -> CaResult<Vec<String>> {
363        match self.db() {
364            Some(db) => db.post_fields_with_mask(name, fields, mask),
365            None => Ok(Vec::new()),
366        }
367    }
368
369    /// C `dbCaPutLinkCallback`'s return status, asked before the put is
370    /// issued: would a put-WITH-completion to `link` be admitted right now?
371    ///
372    /// The gate is `if (!pca->isConnected || !pca->hasWriteAccess) return -1;`
373    /// (`dbCa.c:529-532`), and `PvDatabase::external_put_admitted` is the same
374    /// owner [`Self::put_link_notify`]'s write path consults, so the two cannot
375    /// disagree. Non-blocking and does no I/O — it reads the link set's cached
376    /// connection state, which is why it can be asked from inside `process()`
377    /// while the put itself must be deferred.
378    ///
379    /// A link whose target is a LOCAL record is C's non-`CA_LINK` case, which
380    /// never reaches that gate (`dbPutLink`, no callback): `true`. So is a
381    /// database that has been dropped — nothing is left to refuse.
382    pub fn put_link_admitted(&self, link: &str) -> bool {
383        let Some(db) = self.db() else {
384            return true;
385        };
386        match crate::server::record::parse_output_link_v2(link) {
387            crate::server::record::ParsedLink::Db(target) => {
388                // C `dbInitLink` locality (`dbLink.c:118-130`): a record this
389                // IOC does not hold is a CA link, and the port routes its write
390                // through the same external path.
391                if db.has_name_no_resolve(&target.target().record) {
392                    return true;
393                }
394                db.external_put_admitted(&target.pvname()).is_ok()
395            }
396            other => match other.external_pv_name() {
397                Some(name) => db.external_put_admitted(&name).is_ok(),
398                // Constant / empty: C's switch makes no put at all, so there is
399                // no status to read.
400                None => true,
401            },
402        }
403    }
404
405    /// Resolve a link's target field type for the sseq link-status
406    /// diagnostics — see `PvDatabase::link_target_field_type`. `None` if
407    /// the link is constant / external / unresolvable, or the database is
408    /// gone. (Distinct from the free `server::record::link_field_type`,
409    /// which returns the link *class* `LinkType`, not the target's type.)
410    pub fn link_target_field_type(&self, link: &str) -> Option<crate::types::DbFieldType> {
411        match self.db() {
412            Some(db) => db.link_target_field_type(link),
413            None => None,
414        }
415    }
416
417    /// Schedule a record's link-status classification — see
418    /// `PvDatabase::schedule_record_init`. This is the ONE owner every
419    /// record's `refresh_link_status` goes through: during the LOAD phase the
420    /// classification is queued for `iocInit` (so it never reads a half-built
421    /// database, and its result is final when `iocInit` returns), and on a
422    /// complete database it is spawned at once. Dropped, unrun, if the database
423    /// is gone.
424    pub fn schedule_record_init(
425        &self,
426        record: &str,
427        init: impl std::future::Future<Output = ()> + Send + 'static,
428    ) {
429        if let Some(db) = self.db() {
430            db.schedule_record_init(record, init);
431        }
432    }
433
434    /// Read a link's value WITHOUT processing its source record — the C
435    /// `dbGetLink` semantics. Parses `link` and reads it via
436    /// `PvDatabase::read_link_value_no_process`; `None` if the link is
437    /// constant-less / external-unresolvable or the database has been
438    /// dropped. Used by module-crate records (e.g. std `throttle` SYNC →
439    /// `SINP`→`VAL`) that must pull an input link from `special()` without
440    /// triggering a process cycle.
441    pub async fn read_link_value(&self, link: &str) -> Option<EpicsValue> {
442        let db = self.db()?;
443        let parsed = crate::server::record::parse_link_v2(link);
444        db.read_link_value_no_process(&parsed)
445    }
446
447    /// Out-of-band `dbPutField` on any record field, common fields included —
448    /// see [`PvDatabase::put_pv`]. `Ok(())` (no-op) if the database has been
449    /// dropped.
450    ///
451    /// Unlike [`Self::post_fields`] (which writes through `put_field_internal`
452    /// and only posts), this is the full put path: a `SCAN` write moves the
453    /// record between scan buckets and fires the `get_ioint_info` hook. C
454    /// records call `dbPutField` on their own fields exactly this way — asynRecord's
455    /// `cancelIOInterruptScan` does `dbPutField(&scanAddr, DBR_LONG,
456    /// &passiveScan, 1)` on its own `.SCAN` (asynRecord.c:794-806).
457    pub async fn put_pv(&self, name: &str, value: EpicsValue) -> CaResult<()> {
458        match self.db() {
459            Some(db) => db.put_pv(name, value).await,
460            None => Ok(()),
461        }
462    }
463
464    /// Mint an async re-entry token — see [`PvDatabase::mint_async_token`].
465    /// `None` if the record is absent or the database has been dropped.
466    pub fn mint_async_token(&self, name: &str) -> Option<AsyncToken> {
467        match self.db() {
468            Some(db) => db.mint_async_token(name),
469            None => None,
470        }
471    }
472
473    /// Cancel an outstanding async re-entry — see
474    /// [`PvDatabase::cancel_async_reentry`]. No-op if the database is gone.
475    pub fn cancel_async_reentry(&self, name: &str) {
476        if let Some(db) = self.db() {
477            db.cancel_async_reentry(name);
478        }
479    }
480
481    /// Arm a put-notify wait-set — see [`PvDatabase::new_put_notify`].
482    /// Database-independent (re-exported associated fn).
483    pub fn new_put_notify() -> (
484        Arc<NotifyWaitSet>,
485        crate::runtime::sync::oneshot::Receiver<()>,
486    ) {
487        PvDatabase::new_put_notify()
488    }
489
490    /// Wire a completion oneshot to an async re-entry — see
491    /// [`PvDatabase::reprocess_on_notify`]. `None` if the database is gone
492    /// (the `completion` receiver is dropped, stranding nothing).
493    pub fn reprocess_on_notify(
494        &self,
495        token: AsyncToken,
496        completion: crate::runtime::sync::oneshot::Receiver<()>,
497    ) -> Option<crate::runtime::task::BackgroundTaskHandle<()>> {
498        self.db()
499            .map(|db| db.reprocess_on_notify(token, completion))
500    }
501
502    /// Issue a non-blocking put-with-completion to an OUT link — see
503    /// [`PvDatabase::put_link_notify`]. `None` if the database is gone or
504    /// the source record is missing.
505    pub async fn put_link_notify(
506        &self,
507        record_name: &str,
508        link_field: &str,
509        link_str: &str,
510        value: EpicsValue,
511    ) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
512        match self.db() {
513            Some(db) => {
514                db.put_link_notify(record_name, link_field, link_str, value)
515                    .await
516            }
517            None => None,
518        }
519    }
520}
521
522/// C `dbNotifyCompletion` (`dbNotify.c:445`) reached the way a process cycle
523/// reaches it — through `recGblFwdLink` (`recGbl.c:295`), record support's only
524/// route to it. Take this record's wait-set membership and leave; the
525/// completion oneshot fires on the `leave` that empties the set.
526///
527/// # Invariant (CONTRACT)
528///
529/// A cycle completes an outstanding put-notify IF AND ONLY IF it runs
530/// `recGblFwdLink`. Two things stop it, and **both are read here** so no cycle
531/// tail can consult one and forget the other:
532///
533/// - [`Record::is_put_complete`](crate::server::record::Record::is_put_complete)
534///   — device support took the write async
535///   (`if (!pact && prec->pact) return(0)`), so this pass never reaches the
536///   tail.
537/// - [`Record::should_fire_forward_link`](crate::server::record::Record::should_fire_forward_link)
538///   — the tail was reached and the record type declined it.
539///   `dbNotifyCompletion` sits INSIDE the skipped call, so a suppressed
540///   forward link withholds the `ca_put_callback` too.
541///   `busy` is the clearest case: `busyRecord.c:271` runs the tail only for
542///   `val == 0 || oval == 0`, which is why `caput -c` on a busy record that
543///   stays at 1 is meant to hang until something writes "Done".
544///
545/// Reading them together HERE, rather than at each cycle tail, is what keeps a
546/// record type's C gate to one override: a type states its gate in
547/// `should_fire_forward_link` and gets the notify behaviour for free.
548///
549/// The SDIS-disable bail is C's OTHER `dbNotifyCompletion` caller
550/// (`dbAccess.c:623`), outside `recGblFwdLink` and so deliberately ungated —
551/// it open-codes the take/leave in `process_record_with_links_inner` and does
552/// not come through here.
553///
554/// Idempotent: a record in no put-notify is a no-op.
555/// Cycle-end bookkeeping under the record's data lock.
556///
557/// C `recGblFwdLink:302` clears `putf = FALSE` at the tail of every
558/// synchronous cycle, NOT just the foreign-entry path: a record driven
559/// through an OUT-link propagation (`write_db_link_value` set its putf)
560/// must clear it before returning. Async-pending records skip the clear —
561/// their FLNK / putf-clear happen later, in `complete_async_record_inner`,
562/// once the device round-trip completes.
563///
564/// The record `leave`s the wait-set only here, after its full
565/// OUT/FLNK/process-action tail has run — so every PP target it drove has
566/// already joined (`enter`ed). Whether this cycle may leave at all is
567/// `complete_put_notify`'s decision, not this site's: a record reporting
568/// more work (motor mid-move) or declining its forward link (busy at
569/// VAL=1) keeps its membership and leaves on the later cycle that reaches
570/// C's `recGblFwdLink`.
571fn finish_cycle(inst: &mut RecordInstance) {
572    if !inst.is_processing() {
573        inst.common.putf = false;
574    }
575    complete_put_notify(inst);
576}
577
578fn complete_put_notify(inst: &mut RecordInstance) {
579    // No wait-set, nothing to leave: the usual cycle answers here, ahead of
580    // the two record queries below, which are pure reads either way.
581    if inst.notify.is_none() {
582        return;
583    }
584    if !inst.record.is_put_complete() || !inst.record.should_fire_forward_link() {
585        return;
586    }
587    if let Some(ws) = inst.notify.take() {
588        ws.leave();
589    }
590}
591
592/// Result of an aSub LFLG=READ subroutine re-resolution
593/// (C `aSubRecord.c::fetch_values`). Computed outside the record's process
594/// lock (the SUBL link read may touch another record) and applied inside it.
595struct AsubDynamicSub {
596    /// SNAM read from the SUBL link this cycle — written back to the record
597    /// (C `dbGetLink` writes SNAM every READ cycle). `None` only when the
598    /// link read failed (C `if (status) return status`), leaving SNAM as-is.
599    snam: Option<String>,
600    /// `Some` → swap the live subroutine and set ONAM to `snam` (the name
601    /// changed and was found in the registry).
602    swap: Option<Arc<crate::server::record::SubroutineFn>>,
603    /// `true` → do not run the subroutine this cycle, matching C skipping
604    /// `do_sub`: the link read failed, or the changed name was not registered
605    /// (`S_db_BadSub`).
606    skip_run: bool,
607}
608
609/// Apply an aSub LFLG=READ resolution (from
610/// [`PvDatabase::resolve_asub_dynamic_subroutine`]) to a locked record: write
611/// the read-back SNAM, swap the subroutine + set ONAM when the name changed,
612/// and arm the one-shot suppress flag when the name was bad. The single apply
613/// owner, shared by the engine path ([`PvDatabase::process_record_with_links_inner`])
614/// and the foreign path ([`PvDatabase::process_record`]); the skip is consumed
615/// uniformly by `RecordInstance::run_registered_subroutine`.
616fn apply_asub_dynamic_sub(instance: &mut RecordInstance, ds: &AsubDynamicSub) {
617    if let Some(snam) = &ds.snam {
618        let _ = instance
619            .record
620            .put_field("SNAM", EpicsValue::String(snam.as_str().into()));
621    }
622    if let Some(func) = &ds.swap {
623        instance.subroutine = Some(func.clone());
624        if let Some(snam) = &ds.snam {
625            let _ = instance
626                .record
627                .put_field("ONAM", EpicsValue::String(snam.as_str().into()));
628        }
629    }
630    // One-shot, armed by any reason and cleared only by its owner
631    // (`run_registered_subroutine`): OR-ed in, so a failed `fetch_values`
632    // that armed it before this hook still skips the run.
633    instance.suppress_subroutine_run |= ds.skip_run;
634}
635
636/// If a CA TSEL link's pvname targets a record's `.TIME` field, return
637/// the record name with the `.TIME` suffix stripped; otherwise `None`.
638///
639/// Mirrors C `TSEL_modified` (dbLink.c:80-86): a `PV_LINK` tsel whose
640/// pvname contains `.TIME` is flagged `DBLINK_FLAG_TSELisTIME` and the
641/// name is truncated at `.TIME` to address the record. Matched on the
642/// `.TIME` suffix (the realistic spelling) case-insensitively, to stay
643/// consistent with the DB branch's `field.eq_ignore_ascii_case("TIME")`.
644fn ca_tsel_time_record(pv: &str) -> Option<&str> {
645    let idx = pv.len().checked_sub(".TIME".len())?;
646    pv[idx..]
647        .eq_ignore_ascii_case(".TIME")
648        .then_some(&pv[..idx])
649}
650
651/// Convert an lset `(seconds_past_epoch, nanos, userTag)` timestamp
652/// triple into the record-side `(SystemTime, userTag)` pair, clamping
653/// seconds/nanos to the valid `Duration` range. Shared by the TSEL
654/// `.TIME` Ca arm and the non-local Db arm — both read a `ca://` `.TIME`
655/// source through `external_link_time` and adopt the result identically.
656fn ext_time_pair((secs, ns, utag): (i64, i32, u64)) -> (std::time::SystemTime, u64) {
657    let secs = secs.max(0) as u64;
658    let ns = (ns.max(0) as u32).min(999_999_999);
659    (
660        std::time::UNIX_EPOCH + std::time::Duration::new(secs, ns),
661        utag,
662    )
663}
664
665/// The alarm-field events `recGblResetAlarms` posts (recGbl.c:202-222), each
666/// with its own per-field mask:
667///
668/// * `SEVR` — `DBE_VALUE`, ONLY when `prev_sevr != new_sevr`.
669/// * `STAT`/`AMSG` — `stat_mask` = `DBE_ALARM` (on sevr- or amsg-change) |
670///   `DBE_VALUE` (on stat-change).
671/// * `ACKS` — `DBE_VALUE`, only when `stat_mask != 0` and `recGblResetAlarms`
672///   raised it.
673///
674/// NOT the single owner of these masks, despite an earlier comment here that
675/// claimed so. Two of the five `recGblResetAlarms` post sites call this helper
676/// — the synchronous process epilogue (`process_record_with_links_inner`) and
677/// the `CompleteAlarmOnly` cycle that skips that epilogue (transform
678/// IVLA="Do Nothing"). The other three still open-code the identical mask
679/// arithmetic and can therefore drift from it:
680///
681/// * `complete_async_record_inner` — the async-completion epilogue;
682/// * `sim_process_tail` — the SIMM-mode input tail;
683/// * `RecordInstance::process_local` — the foreign-process / QSRV-group path.
684///
685/// (The SDIS-disable post in `process_record_with_links_inner` and the
686/// fanout/seq SELN post in `links::apply_selm_alarm` are NOT clients: they
687/// carry C's `dbAccess.c:586-593` and `fanoutRecord.c:116` masks, not
688/// `recGblResetAlarms`'.)
689/// The publication half of a process cycle: fan the snapshot out to
690/// subscribers, post the alarm fields under their individual C masks, and
691/// report which classes the cycle emitted.
692///
693/// Takes the guard the segment already holds. C publishes from inside
694/// `monitor()`, which runs under the same `dbScanLock` that built the values
695/// being published; the port used to drop its guard at the segment boundary
696/// and immediately re-acquire it for this, paying a second acquisition per
697/// cycle for a window in which it did nothing.
698fn publish_cycle(
699    instance: &mut RecordInstance,
700    snapshot: &crate::server::record::ProcessSnapshot,
701    backing: crate::server::database::LinkBacking<'_>,
702    alarm_posts: AlarmPosts,
703) -> CyclePosts {
704    // A value-class post advances the record's already-published state
705    // (`RecordInstance::record_value_post`), so this is a `&mut` operation.
706    instance.notify_from_snapshot(snapshot, backing);
707    let mut posts = CyclePosts::of(snapshot);
708    alarm_posts.for_each(|field, mask| {
709        instance.notify_field(field, mask);
710        posts = posts.with(mask);
711    });
712    posts
713}
714
715pub(crate) fn alarm_field_posts(
716    common: &crate::server::record::CommonFields,
717    alarm_result: &crate::server::recgbl::AlarmResetResult,
718) -> AlarmPosts {
719    use crate::server::recgbl::EventMask;
720
721    let sevr_changed = common.sevr != alarm_result.prev_sevr;
722    let stat_changed = common.stat != alarm_result.prev_stat;
723    let stat_mask = {
724        let mut m = EventMask::NONE;
725        if sevr_changed || alarm_result.amsg_changed {
726            m |= EventMask::ALARM;
727        }
728        if stat_changed {
729            m |= EventMask::VALUE;
730        }
731        m
732    };
733    AlarmPosts {
734        sevr: sevr_changed,
735        stat_mask,
736        acks: alarm_result.acks_posted,
737    }
738}
739
740/// The alarm-field posts of one `recGblResetAlarms`, as the three facts that
741/// decide them (see [`alarm_field_posts`]). The posts are a fixed rule over
742/// these facts, so this carries the facts and replays the rule on demand —
743/// a list held them before, and the cycle that posts nothing, which is most
744/// of them, built and dropped it every time.
745#[derive(Clone, Copy, Debug)]
746pub struct AlarmPosts {
747    sevr: bool,
748    stat_mask: crate::server::recgbl::EventMask,
749    acks: bool,
750}
751
752impl AlarmPosts {
753    /// Each post in the order `recGblResetAlarms` makes them — `SEVR`, `STAT`,
754    /// `AMSG`, `ACKS` — with its own C mask.
755    pub fn for_each(&self, mut f: impl FnMut(&'static str, crate::server::recgbl::EventMask)) {
756        use crate::server::recgbl::EventMask;
757        if self.sevr {
758            f("SEVR", EventMask::VALUE);
759        }
760        if !self.stat_mask.is_empty() {
761            f("STAT", self.stat_mask);
762            f("AMSG", self.stat_mask);
763        }
764        if self.acks {
765            f("ACKS", EventMask::VALUE);
766        }
767    }
768
769    /// The posts as a list, for the callers that hold them.
770    pub fn to_vec(self) -> Vec<(&'static str, crate::server::recgbl::EventMask)> {
771        let mut out = Vec::new();
772        self.for_each(|field, mask| out.push((field, mask)));
773        out
774    }
775}
776
777/// What one process cycle hands to its forward-link tail.
778///
779/// The CP/CPP dispatch at the tail needs what the cycle PUBLISHED (see
780/// [`CyclePosts`]); the FLNK's own PUTF and put-notify wait-set ride inside
781/// the [`ForwardTarget`](crate::server::record::record_instance::ForwardTarget)
782/// the tail is handed, so only the target that needs them carries them.
783#[derive(Clone, Copy)]
784struct TailCtx<'a> {
785    posts: CyclePosts,
786    /// The cycle's `ProcessPlan`, so the tail's two type-static
787    /// dispatchers can be skipped without re-taking the record's lock to ask
788    /// what type it is.
789    plan: &'a crate::server::record::record_instance::ProcessPlan,
790}
791
792/// What one process cycle published to monitors: the union of every `DBE_*`
793/// class it posted, across the value snapshot and the `recGblResetAlarms`
794/// fields.
795///
796/// This exists so the CP/CPP trigger reads a *post*, never a *process*. C
797/// serves every CP/CPP link — local target or not — through a CA
798/// subscription taken with `DBE_VALUE | DBE_ALARM` (`dbCa.c:1225-1229` →
799/// `cadef.h:2010-2011`), and only its `eventCallback` adds `CA_DBPROCESS`
800/// (`dbCa.c:955-963`, run at `:1249-1257`). A cycle that posts nothing —
801/// an unchanged value inside `MDEL`, no alarm movement — therefore leaves
802/// the holder unprocessed. Passing this value into the forward-link tail is
803/// what makes "dispatch a CP edge without a post" unrepresentable at the
804/// call site: there is no argument-less way to reach
805/// [`PvDatabase::dispatch_cp_targets`].
806#[derive(Clone, Copy)]
807struct CyclePosts(crate::server::recgbl::EventMask);
808
809impl CyclePosts {
810    /// The classes a value snapshot published.
811    fn of(snapshot: &crate::server::record::ProcessSnapshot) -> Self {
812        Self(snapshot.published_mask())
813    }
814
815    /// Fold in one more posted field (the `recGblResetAlarms` posts, which
816    /// are emitted outside the snapshot).
817    fn with(self, mask: crate::server::recgbl::EventMask) -> Self {
818        Self(self.0 | mask)
819    }
820
821    /// True when this cycle published a class a CP/CPP subscription selects.
822    fn triggers_cp(self) -> bool {
823        use crate::server::recgbl::EventMask;
824        self.0.intersects(EventMask::VALUE | EventMask::ALARM)
825    }
826}
827
828/// Result of the simulation-mode check.
829///
830/// C handles simulation entirely inside `readValue()` / `writeValue()` —
831/// the device-I/O step — and `process()` ALWAYS runs the rest of the body
832/// (`convert`/OROC/the record's own state machine) plus
833/// `checkAlarms`/`monitor`/`recGblFwdLink(prec)`. SIMM replaces ONLY the
834/// device read/write with the SIOL link, never the record-support body.
835/// The two substitution points differ by direction: an INPUT record's
836/// `readValue()` runs at the START of `process()` (before the body), so
837/// [`SimOutcome::Simulated`] does the SIOL read here and short-circuits;
838/// an OUTPUT record's `writeValue()` runs at the END (after the body has
839/// computed OVAL / armed bo HIGH), so [`SimOutcome::RedirectOutputToSiol`]
840/// lets the uniform flow run the body and redirects only the final write.
841enum SimOutcome {
842    /// SIMM disabled / no simulation link configured: run the record
843    /// body normally.
844    NotSimulated,
845    /// Simulated INPUT record: the SIOL read + convert already ran here
846    /// (`readValue` precedes the body). The caller must still run the
847    /// forward-link / CP / RPRO tail exactly as `recGblFwdLink` does for a
848    /// real process cycle, but skips the (already-substituted) body.
849    ///
850    /// Carries the cycle's [`CyclePosts`] because `sim_process_tail` already
851    /// published this cycle's monitors here; only this arm has a post set to
852    /// report, which is why it is on the variant rather than on the tuple.
853    Simulated(CyclePosts),
854    /// Simulated record whose simulation replaces only the INPUT STAGE of its
855    /// body ([`Record::simulation_substitutes_input_stage`](crate::server::record::Record::simulation_substitutes_input_stage)) — swait. The SIOL
856    /// read, the `VAL = SVAL` / `UDF = FALSE` write and the SIMM_ALARM raise
857    /// have already happened here (C `swaitRecord.c:415-422`, which precedes the
858    /// OOPT switch); the caller runs the record body with its input-link fetch
859    /// suppressed, then the ordinary alarm/monitor/forward-link tail — none of
860    /// which C's simulation branch skips.
861    SimulatedInputStage,
862    /// The `default:` arm of C's `switch (prec->simm)` — a SIMM value outside
863    /// the record's own menu (`SimMode::Illegal`):
864    ///
865    /// ```c
866    /// default:
867    ///     recGblSetSevr(prec, SOFT_ALARM, INVALID_ALARM);
868    ///     status = -1;
869    /// ```
870    ///
871    /// SOFT_ALARM/INVALID is already raised into the record's PENDING alarm by
872    /// `check_simulation_mode`. What is left is what C's `readValue`/
873    /// `writeValue` does NOT do on this arm: no device read, no device write, no
874    /// SIOL round-trip, no SIMM_ALARM, no VAL/UDF change. The `-1` it returns is
875    /// not a control-flow abort — the record's `process()` ignores it and still
876    /// runs `checkAlarms`, `monitor` and `recGblFwdLink` — so the cycle's tail
877    /// runs either way. The two record shapes differ only in where the
878    /// suppressed I/O sat: an INPUT's `readValue` precedes the body (nothing of
879    /// the body is left to run), an OUTPUT's `writeValue` follows it (the body
880    /// runs, only the write is suppressed).
881    IllegalMode { is_output: bool },
882    /// The SIML read FAILED and the record's support ABORTS on it — C
883    /// `writeValue` returns before performing any I/O
884    /// ([`Record::aborts_on_failed_siml_read`](crate::server::record::Record::aborts_on_failed_siml_read); `busy` is the only one):
885    ///
886    /// ```c
887    /// status=dbGetLink(&prec->siml,DBR_USHORT, &prec->simm,0,0);
888    /// if (status)
889    ///     return(status);      /* before write_busy AND before the SIOL dbPutLink */
890    /// ```
891    ///
892    /// Like [`Self::IllegalMode`] with `is_output`, this suppresses the cycle's
893    /// output and nothing else: the body runs and `process()` still does
894    /// `checkAlarms` / `monitor` / `recGblFwdLink`. It differs in the alarm — the
895    /// LINK_ALARM that `dbGetLink`'s `setLinkAlarm` already raised is the only
896    /// one; no SOFT_ALARM and no SIMM_ALARM is added, because C never reaches the
897    /// `switch (prec->simm)` that would raise them.
898    AbortedBeforeWrite,
899    /// Simulated OUTPUT record (`SIMM`=YES/RAW, not deferring). C
900    /// `writeValue` substitutes the device write with
901    /// `dbPutLink(&prec->siol, ..., &prec->oval)` — but at the END of
902    /// `process()`, AFTER the body (OROC, bo HIGH momentary reset, OVAL).
903    /// Unlike the input read, the output write cannot be done up-front, so
904    /// the caller runs the uniform record body and redirects only the final
905    /// output write to SIOL. Carries the SIOL link, the SIMS severity, and
906    /// the RAW-mode flag (write RVAL vs OVAL).
907    RedirectOutputToSiol {
908        siol: crate::server::record::ParsedLink,
909        sims: i16,
910        raw_mode: bool,
911    },
912    /// Asynchronous simulation: `SIMM`=YES/RAW with `SDLY` >= 0 on the
913    /// fresh (non-continuation) cycle. C `aiRecord.c::readValue` (488-508)
914    /// / `aoRecord.c::writeValue` (571-587) `callbackRequestProcessCallbackDelayed`:
915    /// hold PACT, schedule a re-process `SDLY` seconds out, and post nothing
916    /// this cycle (C `process()` returns 0 on the async-start pass). The
917    /// SIOL round-trip + alarm/monitor tail run on the continuation, which
918    /// re-enters with `is_continuation = true` and takes the synchronous
919    /// branch. The wrapped [`Duration`](std::time::Duration) is the `SDLY` delay.
920    DeferRead(std::time::Duration),
921}
922
923/// Which link fields of a [`Record::multi_input_links`](crate::server::record::Record::multi_input_links) list are SET, read
924/// once at the top of a process cycle and shared by both stages that want
925/// them.
926///
927/// A mask, and nothing else. A `calc` declares twenty-one input links and a
928/// stock database wires none of them, so any per-link entry — a text, a
929/// parse, a set-link record — was a heap allocation per record per pass. The
930/// parse and target of a set link live in the record's own cache
931/// (`RecordInstance::parsed_inputs`) and are read from there, under the
932/// record's guard, by the fetch that uses them.
933pub struct InputLinkTexts {
934    /// The list the mask indexes — a record's
935    /// [`Record::multi_input_links`](crate::server::record::Record::multi_input_links), or the subset it selected for this pass.
936    /// Carried WITH the mask, and the only list a reader is offered, so no
937    /// reader can pair one list's slots with another list's bits.
938    links: &'static [(&'static str, &'static str)],
939    /// The record's own [`Record::multi_input_links`](crate::server::record::Record::multi_input_links) — the list the parse
940    /// cache is indexed by, and what [`Self::links`] is unless the record
941    /// narrowed it for this pass. Asked of the record once, here: the fetch
942    /// loop and the resolved-links report take it from this value.
943    own: &'static [(&'static str, &'static str)],
944    /// Bit `slot` set when `links[slot]` holds a link text. Every declared
945    /// list fits: the widest, `aSub`'s, has twenty-one entries.
946    wired: u64,
947    /// Whether the links were read at all. For a reader, a clear bit then
948    /// means "unset"; without the flag it would also mean "never asked",
949    /// which is the put paths below, and they must go to the record instead.
950    read: bool,
951}
952
953impl InputLinkTexts {
954    /// A caller that read nothing. The put paths and the async-completion path
955    /// resolve link-backed metadata without running a multi-input fetch, so
956    /// they have nothing to hand over.
957    pub fn none() -> Self {
958        Self {
959            links: &[],
960            own: &[],
961            wired: 0,
962            read: false,
963        }
964    }
965
966    /// The record's own set links, as `instance` holds them now.
967    pub(crate) fn read_own(instance: &RecordInstance) -> Self {
968        let own = instance.record.multi_input_links();
969        Self::read_from(instance, own, own)
970    }
971
972    /// The set links of `links` — [`Self::own`] narrowed to the subset the
973    /// record selected for this pass — as `instance` holds them now.
974    pub(crate) fn read_narrowed(
975        &self,
976        instance: &RecordInstance,
977        links: &'static [(&'static str, &'static str)],
978    ) -> Self {
979        Self::read_from(instance, self.own, links)
980    }
981
982    fn read_from(
983        instance: &RecordInstance,
984        own: &'static [(&'static str, &'static str)],
985        links: &'static [(&'static str, &'static str)],
986    ) -> Self {
987        debug_assert!(links.len() <= u64::BITS as usize);
988        // Which links are wired is asked of the record as ONE question —
989        // `Record::set_input_link_slots`, whose default body is codegen'd per
990        // record type and so reads the type's own fields inline. Walking the
991        // list here instead put one vtable call per declared link in the
992        // cycle: 21 for a `calc`, to learn that a stock database wires none
993        // of them.
994        //
995        // Only for the record's OWN list. A caller that narrowed it — the
996        // `sel` selected-input pass — is asking about a different list than
997        // the record answered for, so it falls through to the walk.
998        let masks = std::ptr::eq(links, own)
999            .then(|| instance.record.set_input_link_slots())
1000            .flatten();
1001        let wired = match masks {
1002            Some((set, mut unknown)) => {
1003                let mut wired = set;
1004                while unknown != 0 {
1005                    let slot = unknown.trailing_zeros() as usize;
1006                    unknown &= unknown - 1;
1007                    if instance.link_is_set(links[slot].0) {
1008                        wired |= 1 << slot;
1009                    }
1010                }
1011                wired
1012            }
1013            None => links
1014                .iter()
1015                .enumerate()
1016                .filter(|(_, (link_field, _))| instance.link_is_set(link_field))
1017                .fold(0, |wired, (slot, _)| wired | 1 << slot),
1018        };
1019        Self {
1020            links,
1021            own,
1022            wired,
1023            read: true,
1024        }
1025    }
1026
1027    /// Whether this pass read the links and found none of them set — the
1028    /// answer for a stock database, where a `calc`'s 21 declared inputs are
1029    /// all unwired. A reader that needs a set link for every one of its own
1030    /// entries is finished before it starts.
1031    pub(crate) fn none_set(&self) -> bool {
1032        self.read && self.wired == 0
1033    }
1034
1035    /// The `(link_field, value_field)` pairs these texts were read from — the
1036    /// list to walk when fetching them.
1037    pub(crate) fn links(&self) -> &'static [(&'static str, &'static str)] {
1038        self.links
1039    }
1040
1041    /// The record's own list — what the parse cache and the resolved-links
1042    /// report are indexed by, whether or not [`Self::links`] was narrowed.
1043    pub(crate) fn own(&self) -> &'static [(&'static str, &'static str)] {
1044        self.own
1045    }
1046
1047    /// The set slots of [`Self::links`], as a mask — what a fetch walks, as
1048    /// C's `fetch_values` loop is over the record's links but its work is
1049    /// only on the set ones (`dbGetLink` on a constant link is a no-op
1050    /// success).
1051    pub(crate) fn wired(&self) -> u64 {
1052        self.wired
1053    }
1054
1055    /// Whether `slot` of [`Self::links`] holds a link.
1056    pub(crate) fn is_set(&self, slot: usize) -> bool {
1057        1u64.checked_shl(slot as u32)
1058            .is_some_and(|bit| self.wired & bit != 0)
1059    }
1060
1061    /// The link at `slot` of the multi-input list: what was pre-read if the
1062    /// cycle pre-read it, and otherwise what the record says now. `None` is
1063    /// an unset link on both paths — the one meaning this answers. The slot
1064    /// comes from [`RecordInstance::link_backed_metadata_input_slots`], fixed
1065    /// by the record type, so no caller searches this list by name.
1066    pub(crate) fn link_at(
1067        &self,
1068        slot: Option<usize>,
1069        instance: &RecordInstance,
1070        field: &str,
1071    ) -> Option<Arc<crate::server::record::ParsedLink>> {
1072        match slot.filter(|_| self.read) {
1073            Some(slot) => {
1074                debug_assert_eq!(
1075                    self.links.get(slot).map(|(lf, _)| *lf),
1076                    Some(field),
1077                    "a metadata slot must name the link it was taken for"
1078                );
1079                if !self.is_set(slot) {
1080                    return None;
1081                }
1082                instance.cached_multi_input(slot, field)
1083            }
1084            None => instance
1085                .link_text(field)
1086                .map(|text| Arc::new(crate::server::record::parse_link_v2(&text))),
1087        }
1088    }
1089}
1090
1091/// One set link of the multi-input fetch, as the loop hands it to
1092/// [`PvDatabase::land_multi_input`].
1093struct MultiInputLink<'a> {
1094    link_field: &'static str,
1095    val_field: &'static str,
1096    parsed: &'a crate::server::record::ParsedLink,
1097    /// [`Record::input_link_request`](crate::server::record::Record::input_link_request) for the link — C's `dbrType` argument
1098    /// to `dbGetLink`.
1099    request: crate::server::record::InputLinkRequest,
1100    /// [`Record::input_link_failure_is_inert`](crate::server::record::Record::input_link_failure_is_inert) for the link.
1101    failure_is_inert: bool,
1102}
1103
1104/// What one link's read came to, for the fetch policy after it.
1105struct LinkOutcome {
1106    read_failed: bool,
1107    /// Failed, and the type declared that failure inert.
1108    skipped: bool,
1109}
1110
1111impl PvDatabase {
1112    /// The tail of one `dbGetLink` into the reader: the read converted to
1113    /// the record's request, the LINK alarm on failure, the value stored,
1114    /// the source's severity inherited — in that order, C's, and in one frame
1115    /// so the value is moved once.
1116    #[inline]
1117    fn land_multi_input(
1118        &self,
1119        record: &mut dyn crate::server::record::Record,
1120        common: &mut crate::server::record::CommonFields,
1121        reader: &Arc<RecordCell>,
1122        link: &MultiInputLink<'_>,
1123        plan: &crate::server::record::record_instance::ProcessPlan,
1124        (mut fetch, alarm): (
1125            crate::server::recgbl::simm::LinkFetch,
1126            Option<super::links::SourceAlarm>,
1127        ),
1128    ) -> LinkOutcome {
1129        use crate::server::recgbl::simm::LinkFetch;
1130        let store_raw = self.convert_link_fetch_as(
1131            record,
1132            link.link_field,
1133            link.parsed,
1134            link.request,
1135            &mut fetch,
1136        );
1137        let read_failed = !fetch.is_ok();
1138        // C `dbGetLink` on failure: `recGblSetSevrMsg(LINK_ALARM)` — for the
1139        // types whose fetch IS `dbGetLink`, and not for a link whose failure
1140        // the type declares inert.
1141        if read_failed && plan.multi_input_is_db_get_link && !link.failure_is_inert {
1142            crate::server::recgbl::rec_gbl_set_link_alarm(common, link.link_field);
1143        }
1144        let value = match fetch {
1145            LinkFetch::Value(v) => Some(v),
1146            LinkFetch::NoData if plan.constants_deliver_at_process => {
1147                crate::server::recgbl::simm::constant_load_value(link.parsed)
1148            }
1149            _ => None,
1150        };
1151        if let Some(value) = value {
1152            deliver_multi_input(record, link.val_field, value, store_raw);
1153        }
1154        // MS/NMS propagation from the source record, C `recGblInheritSevrMsg`
1155        // inside a successful `dbGetLink`.
1156        if let Some(alarm) = alarm {
1157            self.fold_input_link_alarm(common, reader, link.parsed, alarm);
1158        }
1159        LinkOutcome {
1160            read_failed,
1161            skipped: read_failed && link.failure_is_inert,
1162        }
1163    }
1164
1165    /// C `dbGetLink` on a DB link to a held local record, read at the
1166    /// reader's own type with no filter chain — the whole of `dbDbGetValue`'s
1167    /// scalar arm (`dbDbLink.c:220-232`) in one frame: the target's field
1168    /// read under its lock, the reader's `LINK_ALARM` on failure, the value
1169    /// stored, the target's committed severity inherited. Returns whether
1170    /// the read failed.
1171    ///
1172    /// The general path is [`Self::read_db_link_at`] into
1173    /// [`Self::land_multi_input`], which answers the same questions for every
1174    /// caller and so packs the value and alarm into a fetch and a source
1175    /// alarm on the way. This frame asks nothing the general one does not;
1176    /// it only keeps the value where it is consumed. Which is why its
1177    /// callers are gated on `ProcessPlan::multi_inputs_read_native`: the
1178    /// conversion step it omits is a no-op for a native request, and the
1179    /// inert-failure test it omits is settled `false` by the same plan bit.
1180    /// Self-reads are excluded by the caller as C excludes them from
1181    /// `recGblInheritSevrMsg` (`precord != dbChannelRecord(chan)`), so the
1182    /// inheritance needs no record test here.
1183    #[inline(always)]
1184    fn fetch_native_db_input(
1185        &self,
1186        record: &mut dyn crate::server::record::Record,
1187        common: &mut crate::server::record::CommonFields,
1188        held: &crate::server::database::SetGuard,
1189        (db, at, native): (
1190            &crate::server::record::DbLink,
1191            &crate::server::record::record_instance::ResolvedTarget,
1192            crate::server::record::record_instance::NativeRead,
1193        ),
1194        (declared, cache_slot): (&'static [(&'static str, &'static str)], usize),
1195        sets_link_alarm: bool,
1196    ) -> bool {
1197        use super::links::{LinkAlarm, inherit_sevr_msg, local_name};
1198        let target = db.target();
1199        let field: &str = &target.field;
1200        // The simple PV that shadows the field's spelling, asked as
1201        // `read_field_of` asks it — and never for `VAL`, whose spelling is
1202        // the record's own name.
1203        if native.shadowed {
1204            let pv_name = local_name(&target.record, field);
1205            if let Some(pv) = self.inner.simple_pvs.lock().get(&pv_name).cloned() {
1206                deliver_multi_input(record, declared[cache_slot].1, pv.get(), false);
1207                return false;
1208            }
1209        }
1210        let inherits = native.inherits;
1211        let instance = at.rec.read_in(held);
1212        // A field the target hands out a slot for is read as the `f64` the
1213        // numeric funnel would make of it; any other goes by name and
1214        // through the funnel.
1215        let value = match at.field.slot {
1216            Some(slot) => instance.record.get_slot_f64(slot).map(Ok),
1217            None => self
1218                .read_field_at(&instance, field, at.field)
1219                .map(|value| value.into_double()),
1220        };
1221        let alarm = inherits.then(|| LinkAlarm::committed(&instance.common));
1222        drop(instance);
1223        let Some(value) = value else {
1224            if sets_link_alarm {
1225                crate::server::recgbl::rec_gbl_set_link_alarm(common, declared[cache_slot].0);
1226            }
1227            return true;
1228        };
1229        let stored = match value {
1230            Ok(f) => Some(f),
1231            Err(value) => deliver_converted(record, declared[cache_slot].1, value),
1232        };
1233        if let Some(f) = stored
1234            && !native
1235                .val_slot
1236                .is_some_and(|slot| record.put_slot_f64(slot, f))
1237        {
1238            let _ = record.put_multi_input_f64(declared[cache_slot].1, f);
1239        }
1240        if let Some(alarm) = alarm {
1241            inherit_sevr_msg(common, db.monitor_switch, &alarm);
1242        }
1243        false
1244    }
1245}
1246
1247/// What one link of the multi-input fetch came to, before the reader's
1248/// fields were touched: unset, read under the reader's own hold, or not a
1249/// read that hold can make.
1250enum HeldFetch {
1251    /// The link is unset — C `dbConstGetValue` with nothing to deliver.
1252    Unset,
1253    Done(LinkOutcome),
1254    /// A read the general path owns: a target in no local record, the
1255    /// reader's own field, a `PP` source, a filtered target.
1256    NotHeld,
1257}
1258
1259/// The fetch loop's fold of its links' outcomes — C `fetch_values`'
1260/// `status` under the record's [`InputFetchPolicy`], and the mask of links
1261/// that delivered (`RTN_SUCCESS(dbGetLink)` per link). One fold for both
1262/// shapes of the loop, so a policy is applied in one place.
1263#[derive(Default)]
1264struct FetchFold {
1265    resolved: u64,
1266    /// This cycle's `fetch_values()` outcome — non-zero status in C, i.e.
1267    /// "the record body must not run" — under every policy but the sel one.
1268    fetch_values_failed: bool,
1269    /// C `fetch_values`' `status` local, for the record types that return
1270    /// it rather than an early/first-failure fold: assigned on EVERY pass of
1271    /// the loop, so at `return(status)` it holds the LAST link's status and
1272    /// an empty/constant link counts as a success.
1273    last_input_read_failed: bool,
1274}
1275
1276impl FetchFold {
1277    /// Note one link's outcome; `true` when the policy ends the loop here.
1278    fn note(
1279        &mut self,
1280        policy: InputFetchPolicy,
1281        cache_slot: usize,
1282        is_last: bool,
1283        LinkOutcome {
1284            read_failed,
1285            skipped,
1286        }: LinkOutcome,
1287    ) -> bool {
1288        self.last_input_read_failed = read_failed && !skipped && is_last;
1289        if !read_failed && let Some(bit) = 1u64.checked_shl(cache_slot as u32) {
1290            self.resolved |= bit;
1291        }
1292        if read_failed && !skipped {
1293            match policy {
1294                InputFetchPolicy::ReadAll => {}
1295                InputFetchPolicy::ReadAllGateOnFailure => {
1296                    self.fetch_values_failed = true;
1297                }
1298                InputFetchPolicy::AbortOnFirstFailure => {
1299                    self.fetch_values_failed = true;
1300                    self.last_input_read_failed = true;
1301                    return true;
1302                }
1303                // `sel`: the LAST link's status decides, and the loop
1304                // carries that decision to the gate after the loop.
1305                InputFetchPolicy::ReadAllGateOnLastFailure => {}
1306            }
1307        }
1308        false
1309    }
1310
1311    /// C `fetch_values`' return, folded with the sel gate into the ONE
1312    /// boolean delivered to `Record::set_fetch_gate_failed` (calc/calcout/
1313    /// scalcout/acalcout/swait/sel) and `suppress_subroutine_run` (sub/aSub).
1314    ///
1315    /// C `selRecord.c::fetch_values` returns the status of its LAST
1316    /// `dbGetLink` (`:434-437` assigns `status` unguarded every pass), and
1317    /// `process` (`:114-116`) gates `do_sel` on it in EVERY mode. The gate is
1318    /// "the last link read FAILED" — never "a link delivered no value":
1319    /// `dbGetLink` on an unset OR constant link returns success
1320    /// (`dbConstGetValue`), and the field it would have written keeps its
1321    /// init-seeded value, which flows into `do_sel`.
1322    fn failed(&self, policy: InputFetchPolicy, sel_nvl_read_failed: bool) -> bool {
1323        let failed = if matches!(policy, InputFetchPolicy::ReadAllGateOnLastFailure) {
1324            self.last_input_read_failed
1325        } else {
1326            self.fetch_values_failed
1327        };
1328        failed || sel_nvl_read_failed
1329    }
1330}
1331
1332impl PvDatabase {
1333    /// One `dbGetLink` of the multi-input fetch, whichever way the link
1334    /// reads: the held native read where the plan allows it and the link is
1335    /// one, else the general read. `None` for an unset link, which C's loop
1336    /// visits as a `dbConstGetValue` success with nothing to deliver.
1337    ///
1338    /// `always`: the two loops are its callers, and the frame it would
1339    /// otherwise be — the held path's arguments moved in and the outcome
1340    /// moved out — is what the held path exists to avoid.
1341    #[inline(always)]
1342    fn fetch_multi_input(
1343        &self,
1344        guard: &mut DataGuard<'_>,
1345        plan: &crate::server::record::record_instance::ProcessPlan,
1346        declared: &'static [(&'static str, &'static str)],
1347        cache_slot: usize,
1348        visited: &mut ProcStack,
1349    ) -> Option<LinkOutcome> {
1350        if plan.multi_inputs_read_native {
1351            match self.fetch_native_input_held(guard, plan, declared, cache_slot) {
1352                HeldFetch::Done(outcome) => return Some(outcome),
1353                HeldFetch::Unset => return None,
1354                HeldFetch::NotHeld => {}
1355            }
1356        }
1357        self.fetch_multi_input_general(guard, plan, declared, cache_slot, visited)
1358    }
1359
1360    /// The common link of a wired record — a held local target read at its
1361    /// own type, no filter — through [`Self::fetch_native_db_input`], with
1362    /// the reader's guard held throughout. Decides from the cached parse and
1363    /// target alone, so a link it declines has cost the general path one
1364    /// cache hit.
1365    #[inline(always)]
1366    fn fetch_native_input_held(
1367        &self,
1368        guard: &mut DataGuard<'_>,
1369        plan: &crate::server::record::record_instance::ProcessPlan,
1370        declared: &'static [(&'static str, &'static str)],
1371        cache_slot: usize,
1372    ) -> HeldFetch {
1373        use crate::server::record::record_instance::ParsedInputLink;
1374        let rec = guard.rec;
1375        let (inst, held) = guard.hold_in();
1376        // Under THIS hold, as the parse it validates is: a link before this
1377        // one may have released the guard, and a put to the text in that
1378        // window moved the count.
1379        let generation = inst.record.input_links_generation();
1380        let Some(entry) = ParsedInputLink::validated(
1381            &mut inst.parsed_inputs,
1382            &*inst.record,
1383            cache_slot,
1384            declared,
1385            generation,
1386        ) else {
1387            return HeldFetch::Unset;
1388        };
1389        // C `dbGetLink`: a `ProcessPassive` DB input link processes its
1390        // passive source record before the value is read. The source's
1391        // cycle may read THIS record back through a link of its own, so it
1392        // runs with the guard released — as does a read of this record's
1393        // own field. Neither is this frame's, and the handle knows which
1394        // it is.
1395        let Some((db, at, native)) = entry.native_read(self, rec, cache_slot) else {
1396            return HeldFetch::NotHeld;
1397        };
1398        HeldFetch::Done(LinkOutcome {
1399            read_failed: self.fetch_native_db_input(
1400                &mut *inst.record,
1401                &mut inst.common,
1402                held,
1403                (db, at, native),
1404                (declared, cache_slot),
1405                plan.multi_input_is_db_get_link,
1406            ),
1407            skipped: false,
1408        })
1409    }
1410
1411    /// One `dbGetLink` of the multi-input fetch in its general form: the
1412    /// read converted to the record's request, a `PP` source processed
1413    /// first, a target found by name, the reader's own field read with the
1414    /// guard released — every case, through [`Self::land_multi_input`].
1415    /// Out of the loop's line so the loop carries the common case's frame
1416    /// alone.
1417    #[inline(never)]
1418    fn fetch_multi_input_general(
1419        &self,
1420        guard: &mut DataGuard<'_>,
1421        plan: &crate::server::record::record_instance::ProcessPlan,
1422        declared: &'static [(&'static str, &'static str)],
1423        cache_slot: usize,
1424        visited: &mut ProcStack,
1425    ) -> Option<LinkOutcome> {
1426        use crate::server::record::record_instance::ParsedInputLink;
1427        use crate::server::record::{InputLinkRequest, LinkProcessPolicy, LinkReadAs, ParsedLink};
1428        let (link_field, val_field) = declared[cache_slot];
1429        let rec = guard.rec;
1430        let inst = guard.hold();
1431        let (failure_is_inert, request) = if plan.multi_inputs_read_native {
1432            debug_assert!(
1433                !inst.record.input_link_failure_is_inert(link_field)
1434                    && inst.record.input_link_request(link_field)
1435                        == InputLinkRequest::As(LinkReadAs::Native),
1436                "{}: input_link_answers_fixed_at_type must be false for a \
1437                 per-instance input_link_request / input_link_failure_is_inert",
1438                inst.record.record_type()
1439            );
1440            (false, InputLinkRequest::As(LinkReadAs::Native))
1441        } else {
1442            (
1443                inst.record.input_link_failure_is_inert(link_field),
1444                inst.record.input_link_request(link_field),
1445            )
1446        };
1447        let generation = inst.record.input_links_generation();
1448        let entry = ParsedInputLink::validated(
1449            &mut inst.parsed_inputs,
1450            &*inst.record,
1451            cache_slot,
1452            declared,
1453            generation,
1454        )?;
1455        let (parsed, target) = entry.target(self, rec, cache_slot);
1456        // C `dbGetLink`: a `ProcessPassive` DB input link processes its
1457        // passive source record before the value is read. The source's cycle
1458        // may read THIS record back through a link of its own, so it runs
1459        // with the guard released — as does a read of this record's own
1460        // field, and a read that has to find its target by name.
1461        let held_target = match (target, parsed) {
1462            (Some(at), ParsedLink::Db(db))
1463                if !Arc::ptr_eq(&at.rec, rec) && db.policy != LinkProcessPolicy::ProcessPassive =>
1464            {
1465                Some((db, at))
1466            }
1467            _ => None,
1468        };
1469        // One landing site, so the read is written once into the frame it
1470        // is consumed from; the arms differ only in whether the parse and
1471        // the target are borrowed from the cache (the guard held, so nothing
1472        // can replace them) or cloned out to survive the release.
1473        let owned;
1474        let owned_target;
1475        let (record, common, parsed, read) = if let Some((db, at)) = held_target {
1476            let read = self.read_db_link_at(db, at);
1477            (&mut *inst.record, &mut inst.common, parsed, read)
1478        } else {
1479            owned_target = target.cloned();
1480            owned = entry.parsed().clone();
1481            guard.release();
1482            if let ParsedLink::Db(db) = &*owned {
1483                self.process_passive_db_source(db, visited);
1484            }
1485            let read = self.read_link_with_alarm_at(&owned, owned_target.as_ref());
1486            let inst = guard.hold();
1487            (&mut *inst.record, &mut inst.common, &*owned, read)
1488        };
1489        let link = MultiInputLink {
1490            link_field,
1491            val_field,
1492            parsed,
1493            request,
1494            failure_is_inert,
1495        };
1496        Some(self.land_multi_input(record, common, rec, &link, plan, read))
1497    }
1498
1499    /// The input stage of a cycle that reads nothing but the record's own
1500    /// input links, each at its own type — see the shape test in
1501    /// [`Self::fetch_input_stage`]. The multi-input loop alone, over the
1502    /// record's own list, under the guard the caller holds; returns the
1503    /// resolved mask, the one thing the body wants of such a cycle.
1504    fn fetch_own_native_inputs(
1505        &self,
1506        guard: &mut DataGuard<'_>,
1507        plan: &crate::server::record::record_instance::ProcessPlan,
1508        link_texts: &InputLinkTexts,
1509        visited: &mut ProcStack,
1510    ) -> u64 {
1511        let declared = link_texts.own();
1512        let policy = plan.input_fetch_policy;
1513        let mut fold = FetchFold::default();
1514        // Over the set links only: C's loop visits every declared link, but
1515        // an unset one is a `dbConstGetValue` success with nothing to
1516        // deliver, so the passes it would make here are no-ops.
1517        let mut wired = link_texts.wired();
1518        while wired != 0 {
1519            let slot = wired.trailing_zeros() as usize;
1520            wired &= wired - 1;
1521            let Some(outcome) = self.fetch_multi_input(guard, plan, declared, slot, visited) else {
1522                continue;
1523            };
1524            if fold.note(policy, slot, slot + 1 == declared.len(), outcome) {
1525                break;
1526            }
1527        }
1528        let fetch_values_failed = fold.failed(policy, false);
1529        let inst = guard.hold();
1530        inst.record.set_fetch_gate_failed(fetch_values_failed);
1531        if fetch_values_failed {
1532            inst.suppress_subroutine_run = true;
1533        }
1534        fold.resolved
1535    }
1536}
1537
1538/// One `fetch_values` result into its value field — C's `dbGetLink(plink,
1539/// DBR_DOUBLE, &prec->a, ...)` store, for the types that funnel through the
1540/// numeric put and the ones that take the value as read.
1541#[inline]
1542fn deliver_multi_input(
1543    record: &mut dyn crate::server::record::Record,
1544    val_field: &'static str,
1545    value: EpicsValue,
1546    store_raw: bool,
1547) {
1548    if store_raw {
1549        // A string-class declared request (printf `%s`) already produced the
1550        // value the record asked for — the numeric funnel below is the OTHER
1551        // records' `DBR_DOUBLE` request, not a store rule.
1552        let _ = record.put_field_internal(val_field, value);
1553        return;
1554    }
1555    // What a numeric source's `DBR_DOUBLE` fetch is; everything else
1556    // converts in its own frame.
1557    let f = match value.into_double() {
1558        Ok(f) => f,
1559        Err(value) => match deliver_converted(record, val_field, value) {
1560            Some(f) => f,
1561            None => return,
1562        },
1563    };
1564    let _ = record.put_multi_input_f64(val_field, f);
1565}
1566
1567/// [`deliver_multi_input`]'s non-`Double` arm: an array stored whole when
1568/// the field takes one, else the scalar the numeric funnel makes of it.
1569fn deliver_converted(
1570    record: &mut dyn crate::server::record::Record,
1571    val_field: &'static str,
1572    value: EpicsValue,
1573) -> Option<f64> {
1574    if value.is_array() {
1575        if record.put_field_internal(val_field, value.clone()).is_ok() {
1576            return None;
1577        }
1578        // The target is a scalar field: element 0, as C's one-element
1579        // destination takes.
1580        return value.first_element().and_then(|v| v.get_convert_f64());
1581    }
1582    value.get_convert_f64()
1583}
1584
1585/// The record's data guard across the guarded segments of one process cycle.
1586///
1587/// C holds `dbScanLock` for the whole of `dbProcess`. The port's segments each
1588/// re-took the lock because the work between them — link reads, link writes,
1589/// device output, forward-link and CP dispatch — may lock another record, or
1590/// this one again through a cyclic link, and so must run unlocked. That work
1591/// exists on a minority of cycles. One rule at every boundary: release only
1592/// across a boundary that performs such work, decided from state the previous
1593/// segment read under the guard; otherwise the next segment continues under
1594/// the guard the previous one held.
1595/// What a process frame is asked to run: a name still to be looked up, or a
1596/// scan-list entry already resolved to its canonical name and cell.
1597enum ProcessTarget<'a> {
1598    Name(&'a str),
1599    Resolved(&'a str, Arc<RecordCell>),
1600}
1601
1602/// The frame's record name: borrowed from the caller's scan snapshot when the
1603/// entry came resolved, shared out of the registry when the frame looked it
1604/// up. Either way no name is copied per cycle.
1605enum FrameName<'a> {
1606    Borrowed(&'a str),
1607    Shared(Arc<str>),
1608}
1609
1610impl std::ops::Deref for FrameName<'_> {
1611    type Target = str;
1612    fn deref(&self) -> &str {
1613        match self {
1614            FrameName::Borrowed(s) => s,
1615            FrameName::Shared(s) => s,
1616        }
1617    }
1618}
1619
1620struct DataGuard<'a> {
1621    rec: &'a Arc<RecordCell>,
1622    held: Option<crate::server::record::RecordMut<'a>>,
1623}
1624
1625impl<'a> DataGuard<'a> {
1626    fn new(rec: &'a Arc<RecordCell>) -> Self {
1627        Self { rec, held: None }
1628    }
1629
1630    /// The instance under the guard — taken now if the last boundary released it.
1631    ///
1632    /// `always`, as is [`Self::hold_in`]: the body asks a dozen times per
1633    /// cycle and the answer is a loaded pointer whenever the guard is held.
1634    /// Left to the inliner, one more caller of [`RecordCell::write`] flipped
1635    /// it out of line, at 150 instructions of frame per cycle.
1636    #[inline(always)]
1637    fn hold(&mut self) -> &mut RecordInstance {
1638        let rec = self.rec;
1639        self.held.get_or_insert_with(|| rec.write())
1640    }
1641
1642    /// [`Self::hold`] with the set guard alongside, for the link reads of
1643    /// the same set ([`RecordCell::read_in`]).
1644    #[inline(always)]
1645    fn hold_in(&mut self) -> (&mut RecordInstance, &crate::server::database::SetGuard) {
1646        let rec = self.rec;
1647        self.held.get_or_insert_with(|| rec.write()).split()
1648    }
1649
1650    /// Give the guard up ahead of work that may lock another record, or this one.
1651    fn release(&mut self) {
1652        self.held = None;
1653    }
1654}
1655
1656/// What the input stage hands the rest of the cycle. See
1657/// [`PvDatabase::fetch_input_stage`].
1658struct InputStage {
1659    is_soft: bool,
1660    /// The multi-input links whose fetch produced a value, one bit per slot
1661    /// of the record's own `multi_input_links` — C's `RTN_SUCCESS(dbGetLink)`
1662    /// per link, recorded at no cost and delivered as the bits it is
1663    /// ([`crate::server::record::ResolvedInputLinks`]), so every type's
1664    /// report is made.
1665    resolved: u64,
1666    /// What the link reads produced. `None` is the cycle that had nothing to
1667    /// read — a stock `calc` — and costs that cycle one tag, where a struct
1668    /// of empty results cost it every field's write and drop.
1669    links: Option<LinkInputs>,
1670}
1671
1672/// The per-link results of one input stage, present only for a cycle that
1673/// read at least one link.
1674struct LinkInputs {
1675    inp_value: Option<EpicsValue>,
1676    inp_source_time: Option<std::time::SystemTime>,
1677    inp_source_utag: Option<u64>,
1678    inp_link_remote_time: Option<(i64, i32, u64)>,
1679    dol_info: Option<(crate::server::record::ParsedLink, i16)>,
1680    dol_fetch: Option<crate::server::recgbl::simm::LinkFetch>,
1681    dol_read_failed: bool,
1682    sel_nvl_value: Option<EpicsValue>,
1683    string_input_values: Vec<(String, EpicsValue)>,
1684    asub_dynamic: Option<AsubDynamicSub>,
1685    resolved_link_fields: Vec<&'static str>,
1686    link_alarms: Vec<(
1687        crate::server::record::MonitorSwitch,
1688        super::links::LinkAlarm,
1689    )>,
1690}
1691
1692impl InputStage {
1693    /// The stage's result for a cycle that had nothing to read: what the
1694    /// fetch produces when every link it would ask is unset.
1695    fn none(is_soft: bool, resolved: u64) -> Self {
1696        Self {
1697            is_soft,
1698            resolved,
1699            links: None,
1700        }
1701    }
1702}
1703
1704impl LinkInputs {
1705    /// No link read anything — the shape a later stage fills in when it has a
1706    /// result of its own to record (the pre-process `ReadDbLink` reads).
1707    fn none() -> Self {
1708        Self {
1709            inp_value: None,
1710            inp_source_time: None,
1711            inp_source_utag: None,
1712            inp_link_remote_time: None,
1713            dol_info: None,
1714            dol_fetch: None,
1715            dol_read_failed: false,
1716            sel_nvl_value: None,
1717            string_input_values: Vec::new(),
1718            asub_dynamic: None,
1719            resolved_link_fields: Vec::new(),
1720            link_alarms: Vec::new(),
1721        }
1722    }
1723}
1724
1725impl PvDatabase {
1726    /// Process a record by name (process_local + notify).
1727    /// Alias-aware (epics-base PR #336).
1728    pub async fn process_record(&self, name: &str) -> CaResult<()> {
1729        // Delegate to the canonical engine path so a direct process fetches
1730        // input links (DOL/INPx), runs the record body, evaluates alarms,
1731        // writes outputs and dispatches FLNK exactly as a C `dbProcess` does.
1732        // The reduced `process_local` path this used to call fetched no links,
1733        // so a direct process of a calc/sub/aSub used stale A..U inputs; that
1734        // path now exists only as an internal record-body unit-test helper.
1735        // Acquires the entry record's advisory write gate (foreign caller).
1736        let mut visited = ProcStack::new();
1737        self.process_record_with_links(name, &mut visited).await
1738    }
1739
1740    /// `process_record` variant for a caller that already
1741    /// owns the record's advisory write gate — the QSRV atomic group
1742    /// PUT applying a `+proc` member. The gate is not
1743    /// reentrant; the atomic group path MUST use this entry. See
1744    /// [`crate::server::database::PvDatabase::lock_records`].
1745    pub async fn process_record_already_locked(&self, name: &str) -> CaResult<()> {
1746        // Same delegation as [`Self::process_record`], but to the gate-held
1747        // engine entry since the caller already owns the advisory write gate.
1748        let mut visited = ProcStack::new();
1749        self.process_record_with_links_already_locked(name, &mut visited)
1750    }
1751
1752    /// Process a record with full link handling (INP -> process -> alarms -> OUT -> FLNK).
1753    /// Uses the visited set for cycle detection.
1754    ///
1755    /// Foreign-caller entry: FLNK dispatch, scan loop, scan_event, CA put,
1756    /// process(PROC=1) etc. Hits the PACT entry guard (mirrors C `dbProcess`
1757    /// at `dbAccess.c:537-559`) when the record is mid-async.
1758    ///
1759    /// this is a *foreign* full-processing entry, so it acquires
1760    /// the record's advisory write gate (`dbScanLock` analogue) for the
1761    /// entry record before processing. A QSRV atomic group or pvalink
1762    /// atomic scan-on-update epoch that holds `lock_records` over the
1763    /// same record blocks a foreign scan/event/FLNK-dispatch caller
1764    /// here, and vice versa — restoring the `DBManyLock` exclusion. The
1765    /// recursive FLNK / OUT / CP fan-out within one chain does NOT
1766    /// re-acquire the gate (`process_record_with_links_recursive`),
1767    /// mirroring C `processTarget` (`dbDbLink.c:436`) which asserts the
1768    /// target's lock set is already owned by the calling thread; the
1769    /// `visited` cycle guard prevents re-processing the entry record.
1770    pub fn process_record_with_links<'a>(
1771        &'a self,
1772        name: &'a str,
1773        visited: &'a mut ProcStack,
1774    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
1775        Box::pin(async move { self.process_record_with_links_sync(name, visited) })
1776    }
1777
1778    /// The same frame as [`Self::process_record_with_links`], called directly.
1779    ///
1780    /// `run_process_frame` and everything under it is synchronous — the H6
1781    /// contract the body's doc states — so the future the entry above hands
1782    /// back resolves without ever yielding, and its `Box::pin` is an
1783    /// allocation per record per scan cycle for nothing. A sweep already runs
1784    /// on a thread it is allowed to occupy, so it takes the frame directly.
1785    pub(crate) fn process_record_with_links_sync(
1786        &self,
1787        name: &str,
1788        visited: &mut ProcStack,
1789    ) -> CaResult<()> {
1790        self.run_process_frame(ProcessTarget::Name(name), visited, true, false, false)
1791    }
1792
1793    /// [`Self::process_record_with_links_sync`] for a caller that already
1794    /// holds the instance — a scan sweep, whose list carries the handle beside
1795    /// the name it is walking.
1796    pub(crate) fn process_record_with_links_resolved(
1797        &self,
1798        name: &str,
1799        rec: Arc<RecordCell>,
1800        visited: &mut ProcStack,
1801    ) -> CaResult<()> {
1802        self.run_process_frame(
1803            ProcessTarget::Resolved(name, rec),
1804            visited,
1805            true,
1806            false,
1807            false,
1808        )
1809    }
1810
1811    /// Driver-callback (`asyn:READBACK`) full-processing entry.
1812    ///
1813    /// The single owner of this entry is the I/O Intr wiring
1814    /// (`crate::server::ioc_app::setup_io_intr` and its `ioc_builder`
1815    /// twin): the spawned task processes a record because the driver
1816    /// fired an interrupt callback, not because of a client put / FLNK /
1817    /// scan. `device_callback = true` tells
1818    /// `Self::process_record_with_links_inner` that, for an *output*
1819    /// record, this cycle must READ the callback value back into VAL and
1820    /// MUST NOT write it to the driver — C `devAsynInt32.c::processBo`
1821    /// (and `processAo`/`processLongout`/…) take the readback branch when
1822    /// `newOutputCallbackValue` is set, never `processCallbackOutput`'s
1823    /// `write()`. Without this, the readback re-asserts the setpoint and
1824    /// re-triggers the driver (e.g. AD `Acquire` looping). Input records
1825    /// (`!can_device_write`) are unaffected: their read stage already
1826    /// runs, and the no-write gate is keyed on the record being an output.
1827    ///
1828    /// Acquires the entry record's advisory write gate exactly like
1829    /// [`Self::process_record_with_links`] — the callback task is a
1830    /// foreign caller w.r.t. any QSRV atomic group / pvalink epoch.
1831    pub fn process_record_readback<'a>(
1832        &'a self,
1833        name: &'a str,
1834        visited: &'a mut ProcStack,
1835    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
1836        Box::pin(async move {
1837            // C `devAsynInt32.c::outputCallbackCallback` (asyn devEpics):
1838            // arm the output-callback "expected pop" before dbProcess, then
1839            // reconcile after. If this pass never reaches the device read
1840            // stage — the PACT entry guard bails because a put / FLNK cycle
1841            // still owns the record (e.g. the readback racing the bo's own
1842            // put that started the driver) — the callback ring would keep the
1843            // entry forever and desync the wakeup count from the pop count.
1844            // The AD `Acquire` bo getting stuck at 1 after a fast acquire is
1845            // exactly that: the start callback's readback bails on PACT, the
1846            // finalize callback's pop then consumes the stale start value, and
1847            // the finalize 0 is never popped. reconcile discards the stale
1848            // entry (C fallback `getCallbackValue`) so 1 callback == 1 pop.
1849            self.arm_readback_callback(name);
1850            let result = self
1851                .process_record_with_links_inner(name, visited, false, true, true)
1852                .await;
1853            self.reconcile_readback_callback(name);
1854            result
1855        })
1856    }
1857
1858    /// Arm the entry record's output driver-callback cycle before a readback
1859    /// process pass — see [`crate::server::device_support::DeviceSupport::arm_readback_callback`].
1860    fn arm_readback_callback(&self, name: &str) {
1861        let canonical = self.resolve_alias(name);
1862        let key: &str = canonical.as_deref().unwrap_or(name);
1863        // Collect-then-act: clone the instance handle under a brief map read,
1864        // then drop the map lock before taking the per-record write. Never
1865        // hold `records.read()` across `rec.write()` — same lock discipline
1866        // as `add_breaktables` / `all_record_names`.
1867        let rec = {
1868            let records = self.inner.records.read();
1869            records.get(key).cloned()
1870        };
1871        if let Some(rec) = rec {
1872            if let Some(dev) = rec.write().device.as_mut() {
1873                dev.arm_readback_callback();
1874            }
1875        }
1876    }
1877
1878    /// Reconcile the entry record's output driver-callback cycle after a
1879    /// readback process pass — see
1880    /// [`crate::server::device_support::DeviceSupport::reconcile_readback_callback`].
1881    fn reconcile_readback_callback(&self, name: &str) {
1882        let canonical = self.resolve_alias(name);
1883        let key: &str = canonical.as_deref().unwrap_or(name);
1884        // Collect-then-act: clone the handle under a brief map read, drop the
1885        // map lock, then take the per-record write — see `arm_readback_callback`.
1886        let rec = {
1887            let records = self.inner.records.read();
1888            records.get(key).cloned()
1889        };
1890        if let Some(rec) = rec {
1891            if let Some(dev) = rec.write().device.as_mut() {
1892                dev.reconcile_readback_callback();
1893            }
1894        }
1895    }
1896
1897    /// full-processing entry for a caller that already owns the
1898    /// record's advisory write gate via [`PvDatabase::lock_records`] —
1899    /// the QSRV atomic group GET/PUT and the pvalink atomic
1900    /// scan-on-update epoch. The advisory gate is not
1901    /// reentrant; a transaction owner holding `lock_records` over the
1902    /// member set MUST use this entry to scan a member record, or it
1903    /// would deadlock against its own epoch guard. Foreign (non-owner)
1904    /// callers must use [`Self::process_record_with_links`] so the gate
1905    /// is taken.
1906    ///
1907    /// Synchronous: the gate is already held by the caller, so this entry has
1908    /// nothing to wait for. It goes straight to
1909    /// `process_record_with_links_body`, which is where the H6
1910    /// no-suspension contract lives.
1911    pub fn process_record_with_links_already_locked(
1912        &self,
1913        name: &str,
1914        visited: &mut ProcStack,
1915    ) -> CaResult<()> {
1916        self.run_process_frame(ProcessTarget::Name(name), visited, false, false, false)
1917    }
1918
1919    /// One record's process frame: entry bookkeeping, the optional advisory
1920    /// write gate, the cycle, and the unwind that takes this frame's cycle
1921    /// marker back out of `visited`.
1922    ///
1923    /// **Invariant:** a name is in `visited` exactly while its frame is on the
1924    /// CURRENT PROCESS STACK — never "somewhere earlier in this cascade".
1925    /// Both of C's equivalents are stack conditions and nothing else:
1926    /// `processTarget` claims `procThread` at `dbDbLink.c:502-504` and clears
1927    /// it at `:521-526`, around one `dbProcess`; `dbProcess` itself tests
1928    /// `precord->pact` (`dbAccess.c:537`), set for the duration of a cycle.
1929    /// There is no set of already-processed records anywhere in C, and
1930    /// `dbProcess(pdst)` at `dbDbLink.c:511` is unconditional.
1931    ///
1932    /// **Owner/gate:** this function. [`Self::process_entry_prelude`]
1933    /// returning `Some` means THIS frame inserted the name, and this is the
1934    /// only place that takes it out again. A `Some` returning through any
1935    /// other path would leave a marker outliving the stack it describes, and
1936    /// the guard would start refusing records C processes again — which is
1937    /// exactly what a diamond FLNK (`F` → `A`,`B`; `A` → `C`; `B` → `C`) hit.
1938    fn run_process_frame(
1939        &self,
1940        target: ProcessTarget<'_>,
1941        visited: &mut ProcStack,
1942        acquire_gate: bool,
1943        is_continuation: bool,
1944        device_callback: bool,
1945    ) -> CaResult<()> {
1946        // A `None` here found the name already present, so the marker is the
1947        // outer frame's and there is nothing to unwind.
1948        let Some((name, rec)) = self.process_entry_prelude(target, visited)? else {
1949            return Ok(());
1950        };
1951
1952        // advisory write gate (`dbScanLock(precord)` analogue).
1953        // A foreign full-processing entry (scan loop, scan_event, FLNK
1954        // dispatch from another chain, CA put, PINI/startup) acquires
1955        // the entry record's gate so it cannot interleave with a QSRV
1956        // atomic group or a pvalink atomic scan epoch holding
1957        // `lock_records` over the same record. `name` is already the
1958        // alias-resolved canonical name, the same key `lock_records`
1959        // uses. Not acquired when `acquire_gate` is false: either a
1960        // transaction owner already holds the gate via `lock_records`
1961        // (`process_record_with_links_already_locked`), or this is a
1962        // recursive FLNK/OUT/CP call within one chain
1963        // (`process_record_with_links_recursive`) — C `processTarget`
1964        // processes a link target under the lock set the caller already
1965        // owns, and re-acquiring would deadlock the non-reentrant gate.
1966        let _record_gate = if acquire_gate {
1967            Some(self.lock_instance(&rec))
1968        } else {
1969            None
1970        };
1971
1972        // Breakpoint hook, C `dbAccess.c:504-515`:
1973        //
1974        //     if (lset_stack_count != 0) {
1975        //         if (dbBkpt(precord)) goto all_done;
1976        //     }
1977        //
1978        // guarding both the hook and its "skip record support" answer. Here
1979        // the guard is the `ArcSwapOption` load: `None` for a database nobody
1980        // is debugging, so this costs one relaxed atomic per processed record
1981        // where C costs one comparison.
1982        //
1983        // Under the gate, as C's `dbProcess` runs under `dbScanLock`: the
1984        // hook reads the record and orders the lock set before the
1985        // breakpoint stack, the one order every debugger path uses. A stop
1986        // parks the calling thread with the set given up — C drops
1987        // `dbScanLock` before `epicsThreadSuspendSelf` (`dbBkpt.c:794-796`)
1988        // — so `dbb`/`dbd`/`dbc`/`dbs` keep working and the set's other
1989        // records keep processing while one is stopped. The thread that
1990        // parks is never a runtime worker: the hook hands foreign processing
1991        // to the lock set's own continuation thread and returns `Skip`, and
1992        // only that thread reaches the parking arm.
1993        let breakpoints = self.breakpoints_if_debugging();
1994        if let Some(table) = breakpoints.as_ref() {
1995            if table.before_process(self, &name)
1996                == crate::server::database::breakpoint::Before::Skip
1997            {
1998                // C's `goto all_done`, which unwinds the same way the normal
1999                // path does. `visited` was inserted by the prelude above and
2000                // this frame owns it, so it comes out here as it would below.
2001                visited.release(&rec);
2002                return Ok(());
2003            }
2004        }
2005
2006        // NO `.await` may appear below this line while `_record_gate` is
2007        // live — see the module note on `process_record_with_links_body`.
2008        let result = self.process_record_with_links_body(
2009            &name,
2010            &rec,
2011            visited,
2012            is_continuation,
2013            device_callback,
2014        );
2015
2016        // Breakpoint auto-print, C `dbAccess.c:614-616` — after record
2017        // support, under the same `lset_stack_count` guard. Reloaded rather
2018        // than reusing the handle above: a `dbd` during this record's own
2019        // processing can have retired the observer, and C re-tests the count.
2020        if let Some(table) = self.breakpoints_if_debugging() {
2021            table.after_process(self, &name);
2022        }
2023
2024        // The unwind. C `dbDbLink.c:521-526`, `if (claim_dst)
2025        // dbRec2Pvt(pdst)->procThread = NULL;` — after `dbProcess`, whatever
2026        // it returned.
2027        visited.release(&rec);
2028        result
2029    }
2030
2031    /// One entry point processed by a breakpoint continuation thread — C
2032    /// `dbBkptCont`'s `dbScanLock(precord); dbProcess(pqe->entrypoint);
2033    /// dbScanUnlock(precord);` (`dbBkpt.c:604-606`).
2034    ///
2035    /// The gate is acquired here, as for any other foreign entry, and the
2036    /// chain below may park inside the breakpoint hook. That is legal on this
2037    /// call and on no other: the caller is the lock set's dedicated thread,
2038    /// which exists to be parked, never a runtime worker.
2039    pub(crate) fn process_record_for_breakpoint(&self, name: &str) -> CaResult<()> {
2040        self.run_process_frame(
2041            ProcessTarget::Name(name),
2042            &mut ProcStack::new(),
2043            true,
2044            false,
2045            false,
2046        )
2047    }
2048
2049    /// recursive FLNK / OUT / CP fan-out entry within a single
2050    /// processing chain. Does NOT re-acquire the advisory write gate:
2051    /// the chain is one transaction whose entry record's gate is
2052    /// already held by the foreign entry, and C `processTarget`
2053    /// (`dbDbLink.c:436`) processes a link target under the lock set
2054    /// already owned by the calling thread. Re-acquiring per chain
2055    /// member would also create a lock-ordering deadlock between
2056    /// reverse FLNK chains.
2057    ///
2058    /// Synchronous, and recursive as a plain call: the chain runs inside the
2059    /// entry record's gate-held region, so it must not suspend. C's
2060    /// `processTarget` is likewise a direct call under the caller's lock set.
2061    pub(crate) fn process_record_with_links_recursive(
2062        &self,
2063        name: &str,
2064        visited: &mut ProcStack,
2065    ) -> CaResult<()> {
2066        self.run_process_frame(ProcessTarget::Name(name), visited, false, false, false)
2067    }
2068
2069    /// Owner-driven continuation re-entry — bypasses the PACT entry guard.
2070    ///
2071    /// Used by `ProcessAction::ReprocessAfter` timer fires: the spawned
2072    /// re-entry task IS the owner of the async cycle, equivalent to C
2073    /// `callbackRequestDelayed`'s direct call to the record's `process()`
2074    /// (which bypasses `dbProcess`). Foreign callers must still go through
2075    /// `process_record_with_links` so FLNK / scan / CA put cannot race
2076    /// during the wait window.
2077    ///
2078    /// the timer fire is a fresh task — the original cycle's
2079    /// advisory gate was released when `process_record_with_links`
2080    /// returned async-pending. In C, `callbackRequestDelayed` dispatches
2081    /// through a callback that re-takes `dbScanLock(precord)` for the
2082    /// completion `process()`. This entry therefore re-acquires the
2083    /// advisory write gate, so the continuation cannot interleave with a
2084    /// QSRV atomic group or another foreign scan of the same record.
2085    pub fn process_record_continuation<'a>(
2086        &'a self,
2087        name: &'a str,
2088        visited: &'a mut ProcStack,
2089    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
2090        Box::pin(async move {
2091            self.process_record_with_links_inner(name, visited, true, true, false)
2092                .await
2093        })
2094    }
2095
2096    /// A cycle-free [`AsyncDbHandle`] for this database, handed to each
2097    /// record via [`crate::server::record::Record::set_async_context`] at
2098    /// registration. Holds only a `Weak` reference, so a record stashing
2099    /// it never keeps the database alive.
2100    pub fn async_handle(&self) -> AsyncDbHandle {
2101        AsyncDbHandle {
2102            inner: Arc::downgrade(&self.inner),
2103        }
2104    }
2105
2106    /// Mint a fresh async re-entry [`AsyncToken`] for `name`.
2107    ///
2108    /// Minting advances the record's generation counter, so any
2109    /// previously-minted token for the same record is superseded — its
2110    /// [`AsyncToken::fire`] becomes a structural no-op. This mirrors C
2111    /// `callbackRequestDelayed` replacing an outstanding delayed callback
2112    /// for a record. `name` must be the canonical record name (the value
2113    /// of `RecordInstance::name`). Returns `None` if the record is absent.
2114    pub fn mint_async_token(&self, name: &str) -> Option<AsyncToken> {
2115        let rec = self.get_record_no_resolve(name)?;
2116        let generation = rec.read().reprocess_generation.clone();
2117        let epoch = generation.fetch_add(1, Ordering::AcqRel) + 1;
2118        Some(AsyncToken {
2119            name: name.to_string(),
2120            generation,
2121            epoch,
2122        })
2123    }
2124
2125    /// Cancel any outstanding async re-entry token for `name` (C
2126    /// `callbackCancelDelayed`): advance the record's generation counter so
2127    /// every previously-minted [`AsyncToken`] for it becomes stale and its
2128    /// `fire` is a no-op. A subsequent [`Self::mint_async_token`] produces a
2129    /// fresh, current token. No-op if the record is absent.
2130    pub fn cancel_async_reentry(&self, name: &str) {
2131        if let Some(rec) = self.get_record_no_resolve(name) {
2132            rec.read()
2133                .reprocess_generation
2134                .fetch_add(1, Ordering::AcqRel);
2135        }
2136    }
2137
2138    /// The callback band `name`'s `PRIO` selects — C
2139    /// `callbackSetPriority(prec->prio, &pcb->callback)` (`seqRecord.c:146`).
2140    ///
2141    /// For the deferral sites that hold a record *name* rather than a locked
2142    /// instance. Takes the record's read lock, so it must not be called from
2143    /// inside that record's own `process()`/`special()` — those run under the
2144    /// instance write lock and read the band off
2145    /// [`ProcessContext::callback_priority`](crate::server::record::ProcessContext)
2146    /// instead. A record that is gone answers `Low`, the band an unwritten
2147    /// `PRIO` already has; the work being scheduled for it is a no-op anyway.
2148    pub fn record_callback_priority(&self, name: &str) -> crate::runtime::task::CallbackPriority {
2149        match self.get_record_no_resolve(name) {
2150            Some(rec) => rec.read().common.callback_priority(),
2151            None => crate::runtime::task::CallbackPriority::Low,
2152        }
2153    }
2154
2155    /// Schedule a delayed re-process of `name` — the single owner of the
2156    /// "mint a fresh [`AsyncToken`], sleep, then fire" pattern. Used by both
2157    /// [`ProcessAction::ReprocessAfter`](crate::server::record::ProcessAction::ReprocessAfter) (record-driven owner re-entry: ODLY
2158    /// output delay, swait, sequence DLYn) and the `SDLY` async-simulation
2159    /// defer ([`SimOutcome::DeferRead`]). Minting advances the record's
2160    /// generation so a newer schedule supersedes any pending one; a stale
2161    /// token's `fire` is a structural no-op. No-op if the record is absent.
2162    fn schedule_delayed_reprocess(&self, name: &str, delay: std::time::Duration) {
2163        let token = match self.mint_async_token(name) {
2164            Some(t) => t,
2165            None => return,
2166        };
2167        let prio = self.record_callback_priority(name);
2168        let db = self.clone();
2169        crate::runtime::task::spawn_background(prio, async move {
2170            crate::runtime::task::sleep_background(delay).await;
2171            let _ = token.fire(&db).await;
2172        });
2173    }
2174
2175    /// Schedule C `callbackRequestDelayed` with a record-owned handler body —
2176    /// the single owner of [`ProcessAction::DelayedCallbackAfter`](crate::server::record::ProcessAction::DelayedCallbackAfter)
2177    /// and the port of `boRecord.c::myCallbackFunc` (:105-118).
2178    ///
2179    /// The fire takes the record gate (C `dbScanLock`), runs
2180    /// [`Record::delayed_callback_fire`](crate::server::record::Record::delayed_callback_fire)
2181    /// and only then re-enters `process()`. The handler's mutation is therefore
2182    /// reachable from the timer alone: no record flag survives the arm, so no
2183    /// other process cycle can consume the one-shot. Re-arming mints a fresh
2184    /// token, exactly as C's re-`callbackRequestDelayed` replaces the pending
2185    /// delayed callback.
2186    fn schedule_delayed_callback(&self, name: &str, delay: std::time::Duration) {
2187        let Some(token) = self.mint_async_token(name) else {
2188            return;
2189        };
2190        let prio = self.record_callback_priority(name);
2191        let db = self.clone();
2192        let name = name.to_string();
2193        crate::runtime::task::spawn_background(prio, async move {
2194            let mut token = token;
2195            let mut delay = delay;
2196            loop {
2197                crate::runtime::task::sleep_background(delay).await;
2198                // A newer arm (or a cancel) superseded this timer while it
2199                // slept — the same `AsyncToken` gate `ReprocessAfter` uses.
2200                if !token.is_current() {
2201                    return;
2202                }
2203                let outcome = {
2204                    let records = db.inner.records.read();
2205                    let Some(rec) = records.get(name.as_str()) else {
2206                        return;
2207                    };
2208                    let rec = rec.clone();
2209                    drop(records);
2210                    let mut instance = rec.write();
2211                    let pact = instance.is_processing();
2212                    instance.record.delayed_callback_fire(pact)
2213                };
2214                match outcome {
2215                    crate::server::record::DelayedCallbackOutcome::Reprocess => {
2216                        let _ = token.fire(&db).await;
2217                        return;
2218                    }
2219                    crate::server::record::DelayedCallbackOutcome::Rearm(again) => {
2220                        let Some(fresh) = db.mint_async_token(&name) else {
2221                            return;
2222                        };
2223                        token = fresh;
2224                        delay = again;
2225                    }
2226                    crate::server::record::DelayedCallbackOutcome::Drop => return,
2227                }
2228            }
2229        });
2230    }
2231
2232    /// (Re)arm a record's monitor watchdog — the single owner of the
2233    /// [`Record::watchdog_interval`](crate::server::record::Record::watchdog_interval) / [`Record::watchdog_fire`](crate::server::record::Record::watchdog_fire) tick, and the
2234    /// port of C `histogramRecord.c::wdogInit` + `wdogCallback` (:102-152).
2235    ///
2236    /// Called from exactly two places, C's own two `wdogInit` call sites: once
2237    /// per record at `iocInit` (C `init_record` pass 1, `:168`) and from
2238    /// [`ProcessAction::ArmWatchdog`](crate::server::record::ProcessAction::ArmWatchdog), which a record's `special()` emits when
2239    /// a put changed the period (histogram SDEL, `:266-268`).
2240    ///
2241    /// Arming bumps the record's `watchdog_generation`, so a tick already in
2242    /// flight is superseded and simply exits — C's `callbackRequestDelayed`
2243    /// replacing an outstanding delayed callback. The task re-reads the
2244    /// interval on every iteration, so an SDEL put to 0 stops the watchdog at
2245    /// its next fire without a separate cancel path.
2246    ///
2247    /// The tick is NOT a process cycle: it takes the record lock (C
2248    /// `dbScanLock`), lets the record perform its own state change, stamps the
2249    /// record (C `recGblGetTimeStamp`) and posts `DBE_VALUE | DBE_LOG` monitors
2250    /// for the fields the record named — no `add_count`, no alarm tail, no
2251    /// FLNK. A record with no watchdog (`watchdog_interval() == None`) spawns
2252    /// nothing.
2253    pub(crate) fn arm_watchdog(&self, name: &str) {
2254        let (rec, generation, epoch, prio) = {
2255            let Some(rec) = self.get_record_no_resolve(name) else {
2256                return;
2257            };
2258            let instance = rec.read();
2259            if instance.record.watchdog_interval().is_none() {
2260                // Bumping the generation still cancels a watchdog left running
2261                // by an earlier arm — an SDEL put to 0 comes through here.
2262                instance
2263                    .watchdog_generation
2264                    .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
2265                return;
2266            }
2267            let generation = instance.watchdog_generation.clone();
2268            let epoch = generation.fetch_add(1, std::sync::atomic::Ordering::AcqRel) + 1;
2269            let prio = instance.common.callback_priority();
2270            drop(instance);
2271            (rec, generation, epoch, prio)
2272        };
2273
2274        let is_soft = {
2275            let instance = rec.read();
2276            instance.device.is_none()
2277        };
2278        // C `histogramRecord.c::wdogCallback` stamps with `recGblGetTimeStamp`
2279        // (`:113`), TSEL and all, so the tick owes the TSEL read too. Weak, so
2280        // a live watchdog never keeps the database alive — the tick simply
2281        // stamps without TSEL if the database is already gone.
2282        let db = self.async_handle();
2283        crate::runtime::task::spawn_background(prio, async move {
2284            loop {
2285                let interval = {
2286                    let instance = rec.read();
2287                    match instance.record.watchdog_interval() {
2288                        Some(d) => d,
2289                        // C: `if (prec->sdel > 0)` fails -> no re-arm.
2290                        None => return,
2291                    }
2292                };
2293                crate::runtime::task::sleep_background(interval).await;
2294                // A newer arm superseded this task while it slept.
2295                if generation.load(std::sync::atomic::Ordering::Acquire) != epoch {
2296                    return;
2297                }
2298                let fields = {
2299                    let mut instance = rec.write();
2300                    instance.record.watchdog_fire()
2301                };
2302                if fields.is_empty() {
2303                    // C `wdogCallback`: `mcnt == 0` -> no stamp, no post; the
2304                    // timer still re-arms. C tests `prec->mcnt` before it even
2305                    // takes `dbScanLock` (`histogramRecord.c:111-112`), so no
2306                    // TSEL read happens on an empty tick either.
2307                    continue;
2308                }
2309                // Between the two guards, like every other stamp point: the
2310                // TSEL read takes its own locks.
2311                let tsel = match db.db() {
2312                    Some(db) => db.read_tsel(&rec),
2313                    None => super::TselStamp::None,
2314                };
2315                let mut instance = rec.write();
2316                let inst = &mut *instance;
2317                tsel.stamp(&inst.name, &mut inst.common, is_soft);
2318                for field in fields {
2319                    instance.notify_field(
2320                        field,
2321                        crate::server::recgbl::EventMask::VALUE
2322                            | crate::server::recgbl::EventMask::LOG,
2323                    );
2324                }
2325            }
2326        });
2327    }
2328
2329    /// Post an async-side field update for `name` — the C `db_post_events`
2330    /// analogue called from device-support / async-callback context.
2331    ///
2332    /// Each `(field, value)` is written through the internal put (bypassing
2333    /// the read-only field gate, like a record's own `process()` writes)
2334    /// and a monitor event is posted with `DBE_VALUE | DBE_LOG` — the mask C
2335    /// device support uses for an out-of-process value post
2336    /// (`db_post_events(precord, &prec->field, DBE_VALUE | DBE_LOG)`).
2337    /// Metadata-class writes invalidate the metadata cache via
2338    /// `notify_field_written`, honouring the snapshot-cache contract.
2339    ///
2340    /// Unlike [`Self::complete_async_record`], this runs *no* alarm /
2341    /// timestamp / FLNK tail: it is the immediate "push these fields to
2342    /// monitors now" primitive (e.g. asyn TRACE info, motor intermediate
2343    /// readback) that is independent of any process cycle. Returns the
2344    /// field names actually posted, or [`CaError::ChannelNotFound`] if the
2345    /// record is absent.
2346    pub fn post_fields(
2347        &self,
2348        name: &str,
2349        fields: Vec<(String, EpicsValue)>,
2350    ) -> CaResult<Vec<String>> {
2351        self.post_fields_with_mask(
2352            name,
2353            fields,
2354            crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
2355        )
2356    }
2357
2358    /// Out-of-band PROPERTY-class post — the C
2359    /// `db_post_events(precord, &precord->val, DBE_PROPERTY)` analogue used
2360    /// for enum-string table re-propagation (asyn `callbackEnum`,
2361    /// devAsynInt32.c:712-766). Stores [`crate::server::device_support::PropertyPost::writes`] through the
2362    /// internal put, invalidates the metadata cache, and posts a single
2363    /// `DBE_PROPERTY` event on [`crate::server::device_support::PropertyPost::post_field`] so subscribers
2364    /// re-read enum choices / control metadata.
2365    ///
2366    /// The written fields are NOT posted on: C's `setEnums` re-keys
2367    /// ZRST/ZRVL/ZRSV… silently and the one `db_post_events` names
2368    /// `&pr->val`. See [`crate::server::device_support::PropertyPost`] for why the two sets are separate.
2369    ///
2370    /// Unlike [`Self::post_fields`] (which posts `DBE_VALUE | DBE_LOG`) this
2371    /// signals a *property* change, not a value change: a driver that re-keys
2372    /// its enum strings has not produced a new reading, only new choice
2373    /// labels. Returns the field names actually written.
2374    pub fn post_property(
2375        &self,
2376        name: &str,
2377        post: crate::server::device_support::PropertyPost,
2378    ) -> CaResult<Vec<String>> {
2379        let rec = {
2380            let records = self.inner.records.read();
2381            records.get(name).cloned()
2382        };
2383        let rec = rec.ok_or_else(|| CaError::ChannelNotFound(name.to_string()))?;
2384        let link_backing = self.resolve_link_backed_metadata_for_posts(&rec);
2385        let link_backing = link_backing.as_link_backing();
2386        let mut inst = rec.write();
2387        let mut written = Vec::with_capacity(post.writes.len());
2388        for (field, value) in post.writes {
2389            inst.record.put_field_internal(&field, value)?;
2390            // Snapshot-cache contract: a metadata-class write must invalidate
2391            // the cache before the monitor snapshot below is built, or the
2392            // property event would carry the pre-change enum choices.
2393            inst.notify_field_written(&field);
2394            written.push(field);
2395        }
2396        inst.notify_field_backed(
2397            &post.post_field,
2398            crate::server::recgbl::EventMask::PROPERTY,
2399            link_backing,
2400        );
2401        Ok(written)
2402    }
2403
2404    /// Shared body of [`Self::post_fields`] and the record-owned posters:
2405    /// write+notify each field under one record-write lock, posting `mask`.
2406    ///
2407    /// Reachable from the records module because a record's own
2408    /// `db_post_events` mask is the record's to choose — see
2409    /// [`crate::server::records::link_status::post_link_status`], where three
2410    /// records post `DBE_VALUE` and a fourth posts `DBE_VALUE|DBE_LOG`.
2411    pub(crate) fn post_fields_with_mask(
2412        &self,
2413        name: &str,
2414        fields: Vec<(String, EpicsValue)>,
2415        mask: crate::server::recgbl::EventMask,
2416    ) -> CaResult<Vec<String>> {
2417        let rec = {
2418            let records = self.inner.records.read();
2419            records.get(name).cloned()
2420        };
2421        let rec = rec.ok_or_else(|| CaError::ChannelNotFound(name.to_string()))?;
2422        // A link-backed field reaches this poster: `seq` posts `DOn` here
2423        // (`links.rs`, C `seqRecord.c:266-268`) and `DOn`'s metadata comes
2424        // from `DOLn`. Resolved before the write guard, as everywhere.
2425        let link_backing = self.resolve_link_backed_metadata_for_posts(&rec);
2426        let link_backing = link_backing.as_link_backing();
2427        let mut inst = rec.write();
2428        let mut posted = Vec::with_capacity(fields.len());
2429        for (field, value) in fields {
2430            inst.record.put_field_internal(&field, value)?;
2431            // Snapshot-cache contract: a metadata-class write must
2432            // invalidate the cache before the monitor snapshot is built.
2433            inst.notify_field_written(&field);
2434            inst.notify_field_backed(&field, mask, link_backing);
2435            posted.push(field);
2436        }
2437        Ok(posted)
2438    }
2439
2440    /// The single owner of [`crate::server::record::ProcessOutcome::post_write_fields`]: apply the
2441    /// field stores a `process()` withheld until its queued link writes had
2442    /// run, and post each at `DBE_VALUE`.
2443    ///
2444    /// Called on every arm that leaves a process cycle, immediately after that
2445    /// arm has executed the cycle's [`crate::server::record::ProcessAction::WriteDbLink`] and before
2446    /// its snapshot notification — which is where C's single `dbScanLock`
2447    /// makes the clear visible (`sseqRecord.c::asyncFinish` after
2448    /// `processCallback`'s `dbPutLink`s; `scalerRecord.c:370` under the same
2449    /// lock as `:457`/`:463`). A reader that takes the record between
2450    /// `process()` returning and this call sees the flag still SET, which is
2451    /// the conservative half of C's two observable states.
2452    ///
2453    /// Each field is applied independently. The group is one transition and a
2454    /// field that fails to store must not strand the rest of it — a partial
2455    /// apply that abandoned `BUSY` would leave the record busy forever.
2456    ///
2457    /// `DBE_VALUE` alone. C's masks, measured: `asyncFinish` (`sseqRecord.c:461`)
2458    /// posts `abort` at `:481`, `aborting` at `:482` and `busy` at `:505`, all
2459    /// three with `MonitorMask` — `DBE_VALUE | recGblResetAlarms(pR)` (`:471`),
2460    /// i.e. `DBE_VALUE` plus the alarm bit only when the alarm changed. Bare
2461    /// `DBE_VALUE` is what the other posts use: `waiting` (`:343`, `:559`,
2462    /// `:728`, `:1185` — never inside `asyncFinish`), the second `aborting`
2463    /// post (`:1192`), and `scalerRecord.c:372` (`cnt`). So a member published
2464    /// in the same cycle as an alarm transition omits a `DBE_ALARM` C sets.
2465    pub(crate) fn publish_post_write_fields(
2466        &self,
2467        name: &str,
2468        fields: crate::server::record::CycleList<(String, EpicsValue)>,
2469    ) {
2470        if fields.is_empty() {
2471            return;
2472        }
2473        let Some(rec) = self.get_record(name) else {
2474            return;
2475        };
2476        let mut inst = rec.write();
2477        for (field, value) in fields {
2478            if let Err(e) = inst.record.put_field_internal(&field, value) {
2479                eprintln!("{name}.{field}: post-write publication failed: {e:?}");
2480                continue;
2481            }
2482            // Snapshot-cache contract, as `post_fields_with_mask`: invalidate
2483            // before the monitor snapshot is built.
2484            inst.notify_field_written(&field);
2485            inst.notify_field(&field, crate::server::recgbl::EventMask::VALUE);
2486        }
2487    }
2488
2489    /// Resolve a link's target field [`DbFieldType`] for a LOCAL `DB_LINK`,
2490    /// or `None` for a constant / external / unresolvable link.
2491    ///
2492    /// Parity of C `dbGetLinkDBFtype` as `sseqRecord.c:checkLinks`
2493    /// (sseqRecord.c:884-941) uses it to fill the `DTn`/`LTn` diagnostics:
2494    /// a `DB_LINK` whose target record is on this IOC reports its addressed
2495    /// field's type (C `dbNameToAddr` → `pAddr->field_type`). A constant or
2496    /// `CA`/`PVA` (external) link returns `None` — epics-base-rs has no
2497    /// client-side introspection of a remote field's type, so the caller
2498    /// renders those as the `DBF_unknown` sentinel.
2499    pub(crate) fn link_target_field_type(&self, link: &str) -> Option<crate::types::DbFieldType> {
2500        let db = match crate::server::record::parse_link_v2(link) {
2501            crate::server::record::ParsedLink::Db(db) => db,
2502            _ => return None,
2503        };
2504        // Through the split: a filtered link's raw halves name a record that
2505        // does not exist (`SRC.VAL[0]` whole, field `VAL`), so `get_record`
2506        // missed and every filtered DB link reported no type at all.
2507        let addressed = db.target();
2508        let rec = self.get_record(&addressed.record)?;
2509        let inst = rec.read();
2510        let field = if addressed.field.is_empty() {
2511            "VAL"
2512        } else {
2513            addressed.field.as_str()
2514        };
2515        crate::server::record::record_instance::declared_field_type_of(inst.record.as_ref(), field)
2516    }
2517
2518    /// Create a put-notify wait-set for a downstream operation a record is
2519    /// about to drive, returning the wait-set (to attach to the downstream
2520    /// target instance's `notify`) and the completion receiver.
2521    ///
2522    /// C `dbNotify.c` `processNotify`: the set arms `pending = 1` for the
2523    /// downstream operation and fires the oneshot when that slot (plus any
2524    /// FLNK/OUT chain members that `enter` it) drains to zero — i.e. on
2525    /// `dbNotifyCompletion`. Pair with [`Self::reprocess_on_notify`] to
2526    /// re-enter a waiting record when the downstream completes (SSEQ
2527    /// `WAITn`).
2528    pub fn new_put_notify() -> (
2529        Arc<NotifyWaitSet>,
2530        crate::runtime::sync::oneshot::Receiver<()>,
2531    ) {
2532        let (tx, rx) = crate::runtime::sync::oneshot::channel();
2533        (NotifyWaitSet::new(tx), rx)
2534    }
2535
2536    /// Wire a downstream put-notify completion to an async re-entry: spawn a
2537    /// task that awaits `completion` (the oneshot from
2538    /// [`Self::new_put_notify`], fired on `dbNotifyCompletion`) and then
2539    /// `token.fire`s, re-entering the waiting record's `process()`. A
2540    /// superseded / cancelled token re-enters nothing. Returns the spawned
2541    /// task handle; fire-and-forget callers may drop it.
2542    pub fn reprocess_on_notify(
2543        &self,
2544        token: AsyncToken,
2545        completion: crate::runtime::sync::oneshot::Receiver<()>,
2546    ) -> crate::runtime::task::BackgroundTaskHandle<()> {
2547        let prio = self.record_callback_priority(token.record_name());
2548        let db = self.clone();
2549        crate::runtime::task::spawn_background(prio, async move {
2550            // `Err` means the sender was dropped without firing (the
2551            // downstream op vanished); treat it the same as completion so a
2552            // waiting record is never stranded — `fire` is a no-op if the
2553            // token was meanwhile superseded.
2554            let _ = completion.await;
2555            let _ = token.fire(&db).await;
2556        })
2557    }
2558
2559    /// Issue a put-WITH-completion to an OUT link and hand the caller only
2560    /// the completion receiver — the non-blocking sibling of
2561    /// [`Self::reprocess_on_notify`].
2562    ///
2563    /// Each call mints its own put-notify wait-set (C `dbProcessNotify`),
2564    /// writes the link through it with the source record's committed PUTF /
2565    /// alarm propagated (C `recGblInheritSevrMsg`), releases the initiator
2566    /// count, and returns the oneshot that fires on `dbNotifyCompletion`.
2567    /// The caller owns when (and whether) to await each receiver, so several
2568    /// puts can be outstanding at once — unlike
2569    /// [`crate::server::record::ProcessAction::WriteDbLinkNotify`], which wires the completion
2570    /// straight to a single superseding async re-entry token and so allows
2571    /// only one outstanding put per record. This is the seam C
2572    /// `calcApp/src/sseqRecord.c` needs to run multiple `WAITn` put-callbacks
2573    /// concurrently in flight (`processNextLink`).
2574    ///
2575    /// `record_name` is the source whose PUTF/alarm propagate into the
2576    /// target, `link_str` the already-resolved OUT link spelling, `value`
2577    /// the value to write. `None` if the source record is gone; an empty
2578    /// `link_str` returns a receiver that fires immediately (nothing joined
2579    /// the set).
2580    pub async fn put_link_notify(
2581        &self,
2582        record_name: &str,
2583        link_field: &str,
2584        link_str: &str,
2585        value: EpicsValue,
2586    ) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
2587        let rec = {
2588            let records = self.inner.records.read();
2589            records.get(record_name)?.clone()
2590        };
2591        let (src_putf, src_alarm) = {
2592            let instance = rec.read();
2593            // sseq's WAITn puts run from its async machine while the record
2594            // is still PACT — C `sseqRecord.c` issues `dbPutLink` in
2595            // `processCallback` (:734/756/787) and commits the alarm only in
2596            // `asyncFinish` (`recGblResetAlarms`, :471). The put therefore
2597            // inherits the source's PENDING alarm.
2598            (
2599                instance.common.putf,
2600                super::links::LinkAlarm::pending(&instance.common),
2601            )
2602        };
2603        let (waitset, completion) = Self::new_put_notify();
2604        if !link_str.is_empty() {
2605            let parsed = crate::server::record::parse_output_link_v2(link_str);
2606            // Seed the cycle-guard with the source so a target linking back
2607            // does not re-process it, exactly as a top-level OUT-link write
2608            // does (`process_record_with_links_inner` inserts its own name).
2609            let mut visited = ProcStack::new();
2610            visited.claim(&rec);
2611            // Through the put owner: C `dbPutLinkAsync` raises the source's
2612            // LINK_ALARM/INVALID on a failed put exactly as the synchronous
2613            // `dbPutLink` does (dbLink.c:469-471).
2614            self.write_out_link_value(
2615                &rec,
2616                &parsed,
2617                value,
2618                super::links::OutLinkSrc {
2619                    putf: src_putf,
2620                    notify: Some(&waitset),
2621                    alarm: &src_alarm,
2622                    field: link_field,
2623                },
2624                &mut visited,
2625            );
2626        }
2627        // Release the initiator's own count (C `dbProcessNotify` holds one
2628        // count for the requester and drops it after issuing the put). The
2629        // set then drains — firing `completion` — when the downstream
2630        // target(s) that joined via `join_put_notify` finish, or immediately
2631        // when the link was empty / the target completed synchronously.
2632        waitset.leave();
2633        Some(completion)
2634    }
2635
2636    /// aSub LFLG=READ: read the subroutine name from the SUBL link and, when
2637    /// it changed, re-resolve the function from the registry. C
2638    /// `aSubRecord.c::fetch_values`. Returns `None` for any record that is
2639    /// not an aSub in READ mode (the common case), so the caller pays only a
2640    /// single brief read lock. Run BEFORE the process write lock so the SUBL
2641    /// link read cannot deadlock against this record.
2642    fn resolve_asub_dynamic_subroutine(&self, rec: &Arc<RecordCell>) -> Option<AsubDynamicSub> {
2643        let (subl, onam, snam) = {
2644            let inst = rec.read();
2645            if inst.record.record_type() != "aSub" {
2646                return None;
2647            }
2648            // LFLG: IGNORE=0 (static, resolved at init), READ=1 (dynamic).
2649            let lflg = inst
2650                .record
2651                .get_field("LFLG")
2652                .and_then(|v| v.to_f64())
2653                .unwrap_or(0.0) as i16;
2654            if lflg != 1 {
2655                return None;
2656            }
2657            let read_str = |f: &str| match inst.record.get_field(f) {
2658                Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
2659                _ => String::new(),
2660            };
2661            (read_str("SUBL"), read_str("ONAM"), read_str("SNAM"))
2662        };
2663
2664        // C `aSubRecord.c:256`: `dbGetLink(&prec->subl, DBR_STRING,
2665        // prec->snam, 0, 0)` — a plain read into SNAM. A CONSTANT (or unset)
2666        // SUBL delivers NOTHING here, so SNAM keeps the name
2667        // `recGblInitConstantLink(&subl, DBF_STRING, prec->snam)`
2668        // (`aSubRecord.c:126`) loaded at init — which is also what a `caput
2669        // REC.SNAM other` leaves in place.
2670        use crate::server::recgbl::simm::LinkFetch;
2671        let name: Option<String> =
2672            match self.db_get_link(rec, "SUBL", &crate::server::record::parse_link_v2(&subl)) {
2673                LinkFetch::Value(v) => Some(match v {
2674                    EpicsValue::String(s) => s.as_str_lossy().into_owned(),
2675                    o => o.to_f64().map(|f| f.to_string()).unwrap_or_default(),
2676                }),
2677                LinkFetch::NoData => Some(snam),
2678                LinkFetch::Failed => None,
2679            };
2680
2681        let Some(name) = name else {
2682            // Link read failed — C `if (status) return status` skips do_sub.
2683            return Some(AsubDynamicSub {
2684                snam: None,
2685                swap: None,
2686                skip_run: true,
2687            });
2688        };
2689
2690        // Re-resolve only when the name changed (C `strcmp(snam, onam)`); an
2691        // empty name never resolves (do_sub's `snam[0]==0` short-circuit).
2692        if !name.is_empty() && name != onam {
2693            match self.find_subroutine_named(&name) {
2694                Some(f) => Some(AsubDynamicSub {
2695                    snam: Some(name),
2696                    swap: Some(f),
2697                    skip_run: false,
2698                }),
2699                // Name changed but not registered — C returns S_db_BadSub,
2700                // skipping do_sub; ONAM is left unchanged so it retries.
2701                None => Some(AsubDynamicSub {
2702                    snam: Some(name),
2703                    swap: None,
2704                    skip_run: true,
2705                }),
2706            }
2707        } else {
2708            Some(AsubDynamicSub {
2709                snam: Some(name),
2710                swap: None,
2711                skip_run: false,
2712            })
2713        }
2714    }
2715
2716    /// The entry bookkeeping every process entry shares, before the advisory
2717    /// write gate is (or is not) taken: alias normalisation, the `visited`
2718    /// cycle guard and the records-map lookup.
2719    ///
2720    /// Factored out so the gate-taking entry
2721    /// ([`Self::process_record_with_links_inner`]) and the two gate-free
2722    /// entries (`process_record_with_links_body`'s direct callers)
2723    /// run it in the SAME order relative to the gate: bail decisions are made
2724    /// before any waiting, exactly as they were when this was open-coded.
2725    ///
2726    /// `Ok(None)` is "this entry did not run"; `Err` is C's `S_db_notFound`.
2727    ///
2728    /// A non-run is not silent: the cycle guard goes through
2729    /// [`Self::count_refused_active_entry`], which is C's already-active arm,
2730    /// so it cannot be written as a bare `return Ok(None)` here. There is no
2731    /// other non-run — C's `dbProcess` (`dbAccess.c:485`) has no link-depth
2732    /// counter, and neither does the port.
2733    ///
2734    /// Every `Ok(None)` is built by [`Self::entry_did_not_run`], which is
2735    /// also where the put-notify wait-set is released, so a non-run cannot
2736    /// strand a CA `WRITE_NOTIFY`.
2737    fn process_entry_prelude<'a>(
2738        &self,
2739        target: ProcessTarget<'a>,
2740        visited: &mut ProcStack,
2741    ) -> CaResult<Option<(FrameName<'a>, Arc<RecordCell>)>> {
2742        // Normalise to the canonical record name once at entry — both
2743        // for cycle-detection (`visited` would otherwise treat alias
2744        // and canonical as distinct entries) and for the records-map
2745        // lookup below. Mirrors epics-base PR #336.
2746        //
2747        // This is the chain's ONE name resolution: the `Arc` is the records
2748        // map's own key, and every hop below — the cycle guard, the lock set,
2749        // the body — is handed a share of it rather than a copy.
2750        // A sweep walking a scan list already holds the instance the list
2751        // names, so it hands it over rather than paying this resolution again
2752        // per record per cycle. The records map stays the authority on whether
2753        // the record is still IN the database: `remove_record` destroys the
2754        // instance as it takes the key out of the bucket, so a handle that
2755        // outlived its key answers `is_destroyed`; the body checks that under
2756        // the data lock it takes anyway and reports the record missing.
2757        let (name, rec) = match target {
2758            ProcessTarget::Resolved(name, rec) => (FrameName::Borrowed(name), rec),
2759            ProcessTarget::Name(name) => match self.lookup_record(name) {
2760                Some((name, rec)) => (FrameName::Shared(name), rec),
2761                // Nothing is registered under the name — C `S_db_notFound`,
2762                // which C reaches in `dbNameToAddr` before `dbProcess` is
2763                // called at all. Answered BEFORE the marker goes in: this
2764                // frame is not going to run, and a marker left here is one
2765                // the caller's unwind never reaches. An alias whose target
2766                // has gone has always reported the TARGET as missing, so
2767                // resolve it for the message — off the hot path, where the
2768                // answer is an error anyway.
2769                None => {
2770                    let name = match self.resolve_alias(name) {
2771                        Some(target) => target,
2772                        None => name.to_string(),
2773                    };
2774                    return Err(CaError::ChannelNotFound(name));
2775                }
2776            },
2777        };
2778
2779        if !visited.claim(&rec) {
2780            // The name is already on the CURRENT STACK, so this is a genuine
2781            // cycle. C reaches the same decision through PACT: `processTarget`
2782            // forces `psrc->pact = TRUE` before it calls `dbProcess(pdst)`
2783            // (`dbDbLink.c:457`/`:512` at R7.0.10), and a record whose own
2784            // cycle is on the stack has had PACT set by its record support
2785            // anyway, so `dbProcess` takes its already-active arm
2786            // (`dbAccess.c:536-556`). That arm is NOT silent: it counts the
2787            // refused entry in LCNT and, past `MAX_LOCK`, raises
2788            // SCAN_ALARM/INVALID "Async in progress". The port sets PACT only
2789            // for an async defer, so this marker is the synchronous half of
2790            // C's `precord->pact` — and it owes the same arm.
2791            //
2792            // The marker belongs to the OUTER frame — only that frame may
2793            // remove it, which is why this path must not.
2794            //
2795            // Re-reaching a record that has already FINISHED elsewhere in the
2796            // cascade is a different thing entirely and does NOT arrive here:
2797            // its frame took its marker back out on unwind, so the diamond
2798            // processes twice exactly as C's unconditional
2799            // `dbProcess(pdst)` (`dbDbLink.c:512`) does.
2800            self.count_refused_active_entry(&rec);
2801            return self.entry_did_not_run(Some(&rec));
2802        }
2803
2804        Ok(Some((name, rec)))
2805    }
2806
2807    /// C `dbProcess`'s already-active arm (`dbAccess.c:536-556` at R7.0.10)
2808    /// — the ONE place LCNT moves and the ONE place "Async in progress" is
2809    /// raised.
2810    ///
2811    /// C needs one test for "active" because its record support sets
2812    /// `precord->pact = TRUE` at the top of every `process()`, so PACT covers
2813    /// both the async wait and a cycle that is merely on the stack. The port
2814    /// sets PACT only for an async defer ([`RecordInstance::enter_pact`]), so
2815    /// "active" is two tests here: [`RecordInstance::is_processing`] for the
2816    /// async half, and the `visited` marker in
2817    /// [`Self::process_entry_prelude`] for the synchronous half. Two tests,
2818    /// one arm — both call this, so neither can decline more quietly than C.
2819    fn count_refused_active_entry(&self, rec: &Arc<RecordCell>) {
2820        const MAX_LOCK: i16 = 10;
2821        let mut instance = rec.write();
2822
2823        // C `dbAccess.c:539-541` — when TPRO is set on a record whose PACT is
2824        // true, print the diagnostic line before the bail decision. The C path
2825        // emits "%s: dbProcess of Active '%s' with RPRO=%d", mirroring the
2826        // context format the regular trace path uses (thread/client name +
2827        // record name + current RPRO bit). Without this, an operator debugging
2828        // a stuck async record sees NO sign that the entry guard is firing —
2829        // they only notice the eventual SCAN_ALARM after MAX_LOCK=10 attempts.
2830        if instance.common.tpro != 0 {
2831            eprintln!(
2832                "[TPRO] {}: dbProcess of Active '{}' with RPRO={}",
2833                instance.name, instance.name, instance.common.rpro,
2834            );
2835        }
2836
2837        // C `dbAccess.c:544-546`:
2838        //   if ((precord->stat == SCAN_ALARM) ||
2839        //       (precord->lcnt++ < MAX_LOCK) ||
2840        //       (precord->sevr >= INVALID_ALARM)) goto all_done;
2841        // The increment is in the test, so it happens on every refusal.
2842        let already_invalid = instance.common.sevr >= crate::server::record::AlarmSeverity::Invalid;
2843        let already_scan_alarm =
2844            instance.common.stat == crate::server::recgbl::alarm_status::SCAN_ALARM;
2845        let lcnt_before = instance.common.lcnt;
2846        instance.common.lcnt = lcnt_before.saturating_add(1);
2847        if already_scan_alarm || lcnt_before < MAX_LOCK || already_invalid {
2848            return;
2849        }
2850
2851        let snapshot = scan_alarm_refusal(&mut instance, "Async in progress");
2852        drop(instance);
2853        if let Some(snapshot) = snapshot {
2854            // Between the write guard's drop and the read guard's take: the one
2855            // window where a link target's lock is reachable. The refusal posts
2856            // STAT/SEVR/VAL, none of which any type link-backs, but the resolve
2857            // is the record's own answer rather than this caller's claim about
2858            // it — see `RecordInstance::make_monitor_snapshot`.
2859            let backing = self.resolve_link_backed_metadata_for_posts(rec);
2860            let backing = backing.as_link_backing();
2861            let inst = rec.read();
2862            inst.notify_from_snapshot(&snapshot, backing);
2863        }
2864    }
2865
2866    /// The prelude's ONE "this entry did not run its cycle" exit — C
2867    /// `dbProcess`'s `all_done` with `callNotifyCompletion = TRUE`.
2868    ///
2869    /// `join_put_notify` (C `dbNotifyAdd`) is called by the link dispatcher
2870    /// on the will-process branch, *before* the recursion enters the prelude:
2871    ///
2872    /// ```text
2873    /// links.rs:1561   let pact = tg.is_processing();
2874    /// links.rs:1562   if !pact { tg.common.putf = src_putf;
2875    /// links.rs:1564              tg.join_put_notify(src_notify); }   // ws.enter()
2876    /// links.rs:1575   self.process_record_with_links_recursive(target, visited)
2877    /// ```
2878    ///
2879    /// So by the time the cycle guard decides the entry will not run, the
2880    /// target is already counted in the wait-set — and nothing
2881    /// downstream will ever `leave` for it, because the only `leave`s are on
2882    /// paths that ran a cycle. The set never drains, the completion oneshot
2883    /// never fires, and the client's `CA_PROTO_WRITE_NOTIFY` gets no reply
2884    /// (measured on x86_64-wrs-vxworks while the port still refused entries
2885    /// past a 16-hop depth bound: the first put into a longer chain never
2886    /// replied over 90s, and `RTEMS:E8:L16` was left
2887    /// holding a wait-set that could never drain — after which every later put
2888    /// completed, because `join_put_notify`'s `notify.is_none()` guard stops a
2889    /// record that already holds a stale set from joining a live one).
2890    ///
2891    /// C decides this per exit path with one flag and one finalizer
2892    /// (`dbAccess.c:494` `callNotifyCompletion = FALSE`, `:576` disabled,
2893    /// `:598` no RSET, `:619-622` `all_done`), and the pact branch
2894    /// (`:551-555`) deliberately does NOT set it: a record whose own cycle is
2895    /// running owns its completion. The same split holds here — hence the
2896    /// `is_processing` test, which is C's `if (precord->pact)`, not a guard
2897    /// bolted on.
2898    fn entry_did_not_run<'a>(
2899        &self,
2900        rec: Option<&Arc<RecordCell>>,
2901    ) -> CaResult<Option<(FrameName<'a>, Arc<RecordCell>)>> {
2902        if let Some(rec) = rec {
2903            let notify = {
2904                let mut instance = rec.write();
2905                if instance.is_processing() {
2906                    None
2907                } else {
2908                    instance.notify.take()
2909                }
2910            };
2911            // `leave` fires the completion oneshot when it empties the set, so
2912            // it runs outside the record lock — same as the SDIS-disable bail.
2913            if let Some(ws) = notify {
2914                ws.leave();
2915            }
2916        }
2917        Ok(None)
2918    }
2919
2920    /// The gate-taking entry — the ONLY `.await` in the whole H6 chain.
2921    ///
2922    /// Everything after the guard is bound lives in
2923    /// `process_record_with_links_body`, which is a plain `fn`: the
2924    /// L1 gate-held region contains zero suspension points by construction,
2925    /// which is what C's `dbProcess` gives for free (`dbScanLock` is a
2926    /// blocking mutex and the whole cycle between lock and unlock is
2927    /// straight-line C).
2928    async fn process_record_with_links_inner(
2929        &self,
2930        name: &str,
2931        visited: &mut ProcStack,
2932        is_continuation: bool,
2933        acquire_gate: bool,
2934        // This cycle is driven by a driver interrupt callback
2935        // (`asyn:READBACK` / SCAN="I/O Intr" output), not a put/FLNK/scan.
2936        // For an output record it forces the read-back-no-write contract
2937        // (C `devAsynInt32.c::processBo` `newOutputCallbackValue` branch).
2938        // Always `false` for client/FLNK/scan entries.
2939        device_callback: bool,
2940    ) -> CaResult<()> {
2941        self.run_process_frame(
2942            ProcessTarget::Name(name),
2943            visited,
2944            acquire_gate,
2945            is_continuation,
2946            device_callback,
2947        )
2948    }
2949
2950    /// C `dbGetTimeStampTag` (`dbLink.c:420-432`) — the single owner of "read
2951    /// a link's source timestamp", dispatched to the target's lset.
2952    /// `dbDbGetTimeStampTag` (`dbDbLink.c`) copies the source record's `time`
2953    /// and `utag`; the CA lset answers from its cached monitor and the CA wire
2954    /// carries no userTag, so it contributes 0.
2955    ///
2956    /// The tag is always returned; C's callers differ only in whether they ask
2957    /// for it. `recGbl.c:317` passes `&prec->utag`, while every `std/dev` soft
2958    /// input dset reaches this through the `dbGetTimeStamp` macro
2959    /// (`dbLink.c:415-418`), which passes NULL — so those callers DROP the tag,
2960    /// and this port drops it at the same call sites C does.
2961    ///
2962    /// `None` is C's non-zero return (`S_db_noLSET`, or an unresolvable
2963    /// target). A `pvalink` is deliberately absent: pvxs gates its lset's
2964    /// timestamp behind the link's own `time=true` option, which reaches the
2965    /// record through [`Self::external_link_time`] instead.
2966    fn db_get_time_stamp_tag(
2967        &self,
2968        link: &crate::server::record::ParsedLink,
2969    ) -> Option<(std::time::SystemTime, u64)> {
2970        match link {
2971            crate::server::record::ParsedLink::Db(l) => {
2972                self.record_time_stamp_tag(&l.target().record)
2973            }
2974            crate::server::record::ParsedLink::Ca(ca) => self
2975                .external_link_time(&format!("ca://{}", ca.pv))
2976                .map(ext_time_pair),
2977            // `lnkCalc_getTimestampTag` (`lnkCalc.c:749-762`) answers from
2978            // `clink->time`/`clink->utag`, and the only thing that ever fills
2979            // those is `lnkCalc_getValue`/`lnkCalc_putValue` reading the
2980            // `time:"X"` input through `dbGetTimeStampTag` on that child link
2981            // (`:571-576`, `:651-656`). A calc link's timestamp is therefore
2982            // its time-input's, resolved by the same locality rule as any
2983            // other link — which is why this recurses into the owner instead
2984            // of re-deriving it. `tinp < 0` (no `time` key) is C's `return
2985            // -1` at `:761`.
2986            //
2987            // C caches the pair on the link at read time and answers later
2988            // reads from that cache; this port holds no per-link state, so it
2989            // resolves the source live. The two differ only when the source
2990            // is restamped between the calc read and the timestamp fetch —
2991            // microseconds apart inside one `process_record_with_links_body`.
2992            crate::server::record::ParsedLink::Calc(calc) => {
2993                let idx = (calc.time_source? as u8 - b'A') as usize;
2994                let arg = calc.args.get(idx)?;
2995                // `args[i]` names a record only when it is a link; a numeric
2996                // literal has no timestamp to adopt, and C's `readLocked`
2997                // runs it against a zeroed child link, leaving `clink->time`
2998                // at its `calloc` zero (`lnkCalc.c:571-575`). The `.FIELD`
2999                // suffix is stripped because the timestamp belongs to the
3000                // RECORD either way, as `dbDbGetTimeStampTag`
3001                // (`dbDbLink.c:362-370`) reads `dbChannelRecord(chan)->time`
3002                // and not the addressed field's.
3003                let record = Self::calc_time_source_record(arg)?;
3004                self.record_time_stamp_tag(&record)
3005            }
3006            _ => None,
3007        }
3008    }
3009
3010    /// The locality half of [`Self::db_get_time_stamp_tag`], shared by every
3011    /// link class that names a record: `dbInitLink` (`dbLink.c:115-130`)
3012    /// makes a DB-style link naming a record this IOC does not hold a CA
3013    /// link, so its timestamp comes from the CA lset's cached monitor and
3014    /// carries no userTag.
3015    fn record_time_stamp_tag(&self, record: &str) -> Option<(std::time::SystemTime, u64)> {
3016        match self.link_target(record) {
3017            super::links::LinkTarget::Local(src) => {
3018                let g = src.read();
3019                Some((g.common.time, g.common.utag))
3020            }
3021            super::links::LinkTarget::LocalNotRecord => None,
3022            super::links::LinkTarget::External => self
3023                .external_link_time(&format!("ca://{record}"))
3024                .map(ext_time_pair),
3025        }
3026    }
3027
3028    /// C `recGblGetTimeStampSimm`'s TSEL half (`recGbl.c:315-323`): read the
3029    /// record's `TSEL` link as the `.TIME` form (`TIME`/`UTAG`) or as a `TSE`
3030    /// source (every other form).
3031    ///
3032    /// Reads only — the store and the `TSE`→`TIME` lookup that follows it in C
3033    /// are `TselStamp::stamp`, which cannot be reached without the value this
3034    /// returns. Call it at the record's stamp point, not at the head of the
3035    /// cycle: C reads `TSEL` inside `recGblGetTimeStamp`, so a `.TIME` TSEL
3036    /// sees whatever the cycle has already done to its source — `calcRecord.c`
3037    /// runs `fetch_values` (`:120`) before the stamp (`:127`), so an `INPn PP`
3038    /// that reprocessed the TSEL source moves the stamp this record adopts.
3039    /// The link read takes its own locks (a failed `dbGetLink` writes
3040    /// `LINK_ALARM` into this record), so it must not run under the caller's
3041    /// data guard — which is the whole reason C's single function is two here.
3042    fn read_tsel(&self, rec: &Arc<RecordCell>) -> super::TselStamp {
3043        match Self::tsel_link(&rec.read()) {
3044            None => super::TselStamp::None,
3045            Some(link) => self.read_tsel_link(rec, link),
3046        }
3047    }
3048
3049    /// The TSEL link this cycle has to read — `None` for a constant TSEL,
3050    /// decided under whatever guard the caller already holds. C `recGbl.c:315`
3051    /// wraps the whole TSEL read in `if (!dbLinkIsConstant(plink))`: a constant
3052    /// or unset TSEL is skipped outright and TSE keeps its own value.
3053    fn tsel_link(instance: &RecordInstance) -> Option<crate::server::record::ParsedLink> {
3054        (!crate::server::recgbl::simm::is_constant(&instance.parsed_tsel))
3055            .then(|| instance.parsed_tsel.clone())
3056    }
3057
3058    /// The link half of [`Self::read_tsel`]. Takes other records' locks, so
3059    /// the caller's data guard must be released first.
3060    fn read_tsel_link(
3061        &self,
3062        rec: &Arc<RecordCell>,
3063        tsel_link: crate::server::record::ParsedLink,
3064    ) -> super::TselStamp {
3065        // A TSEL link pointing at a `.TIME` field copies that record's
3066        // timestamp+utag into `time`/`utag`, and the TSE→TIME half does not
3067        // run at all — C returns before it, leaving TSE alone.
3068        // C `TSEL_modified`
3069        // (dbLink.c:71-87) sets `DBLINK_FLAG_TSELisTIME` for ANY
3070        // `PV_LINK` tsel whose pvname contains `.TIME`, set BEFORE the
3071        // DB-vs-CA decision (dbLink.c:118) — so a local-DB link AND a
3072        // CA link both qualify. `recGblGetTimeStampSimm`
3073        // (recGbl.c:316-321) then copies the link's time+utag via
3074        // `dbGetTimeStampTag` and RETURNS, never loading TSE from the
3075        // value (even when the read fails). A pva link is a
3076        // `JSON_LINK` and returns early from `dbInitLink`
3077        // (dbLink.c:107) before `TSEL_modified`, so C never flags it;
3078        // pva TSEL `.TIME` is intentionally excluded here.
3079        //
3080        // The field comes from the SPLIT, not from the link's raw halves: C
3081        // truncates the pvname at `.TIME` (`strstr` then `*pfieldname = 0`,
3082        // dbLink.c:81-85), so `TSEL="SRC.TIME[0]"` is flagged TSELisTIME and
3083        // the filter is discarded with the rest of the tail. The raw halves
3084        // leave that link as record `SRC.TIME[0]` with field `VAL`, which is
3085        // neither `.TIME` nor a record — the flag was never set and the
3086        // record stamped itself.
3087        let tsel_is_time = match &tsel_link {
3088            crate::server::record::ParsedLink::Db(link) => {
3089                link.target().field.eq_ignore_ascii_case("TIME")
3090            }
3091            crate::server::record::ParsedLink::Ca(ca) => ca_tsel_time_record(&ca.pv).is_some(),
3092            _ => false,
3093        };
3094        if tsel_is_time {
3095            // C `dbGetTimeStampTag(plink, &prec->time, &prec->utag)`
3096            // (recGbl.c:317) copies BOTH the link's time AND utag —
3097            // through the owner, which returns the pair as one
3098            // consistent snapshot of the source.
3099            //
3100            // `TSEL_modified` strips `.TIME` from the pvname BEFORE the
3101            // DB-vs-CA decision (dbLink.c:115-118), so the link the
3102            // owner reads is the one addressing the source RECORD, not
3103            // its `.TIME` field.
3104            let src_time = match &tsel_link {
3105                crate::server::record::ParsedLink::Db(_) => self.db_get_time_stamp_tag(&tsel_link),
3106                crate::server::record::ParsedLink::Ca(ca) => match ca_tsel_time_record(&ca.pv) {
3107                    Some(rec_name) => self.db_get_time_stamp_tag(
3108                        &crate::server::record::ParsedLink::Ca(crate::server::record::CaLink {
3109                            pv: rec_name.to_string(),
3110                            ..ca.clone()
3111                        }),
3112                    ),
3113                    None => None,
3114                },
3115                _ => None,
3116            };
3117            // C returns after the TSELisTIME branch even when the read
3118            // fails (recGbl.c:317-320): keep the record's current time
3119            // rather than falling through to load TSE from the value.
3120            match src_time {
3121                Some((src_time, src_utag)) => super::TselStamp::Time(src_time, src_utag),
3122                None => super::TselStamp::None,
3123            }
3124        } else if let Some(val) = self.db_get_link(rec, "TSEL", &tsel_link).value() {
3125            // Non-`.TIME` TSEL: C `dbGetLink(&tsel, DBR_SHORT,
3126            // &prec->tse)` loads TSE from the link regardless of its
3127            // type. The pre-fix port only read a `ParsedLink::Db`
3128            // TSEL, ignoring a CA/PVA TSE source — and then over-corrected
3129            // by handing back a CONSTANT TSEL's text every cycle, which C
3130            // never does: `recGblGetTimeStampSimm` (`recGbl.c:315`) is
3131            // wrapped in `if (!dbLinkIsConstant(plink))`, so a constant
3132            // TSEL is skipped outright and TSE keeps its own value. Through the
3133            // coercion owner: the conversion routine is C's, chosen by the
3134            // SOURCE type (see the DISA read above).
3135            super::TselStamp::Tse(val.to_dbf_i16().unwrap_or(0))
3136        } else {
3137            super::TselStamp::None
3138        }
3139    }
3140
3141    /// C `recGblGetTimeStamp` (`recGbl.c:305-308`) in full — the TSEL read
3142    /// followed by the TSE→TIME event lookup, for a soft record.
3143    ///
3144    /// The pair is spelled out at each stamp point that has its own data guard
3145    /// open; this is the entry for the callers that do not — `seq`, whose C
3146    /// `process` calls `recGblGetTimeStamp` once per link group
3147    /// (`seqRecord.c:261`).
3148    pub(crate) fn rec_gbl_get_time_stamp(&self, rec: &Arc<RecordCell>) {
3149        let tsel = self.read_tsel(rec);
3150        let mut instance = rec.write();
3151        let inst = &mut *instance;
3152        tsel.stamp(&inst.name, &mut inst.common, /* is_soft */ true);
3153    }
3154
3155    /// The text every link in [`Record::multi_input_links`](crate::server::record::Record::multi_input_links) held when this
3156    /// process cycle started.
3157    ///
3158    /// One read serves both consumers. A by-name field read is a linear search
3159    /// of the record type's declared names — around ninety on a calc — and the
3160    /// cycle asked for the same twelve `INPA`..`INPL` twice: once at the top,
3161    /// to resolve link-backed metadata for the monitor posters, and again in
3162    /// the multi-input fetch. Reading once also removes the window in which
3163    /// the two answers could disagree, since neither read holds the record
3164    /// across the cycle.
3165    fn read_input_link_texts(instance: &RecordInstance) -> InputLinkTexts {
3166        InputLinkTexts::read_own(instance)
3167    }
3168
3169    /// The process cycle's input stage — C's `dbGetLink` calls before the
3170    /// record body: the soft INP, the closed-loop DOL, the multi-input and
3171    /// string-input arrays, `sel`'s NVL. Runs with no record lock held, since
3172    /// every read takes the SOURCE's lock.
3173    ///
3174    /// The one guard it takes is for the two per-cycle hooks the record owes
3175    /// whatever its links say — the process-context push and
3176    /// `pre_input_link_actions`, which `compress` and `scalcout` use to reset
3177    /// cycle state — and for the facts that decide whether there is anything
3178    /// to read at all. A stock database wires none of a `calc`'s inputs, and
3179    /// that cycle used to walk the whole stage to learn it: the empty INP read
3180    /// through three classifiers, twelve slots asked for a text that was
3181    /// never set. It now gets [`InputStage::none`] from inside that guard.
3182    fn fetch_input_stage(
3183        &self,
3184        name: &str,
3185        guard: &mut DataGuard<'_>,
3186        plan: &crate::server::record::record_instance::ProcessPlan,
3187        input_link_texts: &InputLinkTexts,
3188        visited: &mut ProcStack,
3189    ) -> InputStage {
3190        let rec = guard.rec;
3191        let shape = {
3192            let instance = guard.hold();
3193
3194            let is_soft = instance.common.dtyp.is_soft();
3195
3196            // C `vt.ptime = (dbLinkIsConstant(&prec->tsel) &&
3197            // prec->tse == epicsTimeEventDeviceTime) ? &prec->time : NULL`
3198            // — `devAiSoft.c:73-74`, and byte-for-byte the same in every one of
3199            // the 23 soft input dsets. TSE=-2 says "the device stamps this
3200            // record", and for a soft channel the device IS the INP link, so
3201            // `recGblGetTimeStampSimm` (recGbl.c:324-342) deliberately leaves
3202            // `time` alone and the dset is the only thing that fills it.
3203            //
3204            // The TSEL half is read here, ahead of the stamp point where
3205            // `read_tsel` runs, for the reason C can read it before
3206            // `recGblGetTimeStampSimm` does: this tests only whether the link
3207            // is CONSTANT, and a CONSTANT tsel is never loaded into TSE by
3208            // either — `recGbl.c:315` gates the `dbGetLink` on
3209            // `!dbLinkIsConstant` — so the two orders cannot disagree.
3210            let wants_source_time = instance.common.tse == -2
3211                && crate::server::recgbl::simm::is_constant(&instance.parsed_tsel);
3212
3213            // DOL link info for the records that perform C's SCALAR
3214            // closed-loop DOL fetch. Which records those are is
3215            // `Record::fetches_dol_closed_loop`, whose doc carries the C
3216            // citations and names the OMSL-bearing records that answer false.
3217            let dol = if plan.fetches_dol_closed_loop {
3218                let omsl = instance
3219                    .record
3220                    .get_field("OMSL")
3221                    .and_then(|v| v.to_menu_index())
3222                    .unwrap_or(0);
3223                let oif = instance
3224                    .record
3225                    .get_field("OIF")
3226                    .and_then(|v| v.to_menu_index())
3227                    .unwrap_or(0);
3228                if omsl == 1 {
3229                    let dol_parsed = instance
3230                        .record
3231                        .get_field("DOL")
3232                        .and_then(|v| {
3233                            if let EpicsValue::String(s) = v {
3234                                Some(s)
3235                            } else {
3236                                None
3237                            }
3238                        })
3239                        .map(|s| crate::server::record::parse_link_v2(s.as_str_lossy().as_ref()))
3240                        .unwrap_or(crate::server::record::ParsedLink::None);
3241                    // C `!dbLinkIsConstant(&prec->dol)` gates the per-cycle
3242                    // DOL fetch in every OMSL record (e.g.
3243                    // `aoRecord.c:181`, `boRecord.c:192`,
3244                    // `dfanoutRecord.c:117`): a *constant* DOL is applied to
3245                    // VAL exactly once at init via `recGblInitConstantLink`
3246                    // and never re-sourced at process — so a client caput to
3247                    // VAL is not clobbered every cycle. Only a real
3248                    // (DB/CA/PVA) link is fetched here. The per-record init
3249                    // application lives in each record's `init_record`.
3250                    if matches!(dol_parsed, crate::server::record::ParsedLink::Constant(_)) {
3251                        None
3252                    } else {
3253                        Some((dol_parsed, oif))
3254                    }
3255                } else {
3256                    None
3257                }
3258            } else {
3259                None
3260            };
3261
3262            // The pre-input stage's own two asks, under the same guard: C
3263            // hands a record its `dbCommon` context for free, and the port's
3264            // hook plus `pre_input_link_actions` were taking an acquisition of
3265            // their own immediately after this one for a list that is empty on
3266            // all but compress, histogram, scalcout, sseq and waveform.
3267            let inst = &mut *instance;
3268            let ctx = inst.common.process_context();
3269            inst.record.set_process_context(&ctx);
3270            let pre_input_actions = instance.record.pre_input_link_actions();
3271
3272            // Everything the stage below could read is unset: the answer C's
3273            // `dbConstGetValue` gives twelve times over, taken once, and
3274            // taken before INP is cloned out of the guard — the clone is the
3275            // fetch's to own once the guard is released. The type-static
3276            // halves come off the plan; the per-instance halves were read
3277            // under this guard.
3278            let only_own_inputs = crate::server::recgbl::simm::is_constant(&instance.parsed_inp)
3279                && dol.is_none()
3280                && pre_input_actions.is_empty()
3281                && !plan.string_input
3282                && !plan.sel_nvl
3283                && !plan.resolves_subroutine_from_link;
3284            if only_own_inputs && input_link_texts.none_set() {
3285                instance.record.set_fetch_gate_failed(false);
3286                return InputStage::none(is_soft, 0);
3287            }
3288            // The cycle whose only reads are the record's own input links,
3289            // each at its own type — a wired `calc` — is the multi-input
3290            // loop alone: no INP or DOL to clone out and read, no deferred
3291            // delivery for the body's hold. It runs below in that shape,
3292            // under the guard this block holds.
3293            if only_own_inputs && plan.multi_inputs_read_native && !plan.narrows_input_links {
3294                Err(is_soft)
3295            } else {
3296                // A constant (or unset) INP is no read — C `dbConstGetValue`
3297                // returns 0 without touching the buffer — so nothing below
3298                // asks it: not the soft read, not the source alarm, not a
3299                // remote time. `None` says so once, instead of each reader
3300                // finding out.
3301                let inp = (!crate::server::recgbl::simm::is_constant(&instance.parsed_inp))
3302                    .then(|| instance.parsed_inp.clone());
3303                Ok((inp, is_soft, wants_source_time, dol, pre_input_actions))
3304            }
3305        };
3306        let (inp, is_soft, wants_source_time, dol_info, pre_input_actions) = match shape {
3307            Ok(general) => general,
3308            Err(is_soft) => {
3309                let resolved = self.fetch_own_native_inputs(guard, plan, input_link_texts, visited);
3310                return InputStage::none(is_soft, resolved);
3311            }
3312        };
3313        // The reads between here and the multi-input loop — pre-input
3314        // actions, INP, DOL, NVL — go through the by-name link readers, which
3315        // take the record's lock themselves; the loop takes the guard back.
3316        if inp.is_some() || dol_info.is_some() || plan.sel_nvl || !pre_input_actions.is_empty() {
3317            guard.release();
3318        }
3319
3320        // 1.1. Pre-input-link actions: actions a record needs the
3321        // framework to execute BEFORE any input-link fetch this cycle.
3322        //
3323        // C `devEpidSoftCallback.c:120-151`: a DB-type readback-trigger
3324        // (TRIG) link is written with `dbPutLink` — which synchronously
3325        // processes the triggered source — and only then does
3326        // `dbGetLink(&pepid->inp, ...)` read CVAL. The trigger write
3327        // must land before the `INP -> CVAL` fetch, in the same pass.
3328        // `pre_process_actions` runs too late (after the input-link
3329        // fetch below), so `pre_input_link_actions` is a strictly
3330        // earlier hook. The record needs `dtyp` to decide whether the
3331        // callback DSET is active, so push the process context first.
3332        //
3333        // The ReadDbLink actions of this stage go through the reporting owner
3334        // (`execute_read_db_links`), not the fire-and-forget one: a failed read
3335        // here is a `dbGetLink` failure like any other, and the record must be
3336        // able to see it. C `aaoRecord.c::process` (167-168) aborts the whole
3337        // cycle when its closed-loop DOL fetch fails —
3338        // `if ((status = fetchValue(prec, 0))) return status;` returns BEFORE
3339        // `writeValue`, `monitor` and `recGblFwdLink` — which it can only do
3340        // because `fetchValue`'s `dbGetLink` status reaches it. Discarding the
3341        // outcome (as this stage did) let a dead DOL write a stale VAL to OUT,
3342        // post monitors and fire the forward link, every cycle, with no alarm.
3343        let mut pre_input_resolved: Vec<&'static str> = Vec::new();
3344        {
3345            if !pre_input_actions.is_empty() {
3346                let (reads, others): (Vec<_>, Vec<_>) =
3347                    pre_input_actions.into_iter().partition(|a| {
3348                        matches!(a, crate::server::record::ProcessAction::ReadDbLink { .. })
3349                    });
3350                if !reads.is_empty() {
3351                    pre_input_resolved = self.execute_read_db_links(name, rec, &reads, visited);
3352                }
3353                if !others.is_empty() {
3354                    self.execute_process_actions(name, rec, others, visited);
3355                }
3356            }
3357        }
3358
3359        // Read INP value, converted to the record's declared `dbrType`
3360        // request (stringin/lsi ask for `DBR_STRING`/`dbGetLinkLS` —
3361        // `devSiSoft.c:53`, `devLsiSoft.c:32` — so an ENUM/MENU source
3362        // delivers its state label, not the index).
3363        let inp_value = inp.as_ref().and_then(|inp_parsed| {
3364            self.read_link_value_soft(inp_parsed, is_soft, visited)
3365                .and_then(|v| self.typed_input_value(rec, "INP", inp_parsed, v))
3366        });
3367
3368        // C `readLocked` (`devAiSoft.c:54-63`): the same `dbLinkDoLocked` that
3369        // read the value reads the source's timestamp, under the source's lock
3370        // and gated on the read having succeeded — `if (!status && pvt->ptime)
3371        // dbGetTimeStamp(pinp, pvt->ptime)`. The tag half is dropped because
3372        // `dbGetTimeStamp` passes NULL for it (`dbLink.c:415-418`).
3373        //
3374        // A `lnkCalc` INP is the one class where the tag DOES arrive: the
3375        // adoption is not the dset's at all but the link's own, and
3376        // `lnkCalc_getValue` writes `prec->time` AND `prec->utag`
3377        // (`lnkCalc.c:580-581`) under the identical `dbLinkIsConstant(&prec
3378        // ->tsel) && prec->tse == epicsTimeEventDeviceTime` gate that
3379        // `wants_source_time` already carries. So the pair the owner returns
3380        // is adopted whole for a calc link and time-only otherwise.
3381        let (inp_source_time, inp_source_utag): (Option<std::time::SystemTime>, Option<u64>) =
3382            if let Some(inp_parsed) = inp.as_ref()
3383                && is_soft
3384                && wants_source_time
3385                && inp_value.is_some()
3386            {
3387                match self.db_get_time_stamp_tag(inp_parsed) {
3388                    Some((t, tag))
3389                        if matches!(inp_parsed, crate::server::record::ParsedLink::Calc(_)) =>
3390                    {
3391                        (Some(t), Some(tag))
3392                    }
3393                    Some((t, _tag)) => (Some(t), None),
3394                    None => (None, None),
3395                }
3396            } else {
3397                (None, None)
3398            };
3399
3400        // epics-base PR #d0cf47c: single-INP MS-class link must also
3401        // propagate the source record's STAT/SEVR/AMSG just like the
3402        // multi-input fetch loop below does. Previously the INPA..L
3403        // path (calc/sub/aSub/sel) propagated alarms but plain single
3404        // INP (ai/bi/longin/mbbi/stringin) silently dropped them —
3405        // downstream MSS readers saw NoAlarm even when the source was
3406        // INVALID. Only fires for soft-channel records: hardware-driver
3407        // alarms travel through device-support's own last_alarm path.
3408        //
3409        // B2: a soft INP that is an external `pva://` / `ca://` link
3410        // also propagates the lset's alarm. The link string carries
3411        // no `MonitorSwitch` (the `?sevr=MS` modifier is stripped by
3412        // the parser before epics-base-rs sees it), so the lset has
3413        // already applied the MS/NMS/MSI gate — a `Some` LinkAlarm
3414        // here is one the lset decided to propagate. We fold it in as
3415        // `MaximizeStatus` so the gated severity AND message both
3416        // reach `LINK_ALARM`, matching `pvxs/ioc/pvalink_lset.cpp`
3417        // `recGblSetSevrMsg`.
3418        let inp_link_alarm: Option<(
3419            crate::server::record::MonitorSwitch,
3420            super::links::LinkAlarm,
3421        )> = if let Some(inp_parsed) = inp.as_ref()
3422            && is_soft
3423        {
3424            let (_v, alarm) = self.read_link_with_alarm(inp_parsed);
3425            self.input_link_inheritance(rec, inp_parsed, alarm)
3426        } else {
3427            None
3428        };
3429
3430        // if the single-INP link is an external `pva://` /
3431        // `ca://` link configured with `time=true`, the lset returns
3432        // the latched upstream NT timestamp here and we adopt it
3433        // into the owning record's `common.time` and `common.utag`. The
3434        // lset gates the option internally (returns `None` unless
3435        // `time=true`), so a bare connected link without the flag still
3436        // produces local processing time. Mirrors pvxs
3437        // `pvxs/ioc/pvalink_lset.cpp:577-593`.
3438        let inp_link_remote_time: Option<(i64, i32, u64)> = inp
3439            .as_ref()
3440            .and_then(|inp_parsed| inp_parsed.external_pv_name())
3441            .and_then(|name| self.external_link_time(&name));
3442
3443        // Read DOL value. Through the input-fetch owner, so C's
3444        // `dbDbGetValue` inheritance tail runs on it like every other
3445        // process-time read: `field(DOL,"SRC MS")` on an OMSL=closed_loop
3446        // ao/bo/dfanout raises the READER to the source's severity
3447        // (softIoc: SRC in MAJOR -> A1 SEVR MAJOR, STAT LINK). A constant DOL
3448        // never reaches here (`dol_info` excludes it — the constant is seeded
3449        // once at init), so the PP-aware fetch is the right one.
3450        //
3451        // The three outcomes stay APART here. C's DOL read is a `dbGetLink`
3452        // whose non-zero status has effects beyond "no value arrived":
3453        // `setLinkAlarm` raises LINK/INVALID (owned by `db_get_input_link`),
3454        // and every OMSL record then gates its own body on the status —
3455        // `if(!status) convert(prec, value)` (aoRecord.c:188,
3456        // longoutRecord.c:155, int64outRecord.c:146) or `goto CONTINUE`
3457        // (mbboRecord.c:206, mbboDirectRecord.c:186). Collapsing `Failed` into
3458        // "no value" with `LinkFetch::value()` dropped BOTH: a dead DOL left
3459        // the client's last `caput` sitting in VAL, ran the forward convert on
3460        // it, and drove it to the output with no alarm at all.
3461        let dol_fetch: Option<crate::server::recgbl::simm::LinkFetch> =
3462            dol_info.as_ref().map(|(dol_parsed, _oif)| {
3463                // Converted to the record's declared request: stringout reads
3464                // DOL with `DBR_STRING` (`stringoutRecord.c:141`), lso via
3465                // `dbGetLinkLS` (`lsoRecord.c:114`) — an ENUM/MENU DOL source
3466                // delivers its label, not the index.
3467                let fetch = self.db_get_input_link(rec, "DOL", dol_parsed, visited);
3468                self.convert_link_fetch(rec, "DOL", dol_parsed, fetch).0
3469            });
3470        // C's `if (status)` on the closed-loop DOL read, read twice below: once
3471        // by the record's own failure arm at the DOL-apply site, once by the
3472        // timestamp gate (mbbo/mbboDirect's `goto CONTINUE` jumps past
3473        // `recGblGetTimeStampSimm`, mbboRecord.c:221).
3474        let dol_read_failed = matches!(
3475            dol_fetch,
3476            Some(crate::server::recgbl::simm::LinkFetch::Failed)
3477        );
3478
3479        // 1.45. Sel NVL link: resolve NVL -> SELN BEFORE the input fetch.
3480        // C `selRecord.c::fetch_values` reads NVL into SELN first, then in
3481        // `Specified` mode fetches ONLY INP[SELN] (lines 421-432) — the
3482        // non-selected inputs are never read. Resolving the selector here
3483        // (rather than after the fetch) lets `select_input_links` restrict
3484        // the fetch list, so non-selected links raise no monitors and no
3485        // spurious link-alarm SEVR.
3486        // A CONSTANT NVL is not a failed read: C `selRecord.c:99` seeds SELN
3487        // from it once at init (`recGblInitConstantLink(&nvl, DBF_USHORT,
3488        // &seln)`) and `dbGetLink` then delivers nothing every cycle, so
3489        // `fetch_values` succeeds and `do_sel` runs on the seeded SELN.
3490        let mut sel_nvl_read_failed = false;
3491        let sel_nvl_value: Option<EpicsValue> = if !plan.sel_nvl {
3492            None
3493        } else {
3494            // Extract the NVL link spec under a scoped read guard, releasing it
3495            // (the parking_lot guard is !Send) before the async input fetch.
3496            let nvl_str = {
3497                let instance = rec.read();
3498                // C reads NVL ONLY in `Specified` mode: the `dbGetLink(&nvl,
3499                // ...)` at `selRecord.c:423` sits inside `if (prec->selm ==
3500                // selSELM_Specified)` and the all-inputs loop below it never
3501                // touches the link. So in High/Low/Median a dead NVL processes
3502                // no PP source and raises no `setLinkAlarm`, and SELN keeps
3503                // its value.
3504                if instance.record.record_type() == "sel"
3505                    && matches!(instance.record.get_field("SELM"), Some(EpicsValue::Enum(0)))
3506                {
3507                    instance
3508                        .record
3509                        .get_field("NVL")
3510                        .and_then(|v| {
3511                            if let EpicsValue::String(s) = v {
3512                                Some(s)
3513                            } else {
3514                                None
3515                            }
3516                        })
3517                        .unwrap_or_default()
3518                } else {
3519                    Default::default()
3520                }
3521            };
3522            if !nvl_str.is_empty() {
3523                let parsed = crate::server::record::parse_link_v2(nvl_str.as_str_lossy().as_ref());
3524                let fetch = self.db_get_input_link(rec, "NVL", &parsed, visited);
3525                sel_nvl_read_failed = !fetch.is_ok();
3526                fetch.value()
3527            } else {
3528                None
3529            }
3530        };
3531        // Selector index for `select_input_links`: the freshly-resolved NVL
3532        // value when present, else `None` (the hook falls back to the
3533        // record's current SELN).
3534        let sel_selector: Option<u16> = sel_nvl_value
3535            .as_ref()
3536            .and_then(|v| v.get_convert_f64())
3537            .map(|f| f as u16);
3538
3539        // 1.5. Multi-input link fetch (calc/calcout/sel/sub)
3540        // C's `fetch_values` runs inside `process()`, under the record lock
3541        // it entered with, and each `dbGetLink` writes its result straight
3542        // into the record (`calcRecord.c:434`). The loop below does the
3543        // same: it holds the guard across a read whose target is another
3544        // record — the lock set is shared, so the target's lock is a
3545        // re-entry — and gives it up only for a read that could reach this
3546        // record's data again: a self-link, a `PP` source to process first,
3547        // a link with no local target. Each result is delivered, and its
3548        // alarm inherited, before the next link is read, as `dbGetLink` does.
3549        //
3550        // Link fields whose fetch actually produced a value this cycle —
3551        // pushed to the record via `set_resolved_input_links` so its
3552        // `process()` can observe link-fetch success (C
3553        // `RTN_SUCCESS(dbGetLink(...))`). ONE list per cycle, covering every
3554        // framework-run input read: the pre-input stage (aao DOL, sseq SELL),
3555        // the `multi_input_links` fetch, and the pre-process ReadDbLink reads.
3556        // Built only for a type that reads it.
3557        let resolved_link_fields = pre_input_resolved;
3558        let mut fold = FetchFold::default();
3559        {
3560            // The shape questions — what a failed read means
3561            // (`input_fetch_policy`), whether a constant delivers at process
3562            // (`printf` alone), and whether the fetch is C's `dbGetLink` —
3563            // are settled when the type is compiled, so the cycle carries
3564            // them rather than asking the record.
3565            let input_fetch_policy = plan.input_fetch_policy;
3566            let instance = guard.hold();
3567            // Restrict to the record's active inputs this cycle (sel
3568            // `Specified` → only INP[SELN]); `None` = fetch every link, which
3569            // is every record type but `sel` / `swait` and every pass of them
3570            // that does not narrow. That unrestricted case is what the cycle
3571            // pre-read at its top — see `read_input_link_texts` — so it is
3572            // taken here rather than read a second time. A restriction that
3573            // selects nothing is still a restriction: the `Option`, not the
3574            // emptiness, says whether the record narrowed its inputs this
3575            // pass.
3576            let restricted: Option<InputLinkTexts> = if plan.narrows_input_links {
3577                instance
3578                    .record
3579                    .select_input_links(sel_selector)
3580                    .map(|subset| input_link_texts.read_narrowed(instance, subset))
3581            } else {
3582                None
3583            };
3584            let link_texts = restricted.as_ref().unwrap_or(input_link_texts);
3585            let declared = link_texts.links();
3586            // The record's cache is indexed by its OWN list; a narrowed list
3587            // maps each of its slots back by name.
3588            let own = link_texts.own();
3589            let own_list = std::ptr::eq(declared, own);
3590            // Over the set links only: C's loop visits every declared link,
3591            // but an unset one is a `dbConstGetValue` success with nothing to
3592            // deliver, so the passes it would make here are no-ops.
3593            let mut wired = link_texts.wired();
3594            while wired != 0 {
3595                let slot = wired.trailing_zeros() as usize;
3596                wired &= wired - 1;
3597                let (link_field, val_field) = declared[slot];
3598                let cache_slot = if own_list {
3599                    Some(slot)
3600                } else {
3601                    own.iter().position(|(lf, _)| *lf == link_field)
3602                };
3603                debug_assert!(
3604                    cache_slot.is_some(),
3605                    "select_input_links must narrow to a subset of multi_input_links"
3606                );
3607                let Some(cache_slot) = cache_slot else {
3608                    continue;
3609                };
3610                debug_assert_eq!(
3611                    own[cache_slot],
3612                    (link_field, val_field),
3613                    "a narrowed link is the same pair as its multi_input_links entry"
3614                );
3615                let Some(outcome) = self.fetch_multi_input(guard, plan, own, cache_slot, visited)
3616                else {
3617                    continue;
3618                };
3619                if fold.note(
3620                    input_fetch_policy,
3621                    cache_slot,
3622                    slot + 1 == declared.len(),
3623                    outcome,
3624                ) {
3625                    break;
3626                }
3627            }
3628
3629            // C `selRecord.c::fetch_values` returns the status of its LAST
3630            // `dbGetLink` (`:434-437` assigns `status` unguarded every pass),
3631            // and `process` (`:114-116`) gates `do_sel` on it in EVERY mode.
3632            // The gate is "the last link read FAILED" — never "a link
3633            // delivered no value": `dbGetLink` on an unset OR constant link
3634            // returns success (`dbConstGetValue`), and the field it would have
3635            // written keeps its init-seeded value, which flows into `do_sel`.
3636            // `Specified` mode returns early on a failed NVL read
3637            // (`selRecord.c:423-425`), before any INP is touched. Only `sel`
3638            // reads NVL, so this needs no record-type test.
3639            let fetch_values_failed = fold.failed(input_fetch_policy, sel_nvl_read_failed);
3640
3641            // The outcome, delivered here under the guard the loop holds: to
3642            // `Record::set_fetch_gate_failed` for the records that compute in
3643            // their own `process()` (calc/calcout/scalcout/acalcout/swait/sel)
3644            // — written on EVERY cycle, `false` included, so the flag cannot
3645            // outlive the cycle it belongs to — and, for sub/aSub, whose body
3646            // is the framework-dispatched subroutine, to the same one-shot
3647            // skip the bad-SNAM path arms (C `subRecord.c:144-147`,
3648            // `aSubRecord.c:216-218`: `status = fetch_values(prec); if
3649            // (status == 0) status = do_sub(prec);`), consumed by the single
3650            // owner `run_registered_subroutine`.
3651            let inst = guard.hold();
3652            inst.record.set_fetch_gate_failed(fetch_values_failed);
3653            if fetch_values_failed {
3654                inst.suppress_subroutine_run = true;
3655            }
3656        }
3657        let resolved = fold.resolved;
3658        // The two stages below read this record by name through the shared
3659        // lock, so they run with the guard released.
3660        if plan.string_input || plan.resolves_subroutine_from_link {
3661            guard.release();
3662        }
3663
3664        // The multi-input fetch delivered everything it read; what is left
3665        // is the reads whose delivery waits for the body's own hold. A cycle
3666        // that made none of them — a `calc` with only its inputs wired —
3667        // hands the body the same nothing a cycle with no links does.
3668        let deferred = inp.is_some()
3669            || dol_info.is_some()
3670            || plan.sel_nvl
3671            || plan.string_input
3672            || plan.resolves_subroutine_from_link
3673            || !resolved_link_fields.is_empty()
3674            || inp_link_alarm.is_some();
3675        if !deferred {
3676            return InputStage::none(is_soft, resolved);
3677        }
3678        // PR #d0cf47c continued: the INP alarm (if any) goes into the same
3679        // `link_alarms` list the lock-section iterates over. Order doesn't
3680        // matter — `rec_gbl_set_sevr_msg` takes the maximum severity across
3681        // all sources.
3682        let mut link_alarms: Vec<(
3683            crate::server::record::MonitorSwitch,
3684            super::links::LinkAlarm,
3685        )> = Vec::new();
3686        link_alarms.extend(inp_link_alarm);
3687        // The two fetches only a deferring cycle has, made once the early
3688        // return is behind them: built before it, their empty results were
3689        // dropped on the path that never has them.
3690        // 1.6. String-input link fetch — C `sCalcoutRecord.c::fetch_values`'s
3691        // SECOND loop (890-942), over INAA..INLL → AA..LL. It is a separate
3692        // loop here for the same reason it is one in C: it does not feed the
3693        // fetch gate (`return(0)` at :943, so a failing string link never
3694        // suppresses sCalcPerform), a failed read writes a diagnostic INTO the
3695        // value field instead of leaving it alone, and a multi-element
3696        // DBF_CHAR/DBF_UCHAR source is read as escaped text. See
3697        // `Record::string_input_links`.
3698        let string_input_values: Vec<(String, EpicsValue)> = if !plan.string_input {
3699            Vec::new()
3700        } else {
3701            let link_info: Vec<(String, &'static str, &'static str)> = {
3702                let instance = rec.read();
3703                instance
3704                    .record
3705                    .string_input_links()
3706                    .iter()
3707                    // C (:895-911): an unset link is neither CA_LINK nor
3708                    // DB_LINK, so neither `dbGetLink` branch runs, `status`
3709                    // stays 0, and the string field keeps whatever was last
3710                    // put to it. Dropping it here is that same skip, taken
3711                    // before the text is materialised rather than after.
3712                    .filter_map(|(lf, vf)| Some((instance.link_text(lf)?, *lf, *vf)))
3713                    .collect()
3714            }; // read lock dropped
3715            let mut results = Vec::with_capacity(link_info.len());
3716            for (link_str, link_field, val_field) in &link_info {
3717                let parsed = crate::server::record::parse_link_v2(link_str);
3718                if let crate::server::record::ParsedLink::Db(ref db) = parsed {
3719                    self.process_passive_db_source(db, visited);
3720                }
3721                // C `sCalcoutRecord.c:916` / `:934` read these with `dbGetLink`
3722                // like every other input, so a failed one raises `setLinkAlarm`
3723                // (LINK/INVALID, AMSG `field INAA`) even though `fetch_values`
3724                // itself returns 0 (`:941`) and never gates `sCalcPerform`.
3725                let request = rec.read().record.input_link_request(link_field);
3726                let (fetch, alarm, _raw) =
3727                    self.db_get_link_deferred(rec, link_field, &parsed, None, request);
3728                if let Some(pair) = self.input_link_inheritance(rec, &parsed, alarm) {
3729                    link_alarms.push(pair);
3730                }
3731                let text = match fetch {
3732                    crate::server::recgbl::simm::LinkFetch::Value(value) => {
3733                        string_link_text(&value)
3734                    }
3735                    // C (:894-911) only reads a CA_LINK or a DB_LINK; a
3736                    // CONSTANT string link is never read and never seeded:
3737                    // the `if (i < MAX_FIELDS)` gate around the seed
3738                    // (`sCalcoutRecord.c:257-260`, under the comment "Don't
3739                    // InitConstantLink the string links" at `:256`) skips
3740                    // every string link, so `status` stays 0 and the string
3741                    // field keeps what was last put to it — no diagnostic.
3742                    crate::server::recgbl::simm::LinkFetch::NoData => continue,
3743                    // C (:939-940): `epicsSnprintf(*psvalue, STRING_SIZE-1,
3744                    // "%s:fetch(%s) failed", pcalc->name, sFldnames[i])` — the
3745                    // failed fetch REPLACES the value with the diagnostic; the
3746                    // previous string is not kept, and the record still computes.
3747                    crate::server::recgbl::simm::LinkFetch::Failed => truncate_string_field(
3748                        PvString::from(format!("{name}:fetch({val_field}) failed")),
3749                    ),
3750                };
3751                results.push((val_field.to_string(), EpicsValue::String(text)));
3752            }
3753            results
3754        };
3755
3756        // aSub LFLG=READ: re-read the subroutine name from the SUBL link and,
3757        // if it changed, re-resolve the function — computed here, before the
3758        // process write lock, so the SUBL link read cannot deadlock against
3759        // this record (C `aSubRecord.c::fetch_values`). `None` for everything
3760        // that is not an aSub in READ mode.
3761        let asub_dynamic = if plan.resolves_subroutine_from_link {
3762            self.resolve_asub_dynamic_subroutine(rec)
3763        } else {
3764            None
3765        };
3766
3767        InputStage {
3768            is_soft,
3769            resolved,
3770            links: Some(LinkInputs {
3771                inp_value,
3772                inp_source_time,
3773                inp_source_utag,
3774                inp_link_remote_time,
3775                dol_info,
3776                dol_fetch,
3777                dol_read_failed,
3778                sel_nvl_value,
3779                string_input_values,
3780                asub_dynamic,
3781                resolved_link_fields,
3782                link_alarms,
3783            }),
3784        }
3785    }
3786
3787    /// The record process cycle itself — C `dbProcess`'s body
3788    /// (`dbAccess.c:537-700`), entered with the record's advisory write gate
3789    /// already held (or deliberately not held, for the recursive /
3790    /// already-locked entries).
3791    ///
3792    /// **This function and everything it calls is synchronous.** That is the
3793    /// H6 contract: the gate-held region must contain no suspension point,
3794    /// because the gate is about to become a blocking priority-inheritance
3795    /// mutex and a suspended task holding it would deadlock the executor.
3796    /// Where C's `dbProcess`
3797    /// cannot finish inline it sets `PACT` and RETURNS, releasing
3798    /// `dbScanLock`, and the device callback re-takes the lock later
3799    /// (`dbAccess.c:611-628`, `dbNotify.c:252-264`); every deferred step here
3800    /// does the same — it stages work on a queue or spawns a task and returns.
3801    #[allow(clippy::too_many_arguments)]
3802    fn process_record_with_links_body(
3803        &self,
3804        name: &str,
3805        rec: &Arc<RecordCell>,
3806        visited: &mut ProcStack,
3807        is_continuation: bool,
3808        device_callback: bool,
3809    ) -> CaResult<()> {
3810        let mut cycle_end = CycleEndGuard::new(self, name, rec);
3811        let mut guard = DataGuard::new(rec);
3812
3813        // 0a. PACT entry guard — C `dbProcess`'s PACT test (dbAccess.c:536,
3814        // 557-558 at R7.0.10). If the record is currently mid-async, do NOT
3815        // re-enter the body; hand the refusal to `count_refused_active_entry`,
3816        // which owns the counting and the alarm for both of the port's
3817        // "active" tests.
3818        //
3819        // Without this guard, FLNK / scan-loop / event scans dispatched onto
3820        // a record whose first cycle is still pending (async device support,
3821        // CA put_notify on PUTF) would re-enter `record.process()` while the
3822        // device's first response is still in flight — corrupting the
3823        // record's internal state machine and bypassing the C-parity
3824        // contract that callers see for `dbProcess`. This is where the port
3825        // decides what an ASYNC-active record does with a foreign process
3826        // request; `process_one_cp_target` used to pre-empt it with an
3827        // RPRO-and-skip of its own, which is how a starved CP target got an
3828        // extra device write instead of C's SCAN_ALARM.
3829        //
3830        // Both questions are asked under one guard. C reads the type's `rset`
3831        // and tests `pact` inside a single `dbScanLock`; the plan is settled
3832        // at construction and the PACT test is two field reads, so splitting
3833        // them across two acquisitions cost the record lock twice at the top
3834        // of every cycle and bought nothing.
3835        let (plan, active, input_link_texts, metadata) = {
3836            let plan = guard.rec.process_plan();
3837            let instance = guard.hold();
3838            if instance.is_destroyed() {
3839                return Err(CaError::ChannelNotFound(name.to_string()));
3840            }
3841            let active = !is_continuation
3842                && if instance.is_processing() {
3843                    true
3844                } else {
3845                    // Not pact: reset lcnt (C `else { precord->lcnt = 0; }`
3846                    // at dbAccess.c:558) so the next async cycle starts clean.
3847                    instance.common.lcnt = 0;
3848                    false
3849                };
3850            let input_link_texts = Self::read_input_link_texts(instance);
3851            // C reads a link-backed field's metadata live inside the rset,
3852            // under the TARGET record's lock (`dbDbLink.c:240-261`). A poster
3853            // here holds THIS record's lock and cannot reach for a second one,
3854            // so the cycle resolves once, below, and hands every poster the
3855            // borrowed result. The borrow is what makes "the metadata a
3856            // monitor carries was resolved during this cycle" true by
3857            // construction: there is nowhere to keep it.
3858            //
3859            // What the resolve asks of this record — which links, and whether
3860            // anyone is subscribed to read the answer — it asks here, under
3861            // the guard the cycle already holds. The lock set is held for the
3862            // whole cycle, so the subscriber list it reads is the one every
3863            // poster below will see. Empty for every record type that backs
3864            // no field's metadata with a link — all but calc, calcout, sub,
3865            // aSub and seq.
3866            let metadata = if active {
3867                MetadataPlan::Empty
3868            } else {
3869                PvDatabase::plan_link_backed_metadata_for_posts(instance, &input_link_texts)
3870            };
3871            (plan, active, input_link_texts, metadata)
3872        };
3873        if active {
3874            guard.release();
3875            self.count_refused_active_entry(rec);
3876            return Ok(());
3877        }
3878
3879        // The walk locks each link's target, which for a self-link is this
3880        // record; a plan with nothing to walk keeps the guard.
3881        if matches!(metadata, MetadataPlan::Links(_)) {
3882            guard.release();
3883        }
3884        let link_backing = self.resolve_link_backed_metadata_plan(metadata);
3885        let link_backing = link_backing.as_link_backing();
3886
3887        // 0. SDIS disable check — C parity dbAccess.c:562-592.
3888        //
3889        // When the SDIS link evaluates to a value equal to DISV, the
3890        // record is disabled and bails before record support runs. C
3891        // ALWAYS clears rpro/putf and triggers dbNotifyCompletion at
3892        // this point — regardless of whether the alarm transition
3893        // fires — because a disabled record must not leave behind
3894        // pending reprocess requests or stranded put_notify completion
3895        // callbacks. Pre-fix the Rust port only reset
3896        // nsta/nsev and updated the alarm state, leaking rpro/putf
3897        // into the next cycle and stalling CA WRITE_NOTIFY callers
3898        // (the put_notify_tx never fired so the CA dispatcher waited
3899        // until socket disconnect to release the operation).
3900        let no_sim_pact_exit;
3901        {
3902            // C `dbGetLink(&precord->sdis, DBR_SHORT, &precord->disa, 0, 0)`
3903            // (`dbAccess.c:566`) reads the SDIS link regardless of its type
3904            // (DB / CA / PVA / constant) via the lset — so it goes through the
3905            // one classifier. A CONSTANT SDIS delivers NOTHING
3906            // (`dbConstGetValue`), and dbCommon has no `recGblInitConstantLink`
3907            // for SDIS, so DISA keeps its `initial(0)`: `field(SDIS,"3")` with
3908            // `DISV=3` does NOT disable the record in C (softIoc-verified).
3909            // Handing back the constant here disabled it forever.
3910            //
3911            // Which of the two it is, is asked in the guard that reads DISV and
3912            // DISS: a record with no SDIS source still honours a DISA a client
3913            // put there, so the test below stays, but the link clone, the read
3914            // and the second guard that re-reads DISA after it all belong to
3915            // the sourced case alone.
3916            //
3917            // The same guard answers the cycle's PACT-exit question. C reads
3918            // DISA/DISV/DISS and the record's notify state under the one
3919            // `dbScanLock`; the port asked for them in two acquisitions with
3920            // nothing but read-only tests in between.
3921            let (sdis_link, disv, diss, disa) = {
3922                let instance = guard.hold();
3923                let sourced = !crate::server::recgbl::simm::is_constant(&instance.parsed_sdis);
3924                no_sim_pact_exit = instance.pact_exit_without_release();
3925                (
3926                    sourced.then(|| instance.parsed_sdis.clone()),
3927                    instance.common.disv,
3928                    instance.common.diss,
3929                    instance.common.disa,
3930                )
3931            };
3932
3933            let disa = match sdis_link {
3934                Some(sdis_link) => {
3935                    guard.release();
3936                    if let Some(val) = self.db_get_link(rec, "SDIS", &sdis_link).value() {
3937                        // C `dbGetLink(&prec->sdis, DBR_SHORT, &prec->disa)` — the
3938                        // routine is picked by the SOURCE type, so this goes through
3939                        // the coercion owner, not `c_cast` direct (an integer SDIS
3940                        // source takes C's defined modular conversion; only a float
3941                        // source takes the UB cast).
3942                        let disa_val = val.to_dbf_i16().unwrap_or(0);
3943                        guard.hold().common.disa = disa_val;
3944                    }
3945                    guard.hold().common.disa
3946                }
3947                None => disa,
3948            };
3949            if disa == disv {
3950                let notify = {
3951                    let instance = guard.hold();
3952                    // C `dbAccess.c:575-577` — clear rpro/putf and arm
3953                    // notifyCompletion BEFORE the alarm check. Disabled
3954                    // records skip processing entirely, so any pending
3955                    // reprocess request is dropped (the next non-
3956                    // disabled cycle will pick up fresh state) and the
3957                    // CA put-notify caller must be released. A disabled
3958                    // record drives no FLNK/OUT chain, so leaving the
3959                    // wait-set here is its whole contribution.
3960                    instance.common.rpro = 0;
3961                    instance.common.putf = false;
3962                    let notify = instance.notify.take();
3963
3964                    // Reset nsta/nsev so stale alarm state doesn't bleed
3965                    // into a subsequent (re-enabled) cycle. C resets
3966                    // them after the sevr/stat transition; doing it
3967                    // first here is observationally identical because
3968                    // the SDIS bail short-circuits any record-support
3969                    // path that could read them.
3970                    instance.common.nsta = 0;
3971                    instance.common.nsev = crate::server::record::AlarmSeverity::NoAlarm;
3972
3973                    // C `dbAccess.c:580-581` — if already in
3974                    // DISABLE_ALARM, the alarm post is skipped entirely
3975                    // (the alarm cycle is debounced). The rpro/putf
3976                    // clear above still ran, matching C's pre-`goto
3977                    // all_done` ordering.
3978                    if instance.common.stat != crate::server::recgbl::alarm_status::DISABLE_ALARM {
3979                        use crate::server::recgbl::EventMask;
3980                        instance.common.sevr =
3981                            crate::server::record::AlarmSeverity::from_u16(diss as u16);
3982                        instance.common.stat = crate::server::recgbl::alarm_status::DISABLE_ALARM;
3983                        // C `dbAccess.c:586-593` posts each field with
3984                        // its own mask:
3985                        //   db_post_events(&stat, DBE_VALUE);
3986                        //   db_post_events(&sevr, DBE_VALUE);
3987                        //   db_post_events(&val,  DBE_VALUE|DBE_ALARM);
3988                        // STAT/SEVR get DBE_VALUE only — a DBE_ALARM-only
3989                        // subscriber on `.STAT`/`.SEVR` must NOT receive
3990                        // this disable event. Only the value field
3991                        // carries DBE_ALARM.
3992                        instance.notify_field("STAT", EventMask::VALUE);
3993                        instance.notify_field("SEVR", EventMask::VALUE);
3994                        instance.notify_field("VAL", EventMask::VALUE | EventMask::ALARM);
3995                    }
3996                    notify
3997                };
3998                guard.release();
3999                // Fire dbNotifyCompletion outside the record lock —
4000                // C `dbAccess.c:622-623` runs it at `all_done` after
4001                // the disable bail. Without this, a CA WRITE_NOTIFY
4002                // landing on a disabled record stalls until socket
4003                // disconnect. `leave` fires the completion oneshot when
4004                // this empties the wait-set.
4005                if let Some(ws) = notify {
4006                    ws.leave();
4007                }
4008                return Ok(());
4009            }
4010        }
4011
4012        // 0.4. The dset gate — the FIRST statement of every C `process()`
4013        // that needs device support:
4014        //
4015        // ```c
4016        // if( (pdset==NULL) || (pdset->read_ai==NULL) ) {
4017        //     prec->pact=TRUE;
4018        //     recGblRecordError(S_dev_missingSup, prec, "read_ai");
4019        //     return(S_dev_missingSup);
4020        // }
4021        // ```
4022        // (`aiRecord.c:143-147`, and the same four lines in 19 more
4023        // `<rec>Record.c` files.) It sits here, after `dbProcess`'s PACT test
4024        // and the SDIS disable bail and before anything of the body, because
4025        // that is where C's is: `dbProcess` reaches `prset->process` only past
4026        // those two, and `process` refuses on its first line.
4027        //
4028        // This is not a message. The PACT it takes is never released — the
4029        // only release is a cycle tail this record never reaches — so the
4030        // record is inert from its first process attempt onward, exactly as it
4031        // is in C, and every later attempt is turned away by the PACT guard
4032        // above without a second report. Reporting without taking PACT would
4033        // have printed C's line over a record that then went on processing:
4034        // measured against `softIoc` R7.0.10 on `asyn`'s `testErrors` IOC, C
4035        // leaves `testErrors:AoInt32` at `PACT 1`, `STAT UDF`, `TIME
4036        // <undefined>` where this port left it `PACT 0`, `STAT NO_ALARM` and
4037        // stamped.
4038        //
4039        // The gate is `dev_sup_process_refusal`, which is `None` for every
4040        // record type whose C `process()` has no dset test — `calc`, `sub`,
4041        // `fanout`, and `calcout`, which refuses only at init.
4042        if plan.dset_can_refuse {
4043            let refusal = {
4044                let instance = guard.hold();
4045                if instance.common.dtyp.is_soft() || instance.device.is_some() {
4046                    None
4047                } else {
4048                    crate::server::recgbl::dev_sup_process_refusal(instance.record.record_type())
4049                }
4050            };
4051            if let Some(message) = refusal {
4052                let notify = {
4053                    let instance = guard.hold();
4054                    instance.enter_pact();
4055                    // C returns from `process()` without reaching
4056                    // `recGblFwdLink`, so its `dbNotifyCompletion` never fires
4057                    // and a put-notify parked on such a record waits for a
4058                    // cycle that will never come. Releasing the wait-set is
4059                    // the same thing the SDIS bail above does, and for the
4060                    // same reason: a CA WRITE_NOTIFY caller must not be held
4061                    // to a socket timeout by a record that has already decided
4062                    // not to run.
4063                    instance.notify.take()
4064                };
4065                guard.release();
4066                crate::server::recgbl::rec_gbl_record_error(
4067                    &crate::server::recgbl::DevSupStatus::MissingSup.text(),
4068                    name,
4069                    message,
4070                );
4071                if let Some(ws) = notify {
4072                    ws.leave();
4073                }
4074                return Ok(());
4075            }
4076        }
4077
4078        // 0.5. Simulation mode check.
4079        //
4080        // C handles simulation inside `readValue()` / `writeValue()` — the
4081        // device-I/O step — then `process()` ALWAYS runs the rest of the
4082        // body (`convert` / OROC / the record's own state machine) plus
4083        // `checkAlarms` / `monitor` / `recGblFwdLink(prec)`. SIMM replaces
4084        // ONLY the device read/write, never the body. The substitution
4085        // point differs by direction: an INPUT `readValue()` precedes the
4086        // body, so `Simulated` does the SIOL read here and short-circuits;
4087        // an OUTPUT `writeValue()` follows the body, so
4088        // `RedirectOutputToSiol` falls through to run the uniform body and
4089        // redirects only the final output write to SIOL (see below). Either
4090        // way the forward-link / CP / RPRO tail still runs — returning early
4091        // without it would silently break every FLNK / CP chain downstream
4092        // of any record in SIMM mode.
4093        //
4094        // `sim_output` carries the OUTPUT redirect (SIOL link, SIMS, RAW
4095        // flag) from this point to the OUT stage / alarm epilogue below;
4096        // `None` for a non-simulated record or a simulated INPUT.
4097        // The cycle's simulation state, pushed to the record before the body —
4098        // the twin of `set_fetch_gate_failed`. Written on EVERY cycle of a record
4099        // that declares the input-stage shape (`false` included), so the flag
4100        // cannot outlive the cycle it belongs to.
4101        let mut sim_input_stage = false;
4102        // C `writeValue` returned before performing ANY output. `writeValue`
4103        // runs at the END of C `process()`, so the body has already run and
4104        // only the device / OUT-link / SIOL write is lost. Two C paths reach
4105        // it, and both mean exactly this one thing:
4106        //   * `switch (prec->simm)` `default:` — `recGblSetSevr(SOFT_ALARM,
4107        //     INVALID_ALARM); return -1;`  (`SimOutcome::IllegalMode`)
4108        //   * a failed SIML read — `if (status) return status;`
4109        //     (`SimOutcome::AbortedBeforeWrite`, busyRecord.c:399-401)
4110        let mut sim_write_aborted = false;
4111        // The PACT the SDLY defer held, released by the SIM continuation arms —
4112        // carried to whichever `recGblFwdLink` tail this cycle ends at, so the
4113        // put-notify parked on that window is replayed there (C
4114        // `dbNotifyCompletion`) instead of being stranded.
4115        let (sim_outcome, sim_pact_exit) = if plan.simulation {
4116            guard.release();
4117            self.check_simulation_mode(rec)
4118        } else {
4119            (SimOutcome::NotSimulated, no_sim_pact_exit)
4120        };
4121        // Every exit below this line owes C's `recGblFwdLink` tail. The guard
4122        // owns that debt so no path can leave without either paying it or
4123        // saying, at the site, that it is handing the cycle to someone else.
4124        cycle_end.merge_in(sim_pact_exit);
4125        let sim_output = match sim_outcome {
4126            SimOutcome::NotSimulated => None,
4127            SimOutcome::Simulated(posts) => {
4128                self.run_forward_link_tail(name, rec, posts, visited);
4129                self.end_process_cycle(name, rec, cycle_end.take());
4130                return Ok(());
4131            }
4132            SimOutcome::AbortedBeforeWrite => {
4133                // C busy `writeValue`: `status = dbGetLink(&prec->siml, ...);
4134                // if (status) return status;` — the SIML read failed, so the
4135                // routine returns before `write_busy` AND before the SIOL
4136                // redirect. `dbGetLink` has already raised LINK_ALARM/INVALID.
4137                sim_write_aborted = true;
4138                None
4139            }
4140            SimOutcome::IllegalMode { is_output } => {
4141                if is_output {
4142                    // `writeValue` follows the body, so only the write is lost.
4143                    sim_write_aborted = true;
4144                    None
4145                } else {
4146                    // `readValue` precedes the body and IS the body's input, so
4147                    // nothing of the body is left to run. SOFT_ALARM/INVALID is
4148                    // already pending; commit it, post the monitors and fire the
4149                    // forward link — C `process()` runs `checkAlarms`,
4150                    // `monitor()` and `recGblFwdLink()` regardless of the -1.
4151                    let tsel = self.read_tsel(rec);
4152                    let posts = {
4153                        let mut instance = rec.write();
4154                        sim_process_tail(&mut instance, tsel, false, link_backing)
4155                    };
4156                    self.run_forward_link_tail(name, rec, posts, visited);
4157                    self.end_process_cycle(name, rec, cycle_end.take());
4158                    return Ok(());
4159                }
4160            }
4161            SimOutcome::SimulatedInputStage => {
4162                sim_input_stage = true;
4163                None
4164            }
4165            SimOutcome::DeferRead(delay) => {
4166                // C `readValue`/`writeValue` async path: hold PACT and
4167                // schedule the SIOL round-trip `SDLY` seconds out. Post
4168                // nothing this cycle — C `process()` returns 0 on the
4169                // async-start pass (`if (!pact && prec->pact) return 0`), so
4170                // no value, no alarm, no monitor, no forward link. The
4171                // continuation re-enters via `process_record_continuation`
4172                // (`is_continuation = true`) and runs the synchronous branch
4173                // + tail. The PACT hold is gated on the scheduled re-entry
4174                // that releases it, the same construction-time invariant as
4175                // the `ReprocessAfter` ODLY defers.
4176                {
4177                    let instance = rec.write();
4178                    instance.enter_pact();
4179                }
4180                self.schedule_delayed_reprocess(name, delay);
4181                // This arm is reachable only with PACT clear on entry, so nothing
4182                // can be queued; run the check through the single owner anyway so
4183                // no path drops a token blind.
4184                self.apply_pact_exit(name, rec, cycle_end.take());
4185                return Ok(());
4186            }
4187            SimOutcome::RedirectOutputToSiol {
4188                siol,
4189                sims,
4190                raw_mode,
4191            } => Some((siol, sims, raw_mode)),
4192        };
4193        if plan.substitutes_input_stage_when_simulating {
4194            guard.hold().record.set_simulation_active(sim_input_stage);
4195        }
4196
4197        // 1. The input stage: every link read this cycle performs before it
4198        //    takes the record's lock to apply what arrived. A record with
4199        //    nothing to read gets the stage's empty result without the stage
4200        //    running — see `fetch_input_stage`.
4201        let mut stage = self.fetch_input_stage(name, &mut guard, plan, &input_link_texts, visited);
4202
4203        // 2. Lock record, apply INP/DOL, process, evaluate alarms, build snapshot
4204        let (flnk_name, process_actions, result_is_defer_output, restamps_after, posts) = 'epilogue: {
4205            // One data guard for Segments A–E; each boundary below releases it
4206            // only across work that may lock another record.
4207            // Segment A (guarded): apply DOL/INP/multi-input values, run the
4208            // device read, and collect pre-process ReadDbLink actions. The data
4209            // guard is released at the segment boundary below so the following
4210            // link-I/O awaits hold no `!Send` parking_lot guard (the record stays
4211            // claimed by the `processing` gate meanwhile — the signed-off
4212            // momentary release, uniform with the async paths that already
4213            // release the data lock across link I/O here).
4214            let (
4215                pre_actions,
4216                deferred_device_actions,
4217                is_soft,
4218                device_did_compute,
4219                read_produced_no_value,
4220                device_read_computed,
4221            ) = {
4222                let instance = guard.hold();
4223                // One discriminant for "this cycle sourced no value", set by
4224                // either source: a failed soft-INP read, and a device support
4225                // returning C's negative `read_ai()` status (-1, -2). Both miss
4226                // C's `if (status == 0)` gate identically, so the UDF re-derive
4227                // below tests one condition rather than a per-source exception.
4228                let mut read_produced_no_value = false;
4229                // C `return 2` specifically: the dset wrote VAL. Kept apart
4230                // from `device_did_compute`, which the soft-INP branch also
4231                // sets — there the framework IS the dset (it is the port of
4232                // `devBiSoft.c::readLocked`) and owns the UDF clear, so the
4233                // record's dset-owns-UDF rule must not fire for it.
4234                let mut device_read_computed = false;
4235
4236                // Apply the closed-loop DOL read (OMSL=CLOSED_LOOP), keeping C's
4237                // three outcomes apart.
4238                //
4239                // `Failed` is C's non-zero `dbGetLink` status: the LINK/INVALID
4240                // alarm already rode in with the read, and the record's own
4241                // failure arm — `AoRecord::closed_loop_dol_read_failed` reverting
4242                // VAL to PVAL, every convert-bearing OMSL record suppressing this
4243                // cycle's convert — runs here.
4244                //
4245                // `NoData` is status 0 with the buffer untouched. A CONSTANT DOL
4246                // never reaches here at all (`dol_info` excludes it), so this is
4247                // the reader's own `default:` arm (no declared request for this
4248                // source class): nothing is attempted and nothing changes.
4249                let links = stage.links.as_mut();
4250                let dol_fetch = links.as_ref().and_then(|l| l.dol_fetch.as_ref());
4251                if let Some(crate::server::recgbl::simm::LinkFetch::Failed) = dol_fetch {
4252                    instance.record.closed_loop_dol_read_failed();
4253                }
4254                let mut links = links;
4255                if let Some(crate::server::recgbl::simm::LinkFetch::Value(dol_val)) =
4256                    links.as_mut().and_then(|l| l.dol_fetch.take())
4257                {
4258                    let oif = links
4259                        .as_ref()
4260                        .and_then(|l| l.dol_info.as_ref())
4261                        .map(|(_, oif)| *oif)
4262                        .unwrap_or(0);
4263                    if oif == 1 {
4264                        // Incremental: C `fetch_value` (aoRecord.c:447-455) sets
4265                        // `prec->val = prec->pval` first ("don't allow dbputs to
4266                        // val field"), then `*pvalue += prec->val`, so the
4267                        // increment is relative to PVAL — the last actual output —
4268                        // not the current VAL a client may have just caput. OIF is
4269                        // an ao-only field, so this branch always carries a PVAL.
4270                        if let (Some(pval), Some(dol_f)) = (
4271                            instance.record.get_field("PVAL").and_then(|v| v.to_f64()),
4272                            dol_val.to_f64(),
4273                        ) {
4274                            let _ = instance.record.set_val(EpicsValue::Double(pval + dol_f));
4275                        }
4276                    } else {
4277                        // Full: VAL = DOL value
4278                        let _ = instance.record.set_val(dol_val);
4279                    }
4280                    // The closed-loop DOL read DEFINES the record — C sets UDF from
4281                    // the value it just fetched, in the DOL branch itself:
4282                    // `prec->udf = isnan(value)` (aoRecord.c:147, dfanoutRecord.c:121)
4283                    // / `prec->udf = FALSE` (boRecord.c:162). For ao/bo this repeats
4284                    // what the per-cycle clear below does; for dfanout — whose
4285                    // `process()` touches UDF nowhere else — it is the ONLY definer,
4286                    // which is why dfanout can opt out of the per-cycle clear.
4287                    instance.common.udf = instance.record.value_is_undefined() as u8;
4288                }
4289
4290                // Apply INP value. "Soft Channel" sets VAL directly
4291                // (C `read_xxx` return 2, skip RVAL→VAL conversion).
4292                // "Raw Soft Channel" is a DIFFERENT DSET (`devXxxSoftRaw.c`): its
4293                // `read_xxx` puts the value in RVAL, applies the dset's MASK and
4294                // returns 0, so the record's own RVAL→VAL convert runs. Whether
4295                // that dset exists is the record type's answer, given by
4296                // `Record::raw_soft_input` returning `Some` — the dset table, not a
4297                // separate boolean that could disagree with it.
4298                let inp_value = links.as_mut().and_then(|l| l.inp_value.take());
4299                let had_inp_value = inp_value.is_some();
4300                let mut soft_inp_applied = false;
4301                if let Some(inp_val) = inp_value {
4302                    let raw = if instance.common.dtyp.soft()
4303                        == Some(crate::server::device_support::SoftDtyp::Raw)
4304                    {
4305                        instance
4306                            .record
4307                            .raw_soft_input(RawSoftEntry::Read, inp_val.clone())
4308                    } else {
4309                        None
4310                    };
4311                    match raw {
4312                        // SoftRaw: value landed in RVAL; the record's RVAL->VAL
4313                        // convert runs in `process()`, so VAL was NOT set here.
4314                        Some(res) => {
4315                            let _ = res;
4316                        }
4317                        None => {
4318                            // The soft dset's `read_xxx` body. Only a
4319                            // soft-channel record has one: a `lnkCalc` INP is
4320                            // delivered above whatever the DTYP is
4321                            // (`read_link_value_soft`), and a device record's
4322                            // own dset has already run its filter.
4323                            let _ = if stage.is_soft {
4324                                instance.record.soft_input_read(Some(inp_val))
4325                            } else {
4326                                instance.record.set_val(inp_val)
4327                            };
4328                            soft_inp_applied = true;
4329                        }
4330                    }
4331                }
4332                if !had_inp_value
4333                    && stage.is_soft
4334                    && crate::server::recgbl::simm::is_constant(&instance.parsed_inp)
4335                {
4336                    // C `dbLinkIsConstant(&prec->inp)` at process. The load-once
4337                    // rule (a constant delivers nothing here — it was loaded at
4338                    // init) is the default and stays the default; the ONE soft
4339                    // device support that re-reads its constant INP every process
4340                    // is `devSASoft.c::read_sa` (subArray), which also re-subsets
4341                    // on an EMPTY INP. `Record::read_constant_inp` is that
4342                    // device-support-layer exception: every other record's default
4343                    // returns false and nothing happens, exactly as before.
4344                    let constant =
4345                        crate::server::recgbl::simm::constant_load_value(&instance.parsed_inp);
4346                    if instance.record.read_constant_inp(constant) {
4347                        soft_inp_applied = true;
4348                    }
4349                } else if !had_inp_value
4350                    && stage.is_soft
4351                    && matches!(
4352                        instance.parsed_inp,
4353                        crate::server::record::ParsedLink::Db(_)
4354                            | crate::server::record::ParsedLink::Ca(_)
4355                            | crate::server::record::ParsedLink::Pva(_)
4356                            | crate::server::record::ParsedLink::PvaJson(_)
4357                    )
4358                {
4359                    // A soft-channel `read_xxx` is a plain `dbGetLink` on INP
4360                    // (`devAiSoft.c::read_ai` -> `dbGetLink(&prec->inp, ...)`), so a
4361                    // failed read runs `setLinkAlarm` (dbLink.c:322) —
4362                    // `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "field INP")`.
4363                    // Route it through the `setLinkAlarm` owner so it carries C's
4364                    // message: raising the severity without the AMSG text left the
4365                    // operator with an INVALID/LINK record and a blank `.AMSG`.
4366                    // ParsedLink::None and Constant don't reach this branch — the
4367                    // former is "no link configured", the latter has its own
4368                    // None-as-no-value semantics.
4369                    crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, "INP");
4370                    // C's failure arm — `devAiSoft.c:92` drops the dset's
4371                    // "a read has completed" state so the next good reading is
4372                    // taken unsmoothed.
4373                    let _ = instance.record.soft_input_read(None);
4374                    // …and tell the record, so "no value was sourced" stops
4375                    // being indistinguishable from "no link is configured".
4376                    // C `devSASoft.c::read_sa` (118-120) skips `subset()` on a
4377                    // non-zero status and `subArrayRecord.c:148` turns that
4378                    // status into UDF; without the report the record could only
4379                    // see its own stale buffer and called itself defined.
4380                    instance.record.soft_input_read_failed();
4381                    read_produced_no_value = true;
4382                }
4383
4384                // Apply multi-input values (INPA..INPL -> A..L).
4385                //
4386                // Uses `put_field_internal`, not `put_field`: this is the
4387                // framework writing a resolved input-link value into a
4388                // record field, exactly like the `ReadDbLink` apply
4389                // (`execute_read_db_links` / `execute_process_actions`),
4390                // which already routes through `put_field_internal`. Some
4391                // records map an input link to a normally read-only field
4392                // — e.g. the epid record's `INP -> CVAL` — and `put_field`
4393                // rejects those with `ReadOnlyField`, silently dropping the
4394                // value. `put_field_internal` defaults to `put_field`, so
4395                // records with writable targets (calc/sub `A..L`) are
4396                // unaffected.
4397                // An ARRAY-valued link value is offered to the target field whole:
4398                // C's `fetch_values` hands `dbGetLink` a pointer to the target FIELD,
4399                // so the field decides how much of the source it takes. An array
4400                // field takes `nRequest` = its own element count with the tail
4401                // zero-filled (aCalcoutRecord.c:1097-1102 for INAA..INLL -> AA..LL);
4402                // a scalar field is a one-element destination, so it takes element 0
4403                // (`dbGetLink(..., DBR_DOUBLE, pvalue, 0, 0)`, calcRecord.c:434).
4404                // The numeric view answers None for every array variant, so routing
4405                // every value through it dropped array-valued links outright —
4406                // AA..LL never populated and the record calculated on an empty
4407                // array. The view is `get_convert_f64`, C's DBR_DOUBLE get row,
4408                // not `to_f64`: the two disagree on an empty DBF_STRING source.
4409                let (sel_nvl_value, string_input_values) = match links {
4410                    Some(l) => (
4411                        l.sel_nvl_value.take(),
4412                        Some(std::mem::take(&mut l.string_input_values)),
4413                    ),
4414                    None => (None, None),
4415                };
4416
4417                // The set_resolved_input_links report is deferred until after
4418                // the pre-process ReadDbLink reads below, so the record sees
4419                // ONE per-cycle resolution list covering both fetch paths —
4420                // records reset per-cycle resolution state in that hook, so
4421                // it must not run twice with partial lists.
4422
4423                // Apply sel NVL -> SELN. SELN is DBF_USHORT (selRecord.dbd.pod:295),
4424                // an unsigned 0..65535 index. Carry the native unsigned value so a
4425                // link value in 32768..65535 is not lost to f64->i16 saturation
4426                // before it reaches the field's put.
4427                if let Some(nvl_val) = sel_nvl_value {
4428                    // Same one-element-destination rule as the multi-input loop
4429                    // above: C reads NVL with `dbGetLink(..., DBR_USHORT, &pse->seln,
4430                    // 0, 0)` (selRecord.c), so an array-valued source contributes its
4431                    // element 0 rather than being dropped by `to_f64`.
4432                    let scalar = if nvl_val.is_array() {
4433                        nvl_val.first_element()
4434                    } else {
4435                        Some(nvl_val)
4436                    };
4437                    if let Some(f) = scalar.and_then(|v| v.get_convert_f64()) {
4438                        let _ = instance
4439                            .record
4440                            .put_field("SELN", EpicsValue::UShort(f as u16));
4441                    }
4442                }
4443
4444                // Apply the string-input values (scalcout INAA..INLL -> AA..LL),
4445                // fetched in step 1.6 above. `put_field_internal` is the coercion
4446                // owner: it converts to the target field's declared `DbFieldType`,
4447                // which is `String` for every one of these.
4448                if let Some(string_input_values) = string_input_values {
4449                    for (val_field, value) in string_input_values {
4450                        let _ = instance.record.put_field_internal(&val_field, value);
4451                    }
4452                }
4453
4454                // Device support read (input records only, not output records).
4455                // Shadows the outer `is_soft` on purpose: that one asks "does
4456                // the framework own this record" (all three soft flavours),
4457                // this one asks "does the input dset return 2, do-not-convert"
4458                // — which is Plain and Async but NOT Raw. See
4459                // `device_support::SoftDtyp`.
4460                let is_soft = matches!(
4461                    instance.common.dtyp.soft(),
4462                    Some(
4463                        crate::server::device_support::SoftDtyp::Plain
4464                            | crate::server::device_support::SoftDtyp::Async
4465                    )
4466                );
4467                let is_output = instance.record.can_device_write();
4468                // The actions a device read handed back, if it handed any: a
4469                // soft record has no device to read and owes no empty list.
4470                let mut device_actions: Option<Vec<crate::server::record::ProcessAction>> = None;
4471                // C `devAiSoft.c:65` `read_ai` (and the other soft-channel
4472                // input `read_xxx`) ALWAYS returns 2 ("don't convert") for a
4473                // Soft-Channel input record — whether the value arrived via
4474                // an INP link or the INP link is constant/unset
4475                // (`dbLinkIsConstant` → `return 2`). Only `aiRecord.c:158`'s
4476                // `if (status==0) convert(prec)` runs RVAL→VAL conversion, so
4477                // for a plain Soft-Channel input record `convert()` must be
4478                // skipped unconditionally. Without this, a soft ai with no
4479                // INP would run `convert()` and clobber a preset VAL — e.g.
4480                // a preset NaN would be rewritten to 0.0, then the framework
4481                // UDF check (`value_is_undefined()`) would see a defined 0.0
4482                // and wrongly clear UDF. `SoftDtyp::Raw` is excluded above —
4483                // `devAiSoftRaw` returns 0 and deliberately wants the RVAL→VAL
4484                // convert.
4485                //
4486                // Gated on `soft_channel_skips_convert()` so this only
4487                // suppresses an `RVAL → VAL` convert step. Records such as
4488                // `epid` also override `set_device_did_compute` but treat it
4489                // as "skip the whole built-in compute" (the PID loop); they
4490                // return `false` here so a Soft-Channel `epid` still runs
4491                // `do_pid()` in `process()`.
4492                let soft_input_skips_convert =
4493                    is_soft && !is_output && plan.soft_channel_skips_convert;
4494                let mut device_did_compute =
4495                    (soft_inp_applied && is_soft) || soft_input_skips_convert;
4496                // Input records read every cycle (`!is_output`). An OUTPUT record
4497                // reads only on a driver-callback (`asyn:READBACK`) cycle: it pulls
4498                // the callback value into VAL here and the OUT stage below skips the
4499                // write — C `devAsynInt32.c::processBo` `getCallbackValue` readback
4500                // branch. A put/FLNK/scan cycle (`device_callback == false`) leaves
4501                // the output untouched here and writes below.
4502                if !is_soft && (!is_output || device_callback) {
4503                    if let Some(mut dev) = instance.device.take() {
4504                        // Push framework-owned common state (PHAS/TSE/TSEL/
4505                        // UDF) so device support's read() can see it — C
4506                        // device support reads `dbCommon` directly
4507                        // (`devTimeOfDay.c:122` uses `psi->phas`).
4508                        dev.set_process_context(&instance.common.process_context());
4509                        match dev.read(&mut *instance.record) {
4510                            Ok(read_outcome) => {
4511                                let status = read_outcome.status;
4512                                device_did_compute = status.skips_conversion();
4513                                if status.read_failed() {
4514                                    read_produced_no_value = true;
4515                                }
4516                                device_read_computed = matches!(
4517                                    status,
4518                                    crate::server::device_support::DeviceReadStatus::Computed
4519                                );
4520                                // A C dset writes `prec->udf` itself, before
4521                                // its `return` — `devBiSoft.c::readLocked` and
4522                                // `devBiDbState.c:67` clear it, `devAsynInt32.c
4523                                // :902` sets it. Ours cannot reach `dbCommon`
4524                                // through the `&mut dyn Record` it holds, so the
4525                                // framework — the single owner of the UDF
4526                                // transition — applies what the outcome states,
4527                                // HERE, before the record's own rule below: C's
4528                                // order is dset first, `process()` second, and
4529                                // for the record types that re-derive
4530                                // unconditionally the second write is what wins.
4531                                use crate::server::device_support::DeviceUdf;
4532                                match read_outcome.udf() {
4533                                    DeviceUdf::Untouched => {}
4534                                    DeviceUdf::Defined => instance.common.udf = 0,
4535                                    DeviceUdf::Undefined => instance.common.udf = 1,
4536                                }
4537                                if !read_outcome.actions.is_empty() {
4538                                    device_actions = Some(read_outcome.actions);
4539                                }
4540                            }
4541                            Err(e) => {
4542                                eprintln!("device read error on {}: {e}", instance.name);
4543                                use crate::server::recgbl::{alarm_status, rec_gbl_set_sevr};
4544                                rec_gbl_set_sevr(
4545                                    &mut instance.common,
4546                                    alarm_status::READ_ALARM,
4547                                    crate::server::record::AlarmSeverity::Invalid,
4548                                );
4549                            }
4550                        }
4551                        instance.device = Some(dev);
4552                    }
4553                }
4554
4555                // Pre-process actions: execute ReadDbLink from device support and
4556                // record's pre_process_actions() BEFORE process() so the values
4557                // are immediately available. Matches C dbGetLink() semantics.
4558                let mut pre_actions = instance.record.pre_process_actions();
4559                // Also collect ReadDbLink from device actions; the rest wait
4560                // for the record body and join its own actions after it.
4561                let mut deferred_device_actions: Option<Vec<_>> = None;
4562                if let Some(device_actions) = device_actions {
4563                    for action in device_actions {
4564                        if matches!(
4565                            action,
4566                            crate::server::record::ProcessAction::ReadDbLink { .. }
4567                        ) {
4568                            pre_actions.push(action);
4569                        } else {
4570                            deferred_device_actions
4571                                .get_or_insert_with(Vec::new)
4572                                .push(action);
4573                        }
4574                    }
4575                }
4576                (
4577                    pre_actions,
4578                    deferred_device_actions,
4579                    is_soft,
4580                    device_did_compute,
4581                    read_produced_no_value,
4582                    device_read_computed,
4583                )
4584            };
4585
4586            // await 1 (guard-free): pre-process ReadDbLink resolution. `name` is
4587            // the record's resolved canonical name (== `instance.name`).
4588            if !pre_actions.is_empty() {
4589                guard.release();
4590                let pre_resolved = self.execute_read_db_links(name, rec, &pre_actions, visited);
4591                stage
4592                    .links
4593                    .get_or_insert_with(LinkInputs::none)
4594                    .resolved_link_fields
4595                    .extend(pre_resolved);
4596            }
4597
4598            // Segment B (guarded): apply resolved inputs, run the subroutine and
4599            // `process()`, and classify the outcome. The guard is released before
4600            // the branch-specific async work below (parking_lot guards are
4601            // `!Send`); each branch re-acquires the data lock as it needs it. The
4602            // Segment-A mutations were committed under that guard and are visible
4603            // through this fresh acquisition (same `Arc`).
4604            let (
4605                process_result,
4606                process_actions,
4607                post_write_fields,
4608                result_is_defer_output,
4609                result_is_alarm_only,
4610            ) = {
4611                let instance = guard.hold();
4612
4613                // Tell the record which input link fields actually resolved
4614                // a value this cycle — the union of the multi-input fetch and
4615                // the pre-process ReadDbLink reads; the framework analogue of
4616                // C device support inspecting `RTN_SUCCESS(dbGetLink(...))`
4617                // (`epidRecord.c:191-193`, `motorRecord.cc:3687-3698`).
4618                let links = stage.links.as_ref();
4619                instance.record.set_resolved_input_links(
4620                    crate::server::record::ResolvedInputLinks::new(
4621                        input_link_texts.own(),
4622                        stage.resolved,
4623                        links.map_or(&[][..], |l| l.resolved_link_fields.as_slice()),
4624                    ),
4625                );
4626
4627                // The cycle's single `fetch_values()` outcome reached
4628                // `set_fetch_gate_failed` (and sub/aSub's
4629                // `suppress_subroutine_run`) inside the input stage, under the
4630                // guard its loop held.
4631
4632                // Note: C EPICS LCNT prevents reentrant processing of the same
4633                // record within a single processing chain. In Rust, this is handled
4634                // by the `visited` HashSet (cycle detection) and the `processing`
4635                // AtomicBool guard. LCNT is not needed as a separate mechanism
4636                // because async processing with visited sets already prevents
4637                // the runaway loops that LCNT guards against in C.
4638
4639                // Tell the record whether device support already computed.
4640                // Records that override set_device_did_compute() use this to
4641                // skip their built-in computation (e.g., ai skips RVAL->VAL).
4642                // Note: field_io.rs may have already called set_device_did_compute(true)
4643                // for CA puts to VAL. We only set true here, never reset to false.
4644                if device_did_compute {
4645                    instance.record.set_device_did_compute(true);
4646                } else if instance.record.skips_forward_convert_when_undefined()
4647                    && instance.common.udf != 0
4648                {
4649                    // C output-record `else if (prec->udf) goto CONTINUE`
4650                    // (mbboRecord.c:210-213): an output record whose VAL is still
4651                    // undefined and had no value source this cycle (no VAL put —
4652                    // which clears UDF in `field_io` — and no closed-loop DOL fetch,
4653                    // which clears UDF at the DOL-apply site above) SKIPS the
4654                    // forward VAL->RVAL convert. Without this a `caput REC.RVAL 1`
4655                    // on a bare mbbo is clobbered by `convert()` recomputing
4656                    // `RVAL = VAL(=0)`. Same vehicle as the device-compute skip:
4657                    // `set_device_did_compute(true)` sets the record's own
4658                    // convert-skip flag, which `process()` consumes and clears. The
4659                    // per-cycle UDF clear below stays gated on `clears_udf()` /
4660                    // `device_did_compute` (both false here), so UDF stays 1 —
4661                    // matching C's `goto CONTINUE` leaving `prec->udf` untouched.
4662                    instance.record.set_device_did_compute(true);
4663                }
4664
4665                // TPRO: trace processing (C EPICS dbProcess prints context when TPRO>0)
4666                if instance.common.tpro != 0 {
4667                    eprintln!(
4668                        "[TPRO] {}: process (SCAN={:?}, PACT={})",
4669                        instance.name,
4670                        instance.common.scan,
4671                        instance.is_processing()
4672                    );
4673                }
4674
4675                // MS-class alarm propagation from input links. Mirrors C
4676                // `recGblInheritSevrMsg` (recGbl.c:263-281):
4677                //
4678                // * NMS  — do nothing.
4679                // * MS   — DEST gets `LINK_ALARM` (NOT the source stat),
4680                //          max-raised sevr, NO amsg propagation.
4681                // * MSI  — same as MS, but only when source.sevr == INVALID.
4682                // * MSS  — DEST gets source stat, max-raised sevr, source amsg
4683                //          (PR d0cf47c is the only branch that propagates msg).
4684                //
4685                // Folded BEFORE the record body, not after: C raises the link
4686                // severity inside `dbGetLink` (recGbl.c `recGblInheritSevr` is
4687                // called from the link's `getValue`), i.e. during the record's
4688                // input-fetch phase, so the body already sees it in `prec->nsev`.
4689                // `transformRecord.c:554` branches on exactly that
4690                // (`nsev >= INVALID_ALARM && ivla == DO_NOTHING`), and
4691                // `ProcessContext::nsev` below is that same `common.nsev` — one
4692                // owner, no second severity accumulator for records to consult.
4693                // Folding it here also gives C's tie-break: with equal severities
4694                // the link's LINK_ALARM lands first and `rec_gbl_set_sevr`'s
4695                // strict-greater test keeps it, exactly as in C where `dbGetLink`
4696                // precedes the record's own `recGblSetSevr` calls.
4697                for (ms, alarm) in links.map_or(&[][..], |l| l.link_alarms.as_slice()) {
4698                    super::links::inherit_sevr_msg(&mut instance.common, *ms, alarm);
4699                }
4700
4701                // Push framework-owned common state (UDF/UDFS/NSEV/PHAS/TSE/TSEL) so
4702                // the record's process() can see it — C records read
4703                // `dbCommon` directly (`epidRecord.c:195` checks
4704                // `pepid->udf`, `timestampRecord.c:90` checks `tse`,
4705                // `transformRecord.c:554` checks `ptran->nsev`).
4706                {
4707                    let inst = &mut *instance;
4708                    let ctx = inst.common.process_context();
4709                    inst.record.set_process_context(&ctx);
4710                }
4711                // Tell the record whether this is its own scheduled re-entry
4712                // (the `ReprocessAfter` timer, a put-notify completion) or a
4713                // fresh cycle. Only this path can be a continuation; the
4714                // `process_local` and simulated-read paths always run a fresh
4715                // `process()`, which is the hook's default.
4716                instance.record.set_process_continuation(is_continuation);
4717
4718                // Apply the aSub LFLG=READ resolution computed above (outside the
4719                // lock). The single apply owner; the bad-sub skip is carried on the
4720                // instance and consumed by `run_registered_subroutine`.
4721                if let Some(ds) = links.and_then(|l| l.asub_dynamic.as_ref()) {
4722                    apply_asub_dynamic_sub(instance, ds);
4723                }
4724
4725                // C `subRecord.c:144`+`:147` / `aSubRecord.c:216-218`:
4726                //     status = fetch_values(prec);
4727                //     if (status == 0) status = do_sub(prec);
4728                // A failed input link means the subroutine does not run this cycle
4729                // — VAL (and aSub's VALA..VALU) freeze, and none of `do_sub`'s
4730                // alarms (BAD_SUB / SOFT at BRSV) or its `udf = isnan(val)` update
4731                // happen. Same one-shot flag the aSub bad-SNAM skip arms, consumed
4732                // Invoke the registered subroutine (sub/aSub SNAM) before the
4733                // record body, on the same dispatch path as process_local. The
4734                // framework owns the SubroutineFn registry (the record's own
4735                // process() is a no-op for sub/aSub), so without this the main
4736                // engine path — SCAN, event, CA-put-to-PP, FLNK — never ran the
4737                // subroutine and VAL/VALA..VALU/OUTA..OUTU never updated.
4738                instance.run_registered_subroutine()?;
4739
4740                // Process
4741                let mut outcome = instance.record.process()?;
4742                // Merge deferred device actions into process outcome actions
4743                if let Some(deferred) = deferred_device_actions {
4744                    outcome.actions.extend(deferred);
4745                }
4746                let process_result = outcome.result;
4747                let process_actions = crate::server::record::ProcessActions::from(outcome.actions);
4748                let post_write_fields =
4749                    crate::server::record::CycleList::from(outcome.post_write_fields);
4750                // Captured before the `AsyncPendingNotify` `if let` below moves
4751                // `process_result`; consulted after the monitor epilogue to defer
4752                // the OUT/OEVT/FLNK tail (swait ODLY — see `CompleteDeferOutput`).
4753                let result_is_defer_output = matches!(
4754                    process_result,
4755                    crate::server::record::RecordProcessResult::CompleteDeferOutput
4756                );
4757                // Alarm-epilogue-only cycle (C `transformRecord.c:554-560`): the
4758                // alarm/timestamp commit below runs, the value side does not. See
4759                // `RecordProcessResult::CompleteAlarmOnly` and the `'epilogue`
4760                // break after `apply_timestamp`.
4761                let result_is_alarm_only = matches!(
4762                    process_result,
4763                    crate::server::record::RecordProcessResult::CompleteAlarmOnly
4764                );
4765
4766                (
4767                    process_result,
4768                    process_actions,
4769                    post_write_fields,
4770                    result_is_defer_output,
4771                    result_is_alarm_only,
4772                )
4773            };
4774
4775            if matches!(
4776                process_result,
4777                crate::server::record::RecordProcessResult::AsyncPending
4778            ) {
4779                // C `dbProcess` contract: when device support / record body
4780                // signals "async pending", `pact` MUST be true so subsequent
4781                // dbProcess attempts on the same record bail at the entry
4782                // guard. Previous Rust port assumed `process_local` had
4783                // already set it via the swap-true at function entry, but
4784                // this main path bypasses `process_local` and calls
4785                // `record.process()` directly — leaving `processing=false`.
4786                // Mirrors `aiRecord.c:122` and similar: `prec->pact = TRUE;
4787                // return 0;` before async work.
4788                guard.hold().enter_pact();
4789                guard.release();
4790
4791                // PACT stays set; skip alarm/timestamp/snapshot/OUT/FLNK.
4792                // But still execute any actions (e.g., ReprocessAfter for delayed re-entry).
4793                self.execute_process_actions(name, rec, process_actions, visited);
4794                // After every action this arm runs, so the ordering rule holds
4795                // whatever the outcome carried. The `ReprocessAfter` a pending
4796                // cycle usually arms cannot overtake this: the continuation
4797                // enters through `process_record_continuation`, which acquires
4798                // the same per-record gate this body still holds.
4799                self.publish_post_write_fields(name, post_write_fields);
4800                // The SIM continuation released the SDLY PACT and the body then
4801                // went async again: still run the restart check, which finds the
4802                // record busy again and leaves the queue head where it is (the
4803                // deferral is closed under its own restart).
4804                self.apply_pact_exit(name, rec, cycle_end.take());
4805                return Ok(());
4806            }
4807            if matches!(
4808                process_result,
4809                crate::server::record::RecordProcessResult::CompleteNoEmit
4810            ) {
4811                guard.release();
4812                // C `compressRecord.c:365` `if (status != 1)`: the record
4813                // completed synchronously but emitted no new value this cycle
4814                // (a compress still accumulating toward its next compressed
4815                // sample). C runs none of `prec->udf = FALSE`,
4816                // `recGblGetTimeStamp`, `monitor`, nor `recGblFwdLink` — so the
4817                // entire value-publication epilogue (UDF clear / alarm commit /
4818                // timestamp / monitor / FLNK) is skipped. PACT is already clear
4819                // on this synchronous path (only the async branches set it), so
4820                // there is nothing to release. `complete_no_emit()` carries no
4821                // actions and compress is soft (no deferred device actions), so
4822                // there is nothing to run — return without awaiting
4823                // `execute_process_actions`, which would enlarge this hot
4824                // recursive function's async frame (the FLNK chain nests one
4825                // poll frame per hop, unbounded as in C; the write guard
4826                // `instance` is released on return).
4827                debug_assert!(
4828                    process_actions.is_empty(),
4829                    "CompleteNoEmit must carry no process actions"
4830                );
4831                // No actions means no link writes to order against, so the rule
4832                // is satisfied here; the arm still publishes, so the mechanism
4833                // has no arm-shaped hole.
4834                self.publish_post_write_fields(name, post_write_fields);
4835                // The record is idle (this path sets no PACT), so a notify queued
4836                // on a released SDLY window replays straight away.
4837                self.apply_pact_exit(name, rec, cycle_end.take());
4838                return Ok(());
4839            }
4840            if let crate::server::record::RecordProcessResult::AsyncPendingNotify(fields) =
4841                process_result
4842            {
4843                // Intermediate notification (e.g. DMOV=0 at move start).
4844                // Execute device write first so the move command reaches the
4845                // driver, then fire the record's link writes, then flush
4846                // DMOV=0 etc. to monitors. This mirrors the C ordering on an
4847                // async (pact=1) pass: `motorRecord.cc:1491` runs `do_work`
4848                // (the device move), `motorRecord.cc:1495` then fires
4849                // `dbPutLink(&pmr->rlnk, ...)` UNCONDITIONALLY — on every pass
4850                // including the move-start pass where DMOV just went 0 — and
4851                // only `motorRecord.cc:1507` afterwards calls `monitor()`. So
4852                // the requested `WriteDbLink`/`WriteDbLinkNotify` actions must
4853                // run on the pending cycle as well; a put processes a PP target
4854                // even when the value is unchanged, so dropping them changes
4855                // downstream process counts (motor RLNK, asyn async writes).
4856                // The forward link stays deferred: C runs `recGblFwdLink` only
4857                // when `pmr->dmov != 0` (motorRecord.cc:1509), i.e. on async
4858                // completion, not on this pending pass.
4859                // Guarded: device write, timestamp, and the changed-field
4860                // snapshot. The data guard is released before the link-write /
4861                // notify awaits below (parking_lot guards are `!Send`).
4862                guard.release();
4863                let tsel = self.read_tsel(rec);
4864                let snapshot = {
4865                    let mut instance = rec.write();
4866                    if !is_soft {
4867                        if let Some(mut dev) = instance.device.take() {
4868                            let _ = dev.write(&mut *instance.record);
4869                            instance.device = Some(dev);
4870                        }
4871                    }
4872                    let inst = &mut *instance;
4873                    tsel.stamp(&inst.name, &mut inst.common, is_soft);
4874                    // The pass's posts, through the owner this path shares with
4875                    // `RecordInstance::process_local`.
4876                    let changed_fields = instance.collect_notify_posts(fields);
4877                    // C parity (calcoutRecord.c:277-282, sCalcoutRecord.c:400-404):
4878                    // a record that defers its output by ODLY via a timer
4879                    // (`callbackRequestProcessCallbackDelayed`) keeps `pact=TRUE`
4880                    // across the whole delay — it `return 0`s with pact still set,
4881                    // so the record stays ACTIVE and a concurrent `dbProcess`
4882                    // bails; the delayed callback re-enters (`pact==TRUE`, `dlya`
4883                    // branch) and clears pact. Mirror that: when this notify
4884                    // schedules a `ReprocessAfter` (the continuation that clears
4885                    // PACT at the `is_continuation` arm below), hold PACT now.
4886                    //
4887                    // The gate is the `ReprocessAfter` itself, not a flag: holding
4888                    // PACT is sound ONLY because a continuation is scheduled to
4889                    // release it. A notify WITHOUT a `ReprocessAfter` (motor's
4890                    // DMOV-pulse pass, which completes via its device callback and
4891                    // returns Complete on later passes — no timer continuation)
4892                    // gets no PACT-clearing re-entry, so it must NOT hold PACT or
4893                    // it would stick forever (spurious SCAN_ALARM). Tying the hold
4894                    // to the presence of its own release keeps the invariant by
4895                    // construction and leaves motor's path untouched.
4896                    let holds_pact_until_continuation = process_actions.iter().any(|a| {
4897                        matches!(a, crate::server::record::ProcessAction::ReprocessAfter(_))
4898                    });
4899                    if holds_pact_until_continuation {
4900                        instance.enter_pact();
4901                    }
4902                    changed_fields
4903                };
4904                // Partition exactly as the synchronous Complete path: link
4905                // writes fire here (C `dbPutLink` precedes `monitor()`);
4906                // delayed-reprocess / device-command actions run after the
4907                // notify (the Complete path runs them after the FLNK tail,
4908                // which is deferred to async completion on this pending pass).
4909                let (link_writes, deferred_actions): (Vec<_>, Vec<_>) =
4910                    process_actions.into_iter().partition(|a| {
4911                        matches!(
4912                            a,
4913                            crate::server::record::ProcessAction::WriteDbLink { .. }
4914                                | crate::server::record::ProcessAction::WriteDbLinkNotify { .. }
4915                        )
4916                    });
4917                self.execute_process_actions(name, rec, link_writes, visited);
4918                self.publish_post_write_fields(name, post_write_fields);
4919                {
4920                    let inst = rec.read();
4921                    inst.notify_from_snapshot(&snapshot, link_backing);
4922                }
4923                self.execute_process_actions(name, rec, deferred_actions, visited);
4924                // Same as the `AsyncPending` arm: run the restart check through the
4925                // single drain owner, which is a no-op if this pass re-took PACT.
4926                self.apply_pact_exit(name, rec, cycle_end.take());
4927                return Ok(());
4928            }
4929
4930            // Async-completion PACT clear for the `ReprocessAfter`
4931            // continuation path. C parity `dbAccess.c:583` —
4932            // `prset->process(precord)` for a record whose first cycle
4933            // returned async-pending is the *completion* re-entry; the
4934            // record support clears `pact` itself inside `process()`
4935            // (e.g. `aiRecord.c` second pass sets `prec->pact = FALSE`).
4936            //
4937            // A record that returns `AsyncPending` AND emits a
4938            // `ProcessAction::ReprocessAfter` is re-entered here via
4939            // `process_record_continuation` (`is_continuation == true`,
4940            // PACT entry guard skipped). Reaching this point means the
4941            // continuation's `process()` did NOT return async-pending
4942            // again (both async branches above return early), so the
4943            // async cycle is genuinely complete. The non-continuation
4944            // async-device path clears `processing` in
4945            // `complete_async_record_inner`; the continuation path has
4946            // no such callback, so without this clear `processing`
4947            // stays `true` forever — every later foreign
4948            // `process_record_with_links` then trips the PACT entry
4949            // guard, counts to MAX_LOCK, and raises a spurious
4950            // SCAN_ALARM. Clearing here (record still write-locked,
4951            // before the OUT/FLNK tail) mirrors the C ordering where
4952            // `pact` is already `FALSE` when `recGblFwdLink` runs.
4953            //
4954            // The release is carried to this cycle's `recGblFwdLink` tail below
4955            // as the `PactExit`, which is where C runs the restart check
4956            // (`recGbl.c:295` → `dbNotifyCompletion` → `restartCheck`).
4957            // Restarting at the `pact = FALSE` store instead — before the
4958            // OUT/FLNK tail — would let the replayed put process the record
4959            // concurrently with the tail it is still running.
4960            // dfanout's SELL read sits between `recGblGetTimeStamp` and
4961            // `checkAlarms` (`dfanoutRecord.c:126-127`), so it must run before
4962            // Segment C: a failed read is a `setLinkAlarm`, and the line after
4963            // it is the `nsev < INVALID_ALARM` test that decides between
4964            // `push_values` and the IVOA branch. Taken outside the write guard
4965            // below because the read takes its own locks. The owner ignores
4966            // every record whose C reads SELL elsewhere.
4967            if plan.reads_sell {
4968                guard.release();
4969                self.read_sell_into_seln(rec, super::links::SellPhase::BeforeAlarms);
4970            }
4971
4972            // The TSEL half of this cycle's `recGblGetTimeStampSimm`, read here
4973            // because the store below happens under Segment C's data guard and
4974            // the link read cannot. It is the record's own input stage —
4975            // `fetch_values` / `readValue`, both already done — that C lets move
4976            // the TSEL source before this read, so reading it here and storing
4977            // it at the stamp point is C's order with the lock split out.
4978            let tsel = match Self::tsel_link(guard.hold()) {
4979                None => super::TselStamp::None,
4980                Some(link) => {
4981                    guard.release();
4982                    self.read_tsel_link(rec, link)
4983                }
4984            };
4985
4986            // Segment C (guarded): the alarm / UDF / timestamp epilogue, the IVOA
4987            // output veto, and the output-time-link read list. Re-acquire the data
4988            // lock (Segments A/B committed their writes under their own guards).
4989            // On the alarm-only path this segment `break`s the whole `'epilogue`.
4990            let (restamps_after, skip_out, out_time_reads) = {
4991                let instance = guard.hold();
4992                // Folded into the guard the moment it is minted, and never
4993                // threaded onward by value: one carrier, so the exits between
4994                // here and the tail — the `?` on the device write, the
4995                // async-output `write_begin` early return, the `break 'epilogue`
4996                // — all release it without a site of their own.
4997                cycle_end.merge_in(if is_continuation {
4998                    instance.leave_pact()
4999                } else {
5000                    instance.pact_exit_without_release()
5001                });
5002
5003                // NOTE: the MS-class input-link alarm propagation
5004                // (`inherit_sevr_msg`) already ran BEFORE the record body — see the
5005                // fold site above `set_process_context`. C raises it inside
5006                // `dbGetLink`, so the body must be able to read the resulting
5007                // `nsev` (transform IVLA="Do Nothing").
5008
5009                // UDF update — C parity (aiRecord.c:285, calcRecord.c
5010                // checkAlarms, int64inRecord.c:144): clear UDF only when
5011                // this cycle produced a *defined* value. A NaN computed
5012                // value (calc divide-by-zero) or a failed link read that
5013                // left VAL un-updated must keep UDF true so the following
5014                // `recGblCheckUDF` raises UDF_ALARM at severity UDFS.
5015                //
5016                // This MUST run before `evaluate_alarms()` (which calls
5017                // `rec_gbl_check_udf`): C records set `prec->udf` inside
5018                // `process()` before `checkAlarms()` runs.
5019                //
5020                // The re-derive fires only when a value was actually SOURCED or
5021                // RECOMPUTED this cycle — the C invariant. Two record classes
5022                // reach it:
5023                //   * `clears_udf()` true: records whose C `process()` re-derives
5024                //     UDF UNCONDITIONALLY every cycle, whatever the read did
5025                //     (`aiRecord.c:161` `if(status==0) prec->udf = isnan(val)`,
5026                //     with a soft read's `status==2` folded to 0 — so a constant
5027                //     INP still re-derives). ai/ao/bi/longin/calc/mbbi… .
5028                //   * `device_did_compute`: a value was sourced this cycle — a
5029                //     real soft-channel INP read landed a value, or device
5030                //     support's `read()` computed one. This is how the
5031                //     sourced-only records (`clears_udf()` false: stringin, bo,
5032                //     longout, …) get their UDF cleared on a genuine read, exactly
5033                //     like C `devSiSoft.c::read_stringin` clears UDF only inside
5034                //     the `!dbLinkIsConstant` read branch.
5035                //
5036                // A cycle that sources nothing — e.g. a `caput UDF x` that drove
5037                // processing on a Passive record with a constant/empty INP — must
5038                // NOT re-derive UDF on a sourced-only record: the client's UDF put
5039                // stands (softIoc-verified: `caput REC.UDF 1` keeps UDF=1 for
5040                // stringin/lso/bo/longout, unlike ai/longin which re-derive to 0).
5041                // DOL-sourced output records clear UDF in their own DOL branch
5042                // above; the subroutine records (aSub) clear it in the subroutine
5043                // run (C `do_sub`), so neither needs `device_did_compute` here.
5044                //
5045                // …and it is gated on the READ STATUS, which is C's own shape:
5046                // `if (status == 0) prec->udf = <derive>` (aiRecord.c:161,
5047                // mbbiDirectRecord.c:155-164). A cycle whose soft INP read
5048                // failed sourced nothing, so it re-derives nothing and UDF
5049                // stands — that is what leaves `if (prec->udf) recGblSetSevr(
5050                // prec, UDF_ALARM, ...)` reachable. The array records and
5051                // compress are the documented exceptions
5052                // ([`Record::derives_udf_on_read_failure`]).
5053                //
5054                // A DEVICE read that produced no value is the same case and
5055                // takes the same arm: C's `-1`/`-2` returns miss `if(status==0)`
5056                // just as a failed soft read does, so the gate is one condition
5057                // over both sources rather than a per-record-type exception at
5058                // the ai site ([`DeviceReadStatus::read_failed`]).
5059                let derive_udf = if read_produced_no_value {
5060                    instance.record.derives_udf_on_read_failure()
5061                } else if device_read_computed {
5062                    // C `return 2` from a DEVICE dset. Whether the record
5063                    // re-derives on top of what the dset already wrote is the
5064                    // record's own rule and is not uniform: `aiRecord.c:158-161`
5065                    // folds 2 into 0 first and re-derives, `biRecord.c:136-141`
5066                    // and its four twins keep the assignment inside
5067                    // `if (status == 0)` and never reach it.
5068                    instance.record.rederives_udf_on_computed_read()
5069                } else {
5070                    plan.clears_udf || device_did_compute
5071                };
5072                if derive_udf {
5073                    instance.common.udf = instance.record.value_is_undefined() as u8;
5074                }
5075
5076                // Per-record alarm hook — record-type-specific STATE / COS
5077                // / limit / SOFT alarms (C `checkAlarms()`). Records that
5078                // have migrated their alarm logic here raise into
5079                // `nsta`/`nsev`; the rest fall back to the framework's
5080                // centralised `evaluate_alarms` match below.
5081                {
5082                    let inst = &mut *instance;
5083                    inst.record.check_alarms(&mut inst.common);
5084                }
5085
5086                // Evaluate alarms (accumulates into nsta/nsev)
5087                instance.evaluate_alarms();
5088
5089                // Device support alarm/timestamp override
5090                if !is_soft {
5091                    let (dev_alarm, dev_ts, dev_utag) = if let Some(ref dev) = instance.device {
5092                        (dev.last_alarm(), dev.last_timestamp(), dev.last_utag())
5093                    } else {
5094                        (None, None, None)
5095                    };
5096                    if let Some((stat, sevr)) = dev_alarm {
5097                        use crate::server::recgbl::rec_gbl_set_sevr;
5098                        rec_gbl_set_sevr(
5099                            &mut instance.common,
5100                            stat,
5101                            crate::server::record::AlarmSeverity::from_u16(sevr),
5102                        );
5103                    }
5104                    if let Some(ts) = dev_ts {
5105                        instance.common.time = ts;
5106                    }
5107                    // C device support writes `prec->utag` directly during
5108                    // `read()` — the event-system pulse-id path, since
5109                    // `epicsTimeStamp` carries no tag. Adopt the device's
5110                    // userTag when it supplies one; read in the same `dev`
5111                    // borrow as the timestamp above so the time/tag pair is a
5112                    // single consistent device snapshot.
5113                    if let Some(utag) = dev_utag {
5114                        instance.common.utag = utag;
5115                    }
5116                }
5117
5118                // The soft-channel half of the same override: for a `Soft
5119                // Channel` record the dset IS the device, and the timestamp it
5120                // supplies is the INP source's (`devAiSoft.c:59-60`). `None`
5121                // unless the read succeeded under C's TSE=-2 + constant-TSEL
5122                // gate, so a record that is not asking for device time, or
5123                // whose read failed, keeps whatever `apply_timestamp` gives it.
5124                let links = stage.links.as_ref();
5125                if let Some(ts) = links.and_then(|l| l.inp_source_time) {
5126                    instance.common.time = ts;
5127                }
5128                // The calc half of the same adoption (`lnkCalc.c:581`) — see
5129                // where `inp_source_utag` is built for why only that link
5130                // class supplies one.
5131                if let Some(tag) = links.and_then(|l| l.inp_source_utag) {
5132                    instance.common.utag = tag;
5133                }
5134
5135                // pvalink `time=true` adopts the latched upstream timestamp
5136                // into the owning record. `external_link_time` returned
5137                // `None` unless the lset signalled the option, so a `Some`
5138                // here is the operator-requested remote timestamp: the remote
5139                // NT `timeStamp` while connected, or the disconnect-event time
5140                // while the subscription is down (pvxs `snap_time = e.time`,
5141                // adopted on the invalid read — `pvxs/ioc/pvalink_lset.cpp:268-270`).
5142                // Apply BEFORE `apply_timestamp` so the upstream value
5143                // survives the soft-channel TSE=0 default (`apply_timestamp`
5144                // would otherwise stamp wall-clock-now on top).
5145                if let Some((secs, ns, utag)) = links.and_then(|l| l.inp_link_remote_time) {
5146                    let secs = secs.max(0) as u64;
5147                    let ns = ns.max(0) as u32;
5148                    instance.common.time =
5149                        std::time::UNIX_EPOCH + std::time::Duration::new(secs, ns.min(999_999_999));
5150                    // adopt the upstream `timeStamp.userTag` alongside the
5151                    // time, mirroring pvxs PR-added `precord->utag = snap_tag`
5152                    // next to `precord->time = snap_time` in the `time=true`
5153                    // branch. The tag is already widened without sign
5154                    // extension by the lset; `0` when the source carries
5155                    // none. `apply_timestamp` never touches `utag`, so this
5156                    // survives regardless of the TSE branch below.
5157                    instance.common.utag = utag;
5158                    // Whether the adopted time SURVIVES is the record's
5159                    // declared TSE, not something to arrange here: pvxs writes
5160                    // `precord->time` and `precord->utag` and nothing else
5161                    // (`pvalink_lset.cpp:269-272`), so a `time=true` link needs
5162                    // `field(TSE,"-2")` for `recGblGetTimeStamp` to leave the
5163                    // pair alone — which is exactly what pvxs's own test
5164                    // database declares (`test/testpvalink.db:140,230`).
5165                    // Writing -2 here instead made the field report a value the
5166                    // database never declared.
5167                }
5168
5169                // IVOA gate severity for a redirected SIMM output. C decides
5170                // `if (prec->nsev < INVALID_ALARM)` at the `writeValue` call
5171                // (aoRecord.c:197) using the severity `checkAlarms` produced —
5172                // BEFORE `writeValue` raises SIMM_ALARM. Snapshot the real
5173                // (pre-SIMM) pending severity here so a `SIMS=INVALID` never flips
5174                // the IVOA decision: with a finite, in-range VAL the IVOA veto must
5175                // NOT fire and C still writes OVAL to SIOL. For a non-simulated
5176                // record no SIMM_ALARM is raised below, so `nsev` here equals the
5177                // committed `sevr`, leaving the IVOA gate unchanged.
5178                let real_sev = instance.common.nsev;
5179
5180                // SIMM simulation severity on a redirected OUTPUT record. C
5181                // `writeValue` raises `recGblSetSevr(prec, SIMM_ALARM, prec->sims)`
5182                // AFTER `checkAlarms` (aoRecord.c:196 -> :570 / boRecord.c:219 ->
5183                // :436), so a coincident limit/state alarm of equal severity keeps
5184                // its stat/amsg (set first; `rec_gbl_set_sevr` is strict-greater).
5185                // A simulated INPUT instead raises this inside
5186                // `check_simulation_mode` before its body, because `readValue`
5187                // precedes the body. Raised here (after the alarm hooks, before the
5188                // commit) it still folds into this cycle's committed SEVR.
5189                if let Some((_, sims, _)) = &sim_output {
5190                    let sev = crate::server::record::AlarmSeverity::from_u16(*sims as u16);
5191                    crate::server::recgbl::rec_gbl_set_sevr(
5192                        &mut instance.common,
5193                        crate::server::recgbl::alarm_status::SIMM_ALARM,
5194                        sev,
5195                    );
5196                }
5197
5198                // Apply timestamp based on TSE. BEFORE the output stage: C
5199                // `aoRecord.c:190` stamps the record before `writeValue` "so it
5200                // will be up to date if any downstream records fetch it via TSEL".
5201                //
5202                // A `restamps_time_after_completion` record (sseq) restamps at the
5203                // very END of its completion instead — C `sseqRecord.c::asyncFinish`
5204                // posts VAL (`:474`) and runs `recGblFwdLink` (`:499`) BEFORE
5205                // `recGblGetTimeStamp` (`:501`). Skip the pre-output restamp here so
5206                // this cycle's VAL monitor carries the record's pre-update
5207                // timestamp; the deferred restamp after the forward-link tail
5208                // advances TIME for the BUSY post and the next cycle.
5209                //
5210                // mbbo/mbboDirect are a second exception: C `mbboRecord.c:210-221`
5211                // takes `else if (prec->udf) goto CONTINUE`, jumping PAST this
5212                // pre-output `recGblGetTimeStampSimm`. So a soft (sync) UDF
5213                // mbbo/mbboDirect never stamps here; TIME stays at the epoch until
5214                // VAL is defined. Only the SYNC first-pass stamp is skipped — the
5215                // async-completion re-entry (`complete_async_record_inner`) stamps
5216                // unconditionally, matching C's `if (pact)` re-stamp
5217                // (mbboRecord.c:256-258).
5218                let restamps_after = plan.restamps_time_after_completion;
5219                // Either way into C's `goto CONTINUE` skips the same
5220                // `recGblGetTimeStampSimm`: `else if (prec->udf)`
5221                // (mbboRecord.c:210) and the failed closed-loop DOL read
5222                // (mbboRecord.c:205) jump to the identical label.
5223                let skips_ts_undef = plan.skips_timestamp_when_undefined
5224                    && (instance.common.udf != 0 || links.is_some_and(|l| l.dol_read_failed));
5225                if !restamps_after && !skips_ts_undef {
5226                    let inst = &mut *instance;
5227                    tsel.stamp(&inst.name, &mut inst.common, is_soft);
5228                }
5229                // NOTE: UDF was already updated before `evaluate_alarms`
5230                // above — keyed on `value_is_undefined()` so a NaN result
5231                // keeps UDF true and UDF_ALARM is raised this cycle. Do
5232                // NOT clear UDF unconditionally here.
5233
5234                // C `transformRecord.c:554-560` — the record body asked for the
5235                // ALARM epilogue only (IVLA="Do Nothing" on an INVALID input):
5236                // `recGblGetTimeStamp` + `checkAlarms` + `recGblResetAlarms` have
5237                // now run, and C `return`s here. Everything below is C's
5238                // `monitor()` + output + `recGblFwdLink()` — none of it happens on
5239                // that cycle. The SEVR/STAT/AMSG/ACKS posts `recGblResetAlarms`
5240                // itself makes are the only events the cycle emits; VAL and the
5241                // value fields are NOT posted and their last-posted trackers stay
5242                // put (C leaves `LA..LP` un-updated), so the next publishing cycle
5243                // re-detects the change.
5244                //
5245                // This is C's OTHER `recGblResetAlarms` call site — the record
5246                // body's own, not `monitor()`'s — and the cycle performs no output,
5247                // so the commit happens here and the path returns.
5248                if result_is_alarm_only {
5249                    // This path performs no output — it drops the cycle's
5250                    // actions by design — so a withheld store would have
5251                    // nothing to be ordered against and nothing to publish it.
5252                    debug_assert!(
5253                        post_write_fields.is_empty(),
5254                        "CompleteAlarmOnly runs no outputs and must carry no post-write fields"
5255                    );
5256                    let alarm_result =
5257                        crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
5258                    let alarm_posts = alarm_field_posts(&instance.common, &alarm_result);
5259                    let snapshot = crate::server::record::ProcessSnapshot::new();
5260                    let posts = publish_cycle(instance, &snapshot, link_backing, alarm_posts);
5261                    break 'epilogue (
5262                        // No forward link of EITHER kind: the comment above is
5263                        // C's `return` before `recGblFwdLink`. The external
5264                        // half used to escape it, because the tail re-derived
5265                        // that half for itself out of the record instead of
5266                        // taking the answer this arm hands it.
5267                        crate::server::record::record_instance::ForwardTarget::None,
5268                        crate::server::record::ProcessActions::new(),
5269                        false,
5270                        restamps_after,
5271                        posts,
5272                    );
5273                }
5274
5275                // **The IVOA owner** — the single site that decides what an INVALID
5276                // cycle does with its outputs, for EVERY output path of this
5277                // record: its own OUT, the SIOL redirect, the generic multi-output
5278                // pairs, and the dfanout `OUTn` push. Each of those consumes the
5279                // decision (`skip_out`, plus the IVOV the record has by then
5280                // stored in its own output field); none re-derives it.
5281                //
5282                // C makes the decision exactly once, BEFORE any output — at the
5283                // `writeValue` call (`if (prec->nsev < INVALID_ALARM)`,
5284                // aoRecord.c:197) and at dfanout's push (`dfanoutRecord.c:128`).
5285                // An output path that re-reads `nsev` after the writes have begun
5286                // reads an alarm the writes THEMSELVES raised (a failed put's
5287                // LINK_ALARM/INVALID, dbLink.c:444-446) and acts on a decision C
5288                // never made — e.g. overwriting VAL with IVOV on a cycle whose only
5289                // INVALID came from the failed push.
5290                //
5291                // Gate on the real (pre-SIMM) severity `real_sev` snapshotted above
5292                // — C decides IVOA before `writeValue` raises SIMM_ALARM, so a
5293                // `SIMS=INVALID` simulation severity does not trigger the veto (the
5294                // committed `sevr` may be INVALID from SIMM while the record's own
5295                // alarm is not).
5296                // The cycle drives no outputs when the type has no output stage
5297                // (`ProcessPlan::output_stage`: C `calcRecord.c::process` has no
5298                // OUT lines) or IVOA vetoes them on an INVALID cycle.
5299                let skip_out = if !plan.output_stage {
5300                    true
5301                } else if real_sev == crate::server::record::AlarmSeverity::Invalid {
5302                    let ivoa = instance
5303                        .record
5304                        .get_field("IVOA")
5305                        .and_then(|v| v.to_menu_index())
5306                        .unwrap_or(0);
5307                    match ivoa {
5308                        1 => true, // Don't drive outputs
5309                        2 => {
5310                            // Set output to IVOV. Each record type knows
5311                            // which field its OUT writeback consumes — see
5312                            // [`Record::apply_invalid_output_value`]. The
5313                            // earlier path special-cased `calcout`
5314                            // (OVAL) and fell back to `set_val` (VAL) for
5315                            // every other record. That hid a real bug:
5316                            // ao/lso/bo/mbbo/busy left their OVAL/RVAL
5317                            // staging field stale, so the OUT writeback —
5318                            // which reads `OVAL.or(VAL)` — sent the
5319                            // pre-IVOA value to the linked record. Per-type
5320                            // overrides now apply IVOV to the field that
5321                            // matches the C convention.
5322                            // C's IVOA=2 arm cannot fail. It is a plain store into the
5323                            // record's own fields — `prec->val = prec->ivov`
5324                            // plus the mask conversion (`boRecord.c:231-238`),
5325                            // `strncpy(prec->val, prec->ivov, sizv-1)` plus
5326                            // `len` (`lsoRecord.c:131-137`) — with C's only
5327                            // failure arm reserved for an ILLEGAL IVOA choice
5328                            // (`boRecord.c:241-244`), which this `match` has
5329                            // already excluded. So an `Err` here is a port bug
5330                            // in the record's `apply_invalid_output_value` /
5331                            // `put_field` pair, never a runtime condition, and
5332                            // discarding it silently is what let lso's arm be a
5333                            // complete no-op for a whole round: `put_field` had
5334                            // no `"OVAL"` case, so the `?` returned
5335                            // `FieldNotFound` before VAL was ever written and
5336                            // the record kept its stale value with no monitor.
5337                            // Loud in test/debug; release behaviour unchanged,
5338                            // because C has no alarm for this case to copy.
5339                            if let Some(ivov) = instance.record.get_field("IVOV") {
5340                                let applied = instance.record.apply_invalid_output_value(ivov);
5341                                debug_assert!(
5342                                    applied.is_ok(),
5343                                    "{}: IVOA=Set_output_to_IVOV could not apply IVOV: {:?}",
5344                                    instance.record.record_type(),
5345                                    applied.err()
5346                                );
5347                            }
5348                            false
5349                        }
5350                        _ => false, // Continue normally
5351                    }
5352                } else {
5353                    false
5354                };
5355
5356                // Output-time input links (swait DOL). C
5357                // `swaitRecord.c::execOutput` (763-772) fetches DOL through
5358                // `recDynLinkGet` at OUTPUT time — not in the input-fetch phase —
5359                // and only on a cycle whose output actually fires, so DOLD carries
5360                // the value the link holds at the moment of the write (ODLY
5361                // delay-end included) and a non-firing cycle neither refreshes nor
5362                // posts it. Run here, after the IVOA veto and before the OUT stage
5363                // composes `out_info`, so the fresh value is the one written and
5364                // the changed field still reaches this cycle's snapshot.
5365                //
5366                // The write lock is released across the read (the link may target
5367                // another record) and re-taken, the same way the pre-process
5368                // `ReadDbLink` stage above does it; the record stays claimed by the
5369                // `processing` guard meanwhile.
5370                let out_time_reads: Option<Vec<(String, &'static str)>> = if skip_out {
5371                    None
5372                } else {
5373                    let out_time_links = instance.record.output_time_input_links();
5374                    if !out_time_links.is_empty() && instance.record.should_output() {
5375                        Some(
5376                            out_time_links
5377                                .iter()
5378                                .filter_map(|(link_field, value_field)| {
5379                                    Some((instance.link_text(link_field)?, *value_field))
5380                                })
5381                                .collect(),
5382                        )
5383                    } else {
5384                        None
5385                    }
5386                };
5387
5388                (restamps_after, skip_out, out_time_reads)
5389            };
5390
5391            // await 2 (guard-free): output-time input-link (swait DOL) reads. The
5392            // write lock is released across the reads (a link may target another
5393            // record); the record stays claimed by the `processing` gate.
5394            let mut out_time_fetched: Option<Vec<(&'static str, EpicsValue)>> = None;
5395            if out_time_reads.is_some() {
5396                guard.release();
5397            }
5398            if let Some(out_time_reads) = out_time_reads {
5399                for (link, value_field) in out_time_reads {
5400                    // A bare read, no `process_passive_db_source`: C's DOL is a
5401                    // `recDynLink` (CA-style) input, which never process-passives its
5402                    // source. `NoData` (constant DOL) writes nothing — the value field
5403                    // keeps what it holds, as in C where a swait DOL that is not a PV
5404                    // name never registers a recDynLink and so never delivers.
5405                    let parsed = crate::server::record::parse_link_v2(&link);
5406                    if let Some(value) = self.db_try_get_link(rec, &parsed).value() {
5407                        out_time_fetched
5408                            .get_or_insert_with(Vec::new)
5409                            .push((value_field, value));
5410                    }
5411                }
5412            }
5413
5414            // Segment D (guarded): apply the output-time reads, queue OEVT, compose
5415            // the OUT-stage `out_info` plan, and capture the OUT-link source fields.
5416            // Yields those; the guard then closes so the output-write awaits below
5417            // hold no `!Send` guard (a self/cyclic OUT link would also dead-lock the
5418            // non-reentrant gate). The async device-write branch inside the
5419            // `out_info` match returns straight from the function.
5420            // The cycle's link-carried writes, split off the record's other
5421            // actions; `None` when it has none, which is the usual cycle.
5422            let (link_writes, process_actions): (
5423                Option<Vec<_>>,
5424                crate::server::record::ProcessActions,
5425            ) = if process_actions.is_empty() {
5426                (None, process_actions)
5427            } else {
5428                let (writes, rest): (Vec<_>, Vec<_>) = process_actions.into_iter().partition(|a| {
5429                    matches!(
5430                        a,
5431                        crate::server::record::ProcessAction::WriteDbLink { .. }
5432                            | crate::server::record::ProcessAction::WriteDbLinkNotify { .. }
5433                    )
5434                });
5435                ((!writes.is_empty()).then_some(writes), rest.into())
5436            };
5437            // Whether this cycle has an output stage at all — the one rule that
5438            // decides both the output segment and the guard release it needs.
5439            // It is the union of every output kind the segment below can
5440            // perform; each of its dispatchers is a no-op under the negation,
5441            // so a cycle without an output (a stock `calc`: no OUT stage, no
5442            // simulation, no write actions) runs none of them, and reads
5443            // nothing for a `OutLinkSrc` it would hand to no one.
5444            let has_output = !skip_out
5445                || plan.multi_output_dispatch
5446                || sim_output.is_some()
5447                || link_writes.is_some()
5448                || !post_write_fields.is_empty();
5449            let dispatched = if !has_output {
5450                super::links::MultiOutDispatch::default()
5451            } else {
5452                let (out_info, src_putf, src_notify, src_alarm) = {
5453                    let instance = guard.hold();
5454                    if let Some(out_time_fetched) = out_time_fetched {
5455                        for (field, value) in out_time_fetched {
5456                            let _ = instance.record.put_field(field, value);
5457                        }
5458                    }
5459
5460                    // OEVT: queue the output event when the output fires — the
5461                    // event-subsystem twin of the OUT write, gated by the SAME IVOA
5462                    // Don't_drive veto (`skip_out`). C
5463                    // `calcout`/`sCalcout`/`aCalcout` `execOutput` posts
5464                    // `postEvent(epvt)` / `post_event(oevt)` right after `writeValue`
5465                    // in every OUT-driving branch and never on Don't_drive;
5466                    // `output_event()` folds in the record's own OOPT/calc-fail/ODLY
5467                    // output-fire decision. Spawned (not inline) like
5468                    // `dispatch_event_record` so the woken `SCAN="Event"` records run
5469                    // on the callback path, not recursively inside this cycle.
5470                    if !skip_out {
5471                        if let Some(event_name) = instance.record.output_event() {
5472                            let db = self.clone();
5473                            // Middle band, not this record's PRIO: C `postEvent`
5474                            // fires one `callbackRequest` per non-empty band and
5475                            // each carries the *scanned* record's priority
5476                            // (`dbScan.c:513-527`), a fan-out the port's single
5477                            // Event list cannot express (`scan_index.rs`
5478                            // `post_event_named`). The poster's own PRIO is not
5479                            // the answer, so this keeps `callbackRequest`'s
5480                            // general band (`callback.h:42`).
5481                            crate::runtime::task::spawn_background(
5482                                crate::runtime::task::CallbackPriority::Medium,
5483                                async move {
5484                                    db.post_event_named(&event_name).await;
5485                                },
5486                            );
5487                        }
5488                    }
5489
5490                    // OUT stage: soft channel -> link put, non-soft -> device.write()
5491                    // Must run BEFORE check_deadband_ext so MLST is not prematurely
5492                    // updated for async writes that return early.
5493                    let out_info = if sim_output.is_some() {
5494                        // Simulated OUTPUT record: C `writeValue` redirects the output
5495                        // to SIOL (`dbPutLink(&prec->siol, ..., &prec->oval)`) INSTEAD
5496                        // of the real device write / soft OUT-link write. The redirect
5497                        // is applied from the OUT epilogue by `write_simulated_output_siol`
5498                        // (it reads the post-body OVAL/RVAL), so the normal device/OUT
5499                        // write is suppressed here.
5500                        None
5501                    } else if sim_write_aborted {
5502                        // C `writeValue` returned before writing — either the
5503                        // `default:` arm (`recGblSetSevr(SOFT_ALARM, INVALID_ALARM);
5504                        // status = -1;`) or a failed SIML read. Both return BEFORE the
5505                        // device write and BEFORE the SIOL redirect, so this cycle
5506                        // performs no output at all.
5507                        None
5508                    } else if skip_out {
5509                        None
5510                    } else {
5511                        let can_dev_write = instance.record.can_device_write();
5512                        // The soft OUT-link value THIS DTYP's dset would put — VAL/OVAL for
5513                        // "Soft Channel", RVAL for "Raw Soft Channel". `None` = not a soft
5514                        // output dset. See `RecordInstance::soft_output_value`.
5515                        let soft_out = instance.soft_output_value();
5516                        let record_should_output = instance.record.should_output();
5517                        if !can_dev_write {
5518                            // Non-output records (calcout, etc.) may still have a
5519                            // soft OUT link (DB or external ca://`/`pva://`).
5520                            // Write OVAL to OUT when the record says should_output().
5521                            if record_should_output && instance.parsed_out.is_writable_out_link() {
5522                                let out_val = instance.record.output_link_value();
5523                                out_val.map(|v| (instance.parsed_out.clone(), v))
5524                            } else {
5525                                None
5526                            }
5527                        } else if let Some(out_val) = soft_out {
5528                            if !record_should_output {
5529                                // epics-base 7.0.8 OOPT: gate the soft OUT-link
5530                                // write on the record's `should_output()`. For
5531                                // longout/calcout with OOPT != 0 this lets a
5532                                // condition-not-met cycle silently skip the link
5533                                // write without disturbing alarms / monitors.
5534                                None
5535                            } else if instance.parsed_out.is_writable_out_link() {
5536                                out_val.map(|v| (instance.parsed_out.clone(), v))
5537                            } else {
5538                                None
5539                            }
5540                        } else if device_callback
5541                            && instance
5542                                .device
5543                                .as_ref()
5544                                .is_some_and(|d| d.output_callback_readback())
5545                        {
5546                            // Driver-callback (`asyn:READBACK`) cycle on a hardware output
5547                            // whose device support takes the callback-readback branch: the
5548                            // new value was read back into VAL by the read stage above;
5549                            // writing it here would re-assert the setpoint to the driver and
5550                            // re-trigger it (the AD `Acquire` loop). C
5551                            // `devAsynInt32.c::processBo` takes the `newOutputCallbackValue`
5552                            // readback branch and never calls `processCallbackOutput`'s
5553                            // `write()` on a callback cycle. Devices without that contract
5554                            // (`output_callback_readback` false — devMotorAsyn) run their
5555                            // output stage on callback cycles like any other C `dbProcess`:
5556                            // the motor record's retry / backlash / NTM-stop commands are
5557                            // emitted on exactly these passes.
5558                            None
5559                        } else if !record_should_output {
5560                            // OOPT gating for hardware outputs (longout DTYP=...).
5561                            // Skip the device write when the OOPT predicate is
5562                            // not satisfied; the record's val/timestamp/snapshot
5563                            // path still runs so monitor consumers see the value
5564                            // change even on a non-output cycle.
5565                            None
5566                        } else {
5567                            if let Some(mut dev) = instance.device.take() {
5568                                // Try async write_begin() first
5569                                match dev.write_begin(&mut *instance.record) {
5570                                    Ok(Some(completion)) => {
5571                                        // Async write submitted -- set PACT, return early.
5572                                        // complete_async_record will handle deadband, snapshot,
5573                                        // notification, and FLNK when the write completes.
5574                                        instance.enter_pact();
5575                                        instance.device = Some(dev);
5576                                        let rec_name = instance.name.clone();
5577                                        let timeout = std::time::Duration::from_secs(5);
5578                                        let db = self.clone();
5579                                        let prio = instance.common.callback_priority();
5580                                        crate::runtime::task::spawn_background(prio, async move {
5581                                            let _ =
5582                                                crate::runtime::task::spawn_blocking_background(
5583                                                    prio,
5584                                                    move || completion.wait(timeout),
5585                                                )
5586                                                .await;
5587                                            let _ = db.complete_async_record(&rec_name).await;
5588                                        });
5589                                        // Not an end: `complete_async_record_inner`
5590                                        // owns this cycle's tail now, and mints its own
5591                                        // token from the record when the write lands.
5592                                        cycle_end.hand_off_to_async_completion();
5593                                        return Ok(());
5594                                    }
5595                                    Ok(None) => {
5596                                        // No async support -- fall back to synchronous write
5597                                        if let Err(e) = dev.write(&mut *instance.record) {
5598                                            eprintln!(
5599                                                "device write error on {}: {e}",
5600                                                instance.name
5601                                            );
5602                                            // C device support raises the write failure
5603                                            // through `recGblSetSevr` (a PENDING alarm),
5604                                            // and `process()`'s `monitor()` commits it in
5605                                            // the same cycle — the commit now follows this
5606                                            // output stage, so the pending raise is what
5607                                            // reaches SEVR/STAT (a direct `stat`/`sevr`
5608                                            // poke would be overwritten by the commit).
5609                                            crate::server::recgbl::rec_gbl_set_sevr(
5610                                                &mut instance.common,
5611                                                crate::server::recgbl::alarm_status::WRITE_ALARM,
5612                                                crate::server::record::AlarmSeverity::Invalid,
5613                                            );
5614                                        }
5615                                    }
5616                                    Err(e) => {
5617                                        eprintln!(
5618                                            "device write_begin error on {}: {e}",
5619                                            instance.name
5620                                        );
5621                                        crate::server::recgbl::rec_gbl_set_sevr(
5622                                            &mut instance.common,
5623                                            crate::server::recgbl::alarm_status::WRITE_ALARM,
5624                                            crate::server::record::AlarmSeverity::Invalid,
5625                                        );
5626                                    }
5627                                }
5628                                instance.device = Some(dev);
5629                            }
5630                            None
5631                        }
5632                    };
5633
5634                    // PUTF / put-notify wait-set / source alarm for every write of this
5635                    // cycle. C `dbDbPutValue` (dbDbLink.c:382-383) inherits the source's
5636                    // PENDING alarm (`psrce->nsta/nsev/namsg`) — this is the point in the
5637                    // cycle C reads them, before the commit. Captured under the Segment-D
5638                    // guard, which then closes.
5639                    let src_putf = instance.common.putf;
5640                    let src_notify = instance.notify.clone();
5641                    let src_alarm = super::links::LinkAlarm::pending(&instance.common);
5642                    (out_info, src_putf, src_notify, src_alarm)
5643                };
5644
5645                // C `writeValue` reaches `conditional_write` — whose epilogue
5646                // advances PVAL — on every cycle except the three that return
5647                // before the switch: SIMM simulation (`longoutRecord.c:411-424`
5648                // redirects to SIOL), a failed SIML read or a bad SIMM
5649                // (`:400-403`, `:428-430`), and the IVOA Don't_drive veto, which
5650                // skips the `writeValue` call site altogether (`:169-171`).
5651                let reached_conditional_write =
5652                    sim_output.is_none() && !sim_write_aborted && !skip_out;
5653
5654                // C `process()` runs every output of the cycle BEFORE `monitor()`,
5655                // and `monitor()` is where `recGblResetAlarms` commits the cycle's
5656                // alarm (aoRecord.c:196-232 → aoRecord.c `monitor`). A failed
5657                // `dbPutLink` raises LINK_ALARM/INVALID from INSIDE the put
5658                // (`setLinkAlarm`, dbLink.c:434-448) — so the write alarm must land
5659                // in THIS cycle's committed SEVR and this cycle's monitor posts,
5660                // not the next one. Every link-carried output of the cycle
5661                // therefore runs here, before the commit below:
5662                //
5663                //   * the soft OUT link (`out_info`),
5664                //   * the record's multi-output pairs (scalcout / acalcout OUT),
5665                //   * the SIMM SIOL redirect,
5666                //   * the record's own `WriteDbLink` actions (transform OUTn,
5667                //     scaler COUTP, throttle OUT — C writes them before
5668                //     `monitor()`/`recGblFwdLink` too).
5669                //
5670                // The record's write gate is released across the writes (a
5671                // self/cyclic OUT link would otherwise dead-lock on the
5672                // non-reentrant gate, exactly as the FLNK tail already runs
5673                // unlocked) and re-acquired for the commit. The put owner raises
5674                // the LINK_ALARM on the record itself, so nothing has to be
5675                // threaded back here.
5676                // await 3 (guard-free): the cycle's link-carried outputs run with the
5677                // data guard released (the put owner raises any LINK_ALARM on the
5678                // record itself). SEG E re-acquires for the alarm commit.
5679                // The boundary rule (see `DataGuard`): the guard is released only
5680                // when this cycle has an output to perform — every kind below may
5681                // lock another record, or this one through a cyclic link. An
5682                // un-skipped output stage counts whatever it turns out to write:
5683                // its dispatchers read the record to decide.
5684                guard.release();
5685                let src = super::links::OutLinkSrc {
5686                    putf: src_putf,
5687                    notify: src_notify.as_ref(),
5688                    alarm: &src_alarm,
5689                    field: "OUT",
5690                };
5691                if let Some((ref link, ref out_val)) = out_info {
5692                    self.write_out_link_value(rec, link, out_val.clone(), src, visited);
5693                }
5694                // C `longoutRecord.c:492-493`, OUTSIDE `if (doDevSupWrite)`:
5695                // the OOPT reference advances on a suppressed cycle too, which
5696                // is the only reason a transition can ever be detected.
5697                if reached_conditional_write && plan.redecides_after_output {
5698                    rec.write().record.after_output_decision();
5699                }
5700                self.dispatch_multi_output_values(rec, src, skip_out, plan, visited);
5701                // The value-putting multi-output records — dfanout `OUTn`, seq
5702                // `LNKn` — push HERE, with the record's other outputs, so the
5703                // whole output stage sits between `checkAlarms` and the alarm
5704                // commit exactly as C's does (`dfanoutRecord.c:128-146`
5705                // push_values → monitor; `seqRecord.c:264` dbPutLink →
5706                // asyncFinish's `recGblResetAlarms`, :227). A failed put's
5707                // LINK_ALARM therefore folds into THIS cycle's committed SEVR,
5708                // and the push reads the VAL the IVOA owner already settled.
5709                // The fanout dispatch stays in the forward-link tail: its
5710                // `LNKn` are `DBF_FWDLINK` (dbScanFwdLink), driving no value.
5711                let dispatched = if plan.multi_output_dispatch {
5712                    self.dispatch_multi_output(
5713                        rec,
5714                        super::links::MultiOutPhase::Output { skip_out },
5715                        visited,
5716                    )
5717                } else {
5718                    super::links::MultiOutDispatch::default()
5719                };
5720                self.write_simulated_output_siol(rec, &sim_output, skip_out, src, visited);
5721                if let Some(link_writes) = link_writes {
5722                    self.execute_process_actions(name, rec, link_writes, visited);
5723                }
5724                // Every link-carried output of the cycle has now run, so the
5725                // withheld stores become visible here — still ahead of Segment
5726                // E, which therefore change-detects against the published value
5727                // and does not post it a second time.
5728                self.publish_post_write_fields(name, post_write_fields);
5729                dispatched
5730            };
5731
5732            // The seq record armed its delayed group chain: C `process` has
5733            // set `pact = TRUE` and returned through `processNextLink`
5734            // (`seqRecord.c:143`, `:196`), so THIS cycle commits nothing. The
5735            // alarm/timestamp/monitor/FLNK epilogue is `asyncFinish`'s
5736            // (`:219-241`), reached from the chain's last hop via
5737            // `complete_async_record`. Same shape as the `AsyncPending` arm
5738            // above; PACT was set by the dispatch before it spawned, so the
5739            // chain cannot complete ahead of it.
5740            if dispatched.went_async {
5741                guard.release();
5742                self.execute_process_actions(name, rec, process_actions, visited);
5743                self.apply_pact_exit(name, rec, cycle_end.take());
5744                return Ok(());
5745            }
5746            let push_alarm = dispatched.alarm;
5747
5748            // Segment E (guarded): commit alarms, build the snapshot, resolve the
5749            // FLNK target, and yield the `'epilogue` tuple. Re-acquire the data lock.
5750            let instance = guard.hold();
5751            if let Some((stat, sevr)) = push_alarm {
5752                crate::server::recgbl::rec_gbl_set_sevr(&mut instance.common, stat, sevr);
5753            }
5754
5755            // C `monitor()` with its opening `recGblResetAlarms` — AFTER every
5756            // output of the cycle, so a failed put's LINK_ALARM is committed
5757            // here and no async write advances MLST/ALST before it returns.
5758            let outcome = instance.monitor_cycle();
5759
5760            let flnk_name = instance.forward_target();
5761
5762            // Put-notify completion is NOT fired here. Firing before the
5763            // OUT/FLNK/process-action tail (below) would report the
5764            // WRITE_NOTIFY done while the chain it triggers — including
5765            // an async FLNK target — is still running (C `dbNotify.c`
5766            // keeps the originating record in the waitList until the
5767            // chain settles). The originating record instead `leave`s
5768            // the wait-set at the END of this function, after every PP
5769            // target it drives has joined. See `complete_put_notify`
5770            // at the tail.
5771
5772            // 3. Notify subscribers, still under the segment's own guard.
5773            let posts = publish_cycle(
5774                instance,
5775                &outcome.snapshot,
5776                link_backing,
5777                outcome.alarm_posts,
5778            );
5779
5780            (
5781                flnk_name,
5782                process_actions,
5783                result_is_defer_output,
5784                restamps_after,
5785                posts,
5786            )
5787        };
5788
5789        // C `swaitRecord.c::process` (lines 425-481): `schedOutput` armed the
5790        // ODLY watchdog (`async=TRUE`), so `process` ran `monitor()` — the
5791        // value-publication epilogue above just posted VAL + the alarm fields at
5792        // the START of the delay — but SKIPPED the `if(!async){recGblFwdLink;
5793        // pact=FALSE;}` tail. The OUT write / OEVT are already gated out this
5794        // cycle by `should_output()==false`; `recGblFwdLink` is NOT
5795        // should_output-gated, so the forward-link tail below is skipped when
5796        // deferring (`result_is_defer_output`). The deferred `execOutput` — the
5797        // scheduled `ReprocessAfter` reprocess at delay-END — runs the OUT write
5798        // + OEVT + FLNK. Hold PACT across the wait so a foreign `dbProcess` bails
5799        // at the entry guard (C keeps the record ACTIVE on the watchdog,
5800        // swaitRecord.c:716); the hold is gated on the `ReprocessAfter` that
5801        // releases it (the same by-construction invariant as the
5802        // `AsyncPendingNotify` ODLY defer above). The `ReprocessAfter` itself is
5803        // dispatched by the shared deferred-actions site at the tail, NOT a
5804        // separate `execute_process_actions().await` here — adding one would
5805        // enlarge this hot recursive function's async frame (see the
5806        // `CompleteNoEmit` note above; it overflowed the stack in the deep-chain
5807        // tests).
5808        // Holding `processing=true` also makes the tail's putf-clear (gated on
5809        // `!is_processing()`) a no-op, leaving putf for the continuation.
5810        if result_is_defer_output {
5811            let holds_pact_until_continuation = process_actions
5812                .iter()
5813                .any(|a| matches!(a, crate::server::record::ProcessAction::ReprocessAfter(_)));
5814            if holds_pact_until_continuation {
5815                guard.hold().enter_pact();
5816            }
5817        }
5818
5819        // 4.5 - 7. Multi-output / event / generic-multi-out / FLNK /
5820        // CP / RPRO tail. Shared with the simulation-mode path so a
5821        // simulated record runs the exact same `recGblFwdLink`
5822        // equivalent (C `aiRecord.c:168`).
5823        //
5824        // Skipped on a `CompleteDeferOutput` (swait ODLY) delaying cycle: the
5825        // multi-output / OEVT are already gated out by `should_output()==false`,
5826        // and `recGblFwdLink` runs only at delay-END (C `execOutput`) — the
5827        // continuation drives the whole tail. The deferred-actions site below
5828        // still runs (it dispatches this cycle's `ReprocessAfter`).
5829        if !result_is_defer_output {
5830            self.run_forward_link_tail_with_putf(
5831                name,
5832                &mut guard,
5833                &flnk_name,
5834                TailCtx { posts, plan },
5835                visited,
5836            );
5837        }
5838
5839        // Deferred restamp for a `restamps_time_after_completion` record (sseq):
5840        // C `sseqRecord.c::asyncFinish` calls `recGblGetTimeStamp` (`:501`)
5841        // AFTER the VAL post (`:474`) and `recGblFwdLink` (`:499`). The VAL
5842        // monitor + forward link above therefore carried the record's
5843        // pre-update timestamp; restamp now so TIME advances for the following
5844        // BUSY post (sseq's out-of-band `post_fields`) and the next cycle. Soft
5845        // record (no device support), so `apply_timestamp` resolves TSE→TIME
5846        // the same as the pre-output site it replaces.
5847        if restamps_after {
5848            // Its own TSEL read, not the one Segment C took: C's stamp here is
5849            // a whole `recGblGetTimeStamp` running AFTER `recGblFwdLink`, so a
5850            // `.TIME` TSEL adopts whatever the forward-link chain just did to
5851            // its source.
5852            guard.release();
5853            self.rec_gbl_get_time_stamp(rec);
5854        }
5855
5856        // 8. Execute the deferred ProcessActions after the FLNK tail:
5857        // `ReprocessAfter` schedules a later reprocess (the current
5858        // cycle's FLNK must proceed first) and `DeviceCommand` posts its
5859        // own monitors after this cycle's snapshot. The record's link writes
5860        // are NOT here — they ran pre-commit with the rest of the cycle's
5861        // output (C `transformRecord.c:605-621` / `scalerRecord.c:457-480`
5862        // put before `monitor()` + `recGblFwdLink()`), so a downstream FLNK
5863        // target still reads the freshly written value.
5864        if !process_actions.is_empty() {
5865            guard.release();
5866            self.execute_process_actions(name, rec, process_actions, visited);
5867        }
5868
5869        // 9. C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` at the
5870        // tail of every synchronous process cycle, NOT just on the
5871        // foreign-entry path. When this record was driven through an
5872        // OUT-link propagation (write_db_link_value set our putf), the
5873        // target record's own process cycle must clear it before
5874        // returning — same lifecycle as the source record's PUTF
5875        // (which `put_record_field_from_ca` separately clears at the
5876        // foreign-entry boundary, and the async branch clears in
5877        // `complete_async_record_inner`). Async-pending records skip
5878        // this clear: their FLNK / putf-clear happens later in
5879        // `complete_async_record_inner` once the device round-trip
5880        // completes.
5881        // The guard holds both releases — `check_simulation_mode`'s SDLY/SIM
5882        // continuation and the `is_continuation` arm's — merged. At most one of
5883        // them can carry the parked put. Taking it here disarms the guard, so
5884        // the release happens once whether the cycle reaches this line or leaves
5885        // by one of the exits above.
5886        // The fetch buffers back to the chain, from exactly the cycle that
5887        // took them — the gates are the same facts the takes were: an input
5888        // stage exists only past `fetch_input_stage`'s take, and a set-link
5889        // list has capacity only if `read_into` took the buffer.
5890        finish_cycle(guard.hold());
5891        guard.release();
5892        self.apply_pact_exit(name, rec, cycle_end.take());
5893
5894        Ok(())
5895    }
5896
5897    /// The end of a synchronous process cycle — C `recGblFwdLink`'s tail
5898    /// (`recGbl.c:295-302`), after `dbScanFwdLink`:
5899    ///
5900    /// ```c
5901    /// if (pdbc->ppn) dbNotifyCompletion(pdbc);  /* leave the wait-set; queue the restart */
5902    /// ...
5903    /// pdbc->putf = FALSE;
5904    /// ```
5905    ///
5906    /// The single owner of both halves, so no cycle end can skip them. Open-coded
5907    /// at the tail of `process_record_with_links_inner` alone, it was jumped over
5908    /// by the two simulation early-returns: a put-notify on a SIMM record never
5909    /// left its wait-set (the callback never fired) and PUTF leaked into the next
5910    /// scan.
5911    fn end_process_cycle(&self, name: &str, rec: &Arc<RecordCell>, exit: PactExit) {
5912        finish_cycle(&mut rec.write());
5913        self.apply_pact_exit(name, rec, exit);
5914    }
5915
5916    /// C `restartCheck` (`dbNotify.c:149-170`), reached from
5917    /// `dbNotifyCompletion` (`:445-475`) via `recGblFwdLink` (`recGbl.c:295`)
5918    /// at the tail of the cycle that released the record.
5919    ///
5920    /// **The single owner of the restart-list drain.** Every cycle end routes
5921    /// through it — including cycles that released no PACT, because a notify
5922    /// queued behind an in-flight wait-set on an idle record is freed by
5923    /// `complete_put_notify` above, not by a PACT release.
5924    ///
5925    /// Queued, not recursed — the same `scanOnce` shape as the RPRO restart.
5926    /// The pop itself happens inside `restart_next_notify_put`, under the
5927    /// record's advisory write gate, so a client put racing this spawn cannot
5928    /// take the record between the pop and the replay and thereby overtake a
5929    /// notify that has been waiting longer.
5930    ///
5931    /// `rec` is the record the restart re-enters. It is a parameter, and not a
5932    /// `get_record(name)` inside, because the consumer must be free to read the
5933    /// record: every caller must therefore already have let the record's DATA
5934    /// lock go, which a handle in hand makes visible at the call and a name
5935    /// lookup would hide. `parking_lot::RwLock` is not reentrant, so a caller
5936    /// still holding `rec.write()` would deadlock, not fail.
5937    pub(super) fn apply_pact_exit(&self, name: &str, _rec: &Arc<RecordCell>, exit: PactExit) {
5938        // NO record lock here, deliberately. This runs from cycle tails and
5939        // from a `Drop` that can fire while a `rec.write()` guard is still
5940        // alive in the same scope; parking_lot is not reentrant, so a read
5941        // here would deadlock on drop order. The bit was minted under the
5942        // releasing site's own lock instead — see `PactExit`.
5943        if !exit.restart_pending() {
5944            return;
5945        }
5946        let db = self.clone();
5947        let put_name = name.to_string();
5948        // C pins every put-notify callback to the low band —
5949        // `callbackSetPriority(priorityLow, &pnotifyPvt->callback)`
5950        // (`dbNotify.c:131`) — regardless of the record's PRIO.
5951        crate::runtime::task::spawn_background(
5952            crate::runtime::task::CallbackPriority::Low,
5953            async move {
5954                db.restart_next_notify_put(&put_name).await;
5955            },
5956        );
5957    }
5958
5959    /// Forward-link / CP / RPRO tail for the simulation-mode path.
5960    ///
5961    /// C `aiRecord.c:151-168`: a record in SIMM mode handles the value
5962    /// inside `readValue()`, then `process()` still runs `monitor` +
5963    /// `recGblFwdLink(prec)`. The simulation path in
5964    /// `process_record_with_links_inner` does its own monitor posting,
5965    /// so this drives the forward-link / CP / RPRO tail that
5966    /// `recGblFwdLink` would. `flnk_name` (with its PUTF) is derived
5967    /// fresh from the record (a simulated cycle does not change FLNK,
5968    /// and SIOL reads/writes do not carry a foreign PUTF into the
5969    /// chain).
5970    fn run_forward_link_tail(
5971        &self,
5972        name: &str,
5973        rec: &Arc<RecordCell>,
5974        posts: CyclePosts,
5975        visited: &mut ProcStack,
5976    ) {
5977        let flnk_name = rec.read().forward_target();
5978        let plan = rec.process_plan();
5979        let mut guard = DataGuard::new(rec);
5980        self.run_forward_link_tail_with_putf(
5981            name,
5982            &mut guard,
5983            &flnk_name,
5984            TailCtx { posts, plan },
5985            visited,
5986        );
5987    }
5988
5989    /// Steps 4.5 - 7 of the process chain: multi-output dispatch,
5990    /// event-record posting, generic OUTA..OUTP links, FLNK forward
5991    /// link, CP-target dispatch, and RPRO reprocess. Shared by the
5992    /// main process path and the simulation-mode path so both run the
5993    /// identical `recGblFwdLink` equivalent.
5994    fn run_forward_link_tail_with_putf(
5995        &self,
5996        name: &str,
5997        guard: &mut DataGuard<'_>,
5998        flnk: &crate::server::record::record_instance::ForwardTarget,
5999        src: TailCtx<'_>,
6000        visited: &mut ProcStack,
6001    ) {
6002        let rec = guard.rec;
6003        // 4.5. Multi-output dispatch, forward-link phase: fanout only. Its
6004        // `LNK0..LNKF` are `DBF_FWDLINK` — `dbScanFwdLink`, no value, no put
6005        // status, so the tail is where they belong. dfanout `OUTn` and seq
6006        // `LNKn` carry a value through `dbPutLink` and dispatch pre-commit in
6007        // `process_record_with_links_inner`, so a failed put's LINK_ALARM
6008        // folds into the same cycle's SEVR; the `ForwardLink` phase argument
6009        // skips them here (`multi_out_phase_of`).
6010        if src.plan.multi_output_dispatch {
6011            guard.release();
6012            let _ =
6013                self.dispatch_multi_output(rec, super::links::MultiOutPhase::ForwardLink, visited);
6014        }
6015
6016        // 4.55. event record: post the named software event.
6017        if src.plan.posts_software_event {
6018            guard.release();
6019            self.dispatch_event_record(rec);
6020        }
6021
6022        // The generic multi-output OUT writes (scalcout / acalcout OUT->OVAL)
6023        // are NOT part of this tail: C performs a record's output writes inside
6024        // `process()` BEFORE `monitor()` commits the cycle's alarm, so they run
6025        // pre-commit in `dispatch_multi_output_values` (see R14-62). This tail
6026        // is C's `recGblFwdLink` equivalent only.
6027
6028        // 5. FLNK — C `dbScanFwdLink` → `dbScanPassive` → `processTarget`,
6029        // through the single owner that holds the Passive gate.
6030        // 5b. An external (`pva://`/`ca://`) FLNK goes out through the link
6031        // set's `scanForward` (pvalink `pvaScanForward`) instead — a
6032        // process-only trigger of the remote target. Both halves come from the
6033        // one resolution `RecordInstance::forward_target` made under the
6034        // monitor segment's guard, so the tail re-reads nothing.
6035        match flnk {
6036            crate::server::record::record_instance::ForwardTarget::Db { name, putf, notify } => {
6037                guard.release();
6038                self.process_target(
6039                    name,
6040                    super::links::ProcessTargetGate::ScanPassive,
6041                    *putf,
6042                    notify.as_ref(),
6043                    visited,
6044                );
6045            }
6046            crate::server::record::record_instance::ForwardTarget::External(pv) => {
6047                guard.release();
6048                self.scan_forward_external_flnk(rec, pv);
6049            }
6050            crate::server::record::record_instance::ForwardTarget::None => {}
6051        }
6052
6053        // 6. CP link targets -- holders of a CP/CPP link on this record,
6054        // driven by what this cycle POSTED (see `CyclePosts`), not by the
6055        // fact that it processed.
6056        if src.posts.triggers_cp() && self.sources_cp_edges(name, rec) {
6057            guard.release();
6058            self.dispatch_cp_targets(name, rec, src.posts, visited);
6059        }
6060
6061        // 7. RPRO: if reprocess requested, clear flag and queue a
6062        // fresh process pass.
6063        //
6064        // C `recGblFwdLink` (recGbl.c:296-300) consumes RPRO via
6065        // `scanOnce(pdbc)` — the record is QUEUED on the scanOnce ring
6066        // buffer and reprocessed in a separate pass with a fresh lock
6067        // cycle AFTER the current process chain fully unwinds. It does
6068        // NOT recurse inline within the current link chain.
6069        //
6070        // Spawning a detached task is the Rust equivalent of the
6071        // scanOnce queue: the reprocess runs on its own task, so it must
6072        // carry its own `visited` — the current
6073        // chain's set is a `&mut` local to that stack and cannot be
6074        // shared. That is now the ONLY reason for the fresh set. It used
6075        // to be doing double duty as an escape hatch from the cycle
6076        // guard, which over-blocked; the guard is frame-scoped now
6077        // ([`Self::run_process_frame`]), so there is nothing to escape.
6078        {
6079            let needs_rpro = {
6080                let instance = guard.hold();
6081                if instance.common.rpro != 0 {
6082                    instance.common.rpro = 0;
6083                    true
6084                } else {
6085                    false
6086                }
6087            };
6088            if needs_rpro {
6089                let db = self.clone();
6090                let rpro_name = name.to_string();
6091                // Middle band, not the record's PRIO: C `recGblFwdLink` hands
6092                // RPRO to `scanOnce` (`recGbl.c`), whose single "scanOnce"
6093                // thread runs at `epicsThreadPriorityScanLow + nPeriodic`
6094                // (`dbScan.c:770-779`) and is not a callback band at all.
6095                crate::runtime::task::spawn_background(
6096                    crate::runtime::task::CallbackPriority::Medium,
6097                    async move {
6098                        let mut fresh_visited = ProcStack::new();
6099                        let _ = db
6100                            .process_record_with_links(&rpro_name, &mut fresh_visited)
6101                            .await;
6102                    },
6103                );
6104            }
6105        }
6106    }
6107
6108    /// Fire a non-DB (external `pva://`/`ca://`) forward link (FLNK).
6109    ///
6110    /// C `recGblFwdLink` → `dbScanFwdLink` (`dbLink.c:475-480`) dispatches
6111    /// every FLNK uniformly through `plink->lset->scanForward`: a DB lset
6112    /// runs `scanOnce(target)` — handled directly by the local FLNK §5
6113    /// path — while the pvalink/calink lset runs `pvaScanForward`, a
6114    /// process-only trigger of the remote target. The DB-only `flnk_name`
6115    /// filter at the three `should_fire_forward_link` sites dropped every
6116    /// external FLNK; this is the single owner that forwards them, so the
6117    /// dispatch is not open-coded per site (each FLNK tail calls only
6118    /// this).
6119    ///
6120    /// On a non-retry, disconnected link the lset returns `Err`; pvxs
6121    /// raises `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")` on
6122    /// the owning record (`pvxs/ioc/pvalink_lset.cpp:677-680`). This raises
6123    /// the same *pending* LINK/INVALID alarm via [`rec_gbl_set_sevr_msg`](crate::server::recgbl::rec_gbl_set_sevr_msg),
6124    /// promoted by the next `recGblResetAlarms` — exactly as the C late-set
6125    /// inside `recGblFwdLink` (after the record's own alarm/monitor stage)
6126    /// is.
6127    fn scan_forward_external_flnk(&self, rec: &Arc<RecordCell>, target: &str) {
6128        if let Err(e) = self.scan_forward_external_pv(target) {
6129            let _ = e;
6130            let mut instance = rec.write();
6131            crate::server::recgbl::rec_gbl_set_sevr_msg(
6132                &mut instance.common,
6133                crate::server::recgbl::alarm_status::LINK_ALARM,
6134                crate::server::record::AlarmSeverity::Invalid,
6135                "Disconn",
6136            );
6137        }
6138    }
6139
6140    /// One record-declared input link read — the framework's `dbGetLink`.
6141    ///
6142    /// The value goes into `target_field`; the return is C's
6143    /// `RTN_SUCCESS(dbGetLink(...))` and nothing finer, because C's callers have
6144    /// nothing finer: `dbGetLink` hands back one `long status`, and every reader
6145    /// of it — `motorRecord.cc:3687`, `epidRecord.c:191`, `aaoRecord.c`'s
6146    /// `fetchValue` — asks only whether it was zero.
6147    ///
6148    /// `true` is that zero, and it covers the reads that delivered NO value as
6149    /// well as the ones that did: an empty link, a CONSTANT link
6150    /// (`dbConstGetValue`, `dbConstLink.c:219-225`, sets `*pnRequest = 0` and
6151    /// returns 0), and the source class the record has no case for (C's
6152    /// `default:` — `dbGetLink` is never called, so `status` keeps the 0 it was
6153    /// initialised with). `false` is the non-zero status: a dead DB target, a
6154    /// disconnected CA link, a value the target field rejects.
6155    ///
6156    /// Returning `Option<bool>` here — "nothing attempted" apart from "no
6157    /// value" — invited [`Self::execute_read_db_links`] to report only
6158    /// `Some(true)` as resolved, which made a CONSTANT link indistinguishable
6159    /// from a failed one to every record reading that report. A motor with a
6160    /// constant `RDBL` stopped its own axis (`motorRecord.cc:3690-3697`) on a
6161    /// read C calls successful. The multi-input fetch loop, reading the same
6162    /// links on the same records, always used C's rule.
6163    ///
6164    /// On the `false` side C `dbGetLink` (`dbLink.c:316-323`) runs
6165    /// `setLinkAlarm(plink)`, i.e. `recGblSetSevrMsg(precord, LINK_ALARM,
6166    /// INVALID_ALARM, "%s", dbLinkFieldName(plink))` — so the failure raises
6167    /// LINK/INVALID carrying the link's field name as the AMSG, right here, as
6168    /// an effect of the read itself. Every caller inherits it; none can forget
6169    /// it.
6170    ///
6171    /// A HEALTHY read is the other half of the same C function: `dbDbGetValue`
6172    /// ends with `recGblInheritSevrMsg` (`dbDbLink.c:228-232`), so an
6173    /// `field(INP,"SRC MS")` on a compress / aao-DOL / epid link raises the
6174    /// READER to the source's severity. That inheritance runs here too, through
6175    /// `input_link_inheritance` — the same owner the multi-input
6176    /// fetch uses.
6177    ///
6178    /// The DBR class of the read is the RECORD's
6179    /// ([`Record::input_link_request`](crate::server::record::Record::input_link_request), C's `dbGetLink` `dbrType` argument),
6180    /// resolved from the SOURCE's metadata by the same owner the OUT side uses
6181    /// ([`Self::resolve_out_target`]): a record that switches on the source's
6182    /// DBF class (sseq `DOLn`, `sseqRecord.c:640-705`) gets the value C's
6183    /// `dbGetLink` would deliver — an `ENUM`/`MENU` source's LABEL, a `CHAR`
6184    /// array's bytes — instead of a native value it would have to guess at.
6185    /// `None` from the record is C's `default: break`: no read, no alarm.
6186    fn read_db_link_into_field(
6187        &self,
6188        rec: &Arc<RecordCell>,
6189        link_field: &'static str,
6190        target_field: &'static str,
6191        visited: &mut ProcStack,
6192    ) -> bool {
6193        let link_str = {
6194            let instance = rec.read();
6195            instance
6196                .record
6197                .get_field(link_field)
6198                .and_then(|v| {
6199                    if let EpicsValue::String(s) = v {
6200                        Some(s)
6201                    } else {
6202                        None
6203                    }
6204                })
6205                .unwrap_or_default()
6206        };
6207        // An empty link IS a CONSTANT link in C (`dbConstLink.c`'s lset with a
6208        // NULL string), and `dbConstGetValue` returns 0 for it.
6209        if link_str.is_empty() {
6210            return true;
6211        }
6212        let parsed = crate::server::record::parse_link_v2(link_str.as_str_lossy().as_ref());
6213        // The source's DBF class + element count (C `dbGetLinkDBFtype` /
6214        // `dbGetNelements` — the same lset accessors the OUT side asks of a
6215        // destination), resolved with NO record lock held: a self-referencing
6216        // link would otherwise re-enter this record's own gate.
6217        // C's `default:` arm — the record's switch has no case for this link,
6218        // so `dbGetLink` is never called: nothing is attempted, the untouched
6219        // `status` raises no link alarm, and it is still zero.
6220        let Some(read_as) = self.input_link_read_as(rec, link_field, &parsed) else {
6221            return true;
6222        };
6223        use crate::server::recgbl::simm::LinkFetch;
6224        match self.read_link_value_as(&parsed, read_as, visited) {
6225            // C `dbConstGetValue`: SUCCESS with nothing written. The target
6226            // field keeps what it holds (a client's `caput SELN 5` survives a
6227            // `field(SELL,"3")`), no LINK alarm is raised, and the link did NOT
6228            // deliver. The constant reached the record once, at init, via
6229            // `rec_gbl_init_constant_links`. Status 0 all the same, so the
6230            // record is told the read SUCCEEDED — C's `dbGetLink` on a constant
6231            // returns 0, and `motorRecord.cc:3690` stops the axis on non-zero.
6232            LinkFetch::NoData => true,
6233            LinkFetch::Value(value) => {
6234                // C `dbDbGetValue` tail (dbDbLink.c:228-232): a healthy read
6235                // folds the SOURCE's committed alarm into the READER per the
6236                // link's MS class. The source has already been processed above
6237                // (a PP link), so its alarm is the one this cycle sees.
6238                let inheritance = {
6239                    let alarm = self.read_link_with_alarm(&parsed).1;
6240                    self.input_link_inheritance(rec, &parsed, alarm)
6241                };
6242                let mut instance = rec.write();
6243                // A value the target field REJECTS is a failed read, not a
6244                // silent no-op: C `dbGetLink`'s conversion failure comes back as
6245                // a non-zero status and takes the `setLinkAlarm` path
6246                // (`dbLink.c:316-323`) exactly like a dead target. Discarding it
6247                // left the target field holding its previous value with no
6248                // alarm to say so.
6249                let stored = instance
6250                    .record
6251                    .put_field_internal(target_field, value)
6252                    .is_ok();
6253                if !stored {
6254                    crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, link_field);
6255                    return false;
6256                }
6257                if let Some((ms, alarm)) = inheritance {
6258                    super::links::inherit_sevr_msg(&mut instance.common, ms, &alarm);
6259                }
6260                true
6261            }
6262            LinkFetch::Failed => {
6263                let mut instance = rec.write();
6264                crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, link_field);
6265                false
6266            }
6267        }
6268    }
6269
6270    /// Execute the ReadDbLink actions of a stage, and report which
6271    /// `link_field`s C would call a SUCCESSFUL `dbGetLink` — see
6272    /// [`Self::read_db_link_into_field`], which owns the read (and its
6273    /// LINK/INVALID alarm on failure).
6274    ///
6275    /// One list, one meaning: the multi-input fetch loop feeds the same
6276    /// `set_resolved_input_links` report on the same predicate
6277    /// ([`LinkFetch::is_ok`](crate::server::recgbl::simm::LinkFetch::is_ok), C's
6278    /// `status == 0`), so a record deriving "this link failed" from absence gets
6279    /// the same answer whichever path read it.
6280    fn execute_read_db_links(
6281        &self,
6282        _record_name: &str,
6283        rec: &Arc<RecordCell>,
6284        actions: &[crate::server::record::ProcessAction],
6285        visited: &mut ProcStack,
6286    ) -> Vec<&'static str> {
6287        use crate::server::record::ProcessAction;
6288        let mut resolved = Vec::new();
6289        for action in actions {
6290            match action {
6291                ProcessAction::ReadDbLink {
6292                    link_field,
6293                    target_field,
6294                } => {
6295                    if self.read_db_link_into_field(rec, link_field, target_field, visited) {
6296                        resolved.push(*link_field);
6297                    }
6298                }
6299                // The OUT-link twin: resolve the target's class and hand it to
6300                // the record, so its `process()` can branch on it (C's
6301                // `checkLinks`-cached `lnk_field_type`).
6302                ProcessAction::ResolveOutTarget { link_field } => {
6303                    self.resolve_out_target_into_record(rec, link_field);
6304                }
6305                _ => {}
6306            }
6307        }
6308        resolved
6309    }
6310
6311    /// Resolve one OUT link's TARGET and hand it to the record ahead of
6312    /// `process()` — [`ProcessAction::ResolveOutTarget`](crate::server::record::ProcessAction::ResolveOutTarget).
6313    ///
6314    /// The record's own link string is the input, so an empty/constant `LNKn`
6315    /// resolves to [`OutTarget::UNRESOLVED`](crate::server::record::OutTarget::UNRESOLVED) and the record sees "no target",
6316    /// which is the answer C's `default:` arm acts on.
6317    fn resolve_out_target_into_record(&self, rec: &Arc<RecordCell>, link_field: &'static str) {
6318        let link_str = rec.read().link_text(link_field);
6319        let parsed = crate::server::record::parse_output_link_v2(link_str.as_deref().unwrap_or(""));
6320        let target = self.resolve_out_target(&parsed);
6321        rec.write()
6322            .record
6323            .set_resolved_out_target(link_field, target);
6324    }
6325
6326    /// Execute ProcessActions returned by a record's process() call.
6327    ///
6328    /// Actions are executed in order:
6329    /// - ReadDbLink: reads a linked PV value and writes it into a record field
6330    ///   (bypasses read-only checks via put_field_internal)
6331    /// - WriteDbLink: writes a value to a linked PV
6332    /// - ReprocessAfter: schedules a delayed re-process via tokio::spawn
6333    pub(super) fn execute_process_actions(
6334        &self,
6335        record_name: &str,
6336        rec: &Arc<RecordCell>,
6337        actions: impl IntoIterator<Item = crate::server::record::ProcessAction>,
6338        visited: &mut ProcStack,
6339    ) {
6340        use crate::server::record::ProcessAction;
6341
6342        for action in actions {
6343            match action {
6344                ProcessAction::ReadDbLink {
6345                    link_field,
6346                    target_field,
6347                } => {
6348                    // The read (and the LINK/INVALID alarm a failed one raises,
6349                    // C `dbGetLink` -> `setLinkAlarm`) belongs to ONE owner, so
6350                    // an input link cannot fail silently on one stage and
6351                    // loudly on another.
6352                    let _ = self.read_db_link_into_field(rec, link_field, target_field, visited);
6353                }
6354                // A pre-process action (the record asks for the target BEFORE it
6355                // decides), so it is a no-op if it reaches the post-process
6356                // stage — the resolve here would be too late to change anything.
6357                ProcessAction::ResolveOutTarget { .. } => {}
6358                ProcessAction::WriteDbLink { link_field, value } => {
6359                    // 1. Get the link string (record fields → common fields)
6360                    // and the source PUTF for processTarget propagation,
6361                    // plus the PENDING alarm for `recGblInheritSevrMsg`
6362                    // MS-class propagation into the OUT-link target — this
6363                    // write stage runs before the cycle's
6364                    // `rec_gbl_reset_alarms`, exactly where C reads
6365                    // `psrce->nsta/nsev/namsg` ([`LinkAlarm::pending`]).
6366                    let (link_str, src_putf, src_notify, src_alarm) = {
6367                        let instance = rec.read();
6368                        let link = instance
6369                            .resolve_field(link_field)
6370                            .and_then(|v| {
6371                                if let EpicsValue::String(s) = v {
6372                                    Some(s)
6373                                } else {
6374                                    None
6375                                }
6376                            })
6377                            .unwrap_or_default();
6378                        (
6379                            link,
6380                            instance.common.putf,
6381                            instance.notify.clone(),
6382                            super::links::LinkAlarm::pending(&instance.common),
6383                        )
6384                    };
6385                    if link_str.is_empty() {
6386                        // No link to put through: C `dbPutLink` on an
6387                        // unresolved link is a failure, and the emitter is
6388                        // told so — every emitted action reports exactly once,
6389                        // so a record deriving a field from the result cannot
6390                        // be left holding a stale one.
6391                        rec.write()
6392                            .record
6393                            .set_out_link_write_status(link_field, &value, true);
6394                        continue;
6395                    }
6396                    // 2. Parse and write to the linked PV — DB *or*
6397                    // external `ca://`/`pva://`. A record's `process()`
6398                    // emits `WriteDbLink` to drive an OUT-link field
6399                    // (transform `OUTn`, throttle/scaler `COUTP`, epid
6400                    // `TRIG`/`OUTL`); that field may resolve to a CA/PVA
6401                    // link, which C `dbPutLink` routes through the link
6402                    // set's `putValue` identically to a DB link
6403                    // (dbLink.c:434-448). The field is a `DBF_OUTLINK`, so it
6404                    // carries the OUT modifier mask (`dbStaticLib.c:2382-2387`).
6405                    let parsed = crate::server::record::parse_output_link_v2(
6406                        link_str.as_str_lossy().as_ref(),
6407                    );
6408                    let failed = self.write_out_link_value(
6409                        rec,
6410                        &parsed,
6411                        value.clone(),
6412                        super::links::OutLinkSrc {
6413                            putf: src_putf,
6414                            notify: src_notify.as_ref(),
6415                            alarm: &src_alarm,
6416                            field: link_field,
6417                        },
6418                        visited,
6419                    );
6420                    // The record-owned half of the put's outcome. The alarm
6421                    // half was already raised by `write_out_link_value`; this
6422                    // is what lets a record keep a C-truthful status field
6423                    // (throttle STS) instead of committing its own intent.
6424                    rec.write()
6425                        .record
6426                        .set_out_link_write_status(link_field, &value, failed);
6427                }
6428                ProcessAction::DeviceCommand { command, ref args } => {
6429                    let mut instance = rec.write();
6430                    if let Some(mut dev) = instance.device.take() {
6431                        // `handle_command` runs after the process snapshot
6432                        // was already built/notified, so any record field
6433                        // it mutated needs an explicit monitor post. The
6434                        // returned field names are posted with DBE_VALUE,
6435                        // mirroring the C record's `db_post_events` calls
6436                        // from inside `process()` (scalerRecord.c:425-430).
6437                        let changed = dev
6438                            .handle_command(&mut *instance.record, command, args)
6439                            .unwrap_or_default();
6440                        instance.device = Some(dev);
6441                        for field in changed {
6442                            instance.notify_field(field, crate::server::recgbl::EventMask::VALUE);
6443                        }
6444                    }
6445                }
6446                ProcessAction::DelayedCallbackAfter(delay) => {
6447                    // C `callbackRequestDelayed` whose handler mutates the
6448                    // record before `dbProcess` (bo/busy HIGH one-shot). The
6449                    // mutation lives in `delayed_callback_fire`, not in
6450                    // `process()`, so only this timer can perform it.
6451                    self.schedule_delayed_callback(record_name, delay);
6452                }
6453                ProcessAction::ReprocessAfter(delay) => {
6454                    // Owner-driven delayed re-entry, mirroring C
6455                    // `callbackRequestDelayed` dispatching to
6456                    // `(*prset->process)(prec)` directly (callback.c). The
6457                    // mint-token + delayed-fire is the single
6458                    // `schedule_delayed_reprocess` owner, shared with the
6459                    // SDLY async-simulation defer.
6460                    self.schedule_delayed_reprocess(record_name, delay);
6461                }
6462                ProcessAction::ArmWatchdog => {
6463                    // C `wdogInit` from `special()` (histogram SDEL,
6464                    // histogramRecord.c:266-268). The arm owner supersedes any
6465                    // tick already in flight.
6466                    self.arm_watchdog(record_name);
6467                }
6468                ProcessAction::ScanOnce => {
6469                    // C `scanOnce(precord)`. The `if (precord->scan)` guard C
6470                    // writes at every `special()` call site (scalerRecord.c:655,
6471                    // :667) is owned HERE: a Passive record is already processed
6472                    // by the put's own `pp(TRUE)` path (dbAccess.c:1265-1268), so
6473                    // scanning it again would double-process; a non-Passive
6474                    // record gets no process from the put at all, which is the
6475                    // whole reason C makes the call — without it the state
6476                    // change waits for the next periodic scan.
6477                    let passive = {
6478                        let instance = rec.read();
6479                        instance.common.scan == crate::server::record::ScanType::Passive
6480                    };
6481                    if !passive {
6482                        // Queued, not awaited: C's `scanOnce` hands the record
6483                        // to the scan-once thread, which takes `dbScanLock` —
6484                        // the process lands after the putting thread leaves
6485                        // `dbPutField` and releases the record gate this call is
6486                        // still holding.
6487                        let db = self.clone();
6488                        let name = record_name.to_string();
6489                        // Middle band, not the record's PRIO: `scanOnce` is a
6490                        // dedicated thread in C (`dbScan.c:770-779`), not one
6491                        // of the three callback queues.
6492                        crate::runtime::task::spawn_background(
6493                            crate::runtime::task::CallbackPriority::Medium,
6494                            async move {
6495                                let mut visited = ProcStack::new();
6496                                let _ = db.process_record_with_links(&name, &mut visited).await;
6497                            },
6498                        );
6499                    }
6500                }
6501                ProcessAction::WriteDbLinkNotify { link_field, value } => {
6502                    // C `sseqRecord.c` WAITn put-callback dependency: write
6503                    // the OUT link as a put-WITH-completion and re-enter THIS
6504                    // record's process() once the downstream record (plus its
6505                    // FLNK/OUT chain) finishes. Same OUT-link write a plain
6506                    // WriteDbLink performs, wrapped in the c401e2f0 put-notify
6507                    // wait-set + async re-entry primitive.
6508                    let (link_str, src_putf, src_alarm) = {
6509                        let instance = rec.read();
6510                        let link = instance
6511                            .resolve_field(link_field)
6512                            .and_then(|v| {
6513                                if let EpicsValue::String(s) = v {
6514                                    Some(s)
6515                                } else {
6516                                    None
6517                                }
6518                            })
6519                            .unwrap_or_default();
6520                        (
6521                            link,
6522                            instance.common.putf,
6523                            super::links::LinkAlarm::pending(&instance.common),
6524                        )
6525                    };
6526                    // Mint the re-entry token BEFORE issuing the put so a
6527                    // synchronous downstream completion cannot fire the
6528                    // oneshot before the waiter is wired. The mint supersedes
6529                    // any prior pending re-entry for this record (newer
6530                    // token), exactly like ReprocessAfter.
6531                    let token = match self.mint_async_token(record_name) {
6532                        Some(t) => t,
6533                        None => continue,
6534                    };
6535                    let (waitset, completion) = Self::new_put_notify();
6536                    if !link_str.is_empty() {
6537                        // `DBF_OUTLINK` field — OUT modifier mask applies
6538                        // (`dbStaticLib.c:2382-2387`).
6539                        let parsed = crate::server::record::parse_output_link_v2(
6540                            link_str.as_str_lossy().as_ref(),
6541                        );
6542                        self.write_out_link_value(
6543                            rec,
6544                            &parsed,
6545                            value,
6546                            super::links::OutLinkSrc {
6547                                putf: src_putf,
6548                                notify: Some(&waitset),
6549                                alarm: &src_alarm,
6550                                field: link_field,
6551                            },
6552                            visited,
6553                        );
6554                    }
6555                    // Release the initiator's own wait-set count (C
6556                    // `dbProcessNotify` holds one count for the requester and
6557                    // drops it after issuing the put). The set then drains —
6558                    // and fires the completion — when the downstream
6559                    // target(s) that joined via `join_put_notify` finish, or
6560                    // immediately when the link was empty / the target
6561                    // completed synchronously.
6562                    waitset.leave();
6563                    self.reprocess_on_notify(token, completion);
6564                }
6565                ProcessAction::CancelReprocess => {
6566                    // C `callbackCancelDelayed` for `sseq` ABORT: advance the
6567                    // record's re-entry generation so any pending DLYn timer
6568                    // or WAITn notify re-entry becomes a structural no-op (the
6569                    // AsyncToken gate), with no runtime is-aborted check on
6570                    // the re-entry path.
6571                    self.cancel_async_reentry(record_name);
6572                }
6573            }
6574        }
6575    }
6576
6577    /// Complete an asynchronous record's post-process steps.
6578    /// Call after device support signals completion (clears PACT, runs alarms, snapshot, OUT, FLNK).
6579    ///
6580    /// # The completion RE-TAKES the gate
6581    ///
6582    /// This is the other half of C's async-device shape. `dbProcess` released
6583    /// `dbScanLock` when it set `pact` and returned; the completion runs on the
6584    /// callback task, which takes the record's lock again for the epilogue —
6585    /// C `callback.c:379-388` `ProcessCallback`:
6586    ///
6587    /// ```c
6588    /// dbScanLock(pRec);
6589    /// (*pRec->rset->process)(pRec);
6590    /// dbScanUnlock(pRec);
6591    /// ```
6592    ///
6593    /// So the epilogue below — alarm commit, snapshot, OUT writes, FLNK — runs
6594    /// under the SAME exclusion as the cycle that started it, and a put that
6595    /// arrived during the async window has either already been serialised
6596    /// ahead of it or waits behind it. Every caller reaches this from a
6597    /// completion task holding no gate (the device-write completion spawn
6598    /// above, the seq DLYn chain, the tests); nothing calls it with the gate
6599    /// held, which would dead-lock on the non-reentrant gate.
6600    pub fn complete_async_record<'a>(
6601        &'a self,
6602        name: &'a str,
6603    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
6604        Box::pin(async move {
6605            // Alias-aware entry — same pattern as
6606            // `process_record_with_links_inner`. `name` may arrive as an alias
6607            // from an async device-support callback that captured the original
6608            // record name; normalise to canonical so the gate below, the
6609            // `visited` cycle set, and downstream FLNK/OUT dispatches all see
6610            // the same canonical name.
6611            //
6612            // Resolved HERE and not in the body, because the gate wants the
6613            // record: `lock_instance` reaches the lock set through the
6614            // record's own cell, where `lock_record(name)` would repeat this
6615            // very lookup by hand.
6616            let (canonical, rec) = match self.lookup_record(name) {
6617                Some(found) => found,
6618                // Nothing registered under the name. An alias whose target has
6619                // gone has always reported the TARGET as missing.
6620                None => {
6621                    let missing = self.resolve_alias(name).unwrap_or_else(|| name.to_string());
6622                    return Err(CaError::ChannelNotFound(missing));
6623                }
6624            };
6625            let _record_gate = self.lock_instance(&rec);
6626            let mut visited = ProcStack::new();
6627            self.complete_async_record_inner(canonical, rec, &mut visited)
6628        })
6629    }
6630
6631    fn complete_async_record_inner(
6632        &self,
6633        canonical: Arc<str>,
6634        rec: Arc<RecordCell>,
6635        visited: &mut ProcStack,
6636    ) -> CaResult<()> {
6637        // Seed the cycle guard with this record's own name — mirrors
6638        // the synchronous main path ([`Self::run_process_frame`] does
6639        // `visited.insert(name)` before the body). Without this
6640        // the async-completion FLNK / OUT / CP dispatch can re-enter
6641        // the just-completed record: an async FLNK chain that loops
6642        // back (A async -> completes -> FLNK -> B -> FLNK -> A) would
6643        // re-process A unbounded, because PACT is cleared below before
6644        // the FLNK dispatch and nothing else blocks the re-entry.
6645        //
6646        // This is a frame like any other, so it owes the same unwind at the
6647        // tail — see the invariant on [`Self::run_process_frame`].
6648        if !visited.claim(&rec) {
6649            return Ok(()); // Already on this stack, skip
6650        }
6651        let name: &str = &canonical;
6652
6653        // The async completion is the tail of a cycle, and it posts; it owes
6654        // the same one resolve, at the same no-lock-held point, as the
6655        // synchronous body — see `process_record_with_links_body`.
6656        let link_backing = self.resolve_link_backed_metadata_for_posts(&rec);
6657        let link_backing = link_backing.as_link_backing();
6658
6659        // This pass IS C's `process()` re-entry, so it runs the whole
6660        // `recGblGetTimeStampSimm` again — TSEL read included, before the guard.
6661        let tsel = self.read_tsel(&rec);
6662
6663        let (flnk_name, pact_exit, posts) = {
6664            // Phase 1 — first write guard, confined to this scope so the
6665            // (!Send) parking_lot guard is released before the async OUT
6666            // writes below. Yields the output work plus the put-notify
6667            // source fields those writes consume.
6668            let (out_info, skip_out, src_putf, src_notify, src_alarm, plan) = {
6669                let mut instance = rec.write();
6670
6671                // UDF update before alarm evaluation (C parity — see the
6672                // sync process path). A NaN/undefined value keeps UDF true
6673                // so `recGblCheckUDF` raises UDF_ALARM this cycle.
6674                if instance.record.clears_udf() {
6675                    instance.common.udf = instance.record.value_is_undefined() as u8;
6676                }
6677                // Per-record alarm hook (C `checkAlarms()`).
6678                {
6679                    let inst = &mut *instance;
6680                    inst.record.check_alarms(&mut inst.common);
6681                }
6682
6683                // Evaluate alarms
6684                instance.evaluate_alarms();
6685
6686                // Any soft flavour: the framework owns the transfer, so there
6687                // is no device to take an alarm, time stamp or user tag from.
6688                let is_soft = instance.common.dtyp.is_soft();
6689
6690                // Device support alarm/timestamp override
6691                if !is_soft {
6692                    let (dev_alarm, dev_ts, dev_utag) = if let Some(ref dev) = instance.device {
6693                        (dev.last_alarm(), dev.last_timestamp(), dev.last_utag())
6694                    } else {
6695                        (None, None, None)
6696                    };
6697                    if let Some((stat, sevr)) = dev_alarm {
6698                        crate::server::recgbl::rec_gbl_set_sevr(
6699                            &mut instance.common,
6700                            stat,
6701                            crate::server::record::AlarmSeverity::from_u16(sevr),
6702                        );
6703                    }
6704                    if let Some(ts) = dev_ts {
6705                        instance.common.time = ts;
6706                    }
6707                    // C device support writes `prec->utag` directly during
6708                    // `read()` — the event-system pulse-id path, since
6709                    // `epicsTimeStamp` carries no tag. Adopt the device's
6710                    // userTag when it supplies one; read in the same `dev`
6711                    // borrow as the timestamp above so the time/tag pair is a
6712                    // single consistent device snapshot.
6713                    if let Some(utag) = dev_utag {
6714                        instance.common.utag = utag;
6715                    }
6716                }
6717
6718                // BEFORE the output stage — C `aoRecord.c:190` stamps the record
6719                // ahead of `writeValue` so a downstream TSEL fetch sees this
6720                // cycle's time.
6721                let inst = &mut *instance;
6722                tsel.stamp(&inst.name, &mut inst.common, is_soft);
6723                // UDF was already updated before `evaluate_alarms` above.
6724
6725                // ---- Output stage. C `process()` performs the record's output
6726                // BEFORE `monitor()`, and `monitor()` is where `recGblResetAlarms`
6727                // commits the cycle's alarm — the async-completion re-entry runs
6728                // that same `process()` body. A failed `dbPutLink` raises
6729                // LINK_ALARM/INVALID inside the put (`setLinkAlarm`,
6730                // dbLink.c:434-448), so the commit MUST follow the writes for the
6731                // alarm to land in this cycle's SEVR and monitor posts.
6732
6733                // IVOA check — on the PENDING severity, which is what C's
6734                // `writeValue` call site tests (`if (prec->nsev < INVALID_ALARM)`,
6735                // aoRecord.c:196).
6736                let skip_out =
6737                    if instance.common.nsev == crate::server::record::AlarmSeverity::Invalid {
6738                        let ivoa = instance
6739                            .record
6740                            .get_field("IVOA")
6741                            .and_then(|v| v.to_menu_index())
6742                            .unwrap_or(0);
6743                        match ivoa {
6744                            1 => true,
6745                            2 => {
6746                                // See the IVOA=2 comment in
6747                                // `process_record_with_links_inner` — IVOA=2
6748                                // delegates to the per-record
6749                                // `apply_invalid_output_value` so OVAL/RVAL/VAL
6750                                // get the C-convention values.
6751                                // The same "cannot fail in C" contract as the
6752                                // sync arm above; see its note.
6753                                if let Some(ivov) = instance.record.get_field("IVOV") {
6754                                    let applied = instance.record.apply_invalid_output_value(ivov);
6755                                    debug_assert!(
6756                                        applied.is_ok(),
6757                                        "{}: IVOA=Set_output_to_IVOV could not apply IVOV: {:?}",
6758                                        instance.record.record_type(),
6759                                        applied.err()
6760                                    );
6761                                }
6762                                false
6763                            }
6764                            _ => false,
6765                        }
6766                    } else {
6767                        false
6768                    };
6769
6770                // OEVT: queue the output event when the output fires — same
6771                // IVOA-gated event-twin of the OUT write as
6772                // `process_record_with_links_inner`.
6773                if !skip_out {
6774                    if let Some(event_name) = instance.record.output_event() {
6775                        let db = self.clone();
6776                        // Middle band, not this record's PRIO: C `postEvent`
6777                        // fires one `callbackRequest` per non-empty band and
6778                        // each carries the *scanned* record's priority
6779                        // (`dbScan.c:513-527`), a fan-out the port's single
6780                        // Event list cannot express (`scan_index.rs`
6781                        // `post_event_named`). The poster's own PRIO is not
6782                        // the answer, so this keeps `callbackRequest`'s
6783                        // general band (`callback.h:42`).
6784                        crate::runtime::task::spawn_background(
6785                            crate::runtime::task::CallbackPriority::Medium,
6786                            async move {
6787                                db.post_event_named(&event_name).await;
6788                            },
6789                        );
6790                    }
6791                }
6792
6793                let can_dev_write = instance.record.can_device_write();
6794                // Same single owner of the DTYP -> soft dset mapping as the
6795                // synchronous OUT stage (`RecordInstance::soft_output_value`).
6796                let soft_out = instance.soft_output_value();
6797                let record_should_output = instance.record.should_output();
6798                let out_info = if skip_out {
6799                    None
6800                } else if !can_dev_write {
6801                    // Non-output records (calcout, etc.) with soft OUT link
6802                    // (DB or external `ca://`/`pva://`).
6803                    if record_should_output && instance.parsed_out.is_writable_out_link() {
6804                        let out_val = instance.record.output_link_value();
6805                        out_val.map(|v| (instance.parsed_out.clone(), v))
6806                    } else {
6807                        None
6808                    }
6809                } else if let Some(out_val) = soft_out {
6810                    if instance.parsed_out.is_writable_out_link() {
6811                        out_val.map(|v| (instance.parsed_out.clone(), v))
6812                    } else {
6813                        None
6814                    }
6815                } else {
6816                    // Non-soft output: the async device write already completed
6817                    // (that's why we're in complete_async_record). Don't re-do
6818                    // write_begin -- it would start another async cycle.
6819                    None
6820                };
6821
6822                // PUTF / put-notify wait-set / source PENDING alarm — the
6823                // values C `dbDbPutValue` reads at the put (dbDbLink.c:382-383
6824                // takes `psrce->nsta/nsev/namsg`). Captured here and returned
6825                // so the OUT writes run with NO record guard held (a self /
6826                // cyclic OUT link would dead-lock on the non-reentrant gate);
6827                // a fresh guard is re-taken below for the commit.
6828                let src_putf = instance.common.putf;
6829                let src_notify = instance.notify.clone();
6830                let src_alarm = super::links::LinkAlarm::pending(&instance.common);
6831                (
6832                    out_info,
6833                    skip_out,
6834                    src_putf,
6835                    src_notify,
6836                    src_alarm,
6837                    rec.process_plan(),
6838                )
6839            };
6840
6841            // Phase 2 — async OUT writes, no record guard held.
6842            let src = super::links::OutLinkSrc {
6843                putf: src_putf,
6844                notify: src_notify.as_ref(),
6845                alarm: &src_alarm,
6846                field: "OUT",
6847            };
6848            if let Some((ref link, ref out_val)) = out_info {
6849                self.write_out_link_value(&rec, link, out_val.clone(), src, visited);
6850            }
6851            // Same `conditional_write` epilogue as the synchronous stage. C
6852            // runs it on the async device's first pass as well (the record
6853            // returns at `longoutRecord.c:187` only AFTER `writeValue`), and
6854            // the port's first pass returns from `write_begin` before this
6855            // point — so an async longout latched on no pass at all.
6856            if !skip_out {
6857                rec.write().record.after_output_decision();
6858            }
6859            self.dispatch_multi_output_values(&rec, src, skip_out, plan, visited);
6860
6861            // Phase 3 — fresh write guard for the alarm commit + monitor tail.
6862            let mut instance = rec.write();
6863
6864            // C `monitor()` with its opening `recGblResetAlarms` — after every
6865            // output.
6866            let outcome = instance.monitor_cycle();
6867
6868            // Clear PACT. The release hands back the put-notify parked on this
6869            // window; it is carried to the tail below (C `recGblFwdLink` →
6870            // `dbNotifyCompletion`), never replayed here — the OUT/FLNK chain
6871            // this cycle still owes has not run yet.
6872            let pact_exit = instance.leave_pact();
6873
6874            // Put-notify completion is NOT fired here. The async device
6875            // round-trip has finished, but the OUT/FLNK/process-action
6876            // tail it drives (below) may itself reach an async target;
6877            // firing now would report WRITE_NOTIFY done while that chain
6878            // still runs. The originating record `leave`s the wait-set at
6879            // the END of this function, after every PP target it drives
6880            // has joined. See `complete_put_notify` at the tail.
6881
6882            let flnk_name = instance.forward_target();
6883
6884            // Notify subscribers, still under this segment's own guard.
6885            let posts = publish_cycle(
6886                &mut instance,
6887                &outcome.snapshot,
6888                link_backing,
6889                outcome.alarm_posts,
6890            );
6891
6892            // The FLNK's PUTF + put-notify wait-set ride in `flnk_name`
6893            // (see `ForwardTarget::Db`). On the async-completion path PUTF
6894            // was set when the put landed on the record; it (and wait-set
6895            // membership) must propagate through the (now-completing) FLNK
6896            // chain so an async target reached here also defers
6897            // WRITE_NOTIFY completion.
6898            (flnk_name, pact_exit, posts)
6899        };
6900
6901        // The record's own OUT link and its generic multi-output pairs were
6902        // written in the pre-commit output stage above — C `process()` runs
6903        // `writeValue` before `monitor()`, and a failed `dbPutLink` must be
6904        // able to raise LINK_ALARM into the alarm this cycle commits
6905        // (dbLink.c:434-448). Only the fanout/seq dispatch and the FLNK tail
6906        // remain here.
6907
6908        // Multi-output dispatch, forward-link phase (fanout). The
6909        // `ForwardLink` phase skips dfanout and seq here, which is correct:
6910        // their value-carrying `OUTn`/`LNKn` are driven pre-commit on the
6911        // processing path. seq DOES reach this function as an async
6912        // completion — it is C's `asyncFinish` for the DLYn group chain
6913        // (`seqRecord.c:219-241`) — and its groups have already run, so
6914        // re-dispatching them here would drive every LNKn twice.
6915        let plan = rec.process_plan();
6916        if plan.multi_output_dispatch {
6917            let _ =
6918                self.dispatch_multi_output(&rec, super::links::MultiOutPhase::ForwardLink, visited);
6919        }
6920
6921        // event record: post the named software event.
6922        if plan.posts_software_event {
6923            self.dispatch_event_record(&rec);
6924        }
6925
6926        // FLNK — the async-completion tail's copy of the same C path, through
6927        // the same single owner (C `dbScanFwdLink` → `dbScanPassive` →
6928        // `processTarget`).
6929        // Both halves of the FLNK come from the one resolution
6930        // `RecordInstance::forward_target` made under the monitor segment's
6931        // guard, exactly as on the synchronous tail (C `dbScanFwdLink` →
6932        // `dbScanPassive` for a DB target, → lset `scanForward` for an
6933        // external one).
6934        match &flnk_name {
6935            crate::server::record::record_instance::ForwardTarget::Db { name, putf, notify } => {
6936                self.process_target(
6937                    name,
6938                    super::links::ProcessTargetGate::ScanPassive,
6939                    *putf,
6940                    notify.as_ref(),
6941                    visited,
6942                );
6943            }
6944            crate::server::record::record_instance::ForwardTarget::External(pv) => {
6945                self.scan_forward_external_flnk(&rec, pv);
6946            }
6947            crate::server::record::record_instance::ForwardTarget::None => {}
6948        }
6949
6950        // CP link targets — gated on what this cycle posted, as on the
6951        // synchronous tail.
6952        self.dispatch_cp_targets(name, &rec, posts, visited);
6953
6954        // RPRO: C `recGblFwdLink` consumes a pending reprocess via
6955        // `scanOnce` — queued, not recursed. Mirror the synchronous
6956        // path: spawn a fresh process pass (clean `visited`).
6957        {
6958            let needs_rpro = {
6959                let mut guard = rec.write();
6960                if guard.common.rpro != 0 {
6961                    guard.common.rpro = 0;
6962                    true
6963                } else {
6964                    false
6965                }
6966            };
6967            if needs_rpro {
6968                let db = self.clone();
6969                let rpro_name = name.to_string();
6970                // Middle band, not the record's PRIO: C `recGblFwdLink` hands
6971                // RPRO to `scanOnce` (`recGbl.c`), whose single "scanOnce"
6972                // thread runs at `epicsThreadPriorityScanLow + nPeriodic`
6973                // (`dbScan.c:770-779`) and is not a callback band at all.
6974                crate::runtime::task::spawn_background(
6975                    crate::runtime::task::CallbackPriority::Medium,
6976                    async move {
6977                        let mut fresh_visited = ProcStack::new();
6978                        let _ = db
6979                            .process_record_with_links(&rpro_name, &mut fresh_visited)
6980                            .await;
6981                    },
6982                );
6983            }
6984        }
6985
6986        // C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` after
6987        // the forward-link dispatch. The same clearing must happen
6988        // at the tail of the async-completion path (this is the moral
6989        // equivalent of the synchronous completion path in
6990        // `put_record_field_from_ca` which clears after
6991        // `process_record_with_links` returns). Without this, a
6992        // record that completed an async write triggered by a
6993        // CA put would keep `putf=1` forever, leaking into every
6994        // subsequent scan-driven process cycle.
6995        {
6996            let mut guard = rec.write();
6997            guard.common.putf = false;
6998        }
6999
7000        // Put-notify completion: the async device round-trip is done and
7001        // the full OUT/FLNK/process-action tail above has run, so every PP
7002        // target it drove has joined the wait-set. The originating record
7003        // now `leave`s; the completion oneshot fires on the `leave` that
7004        // empties the set (i.e. once every joined async target has also
7005        // completed). `complete_put_notify` `take`s the membership, so a
7006        // motor re-entering `complete_async_record_inner` over several
7007        // device cycles leaves exactly once — matching the old fire site,
7008        // which `take`d its oneshot.
7009        {
7010            let mut guard = rec.write();
7011            complete_put_notify(&mut guard);
7012        }
7013
7014        // C `dbNotifyCompletion` (dbNotify.c:459-473) → `restartCheck`: the
7015        // put-notifies that arrived while this record was PACT wrote nothing and
7016        // queued. PACT is clear and this cycle's wait-set has drained, so the
7017        // record is now the idle record the queue head was meant to see — replay
7018        // it whole (value + process + callback), through the single drain owner.
7019        self.apply_pact_exit(name, &rec, pact_exit);
7020
7021        // The unwind for the seed above: this frame is leaving the stack, so
7022        // its marker goes with it (C `dbDbLink.c:521-526`).
7023        visited.release(&rec);
7024        Ok(())
7025    }
7026
7027    /// Dispatch CP-link targets that take a CP/CPP input link from `name`,
7028    /// when this cycle published a class the CP subscription selects.
7029    ///
7030    /// **The trigger is a monitor post, never a process.** C serves every
7031    /// CP/CPP link as a CA link — `dbInitLink` tests the modifier BEFORE
7032    /// locality and short-circuits `dbDbInitLink` entirely, so a CP link to a
7033    /// record in this very IOC is still a CA link (`dbLink.c:118-122`; the
7034    /// `isLocal` at `:128` is computed only to pick the init-callback hint).
7035    /// That subscription is taken with `DBE_VALUE | DBE_ALARM`
7036    /// (`dbCa.c:1225-1229` → `cadef.h:2010-2011`), and only its
7037    /// `eventCallback` adds `CA_DBPROCESS` (`dbCa.c:955-963`), which the
7038    /// worker runs as a bare `db_process` (`:1249-1257`). A source cycle that
7039    /// posts nothing — an unchanged value inside `MDEL`, no alarm movement —
7040    /// therefore leaves every CP holder unprocessed.
7041    ///
7042    /// The port keeps a local CP target as a `Db` link rather than routing it
7043    /// through the CA client (the `ca` link set lives in another crate and is
7044    /// optional, so C's literal structure would silently disable local CP
7045    /// links in a bare `epics-base-rs` IOC). `posts` is what restores the C
7046    /// rule on top of that shape: the same `DBE_VALUE|DBE_ALARM` gate the
7047    /// cross-IOC path gets from its remote monitor
7048    /// ([`Self::dispatch_external_cp_targets`]), so "CP dispatch" means one
7049    /// thing on both paths.
7050    ///
7051    /// The dispatch itself is the moral equivalent of dbCaTask's
7052    /// `CA_DBPROCESS` handler invoking `db_process(prec)` and nothing else —
7053    /// no PUTF, no RPRO. Already-visited targets (current process chain) are
7054    /// skipped via the `visited` cycle guard.
7055    fn dispatch_cp_targets(
7056        &self,
7057        name: &str,
7058        rec: &Arc<RecordCell>,
7059        posts: CyclePosts,
7060        visited: &mut ProcStack,
7061    ) {
7062        if !posts.triggers_cp() {
7063            return;
7064        }
7065        // Whether this record has a CP holder at all is the record's own
7066        // state, not something to re-derive from a name-keyed map on every
7067        // cycle — see `PvDatabase::sources_cp_edges`.
7068        if !self.sources_cp_edges(name, rec) {
7069            return;
7070        }
7071        let cp_targets = self.get_cp_targets(name);
7072        for target in cp_targets {
7073            self.process_one_cp_target(&target, visited);
7074        }
7075    }
7076
7077    /// Process a single CP/CPP target edge, applying the CPP passive gate.
7078    /// This is the single owner of the scan-time CP-dispatch decision, shared
7079    /// by the local-source path ([`Self::dispatch_cp_targets`]) and the
7080    /// cross-IOC path ([`Self::dispatch_external_cp_targets`]) so both honour
7081    /// the same `dbCa.c` semantics.
7082    ///
7083    /// The passive gate is the ONLY thing decided here. C's `CA_DBPROCESS`
7084    /// worker (`dbCa.c:1249-1257`) is bare `dbScanLock` / `db_process` /
7085    /// `dbScanUnlock`, so an active target is handled by `dbProcess` itself —
7086    /// which the port models once, in the PACT entry guard of
7087    /// [`Self::process_record_with_links_body`]. Deciding PACT a second time
7088    /// here is what let this path diverge from that owner.
7089    fn process_one_cp_target(&self, target: &super::CpTarget, visited: &mut ProcStack) {
7090        let target_rec = {
7091            let records = self.inner.records.read();
7092            records.get(target.record.as_str()).cloned()
7093        };
7094        let skip = match target_rec {
7095            // CPP gate (`dbCa.c:823-828`, `:958-962`, `:1032-1037`): a CPP link adds
7096            // `CA_DBPROCESS` only when the link-holder's SCAN is Passive. A
7097            // non-Passive target is reached by its own periodic/event scan, so
7098            // it is not dispatched here. A CP link (`passive_only == false`)
7099            // never takes this branch and always dispatches.
7100            //
7101            // epics-base PR #3fb10b6: PUTF must remain false on CP-driven
7102            // targets — only the record directly receiving the dbPut reports
7103            // PUTF=1 to dbNotify/onChange observers, so we deliberately do NOT
7104            // set PUTF here.
7105            Some(t) => {
7106                if visited.holds(&t) {
7107                    return;
7108                }
7109                let tg = t.read();
7110                target.passive_only && tg.common.scan != crate::server::record::ScanType::Passive
7111            }
7112            None => false,
7113        };
7114        if skip {
7115            return;
7116        }
7117        // recursive CP-target fan-out within one chain —
7118        // gate already held by the foreign entry record.
7119        let _ = self.process_record_with_links_recursive(&target.record, visited);
7120    }
7121
7122    /// Process every holder of an EXTERNAL CP/CPP link to `external_pv` —
7123    /// the cross-IOC twin of `Self::dispatch_cp_targets`. Called by the
7124    /// calink/pvalink CA monitor callback on every remote change, this is
7125    /// the Rust equivalent of C `dbCa.c eventCallback` adding
7126    /// `CA_DBPROCESS` for a CP (or Passive CPP) link (`dbCa.c:958-962`)
7127    /// and the worker thread running `db_process(prec)` (`dbCa.c:1255`).
7128    /// A cross-IOC source never processes locally, so this callback is the
7129    /// only trigger; without it a `CP`/`CPP` link's holder never processes
7130    /// on a remote change.
7131    ///
7132    /// A fresh `visited` set starts a new process chain —
7133    /// the monitor event is an independent external trigger, like a scan,
7134    /// not a continuation of an in-flight local chain.
7135    pub fn dispatch_external_cp_targets(&self, external_pv: &str) {
7136        let targets = self.get_external_cp_targets(external_pv);
7137        if targets.is_empty() {
7138            return;
7139        }
7140        let mut visited = ProcStack::new();
7141        for target in targets {
7142            self.process_one_cp_target(&target, &mut visited);
7143        }
7144    }
7145
7146    /// Apply the SIMM-mode OUTPUT redirect (the `writeValue` half of
7147    /// simulation). C `writeValue` substitutes the device write with
7148    /// `dbPutLink(&prec->siol, DBR_DOUBLE, &prec->oval, 1)` (aoRecord.c:574,
7149    /// `DBR_LONG`/`&prec->rval` in SIMM=RAW at :577), so this runs from the OUT
7150    /// epilogue after the body computed OVAL/RVAL.
7151    ///
7152    /// SIOL is a `DBF_OUTLINK` (aoRecord.dbd) driven by the SAME `dbPutLink`
7153    /// as the record's OUT: it is not a bare field poke. Routing it through
7154    /// [`Self::write_out_link_value`] — the put owner — is what gives the
7155    /// simulated write everything C's `dbDbPutValue` (dbDbLink.c:372-393) does
7156    /// and the old open-coded `put_pv_already_locked` did not: MS-class alarm
7157    /// inheritance into the SIOL target, `PP`/`.PROC` `processTarget`, PUTF and
7158    /// put-notify propagation — and the failed-put `LINK_ALARM`/`INVALID`
7159    /// raised BY the owner rather than by this caller (which violated
7160    /// `write_out_link_value`'s own single-raise invariant).
7161    ///
7162    /// `sim_output` is `None` for a non-simulated record or a simulated INPUT
7163    /// (whose `readValue` ran up-front); `skip_out` carries the IVOA
7164    /// Don't_drive veto so the SIOL write is suppressed exactly as the real
7165    /// device write would be.
7166    ///
7167    /// Kept as its own `async fn` so the `EpicsValue` it reads out of the
7168    /// record never enters `process_record_with_links_inner`'s async state —
7169    /// that future is polled one frame deeper per FLNK hop, unbounded as in C,
7170    /// and bloating it overflows the stack sooner (the deep-chain tests).
7171    fn write_simulated_output_siol(
7172        &self,
7173        rec: &Arc<RecordCell>,
7174        sim_output: &Option<(crate::server::record::ParsedLink, i16, bool)>,
7175        skip_out: bool,
7176        src: super::links::OutLinkSrc<'_>,
7177        visited: &mut ProcStack,
7178    ) {
7179        let Some((siol, _sims, raw_mode)) = sim_output else {
7180            return;
7181        };
7182        // IVOA Don't_drive veto (C skips `writeValue` entirely) and a
7183        // non-writable SIOL (empty / constant — C `dbPutLink` no-op) both
7184        // suppress the write.
7185        if skip_out || !siol.is_writable_out_link() {
7186            return;
7187        }
7188        // The record's own OUT value (RAW: RVAL) — matching C `writeValue`
7189        // (`dbPutLink(&prec->siol, ..., &prec->oval)`), so the SIOL redirect
7190        // sends exactly what the real OUT link would have.
7191        let value = {
7192            let instance = rec.read();
7193            if *raw_mode {
7194                instance
7195                    .record
7196                    .get_field("RVAL")
7197                    .or_else(|| instance.record.val())
7198            } else {
7199                instance.record.output_link_value()
7200            }
7201        };
7202        if let Some(value) = value {
7203            self.write_out_link_value(
7204                rec,
7205                siol,
7206                value,
7207                super::links::OutLinkSrc {
7208                    field: "SIOL",
7209                    ..src
7210                },
7211                visited,
7212            );
7213        }
7214    }
7215
7216    /// **C `dbTryGetLink`** (`dbLink.c:307-315`) — the bare `lset->getValue`
7217    /// dispatch, classified into the three outcomes C's `(status, buffer)` pair
7218    /// can carry (see [`crate::server::recgbl::simm::LinkFetch`]) and carrying
7219    /// the source-alarm tail, but WITHOUT `setLinkAlarm`.
7220    ///
7221    /// Only the two readers whose C really is `dbTryGetLink`-shaped call this
7222    /// directly ([`Self::rec_gbl_get_simm`] and swait's `recDynLinkGet` DOL);
7223    /// every other process-time read is a C `dbGetLink` and goes through
7224    /// [`Self::db_get_link`], which owns the failure alarm.
7225    ///
7226    /// The raw [`Self::read_link_value_no_process`] collapses two of them: it
7227    /// hands back the CONSTANT link's parsed text as if the link had delivered
7228    /// it this cycle, and `None` both for "constant with nothing to give" and
7229    /// for "the read failed". C keeps them apart — `dbConstGetValue`
7230    /// (`dbConstLink.c:219-225`) returns SUCCESS and writes nothing, because a
7231    /// constant's value was already loaded into the record's buffer at
7232    /// `init_record`. Every gate downstream (simulation mode, DISA, TSE, SELN)
7233    /// hangs off that distinction, so every one of them reads through here and
7234    /// the constant reaches the record only through the init-seed owner
7235    /// ([`Self::rec_gbl_init_constant_links`] / [`Self::rec_gbl_init_simm`]).
7236    /// The read CARRIES the source alarm: C's `dbGetLink` on a DB link ends in
7237    /// `dbDbGetValue`'s inheritance tail (`dbDbLink.c:228-232`), so every link a
7238    /// record reads at process time — INP, DOL, SDIS, TSEL, SELL, SIML, SIOL —
7239    /// folds an `MS` source's severity into the reader. That tail runs HERE, in
7240    /// the read primitive itself, through the single inheritance owner
7241    /// ([`Self::input_link_inheritance`]): a caller cannot drop it, because a
7242    /// caller never sees the alarm. Dropping it is exactly how DOL, SIML and
7243    /// SIOL came to lose MS while INP kept it.
7244    ///
7245    /// softIoc (`SRC0` in MAJOR): `SDIS="SRC0 MS"`, `TSEL="SRC0 MS"`,
7246    /// `SIML="SRC0 MS"`, `SIOL="SRC0 MS"` and `DOL="SRC0 MS"` (closed-loop) all
7247    /// leave the reader MAJOR/LINK; without `MS`, all leave it NO_ALARM. The
7248    /// one read C does NOT run the tail on is the `TSEL="SRC.TIME"` form
7249    /// (`recGbl.c:316-321` calls `dbGetTimeStampTag`, not `dbGetLink`) — and
7250    /// that branch does not come through here. EVERY other TSEL form falls
7251    /// through to `dbGetLink` at `recGbl.c:322`, so it does.
7252    pub(crate) fn db_try_get_link(
7253        &self,
7254        reader: &Arc<RecordCell>,
7255        link: &crate::server::record::ParsedLink,
7256    ) -> crate::server::recgbl::simm::LinkFetch {
7257        // A constant or unset link has no source, so this whole read is C's
7258        // `dbConstGetValue`: status 0, nothing stored, nothing to inherit.
7259        // Every record carries several — SDIS and TSEL at minimum — and each
7260        // one otherwise spent the read, a reader-name resolution and two lock
7261        // acquisitions per cycle to arrive back at `NoData`.
7262        if crate::server::recgbl::simm::is_constant(link) {
7263            return crate::server::recgbl::simm::LinkFetch::NoData;
7264        }
7265        let (fetch, alarm) = self.read_link_with_alarm(link);
7266        self.inherit_link_severity(reader, link, alarm);
7267        fetch
7268    }
7269
7270    /// **C `dbGetLink`** (`dbLink.c:324-340`) — [`Self::db_try_get_link`] plus the
7271    /// failure effect C attaches to it, because in C the two are ONE function:
7272    ///
7273    /// ```c
7274    /// status = dbTryGetLink(plink, dbrType, pbuffer, pnRequest);
7275    /// if (status == S_db_noLSET) return -1;
7276    /// if (status) setLinkAlarm(plink);
7277    /// ```
7278    ///
7279    /// `setLinkAlarm` is `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "field %s",
7280    /// dbLinkFieldName(plink))` — unconditional on failure, independent of the
7281    /// link's `MS` class, and carrying the LINK FIELD's own name as the AMSG. It
7282    /// is NOT the severity-inheritance tail [`Self::inherit_link_severity`] runs:
7283    /// that propagates the SOURCE's severity on a SUCCESSFUL read, and a link
7284    /// with no `MS` inherits nothing at all.
7285    ///
7286    /// The alarm lives HERE, in the read, and not in the caller, because that is
7287    /// where C puts it. Leaving it to each caller is what let SDIS, TSEL, DOL,
7288    /// NVL, SELL and SUBL go silent on a dead link while SIML, SIOL, INP and the
7289    /// `ReadDbLink` executor — the callers that happened to remember — did not.
7290    /// One uniform rule replaces six chances to forget.
7291    ///
7292    /// `link_field` is C's `dbLinkFieldName(plink)`: a `struct link` knows its own
7293    /// field name, a [`ParsedLink`](crate::server::record::ParsedLink) does not, so
7294    /// the caller spells it.
7295    ///
7296    /// Use [`Self::db_try_get_link`] for the reads whose C is NOT `dbGetLink` —
7297    /// `recGblGetSimm`'s SIML read (`dbTryGetLink`, which bypasses `setLinkAlarm`
7298    /// and writes `nsta` itself, `recGbl.c:453-454`) and swait's output-time DOL
7299    /// (`recDynLinkGet`, `swaitRecord.c:767`).
7300    pub(crate) fn db_get_link(
7301        &self,
7302        reader: &Arc<RecordCell>,
7303        link_field: &str,
7304        link: &crate::server::record::ParsedLink,
7305    ) -> crate::server::recgbl::simm::LinkFetch {
7306        let fetch = self.db_try_get_link(reader, link);
7307        if matches!(fetch, crate::server::recgbl::simm::LinkFetch::Failed) {
7308            let mut instance = reader.write();
7309            crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, link_field);
7310        }
7311        fetch
7312    }
7313
7314    /// [`Self::db_get_link`] for an INPUT link — same classification, same
7315    /// `setLinkAlarm`, but the PP rule applies first: C `dbGetLink` on a
7316    /// `ProcessPassive` DB link processes the passive source before reading it.
7317    /// Used by sel's NVL→SELN read and the closed-loop DOL read.
7318    pub(crate) fn db_get_input_link(
7319        &self,
7320        reader: &Arc<RecordCell>,
7321        link_field: &str,
7322        link: &crate::server::record::ParsedLink,
7323        visited: &mut ProcStack,
7324    ) -> crate::server::recgbl::simm::LinkFetch {
7325        if let crate::server::record::ParsedLink::Db(db) = link {
7326            self.process_passive_db_source(db, visited);
7327        }
7328        self.db_get_link(reader, link_field, link)
7329    }
7330
7331    /// Apply the reader's declared `dbrType` request
7332    /// ([`Record::input_link_request`](crate::server::record::Record::input_link_request))
7333    /// to one delivered link value — C's `dbGetLink(plink, dbrType, ...)`
7334    /// second argument, which the generic fetch paths never passed: they
7335    /// delivered the source's native value and let the target field coerce
7336    /// blind, turning a `DBR_STRING` request at an ENUM/MENU source into
7337    /// index digits (epics-base#183).
7338    ///
7339    /// The source is resolved with NO record lock held (the
7340    /// [`Self::read_db_link_into_field`] rule: a self-referencing link
7341    /// would otherwise re-enter this record's own gate), and only when the
7342    /// fetch actually delivered a value. `None` from the record is C's
7343    /// `default: break` — no read — mapped to `NoData`; a conversion the
7344    /// source cannot satisfy is a FAILED read (C's non-zero status).
7345    ///
7346    /// The second return is whether the reader asked for a STRING class: such a
7347    /// value bypasses the store's `to_f64` funnel, because that funnel IS the
7348    /// `DBR_DOUBLE` request of the calc-class records (`calcRecord.c:434`), not
7349    /// a rule of the store.
7350    fn convert_link_fetch(
7351        &self,
7352        rec: &Arc<RecordCell>,
7353        link_field: &str,
7354        link: &crate::server::record::ParsedLink,
7355        fetch: crate::server::recgbl::simm::LinkFetch,
7356    ) -> (crate::server::recgbl::simm::LinkFetch, bool) {
7357        let instance = rec.read();
7358        let request = instance.record.input_link_request(link_field);
7359        let mut fetch = fetch;
7360        let store_raw =
7361            self.convert_link_fetch_as(&*instance.record, link_field, link, request, &mut fetch);
7362        (fetch, store_raw)
7363    }
7364
7365    /// [`Self::convert_link_fetch`] for a caller that already holds the
7366    /// record's request for the link — the multi-input fetch reads it for
7367    /// every set link under the cycle's entry guard, where C's
7368    /// `fetch_values` has it for free, instead of taking the record's lock
7369    /// once per link to ask.
7370    #[inline]
7371    fn convert_link_fetch_as(
7372        &self,
7373        record: &dyn crate::server::record::Record,
7374        link_field: &str,
7375        link: &crate::server::record::ParsedLink,
7376        request: crate::server::record::InputLinkRequest,
7377        fetch: &mut crate::server::recgbl::simm::LinkFetch,
7378    ) -> bool {
7379        use crate::server::recgbl::simm::LinkFetch;
7380        use crate::server::record::{InputLinkRequest, LinkReadAs};
7381        // A native request converts nothing — C's `dbGet` with the field's
7382        // own `dbrType` is a copy — so the fetch is left where it is, and
7383        // the two tests that settle that are the whole of what the common
7384        // path pays: the conversion itself is a frame of its own.
7385        if let InputLinkRequest::As(LinkReadAs::Native) = request {
7386            return false;
7387        }
7388        if !matches!(fetch, LinkFetch::Value(_)) {
7389            return false;
7390        }
7391        self.convert_link_value(record, link_field, link, request, fetch)
7392    }
7393
7394    /// [`Self::convert_link_fetch_as`] past its gates: `fetch` holds a value
7395    /// and the request is not native.
7396    fn convert_link_value(
7397        &self,
7398        record: &dyn crate::server::record::Record,
7399        link_field: &str,
7400        link: &crate::server::record::ParsedLink,
7401        request: crate::server::record::InputLinkRequest,
7402        fetch: &mut crate::server::recgbl::simm::LinkFetch,
7403    ) -> bool {
7404        use crate::server::recgbl::simm::LinkFetch;
7405        use crate::server::record::LinkReadAs;
7406        let LinkFetch::Value(value) = std::mem::replace(fetch, LinkFetch::NoData) else {
7407            unreachable!("gated by convert_link_fetch_as");
7408        };
7409        match self.link_read_as(record, link_field, link, request) {
7410            None => false,
7411            Some(read_as) => {
7412                let raw = matches!(
7413                    read_as,
7414                    LinkReadAs::String | LinkReadAs::CharArrayAsString { .. }
7415                );
7416                match self.apply_link_read_as(link, read_as, value) {
7417                    Some(v) => {
7418                        *fetch = LinkFetch::Value(v);
7419                        raw
7420                    }
7421                    None => {
7422                        *fetch = LinkFetch::Failed;
7423                        false
7424                    }
7425                }
7426            }
7427        }
7428    }
7429
7430    /// **C `dbGetLink` for a caller that folds its MS tail in later** —
7431    /// [`Self::db_get_link`] read, converted and alarmed, but with the
7432    /// source alarm handed back instead of applied.
7433    ///
7434    /// The multi-input fetch loops (INPA..INPL and sCalcout's INAA..INLL) read
7435    /// many links with the record's write lock released and apply their MS
7436    /// inheritance together at the end, so they cannot use the inline owner.
7437    /// They can still not be the place the `setLinkAlarm` decision lives: that
7438    /// is what left `record(calc,"C"){field(INPA,"NOSUCH")}` publishing
7439    /// NO_ALARM where C publishes INVALID/LINK with AMSG `field INPA`.
7440    ///
7441    /// Returns `(fetch, source alarm, reader-asked-for-a-string-class)`.
7442    fn db_get_link_deferred(
7443        &self,
7444        rec: &Arc<RecordCell>,
7445        link_field: &str,
7446        link: &crate::server::record::ParsedLink,
7447        target: Option<&crate::server::record::record_instance::ResolvedTarget>,
7448        request: crate::server::record::InputLinkRequest,
7449    ) -> (
7450        crate::server::recgbl::simm::LinkFetch,
7451        Option<super::links::SourceAlarm>,
7452        bool,
7453    ) {
7454        let (fetch, alarm, store_raw) =
7455            self.db_try_get_link_deferred(rec, link_field, link, target, request);
7456        if matches!(fetch, crate::server::recgbl::simm::LinkFetch::Failed) {
7457            let mut instance = rec.write();
7458            crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, link_field);
7459        }
7460        (fetch, alarm, store_raw)
7461    }
7462
7463    /// The `dbTryGetLink` twin of [`Self::db_get_link_deferred`] — same read
7464    /// and conversion, no `setLinkAlarm`. swait's `fetch_values`
7465    /// (`swaitRecord.c:702`) reads INAA..INPL with `recDynLinkGet`, which has
7466    /// no such effect; its failure is answered by `recGblSetSevr(READ_ALARM,
7467    /// INVALID_ALARM)` at `swaitRecord.c:413`.
7468    fn db_try_get_link_deferred(
7469        &self,
7470        rec: &Arc<RecordCell>,
7471        link_field: &str,
7472        link: &crate::server::record::ParsedLink,
7473        target: Option<&crate::server::record::record_instance::ResolvedTarget>,
7474        request: crate::server::record::InputLinkRequest,
7475    ) -> (
7476        crate::server::recgbl::simm::LinkFetch,
7477        Option<super::links::SourceAlarm>,
7478        bool,
7479    ) {
7480        let (mut fetch, alarm) = self.read_link_with_alarm_at(link, target);
7481        let store_raw = {
7482            let instance = rec.read();
7483            self.convert_link_fetch_as(&*instance.record, link_field, link, request, &mut fetch)
7484        };
7485        (fetch, alarm, store_raw)
7486    }
7487
7488    /// The `Option`-shaped twin of [`Self::convert_link_fetch`] for the
7489    /// single-INP soft path, whose reader deals in `Option<EpicsValue>`:
7490    /// a conversion (or declaration) miss is `None`, which that path
7491    /// already classifies as a failed read of a real link (LINK alarm,
7492    /// VAL untouched — C `read_si` returning `dbGetLink`'s status).
7493    /// **The one owner of C's `dbGetLink` `dbrType` argument** — the record's
7494    /// per-link request, with the SOURCE resolved only for the record types
7495    /// that let the source decide it.
7496    ///
7497    /// The source walk (`dbGetLinkDBFtype` / `dbGetNelements`) is a records-map
7498    /// lookup plus the TARGET record's read lock, and it must run with no
7499    /// reader lock held — a self-referencing link would otherwise re-enter this
7500    /// record's own gate — so it cannot be deferred inside the record's answer.
7501    /// Asking [`Record::input_link_request`](crate::server::record::Record::input_link_request) first is what keeps it off the
7502    /// cycle of every record type whose C switch is on the link FIELD alone,
7503    /// which is all of them but `sseq`, `aSub`, `lsi` and `lso`.
7504    fn input_link_read_as(
7505        &self,
7506        rec: &Arc<RecordCell>,
7507        link_field: &str,
7508        link: &crate::server::record::ParsedLink,
7509    ) -> Option<crate::server::record::LinkReadAs> {
7510        let instance = rec.read();
7511        let request = instance.record.input_link_request(link_field);
7512        self.link_read_as(&*instance.record, link_field, link, request)
7513    }
7514
7515    /// [`Self::input_link_read_as`] with the record's request already in
7516    /// hand. The `FromSource` arm still resolves the source and asks the
7517    /// record for its answer, as before.
7518    fn link_read_as(
7519        &self,
7520        record: &dyn crate::server::record::Record,
7521        link_field: &str,
7522        link: &crate::server::record::ParsedLink,
7523        request: crate::server::record::InputLinkRequest,
7524    ) -> Option<crate::server::record::LinkReadAs> {
7525        use crate::server::record::InputLinkRequest;
7526        match request {
7527            InputLinkRequest::As(read_as) => Some(read_as),
7528            // C's `default:` arm — the record's switch has no case for this
7529            // link, so `dbGetLink` is never called.
7530            InputLinkRequest::NotRead => None,
7531            InputLinkRequest::FromSource => {
7532                let source = self.resolve_out_target(link);
7533                record.input_link_read_as_from_source(link_field, &source)
7534            }
7535        }
7536    }
7537
7538    fn typed_input_value(
7539        &self,
7540        rec: &Arc<RecordCell>,
7541        link_field: &str,
7542        link: &crate::server::record::ParsedLink,
7543        value: EpicsValue,
7544    ) -> Option<EpicsValue> {
7545        let read_as = self.input_link_read_as(rec, link_field, link)?;
7546        self.apply_link_read_as(link, read_as, value)
7547    }
7548
7549    /// C `dbDbGetValue`'s tail, applied to the reader: the ONE place a
7550    /// process-time link read folds its source's alarm in. Computes the
7551    /// `(MS class, source alarm)` pair through the inheritance owner with no
7552    /// record lock held, then applies it under a brief write lock.
7553    fn inherit_link_severity(
7554        &self,
7555        reader: &Arc<RecordCell>,
7556        link: &crate::server::record::ParsedLink,
7557        alarm: Option<super::links::SourceAlarm>,
7558    ) {
7559        if let Some(alarm) = alarm {
7560            let mut instance = reader.write();
7561            self.fold_input_link_alarm(&mut instance.common, reader, link, alarm);
7562        }
7563    }
7564
7565    /// C `recGblGetSimm` (`recGbl.c:448-457`) — **the single owner of the
7566    /// SIMM transition at process time**, and the only site allowed to write
7567    /// SIMM from SIML.
7568    ///
7569    /// ```c
7570    /// recGblSaveSimm(*psscn, poldsimm, *psimm);
7571    /// status = dbTryGetLink(psiml, DBR_USHORT, psimm, 0);
7572    /// if (status && !pcommon->nsev) pcommon->nsta = LINK_ALARM;
7573    /// recGblCheckSimm(pcommon, psscn, *poldsimm, *psimm);
7574    /// ```
7575    ///
7576    /// Called from `check_simulation_mode` on every `pact == FALSE` entry —
7577    /// C's `if (!prec->pact)` guard around it (aiRecord.c:475).
7578    ///
7579    /// Returns the SIML-read status the record's `readValue`/`writeValue` sees:
7580    /// `true` when the read FAILED. Only a record that declares
7581    /// [`Record::aborts_on_failed_siml_read`](crate::server::record::Record::aborts_on_failed_siml_read) (busy) acts on it — see that hook
7582    /// for why the other two families do not.
7583    pub(crate) fn rec_gbl_get_simm(
7584        &self,
7585        rec: &Arc<RecordCell>,
7586        siml: &crate::server::record::ParsedLink,
7587    ) -> bool {
7588        use crate::server::recgbl::simm::LinkFetch;
7589        // `recGblSaveSimm(*psscn, poldsimm, *psimm)` — latch the outgoing mode
7590        // BEFORE the SIML read can move SIMM.
7591        {
7592            let mut instance = rec.write();
7593            instance.rec_gbl_save_simm();
7594        }
7595        // `dbTryGetLink`: a CONSTANT (or unset) SIML delivers NOTHING here —
7596        // its value was loaded into SIMM once, at init (`rec_gbl_init_simm`).
7597        // So a `caput REC.SIMM YES` on a record with a constant SIML STAYS
7598        // YES; re-reading the constant every cycle (the pre-fix behaviour of
7599        // `read_link_value_no_process`) would stomp the operator's put back to
7600        // the constant on the very next process.
7601        let fetch = self.db_try_get_link(rec, siml);
7602        let failed = matches!(fetch, LinkFetch::Failed);
7603        match fetch {
7604            LinkFetch::Value(v) => {
7605                // `dbGetLink(&prec->siml, DBR_USHORT, &prec->simm)` — through the
7606                // coercion owner, source-type-chosen (see the DISA read above);
7607                // SIMM's storage here is the i16 carrier.
7608                let simm = v.to_dbf_i16().unwrap_or(0);
7609                let mut instance = rec.write();
7610                let _ = instance
7611                    .record
7612                    .put_field_internal("SIMM", EpicsValue::Short(simm));
7613            }
7614            // status 0, nothing written — SIMM keeps what init loaded.
7615            LinkFetch::NoData => {}
7616            // The read FAILED. Two C shapes, keyed on which SIML reader the
7617            // record's support uses (`Record::uses_recgbl_simm_helpers`):
7618            LinkFetch::Failed => {
7619                let mut instance = rec.write();
7620                if instance.record.uses_recgbl_simm_helpers() {
7621                    // `recGblGetSimm` (recGbl.c:453-454):
7622                    //     if (status && !pcommon->nsev) pcommon->nsta = LINK_ALARM;
7623                    // `dbTryGetLink` does NOT call `setLinkAlarm`, and this is a
7624                    // DIRECT write of `nsta` — NOT `recGblSetSevr`. So the record
7625                    // publishes STAT=LINK_ALARM with SEVR still NO_ALARM. That
7626                    // asymmetry is C's, quirk and all; reproduce it exactly.
7627                    if instance.common.nsev == crate::server::record::AlarmSeverity::NoAlarm {
7628                        instance.common.nsta = crate::server::recgbl::alarm_status::LINK_ALARM;
7629                    }
7630                } else {
7631                    // `busyRecord.c:399` / `swaitRecord.c:402` read SIML with a
7632                    // plain `dbGetLink`, whose failure path calls `setLinkAlarm`
7633                    // (dbLink.c:318-323) — a full
7634                    // `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "field %s")`.
7635                    crate::server::recgbl::rec_gbl_set_link_alarm(&mut instance.common, "SIML");
7636                }
7637            }
7638        }
7639        // `recGblCheckSimm(pcommon, psscn, *poldsimm, *psimm)` — a SIML-driven
7640        // SIMM transition swaps SCAN with SSCN exactly like a `caput REC.SIMM`
7641        // does. C runs it even on a FAILED read (recGbl.c:455 is past the
7642        // LINK_ALARM line), so the swap is not conditional on the status.
7643        self.apply_simm_scan_swap(rec);
7644        failed
7645    }
7646
7647    /// Run C `recGblCheckSimm` on a record and hand the resulting scan move to
7648    /// the scan-index owner (`update_scan_index`) — the `scanDelete`/`scanAdd`
7649    /// pair inside it. The record lock is taken and released here: the
7650    /// scan-index update re-enters the database.
7651    pub(crate) fn apply_simm_scan_swap(&self, rec: &Arc<RecordCell>) {
7652        use crate::server::record::CommonFieldPutResult;
7653        let (name, result) = {
7654            let mut instance = rec.write();
7655            let name = instance.name.clone();
7656            let result = instance.rec_gbl_check_simm();
7657            (name, result)
7658        };
7659        if let CommonFieldPutResult::ScanChanged {
7660            old_scan,
7661            new_scan,
7662            phas,
7663        } = result
7664        {
7665            self.update_scan_index(&name, old_scan, new_scan, phas, phas);
7666        }
7667    }
7668
7669    /// C `recGblInitSimm` (`recGbl.c:439-446`) plus the
7670    /// `recGblInitConstantLink(&prec->siol, …, &prec->sval)` that every
7671    /// SIML/SIOL-bearing `init_record` pairs with it (longinRecord.c:99-100,
7672    /// aiRecord.c:103-104, busyRecord.c:138, swaitRecord.c:663-670).
7673    ///
7674    /// A CONSTANT link hands its value to the record exactly ONCE, here, via
7675    /// `dbLoadLink` — at process time `dbGetLink` on a constant delivers
7676    /// nothing. This is the other half of the rule
7677    /// `Self::fetch_link` enforces; without it a `field(SIOL, "42")`
7678    /// would never reach SVAL at all.
7679    ///
7680    /// Must be called once per record, after its fields are applied — the
7681    /// `init_record(1)` sites (`ioc_builder`, `dbLoadRecords`).
7682    /// C `recGblInitConstantLink(&prec->inp, …, &prec->val)` /
7683    /// `dbLoadLinkArray(&prec->inp, prec->ftvl, prec->bptr, &nRequest)` — the
7684    /// ONE place a constant INP reaches a record.
7685    ///
7686    /// Every soft-channel INPUT device support runs this in its
7687    /// `init_record`: `devAiSoft.c:44`, `devLiSoft.c`, `devBiSoft.c`,
7688    /// `devI64inSoft.c`, `devMbbiSoft.c`, `devSiSoft.c`, `devEventSoft.c`
7689    /// (scalars, via `recGblInitConstantLink`), and `devAaiSoft.c:57`,
7690    /// `devWfSoft.c:42`, `devSASoft.c` (arrays, via `dbLoadLinkArray`). The
7691    /// raw variants (`devAiSoftRaw.c`, `devBiSoftRaw.c`, `devMbbiSoftRaw.c`)
7692    /// load into RVAL instead and let the record's own RVAL→VAL conversion
7693    /// run — hence the [`Record::raw_soft_input`](crate::server::record::Record::raw_soft_input) arm, the same sink the
7694    /// process-time path uses for `Raw Soft Channel`.
7695    ///
7696    /// This is the other half of the rule
7697    /// [`PvDatabase::read_link_value_soft`](super::PvDatabase::read_link_value_soft) enforces (a constant
7698    /// delivers NOTHING at process): without the init load a `field(INP, "5")`
7699    /// ai would never see 5 at all; without the process-time skip the constant
7700    /// would clobber the record's VAL on every scan.
7701    ///
7702    /// Gated on soft DTYP because a hardware record's INP is a device ADDRESS,
7703    /// not a value — C only ever loads it in soft dev support.
7704    ///
7705    /// **This is THE init-seed owner.** Beyond the device-support INP above it
7706    /// applies the record's own `recGblInitConstantLink` table,
7707    /// [`Record::constant_init_links`](crate::server::record::Record::constant_init_links) — calc/calcout/sub/sel/aSub/scalcout/
7708    /// acalcout/transform `INPA..L → A..L`, sel `NVL → SELN`, fanout/dfanout/
7709    /// seq `SELL → SELN`, seq `DOLn → DOn`, aSub `SUBL → SNAM`, and the
7710    /// `DOL → VAL` seeds that also clear UDF. Every one of those links is
7711    /// dead at process time (the link layer returns `LinkFetch::NoData` for a
7712    /// constant), so this is the only place their values can arrive.
7713    ///
7714    /// Must be called once per record, after its fields are applied and both
7715    /// `init_record` passes have run (the record needs its final NELM/FTVL
7716    /// buffer before an array constant can land in it) — the `init_record(1)`
7717    /// sites (`ioc_builder`, `dbLoadRecords`). It also runs from
7718    /// `PvDatabase::add_record`, the creation sink every other path funnels
7719    /// through, so a record built programmatically (no `IocBuilder`) still has
7720    /// its constants seeded: in C there is no record in the database that
7721    /// `init_record` did not touch. Seeding twice is a no-op — both calls
7722    /// happen before any client can put.
7723    pub(crate) fn rec_gbl_init_constant_links(&self, rec: &Arc<RecordCell>) {
7724        let mut instance = rec.write();
7725        seed_constant_links(&mut instance);
7726    }
7727}
7728
7729/// The body of the init-seed owner, over a locked record — shared by
7730/// [`PvDatabase::rec_gbl_init_constant_links`] and `PvDatabase::add_record`.
7731pub(crate) fn seed_constant_links(instance: &mut RecordInstance) {
7732    // The SECOND seat of C's `init_record` body, and so it takes the same
7733    // opening test: every step below sits BELOW `if (!pdset) { … return
7734    // S_dev_noDSET; }` in the C source it ports — the soft dset's constant
7735    // load, the record's own `recGblInitConstantLink` table (`aoRecord.c:112`),
7736    // and the tail plus tracker seed at `aoRecord.c:156-161`. A record whose
7737    // dset is NULL reaches none of them, which is why softIoc reads `MLST: 0`
7738    // on an `ai` whose DTYP nobody registered where the port read its VAL.
7739    if !instance.init_record_reaches_body() {
7740        return;
7741    }
7742
7743    // 0. The long-string load, C `dbLoadLinkLS` — a lset entry of its own, NOT
7744    //    `recGblInitConstantLink`, and the only one that can write a
7745    //    long-string VAL: `lso` runs it on DOL (lsoRecord.c:82), `lsi`'s soft
7746    //    device support on INP (devLsiSoft.c:24). It replaces the scalar seeds
7747    //    below for those records — a long-string VAL takes no scalar put.
7748    if let Some(link_field) = instance.record.constant_ls_link() {
7749        // C binds `loadLS` to the INP link through the SOFT device support, so
7750        // a hardware DTYP loads nothing; DOL is in the record itself and is
7751        // never gated.
7752        let gated = link_field != "INP" || instance.common.dtyp.is_soft();
7753        let text = if link_field == "INP" {
7754            instance.common.inp.clone()
7755        } else {
7756            match instance.record.get_field(link_field) {
7757                Some(EpicsValue::String(s)) => s.as_str_lossy().into_owned(),
7758                _ => String::new(),
7759            }
7760        };
7761        if gated {
7762            if let Some(load) = crate::server::record::load_link_ls(&text) {
7763                // C's lso/lsi init tail: `if (prec->len) { … prec->udf = FALSE; }`
7764                // — a link that loaded (even the number case, whose LEN is 1
7765                // with an empty VAL) DEFINES the record.
7766                if instance.record.apply_ls_load(load) != 0 {
7767                    instance.common.udf = 0;
7768                }
7769            }
7770        }
7771        instance.record.init_record_tail();
7772        instance.record.seed_deadband_tracking();
7773        return;
7774    }
7775
7776    // 1. The soft-channel device support's INP → VAL/RVAL load. It is DEVICE
7777    //    SUPPORT's `init_record` (`devAiSoft.c` &c), so it runs only on records
7778    //    that HAVE a DSET — `Record::input_read_by_device_support`. A record
7779    //    that reads its own INP (compress) gets no init load in C, and its
7780    //    constant therefore never reaches the record at all.
7781    if instance.common.dtyp.is_soft() && instance.record.input_read_by_device_support() {
7782        let inp = crate::server::record::parse_link_v2(&instance.common.inp);
7783        let mut loaded = false;
7784        if let Some(value) = crate::server::recgbl::simm::constant_load_value(&inp) {
7785            // Same sink the per-cycle soft-input apply uses, so the constant
7786            // lands in the field the link would have written: RVAL for `Raw
7787            // Soft Channel` (the record converts RVAL→VAL), VAL otherwise.
7788            // `RawSoftEntry::InitConstant` — the SoftRaw dsets do NOT mask the
7789            // init load (`devBiSoftRaw.c:57` calls `recGblInitConstantLink`
7790            // straight into RVAL; only `read_bi` applies MASK).
7791            let raw = if instance.common.dtyp.soft()
7792                == Some(crate::server::device_support::SoftDtyp::Raw)
7793            {
7794                instance
7795                    .record
7796                    .raw_soft_input(RawSoftEntry::InitConstant, value.clone())
7797            } else {
7798                None
7799            };
7800            loaded = match raw {
7801                Some(res) => res.is_ok(),
7802                None => instance.record.set_val(value).is_ok(),
7803            };
7804            // C: `if (recGblInitConstantLink(...)) prec->udf = FALSE;` — a
7805            // record whose value came from a constant link is DEFINED.
7806            if loaded {
7807                instance.common.udf = 0;
7808            }
7809        }
7810        // The FAILURE arm of the same dset `init_record`. `devWfSoft.c:39-51`
7811        // does not just skip a link it could not load — it ZEROES the element
7812        // count:
7813        //
7814        // ```c
7815        //     status = dbLoadLinkArray(&prec->inp, prec->ftvl, prec->bptr, &nelm);
7816        //     if (!status) { prec->nord = nelm; prec->udf = FALSE; }
7817        //     else          prec->nord = 0;
7818        // ```
7819        //
7820        // so the record's own `nord = (nelm == 1)` seed does not survive a
7821        // waveform whose INP is a real link or unset. Defaulted no-op.
7822        instance.record.soft_input_dset_init(loaded);
7823    }
7824
7825    // 2. The record's own `recGblInitConstantLink` table, through the shared
7826    //    owner of "a CONSTANT link's text becomes the target field's value"
7827    //    (`record::rec_gbl_init_constant_link`) — the SAME load a runtime put to
7828    //    the link field re-runs from `special()`, so the two cannot drift.
7829    for seed in instance.record.constant_init_links() {
7830        let Some(value) =
7831            crate::server::record::rec_gbl_init_constant_link(&mut *instance.record, &seed)
7832        else {
7833            continue;
7834        };
7835        // C's UDF rule for a successful constant load is per record, and the two
7836        // shapes differ only in the NaN case:
7837        //   aoRecord.c:112-113 / dfanoutRecord.c:105-106 — `udf = isnan(val)`
7838        //   longoutRecord.c:113 / mbboRecord.c:133 / int64outRecord.c:110 —
7839        //                                            `udf = FALSE`
7840        // A NaN cannot survive the conversion into an integer target, so the
7841        // isnan test covers both: the value that reached the field is defined
7842        // unless it is NaN.
7843        let is_nan = value.to_f64().is_some_and(f64::is_nan);
7844        if seed.clears_udf && !is_nan {
7845            instance.common.udf = 0;
7846        }
7847    }
7848
7849    // 3. C's `init_record` TAIL, which every record runs immediately AFTER its
7850    //    `recGblInitConstantLink` calls (`aoRecord.c:156-161`: `oval = pval =
7851    //    val; mlst = alst = lalm = val; oraw = rval; orbv = rbv`). It re-derives
7852    //    the record's init-time tracking state from the value the seed just
7853    //    loaded — a constant DOL of 5 leaves C's ao at OVAL=5, not 0
7854    //    (softIoc-verified) — so it belongs to the seed owner, not to a caller
7855    //    that may or may not remember it (the iocsh `dbLoadRecords` path did
7856    //    not).
7857    instance.record.init_record_tail();
7858    instance.record.seed_deadband_tracking();
7859
7860    // C's init-time `db_post_events` run during iocInit, before any client can
7861    // subscribe, so they are observable by nobody. A seed put that made the
7862    // record MARK a field (sseq: seeding `STRn` re-derives `DOn`) must not leave
7863    // that mark standing for the first process cycle to emit — that would turn a
7864    // no-op C post into a real, late event. Drop the init-time marks.
7865    let _ = instance.record.take_cycle_posted_fields();
7866}
7867
7868impl PvDatabase {
7869    pub(crate) fn rec_gbl_init_simm(&self, rec: &Arc<RecordCell>) {
7870        // The data guard is released (block close) before the scan-swap await
7871        // below (parking_lot guards are `!Send`).
7872        let siml_is_constant = {
7873            let mut instance = rec.write();
7874            // No SIMM field -> no simulation block -> nothing to init.
7875            if instance.resolve_field("SIMM").is_none() {
7876                return;
7877            }
7878            let link_of = |instance: &RecordInstance, field: &str| {
7879                instance.resolve_field(field).and_then(|v| {
7880                    if let EpicsValue::String(s) = v {
7881                        Some(crate::server::record::parse_link_v2(
7882                            s.as_str_lossy().as_ref(),
7883                        ))
7884                    } else {
7885                        None
7886                    }
7887                })
7888            };
7889            // C `recGblInitSimm` (`recGbl.c:441-445`) is one `if
7890            // (dbLinkIsConstant(psiml))` around ALL THREE steps — the
7891            // `recGblSaveSimm` latch, the `dbLoadLink`, and the
7892            // `recGblCheckSimm` scan swap. A record whose SIML names a PV gets
7893            // none of them: OLDSIMM keeps its dbd initial and SCAN is left
7894            // alone until the first `recGblGetSimm`. Guarding only the load
7895            // would be worse than guarding nothing — with the latch still
7896            // taken, `field(SIMM,"YES")` in the `.db` would then read
7897            // `simm != oldsimm` at the tail and swap a scan C never swaps.
7898            let siml = link_of(&instance, "SIML");
7899            // An unset SIML is a CONSTANT link (`dbConstLink.c`'s lset with a
7900            // NULL string), which is what a missing field means here.
7901            let siml_is_constant = siml
7902                .as_ref()
7903                .is_none_or(crate::server::recgbl::simm::is_constant);
7904            if siml_is_constant {
7905                instance.rec_gbl_save_simm();
7906                if let Some(v) = siml
7907                    .as_ref()
7908                    .and_then(crate::server::recgbl::simm::constant_load_value)
7909                {
7910                    let _ = instance.record.put_field_internal("SIMM", v);
7911                }
7912            }
7913            // `recGblInitConstantLink(&prec->siol, DBF_<sval>, &prec->sval)` — the
7914            // records with no SVAL (waveform/aai read into `bptr`, lsi into `val`)
7915            // load nothing here, exactly as their C `init_record` does.
7916            if instance.record.get_field("SVAL").is_some() {
7917                if let Some(siol) = link_of(&instance, "SIOL") {
7918                    if let Some(v) = crate::server::recgbl::simm::constant_load_value(&siol) {
7919                        let _ = instance.record.put_field_internal("SVAL", v);
7920                    }
7921                }
7922            }
7923            // `recGblCheckSimm(pcommon, psscn, *poldsimm, *psimm)`: a record loaded
7924            // with `field(SIML,"1")` starts in simulation, so its SCAN and SSCN are
7925            // already swapped by the time the IOC reaches runtime.
7926            siml_is_constant
7927        };
7928        if siml_is_constant {
7929            self.apply_simm_scan_swap(rec);
7930        }
7931    }
7932
7933    /// Check simulation mode for a record. Returns
7934    /// `SimOutcome::Simulated` when a simulated INPUT handled the value (the
7935    /// caller still runs the forward-link tail),
7936    /// `SimOutcome::RedirectOutputToSiol` when a simulated OUTPUT needs the
7937    /// uniform body to run first, or `SimOutcome::NotSimulated` when normal
7938    /// processing should proceed.
7939    ///
7940    /// The SIM/SDLY continuation arms release the PACT the SDLY defer held (C
7941    /// `readValue`/`writeValue` continue with `pact = FALSE`), so the call also
7942    /// hands back the [`PactExit`] for that release — the put-notify parked on
7943    /// the SDLY window. The caller carries it to the cycle's `recGblFwdLink`
7944    /// tail; the release cannot silently drop it (`#[must_use]`), which is what
7945    /// stranded it here before.
7946    fn check_simulation_mode(
7947        &self,
7948        rec: &Arc<RecordCell>,
7949    ) -> (SimOutcome, crate::server::record::PactExit) {
7950        // Read SIML, SIMM, SIOL, SIMS, SDLY from the record
7951        let (siml_link, siol_link, sims, sdly, _rtype, is_input, input_stage, pact_held) = {
7952            let instance = rec.read();
7953            // The entry gate is the SIM BLOCK's own marker — the SIMM field.
7954            // C's `readValue`/`writeValue` exists only on a record whose dbd
7955            // declares SIMM, and it dispatches on SIMM alone; the SIML/SIOL
7956            // links are read INSIDE that dispatch, never as a precondition for
7957            // it. Gating on "SIML and SIOL are both empty" (the pre-fix gate)
7958            // made `caput REC.SIMM 1` + `caput REC.SVAL 42` — simulate against
7959            // a constant, the standard idiom — a complete no-op on every
7960            // record, because an unset SIOL is exactly the case C serves from
7961            // SVAL (R12-61).
7962            //
7963            // It is asked FIRST, and of the record's DECLARATION. It used to be
7964            // asked fifth, by `resolve_field("SIMM")`, after SIML, SIOL, SIMS
7965            // and SDLY had each been resolved by name — so every calc, sub,
7966            // aSub, sel, seq and fanout in a database paid four full field
7967            // scans per process cycle to reach a gate that was always going to
7968            // turn it away.
7969            if !instance.declares_simulation() {
7970                return (
7971                    SimOutcome::NotSimulated,
7972                    instance.pact_exit_without_release(),
7973                );
7974            }
7975            let rtype = instance.record.record_type().to_string();
7976            // swait: the simulation replaces the record's input STAGE, not its
7977            // whole cycle. Declared by the record, not by a type-name list —
7978            // the classification is a property of where C put the SIOL read.
7979            let input_stage = instance.record.simulation_substitutes_input_stage();
7980            // C `prec->pact` at process entry — the value every readValue/
7981            // writeValue simulation guard keys on. The framework holds the
7982            // `processing` flag across an async wait owned by PACT (the SDLY
7983            // defer, the ODLY/swait ReprocessAfter), and the entry guard in
7984            // `process_record_with_links_inner` lets only such a held
7985            // continuation reach this point with the flag set. A fresh cycle
7986            // reads `false`; so does a `pact=FALSE` delayed re-trigger that does
7987            // NOT own PACT (e.g. the bo HIGH one-shot, which re-enters via the
7988            // same token mechanism but returned `Complete`). So `is_processing()`
7989            // is the faithful analog of `prec->pact` — finer than "re-entered via
7990            // a token" (`is_continuation`), which conflates the PACT-owning
7991            // continuation with the pact=FALSE re-trigger.
7992            let pact_held = instance.is_processing();
7993            // Every input record whose DBD declares SIML/SIOL/SIMM/SIMS.
7994            // `mbbi`/`mbbiDirect` are input records: `mbbiRecord.c:125-126`
7995            // (and mbbiDirectRecord.c) declare SIML+SIOL, and
7996            // `mbbiRecord.c:388-394` reads `dbGetLink(&prec->siol,
7997            // DBR_ULONG, &prec->sval)` then `rval = sval` — input
7998            // semantics. Omitting them sent a simulated mbbi down the
7999            // OUTPUT branch, which writes VAL out to SIOL instead of
8000            // reading the value in from it.
8001            //
8002            // `waveform`/`histogram` are also `readValue` inputs: both call
8003            // `readValue` at the START of `process()` and read SIOL in
8004            // (`waveformRecord.c:139`->`:351` `dbGetLink(&siol, ftvl, bptr)`;
8005            // `histogramRecord.c:209`->`:384` `dbGetLink(&siol, DBR_DOUBLE,
8006            // &sval)`). They are classified as inputs so a simulated cycle
8007            // reads SIOL rather than running the real device read and writing
8008            // VAL back out. Each lands the value where its own C `readValue`
8009            // lands it, through `Record::land_simulated_value`: `waveform` puts
8010            // the SIOL array in VAL (the default `set_val`), `histogram` puts
8011            // the scalar in SGNL and bins it (`histogramRecord.c:385` +
8012            // `:219` `add_count`), because its VAL is the bin-count array.
8013            //
8014            // `aai` is also a SIOL-reading input, but the SIOL read lives in
8015            // its soft DEVICE support, not the record support. `aaiRecord.c::
8016            // readValue` (:342) raises SIMM_ALARM then calls `read_aai`, and
8017            // `devAaiSoft.c::read_aai` (:89) reads
8018            // `simm == YES ? &prec->siol : &prec->inp` — i.e. SIMM=YES reads
8019            // the SIOL array into VAL, observably identical to `waveform`. (The
8020            // record-support `readValue` alone looks device-only, which is
8021            // misleading: the soft device is what redirects to SIOL, exactly as
8022            // `devAaoSoft.c::write_aao` (:56) writes `simm == YES ? &siol :
8023            // &out` for the `aao` OUTPUT twin.) So `aai` is classified as an
8024            // input alongside `waveform`; its SIOL array lands in VAL via the
8025            // same `set_val` path. `aao` is correctly EXCLUDED: its soft device
8026            // writes VAL out to SIOL, which the OUTPUT redirect (`!is_input` ->
8027            // `RedirectOutputToSiol` -> `write_simulated_output_siol`, VAL array
8028            // -> SIOL) already reproduces.
8029            let is_input = input_stage
8030                || matches!(
8031                    rtype.as_str(),
8032                    "ai" | "bi"
8033                        | "mbbi"
8034                        | "mbbiDirect"
8035                        | "longin"
8036                        | "int64in"
8037                        | "stringin"
8038                        | "lsi"
8039                        | "event"
8040                        | "waveform"
8041                        | "histogram"
8042                        | "aai"
8043                        // synApps `mca`: `mcaRecord.c:1097` `readValue` reads
8044                        // SIOL IN (`dbGetLink(&siol, ftvl, bptr, NULL,
8045                        // &nRequest)` with `nRequest = nmax`), exactly as
8046                        // `waveform` does. Omitting it sent a simulated mca
8047                        // down the OUTPUT branch, which writes VAL out to SIOL.
8048                        | "mca"
8049                );
8050
8051            // Resolve the SIM-block fields through the INSTANCE, not through
8052            // `Record::get_field`. A record need not model every field its
8053            // `.dbd` declares, and `mca` deliberately does not model
8054            // SIML/SIOL — it leaves them to the framework
8055            // (`mca-rs/src/record/mod.rs:896-902`) — so their link text lives
8056            // in the instance's declared-override store and `record.get_field`
8057            // answers `None`. That read an empty SIOL on every simulated mca.
8058            // `resolve_field` is the single owner of "what does this field read
8059            // as": record state, dbCommon, virtual, override, `.dbd` initial.
8060            let siml = instance
8061                .resolve_field("SIML")
8062                .and_then(|v| {
8063                    if let EpicsValue::String(s) = v {
8064                        Some(s)
8065                    } else {
8066                        None
8067                    }
8068                })
8069                .unwrap_or_default();
8070            let siol = instance
8071                .resolve_field("SIOL")
8072                .and_then(|v| {
8073                    if let EpicsValue::String(s) = v {
8074                        Some(s)
8075                    } else {
8076                        None
8077                    }
8078                })
8079                .unwrap_or_default();
8080            // SIMS is `DBF_MENU` (`mcaRecord.dbd:391`, `aiRecord.dbd.pod:511`
8081            // and every other), so read the INDEX and not one chosen carrier:
8082            // base record types answer `EpicsValue::Short` (`records/ai.rs:310`)
8083            // while `mca` answers `EpicsValue::Enum`
8084            // (`mca-rs/src/record/mod.rs:679`). Narrowing on `Short` here read
8085            // `mca`'s SIMS as the `unwrap_or(0)` default, so a simulated mca
8086            // raised `SIMM_ALARM` at NO_ALARM whatever the database asked for
8087            // — silently, since a menu index of 0 is a legal value.
8088            let sims = instance
8089                .resolve_field("SIMS")
8090                .and_then(|v| v.to_menu_index())
8091                .unwrap_or(0);
8092            // SDLY ("Sim. Mode Async Delay", DBF_DOUBLE, dbd initial
8093            // "-1.0"). Absent on record types whose SIMM group Rust does not
8094            // yet fully model — default to -1.0 (synchronous) so the async
8095            // branch is a no-op there, exactly as a record with the C default
8096            // behaves.
8097            let sdly = instance
8098                .resolve_field("SDLY")
8099                .and_then(|v| v.to_f64())
8100                .unwrap_or(-1.0);
8101
8102            let siml_parsed = crate::server::record::parse_link_v2(siml.as_str_lossy().as_ref());
8103            // SIOL is `DBF_INLINK` on an input record (`aiRecord.dbd.pod:492`)
8104            // and `DBF_OUTLINK` on an output one (`aoRecord.dbd.pod:551`), so
8105            // its modifier mask (`dbStaticLib.c:2380-2391`) follows the same
8106            // direction split — CP/CPP is discarded on the output side.
8107            let siol_parsed = crate::server::record::parse_link_field(
8108                siol.as_str_lossy().as_ref(),
8109                if is_input {
8110                    crate::server::record::LinkFieldType::In
8111                } else {
8112                    crate::server::record::LinkFieldType::Out
8113                },
8114            );
8115
8116            (
8117                siml_parsed,
8118                siol_parsed,
8119                sims,
8120                sdly,
8121                rtype,
8122                is_input,
8123                input_stage,
8124                pact_held,
8125            )
8126        };
8127
8128        // Read SIML -> update SIMM, but only when PACT is not held. C resolves
8129        // the simulation mode in `recGblGetSimm` (`dbGetLink(&prec->siml,
8130        // DBR_USHORT, &prec->simm, 0, 0)`, reads the SIML link for any type)
8131        // guarded by `if (!prec->pact)` (aiRecord.c:475 / aoRecord.c:558): SIMM
8132        // is latched whenever the record re-enters with PACT held and is
8133        // re-resolved on every `pact=FALSE` entry. Gate the re-read on
8134        // `!pact_held` to match exactly: on the SDLY async continuation (PACT
8135        // held) the latch holds, so a SIML source that flips during the delay
8136        // cannot switch the deferred SIOL round-trip into a real device read;
8137        // on a `pact=FALSE` delayed re-trigger (the bo HIGH one-shot) the
8138        // re-resolve runs, matching C's fresh `recGblGetSimm`. The non-held
8139        // entry persists SIMM via `put_field` below, so a later held
8140        // continuation reads it back latched. (The pre-fix port only read a
8141        // `ParsedLink::Db` SIML, ignoring a CA/PVA/constant source.)
8142        //
8143        // The read itself goes through the SIMM transition owner
8144        // (`rec_gbl_get_simm`, C `recGblGetSimm`), which is the ONLY site that
8145        // writes SIMM.
8146        if !pact_held {
8147            let siml_read_failed = self.rec_gbl_get_simm(rec, &siml_link);
8148            // W10-E5. `busyRecord.c:399-401` returns from `writeValue` on a
8149            // failed SIML read — BEFORE `write_busy` and before the SIOL
8150            // `dbPutLink`. So C never reaches the `switch (prec->simm)` below:
8151            // no device write, no SIOL redirect, no SIMM_ALARM. The LINK_ALARM
8152            // that `dbGetLink`'s `setLinkAlarm` raised inside `rec_gbl_get_simm`
8153            // is the cycle's only simulation alarm.
8154            //
8155            // Only a record that declares it aborts takes this path — busy. The
8156            // recGblGetSimm records' equivalent `if (status) return status;` is
8157            // dead code (recGbl.c:456 always returns 0) and swait never tests
8158            // the status (swaitRecord.c:402), so both fall through to the switch
8159            // with SIMM at whatever value it already held.
8160            if siml_read_failed {
8161                // Reachable only under `!pact_held`, so no PACT to release —
8162                // and the exit is read under the same guard as the question,
8163                // since nothing sits between them.
8164                let (aborts, exit) = {
8165                    let instance = rec.read();
8166                    (
8167                        instance.record.aborts_on_failed_siml_read(),
8168                        instance.pact_exit_without_release(),
8169                    )
8170                };
8171                if aborts {
8172                    return (SimOutcome::AbortedBeforeWrite, exit);
8173                }
8174            }
8175        }
8176
8177        // Check SIMM. The dispatch is the record's own C `switch (prec->simm)`,
8178        // whose legal arms are the choices of ITS SIMM menu — `resolve_sim_mode`
8179        // is the single owner of that fact.
8180        // PACT, if held, belongs to the continuation arm of the uniform body —
8181        // released there, with its park. Read beside the mode, under one guard.
8182        let (mode, no_sim_exit) = {
8183            let instance = rec.read();
8184            (
8185                crate::server::recgbl::simm::resolve_sim_mode(&*instance.record),
8186                instance.pact_exit_without_release(),
8187            )
8188        };
8189
8190        if !mode.is_simulated() {
8191            return (SimOutcome::NotSimulated, no_sim_exit); // menuSimmNO
8192        }
8193
8194        // C `default:` arm — `recGblSetSevr(prec, SOFT_ALARM, INVALID_ALARM)`
8195        // and NOTHING else: the device is not substituted, SIOL is never read or
8196        // written, SIMM_ALARM is not raised and VAL/UDF are untouched. Raise the
8197        // alarm here (into the PENDING pair, so the body/tail maximizes against
8198        // it exactly as C does) and tell the caller to suppress the record's I/O
8199        // stage. This is the arm a `SIMM = 2` (RAW) reaches on the 13 records
8200        // whose SIMM is `menu(menuYesNo)` — R11-C12 — and the arm ANY
8201        // out-of-menu SIMM reaches on all of them, since `recGblGetSimm`'s
8202        // `dbTryGetLink` writes SIMM with no menu validation at all.
8203        if mode == crate::server::recgbl::simm::SimMode::Illegal {
8204            let mut instance = rec.write();
8205            crate::server::recgbl::rec_gbl_set_sevr(
8206                &mut instance.common,
8207                crate::server::recgbl::alarm_status::SOFT_ALARM,
8208                crate::server::record::AlarmSeverity::Invalid,
8209            );
8210            // Reachable with PACT held only on an SDLY continuation whose SIMM
8211            // was made illegal (by a `caput`) during the delay: C's `readValue`
8212            // re-reads SIMM only when `!pact`, so the continuation's switch sees
8213            // the new value and takes `default:` — which does NOT clear `pact`,
8214            // but the record's `process()` ends with `prec->pact = FALSE` on the
8215            // way out. Release it here for the same reason the YES/RAW branches
8216            // do (below and at the `Simulated` tail): the cycle ends, so the
8217            // record must be left idle. The release carries the put-notify
8218            // parked on the SDLY window out to the caller's tail.
8219            let exit = if pact_held {
8220                instance.leave_pact()
8221            } else {
8222                instance.pact_exit_without_release()
8223            };
8224            let is_output = !is_input;
8225            drop(instance);
8226            return (SimOutcome::IllegalMode { is_output }, exit);
8227        }
8228
8229        // epics-base 7.0.7 (SIMM menu):
8230        //   1 = YES — read/write via SIOL using the cooked VAL
8231        //   2 = RAW — read/write via SIOL using the raw RVAL when the
8232        //             record carries one (ai/ao only); falls back to
8233        //             VAL when no RVAL is present. Mirrors the C
8234        //             implementation, which treats records lacking
8235        //             a raw value as "YES" since there's nothing
8236        //             else to copy.
8237        let raw_mode = mode == crate::server::recgbl::simm::SimMode::Raw;
8238
8239        // SDLY async simulation — C `aiRecord.c::readValue` (488) /
8240        // `aoRecord.c::writeValue` (571): `if (prec->pact || prec->sdly < 0)`
8241        // takes the synchronous SIOL branch; otherwise (`!pact && sdly >= 0`)
8242        // it schedules `callbackRequestProcessCallbackDelayed(..., sdly)` and
8243        // sets `pact = TRUE`. Key the defer on the same `!pact_held && sdly >= 0`
8244        // as C: a non-held entry (fresh cycle, or a `pact=FALSE` re-trigger)
8245        // with a non-negative SDLY defers the whole SIOL round-trip (input read
8246        // OR output write — both C paths share this branch) by `SDLY` seconds
8247        // and holds PACT; the resulting PACT-held continuation falls through to
8248        // the synchronous branch below.
8249        if !pact_held && sdly >= 0.0 {
8250            // Reachable only under `!pact_held`: this is the arm that TAKES PACT.
8251            let exit = rec.read().pact_exit_without_release();
8252            return (
8253                SimOutcome::DeferRead(crate::runtime::time::duration_from_secs(sdly)),
8254                exit,
8255            );
8256        }
8257
8258        // INPUT-STAGE record (swait). C `swaitRecord.c:415-422`:
8259        //
8260        // ```c
8261        // } else {      /* SIMULATION MODE */
8262        //     status = dbGetLink(&(pwait->siol),DBR_DOUBLE,&(pwait->sval),0,0);
8263        //     if (status==0) {
8264        //         pwait->val=pwait->sval;
8265        //         pwait->udf=FALSE;
8266        //     }
8267        //     recGblSetSevr(pwait,SIMM_ALARM,pwait->sims);
8268        // }
8269        // ```
8270        //
8271        // The read substitutes `fetch_values()` + `calcPerform()` and nothing
8272        // else, so this performs exactly those four lines and hands the cycle
8273        // back: the OOPT switch, `execOutput`, the monitors and the forward link
8274        // all still come from the record's own `process()`. SIMM_ALARM goes into
8275        // the PENDING alarm (`rec_gbl_set_sevr` is C's MAXIMIZE) before the body
8276        // runs, so a body-raised alarm maximizes against it exactly as in C.
8277        if input_stage {
8278            // C `swaitRecord.c:416` reads SIOL with a plain `dbGetLink`, so a
8279            // FAILED read runs `setLinkAlarm` (dbLink.c:322) inside the read —
8280            // LINK_ALARM/INVALID with AMSG "field SIOL", raised BEFORE the
8281            // SIMM_ALARM below because that is swait's order (`dbGetLink` at
8282            // `swaitRecord.c:416`, then `recGblSetSevr(SIMM_ALARM, sims)` at
8283            // `:421`) — the opposite of the base records. `rec_gbl_set_sevr*` is
8284            // strict-greater, so with `SIMS = INVALID` the LINK_ALARM raised
8285            // first WINS the tie here and swait publishes
8286            // STAT=LINK/AMSG="field SIOL", where a longin publishes STAT=SIMM.
8287            // Compiled C confirms both.
8288            let fetch = self.db_get_link(rec, "SIOL", &siol_link);
8289            let mut instance = rec.write();
8290            // C `:417-420` — `if (status == 0) { val = sval; udf = FALSE; }`.
8291            // A CONSTANT (or unset) SIOL is `status == 0` with SVAL untouched
8292            // (`dbConstGetValue`), so it still copies SVAL into VAL; only a
8293            // FAILED read changes neither VAL nor UDF. The SIMM_ALARM below is
8294            // unconditional either way.
8295            if fetch.is_ok() {
8296                if let crate::server::recgbl::simm::LinkFetch::Value(v) = fetch {
8297                    let sval = EpicsValue::Double(v.to_f64().unwrap_or(0.0));
8298                    let _ = instance.record.put_field_internal("SVAL", sval);
8299                }
8300                if let Some(sval) = instance.record.get_field("SVAL") {
8301                    let _ = instance.record.land_simulated_value(sval);
8302                }
8303                instance.common.udf = 0;
8304            }
8305            let sev = crate::server::record::AlarmSeverity::from_u16(sims as u16);
8306            crate::server::recgbl::rec_gbl_set_sevr(
8307                &mut instance.common,
8308                crate::server::recgbl::alarm_status::SIMM_ALARM,
8309                sev,
8310            );
8311            // swait keeps the cycle going through the uniform body; a held PACT
8312            // is released at its continuation arm, with its park. Mint the
8313            // token from the write guard already held — parking_lot is not
8314            // reentrant, so a fresh `rec.read()` here deadlocks.
8315            let exit = instance.pact_exit_without_release();
8316            return (SimOutcome::SimulatedInputStage, exit);
8317        }
8318
8319        // OUTPUT record: C `writeValue` substitutes the device write with the
8320        // SIOL write, but it runs at the END of `process()` — after the body
8321        // has computed OVAL (OROC) and armed any record state machine (bo HIGH
8322        // momentary reset). The output write therefore CANNOT be done here, up
8323        // front, the way the input read can: doing so would write the stale
8324        // pre-body VAL and skip the body entirely (the divergence this path
8325        // closes). Hand the redirect back so the uniform flow runs the body and
8326        // the OUT-stage epilogue writes the fresh OVAL/RVAL to SIOL. Clear the
8327        // SDLY-held PACT first (C `writeValue` sets `pact = FALSE` on the sync
8328        // continuation) so the body runs on an idle record.
8329        if !is_input {
8330            let exit = if pact_held {
8331                let mut instance = rec.write();
8332                instance.leave_pact()
8333            } else {
8334                rec.read().pact_exit_without_release()
8335            };
8336            return (
8337                SimOutcome::RedirectOutputToSiol {
8338                    siol: siol_link,
8339                    sims,
8340                    raw_mode,
8341                },
8342                exit,
8343            );
8344        }
8345
8346        // SIMM=YES(1) / SIMM=RAW(2): read the SIOL link into VAL/RVAL. C
8347        // `readValue` for a SIMM-mode INPUT record goes through `dbGetLink`,
8348        // which dispatches by link type — a local DB target, a CA target (a
8349        // bare non-local name or an explicit `CA`/`ca://` link), or a
8350        // constant. The pre-fix port special-cased a local `ParsedLink::Db`
8351        // SIOL only, so a non-local or external SIOL never read yet still
8352        // returned `Simulated` — the record froze with no value and no alarm.
8353        // Dispatch uniformly through the same link read owner as every other
8354        // link; the alarm/timestamp/notify tail below now runs for every SIOL
8355        // link type.
8356        //
8357        // Output records returned `RedirectOutputToSiol` above (the output
8358        // write follows the body), so only an INPUT record reaches here — its
8359        // `readValue` precedes the body, so the SIOL read + convert are done
8360        // in place and the caller short-circuits.
8361        let sim_posts = {
8362            // C `readValue` raises the SIMM severity at the TOP of the
8363            // `case menuYesNoYES:` arm — BEFORE the SIOL read
8364            // (`longinRecord.c:414` `recGblSetSevr(prec, SIMM_ALARM, prec->sims)`,
8365            // then `:416` `dbGetLink(&prec->siol, ...)`); likewise ai, mbbi,
8366            // histogram, waveform. That ORDER is load-bearing, not cosmetic:
8367            // `recGblSetSevr` is strict-greater, so when the SIOL read fails and
8368            // raises LINK_ALARM/INVALID (below), an already-pending
8369            // SIMM_ALARM/INVALID (`SIMS = INVALID`) WINS the tie and the record
8370            // publishes STAT=SIMM_ALARM — while with the default
8371            // `SIMS = NO_ALARM` nothing is pending, so LINK_ALARM/INVALID lands
8372            // and the broken SIOL is reported.
8373            //
8374            // Not every record raises it first, so the ORDER is the record's to
8375            // declare, not this site's: `mca` reads SIOL and only then raises
8376            // (`mcaRecord.c:1118` then `:1129`), so with `SIMS = INVALID` the
8377            // LINK_ALARM wins there and C publishes STAT=LINK_ALARM. That is
8378            // [`Record::raises_simm_after_read`]; the default is C's base-record
8379            // order and lands here, before the read.
8380            let raise_simm = |common: &mut crate::server::record::CommonFields| {
8381                let sev = crate::server::record::AlarmSeverity::from_u16(sims as u16);
8382                crate::server::recgbl::rec_gbl_set_sevr(
8383                    common,
8384                    crate::server::recgbl::alarm_status::SIMM_ALARM,
8385                    sev,
8386                );
8387            };
8388            let simm_after_read = {
8389                let mut instance = rec.write();
8390                let after = instance.record.raises_simm_after_read();
8391                if !after {
8392                    raise_simm(&mut instance.common);
8393                }
8394                after
8395            };
8396
8397            // Read from SIOL -> SVAL -> VAL/RVAL. Uniform across Db (with
8398            // locality fallback) / Ca / Pva / constant via `fetch_link`
8399            // (C `dbGetLink`), which keeps C's three outcomes apart: a value,
8400            // a CONSTANT link's "status 0 with the buffer untouched", and a
8401            // failure. Converted to the record's declared request: stringin
8402            // reads SIOL with `DBR_STRING` (`stringinRecord.c:208`), lsi via
8403            // `dbGetLinkLS` (`lsiRecord.c:244`).
8404            let fetch = self.db_get_link(rec, "SIOL", &siol_link);
8405            let (fetch, _raw) = self.convert_link_fetch(rec, "SIOL", &siol_link, fetch);
8406            // Resolved before the write guard below, which reaches
8407            // `sim_process_tail`'s posts — see the same resolve at the head of
8408            // `process_record_with_links_body`.
8409            let link_backing = self.resolve_link_backed_metadata_for_posts(rec);
8410            let link_backing = link_backing.as_link_backing();
8411            // The read itself raised C's `setLinkAlarm` (dbLink.c:321 ->
8412            // `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "field SIOL")`) on a
8413            // FAILED fetch. For a base record that is AFTER the SIMM_ALARM
8414            // above (`longinRecord.c:414` then `:416`), so with
8415            // `SIMS = INVALID` the equal-severity LINK_ALARM loses the tie and
8416            // STAT stays SIMM.
8417            //
8418            // The tail's `recGblGetTimeStampSimm` owes its TSEL read here, after
8419            // the SIOL read that stands in for the device read and before the
8420            // guard the store runs under.
8421            let tsel = self.read_tsel(rec);
8422            let mut instance = rec.write();
8423
8424            // The other order (`mcaRecord.c:1118` then `:1129`): the read has
8425            // happened, so its LINK_ALARM is already pending and the
8426            // equal-severity SIMM_ALARM now loses the tie instead.
8427            if simm_after_read {
8428                raise_simm(&mut instance.common);
8429            }
8430
8431            // C's SIOL read buffer is `&prec->sval` on every scalar SIML/SIOL
8432            // record (`longinRecord.c:416` `dbGetLink(&prec->siol, DBR_LONG,
8433            // &prec->sval)`, then `prec->val = prec->sval`). The records with
8434            // no SVAL field read straight into the value —
8435            // `waveform`/`aai` into `bptr` (waveformRecord.c:351), `lsi` into
8436            // `val` (lsiRecord.c:244) — so for them the fetched value IS the
8437            // landed value and a constant SIOL lands nothing.
8438            //
8439            // Routing the read through SVAL is what makes `caput REC.SIMM 1;
8440            // caput REC.SVAL 42` work (R12-61): the unset SIOL delivers no
8441            // data (status 0), and C's `val = sval` then publishes the SVAL
8442            // the operator wrote.
8443            let has_sval = instance.record.get_field("SVAL").is_some();
8444            let landed: Option<EpicsValue> = match &fetch {
8445                crate::server::recgbl::simm::LinkFetch::Value(v) => {
8446                    if has_sval {
8447                        // `put_field_internal` is the DBR-coercion owner
8448                        // (C `dbGetLink(DBF_<sval>)`).
8449                        let _ = instance.record.put_field_internal("SVAL", v.clone());
8450                        instance.record.get_field("SVAL")
8451                    } else {
8452                        Some(v.clone())
8453                    }
8454                }
8455                crate::server::recgbl::simm::LinkFetch::NoData => {
8456                    if has_sval {
8457                        instance.record.get_field("SVAL")
8458                    } else {
8459                        None
8460                    }
8461                }
8462                crate::server::recgbl::simm::LinkFetch::Failed => None,
8463            };
8464
8465            if let Some(siol_val) = landed {
8466                let target_supports_raw = raw_mode && instance.record.get_field("RVAL").is_some();
8467                if target_supports_raw {
8468                    // PR #ac92e3e follow-up: SIMM=RAW on records
8469                    // with RVAL (ai/ao/etc.) writes the raw value
8470                    // into RVAL and runs the record's own
8471                    // process() so the LINR / ESLO / EOFF / ASLO
8472                    // / AOFF conversion chain computes VAL. The
8473                    // pre-fix path additionally called set_val
8474                    // here, which overwrote VAL with the raw
8475                    // count and silently bypassed conversion —
8476                    // the visible failure mode was "SIMM=RAW
8477                    // simulation returns counts instead of EGU".
8478                    //
8479                    // Coerce to RVAL's native DBR type before
8480                    // put_field — ai.RVAL is Long, but SIOL on a
8481                    // soft channel typically yields Double. Without
8482                    // the coerce step the put_field rejects with
8483                    // TypeMismatch and leaves RVAL at 0, so
8484                    // process() computes VAL = 0*ESLO + EOFF
8485                    // (the offset only), not the intended
8486                    // RAW*ESLO + EOFF.
8487                    let rval_type = crate::server::record::record_instance::declared_field_type_of(
8488                        instance.record.as_ref(),
8489                        "RVAL",
8490                    )
8491                    .unwrap_or(crate::types::DbFieldType::Long);
8492                    // C parity (aiRecord.c:495): `rval = (long)floor(sval)`.
8493                    // Rust `convert_to(Long)` truncates toward zero,
8494                    // diverging for negative bipolar-ADC raw values
8495                    // (sval=-1.5 → C: -2, Rust as-cast: -1).
8496                    // Floor explicitly when narrowing a float to
8497                    // an integer RVAL.
8498                    let coerced = match (&siol_val, rval_type) {
8499                        (EpicsValue::Double(d), crate::types::DbFieldType::Long) => {
8500                            EpicsValue::Long(d.floor() as i32)
8501                        }
8502                        (EpicsValue::Double(d), crate::types::DbFieldType::Int64) => {
8503                            EpicsValue::Int64(d.floor() as i64)
8504                        }
8505                        (EpicsValue::Float(d), crate::types::DbFieldType::Long) => {
8506                            EpicsValue::Long((*d as f64).floor() as i32)
8507                        }
8508                        (EpicsValue::Float(d), crate::types::DbFieldType::Int64) => {
8509                            EpicsValue::Int64((*d as f64).floor() as i64)
8510                        }
8511                        _ if siol_val.db_field_type() != rval_type => {
8512                            siol_val.convert_to(rval_type)
8513                        }
8514                        _ => siol_val,
8515                    };
8516                    let _ = instance.record.put_field("RVAL", coerced);
8517                    {
8518                        let inst = &mut *instance;
8519                        let ctx = inst.common.process_context();
8520                        inst.record.set_process_context(&ctx);
8521                    }
8522                    let _ = instance.record.process();
8523                } else {
8524                    // Records without RVAL fall back to SIMM=YES semantics: the
8525                    // SIOL value lands where C's `readValue` lands it — VAL for
8526                    // the base records (`longinRecord.c:417` `val = sval`), SGNL
8527                    // plus the bin increment for `histogram`
8528                    // (`histogramRecord.c:385` + `:219`). `land_simulated_value`
8529                    // is the single owner of that assignment; no conversion to
8530                    // run either way.
8531                    let _ = instance.record.land_simulated_value(siol_val);
8532                }
8533            }
8534
8535            // Simulation alarm + per-field monitor tail — see
8536            // `sim_process_tail`. C raises `recGblSetSevr(prec, SIMM_ALARM,
8537            // prec->sims)` at the TOP of the SIMM branch, BEFORE the SIOL read
8538            // (longinRecord.c:413-414), and `process()` runs its
8539            // timestamp/alarm/monitor/forward-link tail whatever the read
8540            // returned — so the tail is unconditional, not gated on a value
8541            // having landed (R12-61). UDF is the one part C does gate on the
8542            // read's status (`if (status == 0) prec->udf = FALSE`), and a
8543            // constant SIOL is status 0.
8544            sim_process_tail(&mut instance, tsel, fetch.is_ok(), link_backing)
8545        };
8546
8547        // C `readValue`/`writeValue` clears `pact` on the synchronous branch
8548        // (`prec->pact = FALSE`, aiRecord.c:496 / aoRecord.c:578). On the
8549        // SDLY continuation this releases the PACT held across the delay so the
8550        // forward-link tail and any subsequent foreign process see the record
8551        // idle (C posts `monitor()` + `recGblFwdLink` with pact already
8552        // FALSE). An entry that never held PACT (a fresh `sdly < 0` cycle, or a
8553        // `pact=FALSE` re-trigger) has nothing to release, so the clear is gated
8554        // on `pact_held` to avoid a needless write-lock there.
8555        let exit = if pact_held {
8556            let mut instance = rec.write();
8557            instance.leave_pact()
8558        } else {
8559            rec.read().pact_exit_without_release()
8560        };
8561
8562        (SimOutcome::Simulated(sim_posts), exit)
8563    }
8564}
8565
8566/// Shared tail of a simulated (`SIMM` != NO) process cycle — the part of
8567/// C `process()` that still runs when `readValue`/`writeValue` divert to
8568/// the SIOL (`aiRecord.c` and every SIML/SIMM-bearing record):
8569/// `checkAlarms`, `recGblResetAlarms` and `monitor()`, so the simulated value
8570/// still trips its own limit/state alarms and the alarms the SIMM branch
8571/// already raised maximize against them.
8572///
8573/// The tail raises NO alarm of its own. Every alarm a simulated cycle can
8574/// raise — SIMM_ALARM at SIMS on the YES/RAW arms, LINK_ALARM on a failed SIOL
8575/// `dbGetLink`, SOFT_ALARM/INVALID on the `default:` arm — is raised by
8576/// `check_simulation_mode` at the point C raises it, because
8577/// `recGblSetSevr` is a strict-greater MAXIMIZE and the ORDER of those calls
8578/// decides equal-severity ties (W10-E4). Folding the SIMM raise in here instead
8579/// silently reordered it after the SIOL read.
8580///
8581/// The posting masks are per-field, identical to the async-completion
8582/// path (`complete_async_record`) and `process_local`:
8583///
8584/// * the deadband-tracked field (default `VAL`) posts the classes that
8585///   actually fired — MDEL → `DBE_VALUE`, ADEL → `DBE_LOG`, alarm
8586///   movement → `DBE_ALARM` (C `recGblResetAlarms` `val_mask`); the
8587///   lsi/lso explicit change gate, MPST/APST always-post override, and
8588///   binary always-post route through the same hooks as those paths;
8589/// * `SEVR` posts `DBE_VALUE` only on a sevr change; `STAT`/`AMSG`
8590///   share a mask carrying `DBE_ALARM` (sevr/amsg moved) and/or
8591///   `DBE_VALUE` (stat moved); `ACKS` posts `DBE_VALUE` when the reset
8592///   raised it (recGbl.c:202-222);
8593/// * subscribed auxiliary fields post on value change with
8594///   `DBE_VALUE|DBE_LOG` plus the cycle's alarm bits (C change-detected
8595///   posts in each record's `monitor()`, e.g. ai `oraw != rval`), and
8596///   `UDF` rides along with the union of the cycle's posted classes.
8597///
8598/// The pre-fix tails (duplicated across the input and output SIMM
8599/// branches) pushed `VAL`/`SEVR`/`STAT` unconditionally with one shared
8600/// `DBE_VALUE|DBE_ALARM` mask and discarded the `rec_gbl_reset_alarms`
8601/// result — every simulated cycle re-sent unchanged alarm fields,
8602/// stamped `DBE_ALARM` on cycles whose alarm state never moved, and
8603/// bypassed the MDEL/ADEL deadband entirely.
8604fn sim_process_tail(
8605    instance: &mut RecordInstance,
8606    tsel: super::TselStamp,
8607    clear_udf: bool,
8608    backing: crate::server::database::LinkBacking<'_>,
8609) -> CyclePosts {
8610    let inst = &mut *instance;
8611    tsel.stamp(&inst.name, &mut inst.common, true);
8612    // C clears UDF only on a `status == 0` SIOL read (`longinRecord.c:418`) —
8613    // for most records a failed read leaves the record undefined. The array
8614    // records are the exception: their `process()` clears UDF itself, after
8615    // `readValue` returns and whatever its status (waveformRecord.c:144,
8616    // aaiRecord.c:174, aaoRecord.c:165). They declare that with
8617    // `clears_udf_unconditionally`, which is the record's own C, not a
8618    // framework choice.
8619    if clear_udf || instance.record.clears_udf_unconditionally() {
8620        instance.common.udf = 0;
8621    }
8622
8623    {
8624        let inst = &mut *instance;
8625        inst.record.check_alarms(&mut inst.common);
8626    }
8627    instance.evaluate_alarms();
8628    let outcome = instance.monitor_cycle();
8629    publish_cycle(instance, &outcome.snapshot, backing, outcome.alarm_posts)
8630}
8631
8632/// The single finalizer for a process cycle, for every path that can end one.
8633///
8634/// **Invariant:** a cycle that ENDS runs [`PvDatabase::end_process_cycle`]
8635/// exactly once — C reaches `recGblFwdLink` (`recGbl.c:295-302`) on every path
8636/// that ends a cycle, and only there does `putf` clear, the wait-set `leave`,
8637/// and the next queued `processNotify` restart. A non-zero record status does
8638/// not exempt a cycle: `subRecord.c:145-167` runs the whole tail on any status
8639/// but the documented async `1`.
8640///
8641/// A guard and not a call because the tail sits BELOW fallible exits —
8642/// `run_registered_subroutine()?` and `record.process()?` — that no explicit
8643/// site covers. `#[must_use]` on [`PactExit`] cannot stand in for it: that
8644/// lint fires on an unused *expression*, and each of those paths drops a
8645/// `let`-bound token, which warns about nothing.
8646///
8647/// Declared BEFORE any `rec.write()` in the cycle body, so Rust's
8648/// reverse-declaration drop order puts the record's DATA lock down first and
8649/// this second; `end_process_cycle` takes that lock itself, and
8650/// `parking_lot::RwLock` is not reentrant.
8651///
8652/// Two ways to leave without the `Drop` firing, both explicit at the site:
8653/// [`Self::take`] for a site that ends the cycle its own way, and
8654/// [`Self::hand_off_to_async_completion`] for the async-output early return,
8655/// which does not end the cycle at all — `complete_async_record_inner` does,
8656/// later, from its own token.
8657struct CycleEndGuard<'a> {
8658    db: &'a PvDatabase,
8659    name: &'a str,
8660    rec: &'a Arc<RecordCell>,
8661    exit: Option<crate::server::record::PactExit>,
8662}
8663
8664impl<'a> CycleEndGuard<'a> {
8665    fn new(db: &'a PvDatabase, name: &'a str, rec: &'a Arc<RecordCell>) -> Self {
8666        Self {
8667            db,
8668            name,
8669            rec,
8670            exit: None,
8671        }
8672    }
8673
8674    /// Fold a release into the cycle's token at the moment it is minted, so the
8675    /// exits between here and the tail carry it without a site of their own.
8676    fn merge_in(&mut self, other: crate::server::record::PactExit) {
8677        self.exit = Some(match self.exit.take() {
8678            Some(held) => held.merge(other),
8679            None => other,
8680        });
8681    }
8682
8683    /// Disarm and hand the token to a site that ends the cycle itself.
8684    fn take(&mut self) -> crate::server::record::PactExit {
8685        self.exit
8686            .take()
8687            .unwrap_or_else(|| crate::server::record::PactExit::new(false))
8688    }
8689
8690    /// Disarm because this cycle is NOT ending: the async-output `write_begin`
8691    /// re-entered PACT and spawned the completion, so
8692    /// `complete_async_record_inner` owns the tail and mints its own token from
8693    /// the record when the device write lands.
8694    fn hand_off_to_async_completion(&mut self) {
8695        self.exit = None;
8696    }
8697}
8698
8699impl Drop for CycleEndGuard<'_> {
8700    fn drop(&mut self) {
8701        if let Some(exit) = self.exit.take() {
8702            self.db.end_process_cycle(self.name, self.rec, exit);
8703        }
8704    }
8705}
8706
8707#[cfg(test)]
8708mod input_link_texts_tests {
8709    use super::InputLinkTexts;
8710    use crate::server::record::RecordInstance;
8711    use crate::server::record::record_instance::ParsedInputLink;
8712    use crate::server::records::calc::CalcRecord;
8713    use crate::types::EpicsValue;
8714
8715    fn calc() -> RecordInstance {
8716        RecordInstance::new_boxed("C:ONE".to_string(), Box::new(CalcRecord::default()))
8717    }
8718
8719    /// The boundaries are READ / NOT READ and SET / UNSET, one case each way.
8720    /// The pair matters because a sparse list answers both with "absent", and
8721    /// only the read flag separates them: a reader that confuses them either
8722    /// re-reads every link on a put path or reports a wired link as unset.
8723    #[test]
8724    fn an_unread_list_sends_every_reader_to_the_record() {
8725        let mut instance = calc();
8726        instance
8727            .record
8728            .put_field("INPB", EpicsValue::String("SRC:ONE.VAL".into()))
8729            .expect("INPB takes a link string");
8730        let texts = InputLinkTexts::none();
8731        assert_eq!(
8732            texts
8733                .link_at(Some(1), &instance, "INPB")
8734                .map(|l| pvname(&l)),
8735            Some("SRC:ONE".to_string()),
8736            "slot 1 is INPB, and an unread list must not answer it itself"
8737        );
8738        assert!(texts.link_at(Some(0), &instance, "INPA").is_none());
8739    }
8740
8741    #[test]
8742    fn a_read_list_holds_the_set_links_and_only_those() {
8743        let mut instance = calc();
8744        instance
8745            .record
8746            .put_field("INPB", EpicsValue::String("SRC:ONE.VAL".into()))
8747            .expect("INPB takes a link string");
8748        let links = instance.record.multi_input_links();
8749        assert_eq!(links[1].0, "INPB", "slot 1 is INPB");
8750        let texts = InputLinkTexts::read_own(&instance);
8751
8752        assert!(texts.is_set(1));
8753        assert_eq!(
8754            texts
8755                .link_at(Some(1), &instance, "INPB")
8756                .map(|l| pvname(&l)),
8757            Some("SRC:ONE".to_string())
8758        );
8759        assert!(!texts.is_set(0), "INPA is unwired");
8760        assert!(!texts.is_set(links.len() - 1), "the last is too");
8761        assert!(!texts.none_set());
8762        assert!(InputLinkTexts::read_own(&calc()).none_set());
8763    }
8764
8765    #[test]
8766    fn the_parse_follows_the_text_the_record_holds() {
8767        let mut instance = calc();
8768        let put = |instance: &mut RecordInstance, text: &str| {
8769            instance
8770                .record
8771                .put_field("INPB", EpicsValue::String(text.into()))
8772                .expect("INPB takes a link string");
8773        };
8774        let parse = |instance: &mut RecordInstance| {
8775            let generation = instance.record.input_links_generation();
8776            ParsedInputLink::validated(
8777                &mut instance.parsed_inputs,
8778                &*instance.record,
8779                1,
8780                instance.record.multi_input_links(),
8781                generation,
8782            )
8783            .map(|entry| entry.parsed().clone())
8784        };
8785        put(&mut instance, "SRC:ONE.VAL");
8786        let first = parse(&mut instance).expect("INPB is set");
8787        let again = parse(&mut instance).expect("INPB is set");
8788        assert!(
8789            std::sync::Arc::ptr_eq(&again, &first),
8790            "the same text reuses the same parse"
8791        );
8792        let texts = InputLinkTexts::read_own(&instance);
8793        let shared = texts
8794            .link_at(Some(1), &instance, "INPB")
8795            .expect("INPB is set");
8796        assert!(
8797            std::sync::Arc::ptr_eq(&shared, &first),
8798            "a shared reader hands out the cached parse"
8799        );
8800        put(&mut instance, "SRC:TWO.VAL");
8801        assert_eq!(
8802            texts
8803                .link_at(Some(1), &instance, "INPB")
8804                .map(|l| pvname(&l)),
8805            Some("SRC:TWO".to_string()),
8806            "a stale cache entry is not handed out"
8807        );
8808        assert_eq!(
8809            parse(&mut instance).map(|l| pvname(&l)),
8810            Some("SRC:TWO".to_string())
8811        );
8812        put(&mut instance, "");
8813        assert!(
8814            parse(&mut instance).is_none(),
8815            "an emptied link is unset again"
8816        );
8817        assert!(
8818            InputLinkTexts::read_own(&instance)
8819                .link_at(Some(1), &instance, "INPB")
8820                .is_none()
8821        );
8822    }
8823
8824    fn pvname(link: &crate::server::record::ParsedLink) -> String {
8825        match link {
8826            crate::server::record::ParsedLink::Db(db) => db.pvname(),
8827            other => panic!("expected a DB link, got {other:?}"),
8828        }
8829    }
8830}