epics_base_rs/server/database/processing.rs
1use std::collections::HashSet;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::error::{CaError, CaResult};
6use crate::runtime::sync::RwLock;
7use crate::server::record::{NotifyWaitSet, RecordInstance};
8use crate::types::EpicsValue;
9
10use super::{PvDatabase, apply_timestamp};
11
12/// A cancellable, generation-gated handle that re-enters an async record's
13/// `process()` exactly once.
14///
15/// C parity: epics-base `callbackRequest` / `callbackRequestDelayed`
16/// (`callback.c`) post a one-shot callback that later runs the record's
17/// `(*prset->process)(precord)` directly, bypassing `dbProcess`'s PACT
18/// entry guard. Here, firing the token re-enters via
19/// [`PvDatabase::process_record_continuation`] (the owner-driven
20/// continuation that also bypasses the PACT guard).
21///
22/// # Cancellation is structural, not a runtime check
23///
24/// The record owns a monotonic generation counter (`reprocess_generation`).
25/// Minting a token snapshots that counter as the token's `epoch` *after*
26/// bumping it, so:
27///
28/// - minting a newer token for the same record (C `callbackRequestDelayed`
29/// replacing an outstanding delayed callback), or
30/// - [`PvDatabase::cancel_async_reentry`] (C `callbackCancelDelayed`),
31///
32/// each advance the counter past every outstanding token's `epoch`. A
33/// stale token therefore re-enters *nothing*: [`AsyncToken::fire`] is the
34/// sole re-entry path, the epoch comparison is owned in one place, and the
35/// token is consumed (`self` by value) so it cannot fire twice. A consumer
36/// never writes an `if generation == ...` guard — it holds the token and
37/// calls `fire`; the no-op-when-stale is guaranteed by construction.
38pub struct AsyncToken {
39 /// Canonical record name to re-enter.
40 name: String,
41 /// Shared generation counter owned by the record
42 /// (`RecordInstance::reprocess_generation`).
43 generation: Arc<AtomicU64>,
44 /// Generation value captured at mint time. The token is current iff
45 /// `generation == epoch`.
46 epoch: u64,
47}
48
49impl AsyncToken {
50 /// The record this token re-enters.
51 pub fn record_name(&self) -> &str {
52 &self.name
53 }
54
55 /// True iff this token is still the current generation — no newer
56 /// token was minted and no [`PvDatabase::cancel_async_reentry`] has
57 /// run for the record since this token was minted. Read-only.
58 pub fn is_current(&self) -> bool {
59 self.generation.load(Ordering::Acquire) == self.epoch
60 }
61
62 /// Cancel this token (C `callbackCancelDelayed` for the holder's own
63 /// pending re-entry): advance the generation so this and any other
64 /// outstanding token for the record become stale, then consume the
65 /// token. Use when the holder itself decides not to re-enter; use
66 /// [`PvDatabase::cancel_async_reentry`] to cancel a token already
67 /// handed to a timer / notify task.
68 pub fn cancel(self) {
69 self.generation.fetch_add(1, Ordering::AcqRel);
70 }
71
72 /// Fire the continuation: if still current, re-enter the record's
73 /// `process()` via [`PvDatabase::process_record_continuation`]. A
74 /// stale (superseded / cancelled) token is a no-op. Consumes the
75 /// token so it cannot fire twice.
76 pub async fn fire(self, db: &PvDatabase) -> CaResult<()> {
77 if self.generation.load(Ordering::Acquire) != self.epoch {
78 return Ok(());
79 }
80 let mut visited = HashSet::new();
81 db.process_record_continuation(&self.name, &mut visited, 0)
82 .await
83 }
84}
85
86/// A cycle-free handle for driving async-side database updates from
87/// OUTSIDE a record's `process()` cycle.
88///
89/// Wraps a [`std::sync::Weak`] reference to the database: a record stashes
90/// it (via [`crate::server::record::Record::set_async_context`]) without
91/// creating an ownership cycle — the database owns the record, so a strong
92/// `Arc<PvDatabaseInner>` stored on the record would leak the whole
93/// database. Every call upgrades the `Weak` to a temporary [`PvDatabase`];
94/// once the last strong owner drops, the upgrade fails and the call is a
95/// no-op (nothing is stranded).
96///
97/// This is the out-of-band counterpart to the in-band re-entry
98/// [`crate::server::record::ProcessAction`]s: a driver / callback thread
99/// (asyn TRACE post, AQR cancel, motor intermediate readback) holds the
100/// handle and pushes field updates or wires a completion-driven re-entry
101/// without going through `process()`. It exposes exactly the c401e2f0
102/// PACT primitive surface, each call guarded by the live-database check.
103#[derive(Clone)]
104pub struct AsyncDbHandle {
105 inner: std::sync::Weak<super::PvDatabaseInner>,
106}
107
108impl AsyncDbHandle {
109 /// Upgrade to a temporary owning [`PvDatabase`], or `None` if the
110 /// database has been dropped.
111 fn db(&self) -> Option<PvDatabase> {
112 self.inner.upgrade().map(|inner| PvDatabase { inner })
113 }
114
115 /// True while the backing database is still alive.
116 pub fn is_alive(&self) -> bool {
117 self.inner.strong_count() > 0
118 }
119
120 /// Out-of-band field post — see [`PvDatabase::post_fields`]. Returns an
121 /// empty `Vec` (no-op) if the database has been dropped.
122 pub async fn post_fields(
123 &self,
124 name: &str,
125 fields: Vec<(String, EpicsValue)>,
126 ) -> CaResult<Vec<String>> {
127 match self.db() {
128 Some(db) => db.post_fields(name, fields).await,
129 None => Ok(Vec::new()),
130 }
131 }
132
133 /// Resolve a link's target field type for the sseq link-status
134 /// diagnostics — see [`PvDatabase::link_target_field_type`]. `None` if
135 /// the link is constant / external / unresolvable, or the database is
136 /// gone. (Distinct from the free `server::record::link_field_type`,
137 /// which returns the link *class* `LinkType`, not the target's type.)
138 pub async fn link_target_field_type(&self, link: &str) -> Option<crate::types::DbFieldType> {
139 match self.db() {
140 Some(db) => db.link_target_field_type(link).await,
141 None => None,
142 }
143 }
144
145 /// Mint an async re-entry token — see [`PvDatabase::mint_async_token`].
146 /// `None` if the record is absent or the database has been dropped.
147 pub async fn mint_async_token(&self, name: &str) -> Option<AsyncToken> {
148 match self.db() {
149 Some(db) => db.mint_async_token(name).await,
150 None => None,
151 }
152 }
153
154 /// Cancel an outstanding async re-entry — see
155 /// [`PvDatabase::cancel_async_reentry`]. No-op if the database is gone.
156 pub async fn cancel_async_reentry(&self, name: &str) {
157 if let Some(db) = self.db() {
158 db.cancel_async_reentry(name).await;
159 }
160 }
161
162 /// Arm a put-notify wait-set — see [`PvDatabase::new_put_notify`].
163 /// Database-independent (re-exported associated fn).
164 pub fn new_put_notify() -> (
165 Arc<NotifyWaitSet>,
166 crate::runtime::sync::oneshot::Receiver<()>,
167 ) {
168 PvDatabase::new_put_notify()
169 }
170
171 /// Wire a completion oneshot to an async re-entry — see
172 /// [`PvDatabase::reprocess_on_notify`]. `None` if the database is gone
173 /// (the `completion` receiver is dropped, stranding nothing).
174 pub fn reprocess_on_notify(
175 &self,
176 token: AsyncToken,
177 completion: crate::runtime::sync::oneshot::Receiver<()>,
178 ) -> Option<tokio::task::JoinHandle<()>> {
179 self.db()
180 .map(|db| db.reprocess_on_notify(token, completion))
181 }
182
183 /// Issue a non-blocking put-with-completion to an OUT link — see
184 /// [`PvDatabase::put_link_notify`]. `None` if the database is gone or
185 /// the source record is missing.
186 pub async fn put_link_notify(
187 &self,
188 record_name: &str,
189 link_str: &str,
190 value: EpicsValue,
191 ) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
192 match self.db() {
193 Some(db) => db.put_link_notify(record_name, link_str, value).await,
194 None => None,
195 }
196 }
197}
198
199/// C `dbNotifyAdd`: a will-process PP target (FLNK / OUT) joins the active
200/// put-notify wait-set exactly once, so the completion waits for it. Called
201/// only on the `!pact` (will-process) branch — a busy target sets RPRO and
202/// does not join (matching the pre-fix drop behaviour), and the
203/// `notify.is_none()` guard prevents a double-join when a record is reached
204/// again within the same chain.
205pub(super) fn join_put_notify(
206 target: &mut RecordInstance,
207 src_notify: Option<&Arc<NotifyWaitSet>>,
208) {
209 if target.notify.is_none() {
210 if let Some(ws) = src_notify {
211 target.notify = Some(ws.clone());
212 ws.enter();
213 }
214 }
215}
216
217/// C `dbNotifyCompletion`: this record finished its contribution to the
218/// put-notify (sync completion, async completion, or SDIS-disable bail).
219/// Take its wait-set membership and leave — the completion oneshot fires on
220/// the `leave` that empties the set. Idempotent: a record not in any
221/// put-notify is a no-op.
222fn complete_put_notify(inst: &mut RecordInstance) {
223 if let Some(ws) = inst.notify.take() {
224 ws.leave();
225 }
226}
227
228/// If a CA TSEL link's pvname targets a record's `.TIME` field, return
229/// the record name with the `.TIME` suffix stripped; otherwise `None`.
230///
231/// Mirrors C `TSEL_modified` (dbLink.c:80-86): a `PV_LINK` tsel whose
232/// pvname contains `.TIME` is flagged `DBLINK_FLAG_TSELisTIME` and the
233/// name is truncated at `.TIME` to address the record. Matched on the
234/// `.TIME` suffix (the realistic spelling) case-insensitively, to stay
235/// consistent with the DB branch's `field.eq_ignore_ascii_case("TIME")`.
236fn ca_tsel_time_record(pv: &str) -> Option<&str> {
237 let idx = pv.len().checked_sub(".TIME".len())?;
238 pv[idx..]
239 .eq_ignore_ascii_case(".TIME")
240 .then_some(&pv[..idx])
241}
242
243/// Convert an lset `(seconds_past_epoch, nanos, userTag)` timestamp
244/// triple into the record-side `(SystemTime, userTag)` pair, clamping
245/// seconds/nanos to the valid `Duration` range. Shared by the TSEL
246/// `.TIME` Ca arm and the non-local Db arm — both read a `ca://` `.TIME`
247/// source through `external_link_time` and adopt the result identically.
248fn ext_time_pair((secs, ns, utag): (i64, i32, u64)) -> (std::time::SystemTime, u64) {
249 let secs = secs.max(0) as u64;
250 let ns = (ns.max(0) as u32).min(999_999_999);
251 (
252 std::time::UNIX_EPOCH + std::time::Duration::new(secs, ns),
253 utag,
254 )
255}
256
257/// The source record's put-propagation context for the forward-link tail.
258/// C `processTarget` (dbDbLink.c:460-474) carries `psrc->putf` and
259/// `psrc->ppn` to each target as a unit — the PUTF bit and the put-notify
260/// wait-set always travel together. Bundled so the tail threads one
261/// snapshot instead of a `(putf, notify)` pair.
262#[derive(Clone, Copy)]
263struct PutNotifyCtx<'a> {
264 putf: bool,
265 notify: Option<&'a Arc<NotifyWaitSet>>,
266}
267
268/// Result of the simulation-mode check.
269///
270/// C `aiRecord.c:151-168` handles simulation entirely inside
271/// `readValue()`; `process()` then ALWAYS runs `convert`/`checkAlarms`/
272/// `monitor`/`recGblFwdLink(prec)`. A simulated record therefore must
273/// NOT skip the forward-link / CP / RPRO tail — only the device read
274/// and record-support body are replaced by the SIOL round-trip.
275enum SimOutcome {
276 /// SIMM disabled / no simulation link configured: run the record
277 /// body normally.
278 NotSimulated,
279 /// Simulation handled the record value (SIOL read/write done).
280 /// The caller must still run the forward-link / CP / RPRO tail
281 /// exactly as `recGblFwdLink` does for a real process cycle.
282 Simulated,
283}
284
285impl PvDatabase {
286 /// Process a record by name (process_local + notify).
287 /// Alias-aware (epics-base PR #336).
288 pub async fn process_record(&self, name: &str) -> CaResult<()> {
289 self.process_record_inner(name, true).await
290 }
291
292 /// `process_record` variant for a caller that already
293 /// owns the record's advisory write gate — the QSRV atomic group
294 /// PUT applying a `+proc` member. The gate `Mutex` is not
295 /// reentrant; the atomic group path MUST use this entry. See
296 /// [`crate::server::database::PvDatabase::lock_records`].
297 pub async fn process_record_already_locked(&self, name: &str) -> CaResult<()> {
298 self.process_record_inner(name, false).await
299 }
300
301 async fn process_record_inner(&self, name: &str, acquire_gate: bool) -> CaResult<()> {
302 let rec = self.get_record(name).await;
303
304 if let Some(rec) = rec {
305 // advisory write gate (`dbScanLock` analogue). A
306 // QSRV atomic group with a `+proc` member holds this
307 // record's gate via `lock_records`; a direct
308 // `process_record` on the same backing record must block
309 // until the atomic group transaction completes. Skipped
310 // when the caller already owns the gate.
311 let _record_gate = if acquire_gate {
312 let canonical = self
313 .resolve_alias(name)
314 .await
315 .unwrap_or_else(|| name.to_string());
316 Some(self.lock_record(&canonical).await)
317 } else {
318 None
319 };
320 let (snapshot, alarm_posts) = {
321 let mut instance = rec.write().await;
322 instance.process_local()?
323 };
324 // Notify outside lock
325 let instance = rec.read().await;
326 instance.notify_from_snapshot(&snapshot);
327 // Post the alarm fields (SEVR/STAT/ACKS) with their
328 // individual C masks — see `process_local` / recGblResetAlarms.
329 for &(field, mask) in &alarm_posts {
330 instance.notify_field(field, mask);
331 }
332 Ok(())
333 } else {
334 Err(CaError::ChannelNotFound(name.to_string()))
335 }
336 }
337
338 /// Process a record with full link handling (INP -> process -> alarms -> OUT -> FLNK).
339 /// Uses visited set for cycle detection and depth limit.
340 ///
341 /// Foreign-caller entry: FLNK dispatch, scan loop, scan_event, CA put,
342 /// process(PROC=1) etc. Hits the PACT entry guard (mirrors C `dbProcess`
343 /// at `dbAccess.c:537-559`) when the record is mid-async.
344 ///
345 /// this is a *foreign* full-processing entry, so it acquires
346 /// the record's advisory write gate (`dbScanLock` analogue) for the
347 /// entry record before processing. A QSRV atomic group or pvalink
348 /// atomic scan-on-update epoch that holds `lock_records` over the
349 /// same record blocks a foreign scan/event/FLNK-dispatch caller
350 /// here, and vice versa — restoring the `DBManyLock` exclusion. The
351 /// recursive FLNK / OUT / CP fan-out within one chain does NOT
352 /// re-acquire the gate (`process_record_with_links_recursive`),
353 /// mirroring C `processTarget` (`dbDbLink.c:436`) which asserts the
354 /// target's lock set is already owned by the calling thread; the
355 /// `visited` cycle guard prevents re-processing the entry record.
356 pub fn process_record_with_links<'a>(
357 &'a self,
358 name: &'a str,
359 visited: &'a mut HashSet<String>,
360 depth: usize,
361 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
362 Box::pin(async move {
363 self.process_record_with_links_inner(name, visited, depth, false, true)
364 .await
365 })
366 }
367
368 /// full-processing entry for a caller that already owns the
369 /// record's advisory write gate via [`PvDatabase::lock_records`] —
370 /// the QSRV atomic group GET/PUT and the pvalink atomic
371 /// scan-on-update epoch. The advisory gate `Mutex` is not
372 /// reentrant; a transaction owner holding `lock_records` over the
373 /// member set MUST use this entry to scan a member record, or it
374 /// would deadlock against its own epoch guard. Foreign (non-owner)
375 /// callers must use [`Self::process_record_with_links`] so the gate
376 /// is taken.
377 pub fn process_record_with_links_already_locked<'a>(
378 &'a self,
379 name: &'a str,
380 visited: &'a mut HashSet<String>,
381 depth: usize,
382 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
383 Box::pin(async move {
384 self.process_record_with_links_inner(name, visited, depth, false, false)
385 .await
386 })
387 }
388
389 /// recursive FLNK / OUT / CP fan-out entry within a single
390 /// processing chain. Does NOT re-acquire the advisory write gate:
391 /// the chain is one transaction whose entry record's gate is
392 /// already held by the foreign entry, and C `processTarget`
393 /// (`dbDbLink.c:436`) processes a link target under the lock set
394 /// already owned by the calling thread. Re-acquiring per chain
395 /// member would also create a lock-ordering deadlock between
396 /// reverse FLNK chains.
397 pub(crate) fn process_record_with_links_recursive<'a>(
398 &'a self,
399 name: &'a str,
400 visited: &'a mut HashSet<String>,
401 depth: usize,
402 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
403 Box::pin(async move {
404 self.process_record_with_links_inner(name, visited, depth, false, false)
405 .await
406 })
407 }
408
409 /// Owner-driven continuation re-entry — bypasses the PACT entry guard.
410 ///
411 /// Used by `ProcessAction::ReprocessAfter` timer fires: the spawned
412 /// re-entry task IS the owner of the async cycle, equivalent to C
413 /// `callbackRequestDelayed`'s direct call to the record's `process()`
414 /// (which bypasses `dbProcess`). Foreign callers must still go through
415 /// `process_record_with_links` so FLNK / scan / CA put cannot race
416 /// during the wait window.
417 ///
418 /// the timer fire is a fresh task — the original cycle's
419 /// advisory gate was released when `process_record_with_links`
420 /// returned async-pending. In C, `callbackRequestDelayed` dispatches
421 /// through a callback that re-takes `dbScanLock(precord)` for the
422 /// completion `process()`. This entry therefore re-acquires the
423 /// advisory write gate, so the continuation cannot interleave with a
424 /// QSRV atomic group or another foreign scan of the same record.
425 pub fn process_record_continuation<'a>(
426 &'a self,
427 name: &'a str,
428 visited: &'a mut HashSet<String>,
429 depth: usize,
430 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
431 Box::pin(async move {
432 self.process_record_with_links_inner(name, visited, depth, true, true)
433 .await
434 })
435 }
436
437 /// A cycle-free [`AsyncDbHandle`] for this database, handed to each
438 /// record via [`crate::server::record::Record::set_async_context`] at
439 /// registration. Holds only a `Weak` reference, so a record stashing
440 /// it never keeps the database alive.
441 pub fn async_handle(&self) -> AsyncDbHandle {
442 AsyncDbHandle {
443 inner: Arc::downgrade(&self.inner),
444 }
445 }
446
447 /// Mint a fresh async re-entry [`AsyncToken`] for `name`.
448 ///
449 /// Minting advances the record's generation counter, so any
450 /// previously-minted token for the same record is superseded — its
451 /// [`AsyncToken::fire`] becomes a structural no-op. This mirrors C
452 /// `callbackRequestDelayed` replacing an outstanding delayed callback
453 /// for a record. `name` must be the canonical record name (the value
454 /// of `RecordInstance::name`). Returns `None` if the record is absent.
455 pub async fn mint_async_token(&self, name: &str) -> Option<AsyncToken> {
456 let records = self.inner.records.read().await;
457 let rec = records.get(name)?;
458 let generation = rec.read().await.reprocess_generation.clone();
459 let epoch = generation.fetch_add(1, Ordering::AcqRel) + 1;
460 Some(AsyncToken {
461 name: name.to_string(),
462 generation,
463 epoch,
464 })
465 }
466
467 /// Cancel any outstanding async re-entry token for `name` (C
468 /// `callbackCancelDelayed`): advance the record's generation counter so
469 /// every previously-minted [`AsyncToken`] for it becomes stale and its
470 /// `fire` is a no-op. A subsequent [`Self::mint_async_token`] produces a
471 /// fresh, current token. No-op if the record is absent.
472 pub async fn cancel_async_reentry(&self, name: &str) {
473 let records = self.inner.records.read().await;
474 if let Some(rec) = records.get(name) {
475 rec.read()
476 .await
477 .reprocess_generation
478 .fetch_add(1, Ordering::AcqRel);
479 }
480 }
481
482 /// Post an async-side field update for `name` — the C `db_post_events`
483 /// analogue called from device-support / async-callback context.
484 ///
485 /// Each `(field, value)` is written through the internal put (bypassing
486 /// the read-only field gate, like a record's own `process()` writes)
487 /// and a monitor event is posted with `DBE_VALUE | DBE_LOG` — the mask C
488 /// device support uses for an out-of-process value post
489 /// (`db_post_events(precord, &prec->field, DBE_VALUE | DBE_LOG)`).
490 /// Metadata-class writes invalidate the metadata cache via
491 /// `notify_field_written`, honouring the snapshot-cache contract.
492 ///
493 /// Unlike [`Self::complete_async_record`], this runs *no* alarm /
494 /// timestamp / FLNK tail: it is the immediate "push these fields to
495 /// monitors now" primitive (e.g. asyn TRACE info, motor intermediate
496 /// readback) that is independent of any process cycle. Returns the
497 /// field names actually posted, or [`CaError::ChannelNotFound`] if the
498 /// record is absent.
499 pub async fn post_fields(
500 &self,
501 name: &str,
502 fields: Vec<(String, EpicsValue)>,
503 ) -> CaResult<Vec<String>> {
504 let rec = {
505 let records = self.inner.records.read().await;
506 records.get(name).cloned()
507 };
508 let rec = rec.ok_or_else(|| CaError::ChannelNotFound(name.to_string()))?;
509 let mut inst = rec.write().await;
510 let mut posted = Vec::with_capacity(fields.len());
511 for (field, value) in fields {
512 inst.record.put_field_internal(&field, value)?;
513 // Snapshot-cache contract: a metadata-class write must
514 // invalidate the cache before the monitor snapshot is built.
515 inst.notify_field_written(&field);
516 inst.notify_field(
517 &field,
518 crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
519 );
520 posted.push(field);
521 }
522 Ok(posted)
523 }
524
525 /// Resolve a link's target field [`DbFieldType`] for a LOCAL `DB_LINK`,
526 /// or `None` for a constant / external / unresolvable link.
527 ///
528 /// Parity of C `dbGetLinkDBFtype` as `sseqRecord.c:checkLinks`
529 /// (sseqRecord.c:884-941) uses it to fill the `DTn`/`LTn` diagnostics:
530 /// a `DB_LINK` whose target record is on this IOC reports its addressed
531 /// field's type (C `dbNameToAddr` → `pAddr->field_type`). A constant or
532 /// `CA`/`PVA` (external) link returns `None` — epics-base-rs has no
533 /// client-side introspection of a remote field's type, so the caller
534 /// renders those as the `DBF_unknown` sentinel.
535 pub(crate) async fn link_target_field_type(
536 &self,
537 link: &str,
538 ) -> Option<crate::types::DbFieldType> {
539 let db = match crate::server::record::parse_link_v2(link) {
540 crate::server::record::ParsedLink::Db(db) => db,
541 _ => return None,
542 };
543 let rec = self.get_record(&db.record).await?;
544 let inst = rec.read().await;
545 let field = if db.field.is_empty() {
546 "VAL"
547 } else {
548 db.field.as_str()
549 };
550 inst.record
551 .field_list()
552 .iter()
553 .find(|f| f.name.eq_ignore_ascii_case(field))
554 .map(|f| f.dbf_type)
555 }
556
557 /// Create a put-notify wait-set for a downstream operation a record is
558 /// about to drive, returning the wait-set (to attach to the downstream
559 /// target instance's `notify`) and the completion receiver.
560 ///
561 /// C `dbNotify.c` `processNotify`: the set arms `pending = 1` for the
562 /// downstream operation and fires the oneshot when that slot (plus any
563 /// FLNK/OUT chain members that `enter` it) drains to zero — i.e. on
564 /// `dbNotifyCompletion`. Pair with [`Self::reprocess_on_notify`] to
565 /// re-enter a waiting record when the downstream completes (SSEQ
566 /// `WAITn`).
567 pub fn new_put_notify() -> (
568 Arc<NotifyWaitSet>,
569 crate::runtime::sync::oneshot::Receiver<()>,
570 ) {
571 let (tx, rx) = crate::runtime::sync::oneshot::channel();
572 (NotifyWaitSet::new(tx), rx)
573 }
574
575 /// Wire a downstream put-notify completion to an async re-entry: spawn a
576 /// task that awaits `completion` (the oneshot from
577 /// [`Self::new_put_notify`], fired on `dbNotifyCompletion`) and then
578 /// `token.fire`s, re-entering the waiting record's `process()`. A
579 /// superseded / cancelled token re-enters nothing. Returns the spawned
580 /// task handle; fire-and-forget callers may drop it.
581 pub fn reprocess_on_notify(
582 &self,
583 token: AsyncToken,
584 completion: crate::runtime::sync::oneshot::Receiver<()>,
585 ) -> tokio::task::JoinHandle<()> {
586 let db = self.clone();
587 tokio::spawn(async move {
588 // `Err` means the sender was dropped without firing (the
589 // downstream op vanished); treat it the same as completion so a
590 // waiting record is never stranded — `fire` is a no-op if the
591 // token was meanwhile superseded.
592 let _ = completion.await;
593 let _ = token.fire(&db).await;
594 })
595 }
596
597 /// Issue a put-WITH-completion to an OUT link and hand the caller only
598 /// the completion receiver — the non-blocking sibling of
599 /// [`Self::reprocess_on_notify`].
600 ///
601 /// Each call mints its own put-notify wait-set (C `dbProcessNotify`),
602 /// writes the link through it with the source record's committed PUTF /
603 /// alarm propagated (C `recGblInheritSevrMsg`), releases the initiator
604 /// count, and returns the oneshot that fires on `dbNotifyCompletion`.
605 /// The caller owns when (and whether) to await each receiver, so several
606 /// puts can be outstanding at once — unlike
607 /// [`ProcessAction::WriteDbLinkNotify`], which wires the completion
608 /// straight to a single superseding async re-entry token and so allows
609 /// only one outstanding put per record. This is the seam C
610 /// `calcApp/src/sseqRecord.c` needs to run multiple `WAITn` put-callbacks
611 /// concurrently in flight (`processNextLink`).
612 ///
613 /// `record_name` is the source whose PUTF/alarm propagate into the
614 /// target, `link_str` the already-resolved OUT link spelling, `value`
615 /// the value to write. `None` if the source record is gone; an empty
616 /// `link_str` returns a receiver that fires immediately (nothing joined
617 /// the set).
618 pub async fn put_link_notify(
619 &self,
620 record_name: &str,
621 link_str: &str,
622 value: EpicsValue,
623 ) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
624 let (src_putf, src_alarm) = {
625 let rec = {
626 let records = self.inner.records.read().await;
627 records.get(record_name)?.clone()
628 };
629 let instance = rec.read().await;
630 (
631 instance.common.putf,
632 super::links::LinkAlarm {
633 stat: instance.common.stat,
634 sevr: instance.common.sevr,
635 amsg: instance.common.amsg.clone(),
636 },
637 )
638 };
639 let (waitset, completion) = Self::new_put_notify();
640 if !link_str.is_empty() {
641 let parsed = crate::server::record::parse_link_v2(link_str);
642 // Seed the cycle-guard with the source so a target linking back
643 // does not re-process it, exactly as a top-level OUT-link write
644 // does (`process_record_with_links_inner` inserts its own name).
645 let mut visited = HashSet::new();
646 visited.insert(record_name.to_string());
647 self.write_out_link_value(
648 &parsed,
649 value,
650 super::links::OutLinkSrc {
651 putf: src_putf,
652 notify: Some(&waitset),
653 alarm: &src_alarm,
654 },
655 &mut visited,
656 0,
657 )
658 .await;
659 }
660 // Release the initiator's own count (C `dbProcessNotify` holds one
661 // count for the requester and drops it after issuing the put). The
662 // set then drains — firing `completion` — when the downstream
663 // target(s) that joined via `join_put_notify` finish, or immediately
664 // when the link was empty / the target completed synchronously.
665 waitset.leave();
666 Some(completion)
667 }
668
669 async fn process_record_with_links_inner(
670 &self,
671 name: &str,
672 visited: &mut HashSet<String>,
673 depth: usize,
674 is_continuation: bool,
675 acquire_gate: bool,
676 ) -> CaResult<()> {
677 const MAX_LINK_DEPTH: usize = 16;
678 const MAX_LINK_OPS: usize = 256;
679
680 // Normalise to the canonical record name once at entry — both
681 // for cycle-detection (`visited` would otherwise treat alias
682 // and canonical as distinct entries) and for the records-map
683 // lookup below. Mirrors epics-base PR #336.
684 let canonical_owned;
685 let name: &str = if let Some(target) = self.resolve_alias(name).await {
686 canonical_owned = target;
687 &canonical_owned
688 } else {
689 name
690 };
691
692 if depth >= MAX_LINK_DEPTH {
693 eprintln!("link chain depth limit reached at record {name}");
694 return Ok(());
695 }
696 if visited.len() >= MAX_LINK_OPS {
697 eprintln!("link chain ops budget exhausted at record {name}");
698 return Ok(());
699 }
700 if !visited.insert(name.to_string()) {
701 return Ok(()); // Cycle detected, skip
702 }
703
704 let rec = {
705 let records = self.inner.records.read().await;
706 records.get(name).cloned()
707 };
708
709 let rec = match rec {
710 Some(r) => r,
711 None => return Err(CaError::ChannelNotFound(name.to_string())),
712 };
713
714 // advisory write gate (`dbScanLock(precord)` analogue).
715 // A foreign full-processing entry (scan loop, scan_event, FLNK
716 // dispatch from another chain, CA put, PINI/startup) acquires
717 // the entry record's gate so it cannot interleave with a QSRV
718 // atomic group or a pvalink atomic scan epoch holding
719 // `lock_records` over the same record. `name` is already the
720 // alias-resolved canonical name, the same key `lock_records`
721 // uses. Not acquired when `acquire_gate` is false: either a
722 // transaction owner already holds the gate via `lock_records`
723 // (`process_record_with_links_already_locked`), or this is a
724 // recursive FLNK/OUT/CP call within one chain
725 // (`process_record_with_links_recursive`) — C `processTarget`
726 // processes a link target under the lock set the caller already
727 // owns, and re-acquiring would deadlock the non-reentrant gate.
728 let _record_gate = if acquire_gate {
729 Some(self.lock_record(name).await)
730 } else {
731 None
732 };
733
734 // 0a. PACT entry guard — mirrors C `dbProcess` (dbAccess.c:537-559).
735 // If the record is currently mid-async (PACT=true), do NOT re-enter
736 // the body. Instead increment LCNT; after MAX_LOCK=10 consecutive
737 // attempts raise SCAN_ALARM/INVALID with "Async in progress" and
738 // post a monitor on VAL (DBE_VALUE|DBE_LOG). Up to MAX_LOCK we just
739 // bail out silently so transient back-to-back scans don't immediately
740 // alarm the record.
741 //
742 // Without this guard, FLNK / scan-loop / event scans dispatched onto
743 // a record whose first cycle is still pending (async device support,
744 // CA put_notify on PUTF) would re-enter `record.process()` while the
745 // device's first response is still in flight — corrupting the
746 // record's internal state machine and bypassing the C-parity
747 // contract that callers see for `dbProcess`. The pre-existing
748 // `dispatch_cp_targets` path already did this check (sets RPRO=true
749 // and skips); the main entry was missing it.
750 if !is_continuation {
751 const MAX_LOCK: i16 = 10;
752 let mut instance = rec.write().await;
753 if instance.is_processing() {
754 // C `dbAccess.c:539-541` — when TPRO is set on a record
755 // whose PACT is true, print the diagnostic line before
756 // the bail decision. The C path emits:
757 // "%s: dbProcess of Active '%s' with RPRO=%d"
758 // mirroring the same context format the regular trace
759 // path below uses (thread/client name + record name +
760 // current RPRO bit). Without this, an operator
761 // debugging a stuck async record sees NO sign that the
762 // entry guard is firing — they only notice the
763 // eventual SCAN_ALARM after MAX_LOCK=10 attempts.
764 if instance.common.tpro {
765 eprintln!(
766 "[TPRO] {}: dbProcess of Active '{}' with RPRO={}",
767 instance.name,
768 instance.name,
769 if instance.common.rpro { 1 } else { 0 },
770 );
771 }
772 let stat = instance.common.stat;
773 let already_invalid =
774 instance.common.sevr >= crate::server::record::AlarmSeverity::Invalid;
775 let already_scan_alarm = stat == crate::server::recgbl::alarm_status::SCAN_ALARM;
776 let lcnt_before = instance.common.lcnt;
777 instance.common.lcnt = lcnt_before.saturating_add(1);
778 if already_scan_alarm || lcnt_before < MAX_LOCK || already_invalid {
779 // Bail out without raising alarm yet.
780 return Ok(());
781 }
782 // Raise SCAN_ALARM/INVALID, reset alarm transition,
783 // and post VAL monitor (DBE_VALUE | DBE_LOG).
784 crate::server::recgbl::rec_gbl_set_sevr_msg(
785 &mut instance.common,
786 crate::server::recgbl::alarm_status::SCAN_ALARM,
787 crate::server::record::AlarmSeverity::Invalid,
788 "Async in progress",
789 );
790 let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
791 // Post VAL with VALUE|LOG|ALARM (C `db_post_events(prec,
792 // &VAL, DBE_VALUE|DBE_LOG)` plus recGblResetAlarms'
793 // `val_mask = DBE_ALARM` for the fresh transition). The
794 // alarm fields carry their C per-field masks
795 // (recGbl.c:201-220): this guard only runs on a fresh
796 // SCAN_ALARM/INVALID raise, so sevr AND stat both moved —
797 // SEVR posts DBE_VALUE, STAT/AMSG post the shared
798 // `stat_mask` = DBE_ALARM|DBE_VALUE.
799 use crate::server::recgbl::EventMask;
800 let stat_mask = EventMask::ALARM | EventMask::VALUE;
801 let mut changed_fields = Vec::new();
802 if let Some(val) = instance.record.val() {
803 changed_fields.push((
804 "VAL".to_string(),
805 val,
806 EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
807 ));
808 }
809 changed_fields.push((
810 "SEVR".to_string(),
811 EpicsValue::Short(instance.common.sevr as i16),
812 EventMask::VALUE,
813 ));
814 changed_fields.push((
815 "STAT".to_string(),
816 EpicsValue::Short(instance.common.stat as i16),
817 stat_mask,
818 ));
819 // Include AMSG so subscribers reading the alarm text
820 // observe "Async in progress" alongside the SCAN_ALARM
821 // transition (C `recGbl.c:210-211` posts STAT and AMSG
822 // together when `stat_mask` is non-zero).
823 changed_fields.push((
824 "AMSG".to_string(),
825 EpicsValue::String(instance.common.amsg.clone().into()),
826 stat_mask,
827 ));
828 let snapshot = crate::server::record::ProcessSnapshot { changed_fields };
829 drop(instance);
830 let inst = rec.read().await;
831 inst.notify_from_snapshot(&snapshot);
832 return Ok(());
833 }
834 // Not pact: reset lcnt (mirrors C `else { precord->lcnt = 0; }`
835 // at dbAccess.c:559) so the next async cycle starts clean.
836 instance.common.lcnt = 0;
837 }
838
839 // 0. SDIS disable check — C parity dbAccess.c:562-592.
840 //
841 // When the SDIS link evaluates to a value equal to DISV, the
842 // record is disabled and bails before record support runs. C
843 // ALWAYS clears rpro/putf and triggers dbNotifyCompletion at
844 // this point — regardless of whether the alarm transition
845 // fires — because a disabled record must not leave behind
846 // pending reprocess requests or stranded put_notify completion
847 // callbacks. Pre-fix the Rust port only reset
848 // nsta/nsev and updated the alarm state, leaking rpro/putf
849 // into the next cycle and stalling CA WRITE_NOTIFY callers
850 // (the put_notify_tx never fired so the CA dispatcher waited
851 // until socket disconnect to release the operation).
852 {
853 let (sdis_link, disv, diss) = {
854 let instance = rec.read().await;
855 (
856 instance.parsed_sdis.clone(),
857 instance.common.disv,
858 instance.common.diss,
859 )
860 };
861
862 // C `dbGetLink(&precord->sdis, DBR_SHORT, &precord->disa, 0, 0)`
863 // reads the SDIS link regardless of its type (DB / CA / PVA /
864 // constant) via the lset. The pre-fix port only refreshed
865 // `disa` from a `ParsedLink::Db` SDIS, so a remote-sourced
866 // (CA/PVA) or constant enable/disable was silently ignored.
867 if let Some(val) = self.read_link_value_no_process(&sdis_link).await {
868 let disa_val = val.to_f64().unwrap_or(0.0) as i16;
869 let mut instance = rec.write().await;
870 instance.common.disa = disa_val;
871 }
872
873 let disa = rec.read().await.common.disa;
874 if disa == disv {
875 let notify = {
876 let mut instance = rec.write().await;
877 // C `dbAccess.c:575-577` — clear rpro/putf and arm
878 // notifyCompletion BEFORE the alarm check. Disabled
879 // records skip processing entirely, so any pending
880 // reprocess request is dropped (the next non-
881 // disabled cycle will pick up fresh state) and the
882 // CA put-notify caller must be released. A disabled
883 // record drives no FLNK/OUT chain, so leaving the
884 // wait-set here is its whole contribution.
885 instance.common.rpro = false;
886 instance.common.putf = false;
887 let notify = instance.notify.take();
888
889 // Reset nsta/nsev so stale alarm state doesn't bleed
890 // into a subsequent (re-enabled) cycle. C resets
891 // them after the sevr/stat transition; doing it
892 // first here is observationally identical because
893 // the SDIS bail short-circuits any record-support
894 // path that could read them.
895 instance.common.nsta = 0;
896 instance.common.nsev = crate::server::record::AlarmSeverity::NoAlarm;
897
898 // C `dbAccess.c:580-581` — if already in
899 // DISABLE_ALARM, the alarm post is skipped entirely
900 // (the alarm cycle is debounced). The rpro/putf
901 // clear above still ran, matching C's pre-`goto
902 // all_done` ordering.
903 if instance.common.stat != crate::server::recgbl::alarm_status::DISABLE_ALARM {
904 use crate::server::recgbl::EventMask;
905 instance.common.sevr = diss;
906 instance.common.stat = crate::server::recgbl::alarm_status::DISABLE_ALARM;
907 // C `dbAccess.c:586-593` posts each field with
908 // its own mask:
909 // db_post_events(&stat, DBE_VALUE);
910 // db_post_events(&sevr, DBE_VALUE);
911 // db_post_events(&val, DBE_VALUE|DBE_ALARM);
912 // STAT/SEVR get DBE_VALUE only — a DBE_ALARM-only
913 // subscriber on `.STAT`/`.SEVR` must NOT receive
914 // this disable event. Only the value field
915 // carries DBE_ALARM.
916 instance.notify_field("STAT", EventMask::VALUE);
917 instance.notify_field("SEVR", EventMask::VALUE);
918 instance.notify_field("VAL", EventMask::VALUE | EventMask::ALARM);
919 }
920 notify
921 };
922 // Fire dbNotifyCompletion outside the record lock —
923 // C `dbAccess.c:622-623` runs it at `all_done` after
924 // the disable bail. Without this, a CA WRITE_NOTIFY
925 // landing on a disabled record stalls until socket
926 // disconnect. `leave` fires the completion oneshot when
927 // this empties the wait-set.
928 if let Some(ws) = notify {
929 ws.leave();
930 }
931 return Ok(());
932 }
933 }
934
935 // 0.3. TSEL link: C `recGblGetTimeStampSimm` (recGbl.c:310-323).
936 //
937 // When `TSEL` is a non-constant link, C distinguishes two
938 // cases by the link target field:
939 // * the link points at another record's `.TIME` field
940 // (`DBLINK_FLAG_TSELisTIME`) — copy that record's
941 // timestamp directly into `prec->time`;
942 // * otherwise `dbGetLink(&tsel, DBR_SHORT, &prec->tse)` —
943 // load `TSE` from the link before the event lookup.
944 {
945 let tsel_link = {
946 let instance = rec.read().await;
947 instance.parsed_tsel.clone()
948 };
949 // A TSEL link pointing at a `.TIME` field copies that record's
950 // timestamp+utag into `time`/`utag` and marks TSE=-2 so
951 // `apply_timestamp` leaves them alone. C `TSEL_modified`
952 // (dbLink.c:71-87) sets `DBLINK_FLAG_TSELisTIME` for ANY
953 // `PV_LINK` tsel whose pvname contains `.TIME`, set BEFORE the
954 // DB-vs-CA decision (dbLink.c:118) — so a local-DB link AND a
955 // CA link both qualify. `recGblGetTimeStampSimm`
956 // (recGbl.c:316-321) then copies the link's time+utag via
957 // `dbGetTimeStampTag` and RETURNS, never loading TSE from the
958 // value (even when the read fails). A pva link is a
959 // `JSON_LINK` and returns early from `dbInitLink`
960 // (dbLink.c:107) before `TSEL_modified`, so C never flags it;
961 // pva TSEL `.TIME` is intentionally excluded here.
962 let tsel_is_time = match &tsel_link {
963 crate::server::record::ParsedLink::Db(link) => {
964 link.field.eq_ignore_ascii_case("TIME")
965 }
966 crate::server::record::ParsedLink::Ca(ca) => ca_tsel_time_record(&ca.pv).is_some(),
967 _ => false,
968 };
969 if tsel_is_time {
970 // C `dbGetTimeStampTag(plink, &prec->time, &prec->utag)`
971 // (recGbl.c:317) copies BOTH the link's time AND utag.
972 // Read the pair as one consistent snapshot per source.
973 let src_time = match &tsel_link {
974 crate::server::record::ParsedLink::Db(link) => {
975 // C `dbInitLink` locality (`dbLink.c:115-130`):
976 // `TSEL_modified` sets the `TSELisTIME` flag and
977 // strips `.TIME` BEFORE the DB-vs-CA decision
978 // (dbLink.c:115-118), so a TSEL `.TIME` link whose
979 // record is not local still becomes a CA link and
980 // reads its remote `.TIME` via the CA lset
981 // `getTimeStampTag`. Local arm reads the source
982 // record's `(time, utag)`; the non-local arm routes
983 // `ca://REC` through `external_link_time` (CA
984 // carries no userTag, so utag is 0) — uniform with
985 // the `Ca` arm below and the `read_db_link_value`
986 // read-locality fallback.
987 if self.has_name_no_resolve(&link.record).await {
988 match self.get_record(&link.record).await {
989 Some(src) => {
990 let g = src.read().await;
991 Some((g.common.time, g.common.utag))
992 }
993 None => None,
994 }
995 } else {
996 self.external_link_time(&format!("ca://{}", link.record))
997 .await
998 .map(ext_time_pair)
999 }
1000 }
1001 crate::server::record::ParsedLink::Ca(ca) => {
1002 // Strip `.TIME` (C dbLink.c:82-84) and read the CA
1003 // link's cached timestamp. `external_link_time`
1004 // routes `ca://` to the ungated CA lset
1005 // `time_stamp` (CA has no `time=` option; gated
1006 // only on `connected`, like C `dbGetTimeStamp`
1007 // failing on a disconnected link). CA wire carries
1008 // no userTag, so the source contributes utag 0.
1009 match ca_tsel_time_record(&ca.pv) {
1010 Some(rec_name) => self
1011 .external_link_time(&format!("ca://{rec_name}"))
1012 .await
1013 .map(ext_time_pair),
1014 None => None,
1015 }
1016 }
1017 _ => None,
1018 };
1019 // C returns after the TSELisTIME branch even when the read
1020 // fails (recGbl.c:317-320): keep the record's current time
1021 // rather than falling through to load TSE from the value.
1022 if let Some((src_time, src_utag)) = src_time {
1023 let mut instance = rec.write().await;
1024 instance.common.time = src_time;
1025 instance.common.utag = src_utag;
1026 instance.common.tse = -2;
1027 }
1028 } else if let Some(val) = self.read_link_value_no_process(&tsel_link).await {
1029 // Non-`.TIME` TSEL: C `dbGetLink(&tsel, DBR_SHORT,
1030 // &prec->tse)` loads TSE from the link regardless of its
1031 // type. The pre-fix port only read a `ParsedLink::Db`
1032 // TSEL, ignoring a CA/PVA/constant TSE source.
1033 let tse_val = val.to_f64().unwrap_or(0.0) as i16;
1034 let mut instance = rec.write().await;
1035 instance.common.tse = tse_val;
1036 }
1037 }
1038
1039 // 0.5. Simulation mode check.
1040 //
1041 // C `aiRecord.c:151-168`: simulation is handled inside
1042 // `readValue()`, then `process()` ALWAYS runs `convert` /
1043 // `checkAlarms` / `monitor` / `recGblFwdLink(prec)`. A
1044 // simulated record therefore must still run the forward-link /
1045 // CP / RPRO tail — only the device read and record-support
1046 // body are replaced by the SIOL round-trip. Returning early
1047 // here would silently break every FLNK / CP chain downstream
1048 // of any record in SIMM mode.
1049 match self.check_simulation_mode(&rec).await {
1050 SimOutcome::NotSimulated => {}
1051 SimOutcome::Simulated => {
1052 self.run_forward_link_tail(name, &rec, visited, depth).await;
1053 return Ok(());
1054 }
1055 }
1056
1057 // 1. Read INP link value and DOL link (outside lock)
1058 let (inp_parsed, is_soft, dol_info) = {
1059 let instance = rec.read().await;
1060 let rtype = instance.record.record_type();
1061
1062 let inp = instance.parsed_inp.clone();
1063 let is_soft = crate::server::device_support::is_soft_dtyp(&instance.common.dtyp);
1064
1065 // DOL link info for output records with OMSL=CLOSED_LOOP.
1066 //
1067 // C parity: every record type whose DBD declares both an
1068 // OMSL `menuOmsl` field AND a DOL link field must honour
1069 // the closed-loop binding. `dfanoutRecord.c:115-122` shows
1070 // dfanout doing this directly via `dbGetLink(&prec->dol,
1071 // DBR_DOUBLE, &prec->val, ...)` when `omsl ==
1072 // menuOmslclosed_loop`. The Rust port previously omitted
1073 // `dfanout`, so a dfanout configured with OMSL=closed_loop
1074 // never sourced VAL from DOL — every cycle silently used
1075 // the previously-cached VAL, breaking any cascaded
1076 // setpoint-distribution chain that relied on dfanout to
1077 // re-read the input.
1078 //
1079 // The `aao` (array analog output) record is the only other
1080 // OMSL-bearing C record; the Rust port does not implement
1081 // aao (confirmed: no `crates/epics-base-rs/src/server/records/aao*.rs`),
1082 // so it is a future gap, not a same-defect-not-fixed site.
1083 let dol = match rtype {
1084 "ao" | "longout" | "int64out" | "bo" | "mbbo" | "mbboDirect" | "stringout"
1085 | "lso" | "dfanout" => {
1086 let omsl = instance
1087 .record
1088 .get_field("OMSL")
1089 .and_then(|v| {
1090 if let EpicsValue::Short(s) = v {
1091 Some(s)
1092 } else {
1093 None
1094 }
1095 })
1096 .unwrap_or(0);
1097 let oif = instance
1098 .record
1099 .get_field("OIF")
1100 .and_then(|v| {
1101 if let EpicsValue::Short(s) = v {
1102 Some(s)
1103 } else {
1104 None
1105 }
1106 })
1107 .unwrap_or(0);
1108 if omsl == 1 {
1109 let dol_parsed = instance
1110 .record
1111 .get_field("DOL")
1112 .and_then(|v| {
1113 if let EpicsValue::String(s) = v {
1114 Some(s)
1115 } else {
1116 None
1117 }
1118 })
1119 .map(|s| {
1120 crate::server::record::parse_link_v2(s.as_str_lossy().as_ref())
1121 })
1122 .unwrap_or(crate::server::record::ParsedLink::None);
1123 Some((dol_parsed, oif))
1124 } else {
1125 None
1126 }
1127 }
1128 _ => None,
1129 };
1130
1131 (inp, is_soft, dol)
1132 };
1133
1134 // 1.1. Pre-input-link actions: actions a record needs the
1135 // framework to execute BEFORE any input-link fetch this cycle.
1136 //
1137 // C `devEpidSoftCallback.c:120-151`: a DB-type readback-trigger
1138 // (TRIG) link is written with `dbPutLink` — which synchronously
1139 // processes the triggered source — and only then does
1140 // `dbGetLink(&pepid->inp, ...)` read CVAL. The trigger write
1141 // must land before the `INP -> CVAL` fetch, in the same pass.
1142 // `pre_process_actions` runs too late (after the input-link
1143 // fetch below), so `pre_input_link_actions` is a strictly
1144 // earlier hook. The record needs `dtyp` to decide whether the
1145 // callback DSET is active, so push the process context first.
1146 {
1147 let pre_input_actions = {
1148 let mut instance = rec.write().await;
1149 let ctx = instance.common.process_context();
1150 instance.record.set_process_context(&ctx);
1151 instance.record.pre_input_link_actions()
1152 };
1153 if !pre_input_actions.is_empty() {
1154 self.execute_process_actions(name, &rec, pre_input_actions, visited, depth)
1155 .await;
1156 }
1157 }
1158
1159 // Read INP value
1160 let inp_value = self
1161 .read_link_value_soft(&inp_parsed, is_soft, visited, depth)
1162 .await;
1163
1164 // epics-base PR #d0cf47c: single-INP MS-class link must also
1165 // propagate the source record's STAT/SEVR/AMSG just like the
1166 // multi-input fetch loop below does. Previously the INPA..L
1167 // path (calc/sub/aSub/sel) propagated alarms but plain single
1168 // INP (ai/bi/longin/mbbi/stringin) silently dropped them —
1169 // downstream MSS readers saw NoAlarm even when the source was
1170 // INVALID. Only fires for soft-channel records: hardware-driver
1171 // alarms travel through device-support's own last_alarm path.
1172 //
1173 // B2: a soft INP that is an external `pva://` / `ca://` link
1174 // also propagates the lset's alarm. The link string carries
1175 // no `MonitorSwitch` (the `?sevr=MS` modifier is stripped by
1176 // the parser before epics-base-rs sees it), so the lset has
1177 // already applied the MS/NMS/MSI gate — a `Some` LinkAlarm
1178 // here is one the lset decided to propagate. We fold it in as
1179 // `MaximizeStatus` so the gated severity AND message both
1180 // reach `LINK_ALARM`, matching pvxs `pvalink_lset.cpp`
1181 // `recGblSetSevrMsg`.
1182 let inp_link_alarm: Option<(
1183 crate::server::record::MonitorSwitch,
1184 super::links::LinkAlarm,
1185 )> = if is_soft {
1186 match inp_parsed {
1187 crate::server::record::ParsedLink::Db(ref db) => {
1188 let (_v, alarm) = self.read_link_with_alarm(&inp_parsed).await;
1189 alarm.map(|a| (db.monitor_switch, a))
1190 }
1191 crate::server::record::ParsedLink::Pva(_)
1192 | crate::server::record::ParsedLink::PvaJson(_) => {
1193 // PVA: the lset already applied the MS/NMS/MSI gate,
1194 // so the returned severity is final — fold it as
1195 // MaximizeStatus to preserve the remote stat+msg
1196 // (pvxs `pvalink_lset.cpp`).
1197 let (_v, alarm) = self.read_link_with_alarm(&inp_parsed).await;
1198 alarm.map(|a| (crate::server::record::MonitorSwitch::MaximizeStatus, a))
1199 }
1200 crate::server::record::ParsedLink::Ca(ref ca) => {
1201 // CA: apply the link's own
1202 // MS/NMS/MSI/MSS gate at the fold boundary, uniform
1203 // with the Db arm above — the resolver returned the
1204 // *raw* remote alarm, not a gated one.
1205 let (_v, alarm) = self.read_link_with_alarm(&inp_parsed).await;
1206 alarm.map(|a| (ca.monitor_switch, a))
1207 }
1208 _ => None,
1209 }
1210 } else {
1211 None
1212 };
1213
1214 // if the single-INP link is an external `pva://` /
1215 // `ca://` link configured with `time=true`, the lset returns
1216 // the latched upstream NT timestamp here and we adopt it
1217 // into the owning record's `common.time` and `common.utag`. The
1218 // lset gates the option internally (returns `None` unless
1219 // `time=true`), so a bare connected link without the flag still
1220 // produces local processing time. Mirrors pvxs
1221 // `pvalink_lset.cpp:427`.
1222 let inp_link_remote_time: Option<(i64, i32, u64)> = match inp_parsed.external_pv_name() {
1223 Some(name) => self.external_link_time(&name).await,
1224 None => None,
1225 };
1226
1227 // Read DOL value
1228 let dol_value = if let Some((ref dol_parsed, _oif)) = dol_info {
1229 self.read_link_value(dol_parsed, visited, depth).await
1230 } else {
1231 None
1232 };
1233
1234 // 1.5. Multi-input link fetch (calc/calcout/sel/sub)
1235 // Also collect alarm info from source records for MS/NMS propagation.
1236 let multi_input_values: Vec<(String, EpicsValue)>;
1237 let mut link_alarms: Vec<(
1238 crate::server::record::MonitorSwitch,
1239 super::links::LinkAlarm,
1240 )> = Vec::new();
1241 // Link fields (the `multi_input_links` first element) whose
1242 // fetch actually produced a value this cycle — pushed to the
1243 // record via `set_resolved_input_links` so its `process()` can
1244 // observe link-fetch success (C `RTN_SUCCESS(dbGetLink(...))`).
1245 let mut resolved_link_fields: Vec<&'static str> = Vec::new();
1246 {
1247 let link_info: Vec<(String, &'static str, String)> = {
1248 let instance = rec.read().await;
1249 instance
1250 .record
1251 .multi_input_links()
1252 .iter()
1253 .map(|(lf, vf)| {
1254 let link_str = instance
1255 .record
1256 .get_field(lf)
1257 .and_then(|v| {
1258 if let EpicsValue::String(s) = v {
1259 Some(s)
1260 } else {
1261 None
1262 }
1263 })
1264 .unwrap_or_default();
1265 (link_str.as_str_lossy().into_owned(), *lf, vf.to_string())
1266 })
1267 .collect()
1268 }; // read lock dropped
1269 let mut results = Vec::new();
1270 for (link_str, link_field, val_field) in &link_info {
1271 if !link_str.is_empty() {
1272 let parsed = crate::server::record::parse_link_v2(link_str);
1273 // C `dbGetLink`: a `ProcessPassive` DB input link
1274 // processes its passive source record before the
1275 // value is read. `read_link_with_alarm` does a bare
1276 // `get_pv`, so process the source here first —
1277 // matching the single-INP `read_link_value_soft`
1278 // path. Without this, calc/sel/sub/aSub INPA..INPL
1279 // PP links read a stale source value.
1280 if let crate::server::record::ParsedLink::Db(ref db) = parsed {
1281 self.process_passive_db_source(db, visited, depth).await;
1282 }
1283 let (value, alarm) = self.read_link_with_alarm(&parsed).await;
1284 if let Some(value) = value {
1285 results.push((val_field.clone(), value));
1286 resolved_link_fields.push(link_field);
1287 }
1288 // B2 / multi-input alarm propagation
1289 // covers external links too. `Db` and `Ca` carry an
1290 // explicit `MonitorSwitch` (CA's was parsed from its
1291 // `MS`/`NMS`/`MSI`/`MSS` modifier); `Pva` is gated by
1292 // its lset, so its already-final severity folds as
1293 // `MaximizeStatus` (preserving remote stat+msg).
1294 if let Some(alarm) = alarm {
1295 match &parsed {
1296 crate::server::record::ParsedLink::Db(db) => {
1297 link_alarms.push((db.monitor_switch, alarm));
1298 }
1299 crate::server::record::ParsedLink::Ca(ca) => {
1300 link_alarms.push((ca.monitor_switch, alarm));
1301 }
1302 crate::server::record::ParsedLink::Pva(_)
1303 | crate::server::record::ParsedLink::PvaJson(_) => {
1304 link_alarms.push((
1305 crate::server::record::MonitorSwitch::MaximizeStatus,
1306 alarm,
1307 ));
1308 }
1309 _ => {}
1310 }
1311 }
1312 }
1313 }
1314 multi_input_values = results;
1315 }
1316 // PR #d0cf47c continued: feed the INP alarm (if any) into the
1317 // same `link_alarms` list the lock-section iterates over. Order
1318 // doesn't matter — `rec_gbl_set_sevr_msg` takes the maximum
1319 // severity across all sources.
1320 if let Some(pair) = inp_link_alarm {
1321 link_alarms.push(pair);
1322 }
1323
1324 // 1.6. Sel NVL link: resolve NVL -> SELN
1325 let sel_nvl_value: Option<EpicsValue> = {
1326 let instance = rec.read().await;
1327 if instance.record.record_type() == "sel" {
1328 let nvl_str = instance
1329 .record
1330 .get_field("NVL")
1331 .and_then(|v| {
1332 if let EpicsValue::String(s) = v {
1333 Some(s)
1334 } else {
1335 None
1336 }
1337 })
1338 .unwrap_or_default();
1339 if !nvl_str.is_empty() {
1340 drop(instance); // release read lock before async read
1341 let parsed =
1342 crate::server::record::parse_link_v2(nvl_str.as_str_lossy().as_ref());
1343 self.read_link_value(&parsed, visited, depth).await
1344 } else {
1345 None
1346 }
1347 } else {
1348 None
1349 }
1350 };
1351
1352 // 2. Lock record, apply INP/DOL, process, evaluate alarms, build snapshot
1353 let (snapshot, out_info, flnk_name, process_actions, alarm_posts) = {
1354 let mut instance = rec.write().await;
1355
1356 // Apply DOL value for output records (OMSL=CLOSED_LOOP)
1357 if let Some(dol_val) = dol_value {
1358 let oif = dol_info.as_ref().map(|(_, oif)| *oif).unwrap_or(0);
1359 if oif == 1 {
1360 // Incremental: VAL += DOL value
1361 if let (Some(cur), Some(dol_f)) = (
1362 instance.record.val().and_then(|v| v.to_f64()),
1363 dol_val.to_f64(),
1364 ) {
1365 let _ = instance.record.set_val(EpicsValue::Double(cur + dol_f));
1366 }
1367 } else {
1368 // Full: VAL = DOL value
1369 let _ = instance.record.set_val(dol_val);
1370 }
1371 }
1372
1373 // Apply INP value. "Soft Channel" sets VAL directly
1374 // (C `read_xxx` return 2, skip RVAL→VAL conversion).
1375 // "Raw Soft Channel" routes the value into RVAL and lets
1376 // the record's RVAL→VAL convert run (epics-base
1377 // f2fe9d12: devBiSoftRaw applies MASK after the read).
1378 // Records opt into the raw path via
1379 // `Record::accepts_raw_soft_input` so DTYPs on records
1380 // that haven't wired raw soft channel stay on the legacy
1381 // VAL-direct path.
1382 let is_raw_soft = instance.common.dtyp == "Raw Soft Channel"
1383 && instance.record.accepts_raw_soft_input();
1384 let soft_inp_applied = inp_value.is_some() && !is_raw_soft;
1385 if let Some(inp_val) = inp_value {
1386 if is_raw_soft {
1387 let _ = instance.record.apply_raw_input(inp_val);
1388 } else {
1389 let _ = instance.record.set_val(inp_val);
1390 }
1391 } else if is_soft
1392 && matches!(
1393 inp_parsed,
1394 crate::server::record::ParsedLink::Db(_)
1395 | crate::server::record::ParsedLink::Ca(_)
1396 | crate::server::record::ParsedLink::Pva(_)
1397 | crate::server::record::ParsedLink::PvaJson(_)
1398 )
1399 {
1400 // epics-base PR #4737901: soft-channel `read_xxx` must
1401 // surface link-read failures via the alarm tree, not
1402 // silently succeed. When the INP link is a real
1403 // Db/Ca/Pva link (i.e. operator expected a value) and
1404 // the read returned None, attach LINK_ALARM/INVALID
1405 // so downstream consumers can react. ParsedLink::None
1406 // and Constant don't fall into this branch — the
1407 // former is "no link configured", the latter has its
1408 // own None-as-no-value semantics.
1409 use crate::server::recgbl::{alarm_status, rec_gbl_set_sevr};
1410 rec_gbl_set_sevr(
1411 &mut instance.common,
1412 alarm_status::LINK_ALARM,
1413 crate::server::record::AlarmSeverity::Invalid,
1414 );
1415 }
1416
1417 // Apply multi-input values (INPA..INPL -> A..L).
1418 //
1419 // Uses `put_field_internal`, not `put_field`: this is the
1420 // framework writing a resolved input-link value into a
1421 // record field, exactly like the `ReadDbLink` apply
1422 // (`execute_read_db_links` / `execute_process_actions`),
1423 // which already routes through `put_field_internal`. Some
1424 // records map an input link to a normally read-only field
1425 // — e.g. the epid record's `INP -> CVAL` — and `put_field`
1426 // rejects those with `ReadOnlyField`, silently dropping the
1427 // value. `put_field_internal` defaults to `put_field`, so
1428 // records with writable targets (calc/sub `A..L`) are
1429 // unaffected.
1430 for (val_field, value) in &multi_input_values {
1431 if let Some(f) = value.to_f64() {
1432 let _ = instance
1433 .record
1434 .put_field_internal(val_field, EpicsValue::Double(f));
1435 }
1436 }
1437
1438 // The set_resolved_input_links report is deferred until after
1439 // the pre-process ReadDbLink reads below, so the record sees
1440 // ONE per-cycle resolution list covering both fetch paths —
1441 // records reset per-cycle resolution state in that hook, so
1442 // it must not run twice with partial lists.
1443
1444 // Apply sel NVL -> SELN. SELN is DBF_USHORT (selRecord.dbd.pod:295),
1445 // an unsigned 0..65535 index. Carry the native unsigned value so a
1446 // link value in 32768..65535 is not lost to f64->i16 saturation
1447 // before it reaches the field's put.
1448 if let Some(nvl_val) = sel_nvl_value {
1449 if let Some(f) = nvl_val.to_f64() {
1450 let _ = instance
1451 .record
1452 .put_field("SELN", EpicsValue::UShort(f as u16));
1453 }
1454 }
1455
1456 // Device support read (input records only, not output records)
1457 let is_soft = instance.common.dtyp.is_empty() || instance.common.dtyp == "Soft Channel";
1458 let is_output = instance.record.can_device_write();
1459 let mut device_actions: Vec<crate::server::record::ProcessAction> = Vec::new();
1460 // C `devAiSoft.c:65` `read_ai` (and the other soft-channel
1461 // input `read_xxx`) ALWAYS returns 2 ("don't convert") for a
1462 // Soft-Channel input record — whether the value arrived via
1463 // an INP link or the INP link is constant/unset
1464 // (`dbLinkIsConstant` → `return 2`). Only `aiRecord.c:158`'s
1465 // `if (status==0) convert(prec)` runs RVAL→VAL conversion, so
1466 // for a plain Soft-Channel input record `convert()` must be
1467 // skipped unconditionally. Without this, a soft ai with no
1468 // INP would run `convert()` and clobber a preset VAL — e.g.
1469 // a preset NaN would be rewritten to 0.0, then the framework
1470 // UDF check (`value_is_undefined()`) would see a defined 0.0
1471 // and wrongly clear UDF. `is_raw_soft`
1472 // (Raw Soft Channel, `devAiSoftRaw` returns 0) is excluded —
1473 // it deliberately wants the RVAL→VAL convert.
1474 //
1475 // Gated on `soft_channel_skips_convert()` so this only
1476 // suppresses an `RVAL → VAL` convert step. Records such as
1477 // `epid` also override `set_device_did_compute` but treat it
1478 // as "skip the whole built-in compute" (the PID loop); they
1479 // return `false` here so a Soft-Channel `epid` still runs
1480 // `do_pid()` in `process()`.
1481 let soft_input_skips_convert = is_soft
1482 && !is_output
1483 && !is_raw_soft
1484 && instance.record.soft_channel_skips_convert();
1485 let mut device_did_compute = (soft_inp_applied && is_soft) || soft_input_skips_convert;
1486 if !is_soft && !is_output {
1487 if let Some(mut dev) = instance.device.take() {
1488 // Push framework-owned common state (PHAS/TSE/TSEL/
1489 // UDF) so device support's read() can see it — C
1490 // device support reads `dbCommon` directly
1491 // (`devTimeOfDay.c:122` uses `psi->phas`).
1492 dev.set_process_context(&instance.common.process_context());
1493 match dev.read(&mut *instance.record) {
1494 Ok(read_outcome) => {
1495 device_did_compute = read_outcome.did_compute;
1496 device_actions = read_outcome.actions;
1497 }
1498 Err(e) => {
1499 eprintln!("device read error on {}: {e}", instance.name);
1500 use crate::server::recgbl::{alarm_status, rec_gbl_set_sevr};
1501 rec_gbl_set_sevr(
1502 &mut instance.common,
1503 alarm_status::READ_ALARM,
1504 crate::server::record::AlarmSeverity::Invalid,
1505 );
1506 }
1507 }
1508 instance.device = Some(dev);
1509 }
1510 }
1511
1512 // Pre-process actions: execute ReadDbLink from device support and
1513 // record's pre_process_actions() BEFORE process() so the values
1514 // are immediately available. Matches C dbGetLink() semantics.
1515 let mut pre_actions = instance.record.pre_process_actions();
1516 // Also collect ReadDbLink from device actions
1517 let mut deferred_device_actions = Vec::new();
1518 for action in device_actions {
1519 if matches!(
1520 action,
1521 crate::server::record::ProcessAction::ReadDbLink { .. }
1522 ) {
1523 pre_actions.push(action);
1524 } else {
1525 deferred_device_actions.push(action);
1526 }
1527 }
1528 if !pre_actions.is_empty() {
1529 let rec_name = instance.name.clone();
1530 drop(instance);
1531 let pre_resolved = self
1532 .execute_read_db_links(&rec_name, &rec, &pre_actions, visited, depth)
1533 .await;
1534 instance = rec.write().await;
1535 resolved_link_fields.extend(pre_resolved);
1536 }
1537
1538 // Tell the record which input link fields actually resolved
1539 // a value this cycle — the union of the multi-input fetch and
1540 // the pre-process ReadDbLink reads; the framework analogue of
1541 // C device support inspecting `RTN_SUCCESS(dbGetLink(...))`
1542 // (`epidRecord.c:191-193`, `motorRecord.cc:3687-3698`).
1543 instance
1544 .record
1545 .set_resolved_input_links(&resolved_link_fields);
1546
1547 // Note: C EPICS LCNT prevents reentrant processing of the same
1548 // record within a single processing chain. In Rust, this is handled
1549 // by the `visited` HashSet (cycle detection) and the `processing`
1550 // AtomicBool guard. LCNT is not needed as a separate mechanism
1551 // because async processing with visited sets already prevents
1552 // the runaway loops that LCNT guards against in C.
1553
1554 // Tell the record whether device support already computed.
1555 // Records that override set_device_did_compute() use this to
1556 // skip their built-in computation (e.g., ai skips RVAL->VAL).
1557 // Note: field_io.rs may have already called set_device_did_compute(true)
1558 // for CA puts to VAL. We only set true here, never reset to false.
1559 if device_did_compute {
1560 instance.record.set_device_did_compute(true);
1561 }
1562
1563 // TPRO: trace processing (C EPICS dbProcess prints context when TPRO>0)
1564 if instance.common.tpro {
1565 eprintln!(
1566 "[TPRO] {}: process (SCAN={:?}, PACT={})",
1567 instance.name,
1568 instance.common.scan,
1569 instance
1570 .processing
1571 .load(std::sync::atomic::Ordering::Relaxed)
1572 );
1573 }
1574
1575 // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so
1576 // the record's process() can see it — C records read
1577 // `dbCommon` directly (`epidRecord.c:195` checks
1578 // `pepid->udf`, `timestampRecord.c:90` checks `tse`).
1579 {
1580 let ctx = instance.common.process_context();
1581 instance.record.set_process_context(&ctx);
1582 }
1583
1584 // Process
1585 let mut outcome = instance.record.process()?;
1586 // Merge deferred device actions into process outcome actions
1587 outcome.actions.extend(deferred_device_actions);
1588 let process_result = outcome.result;
1589 let process_actions = outcome.actions;
1590
1591 if process_result == crate::server::record::RecordProcessResult::AsyncPending {
1592 // C `dbProcess` contract: when device support / record body
1593 // signals "async pending", `pact` MUST be true so subsequent
1594 // dbProcess attempts on the same record bail at the entry
1595 // guard. Previous Rust port assumed `process_local` had
1596 // already set it via the swap-true at function entry, but
1597 // this main path bypasses `process_local` and calls
1598 // `record.process()` directly — leaving `processing=false`.
1599 // Mirrors `aiRecord.c:122` and similar: `prec->pact = TRUE;
1600 // return 0;` before async work.
1601 instance
1602 .processing
1603 .store(true, std::sync::atomic::Ordering::Release);
1604
1605 // PACT stays set; skip alarm/timestamp/snapshot/OUT/FLNK.
1606 // But still execute any actions (e.g., ReprocessAfter for delayed re-entry).
1607 let rec_name = instance.name.clone();
1608 drop(instance);
1609 self.execute_process_actions(&rec_name, &rec, process_actions, visited, depth)
1610 .await;
1611 return Ok(());
1612 }
1613 if let crate::server::record::RecordProcessResult::AsyncPendingNotify(fields) =
1614 process_result
1615 {
1616 // Intermediate notification (e.g. DMOV=0 at move start).
1617 // Execute device write first so the move command reaches the
1618 // driver, then fire the record's link writes, then flush
1619 // DMOV=0 etc. to monitors. This mirrors the C ordering on an
1620 // async (pact=1) pass: `motorRecord.cc:1491` runs `do_work`
1621 // (the device move), `motorRecord.cc:1495` then fires
1622 // `dbPutLink(&pmr->rlnk, ...)` UNCONDITIONALLY — on every pass
1623 // including the move-start pass where DMOV just went 0 — and
1624 // only `motorRecord.cc:1507` afterwards calls `monitor()`. So
1625 // the requested `WriteDbLink`/`WriteDbLinkNotify` actions must
1626 // run on the pending cycle as well; a put processes a PP target
1627 // even when the value is unchanged, so dropping them changes
1628 // downstream process counts (motor RLNK, asyn async writes).
1629 // The forward link stays deferred: C runs `recGblFwdLink` only
1630 // when `pmr->dmov != 0` (motorRecord.cc:1509), i.e. on async
1631 // completion, not on this pending pass.
1632 if !is_soft {
1633 if let Some(mut dev) = instance.device.take() {
1634 let _ = dev.write(&mut *instance.record);
1635 instance.device = Some(dev);
1636 }
1637 }
1638 apply_timestamp(&mut instance.common, is_soft);
1639 // Filter out fields that haven't changed, update MLST/last_posted.
1640 // Each intermediate post carries DBE_VALUE|DBE_LOG — C motor's
1641 // mid-move `db_post_events` calls use `DBE_VAL_LOG`
1642 // (motorRecord.cc:2606 DMOV, and every other do_work post);
1643 // no alarm transition ran on this pending pass, so no
1644 // DBE_ALARM bit.
1645 let mut changed_fields = Vec::new();
1646 for (name, val) in fields {
1647 let changed = match instance.last_posted.get(&name) {
1648 Some(prev) => prev != &val,
1649 None => true,
1650 };
1651 if changed {
1652 if name == "VAL" {
1653 if let Some(f) = val.to_f64() {
1654 instance.put_coerced("MLST", f);
1655 instance.common.mlst = Some(f);
1656 }
1657 }
1658 instance.last_posted.insert(name.clone(), val.clone());
1659 changed_fields.push((
1660 name,
1661 val,
1662 crate::server::recgbl::EventMask::VALUE
1663 | crate::server::recgbl::EventMask::LOG,
1664 ));
1665 }
1666 }
1667 let snapshot = crate::server::record::ProcessSnapshot { changed_fields };
1668 let rec_name = instance.name.clone();
1669 let rec_clone = rec.clone();
1670 drop(instance);
1671 // Partition exactly as the synchronous Complete path: link
1672 // writes fire here (C `dbPutLink` precedes `monitor()`);
1673 // delayed-reprocess / device-command actions run after the
1674 // notify (the Complete path runs them after the FLNK tail,
1675 // which is deferred to async completion on this pending pass).
1676 let (link_writes, deferred_actions): (Vec<_>, Vec<_>) =
1677 process_actions.into_iter().partition(|a| {
1678 matches!(
1679 a,
1680 crate::server::record::ProcessAction::WriteDbLink { .. }
1681 | crate::server::record::ProcessAction::WriteDbLinkNotify { .. }
1682 )
1683 });
1684 self.execute_process_actions(&rec_name, &rec, link_writes, visited, depth)
1685 .await;
1686 {
1687 let inst = rec_clone.read().await;
1688 inst.notify_from_snapshot(&snapshot);
1689 }
1690 self.execute_process_actions(&rec_name, &rec, deferred_actions, visited, depth)
1691 .await;
1692 return Ok(());
1693 }
1694
1695 // Async-completion PACT clear for the `ReprocessAfter`
1696 // continuation path. C parity `dbAccess.c:583` —
1697 // `prset->process(precord)` for a record whose first cycle
1698 // returned async-pending is the *completion* re-entry; the
1699 // record support clears `pact` itself inside `process()`
1700 // (e.g. `aiRecord.c` second pass sets `prec->pact = FALSE`).
1701 //
1702 // A record that returns `AsyncPending` AND emits a
1703 // `ProcessAction::ReprocessAfter` is re-entered here via
1704 // `process_record_continuation` (`is_continuation == true`,
1705 // PACT entry guard skipped). Reaching this point means the
1706 // continuation's `process()` did NOT return async-pending
1707 // again (both async branches above return early), so the
1708 // async cycle is genuinely complete. The non-continuation
1709 // async-device path clears `processing` in
1710 // `complete_async_record_inner`; the continuation path has
1711 // no such callback, so without this clear `processing`
1712 // stays `true` forever — every later foreign
1713 // `process_record_with_links` then trips the PACT entry
1714 // guard, counts to MAX_LOCK, and raises a spurious
1715 // SCAN_ALARM. Clearing here (record still write-locked,
1716 // before the OUT/FLNK tail) mirrors the C ordering where
1717 // `pact` is already `FALSE` when `recGblFwdLink` runs.
1718 if is_continuation {
1719 instance
1720 .processing
1721 .store(false, std::sync::atomic::Ordering::Release);
1722 }
1723
1724 // MS-class alarm propagation from input links. Mirrors C
1725 // `recGblInheritSevrMsg` (recGbl.c::260):
1726 //
1727 // * NMS — do nothing.
1728 // * MS — DEST gets `LINK_ALARM` (NOT the source stat),
1729 // max-raised sevr, NO amsg propagation.
1730 // * MSI — same as MS, but only when source.sevr == INVALID.
1731 // * MSS — DEST gets source stat, max-raised sevr, source amsg
1732 // (PR d0cf47c is the only branch that propagates msg).
1733 //
1734 // Previous version treated Maximize and MaximizeStatus
1735 // identically, propagating source stat + amsg through both
1736 // — that matches MSS but is wrong for MS (and MSI), which
1737 // C says should always surface as LINK_ALARM with no msg.
1738 // The per-mode switch is shared with the DB OUT-link write
1739 // path via `inherit_sevr_msg` so the two sides cannot drift.
1740 for (ms, alarm) in &link_alarms {
1741 super::links::inherit_sevr_msg(&mut instance.common, *ms, alarm);
1742 }
1743
1744 // UDF update — C parity (aiRecord.c:285, calcRecord.c
1745 // checkAlarms, int64inRecord.c:144): clear UDF only when
1746 // this cycle produced a *defined* value. A NaN computed
1747 // value (calc divide-by-zero) or a failed link read that
1748 // left VAL un-updated must keep UDF true so the following
1749 // `recGblCheckUDF` raises UDF_ALARM at severity UDFS.
1750 //
1751 // This MUST run before `evaluate_alarms()` (which calls
1752 // `rec_gbl_check_udf`): C records set `prec->udf` inside
1753 // `process()` before `checkAlarms()` runs.
1754 if instance.record.clears_udf() {
1755 instance.common.udf = instance.record.value_is_undefined();
1756 }
1757
1758 // Per-record alarm hook — record-type-specific STATE / COS
1759 // / limit / SOFT alarms (C `checkAlarms()`). Records that
1760 // have migrated their alarm logic here raise into
1761 // `nsta`/`nsev`; the rest fall back to the framework's
1762 // centralised `evaluate_alarms` match below.
1763 {
1764 let inst = &mut *instance;
1765 inst.record.check_alarms(&mut inst.common);
1766 }
1767
1768 // Evaluate alarms (accumulates into nsta/nsev)
1769 instance.evaluate_alarms();
1770
1771 // Device support alarm/timestamp override
1772 if !is_soft {
1773 let (dev_alarm, dev_ts, dev_utag) = if let Some(ref dev) = instance.device {
1774 (dev.last_alarm(), dev.last_timestamp(), dev.last_utag())
1775 } else {
1776 (None, None, None)
1777 };
1778 if let Some((stat, sevr)) = dev_alarm {
1779 use crate::server::recgbl::rec_gbl_set_sevr;
1780 rec_gbl_set_sevr(
1781 &mut instance.common,
1782 stat,
1783 crate::server::record::AlarmSeverity::from_u16(sevr),
1784 );
1785 }
1786 if let Some(ts) = dev_ts {
1787 instance.common.time = ts;
1788 }
1789 // C device support writes `prec->utag` directly during
1790 // `read()` — the event-system pulse-id path, since
1791 // `epicsTimeStamp` carries no tag. Adopt the device's
1792 // userTag when it supplies one; read in the same `dev`
1793 // borrow as the timestamp above so the time/tag pair is a
1794 // single consistent device snapshot.
1795 if let Some(utag) = dev_utag {
1796 instance.common.utag = utag;
1797 }
1798 }
1799
1800 // pvalink `time=true` adopts the latched upstream timestamp
1801 // into the owning record. `external_link_time` returned
1802 // `None` unless the lset signalled the option, so a `Some`
1803 // here is the operator-requested remote timestamp: the remote
1804 // NT `timeStamp` while connected, or the disconnect-event time
1805 // while the subscription is down (pvxs `snap_time = e.time`,
1806 // adopted on the invalid read — `pvalink_lset.cpp:268-270`).
1807 // Apply BEFORE `apply_timestamp` so the upstream value
1808 // survives the soft-channel TSE=0 default (`apply_timestamp`
1809 // would otherwise stamp wall-clock-now on top).
1810 if let Some((secs, ns, utag)) = inp_link_remote_time {
1811 let secs = secs.max(0) as u64;
1812 let ns = ns.max(0) as u32;
1813 instance.common.time =
1814 std::time::UNIX_EPOCH + std::time::Duration::new(secs, ns.min(999_999_999));
1815 // adopt the upstream `timeStamp.userTag` alongside the
1816 // time, mirroring pvxs PR-added `precord->utag = snap_tag`
1817 // next to `precord->time = snap_time` in the `time=true`
1818 // branch. The tag is already widened without sign
1819 // extension by the lset; `0` when the source carries
1820 // none. `apply_timestamp` never touches `utag`, so this
1821 // survives regardless of the TSE branch below.
1822 instance.common.utag = utag;
1823 // TSE=-2 marks "device-set time" — `apply_timestamp`
1824 // honours this by leaving `common.time` untouched,
1825 // mirroring the device-support timestamp branch above.
1826 instance.common.tse = -2;
1827 }
1828
1829 // Transfer nsta/nsev -> sevr/stat, detect alarm change
1830 let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
1831
1832 // Apply timestamp based on TSE
1833 apply_timestamp(&mut instance.common, is_soft);
1834 // NOTE: UDF was already updated before `evaluate_alarms`
1835 // above — keyed on `value_is_undefined()` so a NaN result
1836 // keeps UDF true and UDF_ALARM is raised this cycle. Do
1837 // NOT clear UDF unconditionally here.
1838
1839 // IVOA check for output records with INVALID alarm
1840 let skip_out = if instance.common.sevr == crate::server::record::AlarmSeverity::Invalid
1841 {
1842 let ivoa = instance
1843 .record
1844 .get_field("IVOA")
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 match ivoa {
1854 1 => true, // Don't drive outputs
1855 2 => {
1856 // Set output to IVOV. Each record type knows
1857 // which field its OUT writeback consumes — see
1858 // [`Record::apply_invalid_output_value`]. The
1859 // earlier path special-cased `calcout`
1860 // (OVAL) and fell back to `set_val` (VAL) for
1861 // every other record. That hid a real bug:
1862 // ao/lso/bo/mbbo/busy left their OVAL/RVAL
1863 // staging field stale, so the OUT writeback —
1864 // which reads `OVAL.or(VAL)` — sent the
1865 // pre-IVOA value to the linked record. Per-type
1866 // overrides now apply IVOV to the field that
1867 // matches the C convention.
1868 if let Some(ivov) = instance.record.get_field("IVOV") {
1869 let _ = instance.record.apply_invalid_output_value(ivov);
1870 }
1871 false
1872 }
1873 _ => false, // Continue normally
1874 }
1875 } else {
1876 false
1877 };
1878
1879 // OUT stage: soft channel -> link put, non-soft -> device.write()
1880 // Must run BEFORE check_deadband_ext so MLST is not prematurely
1881 // updated for async writes that return early.
1882 let can_dev_write = instance.record.can_device_write();
1883 let is_soft_out =
1884 instance.common.dtyp.is_empty() || instance.common.dtyp == "Soft Channel";
1885 let record_should_output = instance.record.should_output();
1886 let out_info = if skip_out {
1887 None
1888 } else if !can_dev_write {
1889 // Non-output records (calcout, etc.) may still have a
1890 // soft OUT link (DB or external ca://`/`pva://`).
1891 // Write OVAL to OUT when the record says should_output().
1892 if record_should_output && instance.parsed_out.is_writable_out_link() {
1893 let oval = instance.record.get_field("OVAL");
1894 let val = instance.record.val();
1895 let out_val = oval.or(val);
1896 out_val.map(|v| (instance.parsed_out.clone(), v))
1897 } else {
1898 None
1899 }
1900 } else if is_soft_out {
1901 if !record_should_output {
1902 // epics-base 7.0.8 OOPT: gate the soft OUT-link
1903 // write on the record's `should_output()`. For
1904 // longout/calcout with OOPT != 0 this lets a
1905 // condition-not-met cycle silently skip the link
1906 // write without disturbing alarms / monitors.
1907 None
1908 } else if instance.parsed_out.is_writable_out_link() {
1909 let out_val = instance
1910 .record
1911 .get_field("OVAL")
1912 .or_else(|| instance.record.val());
1913 out_val.map(|v| (instance.parsed_out.clone(), v))
1914 } else {
1915 None
1916 }
1917 } else if !record_should_output {
1918 // OOPT gating for hardware outputs (longout DTYP=...).
1919 // Skip the device write when the OOPT predicate is
1920 // not satisfied; the record's val/timestamp/snapshot
1921 // path still runs so monitor consumers see the value
1922 // change even on a non-output cycle.
1923 None
1924 } else {
1925 if let Some(mut dev) = instance.device.take() {
1926 // Try async write_begin() first
1927 match dev.write_begin(&mut *instance.record) {
1928 Ok(Some(completion)) => {
1929 // Async write submitted -- set PACT, return early.
1930 // complete_async_record will handle deadband, snapshot,
1931 // notification, and FLNK when the write completes.
1932 instance
1933 .processing
1934 .store(true, std::sync::atomic::Ordering::Release);
1935 instance.device = Some(dev);
1936 let rec_name = instance.name.clone();
1937 let timeout = std::time::Duration::from_secs(5);
1938 let db = self.clone();
1939 tokio::spawn(async move {
1940 let _ =
1941 tokio::task::spawn_blocking(move || completion.wait(timeout))
1942 .await;
1943 let _ = db.complete_async_record(&rec_name).await;
1944 });
1945 return Ok(());
1946 }
1947 Ok(None) => {
1948 // No async support -- fall back to synchronous write
1949 if let Err(e) = dev.write(&mut *instance.record) {
1950 eprintln!("device write error on {}: {e}", instance.name);
1951 instance.common.stat =
1952 crate::server::recgbl::alarm_status::WRITE_ALARM;
1953 instance.common.sevr =
1954 crate::server::record::AlarmSeverity::Invalid;
1955 } else {
1956 // OOPT 7.0.8: notify the record so it can
1957 // latch transition state (e.g. longout.pval)
1958 // for the next cycle.
1959 instance.record.on_output_complete();
1960 }
1961 }
1962 Err(e) => {
1963 eprintln!("device write_begin error on {}: {e}", instance.name);
1964 instance.common.stat = crate::server::recgbl::alarm_status::WRITE_ALARM;
1965 instance.common.sevr = crate::server::record::AlarmSeverity::Invalid;
1966 }
1967 }
1968 instance.device = Some(dev);
1969 }
1970 None
1971 };
1972
1973 // Compute per-field posting masks (after OUT stage so async
1974 // writes don't update MLST/ALST prematurely before returning
1975 // early)
1976 use crate::server::recgbl::EventMask;
1977
1978 let (include_val, include_archive) = match instance.record.monitor_value_changed() {
1979 // lsi/lso post VALUE|LOG only when the string actually
1980 // changed (C `lsiRecord.c`/`lsoRecord.c` monitor: `len !=
1981 // olen || memcmp(oval, val, len)`); they have no MDEL/ADEL
1982 // deadband to express that, so the gate is explicit. The
1983 // MPST/APST `menuPost` "Always" override OR-adds DBE_VALUE /
1984 // DBE_LOG even on an unchanged cycle (C monitor: `if (mpst ==
1985 // menuPost_Always) events |= DBE_VALUE; if (apst ==
1986 // menuPost_Always) events |= DBE_LOG;`).
1987 Some(changed) => {
1988 let (val_always, archive_always) = instance.record.monitor_always_post();
1989 (changed || val_always, changed || archive_always)
1990 }
1991 None => {
1992 if instance.record.uses_monitor_deadband() {
1993 instance.check_deadband_ext()
1994 } else {
1995 // Binary records (bi/bo/busy/mbbi/mbbo): always post monitors
1996 (true, true)
1997 }
1998 }
1999 };
2000 // C `recGblResetAlarms` returns `val_mask = DBE_ALARM`
2001 // (recGbl.c:194/203/212) when the severity/status OR the
2002 // alarm message moved — every monitored-value post this
2003 // cycle carries DBE_ALARM so a `DBE_ALARM`-only subscriber
2004 // sees the value at the moment the alarm changed.
2005 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
2006 EventMask::ALARM
2007 } else {
2008 EventMask::NONE
2009 };
2010
2011 // Build snapshot
2012 let mut changed_fields = Vec::new();
2013 // The deadband-tracked field posts with the classes that
2014 // actually fired: MDEL crossing → DBE_VALUE, ADEL crossing
2015 // → DBE_LOG, alarm movement → DBE_ALARM — and nothing else
2016 // (C `monitor()` per-field masks: motorRecord.cc:3477-3507
2017 // RBV, aiRecord.c VAL). For most records the tracked field
2018 // IS the primary value; a record like motor deadbands its
2019 // readback, and its VAL routes through the generic
2020 // change-detection loop below — an unchanged setpoint is
2021 // not re-posted on every readback poll.
2022 let deadband_field = instance.record.monitor_deadband_field();
2023 let deadband_mask = {
2024 let mut m = alarm_bits;
2025 if include_val {
2026 m |= EventMask::VALUE;
2027 }
2028 if include_archive {
2029 m |= EventMask::LOG;
2030 }
2031 m
2032 };
2033 if !deadband_mask.is_empty() {
2034 let dval = if deadband_field == "VAL" {
2035 instance.record.val()
2036 } else {
2037 instance.resolve_field(deadband_field)
2038 };
2039 if let Some(val) = dval {
2040 changed_fields.push((deadband_field.to_string(), val, deadband_mask));
2041 }
2042 }
2043 // Add subscribed fields that actually changed since last
2044 // notification. The deadband-gated field is excluded — it is
2045 // delivered by the trigger branch above, never by raw
2046 // change-detection (for the default `deadband_field ==
2047 // "VAL"` this is the same VAL exclusion as before). Each
2048 // carries DBE_VALUE|DBE_LOG plus the cycle's alarm bits —
2049 // the C convention for change-detected auxiliary posts
2050 // (`monitor_mask | DBE_VALUE | DBE_LOG`, calcRecord.c:420,
2051 // subRecord.c:400; motor `DBE_VAL_LOG` for marked fields,
2052 // motorRecord.cc:3522-3645).
2053 //
2054 // On a cycle whose alarm transition fired, fields named by
2055 // `alarm_cycle_monitored_fields` post even when unchanged,
2056 // with the alarm bits alone — C motor `monitor()`
2057 // (motorRecord.cc:3513-3645) posts every listed field once
2058 // `monitor_mask != 0`, so a `DBE_ALARM`-only subscriber
2059 // observes the alarm moment on any of them.
2060 let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
2061 let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
2062 &[]
2063 } else {
2064 instance.record.alarm_cycle_monitored_fields()
2065 };
2066 let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
2067 for (field, subs) in &instance.subscribers {
2068 if !subs.is_empty()
2069 && field != deadband_field
2070 && field != "SEVR"
2071 && field != "STAT"
2072 && field != "AMSG"
2073 && field != "UDF"
2074 {
2075 if let Some(val) = instance.resolve_field(field) {
2076 let changed = match instance.last_posted.get(field) {
2077 Some(prev) => prev != &val,
2078 None => true,
2079 };
2080 if changed {
2081 sub_updates.push((field.clone(), val, aux_mask));
2082 } else if alarm_fanout.contains(&field.as_str()) {
2083 sub_updates.push((field.clone(), val, alarm_bits));
2084 }
2085 }
2086 }
2087 }
2088 if !sub_updates.is_empty() {
2089 for (field, val, _) in &sub_updates {
2090 instance.last_posted.insert(field.clone(), val.clone());
2091 }
2092 changed_fields.extend(sub_updates);
2093 }
2094 // C `recGblResetAlarms` (recGbl.c:201-220) posts each
2095 // alarm field with its own per-field mask:
2096 // * SEVR — DBE_VALUE, ONLY when `prev_sevr != new_sevr`.
2097 // * STAT/AMSG — `stat_mask` = DBE_ALARM (on sevr- or
2098 // amsg-change) | DBE_VALUE (on stat-change).
2099 // * ACKS — DBE_VALUE when `stat_mask != 0`.
2100 // The pre-fix port pushed SEVR + STAT together on any
2101 // `alarm_changed`, over-posting SEVR on a stat-only
2102 // transition and collapsing the per-field mask into one
2103 // record-wide mask. Posting these via `notify_field` with
2104 // their individual masks restores C's granularity.
2105 let sevr_changed = instance.common.sevr != alarm_result.prev_sevr;
2106 let stat_changed = instance.common.stat != alarm_result.prev_stat;
2107 let stat_mask = {
2108 let mut m = EventMask::NONE;
2109 if sevr_changed || alarm_result.amsg_changed {
2110 m |= EventMask::ALARM;
2111 }
2112 if stat_changed {
2113 m |= EventMask::VALUE;
2114 }
2115 m
2116 };
2117 // Defer the SEVR/STAT/AMSG/ACKS posts to dedicated
2118 // `notify_field` calls (collected here, fired after the
2119 // snapshot notify below) so each gets its exact C mask.
2120 let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
2121 if sevr_changed {
2122 alarm_posts.push(("SEVR", EventMask::VALUE));
2123 }
2124 if !stat_mask.is_empty() {
2125 alarm_posts.push(("STAT", stat_mask));
2126 alarm_posts.push(("AMSG", stat_mask));
2127 }
2128 // C parity (recGbl.c:216): ACKS is posted (DBE_VALUE) only
2129 // when `stat_mask != 0` AND recGblResetAlarms raised it.
2130 if alarm_result.acks_changed && !stat_mask.is_empty() {
2131 alarm_posts.push(("ACKS", EventMask::VALUE));
2132 }
2133 // UDF rides along whenever any monitored post fired this
2134 // cycle, carrying the union of the cycle's posted classes.
2135 let cycle_mask = changed_fields
2136 .iter()
2137 .fold(EventMask::NONE, |m, (_, _, fm)| m | *fm);
2138 if !cycle_mask.is_empty() {
2139 changed_fields.push((
2140 "UDF".to_string(),
2141 EpicsValue::Char(if instance.common.udf { 1 } else { 0 }),
2142 cycle_mask,
2143 ));
2144 }
2145 let snapshot = crate::server::record::ProcessSnapshot { changed_fields };
2146
2147 let flnk_name = if instance.record.should_fire_forward_link() {
2148 if let crate::server::record::ParsedLink::Db(ref l) = instance.parsed_flnk {
2149 Some(l.record.clone())
2150 } else {
2151 None
2152 }
2153 } else {
2154 None
2155 };
2156
2157 // Put-notify completion is NOT fired here. Firing before the
2158 // OUT/FLNK/process-action tail (below) would report the
2159 // WRITE_NOTIFY done while the chain it triggers — including
2160 // an async FLNK target — is still running (C `dbNotify.c`
2161 // keeps the originating record in the waitList until the
2162 // chain settles). The originating record instead `leave`s
2163 // the wait-set at the END of this function, after every PP
2164 // target it drives has joined. See `complete_put_notify`
2165 // at the tail.
2166
2167 (snapshot, out_info, flnk_name, process_actions, alarm_posts)
2168 };
2169
2170 // 3. Notify subscribers (outside lock)
2171 {
2172 let instance = rec.read().await;
2173 instance.notify_from_snapshot(&snapshot);
2174 // Post the alarm fields (SEVR/STAT/AMSG/ACKS) with their
2175 // individual C masks — see recGblResetAlarms above.
2176 for &(field, mask) in &alarm_posts {
2177 instance.notify_field(field, mask);
2178 }
2179 }
2180
2181 // Snapshot source PUTF + put-notify wait-set for the C
2182 // `processTarget` / `dbNotifyAdd` invariants (see
2183 // `write_db_link_value` doc). Captured once here so every OUT /
2184 // multi-OUT / FLNK dispatch in this cycle propagates the same
2185 // bit and joins the same wait-set. The committed alarm is
2186 // captured the same way for `recGblInheritSevrMsg` MS-class
2187 // propagation into the OUT-link target.
2188 let (src_putf, src_notify, src_alarm) = {
2189 let guard = rec.read().await;
2190 (
2191 guard.common.putf,
2192 guard.notify.clone(),
2193 super::links::LinkAlarm {
2194 stat: guard.common.stat,
2195 sevr: guard.common.sevr,
2196 amsg: guard.common.amsg.clone(),
2197 },
2198 )
2199 };
2200
2201 // 4. OUT link — DB *or* external `ca://`/`pva://`. C
2202 // `dbLink.c::dbPutLink` (dbLink.c:434-448) routes every link
2203 // write through the link set's `putValue`, so the OUTPUT side
2204 // dispatches by scheme exactly as the INPUT side does (B
2205 // `resolve_external_pv`). An external link with no registered
2206 // lset fails gracefully inside `write_out_link_value`.
2207 if let Some((ref link, ref out_val)) = out_info {
2208 self.write_out_link_value(
2209 link,
2210 out_val.clone(),
2211 super::links::OutLinkSrc {
2212 putf: src_putf,
2213 notify: src_notify.as_ref(),
2214 alarm: &src_alarm,
2215 },
2216 visited,
2217 depth,
2218 )
2219 .await;
2220 // OOPT 7.0.8: latch the record's post-output state so the
2221 // next cycle's `should_output` sees the right pval.
2222 {
2223 let mut instance = rec.write().await;
2224 instance.record.on_output_complete();
2225 }
2226 }
2227
2228 // 7b. C record support performs a record's OUT/link writes BEFORE
2229 // its forward link: `transformRecord` calls `dbPutLink()`
2230 // (transformRecord.c:608-619) before `monitor()` +
2231 // `recGblFwdLink()`, `scalerRecord` writes COUT/COUTP
2232 // (scalerRecord.c:457-480) before its FLNK block, `throttleRecord`
2233 // writes the selected OUT link (throttleRecord.c:562-580) before
2234 // `recGblFwdLink()`, and `tableRecord` drives speed/drive links
2235 // (tableRecord.c:573-597) before its final FLNK. The
2236 // `ProcessAction::WriteDbLink` contract is documented as "before
2237 // FLNK", so split the requested actions: link writes run now;
2238 // delayed/reprocess and device-command actions (whose timing must
2239 // stay after the FLNK tail) run afterward. A downstream FLNK
2240 // target therefore reads the freshly written value, matching C.
2241 let (link_writes, deferred_actions): (Vec<_>, Vec<_>) =
2242 process_actions.into_iter().partition(|a| {
2243 matches!(
2244 a,
2245 crate::server::record::ProcessAction::WriteDbLink { .. }
2246 | crate::server::record::ProcessAction::WriteDbLinkNotify { .. }
2247 )
2248 });
2249 self.execute_process_actions(name, &rec, link_writes, visited, depth)
2250 .await;
2251
2252 // 4.5 - 7. Multi-output / event / generic-multi-out / FLNK /
2253 // CP / RPRO tail. Shared with the simulation-mode path so a
2254 // simulated record runs the exact same `recGblFwdLink`
2255 // equivalent (C `aiRecord.c:168`).
2256 self.run_forward_link_tail_with_putf(
2257 name,
2258 &rec,
2259 flnk_name.as_deref(),
2260 PutNotifyCtx {
2261 putf: src_putf,
2262 notify: src_notify.as_ref(),
2263 },
2264 visited,
2265 depth,
2266 )
2267 .await;
2268
2269 // 8. Execute the deferred ProcessActions after the FLNK tail:
2270 // `ReprocessAfter` schedules a later reprocess (the current
2271 // cycle's FLNK must proceed first) and `DeviceCommand` posts its
2272 // own monitors after this cycle's snapshot.
2273 self.execute_process_actions(name, &rec, deferred_actions, visited, depth)
2274 .await;
2275
2276 // 9. C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` at the
2277 // tail of every synchronous process cycle, NOT just on the
2278 // foreign-entry path. When this record was driven through an
2279 // OUT-link propagation (write_db_link_value set our putf), the
2280 // target record's own process cycle must clear it before
2281 // returning — same lifecycle as the source record's PUTF
2282 // (which `put_record_field_from_ca` separately clears at the
2283 // foreign-entry boundary, and the async branch clears in
2284 // `complete_async_record_inner`). Async-pending records skip
2285 // this clear: their FLNK / putf-clear happens later in
2286 // `complete_async_record_inner` once the device round-trip
2287 // completes.
2288 {
2289 let guard = rec.read().await;
2290 if !guard.is_processing() {
2291 drop(guard);
2292 let mut guard = rec.write().await;
2293 guard.common.putf = false;
2294 }
2295 }
2296
2297 // Put-notify completion: the record `leave`s the wait-set only
2298 // here, after its full OUT/FLNK/process-action tail has run — so
2299 // every PP target it drove has already joined (`enter`ed). Gated
2300 // on `is_put_complete`: a record reporting more work (e.g. motor
2301 // mid-move via `is_put_complete()==false`) keeps its membership
2302 // and leaves on the later cycle that completes the put — matching
2303 // the old fire site's gate. An async-pending record returned
2304 // earlier and is handled in `complete_async_record_inner`. The
2305 // completion oneshot fires on the `leave` that empties the set.
2306 {
2307 let mut guard = rec.write().await;
2308 if guard.record.is_put_complete() {
2309 complete_put_notify(&mut guard);
2310 }
2311 }
2312
2313 Ok(())
2314 }
2315
2316 /// Forward-link / CP / RPRO tail for the simulation-mode path.
2317 ///
2318 /// C `aiRecord.c:151-168`: a record in SIMM mode handles the value
2319 /// inside `readValue()`, then `process()` still runs `monitor` +
2320 /// `recGblFwdLink(prec)`. The simulation path in
2321 /// `process_record_with_links_inner` does its own monitor posting,
2322 /// so this drives the forward-link / CP / RPRO tail that
2323 /// `recGblFwdLink` would. `flnk_name` and `src_putf` are derived
2324 /// fresh from the record (a simulated cycle does not change FLNK,
2325 /// and SIOL reads/writes do not carry a foreign PUTF into the
2326 /// chain).
2327 async fn run_forward_link_tail(
2328 &self,
2329 name: &str,
2330 rec: &Arc<RwLock<RecordInstance>>,
2331 visited: &mut std::collections::HashSet<String>,
2332 depth: usize,
2333 ) {
2334 let (flnk_name, src_putf, src_notify) = {
2335 let instance = rec.read().await;
2336 let flnk = if instance.record.should_fire_forward_link() {
2337 if let crate::server::record::ParsedLink::Db(ref l) = instance.parsed_flnk {
2338 Some(l.record.clone())
2339 } else {
2340 None
2341 }
2342 } else {
2343 None
2344 };
2345 (flnk, instance.common.putf, instance.notify.clone())
2346 };
2347 self.run_forward_link_tail_with_putf(
2348 name,
2349 rec,
2350 flnk_name.as_deref(),
2351 PutNotifyCtx {
2352 putf: src_putf,
2353 notify: src_notify.as_ref(),
2354 },
2355 visited,
2356 depth,
2357 )
2358 .await;
2359 }
2360
2361 /// Steps 4.5 - 7 of the process chain: multi-output dispatch,
2362 /// event-record posting, generic OUTA..OUTP links, FLNK forward
2363 /// link, CP-target dispatch, and RPRO reprocess. Shared by the
2364 /// main process path and the simulation-mode path so both run the
2365 /// identical `recGblFwdLink` equivalent.
2366 async fn run_forward_link_tail_with_putf(
2367 &self,
2368 name: &str,
2369 rec: &Arc<RwLock<RecordInstance>>,
2370 flnk_name: Option<&str>,
2371 src: PutNotifyCtx<'_>,
2372 visited: &mut std::collections::HashSet<String>,
2373 depth: usize,
2374 ) {
2375 // 4.5. Multi-output dispatch (fanout/dfanout/seq)
2376 self.dispatch_multi_output(rec, visited, depth).await;
2377
2378 // 4.55. event record: post the named software event.
2379 self.dispatch_event_record(rec).await;
2380
2381 // 4.6. Generic multi-output links (transform OUTA..OUTP -> A..P,
2382 // scalcout OUT->OVAL, epid OUTL).
2383 //
2384 // SINGLE-OWNER INVARIANT: a record type whose link groups are
2385 // dispatched by `dispatch_multi_output` (§4.5 above) MUST be
2386 // skipped here — otherwise its `LNKn`/`OUTn` would be written
2387 // twice per cycle. `sseq` previously also implemented the
2388 // `Record::multi_output_links` trait method, so this block
2389 // re-dispatched every selected `LNKn` after §4.5 already drove
2390 // it. The `multi_output_dispatch_owned` gate makes the
2391 // double-dispatch structurally impossible — not just removed
2392 // at the `SseqRecord` call site.
2393 {
2394 let multi_out = {
2395 let instance = rec.read().await;
2396 let links =
2397 if super::links::multi_output_dispatch_owned(instance.record.record_type()) {
2398 &[][..]
2399 } else {
2400 instance.record.multi_output_links()
2401 };
2402 if links.is_empty() {
2403 None
2404 } else {
2405 let mut pairs = Vec::new();
2406 for &(link_field, val_field) in links {
2407 let link_str = instance
2408 .record
2409 .get_field(link_field)
2410 .and_then(|v| {
2411 if let EpicsValue::String(s) = v {
2412 Some(s)
2413 } else {
2414 None
2415 }
2416 })
2417 .unwrap_or_default();
2418 if link_str.is_empty() {
2419 continue;
2420 }
2421 if let Some(val) = instance.record.get_field(val_field) {
2422 pairs.push((link_str, val));
2423 }
2424 }
2425 if pairs.is_empty() { None } else { Some(pairs) }
2426 }
2427 };
2428 if let Some(pairs) = multi_out {
2429 // Source committed alarm for `recGblInheritSevrMsg`
2430 // MS-class propagation into each OUT-link target —
2431 // captured once, same lifecycle as `src.putf`.
2432 let src_alarm = {
2433 let guard = rec.read().await;
2434 super::links::LinkAlarm {
2435 stat: guard.common.stat,
2436 sevr: guard.common.sevr,
2437 amsg: guard.common.amsg.clone(),
2438 }
2439 };
2440 for (link_str, val) in pairs {
2441 // `multi_output_links` carries record OUT links
2442 // (sseq `LNKn`, scalcout `OUTn` — all `DBF_OUTLINK`)
2443 // driven via `dbPutLink` → `dbDbPutValue`
2444 // (`dbDbLink.c:388`): a bare DB link is NPP, the
2445 // value is written but the target is NOT processed.
2446 // `parse_output_link_v2` applies the
2447 // OUT-link-correct NPP default; `parse_link_v2` would
2448 // wrongly default a bare link to ProcessPassive and
2449 // re-process the target. An external `ca://`/`pva://`
2450 // OUT link is routed through the link set's
2451 // `putValue` (C `dbLink.c::dbPutLink`,
2452 // dbLink.c:434-448).
2453 let parsed = crate::server::record::parse_output_link_v2(
2454 link_str.as_str_lossy().as_ref(),
2455 );
2456 self.write_out_link_value(
2457 &parsed,
2458 val,
2459 super::links::OutLinkSrc {
2460 putf: src.putf,
2461 notify: src.notify,
2462 alarm: &src_alarm,
2463 },
2464 visited,
2465 depth,
2466 )
2467 .await;
2468 }
2469 }
2470 }
2471
2472 // 5. FLNK -- only process if target is Passive (like C dbScanFwdLink).
2473 // FLNK goes through C `dbScanPassive` -> `processTarget`, which
2474 // propagates `src.putf` to the target the same way OUT links do.
2475 if let Some(flnk) = flnk_name {
2476 if let Some(target_rec) = self.get_record(flnk).await {
2477 let (target_scan, should_process) = {
2478 let mut tg = target_rec.write().await;
2479 let pact = tg.is_processing();
2480 let on_chain = visited.contains(flnk);
2481 let scan = tg.common.scan;
2482 if !pact {
2483 tg.common.putf = src.putf;
2484 // C `dbNotifyAdd` (dbDbLink.c:460) lives inside
2485 // `processTarget`, which `dbScanPassive` reaches
2486 // ONLY for a passive target (it returns early for
2487 // non-passive — dbDbLink.c:431). Gate the join on
2488 // the same passive condition as the process call
2489 // below: a non-passive FLNK target is dropped here
2490 // and must NOT join, or it would `enter` the
2491 // wait-set without ever processing to `leave` it,
2492 // hanging the completion forever.
2493 if scan == crate::server::record::ScanType::Passive {
2494 join_put_notify(&mut tg, src.notify);
2495 }
2496 } else if src.putf && !on_chain {
2497 tg.common.rpro = true;
2498 tg.common.putf = false;
2499 }
2500 (scan, !pact)
2501 };
2502 if should_process && target_scan == crate::server::record::ScanType::Passive {
2503 // recursive FLNK within one chain — gate
2504 // already held by the foreign entry record.
2505 let _ = self
2506 .process_record_with_links_recursive(flnk, visited, depth + 1)
2507 .await;
2508 }
2509 }
2510 }
2511
2512 // 5b. FLNK whose target is external (`pva://`/`ca://`): C
2513 // `dbScanFwdLink` dispatches it through the link set's
2514 // `scanForward` (pvalink `pvaScanForward`), a process-only trigger
2515 // of the remote target. The `flnk_name` above only ever names a
2516 // local DB target, so a non-DB FLNK is forwarded here through the
2517 // single owner.
2518 self.dispatch_external_forward_link(rec).await;
2519
2520 // 6. CP link targets -- process records that have CP input links from this record
2521 self.dispatch_cp_targets(name, visited, depth).await;
2522
2523 // 7. RPRO: if reprocess requested, clear flag and queue a
2524 // fresh process pass.
2525 //
2526 // C `recGblFwdLink` (recGbl.c:296-300) consumes RPRO via
2527 // `scanOnce(pdbc)` — the record is QUEUED on the scanOnce ring
2528 // buffer and reprocessed in a separate pass with a fresh lock
2529 // cycle AFTER the current process chain fully unwinds. It does
2530 // NOT recurse inline within the current link chain.
2531 //
2532 // Spawning a detached task is the Rust equivalent of the
2533 // scanOnce queue: the reprocess runs with a clean (empty)
2534 // `visited` set and starts at depth 0, so it cannot be
2535 // silently skipped by the current chain's cycle guard nor hit
2536 // the MAX_LINK_DEPTH / MAX_LINK_OPS budget the current chain
2537 // has already consumed.
2538 {
2539 let needs_rpro = {
2540 let mut instance = rec.write().await;
2541 if instance.common.rpro {
2542 instance.common.rpro = false;
2543 true
2544 } else {
2545 false
2546 }
2547 };
2548 if needs_rpro {
2549 let db = self.clone();
2550 let rpro_name = name.to_string();
2551 crate::runtime::task::spawn(async move {
2552 let mut fresh_visited = std::collections::HashSet::new();
2553 let _ = db
2554 .process_record_with_links(&rpro_name, &mut fresh_visited, 0)
2555 .await;
2556 });
2557 }
2558 }
2559 }
2560
2561 /// Fire a non-DB (external `pva://`/`ca://`) forward link (FLNK).
2562 ///
2563 /// C `recGblFwdLink` → `dbScanFwdLink` (`dbLink.c:475-480`) dispatches
2564 /// every FLNK uniformly through `plink->lset->scanForward`: a DB lset
2565 /// runs `scanOnce(target)` — handled directly by the local FLNK §5
2566 /// path — while the pvalink/calink lset runs `pvaScanForward`, a
2567 /// process-only trigger of the remote target. The DB-only `flnk_name`
2568 /// filter at the three `should_fire_forward_link` sites dropped every
2569 /// external FLNK; this is the single owner that forwards them, so the
2570 /// dispatch is not open-coded per site (each FLNK tail calls only
2571 /// this).
2572 ///
2573 /// On a non-retry, disconnected link the lset returns `Err`; pvxs
2574 /// raises `recGblSetSevrMsg(LINK_ALARM, INVALID_ALARM, "Disconn")` on
2575 /// the owning record (`pvxs/ioc/pvalink_lset.cpp:677-679`). This raises
2576 /// the same *pending* LINK/INVALID alarm via [`rec_gbl_set_sevr_msg`],
2577 /// promoted by the next `recGblResetAlarms` — exactly as the C late-set
2578 /// inside `recGblFwdLink` (after the record's own alarm/monitor stage)
2579 /// is.
2580 async fn dispatch_external_forward_link(&self, rec: &Arc<RwLock<RecordInstance>>) {
2581 let target = {
2582 let instance = rec.read().await;
2583 if !instance.record.should_fire_forward_link() {
2584 return;
2585 }
2586 match &instance.parsed_flnk {
2587 crate::server::record::ParsedLink::Pva(_)
2588 | crate::server::record::ParsedLink::PvaJson(_)
2589 | crate::server::record::ParsedLink::Ca(_) => instance
2590 .parsed_flnk
2591 .external_pv_name()
2592 .map(|s| s.to_string()),
2593 // A DB FLNK is processed by the local §5 scanOnce path;
2594 // every other kind (Constant/Hw/Calc/None) carries no
2595 // forward action.
2596 _ => None,
2597 }
2598 };
2599 let Some(target) = target else {
2600 return;
2601 };
2602 if let Err(e) = self.scan_forward_external_pv(&target).await {
2603 let _ = e;
2604 let mut instance = rec.write().await;
2605 crate::server::recgbl::rec_gbl_set_sevr_msg(
2606 &mut instance.common,
2607 crate::server::recgbl::alarm_status::LINK_ALARM,
2608 crate::server::record::AlarmSeverity::Invalid,
2609 "Disconn",
2610 );
2611 }
2612 }
2613
2614 /// Execute ReadDbLink actions before process().
2615 /// Reads linked PV values and writes them into record fields via put_field_internal.
2616 /// Returns the `link_field` names whose read produced a value, so the
2617 /// caller can fold them into the per-cycle `set_resolved_input_links`
2618 /// report (C `RTN_SUCCESS(dbGetLink(...))`). An empty link is skipped
2619 /// and NOT reported — it is a CONSTANT link in C, which records must
2620 /// not treat as a failed fetch.
2621 async fn execute_read_db_links(
2622 &self,
2623 _record_name: &str,
2624 rec: &Arc<crate::runtime::sync::RwLock<RecordInstance>>,
2625 actions: &[crate::server::record::ProcessAction],
2626 visited: &mut HashSet<String>,
2627 depth: usize,
2628 ) -> Vec<&'static str> {
2629 use crate::server::record::ProcessAction;
2630 let mut resolved = Vec::new();
2631 for action in actions {
2632 if let ProcessAction::ReadDbLink {
2633 link_field,
2634 target_field,
2635 } = action
2636 {
2637 let link_str = {
2638 let instance = rec.read().await;
2639 instance
2640 .record
2641 .get_field(link_field)
2642 .and_then(|v| {
2643 if let EpicsValue::String(s) = v {
2644 Some(s)
2645 } else {
2646 None
2647 }
2648 })
2649 .unwrap_or_default()
2650 };
2651 if link_str.is_empty() {
2652 continue;
2653 }
2654 let parsed = crate::server::record::parse_link_v2(link_str.as_str_lossy().as_ref());
2655 if let Some(value) = self.read_link_value(&parsed, visited, depth).await {
2656 let mut instance = rec.write().await;
2657 let _ = instance.record.put_field_internal(target_field, value);
2658 resolved.push(*link_field);
2659 }
2660 }
2661 }
2662 resolved
2663 }
2664
2665 /// Execute ProcessActions returned by a record's process() call.
2666 ///
2667 /// Actions are executed in order:
2668 /// - ReadDbLink: reads a linked PV value and writes it into a record field
2669 /// (bypasses read-only checks via put_field_internal)
2670 /// - WriteDbLink: writes a value to a linked PV
2671 /// - ReprocessAfter: schedules a delayed re-process via tokio::spawn
2672 async fn execute_process_actions(
2673 &self,
2674 record_name: &str,
2675 rec: &Arc<crate::runtime::sync::RwLock<RecordInstance>>,
2676 actions: Vec<crate::server::record::ProcessAction>,
2677 visited: &mut HashSet<String>,
2678 depth: usize,
2679 ) {
2680 use crate::server::record::ProcessAction;
2681
2682 for action in actions {
2683 match action {
2684 ProcessAction::ReadDbLink {
2685 link_field,
2686 target_field,
2687 } => {
2688 // 1. Get the link string from the record
2689 let link_str = {
2690 let instance = rec.read().await;
2691 instance
2692 .record
2693 .get_field(link_field)
2694 .and_then(|v| {
2695 if let EpicsValue::String(s) = v {
2696 Some(s)
2697 } else {
2698 None
2699 }
2700 })
2701 .unwrap_or_default()
2702 };
2703 if link_str.is_empty() {
2704 continue;
2705 }
2706 // 2. Parse and read the linked PV
2707 let parsed =
2708 crate::server::record::parse_link_v2(link_str.as_str_lossy().as_ref());
2709 if let Some(value) = self.read_link_value(&parsed, visited, depth).await {
2710 // 3. Write into the record field (internal put bypasses read-only)
2711 let mut instance = rec.write().await;
2712 let _ = instance.record.put_field_internal(target_field, value);
2713 }
2714 }
2715 ProcessAction::WriteDbLink { link_field, value } => {
2716 // 1. Get the link string (record fields → common fields)
2717 // and the source PUTF for processTarget propagation,
2718 // plus the committed alarm for `recGblInheritSevrMsg`
2719 // MS-class propagation into the OUT-link target.
2720 let (link_str, src_putf, src_notify, src_alarm) = {
2721 let instance = rec.read().await;
2722 let link = instance
2723 .resolve_field(link_field)
2724 .and_then(|v| {
2725 if let EpicsValue::String(s) = v {
2726 Some(s)
2727 } else {
2728 None
2729 }
2730 })
2731 .unwrap_or_default();
2732 (
2733 link,
2734 instance.common.putf,
2735 instance.notify.clone(),
2736 super::links::LinkAlarm {
2737 stat: instance.common.stat,
2738 sevr: instance.common.sevr,
2739 amsg: instance.common.amsg.clone(),
2740 },
2741 )
2742 };
2743 if link_str.is_empty() {
2744 continue;
2745 }
2746 // 2. Parse and write to the linked PV — DB *or*
2747 // external `ca://`/`pva://`. A record's `process()`
2748 // emits `WriteDbLink` to drive an OUT-link field
2749 // (transform `OUTn`, throttle/scaler `COUTP`, epid
2750 // `TRIG`/`OUTL`); that field may resolve to a CA/PVA
2751 // link, which C `dbPutLink` routes through the link
2752 // set's `putValue` identically to a DB link
2753 // (dbLink.c:434-448).
2754 let parsed =
2755 crate::server::record::parse_link_v2(link_str.as_str_lossy().as_ref());
2756 self.write_out_link_value(
2757 &parsed,
2758 value,
2759 super::links::OutLinkSrc {
2760 putf: src_putf,
2761 notify: src_notify.as_ref(),
2762 alarm: &src_alarm,
2763 },
2764 visited,
2765 depth,
2766 )
2767 .await;
2768 }
2769 ProcessAction::DeviceCommand { command, ref args } => {
2770 let mut instance = rec.write().await;
2771 if let Some(mut dev) = instance.device.take() {
2772 // `handle_command` runs after the process snapshot
2773 // was already built/notified, so any record field
2774 // it mutated needs an explicit monitor post. The
2775 // returned field names are posted with DBE_VALUE,
2776 // mirroring the C record's `db_post_events` calls
2777 // from inside `process()` (scalerRecord.c:425-430).
2778 let changed = dev
2779 .handle_command(&mut *instance.record, command, args)
2780 .unwrap_or_default();
2781 instance.device = Some(dev);
2782 for field in changed {
2783 instance.notify_field(field, crate::server::recgbl::EventMask::VALUE);
2784 }
2785 }
2786 }
2787 ProcessAction::ReprocessAfter(delay) => {
2788 // Owner-driven delayed re-entry, mirroring C
2789 // `callbackRequestDelayed` dispatching to
2790 // `(*prset->process)(prec)` directly (callback.c). Mint
2791 // a fresh token — which advances the record's generation
2792 // and so supersedes any prior pending re-entry for this
2793 // record (a newer ReprocessAfter replaces the older
2794 // timer) — then fire it after the delay. A newer mint or
2795 // an explicit `cancel_async_reentry` makes this fire a
2796 // structural no-op: the gate lives entirely in
2797 // `AsyncToken`, not in an inline generation compare here.
2798 let token = match self.mint_async_token(record_name).await {
2799 Some(t) => t,
2800 None => continue,
2801 };
2802 let db = self.clone();
2803 tokio::spawn(async move {
2804 tokio::time::sleep(delay).await;
2805 let _ = token.fire(&db).await;
2806 });
2807 }
2808 ProcessAction::WriteDbLinkNotify { link_field, value } => {
2809 // C `sseqRecord.c` WAITn put-callback dependency: write
2810 // the OUT link as a put-WITH-completion and re-enter THIS
2811 // record's process() once the downstream record (plus its
2812 // FLNK/OUT chain) finishes. Same OUT-link write a plain
2813 // WriteDbLink performs, wrapped in the c401e2f0 put-notify
2814 // wait-set + async re-entry primitive.
2815 let (link_str, src_putf, src_alarm) = {
2816 let instance = rec.read().await;
2817 let link = instance
2818 .resolve_field(link_field)
2819 .and_then(|v| {
2820 if let EpicsValue::String(s) = v {
2821 Some(s)
2822 } else {
2823 None
2824 }
2825 })
2826 .unwrap_or_default();
2827 (
2828 link,
2829 instance.common.putf,
2830 super::links::LinkAlarm {
2831 stat: instance.common.stat,
2832 sevr: instance.common.sevr,
2833 amsg: instance.common.amsg.clone(),
2834 },
2835 )
2836 };
2837 // Mint the re-entry token BEFORE issuing the put so a
2838 // synchronous downstream completion cannot fire the
2839 // oneshot before the waiter is wired. The mint supersedes
2840 // any prior pending re-entry for this record (newer
2841 // token), exactly like ReprocessAfter.
2842 let token = match self.mint_async_token(record_name).await {
2843 Some(t) => t,
2844 None => continue,
2845 };
2846 let (waitset, completion) = Self::new_put_notify();
2847 if !link_str.is_empty() {
2848 let parsed =
2849 crate::server::record::parse_link_v2(link_str.as_str_lossy().as_ref());
2850 self.write_out_link_value(
2851 &parsed,
2852 value,
2853 super::links::OutLinkSrc {
2854 putf: src_putf,
2855 notify: Some(&waitset),
2856 alarm: &src_alarm,
2857 },
2858 visited,
2859 depth,
2860 )
2861 .await;
2862 }
2863 // Release the initiator's own wait-set count (C
2864 // `dbProcessNotify` holds one count for the requester and
2865 // drops it after issuing the put). The set then drains —
2866 // and fires the completion — when the downstream
2867 // target(s) that joined via `join_put_notify` finish, or
2868 // immediately when the link was empty / the target
2869 // completed synchronously.
2870 waitset.leave();
2871 self.reprocess_on_notify(token, completion);
2872 }
2873 ProcessAction::CancelReprocess => {
2874 // C `callbackCancelDelayed` for `sseq` ABORT: advance the
2875 // record's re-entry generation so any pending DLYn timer
2876 // or WAITn notify re-entry becomes a structural no-op (the
2877 // AsyncToken gate), with no runtime is-aborted check on
2878 // the re-entry path.
2879 self.cancel_async_reentry(record_name).await;
2880 }
2881 }
2882 }
2883 }
2884
2885 /// Complete an asynchronous record's post-process steps.
2886 /// Call after device support signals completion (clears PACT, runs alarms, snapshot, OUT, FLNK).
2887 pub fn complete_async_record<'a>(
2888 &'a self,
2889 name: &'a str,
2890 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'a>> {
2891 Box::pin(async move {
2892 let mut visited = HashSet::new();
2893 self.complete_async_record_inner(name, &mut visited, 0)
2894 .await
2895 })
2896 }
2897
2898 async fn complete_async_record_inner(
2899 &self,
2900 name: &str,
2901 visited: &mut HashSet<String>,
2902 depth: usize,
2903 ) -> CaResult<()> {
2904 // Alias-aware entry — same pattern as
2905 // `process_record_with_links_inner`. `name` may arrive as an
2906 // alias from an async device-support callback that captured
2907 // the original record name; normalise to canonical so the
2908 // records-map lookup, the `visited` cycle set, and downstream
2909 // FLNK/OUT dispatches all see the same canonical name.
2910 let canonical_owned;
2911 let name: &str = if let Some(target) = self.resolve_alias(name).await {
2912 canonical_owned = target;
2913 &canonical_owned
2914 } else {
2915 name
2916 };
2917
2918 let rec = {
2919 let records = self.inner.records.read().await;
2920 records
2921 .get(name)
2922 .cloned()
2923 .ok_or_else(|| CaError::ChannelNotFound(name.to_string()))?
2924 };
2925
2926 // Seed the cycle guard with this record's own name — mirrors
2927 // the synchronous main path (`process_record_with_links_inner`
2928 // does `visited.insert(name)` before the body). Without this
2929 // the async-completion FLNK / OUT / CP dispatch can re-enter
2930 // the just-completed record: an async FLNK chain that loops
2931 // back (A async -> completes -> FLNK -> B -> FLNK -> A) would
2932 // re-process A unbounded, because PACT is cleared below before
2933 // the FLNK dispatch and nothing else blocks the re-entry.
2934 if !visited.insert(name.to_string()) {
2935 return Ok(()); // Cycle detected, skip
2936 }
2937
2938 let (snapshot, out_info, flnk_name, alarm_posts) = {
2939 let mut instance = rec.write().await;
2940
2941 // UDF update before alarm evaluation (C parity — see the
2942 // sync process path). A NaN/undefined value keeps UDF true
2943 // so `recGblCheckUDF` raises UDF_ALARM this cycle.
2944 if instance.record.clears_udf() {
2945 instance.common.udf = instance.record.value_is_undefined();
2946 }
2947 // Per-record alarm hook (C `checkAlarms()`).
2948 {
2949 let inst = &mut *instance;
2950 inst.record.check_alarms(&mut inst.common);
2951 }
2952
2953 // Evaluate alarms
2954 instance.evaluate_alarms();
2955
2956 let is_soft = instance.common.dtyp.is_empty() || instance.common.dtyp == "Soft Channel";
2957
2958 // Device support alarm/timestamp override
2959 if !is_soft {
2960 let (dev_alarm, dev_ts, dev_utag) = if let Some(ref dev) = instance.device {
2961 (dev.last_alarm(), dev.last_timestamp(), dev.last_utag())
2962 } else {
2963 (None, None, None)
2964 };
2965 if let Some((stat, sevr)) = dev_alarm {
2966 crate::server::recgbl::rec_gbl_set_sevr(
2967 &mut instance.common,
2968 stat,
2969 crate::server::record::AlarmSeverity::from_u16(sevr),
2970 );
2971 }
2972 if let Some(ts) = dev_ts {
2973 instance.common.time = ts;
2974 }
2975 // C device support writes `prec->utag` directly during
2976 // `read()` — the event-system pulse-id path, since
2977 // `epicsTimeStamp` carries no tag. Adopt the device's
2978 // userTag when it supplies one; read in the same `dev`
2979 // borrow as the timestamp above so the time/tag pair is a
2980 // single consistent device snapshot.
2981 if let Some(utag) = dev_utag {
2982 instance.common.utag = utag;
2983 }
2984 }
2985
2986 let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
2987
2988 apply_timestamp(&mut instance.common, is_soft);
2989 // UDF was already updated before `evaluate_alarms` above.
2990
2991 // Clear PACT
2992 instance
2993 .processing
2994 .store(false, std::sync::atomic::Ordering::Release);
2995
2996 // Put-notify completion is NOT fired here. The async device
2997 // round-trip has finished, but the OUT/FLNK/process-action
2998 // tail it drives (below) may itself reach an async target;
2999 // firing now would report WRITE_NOTIFY done while that chain
3000 // still runs. The originating record `leave`s the wait-set at
3001 // the END of this function, after every PP target it drives
3002 // has joined. See `complete_put_notify` at the tail.
3003
3004 use crate::server::recgbl::EventMask;
3005 let (include_val, include_archive) = match instance.record.monitor_value_changed() {
3006 // lsi/lso post VALUE|LOG only when the string actually
3007 // changed (C `lsiRecord.c`/`lsoRecord.c` monitor: `len !=
3008 // olen || memcmp(oval, val, len)`); they have no MDEL/ADEL
3009 // deadband to express that, so the gate is explicit. The
3010 // MPST/APST `menuPost` "Always" override OR-adds DBE_VALUE /
3011 // DBE_LOG even on an unchanged cycle (C monitor: `if (mpst ==
3012 // menuPost_Always) events |= DBE_VALUE; if (apst ==
3013 // menuPost_Always) events |= DBE_LOG;`).
3014 Some(changed) => {
3015 let (val_always, archive_always) = instance.record.monitor_always_post();
3016 (changed || val_always, changed || archive_always)
3017 }
3018 None => {
3019 if instance.record.uses_monitor_deadband() {
3020 instance.check_deadband_ext()
3021 } else {
3022 // Binary records (bi/bo/busy/mbbi/mbbo): always post monitors
3023 (true, true)
3024 }
3025 }
3026 };
3027 // C `recGblResetAlarms` `val_mask = DBE_ALARM`
3028 // (recGbl.c:194/203/212) — same parity rule as the main
3029 // process path above (see comment there).
3030 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
3031 EventMask::ALARM
3032 } else {
3033 EventMask::NONE
3034 };
3035
3036 let mut changed_fields = Vec::new();
3037 // Same deadband-field routing and per-field mask as the main
3038 // process path: the tracked field posts the classes that
3039 // actually fired (MDEL → DBE_VALUE, ADEL → DBE_LOG, alarm
3040 // movement → DBE_ALARM); a non-primary deadband field
3041 // (motor RBV) leaves VAL to the generic change-detection
3042 // loop below.
3043 let deadband_field = instance.record.monitor_deadband_field();
3044 let deadband_mask = {
3045 let mut m = alarm_bits;
3046 if include_val {
3047 m |= EventMask::VALUE;
3048 }
3049 if include_archive {
3050 m |= EventMask::LOG;
3051 }
3052 m
3053 };
3054 if !deadband_mask.is_empty() {
3055 let dval = if deadband_field == "VAL" {
3056 instance.record.val()
3057 } else {
3058 instance.resolve_field(deadband_field)
3059 };
3060 if let Some(val) = dval {
3061 changed_fields.push((deadband_field.to_string(), val, deadband_mask));
3062 }
3063 }
3064 // C `recGblResetAlarms` (recGbl.c:201-220) posts each alarm
3065 // field with its OWN per-field mask. Mirror the synchronous
3066 // link path (`process_record_with_links_inner`) and
3067 // `process_local` exactly: SEVR=DBE_VALUE on a sevr change;
3068 // STAT/AMSG share `stat_mask` which carries DBE_ALARM when
3069 // sevr OR amsg moved and DBE_VALUE on a stat change;
3070 // ACKS=DBE_VALUE only when an alarm field moved AND
3071 // recGblResetAlarms raised it. Collapsing these into
3072 // `changed_fields` would post them all on one shared mask —
3073 // losing C's per-field granularity for `.SEVR`/`.STAT`-only
3074 // subscribers.
3075 let sevr_changed = instance.common.sevr != alarm_result.prev_sevr;
3076 let stat_changed = instance.common.stat != alarm_result.prev_stat;
3077 let stat_mask = {
3078 let mut m = EventMask::NONE;
3079 if sevr_changed || alarm_result.amsg_changed {
3080 m |= EventMask::ALARM;
3081 }
3082 if stat_changed {
3083 m |= EventMask::VALUE;
3084 }
3085 m
3086 };
3087 let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
3088 if sevr_changed {
3089 alarm_posts.push(("SEVR", EventMask::VALUE));
3090 }
3091 if !stat_mask.is_empty() {
3092 alarm_posts.push(("STAT", stat_mask));
3093 alarm_posts.push(("AMSG", stat_mask));
3094 }
3095 // C parity (recGbl.c:216): ACKS is posted (DBE_VALUE) only
3096 // when an alarm field moved AND recGblResetAlarms raised it.
3097 if alarm_result.acks_changed && !stat_mask.is_empty() {
3098 alarm_posts.push(("ACKS", EventMask::VALUE));
3099 }
3100 // Add subscribed non-{deadband-field,SEVR,STAT,AMSG,UDF}
3101 // fields that actually changed since last notification —
3102 // mirrors the main-path snapshot gate
3103 // (process_record_with_links_inner L794-820). Without this,
3104 // every async-completion cycle re-sends every subscribed
3105 // auxiliary field even when its value is unchanged,
3106 // multiplying the monitor traffic for any record that pairs
3107 // an async write with a sticky metadata field. The
3108 // deadband-gated field (default VAL) is delivered by the
3109 // trigger branch above, never by raw change-detection. Each
3110 // carries DBE_VALUE|DBE_LOG plus the cycle's alarm bits (C
3111 // `monitor_mask | DBE_VALUE | DBE_LOG` for change-detected
3112 // auxiliary posts). On a cycle whose alarm transition
3113 // fired, fields named by `alarm_cycle_monitored_fields`
3114 // post even when unchanged, with the alarm bits alone — C
3115 // motor `monitor()` (motorRecord.cc:3513-3645) posts every
3116 // listed field once `monitor_mask != 0`.
3117 let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
3118 let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
3119 &[]
3120 } else {
3121 instance.record.alarm_cycle_monitored_fields()
3122 };
3123 let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
3124 for (field, subs) in &instance.subscribers {
3125 if !subs.is_empty()
3126 && field != deadband_field
3127 && field != "SEVR"
3128 && field != "STAT"
3129 && field != "AMSG"
3130 && field != "UDF"
3131 {
3132 if let Some(val) = instance.resolve_field(field) {
3133 let changed = match instance.last_posted.get(field) {
3134 Some(prev) => prev != &val,
3135 None => true,
3136 };
3137 if changed {
3138 sub_updates.push((field.clone(), val, aux_mask));
3139 } else if alarm_fanout.contains(&field.as_str()) {
3140 sub_updates.push((field.clone(), val, alarm_bits));
3141 }
3142 }
3143 }
3144 }
3145 if !sub_updates.is_empty() {
3146 for (field, val, _) in &sub_updates {
3147 instance.last_posted.insert(field.clone(), val.clone());
3148 }
3149 changed_fields.extend(sub_updates);
3150 }
3151 // UDF rides along whenever any monitored post fired this
3152 // cycle, carrying the union of the cycle's posted classes —
3153 // same rule as the main process path.
3154 let cycle_mask = changed_fields
3155 .iter()
3156 .fold(EventMask::NONE, |m, (_, _, fm)| m | *fm);
3157 if !cycle_mask.is_empty() {
3158 changed_fields.push((
3159 "UDF".to_string(),
3160 EpicsValue::Char(if instance.common.udf { 1 } else { 0 }),
3161 cycle_mask,
3162 ));
3163 }
3164 let snapshot = crate::server::record::ProcessSnapshot { changed_fields };
3165
3166 // IVOA check
3167 let skip_out = if instance.common.sevr == crate::server::record::AlarmSeverity::Invalid
3168 {
3169 let ivoa = instance
3170 .record
3171 .get_field("IVOA")
3172 .and_then(|v| {
3173 if let EpicsValue::Short(s) = v {
3174 Some(s)
3175 } else {
3176 None
3177 }
3178 })
3179 .unwrap_or(0);
3180 match ivoa {
3181 1 => true,
3182 2 => {
3183 // See the IVOA=2 comment in
3184 // `process_record_with_links_inner` — IVOA=2
3185 // delegates to the per-record
3186 // `apply_invalid_output_value` so OVAL/RVAL/VAL
3187 // get the C-convention values.
3188 if let Some(ivov) = instance.record.get_field("IVOV") {
3189 let _ = instance.record.apply_invalid_output_value(ivov);
3190 }
3191 false
3192 }
3193 _ => false,
3194 }
3195 } else {
3196 false
3197 };
3198
3199 let can_dev_write = instance.record.can_device_write();
3200 let is_soft_out =
3201 instance.common.dtyp.is_empty() || instance.common.dtyp == "Soft Channel";
3202 let record_should_output = instance.record.should_output();
3203 let out_info = if skip_out {
3204 None
3205 } else if !can_dev_write {
3206 // Non-output records (calcout, etc.) with soft OUT link
3207 // (DB or external `ca://`/`pva://`).
3208 if record_should_output && instance.parsed_out.is_writable_out_link() {
3209 let out_val = instance
3210 .record
3211 .get_field("OVAL")
3212 .or_else(|| instance.record.val());
3213 out_val.map(|v| (instance.parsed_out.clone(), v))
3214 } else {
3215 None
3216 }
3217 } else if is_soft_out {
3218 if instance.parsed_out.is_writable_out_link() {
3219 let out_val = instance
3220 .record
3221 .get_field("OVAL")
3222 .or_else(|| instance.record.val());
3223 out_val.map(|v| (instance.parsed_out.clone(), v))
3224 } else {
3225 None
3226 }
3227 } else {
3228 // Non-soft output: the async device write already completed
3229 // (that's why we're in complete_async_record). Don't re-do
3230 // write_begin -- it would start another async cycle.
3231 None
3232 };
3233
3234 let flnk_name = if instance.record.should_fire_forward_link() {
3235 if let crate::server::record::ParsedLink::Db(ref l) = instance.parsed_flnk {
3236 Some(l.record.clone())
3237 } else {
3238 None
3239 }
3240 } else {
3241 None
3242 };
3243
3244 (snapshot, out_info, flnk_name, alarm_posts)
3245 };
3246
3247 // Notify subscribers
3248 {
3249 let instance = rec.read().await;
3250 instance.notify_from_snapshot(&snapshot);
3251 // Post the alarm fields (SEVR/STAT/AMSG/ACKS) with their
3252 // individual C masks — see recGblResetAlarms above.
3253 for &(field, mask) in &alarm_posts {
3254 instance.notify_field(field, mask);
3255 }
3256 }
3257
3258 // Snapshot source PUTF + put-notify wait-set for processTarget /
3259 // dbNotifyAdd propagation (see `write_db_link_value` doc). For the
3260 // async-completion path PUTF would have been set when the put
3261 // landed on the record; it (and wait-set membership) must
3262 // propagate through the (now-completing) OUT / FLNK chain so an
3263 // async target reached here also defers WRITE_NOTIFY completion.
3264 // The committed alarm propagates the same way for
3265 // `recGblInheritSevrMsg` MS-class inheritance.
3266 let (src_putf, src_notify, src_alarm) = {
3267 let guard = rec.read().await;
3268 (
3269 guard.common.putf,
3270 guard.notify.clone(),
3271 super::links::LinkAlarm {
3272 stat: guard.common.stat,
3273 sevr: guard.common.sevr,
3274 amsg: guard.common.amsg.clone(),
3275 },
3276 )
3277 };
3278
3279 // OUT link — DB *or* external `ca://`/`pva://`. Same scheme
3280 // dispatch as the sync path (C `dbLink.c::dbPutLink`,
3281 // dbLink.c:434-448).
3282 if let Some((link, out_val)) = out_info {
3283 self.write_out_link_value(
3284 &link,
3285 out_val,
3286 super::links::OutLinkSrc {
3287 putf: src_putf,
3288 notify: src_notify.as_ref(),
3289 alarm: &src_alarm,
3290 },
3291 visited,
3292 depth,
3293 )
3294 .await;
3295 }
3296
3297 // Multi-output dispatch (fanout/dfanout/seq/sseq)
3298 self.dispatch_multi_output(&rec, visited, depth).await;
3299
3300 // event record: post the named software event.
3301 self.dispatch_event_record(&rec).await;
3302
3303 // Generic multi-output links (transform OUTA..OUTP -> A..P,
3304 // scalcout OUT->OVAL, epid OUTL).
3305 //
3306 // SINGLE-OWNER INVARIANT: skip any record type owned by
3307 // `dispatch_multi_output` (called above) so its `LNKn`/`OUTn`
3308 // is not dispatched twice — see the sync-path twin in
3309 // `run_forward_link_tail_with_putf` §4.6.
3310 {
3311 let multi_out = {
3312 let instance = rec.read().await;
3313 let links =
3314 if super::links::multi_output_dispatch_owned(instance.record.record_type()) {
3315 &[][..]
3316 } else {
3317 instance.record.multi_output_links()
3318 };
3319 if links.is_empty() {
3320 None
3321 } else {
3322 let mut pairs = Vec::new();
3323 for &(link_field, val_field) in links {
3324 let link_str = instance
3325 .record
3326 .get_field(link_field)
3327 .and_then(|v| {
3328 if let EpicsValue::String(s) = v {
3329 Some(s)
3330 } else {
3331 None
3332 }
3333 })
3334 .unwrap_or_default();
3335 if link_str.is_empty() {
3336 continue;
3337 }
3338 if let Some(val) = instance.record.get_field(val_field) {
3339 pairs.push((link_str, val));
3340 }
3341 }
3342 if pairs.is_empty() { None } else { Some(pairs) }
3343 }
3344 };
3345 if let Some(pairs) = multi_out {
3346 for (link_str, val) in pairs {
3347 // `multi_output_links` carries record OUT links
3348 // (sseq `LNKn`, scalcout `OUTn` — all `DBF_OUTLINK`):
3349 // a bare DB link is NPP (`dbDbLink.c:388`).
3350 // `parse_output_link_v2` applies the OUT-link-correct
3351 // NPP default; an external `ca://`/`pva://` link is
3352 // routed through the link set's `putValue` — see the
3353 // sync-path twin above.
3354 let parsed = crate::server::record::parse_output_link_v2(
3355 link_str.as_str_lossy().as_ref(),
3356 );
3357 self.write_out_link_value(
3358 &parsed,
3359 val,
3360 super::links::OutLinkSrc {
3361 putf: src_putf,
3362 notify: src_notify.as_ref(),
3363 alarm: &src_alarm,
3364 },
3365 visited,
3366 depth,
3367 )
3368 .await;
3369 }
3370 }
3371 }
3372
3373 // FLNK -- only process if target is Passive (C `dbScanFwdLink` ->
3374 // `dbScanPassive` -> `processTarget` propagates PUTF the same way
3375 // OUT links do).
3376 if let Some(ref flnk) = flnk_name {
3377 if let Some(target_rec) = self.get_record(flnk).await {
3378 let (target_scan, should_process) = {
3379 let mut tg = target_rec.write().await;
3380 let pact = tg.is_processing();
3381 let on_chain = visited.contains(flnk);
3382 let scan = tg.common.scan;
3383 if !pact {
3384 tg.common.putf = src_putf;
3385 // C `dbNotifyAdd` (dbDbLink.c:460) is reached only
3386 // inside `processTarget`, which `dbScanPassive`
3387 // calls solely for a passive target. Gate the join
3388 // on the same passive condition as the process
3389 // call below so a dropped (non-passive) target
3390 // never `enter`s the wait-set without `leave`ing.
3391 if scan == crate::server::record::ScanType::Passive {
3392 join_put_notify(&mut tg, src_notify.as_ref());
3393 }
3394 } else if src_putf && !on_chain {
3395 tg.common.rpro = true;
3396 tg.common.putf = false;
3397 }
3398 (scan, !pact)
3399 };
3400 if should_process && target_scan == crate::server::record::ScanType::Passive {
3401 // recursive FLNK within one chain — gate
3402 // already held by the foreign entry record.
3403 let _ = self
3404 .process_record_with_links_recursive(flnk, visited, depth + 1)
3405 .await;
3406 }
3407 }
3408 }
3409
3410 // FLNK whose target is external (`pva://`/`ca://`): forwarded
3411 // through the same single owner as the synchronous tail (C
3412 // `dbScanFwdLink` → lset `scanForward`). `flnk_name` above only
3413 // names a local DB target.
3414 self.dispatch_external_forward_link(&rec).await;
3415
3416 // CP link targets
3417 self.dispatch_cp_targets(name, visited, depth).await;
3418
3419 // RPRO: C `recGblFwdLink` consumes a pending reprocess via
3420 // `scanOnce` — queued, not recursed. Mirror the synchronous
3421 // path: spawn a fresh process pass (clean `visited`, depth 0).
3422 {
3423 let needs_rpro = {
3424 let mut guard = rec.write().await;
3425 if guard.common.rpro {
3426 guard.common.rpro = false;
3427 true
3428 } else {
3429 false
3430 }
3431 };
3432 if needs_rpro {
3433 let db = self.clone();
3434 let rpro_name = name.to_string();
3435 crate::runtime::task::spawn(async move {
3436 let mut fresh_visited = std::collections::HashSet::new();
3437 let _ = db
3438 .process_record_with_links(&rpro_name, &mut fresh_visited, 0)
3439 .await;
3440 });
3441 }
3442 }
3443
3444 // C `recGbl.c::recGblFwdLink:302` clears `putf = FALSE` after
3445 // the forward-link dispatch. The same clearing must happen
3446 // at the tail of the async-completion path (this is the moral
3447 // equivalent of the synchronous completion path in
3448 // `put_record_field_from_ca` which clears after
3449 // `process_record_with_links` returns). Without this, a
3450 // record that completed an async write triggered by a
3451 // CA put would keep `putf=1` forever, leaking into every
3452 // subsequent scan-driven process cycle.
3453 {
3454 let mut guard = rec.write().await;
3455 guard.common.putf = false;
3456 }
3457
3458 // Put-notify completion: the async device round-trip is done and
3459 // the full OUT/FLNK/process-action tail above has run, so every PP
3460 // target it drove has joined the wait-set. The originating record
3461 // now `leave`s; the completion oneshot fires on the `leave` that
3462 // empties the set (i.e. once every joined async target has also
3463 // completed). `complete_put_notify` `take`s the membership, so a
3464 // motor re-entering `complete_async_record_inner` over several
3465 // device cycles leaves exactly once — matching the old fire site,
3466 // which `take`d its oneshot.
3467 {
3468 let mut guard = rec.write().await;
3469 complete_put_notify(&mut guard);
3470 }
3471
3472 Ok(())
3473 }
3474
3475 /// Dispatch CP-link targets that take a CP/CPP input link from `name`.
3476 ///
3477 /// C parity (a4bc0db): the CP-driven dispatch is the moral equivalent of
3478 /// dbCaTask's CA_DBPROCESS handler invoking `db_process(prec)`. Before
3479 /// processing each target, set PUTF=true; if the target is already
3480 /// processing (async record mid-flight), set RPRO=true instead so the
3481 /// in-flight pass reprocesses on completion. Already-visited targets
3482 /// (current process chain) are skipped via the `visited` cycle guard.
3483 async fn dispatch_cp_targets(
3484 &self,
3485 name: &str,
3486 visited: &mut std::collections::HashSet<String>,
3487 depth: usize,
3488 ) {
3489 let cp_targets = self.get_cp_targets(name).await;
3490 for target in cp_targets {
3491 self.process_one_cp_target(&target, visited, depth).await;
3492 }
3493 }
3494
3495 /// Process a single CP/CPP target edge, applying the CPP passive gate
3496 /// and the PACT/RPRO pre-check. This is the single owner of the
3497 /// scan-time CP-dispatch decision, shared by the local-source path
3498 /// ([`Self::dispatch_cp_targets`]) and the cross-IOC path
3499 /// ([`Self::dispatch_external_cp_targets`]) so both honour the same
3500 /// `dbCa.c` semantics.
3501 async fn process_one_cp_target(
3502 &self,
3503 target: &super::CpTarget,
3504 visited: &mut std::collections::HashSet<String>,
3505 depth: usize,
3506 ) {
3507 if visited.contains(&target.record) {
3508 return;
3509 }
3510 let target_rec = {
3511 let records = self.inner.records.read().await;
3512 records.get(&target.record).cloned()
3513 };
3514 let mut skip = false;
3515 if let Some(ref t) = target_rec {
3516 let mut tg = t.write().await;
3517 if target.passive_only && tg.common.scan != crate::server::record::ScanType::Passive {
3518 // CPP gate (`dbCa.c:854,994,1072`): a CPP link adds
3519 // `CA_DBPROCESS` only when the link-holder's SCAN is
3520 // Passive. A non-Passive target is reached by its own
3521 // periodic/event scan, so skip it here — no process,
3522 // no RPRO. A CP link (`passive_only == false`) never
3523 // takes this branch and always processes.
3524 skip = true;
3525 } else if tg.processing.load(std::sync::atomic::Ordering::Acquire) {
3526 tg.common.rpro = true;
3527 skip = true;
3528 }
3529 // else (not processing): fall through and process below.
3530 // epics-base PR #3fb10b6: PUTF must remain false on
3531 // CP-driven targets — only the record directly receiving
3532 // the dbPut reports PUTF=1 to dbNotify/onChange observers,
3533 // so we deliberately do NOT set PUTF here.
3534 }
3535 if skip {
3536 return;
3537 }
3538 // recursive CP-target fan-out within one chain —
3539 // gate already held by the foreign entry record.
3540 let _ = self
3541 .process_record_with_links_recursive(&target.record, visited, depth + 1)
3542 .await;
3543 }
3544
3545 /// Process every holder of an EXTERNAL CP/CPP link to `external_pv` —
3546 /// the cross-IOC twin of [`Self::dispatch_cp_targets`]. Called by the
3547 /// calink/pvalink CA monitor callback on every remote change, this is
3548 /// the Rust equivalent of C `dbCa.c eventCallback` adding
3549 /// `CA_DBPROCESS` for a CP (or Passive CPP) link (`dbCa.c:993-994`)
3550 /// and the worker thread running `db_process(prec)` (`dbCa.c:1295`).
3551 /// A cross-IOC source never processes locally, so this callback is the
3552 /// only trigger; without it a `CP`/`CPP` link's holder never processes
3553 /// on a remote change.
3554 ///
3555 /// A fresh `visited` set and `depth = 0` start a new process chain —
3556 /// the monitor event is an independent external trigger, like a scan,
3557 /// not a continuation of an in-flight local chain.
3558 pub async fn dispatch_external_cp_targets(&self, external_pv: &str) {
3559 let targets = self.get_external_cp_targets(external_pv).await;
3560 if targets.is_empty() {
3561 return;
3562 }
3563 let mut visited = std::collections::HashSet::new();
3564 for target in targets {
3565 self.process_one_cp_target(&target, &mut visited, 0).await;
3566 }
3567 }
3568
3569 /// Write a simulation value to an output record's SIOL link,
3570 /// dispatching by link type and locality exactly as C `dbPutLink`
3571 /// (reached from `writeValue` for a SIMM-mode output record):
3572 ///
3573 /// - a **local DB** target uses the already-locked write — writing
3574 /// VAL is an internal step of this record's processing chain,
3575 /// which already holds the entry record's advisory write gate, so
3576 /// a SIOL pointing back at a chain record must not re-acquire the
3577 /// non-reentrant gate (same reasoning as `write_db_link_value`);
3578 /// - a **non-local DB** target (`dbInitLink` made it a CA link) and
3579 /// an explicit **`Ca`/`Pva`** link route through the lset put path;
3580 /// - constant / hardware / none SIOL targets are not writable — no-op
3581 /// (C `dbPutLink` -> `S_db_noLSET`).
3582 async fn write_sim_siol_value(
3583 &self,
3584 siol: &crate::server::record::ParsedLink,
3585 value: EpicsValue,
3586 ) {
3587 match siol {
3588 crate::server::record::ParsedLink::Db(link) => {
3589 let pv_name = if link.field == "VAL" {
3590 link.record.clone()
3591 } else {
3592 format!("{}.{}", link.record, link.field)
3593 };
3594 if self.has_name_no_resolve(&link.record).await {
3595 let _ = self.put_pv_already_locked(&pv_name, value).await;
3596 } else if let Err(e) = self
3597 .write_external_pv(&pv_name, value, crate::server::database::LinkPutOp::Plain)
3598 .await
3599 {
3600 eprintln!("SIOL simulation write to external PV '{pv_name}' failed: {e}");
3601 }
3602 }
3603 crate::server::record::ParsedLink::Ca(_)
3604 | crate::server::record::ParsedLink::Pva(_)
3605 | crate::server::record::ParsedLink::PvaJson(_) => {
3606 let name = siol
3607 .external_pv_name()
3608 .expect("Ca/Pva/PvaJson link carries a PV name");
3609 if let Err(e) = self
3610 .write_external_pv(&name, value, crate::server::database::LinkPutOp::Plain)
3611 .await
3612 {
3613 eprintln!("SIOL simulation write to external PV '{name}' failed: {e}");
3614 }
3615 }
3616 _ => {}
3617 }
3618 }
3619
3620 /// Check simulation mode for a record. Returns
3621 /// `SimOutcome::Simulated` when simulation handled the value (the
3622 /// caller must still run the forward-link tail), or
3623 /// `SimOutcome::NotSimulated` when normal processing should proceed.
3624 async fn check_simulation_mode(&self, rec: &Arc<RwLock<RecordInstance>>) -> SimOutcome {
3625 // Read SIML, SIMM, SIOL, SIMS from the record
3626 let (siml_link, siol_link, sims, _rtype, is_input) = {
3627 let instance = rec.read().await;
3628 let rtype = instance.record.record_type().to_string();
3629 // Every input record whose DBD declares SIML/SIOL/SIMM/SIMS.
3630 // `mbbi`/`mbbiDirect` are input records: `mbbiRecord.c:125-126`
3631 // (and mbbiDirectRecord.c) declare SIML+SIOL, and
3632 // `mbbiRecord.c:388-394` reads `dbGetLink(&prec->siol,
3633 // DBR_ULONG, &prec->sval)` then `rval = sval` — input
3634 // semantics. Omitting them sent a simulated mbbi down the
3635 // OUTPUT branch, which writes VAL out to SIOL instead of
3636 // reading the value in from it.
3637 let is_input = matches!(
3638 rtype.as_str(),
3639 "ai" | "bi"
3640 | "mbbi"
3641 | "mbbiDirect"
3642 | "longin"
3643 | "int64in"
3644 | "stringin"
3645 | "lsi"
3646 | "event"
3647 );
3648
3649 let siml = instance
3650 .record
3651 .get_field("SIML")
3652 .and_then(|v| {
3653 if let EpicsValue::String(s) = v {
3654 Some(s)
3655 } else {
3656 None
3657 }
3658 })
3659 .unwrap_or_default();
3660 let siol = instance
3661 .record
3662 .get_field("SIOL")
3663 .and_then(|v| {
3664 if let EpicsValue::String(s) = v {
3665 Some(s)
3666 } else {
3667 None
3668 }
3669 })
3670 .unwrap_or_default();
3671 let sims = instance
3672 .record
3673 .get_field("SIMS")
3674 .and_then(|v| {
3675 if let EpicsValue::Short(s) = v {
3676 Some(s)
3677 } else {
3678 None
3679 }
3680 })
3681 .unwrap_or(0);
3682
3683 if siml.is_empty() && siol.is_empty() {
3684 return SimOutcome::NotSimulated; // No simulation configured
3685 }
3686
3687 let siml_parsed = crate::server::record::parse_link_v2(siml.as_str_lossy().as_ref());
3688 let siol_parsed = crate::server::record::parse_link_v2(siol.as_str_lossy().as_ref());
3689
3690 (siml_parsed, siol_parsed, sims, rtype, is_input)
3691 };
3692
3693 // Read SIML -> update SIMM. C `dbGetLink(&prec->siml, DBR_USHORT,
3694 // &prec->simm, 0, 0)` reads the SIML link for any type; the
3695 // pre-fix port only read a `ParsedLink::Db` SIML, ignoring a
3696 // CA/PVA/constant simulation-mode source.
3697 if let Some(val) = self.read_link_value_no_process(&siml_link).await {
3698 let simm_val = val.to_f64().unwrap_or(0.0) as i16;
3699 let mut instance = rec.write().await;
3700 let _ = instance
3701 .record
3702 .put_field("SIMM", EpicsValue::Short(simm_val));
3703 }
3704
3705 // Check SIMM
3706 let simm = {
3707 let instance = rec.read().await;
3708 instance
3709 .record
3710 .get_field("SIMM")
3711 .and_then(|v| {
3712 if let EpicsValue::Short(s) = v {
3713 Some(s)
3714 } else {
3715 None
3716 }
3717 })
3718 .unwrap_or(0)
3719 };
3720
3721 if simm == 0 {
3722 return SimOutcome::NotSimulated; // NO simulation, proceed normally
3723 }
3724
3725 // epics-base 7.0.7 (SIMM menu):
3726 // 1 = YES — read/write via SIOL using the cooked VAL
3727 // 2 = RAW — read/write via SIOL using the raw RVAL when the
3728 // record carries one (ai/ao only); falls back to
3729 // VAL when no RVAL is present. Mirrors the C
3730 // implementation, which treats records lacking
3731 // a raw value as "YES" since there's nothing
3732 // else to copy.
3733 let raw_mode = simm == 2;
3734 let raw_field = if raw_mode { "RVAL" } else { "VAL" };
3735
3736 // SIMM=YES(1) / SIMM=RAW(2): read/write the SIOL link. C
3737 // `readValue`/`writeValue` for a SIMM-mode record go through
3738 // `dbGetLink`/`dbPutLink`, which dispatch by link type — a local
3739 // DB target, a CA target (a bare non-local name or an explicit
3740 // `CA`/`ca://` link), or a constant. The pre-fix port special-
3741 // cased a local `ParsedLink::Db` SIOL only, so a non-local or
3742 // external SIOL neither read nor wrote yet still returned
3743 // `Simulated` — the record froze with no value and no alarm.
3744 // Dispatch uniformly through the same link read/write owners as
3745 // every other link; the alarm/timestamp/notify tail below now
3746 // runs for every SIOL link type.
3747 {
3748 if is_input {
3749 // Input record: read from SIOL -> set VAL/RVAL. Uniform
3750 // across Db (with locality fallback) / Ca / Pva / constant
3751 // via `read_link_value_no_process` (C `dbGetLink`).
3752 if let Some(siol_val) = self.read_link_value_no_process(&siol_link).await {
3753 let mut instance = rec.write().await;
3754 let target_supports_raw =
3755 raw_mode && instance.record.get_field("RVAL").is_some();
3756 if target_supports_raw {
3757 // PR #ac92e3e follow-up: SIMM=RAW on records
3758 // with RVAL (ai/ao/etc.) writes the raw value
3759 // into RVAL and runs the record's own
3760 // process() so the LINR / ESLO / EOFF / ASLO
3761 // / AOFF conversion chain computes VAL. The
3762 // pre-fix path additionally called set_val
3763 // here, which overwrote VAL with the raw
3764 // count and silently bypassed conversion —
3765 // the visible failure mode was "SIMM=RAW
3766 // simulation returns counts instead of EGU".
3767 //
3768 // Coerce to RVAL's native DBR type before
3769 // put_field — ai.RVAL is Long, but SIOL on a
3770 // soft channel typically yields Double. Without
3771 // the coerce step the put_field rejects with
3772 // TypeMismatch and leaves RVAL at 0, so
3773 // process() computes VAL = 0*ESLO + EOFF
3774 // (the offset only), not the intended
3775 // RAW*ESLO + EOFF.
3776 let rval_type = instance
3777 .record
3778 .field_list()
3779 .iter()
3780 .find(|f| f.name == "RVAL")
3781 .map(|f| f.dbf_type)
3782 .unwrap_or(crate::types::DbFieldType::Long);
3783 // C parity (aiRecord.c:495): `rval = (long)floor(sval)`.
3784 // Rust `convert_to(Long)` truncates toward zero,
3785 // diverging for negative bipolar-ADC raw values
3786 // (sval=-1.5 → C: -2, Rust as-cast: -1).
3787 // Floor explicitly when narrowing a float to
3788 // an integer RVAL.
3789 let coerced = match (&siol_val, rval_type) {
3790 (EpicsValue::Double(d), crate::types::DbFieldType::Long) => {
3791 EpicsValue::Long(d.floor() as i32)
3792 }
3793 (EpicsValue::Double(d), crate::types::DbFieldType::Int64) => {
3794 EpicsValue::Int64(d.floor() as i64)
3795 }
3796 (EpicsValue::Float(d), crate::types::DbFieldType::Long) => {
3797 EpicsValue::Long((*d as f64).floor() as i32)
3798 }
3799 (EpicsValue::Float(d), crate::types::DbFieldType::Int64) => {
3800 EpicsValue::Int64((*d as f64).floor() as i64)
3801 }
3802 _ if siol_val.db_field_type() != rval_type => {
3803 siol_val.convert_to(rval_type)
3804 }
3805 _ => siol_val,
3806 };
3807 let _ = instance.record.put_field("RVAL", coerced);
3808 let ctx = instance.common.process_context();
3809 instance.record.set_process_context(&ctx);
3810 let _ = instance.record.process();
3811 } else {
3812 // Records without RVAL fall back to SIMM=YES
3813 // semantics: the SIOL value goes straight into
3814 // VAL; no conversion to run.
3815 let _ = instance.record.set_val(siol_val);
3816 }
3817 // Simulation alarm + per-field monitor tail — see
3818 // `sim_process_tail`.
3819 sim_process_tail(&mut instance, sims);
3820 }
3821 } else {
3822 // Output record: write VAL (or RVAL for SIMM=RAW) to
3823 // SIOL (skip device write).
3824 let out_val = {
3825 let instance = rec.read().await;
3826 if raw_mode {
3827 // RAW path: prefer RVAL when the record has
3828 // one. Otherwise fall through to VAL.
3829 instance
3830 .record
3831 .get_field(raw_field)
3832 .or_else(|| instance.record.val())
3833 } else {
3834 instance.record.val()
3835 }
3836 };
3837 if let Some(val) = out_val {
3838 // Write VAL to the SIOL target, dispatching by link
3839 // type/locality (C `dbPutLink`). A local DB target
3840 // uses the `_already_locked` write — writing VAL is an
3841 // internal step of this record's processing chain,
3842 // which already holds the entry record's advisory
3843 // write gate, so a SIOL that points back at a chain
3844 // record cannot dead-lock on a non-reentrant gate
3845 // (same reasoning as the OUT-link write in
3846 // `write_db_link_value`). A non-local or external
3847 // SIOL routes through the lset put path.
3848 self.write_sim_siol_value(&siol_link, val).await;
3849 }
3850
3851 let mut instance = rec.write().await;
3852 // Simulation alarm + per-field monitor tail — see
3853 // `sim_process_tail`.
3854 sim_process_tail(&mut instance, sims);
3855 }
3856 }
3857
3858 SimOutcome::Simulated
3859 }
3860}
3861
3862/// Shared tail of a simulated (`SIMM` != NO) process cycle — the part of
3863/// C `process()` that still runs when `readValue`/`writeValue` divert to
3864/// the SIOL (`aiRecord.c` and every SIML/SIMM-bearing record):
3865/// `recGblSetSevr(prec, SIMM_ALARM, prec->sims)` — a MAXIMIZE into the
3866/// pending nsta/nsev raised first so it wins severity ties (C order:
3867/// readValue before checkAlarms) — then `checkAlarms`,
3868/// `recGblResetAlarms`, and `monitor()`, so the simulated value still
3869/// trips its own limit/state alarms and the SIMM severity maximizes
3870/// against them.
3871///
3872/// The posting masks are per-field, identical to the async-completion
3873/// path (`complete_async_record`) and `process_local`:
3874///
3875/// * the deadband-tracked field (default `VAL`) posts the classes that
3876/// actually fired — MDEL → `DBE_VALUE`, ADEL → `DBE_LOG`, alarm
3877/// movement → `DBE_ALARM` (C `recGblResetAlarms` `val_mask`); the
3878/// lsi/lso explicit change gate, MPST/APST always-post override, and
3879/// binary always-post route through the same hooks as those paths;
3880/// * `SEVR` posts `DBE_VALUE` only on a sevr change; `STAT`/`AMSG`
3881/// share a mask carrying `DBE_ALARM` (sevr/amsg moved) and/or
3882/// `DBE_VALUE` (stat moved); `ACKS` posts `DBE_VALUE` when the reset
3883/// raised it (recGbl.c:201-220);
3884/// * subscribed auxiliary fields post on value change with
3885/// `DBE_VALUE|DBE_LOG` plus the cycle's alarm bits (C change-detected
3886/// posts in each record's `monitor()`, e.g. ai `oraw != rval`), and
3887/// `UDF` rides along with the union of the cycle's posted classes.
3888///
3889/// The pre-fix tails (duplicated across the input and output SIMM
3890/// branches) pushed `VAL`/`SEVR`/`STAT` unconditionally with one shared
3891/// `DBE_VALUE|DBE_ALARM` mask and discarded the `rec_gbl_reset_alarms`
3892/// result — every simulated cycle re-sent unchanged alarm fields,
3893/// stamped `DBE_ALARM` on cycles whose alarm state never moved, and
3894/// bypassed the MDEL/ADEL deadband entirely.
3895fn sim_process_tail(instance: &mut RecordInstance, sims: i16) {
3896 use crate::server::recgbl::EventMask;
3897
3898 apply_timestamp(&mut instance.common, true);
3899 instance.common.udf = false;
3900
3901 let sev = crate::server::record::AlarmSeverity::from_u16(sims as u16);
3902 crate::server::recgbl::rec_gbl_set_sevr(
3903 &mut instance.common,
3904 crate::server::recgbl::alarm_status::SIMM_ALARM,
3905 sev,
3906 );
3907 {
3908 let inst = &mut *instance;
3909 inst.record.check_alarms(&mut inst.common);
3910 }
3911 instance.evaluate_alarms();
3912 let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut instance.common);
3913
3914 let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
3915 EventMask::ALARM
3916 } else {
3917 EventMask::NONE
3918 };
3919
3920 let (include_val, include_archive) = match instance.record.monitor_value_changed() {
3921 Some(changed) => {
3922 let (val_always, archive_always) = instance.record.monitor_always_post();
3923 (changed || val_always, changed || archive_always)
3924 }
3925 None => {
3926 if instance.record.uses_monitor_deadband() {
3927 instance.check_deadband_ext()
3928 } else {
3929 (true, true)
3930 }
3931 }
3932 };
3933 let deadband_field = instance.record.monitor_deadband_field();
3934 let deadband_mask = {
3935 let mut m = alarm_bits;
3936 if include_val {
3937 m |= EventMask::VALUE;
3938 }
3939 if include_archive {
3940 m |= EventMask::LOG;
3941 }
3942 m
3943 };
3944 let mut changed_fields = Vec::new();
3945 if !deadband_mask.is_empty() {
3946 let dval = if deadband_field == "VAL" {
3947 instance.record.val()
3948 } else {
3949 instance.resolve_field(deadband_field)
3950 };
3951 if let Some(val) = dval {
3952 changed_fields.push((deadband_field.to_string(), val, deadband_mask));
3953 }
3954 }
3955
3956 let sevr_changed = instance.common.sevr != alarm_result.prev_sevr;
3957 let stat_changed = instance.common.stat != alarm_result.prev_stat;
3958 let stat_mask = {
3959 let mut m = EventMask::NONE;
3960 if sevr_changed || alarm_result.amsg_changed {
3961 m |= EventMask::ALARM;
3962 }
3963 if stat_changed {
3964 m |= EventMask::VALUE;
3965 }
3966 m
3967 };
3968
3969 let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
3970 let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
3971 &[]
3972 } else {
3973 instance.record.alarm_cycle_monitored_fields()
3974 };
3975 let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
3976 for (field, subs) in &instance.subscribers {
3977 if !subs.is_empty()
3978 && field != deadband_field
3979 && field != "SEVR"
3980 && field != "STAT"
3981 && field != "AMSG"
3982 && field != "UDF"
3983 {
3984 if let Some(val) = instance.resolve_field(field) {
3985 let changed = match instance.last_posted.get(field) {
3986 Some(prev) => prev != &val,
3987 None => true,
3988 };
3989 if changed {
3990 sub_updates.push((field.clone(), val, aux_mask));
3991 } else if alarm_fanout.contains(&field.as_str()) {
3992 sub_updates.push((field.clone(), val, alarm_bits));
3993 }
3994 }
3995 }
3996 }
3997 if !sub_updates.is_empty() {
3998 for (field, val, _) in &sub_updates {
3999 instance.last_posted.insert(field.clone(), val.clone());
4000 }
4001 changed_fields.extend(sub_updates);
4002 }
4003 let cycle_mask = changed_fields
4004 .iter()
4005 .fold(EventMask::NONE, |m, (_, _, fm)| m | *fm);
4006 if !cycle_mask.is_empty() {
4007 changed_fields.push((
4008 "UDF".to_string(),
4009 EpicsValue::Char(if instance.common.udf { 1 } else { 0 }),
4010 cycle_mask,
4011 ));
4012 }
4013
4014 let snapshot = crate::server::record::ProcessSnapshot { changed_fields };
4015 instance.notify_from_snapshot(&snapshot);
4016 if sevr_changed {
4017 instance.notify_field("SEVR", EventMask::VALUE);
4018 }
4019 if !stat_mask.is_empty() {
4020 instance.notify_field("STAT", stat_mask);
4021 instance.notify_field("AMSG", stat_mask);
4022 }
4023 if alarm_result.acks_changed && !stat_mask.is_empty() {
4024 instance.notify_field("ACKS", EventMask::VALUE);
4025 }
4026}