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