Skip to main content

epics_base_rs/server/
ioc_app.rs

1// RTEMS-EXEC-MODEL-ALLOW(1): a sync test that hand-builds its own tokio runtime; runs and passes in the exec-backend suite.
2//! IOC Application — st.cmd-style startup for Rust IOCs.
3//!
4//! Provides a 2-phase IOC lifecycle matching the C++ EPICS pattern:
5//!
6//! **Phase 1 (pre-init):** Execute startup script (`st.cmd`)
7//!   - `epicsEnvSet`, `dbLoadRecords`, custom driver config commands
8//!
9//! **Phase 2 (iocInit):** Wire device support, start protocol server
10//!
11//! **Phase 3 (post-init):** Interactive iocsh REPL
12//!   - `dbl`, `dbgf`, `dbpf`, `dbpr`, custom commands
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! IocApplication::new()
18//!     .port(5064)
19//!     .register_device_support("myDevice", || Box::new(MyDeviceSupport::new()))
20//!     .register_startup_command(my_config_command())
21//!     .startup_script("st.cmd")
22//!     .run(my_protocol_runner)
23//!     .await
24//! ```
25
26use std::collections::HashMap;
27use std::sync::{Arc, Mutex};
28
29use crate::error::{CaError, CaResult};
30use crate::runtime::net::cas_server_port;
31use crate::server::record::{self, Record, SubroutineFn};
32
33use crate::server::database::PvDatabase;
34use crate::server::device_support::DeviceSupport;
35use crate::server::iocsh::{self, registry::CommandDef};
36use crate::server::{DeviceSupportFactory, access_security, autosave};
37use autosave::startup::AutosaveStartupConfig;
38
39/// IOC lifecycle init-hook subsystem — Rust port of epics-base
40/// `libcom/src/iocsh/initHooks.{c,h}`.
41///
42/// C code registers a callback via `initHookRegister()` and the IOC
43/// fires `initHookAnnounce(state)` at fixed points during
44/// `iocBuild()` / `iocRun()`. Ported code (autosave pass-0/pass-1
45/// restore, areaDetector plugins, sequencer programs, caPutLog,
46/// devIocStats) all hang behaviour off these announcements.
47///
48/// Both Rust build paths ([`IocApplication::run`] and
49/// [`crate::server::ioc_builder::IocBuilder::build`]) announce the
50/// states they reach in the same order C does.
51pub mod init_hooks {
52    use std::sync::{Arc, Mutex};
53
54    /// Initialization stages, mirroring C's `initHookState` enum
55    /// (`initHooks.h`). Only the states this IOC can actually reach are
56    /// modelled, and the order of the modelled variants matches C exactly.
57    ///
58    /// The pause block and the reachable half of the shutdown block are
59    /// here because [`super::ioc_pause`] and [`super::ioc_shutdown`] make
60    /// those transitions real. Still absent, because the port has no such
61    /// transition to announce them from: `initHookAfterCloseLinks`,
62    /// `initHookAfterStopCallback` and `initHookAfterStopLinks` (no
63    /// `doCloseLinks`, `callbackStop` or `dbCaShutdown` analogue — the
64    /// callback facility has no stop and the link sets have no lifecycle
65    /// methods), `initHookBeforeFree` (C announces it only from
66    /// `iocBuildIsolated`'s shutdown), the two `dbUnitTest` states, and the
67    /// two states C itself marks deprecated.
68    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69    pub enum InitHookState {
70        /// Start of iocBuild() / iocInit().
71        AtIocBuild,
72        /// Database sanity checks passed.
73        AtBeginning,
74        /// Callbacks, generalTime & taskwd init.
75        AfterCallbackInit,
76        /// CA links init.
77        AfterCaLinkInit,
78        /// Driver support init.
79        AfterInitDrvSup,
80        /// Record support init.
81        AfterInitRecSup,
82        /// Device support init pass 0 (also autosave pass 0).
83        AfterInitDevSup,
84        /// Records and locksets init (also autosave pass 1).
85        AfterInitDatabase,
86        /// Device support init pass 1.
87        AfterFinishDevSup,
88        /// Scan, AS, ProcessNotify init.
89        AfterScanInit,
90        /// Records with PINI = YES processed.
91        AfterInitialProcess,
92        /// RSRV (CA server) init.
93        AfterCaServerInit,
94        /// End of iocBuild().
95        AfterIocBuilt,
96        /// Start of iocRun().
97        AtIocRun,
98        /// Scan tasks and CA links running.
99        AfterDatabaseRunning,
100        /// RSRV (CA server) running.
101        AfterCaServerRunning,
102        /// End of iocRun() / iocInit().
103        AfterIocRunning,
104
105        /// Start of iocPause().
106        AtIocPause,
107        /// Protocol servers paused.
108        AfterCaServerPaused,
109        /// CA links and scan tasks paused.
110        AfterDatabasePaused,
111        /// End of iocPause().
112        AfterIocPaused,
113
114        /// Start of iocShutdown().
115        AtShutdown,
116        /// Scan tasks stopped.
117        AfterStopScan,
118        /// End of iocShutdown().
119        AfterShutdown,
120    }
121
122    impl InitHookState {
123        /// Printable representation — mirrors C `initHookName()`.
124        pub fn name(&self) -> &'static str {
125            match self {
126                InitHookState::AtIocBuild => "initHookAtIocBuild",
127                InitHookState::AtBeginning => "initHookAtBeginning",
128                InitHookState::AfterCallbackInit => "initHookAfterCallbackInit",
129                InitHookState::AfterCaLinkInit => "initHookAfterCaLinkInit",
130                InitHookState::AfterInitDrvSup => "initHookAfterInitDrvSup",
131                InitHookState::AfterInitRecSup => "initHookAfterInitRecSup",
132                InitHookState::AfterInitDevSup => "initHookAfterInitDevSup",
133                InitHookState::AfterInitDatabase => "initHookAfterInitDatabase",
134                InitHookState::AfterFinishDevSup => "initHookAfterFinishDevSup",
135                InitHookState::AfterScanInit => "initHookAfterScanInit",
136                InitHookState::AfterInitialProcess => "initHookAfterInitialProcess",
137                InitHookState::AfterCaServerInit => "initHookAfterCaServerInit",
138                InitHookState::AfterIocBuilt => "initHookAfterIocBuilt",
139                InitHookState::AtIocRun => "initHookAtIocRun",
140                InitHookState::AfterDatabaseRunning => "initHookAfterDatabaseRunning",
141                InitHookState::AfterCaServerRunning => "initHookAfterCaServerRunning",
142                InitHookState::AfterIocRunning => "initHookAfterIocRunning",
143                InitHookState::AtIocPause => "initHookAtIocPause",
144                InitHookState::AfterCaServerPaused => "initHookAfterCaServerPaused",
145                InitHookState::AfterDatabasePaused => "initHookAfterDatabasePaused",
146                InitHookState::AfterIocPaused => "initHookAfterIocPaused",
147                InitHookState::AtShutdown => "initHookAtShutdown",
148                InitHookState::AfterStopScan => "initHookAfterStopScan",
149                InitHookState::AfterShutdown => "initHookAfterShutdown",
150            }
151        }
152    }
153
154    /// Application callback type — Rust equivalent of C's
155    /// `initHookFunction`. Invoked once per announced state. `Arc`
156    /// so [`init_hook_announce`] can snapshot the list and drop the
157    /// lock before invoking callbacks (C holds its list mutex only
158    /// during traversal, never across the callback).
159    pub type InitHookFunction = Arc<dyn Fn(InitHookState) + Send + Sync>;
160
161    static HOOKS: Mutex<Vec<InitHookFunction>> = Mutex::new(Vec::new());
162
163    /// Register a function for initHook notifications — Rust port of
164    /// C `initHookRegister()`. The callback is invoked for every
165    /// subsequently-announced state. Registration is process-global,
166    /// matching C's single `functionList`.
167    ///
168    /// Unlike C (which dedups by function pointer) closures cannot be
169    /// compared for identity, so every call adds a distinct callback;
170    /// callers must register each hook once.
171    pub fn init_hook_register(func: InitHookFunction) {
172        HOOKS.lock().unwrap().push(func);
173    }
174
175    /// Announce an init-hook state to all registered callbacks —
176    /// Rust port of C `initHookAnnounce()`. Called only by the IOC
177    /// build paths at the fixed lifecycle points.
178    ///
179    /// The callback list is snapshotted (cheap `Arc` clones) and the
180    /// lock released before any callback runs, so a hook that calls
181    /// [`init_hook_register`] from inside the callback cannot
182    /// deadlock. Hooks registered during an announce are not invoked
183    /// for that same state — matching C's snapshot-of-`ellFirst`
184    /// traversal semantics.
185    pub fn init_hook_announce(state: InitHookState) {
186        let snapshot: Vec<InitHookFunction> = HOOKS.lock().unwrap().clone();
187        for cb in snapshot {
188            cb(state);
189        }
190    }
191
192    /// Forget all registered callbacks. Test-only — mirrors C
193    /// `initHookFree()`. Lets unit tests run in isolation without
194    /// leaking process-global hook state into each other.
195    #[cfg(test)]
196    pub fn init_hook_free() {
197        HOOKS.lock().unwrap().clear();
198    }
199}
200
201pub use init_hooks::{InitHookFunction, InitHookState, init_hook_announce, init_hook_register};
202
203/// The IOC's run state — C `enum iocStateEnum` (`iocInit.h:17-19`
204/// @R7.0.10) and the file-static `iocState` every transition in
205/// `iocInit.c` guards on.
206///
207/// Modelled as a state, not as a set of booleans, for the reason C keeps
208/// one cell: `iocRun` and `iocPause` are legal only from particular
209/// states, and each answers a wrong one with a diagnostic rather than
210/// doing half the work.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum IocState {
213    /// C `iocVoid` — nothing built, or shut down again.
214    Void,
215    /// C `iocBuilding` — inside the build phase.
216    Building,
217    /// C `iocBuilt` — quiescent: the database exists, nothing scans.
218    Built,
219    /// C `iocRunning`.
220    Running,
221    /// C `iocPaused` — built and populated, record processing frozen.
222    Paused,
223}
224
225/// The IOC lifecycle's single owner.
226///
227/// C keeps `iocState` in one file-static and lets only `iocBuild`,
228/// `iocRun`, `iocPause` and `iocShutdown` write it. This holds the same
229/// cell plus the one resource whose lifetime is the IOC's rather than any
230/// caller's: the scan owner. It used to be a local in
231/// [`IocApplication::run_to_completion`], which is why nothing but
232/// returning from `run` could stop scanning — and why there was no
233/// `iocShutdown` to call.
234struct Lifecycle {
235    state: IocState,
236    /// Alive from the `iocRun` transition until `iocShutdown`. `iocPause`
237    /// does NOT drop it: C's `scanPause` leaves the periodic threads
238    /// running and merely stops them scanning, which is what lets
239    /// `iocRun` resume the rates in phase.
240    scan: Option<crate::server::scan::ScanOwner>,
241}
242
243static LIFECYCLE: Mutex<Lifecycle> = Mutex::new(Lifecycle {
244    state: IocState::Void,
245    scan: None,
246});
247
248fn lifecycle() -> std::sync::MutexGuard<'static, Lifecycle> {
249    LIFECYCLE.lock().unwrap_or_else(|e| e.into_inner())
250}
251
252/// C `getIocState` (`iocInit.c:100-103`).
253pub fn get_ioc_state() -> IocState {
254    lifecycle().state
255}
256
257/// Record a lifecycle transition. Private, and the only writer of the
258/// state cell — the whole point of having one owner.
259fn set_ioc_state(state: IocState) {
260    lifecycle().state = state;
261}
262
263/// Hand the scan owner to the lifecycle — called once, at the `iocRun`
264/// point, by the build path that created it.
265fn adopt_scan_owner(owner: crate::server::scan::ScanOwner) {
266    // Replacing an existing owner drops the old one (joining its thread)
267    // AFTER the lock is released, so a second `run` in one process cannot
268    // deadlock against the join.
269    let previous = lifecycle().scan.replace(owner);
270    drop(previous);
271}
272
273/// C `iocRun` (`iocInit.c:246-276`): bring a built or paused IOC to the
274/// running state. Returns C's status — 0 on success, -1 when the IOC is in
275/// neither state.
276///
277/// The port's `iocRun` covers C's `scanRun` and `dbRunServers` halves.
278/// `dbCaRun` has no analogue: the link sets ([`crate::server::database::LinkSet`])
279/// carry no lifecycle methods, so external links keep running across a
280/// pause — see [`ioc_pause`].
281pub fn ioc_run() -> i32 {
282    let from = get_ioc_state();
283    if from != IocState::Paused && from != IocState::Built {
284        crate::runtime::log::errlog_printf(&format!(
285            "iocRun: {} IOC not paused\n",
286            crate::runtime::log::erl_warning()
287        ));
288        return -1;
289    }
290    init_hook_announce(InitHookState::AtIocRun);
291
292    crate::server::scan::scan_run();
293    init_hook_announce(InitHookState::AfterDatabaseRunning);
294
295    crate::server::db_server::db_run_servers();
296    init_hook_announce(InitHookState::AfterCaServerRunning);
297
298    crate::runtime::log::errlog_printf(if from == IocState::Built {
299        "iocRun: All initialization complete\n"
300    } else {
301        "iocRun: IOC restarted\n"
302    });
303    set_ioc_state(IocState::Running);
304    init_hook_announce(InitHookState::AfterIocRunning);
305    0
306}
307
308/// The `iocRun` transition as reached by [`crate::server::scan::ScanOwner::start`]
309/// — the one line every bring-up path in this workspace shares.
310///
311/// `IocApplication` is the port of C's `iocBuild`, but it is not the only
312/// way an IOC starts here: `softioc-rs`, `qsrv-rs`, `dual_ioc_rs`,
313/// `oracle_ioc` and the two realtime IOCs build their database through
314/// `CaServerBuilder` / `PvaServerBuilder` and then start the scan owner
315/// themselves. Those paths have no build phase to announce, so their
316/// database is already built by the time they start scanning — which is
317/// why [`IocState::Void`] resolves to [`IocState::Built`] here rather than
318/// being refused. Without this the lifecycle would be correct on one path
319/// and permanently `Void` on six, and `iocPause` would answer "IOC not
320/// running" on the very IOC that is.
321///
322/// A redundant owner (the `try_claim_scan_start` case) finds the IOC
323/// already running and only re-arms the facility cell.
324pub(crate) fn note_scan_owner_started() {
325    if get_ioc_state() == IocState::Void {
326        set_ioc_state(IocState::Built);
327    }
328    if get_ioc_state() == IocState::Built {
329        ioc_run();
330    } else {
331        crate::server::scan::scan_run();
332    }
333}
334
335/// C `iocPause` (`iocInit.c:278-300`): freeze record processing without
336/// tearing anything down. Returns 0, or -1 when the IOC is not running.
337///
338/// What freezes is exactly what C's `scanCtl` gate covers — periodic
339/// scanning, `postEvent`, and I/O Intr callbacks. `scanOnce` is not gated
340/// in C either, so a `dbpf` from the paused shell still processes its
341/// record; and this port additionally leaves CA/PVA link input running,
342/// because there is no `dbCaPause` to call.
343pub fn ioc_pause() -> i32 {
344    if get_ioc_state() != IocState::Running {
345        crate::runtime::log::errlog_printf(&format!(
346            "iocPause: {} IOC not running\n",
347            crate::runtime::log::erl_warning()
348        ));
349        return -1;
350    }
351    init_hook_announce(InitHookState::AtIocPause);
352
353    crate::server::db_server::db_pause_servers();
354    init_hook_announce(InitHookState::AfterCaServerPaused);
355
356    crate::server::scan::scan_pause();
357    init_hook_announce(InitHookState::AfterDatabasePaused);
358
359    set_ioc_state(IocState::Paused);
360    crate::runtime::log::errlog_printf("iocPause: IOC suspended\n");
361    init_hook_announce(InitHookState::AfterIocPaused);
362    0
363}
364
365/// C `iocShutdown` (`iocInit.c:722-763`): stop the IOC and return it to
366/// [`IocState::Void`]. Idempotent — C returns 0 immediately when already
367/// void, which is what makes it safe on every exit path.
368///
369/// C reaches this from `epicsAtExit(exitDatabase)` (`iocInit.c:579`), and
370/// so does the port: [`IocApplication::run`] registers it there, so the
371/// scan threads stop when the IOC's `run` returns however it returns.
372///
373/// C's `doCloseLinks`, `callbackStop` and `dbCaShutdown` steps have no
374/// port analogue and are not faked — the callback facility has no stop and
375/// the link sets have no lifecycle methods, so their init-hook states are
376/// not announced either.
377pub fn ioc_shutdown() -> i32 {
378    if get_ioc_state() == IocState::Void {
379        return 0;
380    }
381    init_hook_announce(InitHookState::AtShutdown);
382
383    // Dropping the owner is C's `scanStop`: it trips the facility to
384    // `ctlExit` and joins the owner thread. Taken out from under the lock
385    // first so the join happens with the lifecycle unlocked.
386    let owner = lifecycle().scan.take();
387    drop(owner);
388    // A build that never reached `iocRun` has no owner to drop, so the
389    // facility is stopped here rather than by the drop above.
390    crate::server::scan::scan_stop();
391    init_hook_announce(InitHookState::AfterStopScan);
392
393    crate::server::db_server::db_stop_servers();
394
395    set_ioc_state(IocState::Void);
396    init_hook_announce(InitHookState::AfterShutdown);
397    0
398}
399
400// ── QSRV `dbLoadGroup` startup queue ──────────────────────────────────
401//
402// `dbLoadGroup("file.json", "macros")` is the pvxs/QSRV iocsh command that
403// adds DB group definitions before `iocInit`. pvxs registers it from its
404// `epicsExportRegistrar` (ioc/groupsourcehooks.cpp:233-244, run before the
405// startup script) and its only startup-time effect is to *queue* the file:
406// the JSON is parsed and the group built only later, by `processGroups()`
407// at `initHookAfterInitDatabase` (groupsourcehooks.cpp:99-188 append to
408// `IOCGroupConfig::groupConfigFiles`; :192-213 process them).
409//
410// epics-rs has no link-time registrar, and the served `BridgeProvider`
411// (the QSRV group source) is created by the PVA protocol runner *after*
412// the startup script has already executed. So the iocsh `dbLoadGroup`
413// command itself must live in this base layer — the owner of the startup
414// shell — while the group *semantics* (JSON parse, macLib expansion,
415// serving) stay entirely in the QSRV bridge: this command only records the
416// request, and the QSRV runner drains [`take_group_load_requests`] and
417// applies each entry to the provider it serves. A non-QSRV runner simply
418// never drains the queue, so the command is a harmless no-op there.
419
420/// One queued `dbLoadGroup(filename, macros)` invocation, recorded during
421/// st.cmd for the QSRV protocol runner to apply to the served provider.
422#[derive(Clone, Debug, PartialEq, Eq)]
423pub struct GroupLoadRequest {
424    pub filename: String,
425    pub macros: String,
426}
427
428static GROUP_LOAD_REQUESTS: std::sync::LazyLock<Mutex<Vec<GroupLoadRequest>>> =
429    std::sync::LazyLock::new(|| Mutex::new(Vec::new()));
430
431/// Drain every queued `dbLoadGroup` request, in invocation order. The
432/// QSRV protocol runner calls this once before the PVA server accepts
433/// connections — the epics-rs iocRun-handoff equivalent of pvxs running
434/// `processGroups()` at `initHookAfterInitDatabase`.
435pub fn take_group_load_requests() -> Vec<GroupLoadRequest> {
436    std::mem::take(&mut *GROUP_LOAD_REQUESTS.lock().unwrap())
437}
438
439/// Build the `dbLoadGroup <jsonFilename> [<macros>]` startup command.
440///
441/// Mirrors pvxs `dbLoadGroup` (ioc/groupsourcehooks.cpp:99-188): a leading
442/// `-` removes a previously queued identity (`-*` clears all, `-file`
443/// removes the matching `(filename, macros)` entry); otherwise the pair is
444/// appended after first erasing any prior entry of the same identity (pvxs
445/// erases the matching entry before re-appending, :174-179 erase and
446/// :181-183 re-append). The file is
447/// opened here only to surface pvxs's early "Error opening" diagnostic at
448/// the st.cmd line; the JSON parse and macLib expansion happen later in the
449/// QSRV runner when the queue is drained.
450///
451/// [`IocApplication::run`] already registers this command into every
452/// startup shell, so applications using the standard lifecycle need not
453/// call it. It is public for harnesses that build their own iocsh shell
454/// and want the pvxs-compatible `dbLoadGroup` command (its queue is
455/// consumed via [`take_group_load_requests`]).
456pub fn db_load_group_startup_command() -> CommandDef {
457    use crate::server::iocsh::registry::{
458        ArgDesc, ArgType, ArgValue, CommandContext, CommandOutcome,
459    };
460    CommandDef::new(
461        "dbLoadGroup",
462        vec![
463            ArgDesc {
464                name: "filename",
465                arg_type: ArgType::String,
466            },
467            ArgDesc {
468                name: "macros",
469                arg_type: ArgType::String,
470            },
471        ],
472        "dbLoadGroup <jsonFilename> [<macros>]",
473        move |args: &[ArgValue], ctx: &CommandContext| {
474            let filename = match args.first() {
475                Some(ArgValue::String(s)) => s.clone(),
476                _ => return Err("dbLoadGroup: missing filename".into()),
477            };
478            let macros = match args.get(1) {
479                Some(ArgValue::String(s)) => s.clone(),
480                _ => String::new(),
481            };
482            let mut queue = GROUP_LOAD_REQUESTS.lock().unwrap();
483            // Leading `-`: removal by identity, applied to the queue (pvxs
484            // groupsourcehooks.cpp:140-179 — never touches the filesystem).
485            if let Some(rest) = filename.strip_prefix('-') {
486                if rest == "*" {
487                    let n = queue.len();
488                    queue.clear();
489                    ctx.println(&format!(
490                        "dbLoadGroup: cleared all queued group files ({n} removed)"
491                    ));
492                } else {
493                    let before = queue.len();
494                    queue.retain(|r| !(r.filename == rest && r.macros == macros));
495                    let dropped = before - queue.len();
496                    ctx.println(&format!(
497                        "dbLoadGroup: removed '{rest}' ({dropped} queued entr{} dropped)",
498                        if dropped == 1 { "y" } else { "ies" }
499                    ));
500                }
501                return Ok(CommandOutcome::Continue);
502            }
503            // pvxs opens the file at command time (early error); mirror that
504            // with a readability probe. The QSRV runner re-reads and parses.
505            if let Err(e) = std::fs::metadata(&filename) {
506                return Err(format!("dbLoadGroup: error opening \"{filename}\": {e}"));
507            }
508            // Re-load of the same identity first drops the prior queue entry
509            // (pvxs erases the matching `(fname, macros)` before appending).
510            queue.retain(|r| !(r.filename == filename && r.macros == macros));
511            queue.push(GroupLoadRequest {
512                filename: filename.clone(),
513                macros,
514            });
515            ctx.println(&format!(
516                "dbLoadGroup: queued '{filename}' ({} group file(s) queued)",
517                queue.len()
518            ));
519            Ok(CommandOutcome::Continue)
520        },
521    )
522}
523
524/// Context passed to dynamic device support factories during iocInit wiring.
525pub struct DeviceSupportContext<'a> {
526    pub dtyp: &'a str,
527    pub inp: &'a str,
528    pub out: &'a str,
529}
530
531/// Dynamic device support factory: given a context, returns device support if recognized.
532pub type DynamicDeviceSupportFactory =
533    Box<dyn Fn(&DeviceSupportContext) -> Option<Box<dyn DeviceSupport>> + Send + Sync>;
534
535/// An async external link-set installer, registered on an
536/// [`IocApplication`] via [`IocApplication::register_link_set_installer`]
537/// and invoked by [`IocApplication::run`] at the C `initHookAfterCaLinkInit`
538/// point — BEFORE [`PvDatabase::setup_cp_links`] warms Passive CP holders.
539///
540/// The installer receives the live database, registers its external
541/// [`crate::server::database::LinkSet`] (e.g. the `ca` set from
542/// `epics-ca-rs`'s `calink`, the `pva` set from the bridge's `pvalink`),
543/// and returns any iocsh commands it owns. Those go straight onto the
544/// process's one command table — C `iocshRegister` from a registrar the
545/// `initHookAfterCaLinkInit` point reaches — so they are callable from the
546/// script line after `iocInit` and from the prompt alike.
547/// Registering the link set here — not inside the Phase-3 protocol runner
548/// — is what makes a Passive holder of an external CP/CPP link warm at
549/// iocInit: `setup_cp_links`'s `resolve_external_pv` open path is a no-op
550/// unless the matching link set is already installed.
551pub type LinkSetInstaller = Box<
552    dyn FnOnce(
553            Arc<PvDatabase>,
554        ) -> std::pin::Pin<
555            Box<dyn std::future::Future<Output = Vec<CommandDef>> + Send + 'static>,
556        > + Send
557        + 'static,
558>;
559
560/// Configuration passed to the protocol runner after IOC initialization.
561///
562/// Contains all the pieces needed to start a protocol-specific server
563/// (e.g., CA or PVA) with an interactive shell.
564pub struct IocRunConfig {
565    pub db: Arc<PvDatabase>,
566    /// UDP discovery port — clients SEARCH here. Defaults to
567    /// `EPICS_CA_SERVER_PORT` or 5064.
568    pub port: u16,
569    /// Optional TCP-listen port override. `None` means "use `port`".
570    /// `Some(p)` lets multiple IOCs on one host bind unique TCP ports
571    /// (epics-base PR #69, `EPICS_CAS_SERVER_PORT`) while keeping the
572    /// canonical UDP discovery port.
573    pub tcp_port: Option<u16>,
574    /// The IOC's single live Access Security policy cell, seeded from
575    /// [`IocApplication::acf`] and shared with the startup/interactive
576    /// iocsh shells (whose `asInit` stores into it). A protocol runner
577    /// must hand this cell to every server it builds — never re-wrap
578    /// the config in a fresh cell — so a later `asInit`/ACF reload
579    /// reaches all of them at once.
580    pub acf: access_security::AcfCell,
581    pub autosave_config: Option<autosave::SaveSetConfig>,
582    pub autosave_manager: Option<Arc<autosave::AutosaveManager>>,
583    pub shell_commands: Vec<CommandDef>,
584    /// Retained for API compatibility. [`IocApplication::run`] now
585    /// drains `register_after_init` hooks itself at the
586    /// `initHookAfterIocRunning` point, so this is always handed to
587    /// the protocol runner EMPTY. A runner must not execute it
588    /// (doing so is a no-op on the empty vec, but the hooks have
589    /// already run).
590    pub after_init_hooks: Vec<Box<dyn FnOnce() + Send>>,
591}
592
593/// The two independent questions C softMain asks between the startup script
594/// and the end of `main`: whether to call `iocInit()` at all
595/// (`softMain.cpp:239`), and what this process does when it reaches its own
596/// tail (`:247`).
597///
598/// C sets `loadedDb` only from `-d` and `-x`, and calls `iocInit()` only
599/// when it is true. Everything a running IOC has — the scan threads, PINI,
600/// and RSRV, which starts inside `iocRun` — therefore exists only on that
601/// arm; a `softIoc` given no database reaches its `iocsh(NULL)` prompt
602/// having built nothing and having opened no port. This port ran the whole
603/// lifecycle unconditionally, so a bare `softioc-rs` bound 5064 and served
604/// an empty database where C serves nothing at all.
605///
606/// The two booleans are orthogonal in C and so are they here. `interactive`
607/// used to live inside a `Skip` variant, on the reasoning that it "means
608/// nothing on the `Run` arm, where the protocol runner owns the tail" — and
609/// that is false the moment `iocInit()` fails, because C then never reaches
610/// `iocRun`, never starts a server, and falls through to exactly the same
611/// tail (`softMain.cpp:239-245`, measured: `-S` with an unreadable ACF stays
612/// alive and listens on nothing). A struct is what lets the failure arm ask
613/// C's `-S` question; the constructors keep call sites from reading as a
614/// bare pair of bools.
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub struct IocInitDecision {
617    /// C's `loadedDb` (`softMain.cpp:239`).
618    run: bool,
619    /// C's `-S` (`softMain.cpp:137`, `:202-203`): the `iocsh(NULL)` prompt
620    /// at `:250`, or the `epicsThreadSleep(1000.0)` spin at `:264`.
621    interactive: bool,
622}
623
624impl IocInitDecision {
625    /// C's `loadedDb` arm (`softMain.cpp:239-245`): build the IOC, run it,
626    /// and hand it to the protocol runner, which owns the tail from there.
627    /// `interactive` is still C's `-S` and is what the tail falls back to
628    /// when the build fails and the runner is never called.
629    pub fn run(interactive: bool) -> Self {
630        Self {
631            run: true,
632            interactive,
633        }
634    }
635
636    /// C's other arm. `iocInit()` is never called, so there is no server to
637    /// hand anything to and the protocol runner is NOT invoked — that is
638    /// the whole content of the difference, and passing a runner a flag to
639    /// obey would put it back in the hands of the caller who cannot see
640    /// this decision. The process goes straight to C's tail
641    /// (`softMain.cpp:247-268`).
642    pub fn skip(interactive: bool) -> Self {
643        Self {
644            run: false,
645            interactive,
646        }
647    }
648}
649
650/// Which phase of C softIoc's `main` a failed [`IocApplication::run_phased`]
651/// was in.
652///
653/// C runs the whole boot inside one `try` whose `catch` exits 2, and reaches
654/// its serving phase — `iocsh(NULL)`, or the non-interactive spin — only after
655/// that block closes, exiting 1 when the shell fails (`softMain.cpp:247-279`).
656/// A single flat error type cannot carry that difference, and a caller that
657/// reconstructs it by reading the message is guessing at something already
658/// known here, where the failure happens.
659#[derive(Debug)]
660pub enum IocRunFailure {
661    /// The startup script returned non-zero. C wraps this as `Error in
662    /// <path>` (`softMain.cpp:231`), so the path travels with the failure
663    /// rather than being re-derived from the caller's own argv.
664    StartupScript {
665        /// The `st.cmd` as the caller named it.
666        path: String,
667        /// What the shell reported.
668        reason: String,
669    },
670    /// A pre-script command line returned non-zero. C `softMain.cpp:192-198`
671    /// runs `-d` as `errIf(dbLoadRecords(...), "")` — an EMPTY message, so
672    /// the catch block prints nothing and the command's own diagnostic is
673    /// the whole report. The line travels with the failure for the same
674    /// reason the script path does above.
675    StartupCommand {
676        /// The line as this application queued it.
677        line: String,
678        /// What the shell reported.
679        reason: String,
680    },
681    /// Any other failure before the protocol runner starts — inline records,
682    /// autosave restore, `iocInit`. C's catch block.
683    Startup(CaError),
684    /// The protocol runner itself, past the point C's `try` block ends.
685    Serving(CaError),
686}
687
688/// The lifecycle's default phase: every `?` inside it is a boot step, which
689/// is why only the two exceptions are written out by hand.
690impl From<CaError> for IocRunFailure {
691    fn from(e: CaError) -> Self {
692        IocRunFailure::Startup(e)
693    }
694}
695
696impl From<IocRunFailure> for CaError {
697    fn from(failure: IocRunFailure) -> Self {
698        match failure {
699            IocRunFailure::StartupScript { reason, .. }
700            | IocRunFailure::StartupCommand { reason, .. } => CaError::InvalidValue(reason),
701            IocRunFailure::Startup(e) | IocRunFailure::Serving(e) => e,
702        }
703    }
704}
705
706impl std::fmt::Display for IocRunFailure {
707    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708        match self {
709            IocRunFailure::StartupScript { reason, .. }
710            | IocRunFailure::StartupCommand { reason, .. } => write!(f, "{reason}"),
711            IocRunFailure::Startup(e) | IocRunFailure::Serving(e) => write!(f, "{e}"),
712        }
713    }
714}
715
716/// The exact bytes `iocBuild_2` hands `errlogPrintf` when `asInit()` fails
717/// (`iocInit.c:188-190`).
718///
719/// C's literal is
720///
721/// ```c
722/// ERL_ERROR " iocBuild: asInit Failed.\n"
723///     ANSI_MAGENTA(" The IOC has not been started.") "\n"
724/// ```
725///
726/// so the reset closes BEFORE the second newline and the message ends with
727/// one — a terminator this had to carry itself once `console_fallback`
728/// started writing the caller's bytes verbatim, as C's
729/// `fprintf(console, "%s", …)` (`errlog.c:795`) does.
730///
731/// `paints` is [`crate::runtime::log::errlog_console_paints`]: unlike the
732/// `fprintf(stderr, …)` diagnostics, this one goes through errlog's pump,
733/// where `errlogStripANSI` takes BOTH spans off together when the console is
734/// not a terminal.
735///
736/// A function so the bytes can be pinned against a measured `softIoc` run
737/// without capturing the process stderr, the way `format_show_error` pins
738/// `iocsh.cpp`'s.
739fn as_init_failed_message(paints: bool) -> String {
740    let (error, magenta, reset) = if paints {
741        (crate::runtime::log::ERL_ERROR, "\x1b[35;1m", "\x1b[0m")
742    } else {
743        ("ERROR", "", "")
744    };
745    format!("{error} iocBuild: asInit Failed.\n{magenta} The IOC has not been started.{reset}\n")
746}
747
748/// C softMain's whole pre-`iocInit` sequence, on one shell: the command
749/// lines argv built, in argv order, then the startup script.
750///
751/// Free rather than a method because it runs on the startup thread, where
752/// the application struct has already been taken apart.
753fn run_startup_phase(
754    shell: &iocsh::IocShell,
755    lines: &[String],
756    script: Option<&str>,
757) -> Result<(), IocRunFailure> {
758    for line in lines {
759        shell
760            .execute_line_reported(line)
761            .map_err(|reason| IocRunFailure::StartupCommand {
762                line: line.clone(),
763                reason,
764            })?;
765    }
766    if let Some(script) = script {
767        // The iocshLoad mirror: C `iocsh(pathname)` is
768        // `iocshLoad(pathname, NULL)`, which also records
769        // IOCSH_STARTUP_SCRIPT (epics-base#469).
770        shell
771            .execute_script_with_macros(script, &Default::default())
772            .map_err(|reason| IocRunFailure::StartupScript {
773                path: script.to_string(),
774                reason,
775            })?;
776    }
777    Ok(())
778}
779
780/// C softMain's tail for a process whose `iocInit()` never ran
781/// (`softMain.cpp:247-268`): the interactive shell, or the forever spin.
782///
783/// Nothing here starts a server, and that is the point — RSRV is started by
784/// `iocRun` (`rsrv_run`, `caservertask.c`), so a C `softIoc` on this arm has
785/// no listener and answers no search. The protocol runner is therefore not
786/// called at all rather than being handed a flag it might not obey.
787///
788/// Free rather than a method for the same reason [`run_startup_phase`] is:
789/// the application struct has already been taken apart by here.
790async fn run_uninitialized_tail(
791    db: Arc<PvDatabase>,
792    bridge: crate::runtime::task::BlockingBridge,
793    acf: access_security::AcfCell,
794    interactive: bool,
795) -> Result<(), IocRunFailure> {
796    if !interactive {
797        // C `softMain.cpp:264-265`: `while (true) epicsThreadSleep(1000.0);`
798        // — the process exists to be killed. A future that never completes
799        // is the same forever and parks instead of holding a thread.
800        std::future::pending::<()>().await;
801        unreachable!("a pending future never completes");
802    }
803
804    let (tx, rx) = crate::runtime::sync::oneshot::channel();
805    // Mandatory for the same reason the two shells above are: this IS the
806    // process on this arm, so a thread that will not start is a boot
807    // failure and not something to carry on without.
808    crate::runtime::task::MandatoryThread::new(
809        "iocsh",
810        crate::runtime::task::ThreadPriority::Iocsh,
811        crate::runtime::task::StackSizeClass::Big,
812    )
813    .try_spawn(move || {
814        // C runs `iocsh(NULL)` on the thread `epicsThreadInit` lists as
815        // `_main_` (`osdThread.c:406-412`), as `CaServer::run_with_shell`
816        // does for the initialised arm.
817        crate::runtime::task::register_main_thread();
818        let shell = iocsh::IocShell::new_with_acf(db, bridge, acf);
819        let _ = tx.send(shell.run_repl());
820    })
821    .map_err(|e| CaError::InvalidValue(format!("could not start the iocsh thread: {e}")))?;
822
823    match rx.await {
824        Ok(Ok(())) => Ok(()),
825        // C `softMain.cpp:253-256`: a non-zero `iocsh(NULL)` is
826        // `epicsExit(1)`, which is this port's serving-phase status.
827        Ok(Err(e)) => Err(IocRunFailure::Serving(CaError::InvalidValue(e))),
828        Err(_) => Err(IocRunFailure::Serving(CaError::InvalidValue(
829            "shell thread dropped".into(),
830        ))),
831    }
832}
833
834/// Everything C's `iocBuild()` needs, and the only implementation of that
835/// transition in this crate.
836///
837/// **Invariant: the build and the run each happen exactly once per
838/// [`IocApplication::run`], and both are complete before the line after
839/// `iocInit` in the startup script executes.** [`IocLifecycle`] is the owner
840/// that enforces it, by consuming the value that stands for the state each
841/// transition starts from. This one is armed before the script runs and
842/// consumed by whichever entry point reaches it first — the script's own
843/// `iocInit` or `iocBuild` line, or [`IocApplication::run_to_completion`] when
844/// the script spelled neither. Because there is one implementation and one
845/// consumption, `iocInit` has a single meaning on both paths.
846///
847/// It used to have two. The shell's `iocInit` closed the record-load phase and
848/// nothing else, while the real build ran after the whole script — so every
849/// line a real `st.cmd` puts after `iocInit` (`dbpf`, `dbl`, `dbtr`,
850/// `asSetFilename`, `seq`) ran against an IOC with no device support, no scan
851/// threads and no PINI, and a script-loaded database never reached the protocol
852/// runner at all: `softioc-rs -S st.cmd` listed its records from `dbl` and
853/// served none of them to a CA client.
854/// The protocol runner, type-erased so the lifecycle can carry it.
855///
856/// [`IocApplication::run`] takes it as a generic closure; it has to reach
857/// [`BuiltIoc::run`], which is inside the lifecycle owner and cannot be
858/// generic over it.
859type ProtocolRunner = Box<
860    dyn FnOnce(
861            IocRunConfig,
862        ) -> std::pin::Pin<
863            Box<dyn std::future::Future<Output = CaResult<()>> + Send + 'static>,
864        > + Send
865        + 'static,
866>;
867
868/// What [`BuiltIoc::run`] needs to start the servers, carried from
869/// [`IocApplication::run_to_completion`] through the build untouched.
870struct ProtocolStart {
871    /// The runtime the runner is spawned onto. `BuiltIoc::run` is reached from
872    /// the `iocsh-startup` thread through `BlockingBridge::block_on`, which is
873    /// not a runtime thread, so the reactor has to be carried rather than
874    /// looked up.
875    bridge: crate::runtime::task::BlockingBridge,
876    port: u16,
877    tcp_port: Option<u16>,
878    acf: access_security::AcfCell,
879    autosave_config: Option<autosave::SaveSetConfig>,
880    runner: ProtocolRunner,
881}
882
883/// The started protocol runner, and the only handle to it.
884///
885/// **Once the runner has been spawned, no path may leave
886/// [`IocApplication::run_to_completion`] without this value having joined or
887/// stopped the task.** C needs no such rule: `rsrv_run` starts threads the
888/// process owns until `epicsExit`, and softMain's exits all run through it.
889/// Here the runner is one task with one outcome, and the outcome is this
890/// `run`'s return value, so it needs exactly one owner.
891///
892/// [`Self::wait`] and [`Self::shut_down`] are that owner's two answers; `Drop`
893/// is the backstop for the paths that reach neither — a `?` in the
894/// `afterIocRunning` drain below, a panic unwinding through `run_to_completion`,
895/// a startup thread that failed after the script had already run `iocInit`.
896struct ProtocolServer {
897    handle: crate::runtime::task::TaskHandle<CaResult<()>>,
898    /// The outcome [`Self::await_serving`] collected because the runner
899    /// finished before any layer announced. Held rather than reported on the
900    /// spot so the outcome still leaves through the one owner.
901    finished: Option<Result<(), IocRunFailure>>,
902    /// False once the outcome has been taken, so `Drop` does not abort a task
903    /// that has already been accounted for.
904    live: bool,
905}
906
907impl ProtocolServer {
908    /// The runner's own outcome. Cancel-safe: dropping this future (the signal
909    /// arms of the `select!` below) leaves `self.live` set, so the guard or
910    /// [`Self::shut_down`] still owns the task.
911    async fn wait(&mut self) -> Result<(), IocRunFailure> {
912        if let Some(collected) = self.finished.take() {
913            return collected;
914        }
915        let joined = (&mut self.handle).await;
916        self.live = false;
917        Self::outcome(joined)
918    }
919
920    /// C `iocRun`'s ordering, which the port has to wait for where C gets it
921    /// for free: return once a protocol layer has announced it is serving
922    /// (`db_server::announce_serving`, the port's `rsrv_run` return), or once
923    /// the runner has finished without ever announcing.
924    ///
925    /// Both arms are terminal — a runner is either serving or done — so this
926    /// is not a check that can be left waiting on a runner that forgot to
927    /// signal. A runner that serves nothing resolves through the second arm,
928    /// which is what every `async { Ok(()) }` in the tests does.
929    async fn await_serving(&mut self, generation: u64) {
930        tokio::select! {
931            biased;
932            joined = &mut self.handle => {
933                self.live = false;
934                self.finished = Some(Self::outcome(joined));
935            }
936            () = crate::server::db_server::serving_after(generation) => {}
937        }
938    }
939
940    /// The join result as the failure `run_to_completion` reports.
941    fn outcome(
942        joined: Result<CaResult<()>, crate::runtime::task::TaskJoinError>,
943    ) -> Result<(), IocRunFailure> {
944        match joined {
945            Ok(res) => res.map_err(IocRunFailure::Serving),
946            // A panic in the runner reached `run_to_completion` as an unwind
947            // before it was spawned, which skipped `run_phased`'s
948            // `call_at_exits`. As a value it takes the same exit every other
949            // serving failure does.
950            Err(e) => Err(IocRunFailure::Serving(CaError::InvalidValue(format!(
951                "protocol runner did not finish: {e}"
952            )))),
953        }
954    }
955
956    /// Stop the runner and wait until it is gone — what dropping the awaited
957    /// future used to do, made explicit now that the task outlives the await.
958    async fn shut_down(mut self) {
959        self.live = false;
960        self.handle.abort();
961        let _ = (&mut self.handle).await;
962    }
963}
964
965impl Drop for ProtocolServer {
966    fn drop(&mut self) {
967        if self.live {
968            self.handle.abort();
969        }
970    }
971}
972
973/// Drops whatever this `run` armed and did not consume.
974///
975/// The window is [`arm_build`] to the `take_lifecycle` that finishes the
976/// script's work: the startup script performs the build AND the run inside
977/// itself, so a failure of the startup thread — or a panic anywhere in that
978/// window — can return with an [`IocLifecycle::Running`], and its live
979/// [`ProtocolServer`], still parked in the static. C has no such window
980/// because its `iocInit()` and its `iocsh()` are statements in one `main`.
981struct ArmedLifecycle;
982
983impl Drop for ArmedLifecycle {
984    fn drop(&mut self) {
985        drop(take_lifecycle());
986    }
987}
988
989struct IocBuild {
990    db: Arc<PvDatabase>,
991    acf: access_security::AcfCell,
992    autosave_config: Option<autosave::SaveSetConfig>,
993    autosave_startup: Option<Arc<Mutex<AutosaveStartupConfig>>>,
994    link_set_installers: Vec<LinkSetInstaller>,
995    after_init_hooks: Vec<Box<dyn FnOnce() + Send>>,
996    protocol: ProtocolStart,
997}
998
999/// The IOC C `iocBuild()` leaves behind: [`IocState::Built`], quiescent, with
1000/// everything [`BuiltIoc::run`] needs to make it run.
1001struct BuiltIoc {
1002    db: Arc<PvDatabase>,
1003    autosave_manager: Option<Arc<autosave::AutosaveManager>>,
1004    /// Drained by the run half, at C's `initHookAfterIocRunning` point.
1005    after_init_hooks: Vec<Box<dyn FnOnce() + Send>>,
1006    /// Carried, not used: C's servers are initialised by `dbInitServers()` in
1007    /// `iocBuild` (`iocInit.c:222`) and only *started* by `dbRunServers()` in
1008    /// `iocRun` (`:265-267`), and this port's runner does both at once, so the
1009    /// whole of it belongs on the run half.
1010    protocol: ProtocolStart,
1011}
1012
1013/// What the run transition leaves for the phases after it: the running
1014/// servers, and nothing else. The autosave manager and the port numbers used
1015/// to be carried across it so Phase 3 could assemble an [`IocRunConfig`]; that
1016/// assembly is now [`BuiltIoc::run`]'s, at C's `dbRunServers` point.
1017struct RunningIoc {
1018    server: ProtocolServer,
1019}
1020
1021/// Where [`IocBuild::perform_build`] stops.
1022enum BuildOutcome {
1023    /// Boxed for the reason [`IocLifecycle::Armed`] is: `BuiltIoc` carries the
1024    /// database, the after-init hooks and the whole [`ProtocolStart`], and the
1025    /// other variant carries nothing.
1026    Built(Box<BuiltIoc>),
1027    /// `asInit` returned non-zero, so the build stopped there. Nothing here
1028    /// re-words the reason, which `asInitFile` has already written.
1029    AsInitFailed,
1030}
1031
1032/// One value, one lifecycle — and the whole of this `run`'s claim on it.
1033///
1034/// Each variant is a state C names, and each transition **consumes** the value
1035/// standing for the state it starts from, so "exactly once" is carried by
1036/// ownership rather than by a runtime `if already_built`: after
1037/// [`IocBuild::perform_build`] there is no `IocBuild` left to build again, and
1038/// after [`BuiltIoc::run`] there is no `BuiltIoc` left to run again.
1039enum IocLifecycle {
1040    /// Armed before the startup script; C's `iocVoid`.
1041    /// Boxed: `IocBuild` carries the whole database and every registry, and
1042    /// the other variants are a fraction of its size.
1043    Armed(Box<IocBuild>),
1044    /// C's `iocBuilt`. Boxed for the same reason [`Self::Armed`] is.
1045    Built(Box<BuiltIoc>),
1046    /// C's `iocRunning`.
1047    Running(RunningIoc),
1048    /// The build stopped at a failed `asInit`.
1049    AsInitFailed,
1050    /// A transition returned an error, held for `run_to_completion` to report.
1051    Failed(CaError),
1052}
1053
1054/// The single cell. The IOC lifecycle it belongs to is already process-global
1055/// (see [`set_ioc_state`]), so this is scoped the same way.
1056static LIFECYCLE_OWNER: Mutex<Option<IocLifecycle>> = Mutex::new(None);
1057
1058fn arm_build(build: IocBuild) {
1059    *LIFECYCLE_OWNER.lock().unwrap() = Some(IocLifecycle::Armed(Box::new(build)));
1060}
1061
1062fn take_lifecycle() -> Option<IocLifecycle> {
1063    LIFECYCLE_OWNER.lock().unwrap().take()
1064}
1065
1066fn put_lifecycle(state: IocLifecycle) {
1067    *LIFECYCLE_OWNER.lock().unwrap() = Some(state);
1068}
1069
1070/// What an iocsh lifecycle line did.
1071pub(crate) enum ShellTransition {
1072    /// This call performed the transition.
1073    Done,
1074    /// It performed it and the build failed; the line fails with it.
1075    Failed,
1076    /// This `run` owns a lifecycle, but not in the state this transition
1077    /// starts from — a second `iocBuild`, or `iocRun` with nothing built.
1078    Refused,
1079    /// This IOC has no [`IocApplication`] lifecycle to drive: every
1080    /// `CaServerBuilder` binary and every bare [`PvDatabase`] shell.
1081    NotOurs,
1082}
1083
1084/// The iocsh `iocBuild` line, and the build half of `iocInit`.
1085pub(crate) fn build_from_shell(bridge: &crate::runtime::task::BlockingBridge) -> ShellTransition {
1086    match take_lifecycle() {
1087        None => ShellTransition::NotOurs,
1088        Some(IocLifecycle::Armed(build)) => match bridge.block_on(build.perform_build()) {
1089            Ok(BuildOutcome::Built(built)) => {
1090                put_lifecycle(IocLifecycle::Built(built));
1091                ShellTransition::Done
1092            }
1093            Ok(BuildOutcome::AsInitFailed) => {
1094                put_lifecycle(IocLifecycle::AsInitFailed);
1095                ShellTransition::Failed
1096            }
1097            Err(e) => {
1098                put_lifecycle(IocLifecycle::Failed(e));
1099                ShellTransition::Failed
1100            }
1101        },
1102        Some(other) => {
1103            put_lifecycle(other);
1104            ShellTransition::Refused
1105        }
1106    }
1107}
1108
1109/// C `iocBuild_1`'s refusal (`iocInit.c:117-120`), which every caller of the
1110/// build shares because C has one `iocBuild_1` and reaches it from both
1111/// `iocBuild()` and `iocInit()` — which is why the sentence names `iocBuild`
1112/// even when `iocInit` is the line that failed.
1113///
1114/// It is a function because the port had three copies of it, one per arm that
1115/// could refuse, and a duplicated diagnostic drifts the moment one copy is
1116/// touched.
1117pub(crate) fn build_refusal() -> String {
1118    format!(
1119        "iocBuild: {} IOC can only be initialized from uninitialized or \
1120         stopped state\n",
1121        if crate::runtime::log::errlog_console_paints() {
1122            crate::runtime::log::ERL_ERROR
1123        } else {
1124            "ERROR"
1125        }
1126    )
1127}
1128
1129/// The build for a shell with no [`IocApplication`] behind it — every
1130/// `CaServerBuilder` binary and every bare [`PvDatabase`] shell.
1131///
1132/// C has no such shell: `iocBuild_1` runs for everyone, so the state cell
1133/// advances, `coreRelease()` prints and `Starting iocInit` is said whatever
1134/// built the IOC. This arm did none of that. It closed the record load and
1135/// returned, leaving `iocState` at [`IocState::Void`], which is why a
1136/// measured `iocBuild`/`iocRun` pair answered `iocRun: WARNING IOC not
1137/// paused` where C answers `iocRun: All initialization complete`, why a
1138/// second `iocBuild` was accepted where C refuses it, and why the
1139/// `coreRelease` banner C puts between the two command echoes was missing
1140/// from stdout.
1141///
1142/// `close_record_load` is the caller's half — it needs the shell's database
1143/// and its blocking bridge — and it sits exactly where C's record work sits,
1144/// after `coreRelease()` and inside [`IocState::Building`]. The transition
1145/// itself stays here because [`set_ioc_state`] is private to this module and
1146/// must remain the only writer.
1147///
1148/// The init hooks C announces around this (`initHookAtIocBuild`,
1149/// `initHookAtBeginning`, `initHookAfterIocBuilt`) are deliberately not
1150/// announced: they produce no output, so nothing measured says what a bare
1151/// shell should do with them, and firing hooks nobody has asked for is not
1152/// something a byte-parity fix should decide.
1153pub(crate) fn build_without_application(close_record_load: impl FnOnce()) -> bool {
1154    // C `iocBuild_1` (`iocInit.c:117-120`).
1155    if get_ioc_state() != IocState::Void {
1156        crate::runtime::log::errlog_printf(&build_refusal());
1157        return false;
1158    }
1159    // C `iocInit.c:129`, before anything the build prints.
1160    crate::runtime::log::errlog_printf("Starting iocInit\n");
1161    // C `iocInit.c:147`: `coreRelease()` immediately before the state moves,
1162    // and its `printf` (`misc/epicsRelease.c:23-27`) is the only stdout write
1163    // on the whole path.
1164    for line in crate::server::iocsh::misc_commands::core_release_block() {
1165        println!("{line}");
1166    }
1167    set_ioc_state(IocState::Building);
1168    close_record_load();
1169    // C `iocBuild_3` (`iocInit.c:205`).
1170    set_ioc_state(IocState::Built);
1171    true
1172}
1173
1174/// The iocsh `iocRun` line, and the run half of `iocInit`.
1175///
1176/// `Refused` is not an error the caller should print: it only means this line
1177/// has no freshly built IOC to consume, and the plain [`ioc_run`] transition —
1178/// which is what resumes a paused IOC — is the right next thing to try.
1179pub(crate) fn run_from_shell(bridge: &crate::runtime::task::BlockingBridge) -> ShellTransition {
1180    match take_lifecycle() {
1181        None => ShellTransition::NotOurs,
1182        Some(IocLifecycle::Built(built)) => {
1183            put_lifecycle(IocLifecycle::Running(bridge.block_on(built.run())));
1184            ShellTransition::Done
1185        }
1186        Some(other) => {
1187            put_lifecycle(other);
1188            ShellTransition::Refused
1189        }
1190    }
1191}
1192
1193impl IocBuild {
1194    /// C `iocBuild()` (`iocInit.c:210-231`), ending where `iocBuild_3` does:
1195    /// [`IocState::Built`], the quiescent state [`BuiltIoc::run`] is legal
1196    /// from. `initHookAfterIocBuilt` is announced on this half, as
1197    /// `iocBuild_3` announces it (`iocInit.c:201-207`).
1198    async fn perform_build(self) -> CaResult<BuildOutcome> {
1199        let Self {
1200            db,
1201            acf,
1202            autosave_config,
1203            autosave_startup,
1204            link_set_installers,
1205            after_init_hooks,
1206            protocol,
1207        } = self;
1208
1209        // Collect restore paths and builder from startup config (scoped mutex lock)
1210        let (pass0_files, pass1_files, builder_opt) = if let Some(ref config) = autosave_startup {
1211            let cfg = config.lock().unwrap();
1212            let pass0: Vec<std::path::PathBuf> = cfg
1213                .pass0_restores
1214                .iter()
1215                .map(|r| cfg.resolve_save_file(&r.filename))
1216                .collect();
1217            let pass1: Vec<std::path::PathBuf> = cfg
1218                .pass1_restores
1219                .iter()
1220                .map(|r| cfg.resolve_save_file(&r.filename))
1221                .collect();
1222            let builder = if !cfg.monitor_sets.is_empty() || !cfg.triggered_sets.is_empty() {
1223                Some(cfg.into_builder())
1224            } else {
1225                None
1226            };
1227            (pass0, pass1, builder)
1228        } else {
1229            (Vec::new(), Vec::new(), None)
1230        };
1231
1232        // initHooks subsystem (C `iocInit.c` / `initHooks.c` parity).
1233        //
1234        // Autosave pass-0 / pass-1 restore are no longer hard-coded
1235        // into the build flow: they are registered here as ordinary
1236        // init hooks (C autosave registers `initHookAfterInitDevSup`
1237        // for pass 0 and `initHookAfterInitDatabase` for pass 1).
1238        // Any third-party `init_hook_register` callback also fires at
1239        // the matching `init_hook_announce` point below. Because the
1240        // restore work is async and the C-parity `InitHookFunction`
1241        // is sync, autosave restores live in this local async-hook
1242        // table; `announce` below fires *both* tables.
1243        type AsyncHook = Box<
1244            dyn FnOnce()
1245                    -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>
1246                + Send
1247                + 'static,
1248        >;
1249        let mut lifecycle_hooks: Vec<(InitHookState, AsyncHook)> = Vec::new();
1250
1251        // Register pass-0 restore as an `AfterInitDevSup` hook.
1252        {
1253            let db_p0 = db.clone();
1254            let files = pass0_files.clone();
1255            lifecycle_hooks.push((
1256                InitHookState::AfterInitDevSup,
1257                Box::new(move || {
1258                    Box::pin(async move {
1259                        for sav_path in &files {
1260                            match autosave::restore_from_file(&db_p0, sav_path).await {
1261                                Ok(count) if count > 0 => {
1262                                    eprintln!(
1263                                        "pass0 restore: {count} PVs from {}",
1264                                        sav_path.display()
1265                                    );
1266                                }
1267                                Err(e) => {
1268                                    eprintln!(
1269                                        "pass0 restore warning: {} - {e}",
1270                                        sav_path.display()
1271                                    );
1272                                }
1273                                _ => {}
1274                            }
1275                        }
1276                    })
1277                }),
1278            ));
1279        }
1280        // Register pass-1 restore + SaveSetConfig restore as an
1281        // `AfterInitDatabase` hook.
1282        {
1283            let db_p1 = db.clone();
1284            let files = pass1_files.clone();
1285            let cfg_path = autosave_config.as_ref().map(|c| c.save_path.clone());
1286            lifecycle_hooks.push((
1287                InitHookState::AfterInitDatabase,
1288                Box::new(move || {
1289                    Box::pin(async move {
1290                        for sav_path in &files {
1291                            match autosave::restore_from_file(&db_p1, sav_path).await {
1292                                Ok(count) if count > 0 => {
1293                                    eprintln!(
1294                                        "pass1 restore: {count} PVs from {}",
1295                                        sav_path.display()
1296                                    );
1297                                }
1298                                Err(e) => {
1299                                    eprintln!(
1300                                        "pass1 restore warning: {} - {e}",
1301                                        sav_path.display()
1302                                    );
1303                                }
1304                                _ => {}
1305                            }
1306                        }
1307                        if let Some(path) = cfg_path {
1308                            match autosave::restore_from_file(&db_p1, &path).await {
1309                                Ok(count) if count > 0 => {
1310                                    eprintln!("autosave: restored {count} PVs");
1311                                }
1312                                Err(e) => {
1313                                    eprintln!("autosave restore warning: {} - {e}", path.display());
1314                                }
1315                                _ => {}
1316                            }
1317                        }
1318                    })
1319                }),
1320            ));
1321        }
1322
1323        // Fire an init-hook state: the C-parity sync `init_hook_*`
1324        // callbacks first, then the local async lifecycle hooks
1325        // (autosave restore). Drains every lifecycle hook matching
1326        // `state` out of the table so each fires exactly once.
1327        macro_rules! announce {
1328            ($state:expr) => {{
1329                let state = $state;
1330                init_hook_announce(state);
1331                let mut i = 0;
1332                while i < lifecycle_hooks.len() {
1333                    if lifecycle_hooks[i].0 == state {
1334                        let (_, hook) = lifecycle_hooks.remove(i);
1335                        hook().await;
1336                    } else {
1337                        i += 1;
1338                    }
1339                }
1340            }};
1341        }
1342
1343        // C `iocBuild_1` announces both of these while `iocState` is still
1344        // `iocVoid` (`iocInit.c:122` and `:145`) and assigns `iocBuilding`
1345        // only after them (`:148`), so an `initHookRegister` observer at
1346        // `AtBeginning` reads the PRE-build state. Announcing them below the
1347        // assignment showed it `Building` instead — the one thing this order
1348        // decides that no caller can see for itself.
1349        announce!(InitHookState::AtIocBuild);
1350        // C `iocBuild_1` (`iocInit.c:129`), between the two announces above and
1351        // through the errlog rather than stdout, so a subscriber that captures
1352        // the errlog sink sees the boot start exactly as it sees every later
1353        // `iocInit:` line. It is what `epics_oracle_rs::ioc`'s boot classifier
1354        // reads to tell "the IOC began initialising" from "the process printed
1355        // nothing", and this port emitted it nowhere.
1356        crate::runtime::log::errlog_printf("Starting iocInit\n");
1357        announce!(InitHookState::AtBeginning);
1358        // C `coreRelease()` stands between the announce and the assignment
1359        // (`iocInit.c:147`) and writes stdout, as its `printf` does
1360        // (`misc/epicsRelease.c:23-27`). The wording is the iocsh command's,
1361        // which is C's arrangement too: one function, two callers.
1362        for line in crate::server::iocsh::misc_commands::core_release_block() {
1363            println!("{line}");
1364        }
1365
1366        // iocBuild begins.
1367        set_ioc_state(IocState::Building);
1368        // C `scanInit` leaves the facility at `ctlPause` (`dbScan.c:199`)
1369        // and `iocRun`'s `scanRun` is what starts it, so nothing an
1370        // in-flight build wires up — an I/O Intr callback, a posted event
1371        // — can process a record before `initHookAfterInterruptAccept`.
1372        // The port's facility starts running (a bare `PvDatabase` has no
1373        // lifecycle to have paused it), so the build closes the gate here
1374        // rather than the facility opening it later.
1375        crate::server::scan::scan_pause();
1376        // C `initDatabase` registers the shutdown on the process exit list
1377        // (`epicsAtExit(exitDatabase, NULL)`, iocInit.c:579), and
1378        // `exitDatabase` is a one-line call to `iocShutdown`. Registered
1379        // here so `run`'s `call_at_exits()` stops the scan threads however
1380        // this IOC ends.
1381        crate::runtime::exit::at_exit("iocShutdown", || {
1382            ioc_shutdown();
1383        });
1384        // C `iocBuild_1` builds the callback facilities here and nowhere else
1385        // — `iocState = iocBuilding` then `taskwdInit(); callbackInit();`
1386        // immediately before this announce (`iocInit.c:148-153`). That
1387        // position is the whole of `callbackSetQueueSize`'s and
1388        // `callbackParallelThreads`'s contract: both refuse once the pool
1389        // exists (`callback.c:106-109`, `:162-165`) because the pool reads
1390        // their knobs when it is constructed, so a startup script's
1391        // `callbackSetQueueSize 5000` on the line before `iocInit` is only
1392        // honoured while nothing has constructed it earlier.
1393        crate::runtime::task::background_init();
1394        // Both of this IOC's access-security watchers spawn on the pool that
1395        // line just built (C `asCaStart`, reached from `asInitCommon`,
1396        // `asDbLib.c:147`). They are the reason moving `background_init`
1397        // alone changed nothing: the cell used to be created watching, and
1398        // that spawn built the pool before the script ran.
1399        access_security::start_acf_watchers(&db, &acf);
1400        announce!(InitHookState::AfterCallbackInit);
1401        announce!(InitHookState::AfterCaLinkInit);
1402
1403        // External link-set installers fire at `AfterCaLinkInit` — the C
1404        // `initHookAfterCaLinkInit` point — so every external link set is
1405        // registered on the database BEFORE `setup_cp_links` (Phase 2b,
1406        // below) warms Passive CP/CPP holders. A link set registered later
1407        // (e.g. inside the Phase-3 protocol runner) is too late: the warm's
1408        // `resolve_external_pv` open path no-ops when no matching link set
1409        // is installed, so a Passive holder of an external CP link never
1410        // opens its monitor and never processes on a remote change. Each
1411        // installer also yields its iocsh commands (`caxr`/`dbcaxr`, …),
1412        // registered on the process's command table the moment they exist —
1413        // C `iocshRegister` from a registrar reached by `initHookAfterCaLinkInit`.
1414        // This is the point that made the table have to be one: the startup
1415        // shell was constructed before this line, so a per-shell table could
1416        // never carry these names into the script, however they were handed
1417        // around afterwards.
1418        for installer in link_set_installers {
1419            for cmd in installer(db.clone()).await {
1420                iocsh::register_command(cmd);
1421            }
1422        }
1423
1424        announce!(InitHookState::AfterInitDrvSup);
1425        announce!(InitHookState::AfterInitRecSup);
1426
1427        // Phase 2b: iocInit. C order is initDevSup() → initHookAfterInitDevSup
1428        // (autosave pass 0) → initDatabase() (per-record init_record, where
1429        // devMotorAsyn's init_controller runs).
1430        //
1431        // C's `initDatabase` runs HERE, as a whole-database pass that binds each
1432        // record's dset and only then runs its `init_record`
1433        // (`drain_deferred_record_inits` → per record `attach_device_support`
1434        // ahead of `run_init_passes`). The record was published at
1435        // `dbLoadRecords` but its init deferred to this point, because a
1436        // record's device-support PORT can be configured by a startup command
1437        // AFTER the `dbLoadRecords` that created it (ADCore's
1438        // `NDTimeSeriesConfigure` builds the `*_TS` port), so binding the dset
1439        // at load would bind it against a port that does not exist yet. The
1440        // pass runs before `setup_io_intr` (C's `scanInit`) so a dset is bound
1441        // before I/O Intr wiring reads it. A pass0-restored field still lands as
1442        // a plain pre-init field write (C dbPut before init_record): this hook
1443        // fires before the startup script's `iocInit` line, and the records it
1444        // restores were loaded before that.
1445        announce!(InitHookState::AfterInitDevSup);
1446        db.drain_deferred_record_inits();
1447        let record_count = db.records_with_device_support().await;
1448        let io_intr_count = setup_io_intr(db.clone()).await;
1449        setup_property_posts(db.clone()).await;
1450        // C `dbInitLink`'s locality decision, committed once for the whole
1451        // database now that every record has loaded: a `Db` link naming a
1452        // record this IOC does not have becomes a `Ca` link
1453        // (`dbLink.c:118-130` falling through `dbDbInitLink`'s
1454        // `S_db_notFound`, `dbDbLink.c:94-96`). Runs BEFORE `setup_cp_links`
1455        // for C's reason — `initPVLinks` initialises links before anything
1456        // consumes them — and it is a one-shot: C guards re-entry with
1457        // `DBLINK_FLAG_INITIALIZED` (`dbLink.c:96-100`), so a record added
1458        // later at runtime does not un-convert a link already made external.
1459        db.initialize_link_locality().await;
1460        db.setup_cp_links().await;
1461        // Open the rest of the external links at init, as C does. Every
1462        // non-local `PV_LINK` reaches `dbCaAddLink` from `dbInitLink`
1463        // (`dbLink.c:118-130`) regardless of direction or CP/CPP policy, so a
1464        // C IOC's first scan finds an already-connecting channel.
1465        // `setup_cp_links` above covers only the CP/CPP subset; without this
1466        // pass every other external `INP`/`OUT`/`DOL`/`TSEL`/`SDIS`/`INPA..`
1467        // link pays one cold scan cycle to stage its own open. Runs here — the
1468        // same init phase, after link parsing and before scan start — and
1469        // after `setup_cp_links` so the `Db`→`Ca` rewrite it applies to
1470        // non-local CP holders is already visible to the enumeration.
1471        db.setup_external_link_opens().await;
1472
1473        // Phase 2b.5: wait for the CA links to local records to connect
1474        // before PINI runs (epics-base PR #768/#856 — `dbCa: iocInit
1475        // wait`). This is a CA-facility wait only: `pva://` links and
1476        // non-local CA links open in the background and never block
1477        // iocInit (pvxs parity — pvalink `linkGlobal_t::init` just opens
1478        // channels). Default 10s timeout, override via
1479        // `EPICS_RS_INIT_LINK_TIMEOUT` (seconds, fractional accepted).
1480        // Pass-through when no CA link set is registered.
1481        let link_wait_secs = crate::runtime::env::get("EPICS_RS_INIT_LINK_TIMEOUT")
1482            .and_then(|s| s.parse::<f64>().ok())
1483            .unwrap_or(10.0)
1484            .max(0.0);
1485        if link_wait_secs > 0.0 {
1486            let (connected, total) = db
1487                .wait_for_external_links(crate::runtime::time::duration_from_secs(link_wait_secs))
1488                .await;
1489            if total > 0 {
1490                if connected == total {
1491                    eprintln!("iocInit: {connected}/{total} external links connected");
1492                } else {
1493                    let unconnected = db.unconnected_external_links().await;
1494                    eprintln!(
1495                        "iocInit: {connected}/{total} external links connected after \
1496                         {link_wait_secs}s — proceeding without: {}",
1497                        unconnected.join(", ")
1498                    );
1499                }
1500            }
1501        }
1502
1503        // C: initDatabase() then initHookAfterInitDatabase (autosave
1504        // pass 1). The registered hook performs pass-1 + SaveSetConfig
1505        // restore.
1506        //
1507        // The `iocInit` barrier runs as part of initDatabase: every record the
1508        // startup script loaded now exists, so the link-status classifications
1509        // queued during the load run here, against the complete database. A
1510        // no-op when the script already spelled `iocInit` out.
1511        db.ioc_init().await;
1512        announce!(InitHookState::AfterInitDatabase);
1513        announce!(InitHookState::AfterFinishDevSup);
1514
1515        // C `iocBuild_2` (`iocInit.c:186-191`): `scanInit()`, then `asInit()`,
1516        // then `initHookAfterScanInit`. THIS is the second caller of
1517        // `asInitCommon`, and the port had only the iocsh command — so
1518        // `softioc-rs` queued a literal `asInit` line to stand in for it,
1519        // which ran before the startup script rather than after it and left
1520        // an `st.cmd` that names its own ACF with access security off.
1521        //
1522        // A non-zero `asInit` fails the build in C, and the two lines it then
1523        // writes are [`as_init_failed_message`]. Nothing here re-words the
1524        // REASON: C `asInitFile` reports an unreadable or unparseable ACF
1525        // itself, on stderr, and hands `asInitCommon` only a status
1526        // (`asLibRoutines.c:174-190`), which is why `as_init` returns an
1527        // outcome and not a message.
1528        let as_init = crate::server::iocsh::as_init(&acf);
1529        if let Some(message) = as_init.message() {
1530            println!("{message}");
1531        }
1532        if as_init.failed() {
1533            crate::runtime::log::errlog_printf(&as_init_failed_message(
1534                crate::runtime::log::errlog_console_paints(),
1535            ));
1536            // `iocBuild_2` returns -1, so `iocBuild` does, so `iocInit()`
1537            // does — and a non-zero `iocInit()` is REPORTED, not fatal:
1538            // C `softMain.cpp:239-243` prints one line and falls through to
1539            // the same tail the never-built arm takes. Measured against
1540            // R7.0.10.1-DEV with an unreadable ACF: interactive reaches the
1541            // prompt and exits 0 on EOF, `-S` stays alive and listens on
1542            // nothing, because `iocRun` — which starts RSRV — never ran.
1543            //
1544            // The line is softMain's, and softMain's tail already lives here
1545            // ([`run_uninitialized_tail`]); a return channel out of
1546            // `run_phased` would only move the same bytes further from the
1547            // moment C writes them, which is before the shell starts.
1548            // Straight to stderr as C's `std::cerr` is, so `ERL_ERROR`'s
1549            // escapes survive a non-terminal stream exactly as C's do.
1550            return Ok(BuildOutcome::AsInitFailed);
1551        }
1552
1553        announce!(InitHookState::AfterScanInit);
1554
1555        // Phase 2b.6: process PINI=YES records BEFORE the protocol
1556        // runner can accept client connections (H2 — match C's
1557        // iocBuild ordering: initialProcess() runs inside iocBuild,
1558        // before iocRun starts the CA server). Without this, a CA
1559        // client connecting in the first moments after IOC start
1560        // could `caget` a PINI record's UDF/default value instead of
1561        // its processed value. C guarantees this cannot happen.
1562        {
1563            // C `initialProcess()` (iocInit.c:653-657) — `piniProcess(menuPiniYES)`.
1564            db.pini_process(crate::server::record::PiniMode::Yes).await;
1565            // Publish completion: a later-started scan owner (or a
1566            // non-owner scheduler) sees PINI as already done — the
1567            // owner branch then skips its own PINI pass (exactly-once,
1568            // as C's `initialProcess`) and non-owners run their hooks
1569            // without blocking.
1570            db.mark_pini_done();
1571        }
1572        announce!(InitHookState::AfterInitialProcess);
1573
1574        // Phase 2d: Build AutosaveManager from startup config
1575        let autosave_manager = if let Some(builder) = builder_opt {
1576            // `build` cannot fail: a set it could not construct is reported
1577            // on the error log and carried as that set's error status, so
1578            // one bad `.req` file no longer costs the IOC every other set.
1579            let mgr = builder.build().await;
1580            eprintln!("autosave: {} save set(s) configured", mgr.set_names().len());
1581            Some(Arc::new(mgr))
1582        } else {
1583            None
1584        };
1585
1586        let total_records = db.all_record_names().await.len();
1587        // A line C does not have, kept because the three counts are what an
1588        // operator checks an `st.cmd` by — but said through the errlog, which
1589        // is where C puts everything the boot says. A raw `eprintln!` here
1590        // was outside `eltc`'s reach: measured, `eltc 0` then `iocInit`
1591        // silenced C's console completely and left this line and the CA
1592        // server's on the port's.
1593        crate::runtime::log::errlog_printf(&format!(
1594            "iocInit: {total_records} records, {record_count} with device support, {io_intr_count} I/O Intr\n"
1595        ));
1596
1597        // C: rsrv init / iocBuild end. The Rust CA/PVA listener is
1598        // owned by the protocol runner, but PINI is already complete
1599        // (Phase 2b.6) so announcing here keeps hook ordering correct
1600        // for consumers that key off these states.
1601        announce!(InitHookState::AfterCaServerInit);
1602        announce!(InitHookState::AfterIocBuilt);
1603        // C `iocBuild` ends here, and the IOC is quiescent — `iocBuilt` is
1604        // the state `iocRun` below is legal from.
1605        set_ioc_state(IocState::Built);
1606
1607        Ok(BuildOutcome::Built(Box::new(BuiltIoc {
1608            db,
1609            autosave_manager,
1610            after_init_hooks,
1611            protocol,
1612        })))
1613    }
1614}
1615
1616impl BuiltIoc {
1617    /// C `iocRun()` (`iocInit.c:250-277`) over the IOC this value owns.
1618    ///
1619    /// Consuming `self` is what keeps the transition exactly once: a
1620    /// `BuiltIoc` is produced only by [`IocBuild::perform_build`] and
1621    /// destroyed only here, so a second `iocRun` has no built IOC to run and
1622    /// the question never becomes a runtime check.
1623    async fn run(self) -> RunningIoc {
1624        let Self {
1625            db,
1626            autosave_manager,
1627            after_init_hooks,
1628            protocol,
1629        } = self;
1630
1631        // C `piniProcessHook` (iocInit.c:629-646): the hook registered by
1632        // `initialProcess()` runs `piniProcess(menuPiniRUN)` when
1633        // `initHookAtIocRun` is announced. It is a hook CONSUMER in C, not
1634        // part of `iocRun`'s body; the port's hook table is synchronous
1635        // and `pini_process` is not, so the pass runs here, immediately
1636        // before the transition that announces the state it keys on.
1637        db.pini_process(crate::server::record::PiniMode::Run).await;
1638        // Periodic scanning is owned by the IOC core, not by any protocol
1639        // server — a PVA-only or server-less IOC scans all the same. The
1640        // owner's PINI=YES pass is skipped (Phase 2b.6 already ran it and
1641        // published completion); the `try_claim_scan_start` claim it takes
1642        // keeps a protocol runner or embedded harness that starts another
1643        // owner parked and harmless.
1644        //
1645        // Handed to the lifecycle rather than held in a local: `iocShutdown`
1646        // is what stops the scan threads now, and `run` reaches it through
1647        // the `epicsAtExit` registration below on every exit path — which
1648        // the local could only do by returning from this function.
1649        // C `iocInit` is `iocBuild() || iocRun()` (iocInit.c:107-110), and
1650        // starting the owner is that `iocRun`: `ScanOwner::start` calls
1651        // [`note_scan_owner_started`], which runs the same transition the
1652        // shell's `iocRun` runs. One caller, so the two cannot announce
1653        // different things.
1654        adopt_scan_owner(crate::server::scan::ScanOwner::start(db.clone()));
1655        // C `piniProcessHook` at `initHookAfterIocRunning` (iocInit.c:637-639)
1656        // — `piniProcess(menuPiniRUNNING)`; a hook consumer for the same
1657        // reason as the PINI=RUN pass above, run immediately after the
1658        // transition that announces its state.
1659        db.pini_process(crate::server::record::PiniMode::Running)
1660            .await;
1661
1662        // H3: drain `after_init_hooks` HERE — a guaranteed drain
1663        // point inside `run`. These were previously moved into
1664        // `IocRunConfig.after_init_hooks` and silently dropped
1665        // unless the external protocol runner remembered to execute
1666        // the vector. `register_after_init` promises "run after
1667        // iocInit completes"; PINI is done and the database is
1668        // built, so this is the correct C `initHookAfterIocRunning`
1669        // equivalent point. The `IocRunConfig.after_init_hooks`
1670        // field is now always handed over EMPTY (kept for API
1671        // compatibility) so a runner cannot double-run them.
1672        for hook in after_init_hooks {
1673            hook();
1674        }
1675
1676        // C `iocRun` starts the servers HERE — `if (iocBuildMode ==
1677        // buildServers) { dbRunServers(); initHookAnnounce(
1678        // initHookAfterCaServerRunning); }` (`iocInit.c:265-268`), which
1679        // reaches `rsrv_run` (`caservertask.c:766-771`) and RETURNS, leaving
1680        // the CA server threads listening while softMain goes on to its
1681        // interactive `iocsh`. The port had the runner awaited by
1682        // `run_to_completion` instead, so the servers could not exist until
1683        // the whole startup script had finished: measured, a `st.cmd` of
1684        // `iocInit` then `casr` printed nothing and `ss` showed no listening
1685        // socket where C shows one.
1686        //
1687        // Spawned rather than awaited, for the same reason `rsrv_run` returns:
1688        // this transition is a statement in the middle of a script, not the
1689        // tail of the process. `ProtocolServer` is what keeps that from being
1690        // a detached task.
1691        let ProtocolStart {
1692            bridge,
1693            port,
1694            tcp_port,
1695            acf,
1696            autosave_config,
1697            runner,
1698        } = protocol;
1699        let config = IocRunConfig {
1700            db,
1701            port,
1702            tcp_port,
1703            acf,
1704            autosave_config,
1705            autosave_manager,
1706            // Retained for the runners that build an `IocRunConfig` by hand
1707            // (`run_ca_ioc`, `run_pva_ioc`, `softioc-rs`): it is how THEY get
1708            // their own `casr`/`caxr` onto the table. `IocApplication::run`
1709            // has already registered everything it owns on the process's one
1710            // command table, so it hands the field over EMPTY rather than
1711            // asking the runner to register the same names again.
1712            shell_commands: Vec::new(),
1713            // Drained above. Handed over empty so a protocol runner that still
1714            // inspects the field cannot double-run the hooks.
1715            after_init_hooks: Vec::new(),
1716        };
1717        // C's `iocRun` does not return until every layer is up, because
1718        // `dbRunServers()` is a plain call: `rsrv_run` flips the control words
1719        // and returns (`caservertask.c:766-771`) over sockets `dbInitServers()`
1720        // bound one phase earlier. Spawning the runner alone does not
1721        // reproduce that — the next script line would race the runner's own
1722        // bind and its `casr` registration, and a measured `ss` of 1 was a race
1723        // that happened to win. So the generation is sampled BEFORE the spawn
1724        // and awaited after it: the serve entry of each protocol server
1725        // announces (see [`crate::server::db_server::announce_serving`]), and
1726        // this line is where C's ordering is restored.
1727        let generation = crate::server::db_server::serving_generation();
1728        let mut server = ProtocolServer {
1729            handle: bridge.spawn(runner(config)),
1730            finished: None,
1731            live: true,
1732        };
1733        server.await_serving(generation).await;
1734
1735        RunningIoc { server }
1736    }
1737}
1738
1739/// IOC Application with st.cmd-style startup support.
1740pub struct IocApplication {
1741    port: u16,
1742    /// Optional TCP listen port override. `None` means "share with UDP
1743    /// discovery port". Set via [`Self::tcp_port`] or the
1744    /// `EPICS_CAS_SERVER_PORT` env var (resolved at run time).
1745    tcp_port: Option<u16>,
1746    device_factories: HashMap<String, DeviceSupportFactory>,
1747    dynamic_device_factory: Option<DynamicDeviceSupportFactory>,
1748    record_factories: HashMap<String, super::RecordFactory>,
1749    subroutine_registry: HashMap<String, Arc<SubroutineFn>>,
1750    acf: Option<access_security::AccessSecurityConfig>,
1751    autosave_config: Option<autosave::SaveSetConfig>,
1752    autosave_startup: Option<Arc<Mutex<AutosaveStartupConfig>>>,
1753    /// Every iocsh command this application contributes, in registration
1754    /// order. One list because there is one command table: the two public
1755    /// `register_*` methods differ only in the name a caller reads, and a
1756    /// command that existed in one shell and not the other was the defect
1757    /// they encoded.
1758    commands: Vec<CommandDef>,
1759    startup_script: Option<String>,
1760    /// Command lines run on the startup shell BEFORE the startup script,
1761    /// in the order queued. C `softMain.cpp:192-198,216-222` builds the
1762    /// same list out of `-d` and `-x` while it is still reading argv, so
1763    /// the records they load are already in the database when the script's
1764    /// first line runs.
1765    startup_lines: Vec<String>,
1766    /// Simple PVs added via the declarative builder.
1767    inline_pvs: Vec<(String, crate::types::EpicsValue)>,
1768    /// Records added via the declarative builder (Phase 7).
1769    inline_records: Vec<(String, Box<dyn Record>)>,
1770    /// Callbacks invoked after iocInit completes (e.g., start pollers).
1771    after_init_hooks: Vec<Box<dyn FnOnce() + Send>>,
1772    /// Async external link-set installers (CA links via `epics-ca-rs`'s
1773    /// `calink`, PVA links via the bridge's `pvalink`). Fired at the
1774    /// `AfterCaLinkInit` hook in [`Self::run`] — before `setup_cp_links`
1775    /// — so a Passive holder of an external CP link warms at iocInit.
1776    link_set_installers: Vec<LinkSetInstaller>,
1777    /// C softMain's own turn between the startup script and `iocInit`
1778    /// ([`Self::before_ioc_init`]). `None` is [`IocInitDecision::run`].
1779    ioc_init_gate: Option<Box<dyn FnOnce() -> IocInitDecision + Send>>,
1780}
1781
1782impl IocApplication {
1783    pub fn new() -> Self {
1784        // No context-free built-in device support: every base builtin
1785        // (`Soft Timestamp`, `stdio`, `Db State`, `getenv`) needs the record's
1786        // INST_IO `INP`/`OUT`, which only the dynamic factory's
1787        // `DeviceSupportContext` carries — so all base builtins are dispatched
1788        // below, and this static map starts empty (users register their own
1789        // context-free device support into it via `register_device_support`).
1790        let device_factories: HashMap<String, DeviceSupportFactory> = HashMap::new();
1791        Self {
1792            // SERVER-side port: caservertask.c:492-499 honours
1793            // EPICS_CAS_SERVER_PORT > EPICS_CA_SERVER_PORT > 5064.
1794            port: cas_server_port(),
1795            tcp_port: None,
1796            device_factories,
1797            // The base built-in device support — all needing the runtime
1798            // context (INP/OUT). Pre-registered as the base of the
1799            // dynamic-factory chain so a user's
1800            // `register_dynamic_device_support` factory takes priority and
1801            // falls through to here.
1802            dynamic_device_factory: Some(Box::new(
1803                crate::server::builtin_devices::builtin_dynamic_factory,
1804            )),
1805            record_factories: HashMap::new(),
1806            subroutine_registry: HashMap::new(),
1807            acf: None,
1808            autosave_config: None,
1809            autosave_startup: None,
1810            commands: Vec::new(),
1811            startup_script: None,
1812            startup_lines: Vec::new(),
1813            inline_pvs: Vec::new(),
1814            inline_records: Vec::new(),
1815            after_init_hooks: Vec::new(),
1816            link_set_installers: Vec::new(),
1817            ioc_init_gate: None,
1818        }
1819    }
1820
1821    /// Set the UDP discovery port (default: 5064).
1822    pub fn port(mut self, port: u16) -> Self {
1823        self.port = port;
1824        self
1825    }
1826
1827    /// Set the TCP listen port independently from the UDP discovery
1828    /// port (epics-base PR #69, `EPICS_CAS_SERVER_PORT`). Multiple IOCs
1829    /// on one host can each bind a unique TCP port while sharing the
1830    /// canonical 5064 UDP search port. When unset, the IOC resolves it
1831    /// at run time from `EPICS_CAS_SERVER_PORT`; if that's also unset,
1832    /// the TCP listener inherits [`Self::port`].
1833    pub fn tcp_port(mut self, port: u16) -> Self {
1834        self.tcp_port = Some(port);
1835        self
1836    }
1837
1838    /// Register a device support factory by DTYP name.
1839    /// Called during iocInit to wire device support to records.
1840    pub fn register_device_support<F>(mut self, dtyp: &str, factory: F) -> Self
1841    where
1842        F: Fn() -> Box<dyn DeviceSupport> + Send + Sync + 'static,
1843    {
1844        self.device_factories
1845            .insert(dtyp.to_string(), Box::new(factory));
1846        self
1847    }
1848
1849    /// Register a dynamic device support factory.
1850    ///
1851    /// Called as a fallback when a record's DTYP doesn't match any
1852    /// statically registered factory. The closure receives the DTYP name
1853    /// and returns `Some(device_support)` if it can handle that DTYP.
1854    ///
1855    /// Multiple calls are chained: new factory is tried first, then existing.
1856    pub fn register_dynamic_device_support<F>(mut self, factory: F) -> Self
1857    where
1858        F: Fn(&DeviceSupportContext) -> Option<Box<dyn DeviceSupport>> + Send + Sync + 'static,
1859    {
1860        if let Some(existing) = self.dynamic_device_factory.take() {
1861            self.dynamic_device_factory = Some(Box::new(move |ctx: &DeviceSupportContext| {
1862                factory(ctx).or_else(|| existing(ctx))
1863            }));
1864        } else {
1865            self.dynamic_device_factory = Some(Box::new(factory));
1866        }
1867        self
1868    }
1869
1870    /// Register an iocsh command on this application.
1871    ///
1872    /// C has one command table and one `iocshRegister`, so a registered name
1873    /// is callable from `st.cmd` and from the `epics>` prompt alike; this
1874    /// method and [`Self::register_shell_command`] are the same registration
1875    /// and are kept apart only because callers spell both.
1876    pub fn register_startup_command(mut self, cmd: CommandDef) -> Self {
1877        self.commands.push(cmd);
1878        self
1879    }
1880
1881    /// [`Self::register_startup_command`] under its other name. Registering a
1882    /// command twice, once through each, is the workaround the two shells used
1883    /// to need and now displaces the name with itself.
1884    pub fn register_shell_command(mut self, cmd: CommandDef) -> Self {
1885        self.commands.push(cmd);
1886        self
1887    }
1888
1889    /// The commands this application registers, in registration order.
1890    ///
1891    /// This is the surface a startup script is executed against — a command
1892    /// missing here is a fatal unknown command in `st.cmd`, before `iocInit`.
1893    /// Exposed so a pre-configured IOC (e.g. `AdIoc`) can be checked against
1894    /// the script it promises to run without booting a server. `CommandDef` is
1895    /// `Clone`, so a caller may also install these on an [`iocsh::IocShell`] of
1896    /// its own to exercise a script.
1897    pub fn startup_commands(&self) -> &[CommandDef] {
1898        &self.commands
1899    }
1900
1901    /// Register a callback to run after iocInit completes.
1902    ///
1903    /// Use this to start pollers and other periodic tasks that should
1904    /// not run during st.cmd execution or autosave restore.
1905    ///
1906    /// [`Self::run`] guarantees these fire — they are drained inside
1907    /// `run` at the `initHookAfterIocRunning` point (after PINI
1908    /// processing, before handoff to the protocol runner). They are
1909    /// NOT delegated to the protocol runner, so a custom runner does
1910    /// not need to remember to drain them.
1911    pub fn register_after_init(mut self, hook: impl FnOnce() + Send + 'static) -> Self {
1912        self.after_init_hooks.push(Box::new(hook));
1913        self
1914    }
1915
1916    /// Register an async external link-set installer.
1917    ///
1918    /// The installer is invoked by [`Self::run`] at the C
1919    /// `initHookAfterCaLinkInit` point — BEFORE
1920    /// [`PvDatabase::setup_cp_links`] warms Passive CP holders. It
1921    /// registers its external [`crate::server::database::LinkSet`] on the
1922    /// live database and returns any iocsh commands it owns, which [`Self::run`]
1923    /// registers on the process's command table as it receives them.
1924    ///
1925    /// This is the seam that makes external record links resolve by
1926    /// construction: registering the link set inside the Phase-3 protocol
1927    /// runner is too late, because `setup_cp_links`'s `resolve_external_pv`
1928    /// warm has already run and found no matching link set, so a Passive
1929    /// holder of an external CP/CPP link never opens its monitor. A
1930    /// CA-serving IOC wires `epics_ca_rs::calink::calink_link_set_install`
1931    /// here so CA links resolve with no further setup.
1932    pub fn register_link_set_installer<F, Fut>(mut self, installer: F) -> Self
1933    where
1934        F: FnOnce(Arc<PvDatabase>) -> Fut + Send + 'static,
1935        Fut: std::future::Future<Output = Vec<CommandDef>> + Send + 'static,
1936    {
1937        self.link_set_installers
1938            .push(Box::new(move |db| Box::pin(installer(db))));
1939        self
1940    }
1941
1942    /// C softMain's own turn between the startup script and `iocInit`
1943    /// (`softMain.cpp:236-245`).
1944    ///
1945    /// The one instant at which a caller can both observe that the script
1946    /// has finished and decide whether the IOC initialises at all — C has
1947    /// it because `iocsh(st.cmd)` and `iocInit()` are two statements in its
1948    /// `main`, and this port had collapsed them into one call. Unset means
1949    /// [`IocInitDecision::run`], so an application that never had C's
1950    /// `loadedDb` question boots exactly as it did before.
1951    ///
1952    /// The gate runs on the lifecycle's own task, after the startup shell
1953    /// has been joined, so anything it prints lands between the script's
1954    /// last line and the build's first.
1955    pub fn before_ioc_init(
1956        mut self,
1957        gate: impl FnOnce() -> IocInitDecision + Send + 'static,
1958    ) -> Self {
1959        self.ioc_init_gate = Some(Box::new(gate));
1960        self
1961    }
1962
1963    /// Set the startup script path (executed before iocInit).
1964    pub fn startup_script(mut self, path: &str) -> Self {
1965        self.startup_script = Some(path.to_string());
1966        self
1967    }
1968
1969    /// Queue one iocsh command line to run before the startup script.
1970    ///
1971    /// The pre-`iocInit` half of C's argv handling: `-d file.db` IS
1972    /// `dbLoadRecords("file.db", "macros")` called before `iocsh(st.cmd)`,
1973    /// and `-a`/`-x` are the same shape. Expressing those flags as lines on
1974    /// the startup shell keeps ONE loader — the command the script would
1975    /// have called itself — instead of a second implementation reachable
1976    /// only from the command line. A line that fails ends the boot with
1977    /// [`IocRunFailure::StartupCommand`], C's `errIf(..., "")`.
1978    pub fn startup_line(mut self, line: &str) -> Self {
1979        self.startup_lines.push(line.to_string());
1980        self
1981    }
1982
1983    /// Register a record type factory (e.g., "motor", "asyn").
1984    /// Avoids the global registry — factories are passed to IocBuilder.
1985    pub fn register_record_type<F>(mut self, type_name: &str, factory: F) -> Self
1986    where
1987        F: Fn() -> Box<dyn Record> + Send + Sync + 'static,
1988    {
1989        let factory: super::RecordFactory = Box::new(factory);
1990        super::db_loader::snapshot_declared_fields(type_name, &factory);
1991        self.record_factories.insert(type_name.to_string(), factory);
1992        self
1993    }
1994
1995    /// Register a subroutine function by name (for sub/aSub records).
1996    /// The closure returns the C `long` status (`Ok(0)` normal, `Ok(n<0)`
1997    /// raises `SOFT_ALARM`/`BRSV`; `aSub` publishes it as `VAL`).
1998    pub fn register_subroutine<F>(mut self, name: &str, func: F) -> Self
1999    where
2000        F: Fn(&mut dyn Record) -> CaResult<i64> + Send + Sync + 'static,
2001    {
2002        self.subroutine_registry
2003            .insert(name.to_string(), Arc::new(Box::new(func)));
2004        self
2005    }
2006
2007    /// Configure autosave with a save set configuration.
2008    pub fn autosave(mut self, config: autosave::SaveSetConfig) -> Self {
2009        self.autosave_config = Some(config);
2010        self
2011    }
2012
2013    /// Configure autosave startup (C-compatible iocsh commands).
2014    ///
2015    /// When set, autosave iocsh commands (`set_requestfile_path`, `create_monitor_set`,
2016    /// `set_pass0_restoreFile`, etc.) are registered as startup commands and populate
2017    /// the config during st.cmd execution. After iocInit, the config is consumed to
2018    /// build an `AutosaveManager`.
2019    pub fn autosave_startup(mut self, config: Arc<Mutex<AutosaveStartupConfig>>) -> Self {
2020        self.autosave_startup = Some(config);
2021        self
2022    }
2023
2024    /// Configure access security.
2025    pub fn acf(mut self, config: access_security::AccessSecurityConfig) -> Self {
2026        self.acf = Some(config);
2027        self
2028    }
2029
2030    // --- Declarative IOC Builder (Phase 7) ---
2031
2032    /// Add a typed record to the IOC (no .db file needed).
2033    ///
2034    /// ```rust,ignore
2035    /// IocApplication::new()
2036    ///     .record("sensor:temp", AiRecord::new(0.0))
2037    ///     .record("heater:sp", AoRecord::new(0.0))
2038    ///     .run(my_runner).await
2039    /// ```
2040    pub fn record(mut self, name: &str, record: impl Record) -> Self {
2041        self.inline_records
2042            .push((name.to_string(), Box::new(record)));
2043        self
2044    }
2045
2046    /// Add a pre-boxed record.
2047    pub fn record_boxed(mut self, name: &str, record: Box<dyn Record>) -> Self {
2048        self.inline_records.push((name.to_string(), record));
2049        self
2050    }
2051
2052    /// Add a simple PV, created before the startup script runs.
2053    ///
2054    /// Same instant as [`Self::record`], and before it, which is the order
2055    /// `IocBuilder::build` uses for the same two sources.
2056    pub fn pv(mut self, name: &str, initial: crate::types::EpicsValue) -> Self {
2057        self.inline_pvs.push((name.to_string(), initial));
2058        self
2059    }
2060
2061    /// Run the full IOC lifecycle: startup script -> iocInit -> the tail.
2062    ///
2063    /// The `protocol_runner` closure receives an [`IocRunConfig`] containing the
2064    /// fully initialized database, port, and configuration. It is responsible for
2065    /// starting the protocol-specific server (e.g., CA, PVA) and the interactive
2066    /// shell. It is SPAWNED by `BuiltIoc::run`, at C's `iocRun` ->
2067    /// `dbRunServers()` point (`iocInit.c:265-267`), so the servers are up
2068    /// while the rest of the startup script runs; this function then waits for
2069    /// it the way softMain waits on its `iocsh(NULL)`.
2070    /// Every way out of the IOC — the runner finishing, a signal, a failure
2071    /// during load or `iocInit` — runs the process's exit callbacks before
2072    /// returning. That is C's arrangement: `softIoc`'s `main` reaches all six
2073    /// of its exits through `epicsExit(status)` (`softMain.cpp:167`, `:172`,
2074    /// `:251`, `:265`, `:270`, `:277`), and `epicsExit` runs the list first
2075    /// (`epicsExit.c:172-177`).
2076    ///
2077    /// This wrapper is where that becomes structural rather than remembered:
2078    /// the whole lifecycle sits in `Self::run_to_completion`, whose every
2079    /// `?` returns *here*, so no exit path can be added later that skips the
2080    /// teardown. Ports registered themselves at creation
2081    /// (`asyn`'s `create_port_runtime`, C's `registerPort` at
2082    /// `asynManager.c:2097`), so this is where a driver's `Drop` finally runs
2083    /// and its device gets the goodbye it is owed.
2084    ///
2085    /// [`crate::runtime::exit::call_at_exits`] runs the list once per process,
2086    /// so a second `run` — a test that boots two IOCs — tears down what the
2087    /// first left, not what the first already tore down.
2088    pub async fn run<F, Fut>(self, protocol_runner: F) -> CaResult<()>
2089    where
2090        F: FnOnce(IocRunConfig) -> Fut + Send + 'static,
2091        Fut: std::future::Future<Output = CaResult<()>> + Send + 'static,
2092    {
2093        self.run_phased(protocol_runner)
2094            .await
2095            .map_err(CaError::from)
2096    }
2097
2098    /// [`Self::run`], reporting which phase of the lifecycle failed.
2099    ///
2100    /// For a caller that has to reproduce C softIoc's exit statuses, where
2101    /// the same `CaError` means 2 before the protocol runner starts and 1
2102    /// after it (`softMain.cpp:247-279`). Everything else wants [`Self::run`],
2103    /// which throws the phase away.
2104    pub async fn run_phased<F, Fut>(self, protocol_runner: F) -> Result<(), IocRunFailure>
2105    where
2106        F: FnOnce(IocRunConfig) -> Fut + Send + 'static,
2107        Fut: std::future::Future<Output = CaResult<()>> + Send + 'static,
2108    {
2109        let result = self.run_to_completion(protocol_runner).await;
2110        crate::runtime::exit::call_at_exits();
2111        result
2112    }
2113
2114    /// The IOC lifecycle itself. Private, and reached only through
2115    /// [`Self::run`], because leaving it by any route has to run the exit
2116    /// callbacks and only that wrapper does.
2117    async fn run_to_completion<F, Fut>(self, protocol_runner: F) -> Result<(), IocRunFailure>
2118    where
2119        F: FnOnce(IocRunConfig) -> Fut + Send + 'static,
2120        Fut: std::future::Future<Output = CaResult<()>> + Send + 'static,
2121    {
2122        let db = Arc::new(PvDatabase::new());
2123        // Everything from here to the `db.ioc_init()` barrier below is C's
2124        // pre-`iocInit` load: inline records, then the `st.cmd`'s
2125        // `dbLoadRecords` calls. Records created in it queue their link-status
2126        // classification instead of running it against a database that is still
2127        // being built (R18-92).
2128        db.begin_load()
2129            .expect("a database created a line ago has not run iocInit");
2130
2131        let bridge = crate::runtime::task::BlockingBridge::capture();
2132
2133        let Self {
2134            port,
2135            tcp_port,
2136            device_factories,
2137            dynamic_device_factory,
2138            record_factories,
2139            subroutine_registry,
2140            acf,
2141            autosave_config,
2142            autosave_startup,
2143            mut commands,
2144            startup_script,
2145            startup_lines,
2146            inline_pvs,
2147            inline_records,
2148            after_init_hooks,
2149            link_set_installers,
2150            ioc_init_gate,
2151        } = self;
2152
2153        // The IOC's single live policy cell, created BEFORE the startup
2154        // script runs so the script's `asInit` and the servers built
2155        // afterwards observe the same store (upstream issue #667
2156        // adjacent: a config that only lands in a shell-local copy is
2157        // access security silently OFF).
2158        // The cell only. Its two watcher tasks — the ASG `INP*` monitor and
2159        // the HAG DNS refresher — run on the callback pool, so starting them
2160        // here would build that pool before the script's first line and take
2161        // `callbackSetQueueSize` away from it; `perform_build` starts them at
2162        // C's `callbackInit` point instead. Nothing serves this database
2163        // until Phase 3, so the unwatched window is not one in which a
2164        // client can hold a stale grant.
2165        let acf = access_security::new_acf_cell(acf);
2166
2167        // Register record type factories with global registry so dbLoadRecords
2168        // (called from st.cmd) can find them. This bridges the injected factories
2169        // to the global registry that the iocsh dbLoadRecords command uses.
2170        for (name, factory) in record_factories {
2171            super::db_loader::register_record_type(&name, factory);
2172        }
2173
2174        // Register autosave startup commands if configured
2175        if let Some(ref config) = autosave_startup {
2176            let cmds = AutosaveStartupConfig::register_startup_commands(config.clone());
2177            commands.extend(cmds);
2178        }
2179
2180        // Register the QSRV `dbLoadGroup` startup command so a
2181        // pvxs-compatible st.cmd can queue group definition files before
2182        // iocInit (pvxs registers it from its registrar before the
2183        // startup script). The QSRV protocol runner drains the queue and
2184        // applies it to the served provider; a non-QSRV runner never
2185        // drains it, leaving the command a harmless no-op.
2186        commands.push(db_load_group_startup_command());
2187
2188        // C has every command registered before it reads a script: the
2189        // registrars run from `registerRecordDeviceDriver` and `dbLoadDatabase`,
2190        // which softMain calls before `iocsh(st.cmd)` (`softMain.cpp:181-232`).
2191        // One registration onto the one table, so each of the shells below —
2192        // the script's, the `afterIocRunning` queue's, the interactive tail's,
2193        // the protocol runner's — has the name without being handed it.
2194        for cmd in commands {
2195            iocsh::register_command(cmd);
2196        }
2197
2198        // C's device support table and its function registry are
2199        // process-global and complete BEFORE the first `dbLoadRecords`: the
2200        // registrars run from `registerRecordDeviceDriver`, which softMain
2201        // calls before `iocsh(st.cmd)` (`softMain.cpp:181-232`). Install both
2202        // on the database at that same point, because the record creation sink
2203        // now consults them at C's positions — the dset bound ahead of
2204        // `init_record(0)`, the SNAM resolved inside pass 1. Applying them
2205        // afterwards, in a second whole-database pass, is exactly what let
2206        // every record type's `init_record` run the tail C's early returns
2207        // skip.
2208        db.install_device_support_resolver(device_support_resolver(
2209            device_factories,
2210            dynamic_device_factory,
2211        ));
2212        db.install_subroutine_registry(subroutine_registry).await;
2213
2214        // Add inline PVs then inline records — `IocBuilder::build`'s order
2215        // for the same two sources, and before the script for C's reason:
2216        // everything argv named is in the database when the script starts.
2217        for (name, value) in inline_pvs {
2218            db.add_pv(&name, value).await?;
2219        }
2220        for (name, record) in inline_records {
2221            db.add_record(&name, record).await?;
2222        }
2223
2224        // Arm the build BEFORE the script runs. That is what gives `iocInit`
2225        // one meaning: the script's own `iocInit` line performs this build, so
2226        // the line after it — a `dbpf`, a `dbl`, an `asSetFilename` — runs
2227        // against an IOC that has device support, scan threads and PINI behind
2228        // it. When the script never spells `iocInit`, the turn after Phase 1
2229        // performs the same build instead.
2230        arm_build(IocBuild {
2231            db: db.clone(),
2232            acf: acf.clone(),
2233            autosave_config: autosave_config.clone(),
2234            autosave_startup,
2235            link_set_installers,
2236            after_init_hooks,
2237            // C parity (`caservertask.c:492-500`): the server-side env var
2238            // EPICS_CAS_SERVER_PORT sets `ca_server_port`, and `ca_udp_port =
2239            // ca_server_port` — so UDP and TCP bind the same value unless the
2240            // Rust-extension `.tcp_port(...)` explicitly splits them. The
2241            // `port` field already incorporates the CAS / CA / default
2242            // precedence via `cas_server_port()` (see `IocApplication::new`);
2243            // `tcp_port` stays `Some(...)` only when the caller explicitly
2244            // invoked `.tcp_port(...)`.
2245            protocol: ProtocolStart {
2246                bridge: bridge.clone(),
2247                port,
2248                tcp_port,
2249                acf: acf.clone(),
2250                autosave_config,
2251                runner: Box::new(move |config| Box::pin(protocol_runner(config))),
2252            },
2253        });
2254        // From here the static can hold a running IOC, and a running IOC owns
2255        // a spawned protocol runner. Nothing may return past this line without
2256        // that value being dropped.
2257        let _armed = ArmedLifecycle;
2258
2259        // Phase 1: Execute the queued command lines and then the startup
2260        // script, on ONE shell, in a separate std::thread. std::thread (not
2261        // spawn_blocking) is required because iocsh commands use
2262        // Handle::block_on() which panics inside the tokio runtime context.
2263        //
2264        // One shell for both because C uses one iocsh context too: a
2265        // `dbLoadRecords` from `-d` and one from the script are the same
2266        // call, so a `dbPutAttribute` or `dbLoadTemplate` queued here must
2267        // be visible to the script exactly as it is to a later script line.
2268        if startup_script.is_some() || !startup_lines.is_empty() {
2269            // C's `iocsh(pathname)` returns before `iocsh(NULL)` is reached
2270            // (`softMain.cpp:231`, `:250`). The script's `iocInit` line now
2271            // starts the protocol runner, whose interactive shell is a thread
2272            // of its own, so that ordering has to be held rather than implied
2273            // — see `iocsh::STARTUP_SCRIPT_PHASE`. A guard, so a failed load
2274            // ends the phase too.
2275            let _script_phase = iocsh::startup_script_phase();
2276            let script = startup_script;
2277            let db1 = db.clone();
2278            let b1 = bridge.clone();
2279            let acf1 = acf.clone();
2280
2281            let (tx, rx) = crate::runtime::sync::oneshot::channel();
2282            // Mandatory: the startup script is what loads this IOC's database.
2283            // Booting on without it would serve an empty or half-loaded IOC.
2284            // `try_spawn` rather than `spawn` because this *is* a fallible boot
2285            // step — the error reaches `run`'s caller, which then never starts
2286            // serving, so there is no need to abort the process.
2287            crate::runtime::task::MandatoryThread::new(
2288                "iocsh-startup",
2289                // C bands the thread that runs iocsh, and for the reason
2290                // this thread has too — see `iocsh_threads_take_the_iocsh_band`.
2291                crate::runtime::task::ThreadPriority::Iocsh,
2292                // The shell runs arbitrary registered commands, which reach
2293                // record processing and device support — the same depth the
2294                // callback bands get, so the same class they use.
2295                crate::runtime::task::StackSizeClass::Big,
2296            )
2297            .try_spawn(move || {
2298                let shell = iocsh::IocShell::new_with_acf(db1, b1, acf1);
2299                let _ = tx.send(run_startup_phase(&shell, &startup_lines, script.as_deref()));
2300            })
2301            .map_err(|e| {
2302                CaError::InvalidValue(format!("could not start the iocsh-startup thread: {e}"))
2303            })?;
2304
2305            rx.await
2306                .map_err(|_| CaError::InvalidValue("startup thread dropped".into()))??;
2307        }
2308
2309        // C softMain's turn between `iocsh(st.cmd)` and `iocInit()`
2310        // (`softMain.cpp:236-245`). Outside the `if` above because C asks
2311        // its question whatever the flags were — a `softIoc` with neither a
2312        // script nor a `-d` still reaches this line, and is exactly the case
2313        // that must NOT build.
2314        // `None` is an application that never installed the gate, and that is
2315        // not the same as answering `Run`: such a caller is not C softMain,
2316        // has not answered C's `-S`, and owns no tail of its own — the
2317        // protocol runner is the whole of its tail. It therefore gets a
2318        // failed build as an `Err` rather than a shell it never asked for.
2319        let decision = ioc_init_gate.map(|gate| gate());
2320        // Finish whatever the startup script left undone. Each arm is a state
2321        // the script could have stopped in, and the value it carries is this
2322        // `run`'s only claim on the transition out of it.
2323        let outcome = match take_lifecycle() {
2324            // The script spelled neither `iocInit` nor `iocBuild`, so this is
2325            // the turn that decides whether the IOC is built at all.
2326            Some(IocLifecycle::Armed(build)) => {
2327                if let Some(decision) = decision
2328                    && !decision.run
2329                {
2330                    return run_uninitialized_tail(db, bridge, acf, decision.interactive).await;
2331                }
2332                match build.perform_build().await? {
2333                    BuildOutcome::Built(built) => Ok(built.run().await),
2334                    BuildOutcome::AsInitFailed => Err(()),
2335                }
2336            }
2337            // The script spelled `iocBuild` and never `iocRun`. C's softMain
2338            // would call `iocInit()`, watch `iocBuild_1` refuse from a state
2339            // that is not `iocVoid`, and leave the IOC quiescent — bound to no
2340            // port and serving nothing, which is the failure this whole owner
2341            // exists to remove. Finish the transition the script started, and
2342            // say so rather than doing it silently.
2343            Some(IocLifecycle::Built(built)) => {
2344                crate::runtime::log::errlog_printf(
2345                    "iocInit: startup script built the IOC without running it; running it now\n",
2346                );
2347                Ok(built.run().await)
2348            }
2349            // The script's `iocInit`, or its `iocBuild` and `iocRun`, already
2350            // did it. There is no second build to gate and no second decision
2351            // to take: the script asked for the IOC to be built and run, which
2352            // is what makes every line after those — and the protocol runner
2353            // below — see a running IOC.
2354            Some(IocLifecycle::Running(running)) => Ok(running),
2355            Some(IocLifecycle::AsInitFailed) => Err(()),
2356            Some(IocLifecycle::Failed(e)) => return Err(e.into()),
2357            None => unreachable!(
2358                "the lifecycle owner is armed before the startup script runs and \
2359                 every transition puts a state back"
2360            ),
2361        };
2362        let running = match outcome {
2363            Ok(running) => running,
2364            // `iocBuild_2` returns -1, so `iocBuild` does, so `iocInit()`
2365            // does — and a non-zero `iocInit()` is REPORTED, not fatal:
2366            // C `softMain.cpp:239-243` prints one line and falls through to
2367            // the same tail the never-built arm takes. Measured against
2368            // R7.0.10.1-DEV with an unreadable ACF: interactive reaches the
2369            // prompt and exits 0 on EOF, `-S` stays alive and listens on
2370            // nothing, because `iocRun` — which starts RSRV — never ran.
2371            Err(()) => {
2372                return match decision {
2373                    Some(decision) => {
2374                        eprintln!("{} during iocInit()", crate::runtime::log::ERL_ERROR);
2375                        run_uninitialized_tail(db, bridge, acf, decision.interactive).await
2376                    }
2377                    None => Err(IocRunFailure::Startup(CaError::InvalidValue(
2378                        "iocBuild: asInit Failed.".into(),
2379                    ))),
2380                };
2381            }
2382        };
2383        // Held as a live local across everything below: the runner is already
2384        // serving, and a `?` from here on must stop it. `ProtocolServer`'s
2385        // `Drop` is what makes that true of a return this function does not
2386        // yet have.
2387        let mut server = running.server;
2388
2389        // Phase 2e: drain `afterIocRunning` queue (epics-base PR #558).
2390        // Each line is an iocsh command queued by the startup script;
2391        // execute through a fresh shell so post-init state (including
2392        // PINI side effects) is visible. It reads the same command table
2393        // every other shell does, so a site-specific name like `motorReport`
2394        // is addressable from the post-init queue with no re-registration.
2395        let pending = db.take_after_ioc_running();
2396        if !pending.is_empty() {
2397            let db1 = db.clone();
2398            let b1 = bridge.clone();
2399            let acf1 = acf.clone();
2400            let (tx, rx) = crate::runtime::sync::oneshot::channel();
2401            // Mandatory for the same reason as "iocsh-startup": the queue holds
2402            // commands the startup script deferred to post-init, so skipping it
2403            // hands the operator an IOC that is missing part of its own boot.
2404            // Still inside `run`, so the failure propagates rather than aborts.
2405            crate::runtime::task::MandatoryThread::new(
2406                "iocsh-after-ioc-running",
2407                // Same reasoning as "iocsh-startup" above.
2408                crate::runtime::task::ThreadPriority::Iocsh,
2409                // Same reasoning as "iocsh-startup" above.
2410                crate::runtime::task::StackSizeClass::Big,
2411            )
2412            .try_spawn(move || {
2413                let shell = iocsh::IocShell::new_with_acf(db1, b1, acf1);
2414                let mut errs: Vec<String> = Vec::new();
2415                for line in pending {
2416                    // A queued line fails in either of the two ways C's
2417                    // `scope.errored` covers: with a diagnostic for the
2418                    // caller to print (`Err`), or having already printed
2419                    // its own (`CommandOutcome::Failed` — an unregistered
2420                    // command reports itself at `iocsh.cpp:1302`, and the
2421                    // `db*` commands that answer a bare non-zero do the
2422                    // same). Reading only `Err` dropped the second kind
2423                    // from this summary.
2424                    match shell.execute_line(&line) {
2425                        Err(e) => errs.push(format!("{line}: {e}")),
2426                        Ok(iocsh::registry::CommandOutcome::Failed) => {
2427                            errs.push(format!("{line}: failed"));
2428                        }
2429                        Ok(
2430                            iocsh::registry::CommandOutcome::Continue
2431                            | iocsh::registry::CommandOutcome::Exit,
2432                        ) => {}
2433                    }
2434                }
2435                let _ = tx.send(errs);
2436            })
2437            .map_err(|e| {
2438                CaError::InvalidValue(format!(
2439                    "could not start the iocsh-after-ioc-running thread: {e}"
2440                ))
2441            })?;
2442            if let Ok(errs) = rx.await {
2443                for e in errs {
2444                    eprintln!("afterIocRunning: {e}");
2445                }
2446            }
2447        }
2448
2449        // Phase 3: what softMain does once `iocInit()` has returned — wait for
2450        // the process to be told to stop. The servers have been running since
2451        // the `iocInit` line (see `BuiltIoc::run`); what is left here is the
2452        // runner's own tail, which for `run_ca_ioc` and `run_pva_ioc` is the
2453        // interactive `iocsh(NULL)` of `softMain.cpp:250`.
2454        //
2455        // epics-base PR #671 parity: race it against SIGTERM/SIGINT so a `kill`
2456        // (or Ctrl+C on the controlling terminal) cleanly returns Ok(()) instead
2457        // of leaving the future suspended forever. The CA/PVA runners already
2458        // wire their own signal handlers when used standalone; this one covers
2459        // the `IocApplication::run` entry point where the runner closure may
2460        // not (e.g., a custom user runner that only sleeps on `pending()`).
2461        // SIGINT/SIGTERM racing is host-only: `tokio::signal` needs the tokio
2462        // `signal` feature (signal-hook-registry + mio), which is dropped for
2463        // both embedded targets (RTEMS, VxWorks). On either, both arms are
2464        // `pending()`, so `run` simply awaits the runner; process-signal
2465        // shutdown is the embedded driver's concern (a later increment).
2466        // Both embedded targets are `cfg(unix)` too, so the guard is
2467        // `all(unix, not(epics_embedded_target))`, not `unix` alone.
2468        #[cfg(not(epics_embedded_target))]
2469        let ctrl_c = async {
2470            let _ = tokio::signal::ctrl_c().await;
2471        };
2472        #[cfg(epics_embedded_target)]
2473        let ctrl_c = std::future::pending::<()>();
2474        #[cfg(all(unix, not(epics_embedded_target)))]
2475        let sigterm = async {
2476            if let Ok(mut sig) =
2477                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
2478            {
2479                let _ = sig.recv().await;
2480            } else {
2481                std::future::pending::<()>().await;
2482            }
2483        };
2484        #[cfg(not(all(unix, not(epics_embedded_target))))]
2485        let sigterm = std::future::pending::<()>();
2486
2487        let outcome = tokio::select! {
2488            biased;
2489            // Runner takes priority: if it completes naturally
2490            // before any signal arrives, propagate its result. This is the
2491            // one arm that is past C's `try` block, so it is the one arm
2492            // whose failure is not the catch block's.
2493            res = server.wait() => Some(res),
2494            _ = ctrl_c => {
2495                tracing::info!(target: "epics_base_rs::ioc_app", "SIGINT received, shutting down IOC");
2496                None
2497            }
2498            _ = sigterm => {
2499                tracing::info!(target: "epics_base_rs::ioc_app", "SIGTERM received, shutting down IOC");
2500                None
2501            }
2502        };
2503        match outcome {
2504            Some(res) => res,
2505            // The signal arms dropped `ProtocolServer::wait` without taking the
2506            // outcome, so the task is still this value's to stop. Awaiting the
2507            // abort — rather than letting `Drop` fire it — is what keeps the
2508            // process's exit callbacks, which `run_phased` runs the moment this
2509            // returns, from racing a server that is still on a socket.
2510            None => {
2511                server.shut_down().await;
2512                Ok(())
2513            }
2514        }
2515    }
2516}
2517
2518/// The process-wide device support table, seen the way C's `dbDTYPtoDevSup`
2519/// sees it: a DTYP plus the record's links in, a dset (or nothing) out.
2520///
2521/// Held by [`PvDatabase`] rather than by the two builders because C's table is
2522/// global and filled by registrars BEFORE any record exists, while the port's
2523/// factories used to reach the records only after the whole database had been
2524/// built — which is what put dset resolution after `init_record`.
2525pub type DeviceSupportResolver =
2526    Arc<dyn Fn(&DeviceSupportContext) -> Option<Box<dyn DeviceSupport>> + Send + Sync>;
2527
2528/// Fold the two registration shapes a builder collects — the DTYP-keyed
2529/// context-free factories and the single dynamic factory — into the one
2530/// lookup [`PvDatabase`] holds. DTYP-keyed first, dynamic as the fallback:
2531/// the priority `attach_device_support` had.
2532pub(crate) fn device_support_resolver(
2533    factories: HashMap<String, DeviceSupportFactory>,
2534    dynamic_factory: Option<DynamicDeviceSupportFactory>,
2535) -> DeviceSupportResolver {
2536    Arc::new(move |ctx: &DeviceSupportContext| {
2537        if let Some(factory) = factories.get(ctx.dtyp) {
2538            Some(factory())
2539        } else if let Some(dyn_factory) = dynamic_factory.as_ref() {
2540            dyn_factory(ctx)
2541        } else {
2542            None
2543        }
2544    })
2545}
2546
2547/// C `iocInit.c::doInitRecord0`'s `precord->dset = pdevSup ? pdevSup->pdset :
2548/// NULL` (`:530-533`), and the refusal every `<rec>Record.c init_record` makes
2549/// when that comes back NULL, for one record. Returns whether a device
2550/// attached.
2551///
2552/// **The single owner**, for the reason [`wire_subroutine`] is one:
2553/// `IocBuilder` wires its records as it installs them and `IocApplication`
2554/// wires them from the database afterwards, and the two had already diverged —
2555/// the builder route reported a missing DTYP not at all, so an IOC built
2556/// through it booted in silence holding records that can never process. A DTYP
2557/// nobody registered is the same broken record on both.
2558///
2559/// Called by [`PvDatabase::add_record`] — the creation sink — BEFORE the
2560/// record's init passes, which is the whole point: C binds the dset first and
2561/// every record type's `init_record` opens by testing it. It binds only; the
2562/// driver's own `init_record` half runs from inside the passes
2563/// ([`crate::server::device_support::init_device_support`]).
2564pub(crate) fn attach_device_support(
2565    instance: &mut record::RecordInstance,
2566    name: &str,
2567    resolve: Option<&DeviceSupportResolver>,
2568) -> bool {
2569    let dtyp = instance.common.dtyp.clone();
2570    if crate::server::device_support::is_soft_dtyp(&dtyp) {
2571        // A soft channel needs no dset. Its `"Async Soft Channel"` variant
2572        // still owns an `add_record`, but that is C's `doResolveLinks` moment,
2573        // which sits BETWEEN the two init passes — so the init owner runs it,
2574        // not this one.
2575        return false;
2576    }
2577    let ctx = DeviceSupportContext {
2578        dtyp: &dtyp,
2579        inp: &instance.common.inp,
2580        out: &instance.common.out,
2581    };
2582    let dev_opt = resolve.and_then(|resolve| resolve(&ctx));
2583    let Some(dev) = dev_opt else {
2584        // Two reports, because C makes two and they answer different
2585        // questions. This one is deliberately per RECORD, where C's `device
2586        // support %s not found` is per DBD ENTRY: C's line names a `device()`
2587        // line nobody linked and leaves the operator to find which records
2588        // used it, while this names the record that will not work.
2589        eprintln!("warning: no device support registered for DTYP '{dtyp}' (record: {name})");
2590        // C's own per-record line, the one a site greps for by
2591        // `recGblRecordError` and the one that carries the status. Silent for
2592        // a record type that needs no device support — the table is the gate.
2593        crate::server::recgbl::rec_gbl_no_device_support(instance.record.record_type(), name);
2594        return false;
2595    };
2596    // The BIND half only (set_record_info → apply_record_info → attach). The
2597    // driver's `init()` is C's `pdset->common.init_record`, which runs from
2598    // inside `init_record` — after the dset test this line's outcome answers.
2599    crate::server::device_support::attach_device_to_record(instance, dev);
2600    true
2601}
2602
2603/// C `subRecord.c`/`aSubRecord.c` `init_record`'s name resolution
2604/// (`subRecord.c:107-130`, `aSubRecord.c:139-160`), for one record.
2605///
2606/// **The single owner**, for the reason [`setup_io_intr`] is one: `IocBuilder`
2607/// wires its records as it installs them and `IocApplication` wires them from
2608/// the database afterwards, and while each spelt this out for itself a
2609/// diagnostic added to one route was missing from the other. A `sub` record
2610/// whose SNAM names nothing is exactly the same broken record on both.
2611///
2612/// Both misses are `fprintf(stderr, "%s.SNAM " ERL_ERROR " function '%s' not
2613/// found\n", ...)` in C — the console directly, not the errlog, which is why
2614/// this writes `eprintln!` and not [`rec_gbl_record_error`]. The INAM half was
2615/// worded `iocInit: <name>.INAM function ... not found` here and the SNAM half
2616/// was not written at all, so an IOC that booted with an unresolved SNAM said
2617/// nothing and then processed the record as a no-op.
2618///
2619/// Returns whether C's `init_record` REACHED ITS TAIL — `false` is C's `return
2620/// S_db_BadSub` (either miss) and C's `prec->pact = TRUE; return 0` (an empty
2621/// `sub` SNAM), both of which skip everything below them. The init owner
2622/// ([`record::RecordInstance::run_init_passes`]) calls this from inside pass 1
2623/// and honours the answer, so the tail is unreachable by construction rather
2624/// than by a per-record-type opt-out.
2625///
2626/// [`rec_gbl_record_error`]: crate::server::recgbl::rec_gbl_record_error
2627pub(crate) fn wire_subroutine(
2628    instance: &mut record::RecordInstance,
2629    name: &str,
2630    registry: &HashMap<String, Arc<SubroutineFn>>,
2631) -> bool {
2632    // Both `sub` and `aSub` resolve their subroutine from SNAM via the
2633    // function registry at init (C `registryFunctionFind`).
2634    let rt = instance.record.record_type();
2635    if rt != "sub" && rt != "aSub" {
2636        return true;
2637    }
2638    let erl = crate::runtime::log::ERL_ERROR;
2639    // INAM: invoke the init routine exactly once at init, before SNAM
2640    // resolution (C `init_record`: `registryFunctionFind(inam)` then
2641    // `(*psubroutine)(prec)`, return value discarded).
2642    //
2643    // A name that does not RESOLVE is C's `return S_db_BadSub`
2644    // (`subRecord.c:110-114`, `aSubRecord.c:141-146`) — an early return, so
2645    // everything below is skipped and the record keeps a null `sadr` however
2646    // good its SNAM is. The routine's own status is discarded, so a routine
2647    // that ran and failed is not an early return.
2648    if let Some(crate::types::EpicsValue::String(inam_field)) = instance.record.get_field("INAM") {
2649        let inam = inam_field.as_str_lossy();
2650        if !inam.is_empty() {
2651            let Some(init_fn) = registry.get(inam.as_ref()) else {
2652                eprintln!("{name}.INAM {erl} function '{inam}' not found");
2653                return false;
2654            };
2655            let init_fn = init_fn.clone();
2656            if let Err(e) = init_fn(&mut *instance.record) {
2657                eprintln!("iocInit: {name}.INAM '{inam}' init routine failed: {e}");
2658            }
2659        }
2660    }
2661    // C resolves SNAM at init only for a record that will USE the resolution:
2662    // `subRecord.c:123` always, `aSubRecord.c:151-158` only under `LFLG ==
2663    // IGNORE`, because an `LFLG == READ` aSub reads its name from SUBL every
2664    // cycle and resolves it there (`apply_asub_dynamic_sub`). That rule
2665    // already had an owner — the record's own `is_subroutine_name_field`,
2666    // which the put path gates on — so this asks it instead of re-deriving
2667    // "sub or aSub" and reporting a miss C never went looking for.
2668    if instance.record.is_subroutine_name_field("SNAM")
2669        && let Some(crate::types::EpicsValue::String(snam_field)) =
2670            instance.record.get_field("SNAM")
2671    {
2672        let snam = snam_field.as_str_lossy();
2673        if snam.is_empty() {
2674            // C `subRecord.c:118-122` — `epicsPrintf`, which is `errlogPrintf`
2675            // (`errlog.h:90`), then `prec->pact = TRUE` and `return 0`. Both
2676            // halves are this line's, because both are inside C's
2677            // `init_record`: an INAM that failed above returned before them,
2678            // so a record whose INAM missed is NOT parked however empty its
2679            // SNAM is (softIoc-measured: `PACT: 0`). `aSubRecord.c:152` tests
2680            // `snam[0] != 0` and neither reports nor returns, so it falls
2681            // through to the tail below.
2682            instance.subroutine = None;
2683            if rt == "sub" {
2684                crate::runtime::log::errlog_printf(&format!("{name}.SNAM is empty\n"));
2685                instance.enter_pact();
2686                return false;
2687            }
2688        } else {
2689            // C `init_record`'s `prec->sadr = registryFunctionFind(...)`,
2690            // assigned whatever the lookup returned, and `return S_db_BadSub`
2691            // when that is NULL (`subRecord.c:125-129`, `aSubRecord.c:155-158`)
2692            // — the second early return past the tail.
2693            instance.subroutine = registry.get(snam.as_ref()).cloned();
2694            if instance.subroutine.is_none() {
2695                eprintln!("{name}.SNAM {erl} function '{snam}' not found");
2696                return false;
2697            }
2698        }
2699    }
2700    // C's init tail: the lines BELOW every early return above, reached only by
2701    // a record whose INAM and SNAM both resolved. That placement is the whole
2702    // point — an unresolved name means C never seeds, and `dbpr REC 4` on
2703    // `record(sub,"X"){field(SNAM,"noSuchSub") field(VAL,"5")}` reads back
2704    // `MLST: 0 ALST: 0 LALM: 0` on softIoc R7.0.10 while the port read 5.
2705    // It lives here rather than in `SubRecord::init_record` / the generic
2706    // `seed_deadband_tracking` because both of those run before this function,
2707    // where the resolution's outcome is not yet known.
2708    match rt {
2709        // `subRecord.c:130-132`: `prec->mlst = prec->alst = prec->lalm =
2710        // prec->val`, so the first `monitor()` posts nothing for a value that
2711        // has not moved since init.
2712        "sub" => {
2713            if let Some(val) = instance.record.get_field("VAL") {
2714                for field in ["MLST", "ALST", "LALM"] {
2715                    let _ = instance.record.put_field(field, val.clone());
2716                }
2717            }
2718        }
2719        // `aSubRecord.c:162`: `strcpy(prec->onam, prec->snam)`. That seed is
2720        // what gives `fetch_values`' `strcmp(prec->snam, prec->onam)` (`:261`)
2721        // its meaning — "the SUBL link delivered a name different from the one
2722        // this record booted with", not "different from the empty string".
2723        // Without it an `LFLG=READ` aSub whose SUBL is a constant re-resolved
2724        // its own boot-time SNAM on the first cycle and bound the subroutine C
2725        // deliberately leaves NULL.
2726        _ => {
2727            if let Some(snam_field) = instance.record.get_field("SNAM") {
2728                let _ = instance.record.put_field("ONAM", snam_field);
2729            }
2730        }
2731    }
2732    true
2733}
2734
2735/// C `scanAdd`'s `menuScanI_O_Intr` failure exit (`dbScan.c:272-293`): a record
2736/// whose device support cannot supply an interrupt source is reported with
2737/// `recGblRecordError` and **demoted to `menuScanPassive`** — it never joins the
2738/// I/O Intr scan list, and `caget REC.SCAN` reads back `Passive`.
2739///
2740/// The demotion is a SCAN transition like any other, so it goes through
2741/// `RecordInstance::set_scan` — the single owner, which drives the C
2742/// `scanDelete` → `get_ioint_info(1)` hook and hands back the delta for
2743/// `update_scan_index`, so the record also leaves the `IoIntr` scan bucket that
2744/// `scanpiol` and `dbla` report from.
2745///
2746/// `message` is C's `pmessage` verbatim, trailing space included, and the
2747/// report goes through [`rec_gbl_record_error`] rather than being spelt out
2748/// here. Both matter to a site: C's line is `recGblRecordError: <message>
2749/// <errSym> PV: <name>` and it goes to the *errlog*, so an operator grepping
2750/// `recGblRecordError` or reading the IOC log server sees a demoted record.
2751/// The port used to print its own sentence straight to `stderr`, which
2752/// reaches neither. C names no record's new SCAN and neither does this — the
2753/// demotion is `scanAdd`'s documented behaviour, not news.
2754///
2755/// [`rec_gbl_record_error`]: crate::server::recgbl::rec_gbl_record_error
2756async fn demote_io_intr_to_passive(db: &PvDatabase, name: &str, message: &str) {
2757    let Some(rec_arc) = db.get_record(name) else {
2758        return;
2759    };
2760    let result = {
2761        let mut inst = rec_arc.write();
2762        if inst.common.scan != record::ScanType::IoIntr {
2763            return;
2764        }
2765        inst.set_scan(record::ScanType::Passive)
2766    };
2767    if let record::CommonFieldPutResult::ScanChanged {
2768        old_scan,
2769        new_scan,
2770        phas,
2771    } = result
2772    {
2773        db.update_scan_index(name, old_scan, new_scan, phas, phas);
2774    }
2775    // C passes `-1`, for which `errSymLookup` is skipped and the slot is
2776    // empty (`recGbl.c:65-70`) — hence the doubled space in C's own output.
2777    crate::server::recgbl::rec_gbl_record_error("", name, message);
2778}
2779
2780/// Set up I/O Intr scanning for records with SCAN="I/O Intr".
2781///
2782/// The single owner of that wiring — `IocBuilder` calls this too, so the C
2783/// `scanAdd` failure paths cannot be present on one startup route and absent on
2784/// the other.
2785pub(crate) async fn setup_io_intr(db: Arc<PvDatabase>) -> usize {
2786    let all_names = db.all_record_names().await;
2787    let io_intr_recs: Vec<(String, Arc<parking_lot::RwLock<record::RecordInstance>>)> = {
2788        let mut recs = Vec::new();
2789        for name in &all_names {
2790            if let Some(arc) = db.get_record(name) {
2791                recs.push((name.clone(), arc));
2792            }
2793        }
2794        recs
2795    };
2796
2797    let mut count = 0;
2798    // Records that reached one of C `scanAdd`'s I/O Intr failure exits. The
2799    // demotion runs after the loop: it takes the registration mutex and the
2800    // records map (`update_scan_index`), which must not be entered while this
2801    // loop holds a record's write guard.
2802    let mut demote: Vec<(String, &'static str)> = Vec::new();
2803    for (name, rec_arc) in io_intr_recs {
2804        let mut inst = rec_arc.write();
2805        // Wire poll feedback when the record is on I/O Intr scan, OR when the
2806        // device drives processing from its own callback independently of the
2807        // SCAN menu (motorRecord statusCallback; asyn readback records, PRs
2808        // #60/#208). The device's decision is authoritative — the SCAN check
2809        // alone would suppress callbacks for a SCAN="Passive" motor, breaking
2810        // the pp(TRUE) dbPutField re-process gate.
2811        // NOTE: property-post wiring (setup_property_posts) is a separate
2812        // pass — an enum re-propagation callback is independent of SCAN.
2813        let independent = inst
2814            .device
2815            .as_ref()
2816            .is_some_and(|d| d.io_intr_scan_independent());
2817        let on_io_intr = inst.common.scan == record::ScanType::IoIntr;
2818        if !on_io_intr && !independent {
2819            continue;
2820        }
2821        let Some(mut dev) = inst.device.take() else {
2822            // C `dbScan.c:272-276` — `precord->dset == NULL`. The trailing
2823            // space is C's literal (`dbScan.c:275`), not a typo.
2824            if on_io_intr {
2825                demote.push((name, "scanAdd: I/O Intr not valid (no DSET) "));
2826            }
2827            continue;
2828        };
2829        if let Some(mut intr_rx) = dev.io_intr_receiver() {
2830            let db_clone = db.clone();
2831            let rec_name = name.clone();
2832            let rec_arc_clone = rec_arc.clone();
2833            // C keeps one I/O Intr scan list per band and fires
2834            // `callbackRequest` on the band the record joined —
2835            // `callbackSetPriority(prio, &piosl->callback)` (`dbScan.c:597`),
2836            // with `scanAdd` filing the record under `precord->prio`. This
2837            // pump is per record, so the record's own band is the list it
2838            // would be on.
2839            let prio = inst.common.callback_priority();
2840            crate::runtime::task::spawn_background(prio, async move {
2841                while intr_rx.recv().await.is_some() {
2842                    // C `scanIoRequest` (`dbScan.c:616-618`): an I/O Intr
2843                    // callback queues nothing while the scan facility is
2844                    // not running, which is what `interruptAccept` buys a
2845                    // building or paused IOC.
2846                    if !crate::server::scan::scan_is_running() {
2847                        continue;
2848                    }
2849                    // Process if the device drives SCAN-independently,
2850                    // or the record is still on I/O Intr scan.
2851                    let process = independent || {
2852                        let inst = rec_arc_clone.read();
2853                        inst.common.scan == record::ScanType::IoIntr
2854                    };
2855                    if !process {
2856                        continue;
2857                    }
2858                    let mut visited = std::collections::HashSet::new();
2859                    // Driver-callback cycle: an output (`asyn:READBACK`)
2860                    // record reads the value back into VAL and skips the
2861                    // device write; input records are unaffected.
2862                    let _ = db_clone
2863                        .process_record_readback(&rec_name, &mut visited, 0)
2864                        .await;
2865                }
2866            });
2867            count += 1;
2868        } else if on_io_intr {
2869            // C `dbScan.c:278-293` — device support with no `get_ioint_info`,
2870            // or one whose `get_ioint_info` yields no scan list. The port
2871            // collapses all three into "the device offers no interrupt
2872            // receiver"; the observable is the same demotion, and the message
2873            // is C's first and dominant case (`dbScan.c:281-282`), the one a
2874            // device support that never implemented the hook produces.
2875            demote.push((name, "scanAdd: I/O Intr not valid (no get_ioint_info)"));
2876        }
2877        inst.device = Some(dev);
2878    }
2879    for (name, message) in demote {
2880        demote_io_intr_to_passive(&db, &name, message).await;
2881    }
2882    count
2883}
2884
2885/// Spawn the out-of-band PROPERTY-post drains for every device exposing a
2886/// [`DeviceSupport::property_post_receiver`] (asyn enum-string runtime
2887/// re-propagation). C `registerInterruptUser(callbackEnum)` registers the
2888/// callback at init; the per-record callback re-applies the enum table and
2889/// `db_post_events(DBE_PROPERTY)` independently of `SCAN`. This mirrors
2890/// [`setup_io_intr`]: the device owns the source subscription (the asyn
2891/// interrupt) and yields a channel of [`PropertyPost`]s; the framework owns
2892/// the post (`post_property`). Returns the number of drains wired.
2893///
2894/// [`PropertyPost`]: crate::server::device_support::PropertyPost
2895pub(crate) async fn setup_property_posts(db: Arc<PvDatabase>) -> usize {
2896    let names = db.all_record_names().await;
2897    let mut count = 0;
2898    for name in names {
2899        if let Some(rec_arc) = db.get_record(&name) {
2900            let mut inst = rec_arc.write();
2901            if let Some(mut dev) = inst.device.take() {
2902                if let Some(mut rx) = dev.property_post_receiver() {
2903                    let db_clone = db.clone();
2904                    let rec_name = name.clone();
2905                    let prio = inst.common.callback_priority();
2906                    crate::runtime::task::spawn_background(prio, async move {
2907                        // Each message is the setEnums field block plus the
2908                        // one field C posts on; DBE_PROPERTY on that field is
2909                        // what makes clients re-read the choices.
2910                        while let Some(post) = rx.recv().await {
2911                            let _ = db_clone.post_property(&rec_name, post);
2912                        }
2913                    });
2914                    count += 1;
2915                }
2916                inst.device = Some(dev);
2917            }
2918        }
2919    }
2920    count
2921}
2922
2923#[cfg(test)]
2924mod io_intr_scan_add_tests {
2925    use super::setup_io_intr;
2926    use crate::server::database::PvDatabase;
2927    use crate::server::record::ScanType;
2928    use crate::server::records::ai::AiRecord;
2929    use std::sync::Arc;
2930
2931    /// R6-8 — C `scanAdd` (`dbScan.c:272-276`): a `SCAN="I/O Intr"` record with
2932    /// no device support (`precord->dset == NULL`) is reported with
2933    /// `recGblRecordError` and **demoted to `menuScanPassive`**. It must not be
2934    /// left claiming I/O Intr, and must not stay in the I/O Intr scan bucket
2935    /// that `scanpiol` reports from.
2936    #[epics_macros_rs::epics_test]
2937    async fn io_intr_without_device_support_is_demoted_to_passive() {
2938        let db = Arc::new(PvDatabase::new());
2939        db.add_record("NODEV", Box::new(AiRecord::new(0.0)))
2940            .await
2941            .unwrap();
2942        {
2943            let rec = db.get_record("NODEV").unwrap();
2944            let mut inst = rec.write();
2945            inst.common.scan = ScanType::IoIntr;
2946        }
2947        db.update_scan_index("NODEV", ScanType::Passive, ScanType::IoIntr, 0, 0);
2948        assert_eq!(
2949            db.records_for_scan(ScanType::IoIntr).await,
2950            vec!["NODEV".to_string()],
2951            "precondition: the record starts in the I/O Intr bucket"
2952        );
2953
2954        let wired = setup_io_intr(db.clone()).await;
2955        assert_eq!(wired, 0, "no device support ⇒ nothing to wire");
2956
2957        // C: `precord->scan = menuScanPassive` — `caget NODEV.SCAN` reads Passive.
2958        let rec = db.get_record("NODEV").unwrap();
2959        assert_eq!(
2960            rec.read().common.scan,
2961            ScanType::Passive,
2962            "an unusable I/O Intr record must be demoted to Passive"
2963        );
2964        assert!(
2965            db.records_for_scan(ScanType::IoIntr).await.is_empty(),
2966            "and must leave the I/O Intr scan list"
2967        );
2968    }
2969
2970    /// R6-8 — C `scanAdd` (`dbScan.c:278-293`): device support that supplies no
2971    /// interrupt source (`get_ioint_info == NULL`, or it returns non-zero, or it
2972    /// yields a NULL scan list) is the same failure exit — log and demote. The
2973    /// port collapses those three C cases into "the device offers no
2974    /// `io_intr_receiver`", so this covers all of them.
2975    #[epics_macros_rs::epics_test]
2976    async fn io_intr_with_device_but_no_interrupt_source_is_demoted_to_passive() {
2977        use crate::error::CaResult;
2978        use crate::server::device_support::DeviceSupport;
2979        use crate::server::record::Record;
2980
2981        /// Device support with the default `io_intr_receiver` (→ `None`).
2982        struct NoIntrDevice;
2983        impl DeviceSupport for NoIntrDevice {
2984            fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
2985                Ok(())
2986            }
2987            fn dtyp(&self) -> &str {
2988                "NoIntr"
2989            }
2990        }
2991
2992        let db = Arc::new(PvDatabase::new());
2993        db.add_record("NOINTR", Box::new(AiRecord::new(0.0)))
2994            .await
2995            .unwrap();
2996        {
2997            let rec = db.get_record("NOINTR").unwrap();
2998            let mut inst = rec.write();
2999            inst.common.scan = ScanType::IoIntr;
3000            inst.device = Some(Box::new(NoIntrDevice));
3001        }
3002        db.update_scan_index("NOINTR", ScanType::Passive, ScanType::IoIntr, 0, 0);
3003
3004        let wired = setup_io_intr(db.clone()).await;
3005        assert_eq!(wired, 0, "no interrupt source ⇒ nothing to wire");
3006
3007        let rec = db.get_record("NOINTR").unwrap();
3008        {
3009            let inst = rec.read();
3010            assert_eq!(
3011                inst.common.scan,
3012                ScanType::Passive,
3013                "device support with no interrupt source must demote SCAN to Passive"
3014            );
3015            assert!(
3016                inst.device.is_some(),
3017                "the demotion must not drop the record's device support"
3018            );
3019        }
3020        assert!(db.records_for_scan(ScanType::IoIntr).await.is_empty());
3021    }
3022
3023    /// The demotion is scoped to the failure exits: a record that was never on
3024    /// I/O Intr is untouched by the pass.
3025    #[epics_macros_rs::epics_test]
3026    async fn a_passive_record_is_not_touched_by_the_io_intr_pass() {
3027        let db = Arc::new(PvDatabase::new());
3028        db.add_record("PASV", Box::new(AiRecord::new(0.0)))
3029            .await
3030            .unwrap();
3031        let wired = setup_io_intr(db.clone()).await;
3032        assert_eq!(wired, 0);
3033        let rec = db.get_record("PASV").unwrap();
3034        assert_eq!(rec.read().common.scan, ScanType::Passive);
3035    }
3036}
3037
3038#[cfg(test)]
3039mod tests {
3040    use super::*;
3041    use source_guard::{Comments, production};
3042    use std::sync::Mutex as StdMutex;
3043    use std::sync::atomic::{AtomicUsize, Ordering};
3044
3045    /// Serialises the initHooks tests — `HOOKS` is process-global, so
3046    /// two tests announcing at once would observe each other's
3047    /// callbacks. The state machine here is small; a mutex is enough.
3048    static INIT_HOOK_TEST_LOCK: StdMutex<()> = StdMutex::new(());
3049
3050    /// `iocInit.c:188-190`, byte for byte, terminator included.
3051    ///
3052    /// Measured on stderr from `softIoc` R7.0.10.1-DEV with an unreadable
3053    /// ACF, redirected to a FILE — so this is the stripped form, which is
3054    /// what `errlogStripANSI` leaves when the console is not a terminal:
3055    ///
3056    /// ```text
3057    /// ERROR iocBuild: asInit Failed.$
3058    ///  The IOC has not been started.$
3059    /// ```
3060    ///
3061    /// (`cat -A`, so `$` is the newline.) Both lines are terminated; the
3062    /// second's `\n` sits OUTSIDE `ANSI_MAGENTA(...)`, after the reset.
3063    #[test]
3064    fn the_as_init_failure_lines_are_c_s() {
3065        assert_eq!(
3066            as_init_failed_message(false),
3067            "ERROR iocBuild: asInit Failed.\n The IOC has not been started.\n"
3068        );
3069        assert_eq!(
3070            as_init_failed_message(true),
3071            format!(
3072                "{} iocBuild: asInit Failed.\n\u{1b}[35;1m The IOC has not been \
3073                 started.\u{1b}[0m\n",
3074                crate::runtime::log::ERL_ERROR
3075            )
3076        );
3077    }
3078
3079    /// # Invariant
3080    ///
3081    /// MUST: every thread this module creates take its band **and** its OS
3082    /// name through `enter_ioc_thread`. MUST NOT: an iocsh thread run at the
3083    /// priority it inherited from `POSIX_Init`.
3084    ///
3085    /// All three threads here run iocsh command bodies — the startup script,
3086    /// the `afterIocRunning` queue (epics-base PR #558), and the `iocsh(NULL)`
3087    /// prompt of an IOC whose `iocInit()` never ran (C `softMain.cpp:250`,
3088    /// [`run_uninitialized_tail`]). In C that is one thread, the shell, and
3089    /// base-on-RTEMS bands it explicitly:
3090    /// `epicsThreadSetPriority(epicsThreadGetIdSelf(), epicsThreadPriorityIocsh)`
3091    /// (`libcom/RTEMS/posix/rtems_init.c:1002`), under the comment *"Override
3092    /// RTEMS Posix configuration, it gets started with posix prio 2"*. That is
3093    /// the same inheritance defect the port has: RTEMS pthreads inherit their
3094    /// creator's parameters (`cpukit/posix/src/pthreadattrdefault.c:49-58` (both `rtems` pins))
3095    /// and the boot shim runs `POSIX_Init` at `RTEMS_MAXIMUM_PRIORITY - 1`, so
3096    /// a thread that skips the prologue runs one level above idle.
3097    ///
3098    /// `Iocsh` = 91 is the top of the EPICS range — above `High`(90) and every
3099    /// scan and callback band. That is C's choice and it is the right one for
3100    /// both callers: `run` **awaits** each of these threads, so with an
3101    /// inherited near-idle band the whole of iocInit sits behind every scan
3102    /// thread and callback worker already running. The startup script is
3103    /// bounded (it runs once and exits); the post-init queue is as bounded as
3104    /// the command an operator would have typed at the C console, which C runs
3105    /// at this same 91.
3106    ///
3107    /// Source inspection, because the defect is a call that is *absent*.
3108    ///
3109    /// The prologue itself moved into `runtime::task::MandatoryThread`, which
3110    /// takes the band as a constructor argument and runs `enter_ioc_thread`
3111    /// before the body — so what this module can still get wrong is *which*
3112    /// band it declares, and whether it declares one at all. Both are checked
3113    /// below; `thread_census.rs` is what forbids creating a thread here by any
3114    /// other route.
3115    #[test]
3116    fn iocsh_threads_take_the_iocsh_band() {
3117        let prod = production(include_str!("ioc_app.rs"), Comments::Strip);
3118
3119        assert_eq!(
3120            prod.matches("MandatoryThread::new(").count(),
3121            3,
3122            "the startup-script, afterIocRunning and uninitialised-tail threads"
3123        );
3124        assert_eq!(
3125            prod.matches("name_current_thread(").count(),
3126            0,
3127            "naming without banding leaves the thread one level above idle on \
3128             the target; the `MandatoryThread` prologue is the whole of it"
3129        );
3130        assert_eq!(
3131            prod.matches("apply_to_current_thread(").count(),
3132            0,
3133            "banding without naming leaves an RTEMS-anonymous thread"
3134        );
3135        for name in ["iocsh-startup", "iocsh-after-ioc-running", "iocsh"] {
3136            let at = prod
3137                .find(&format!("\"{name}\","))
3138                .unwrap_or_else(|| panic!("the {name} thread moved; update this guard"));
3139            let head = &prod[at..(at + 700).min(prod.len())];
3140            assert!(
3141                head.contains("ThreadPriority::Iocsh"),
3142                "{name} must be declared at `ThreadPriority::Iocsh` \
3143                 (posix/rtems_init.c:1002)"
3144            );
3145        }
3146    }
3147
3148    /// `epicsThread.h:86` — the band the guard above pins is C's constant.
3149    #[test]
3150    fn the_iocsh_band_is_epics_thread_priority_iocsh() {
3151        assert_eq!(crate::runtime::task::ThreadPriority::Iocsh.value(), 91);
3152    }
3153
3154    /// The `dbLoadGroup` startup command queues `(filename, macros)`
3155    /// pairs (NOT bound to any provider), with pvxs removal
3156    /// semantics applied to the queue (`-file` by identity, `-*` clears),
3157    /// and a re-load of the same identity nets to a single entry. The QSRV
3158    /// runner later drains [`take_group_load_requests`] to build the
3159    /// served provider.
3160    #[test]
3161    fn dbloadgroup_startup_command_queues_and_removes() {
3162        // Process-global queue: drain any leftover so this test is isolated.
3163        let _ = take_group_load_requests();
3164
3165        let rt = tokio::runtime::Runtime::new().unwrap();
3166        let db = Arc::new(PvDatabase::new());
3167        let bridge = {
3168            let _guard = rt.enter();
3169            crate::runtime::task::BlockingBridge::capture()
3170        };
3171        let shell = iocsh::IocShell::new(db, bridge);
3172        shell.register(db_load_group_startup_command());
3173
3174        // Two distinct group files (the command probes existence early).
3175        let tmpdir = tempfile::tempdir().expect("fixture root");
3176        let a = tmpdir.path().join("qsrv_q_a.json");
3177        let b = tmpdir.path().join("qsrv_q_b.json");
3178        std::fs::write(&a, "{}").unwrap();
3179        std::fs::write(&b, "{}").unwrap();
3180
3181        shell
3182            .execute_line(&format!("dbLoadGroup(\"{}\")", a.display()))
3183            .unwrap();
3184        shell
3185            .execute_line(&format!("dbLoadGroup(\"{}\",\"M=1\")", b.display()))
3186            .unwrap();
3187        // Re-load of the same identity nets to one entry (pvxs erases first).
3188        shell
3189            .execute_line(&format!("dbLoadGroup(\"{}\")", a.display()))
3190            .unwrap();
3191
3192        // Missing file → early error at the st.cmd line (pvxs parity).
3193        assert!(
3194            shell
3195                .execute_line("dbLoadGroup(\"/no/such/group.json\")")
3196                .is_err(),
3197            "a missing group file must error at command time"
3198        );
3199
3200        // `-file` removes only the matching identity (a, macros="").
3201        shell
3202            .execute_line(&format!("dbLoadGroup(\"-{}\")", a.display()))
3203            .unwrap();
3204
3205        let reqs = take_group_load_requests();
3206        assert_eq!(reqs.len(), 1, "only the (b, M=1) entry must remain");
3207        assert_eq!(reqs[0].filename, b.to_string_lossy());
3208        assert_eq!(reqs[0].macros, "M=1");
3209
3210        // `-*` clears the whole queue.
3211        shell
3212            .execute_line(&format!("dbLoadGroup(\"{}\")", b.display()))
3213            .unwrap();
3214        shell.execute_line("dbLoadGroup(\"-*\")").unwrap();
3215        assert!(
3216            take_group_load_requests().is_empty(),
3217            "dbLoadGroup(\"-*\") must clear the queue"
3218        );
3219
3220        let _ = std::fs::remove_file(&a);
3221        let _ = std::fs::remove_file(&b);
3222    }
3223
3224    /// H1 regression: a callback registered via `init_hook_register`
3225    /// fires for every announced state, and `init_hook_announce`
3226    /// delivers states in the order they were announced.
3227    #[test]
3228    fn init_hook_register_and_announce_in_order() {
3229        let _guard = INIT_HOOK_TEST_LOCK.lock().unwrap();
3230        init_hooks::init_hook_free();
3231
3232        let seen: Arc<StdMutex<Vec<InitHookState>>> = Arc::new(StdMutex::new(Vec::new()));
3233        let seen_cb = seen.clone();
3234        init_hook_register(Arc::new(move |state| {
3235            seen_cb.lock().unwrap().push(state);
3236        }));
3237
3238        // Announce a subset in C order.
3239        let order = [
3240            InitHookState::AtIocBuild,
3241            InitHookState::AfterInitDevSup,
3242            InitHookState::AfterInitDatabase,
3243            InitHookState::AfterInitialProcess,
3244            InitHookState::AfterIocRunning,
3245        ];
3246        for &s in &order {
3247            init_hook_announce(s);
3248        }
3249
3250        let got = seen.lock().unwrap().clone();
3251        assert_eq!(got, order, "hooks must fire in announce order");
3252
3253        init_hooks::init_hook_free();
3254    }
3255
3256    /// H1 regression: a hook that registers ANOTHER hook from inside
3257    /// its callback must not deadlock, and the newly-registered hook
3258    /// is not invoked for the in-progress state (C snapshot
3259    /// semantics).
3260    #[test]
3261    fn init_hook_reentrant_register_does_not_deadlock() {
3262        let _guard = INIT_HOOK_TEST_LOCK.lock().unwrap();
3263        init_hooks::init_hook_free();
3264
3265        let inner_calls = Arc::new(AtomicUsize::new(0));
3266        let inner_for_outer = inner_calls.clone();
3267        init_hook_register(Arc::new(move |_state| {
3268            // Register a second hook from inside the callback.
3269            let inner = inner_for_outer.clone();
3270            init_hook_register(Arc::new(move |_s| {
3271                inner.fetch_add(1, Ordering::SeqCst);
3272            }));
3273        }));
3274
3275        // First announce: outer hook runs, registers inner. Inner is
3276        // NOT called for this state.
3277        init_hook_announce(InitHookState::AtIocBuild);
3278        assert_eq!(inner_calls.load(Ordering::SeqCst), 0);
3279
3280        // Second announce: both outer and the inner(s) run.
3281        init_hook_announce(InitHookState::AfterIocRunning);
3282        assert!(inner_calls.load(Ordering::SeqCst) >= 1);
3283
3284        init_hooks::init_hook_free();
3285    }
3286
3287    /// H1: state name strings match C `initHookName()`.
3288    #[test]
3289    fn init_hook_state_names_match_c() {
3290        assert_eq!(InitHookState::AtIocBuild.name(), "initHookAtIocBuild");
3291        assert_eq!(
3292            InitHookState::AfterInitDevSup.name(),
3293            "initHookAfterInitDevSup"
3294        );
3295        assert_eq!(
3296            InitHookState::AfterInitDatabase.name(),
3297            "initHookAfterInitDatabase"
3298        );
3299        assert_eq!(
3300            InitHookState::AfterIocRunning.name(),
3301            "initHookAfterIocRunning"
3302        );
3303    }
3304
3305    #[epics_macros_rs::epics_test]
3306    async fn test_ioc_application_empty() {
3307        // An empty IocApplication with no script or records should start and stop cleanly
3308        // We can't easily test run() because it blocks on REPL, so test the wiring functions
3309        let db = Arc::new(PvDatabase::new());
3310        assert_eq!(db.records_with_device_support().await, 0);
3311    }
3312
3313    #[epics_macros_rs::epics_test]
3314    async fn test_wire_device_support_no_dtyp() {
3315        use crate::server::records::ai::AiRecord;
3316
3317        let db = Arc::new(PvDatabase::new());
3318        db.install_device_support_resolver(device_support_resolver(HashMap::new(), None));
3319        db.add_record("TEST", Box::new(AiRecord::new(0.0)))
3320            .await
3321            .unwrap();
3322
3323        // No DTYP set, so the record is a soft channel and binds no dset.
3324        assert_eq!(db.records_with_device_support().await, 0);
3325    }
3326
3327    /// Regression: `wire_device_support` (the IocApplication
3328    /// startup-script device-support attach path) MUST forward
3329    /// info(...) tags to the driver via `apply_record_info`. An earlier fix
3330    /// only patched the IocBuilder path; without this fix, IOCs
3331    /// loaded entirely through iocsh `dbLoadRecords` lose every
3332    /// `info()` tag the driver depends on (e.g. asyn `asyn:READBACK`).
3333    #[epics_macros_rs::epics_test]
3334    async fn wire_device_support_forwards_info_tags_to_driver() {
3335        use crate::server::device_support::{DeviceReadOutcome, DeviceSupport};
3336        use crate::server::record::ScanType;
3337        use crate::server::records::ai::AiRecord;
3338        use std::sync::{Arc as StdArc, Mutex as StdMutex};
3339
3340        // Recording device support — captures the info map it received
3341        // via apply_record_info so the test can assert on its contents.
3342        struct RecordingDev {
3343            seen: StdArc<StdMutex<HashMap<String, String>>>,
3344        }
3345        impl DeviceSupport for RecordingDev {
3346            fn write(&mut self, _record: &mut dyn crate::server::record::Record) -> CaResult<()> {
3347                Ok(())
3348            }
3349            fn dtyp(&self) -> &str {
3350                "TestRecording"
3351            }
3352            fn read(
3353                &mut self,
3354                _record: &mut dyn crate::server::record::Record,
3355            ) -> CaResult<DeviceReadOutcome> {
3356                Ok(DeviceReadOutcome::ok())
3357            }
3358            fn apply_record_info(&mut self, info: &HashMap<String, String>) {
3359                let mut g = self.seen.lock().unwrap();
3360                *g = info.clone();
3361            }
3362            fn set_record_info(&mut self, _name: &str, _scan: ScanType) {}
3363        }
3364
3365        let seen = StdArc::new(StdMutex::new(HashMap::<String, String>::new()));
3366        let seen_factory = seen.clone();
3367        let mut factories: HashMap<String, DeviceSupportFactory> = HashMap::new();
3368        factories.insert(
3369            "TestRecording".to_string(),
3370            Box::new(move || {
3371                Box::new(RecordingDev {
3372                    seen: seen_factory.clone(),
3373                })
3374            }),
3375        );
3376
3377        let db = Arc::new(PvDatabase::new());
3378        db.install_device_support_resolver(device_support_resolver(factories, None));
3379        // DTYP and the info(...) tags arrive WITH the record, because the bind
3380        // now happens at creation — C's `doInitRecord0`, which reads the field
3381        // set `dbLoadRecords` already wrote.
3382        db.add_loaded_record(
3383            "AI:WITH:INFO",
3384            Box::new(AiRecord::new(0.0)),
3385            crate::server::database::RecordLoad {
3386                common_fields: vec![(
3387                    "DTYP".to_string(),
3388                    crate::types::EpicsValue::String("TestRecording".into()),
3389                )],
3390                info_tags: vec![
3391                    ("asyn:READBACK".to_string(), "1".to_string()),
3392                    ("Q:group".to_string(), "demo".to_string()),
3393                ],
3394            },
3395        )
3396        .await
3397        .unwrap();
3398        assert_eq!(
3399            db.records_with_device_support().await,
3400            1,
3401            "device support must have attached"
3402        );
3403
3404        // The recording driver should have observed both tags via
3405        // apply_record_info — proves the hook fires from the
3406        // IocApplication batch-wiring path too.
3407        let observed = seen.lock().unwrap().clone();
3408        assert_eq!(observed.get("asyn:READBACK").map(String::as_str), Some("1"));
3409        assert_eq!(observed.get("Q:group").map(String::as_str), Some("demo"));
3410    }
3411
3412    /// Regression: `wire_device_support` must bind records in **database load
3413    /// order**, the order C's `initDevSup` walks
3414    /// (`dbFirstRecord`/`dbNextRecord`). Real device support depends on it:
3415    /// epics-modules/opcua's element records refuse to init unless their
3416    /// `opcuaItem` record bound first (`linkParser.cpp:226-234`), and the
3417    /// shipped databases guarantee that only by declaring the item record
3418    /// first. This walked `all_record_names()` when that returned `HashMap`
3419    /// keys, so binding ran in hash order — not load order, and not even
3420    /// stable across runs of the same binary.
3421    #[epics_macros_rs::epics_test]
3422    async fn wire_device_support_binds_in_database_load_order() {
3423        use crate::server::device_support::{DeviceReadOutcome, DeviceSupport};
3424        use crate::server::records::ai::AiRecord;
3425        use std::sync::{Arc as StdArc, Mutex as StdMutex};
3426
3427        struct NoopDev;
3428        impl DeviceSupport for NoopDev {
3429            fn write(&mut self, _record: &mut dyn crate::server::record::Record) -> CaResult<()> {
3430                Ok(())
3431            }
3432            fn dtyp(&self) -> &str {
3433                "SeqDev"
3434            }
3435            fn read(
3436                &mut self,
3437                _record: &mut dyn crate::server::record::Record,
3438            ) -> CaResult<DeviceReadOutcome> {
3439                Ok(DeviceReadOutcome::ok())
3440            }
3441        }
3442
3443        // A fixed permutation of 0..24 — load order is deliberately neither
3444        // lexical nor hash order, so a pass cannot be a coincidence.
3445        let names: Vec<String> = (0..24)
3446            .map(|i: usize| format!("LOAD:{:02}", (i * 7 + 3) % 24))
3447            .collect();
3448
3449        let wired: StdArc<StdMutex<Vec<String>>> = StdArc::new(StdMutex::new(Vec::new()));
3450        let captured = wired.clone();
3451        let dynamic: Option<DynamicDeviceSupportFactory> =
3452            Some(Box::new(move |ctx: &DeviceSupportContext| {
3453                captured
3454                    .lock()
3455                    .unwrap()
3456                    .push(ctx.inp.trim_start_matches('@').to_string());
3457                Some(Box::new(NoopDev) as Box<dyn DeviceSupport>)
3458            }));
3459
3460        let db = Arc::new(PvDatabase::new());
3461        db.install_device_support_resolver(device_support_resolver(HashMap::new(), dynamic));
3462        for name in &names {
3463            db.add_loaded_record(
3464                name,
3465                Box::new(AiRecord::new(0.0)),
3466                crate::server::database::RecordLoad::from_common_fields(vec![
3467                    (
3468                        "DTYP".to_string(),
3469                        crate::types::EpicsValue::String("SeqDev".into()),
3470                    ),
3471                    // `DeviceSupportContext` carries the links, not the record
3472                    // name; echoing the name through INP is how the test
3473                    // observes which record is being wired.
3474                    (
3475                        "INP".to_string(),
3476                        crate::types::EpicsValue::String(format!("@{name}").into()),
3477                    ),
3478                ]),
3479            )
3480            .await
3481            .unwrap();
3482        }
3483        assert_eq!(db.records_with_device_support().await, names.len());
3484
3485        let wired = std::mem::take(&mut *wired.lock().unwrap());
3486        assert_eq!(
3487            wired, names,
3488            "device support must bind in database load order (C initDevSup), \
3489             not HashMap hash order"
3490        );
3491    }
3492
3493    /// Regression: an `asyn:READBACK` OUTPUT record processed because of a
3494    /// driver interrupt callback must READ the callback value back into VAL
3495    /// and MUST NOT write it to the driver. Writing it re-asserts the
3496    /// setpoint and re-triggers the driver — the AD `Acquire` loop where a
3497    /// single `Acquire 1` produced ~6 acquisitions (`ArrayCounter` ≈ 6,
3498    /// `Acquire` stuck at 1). C `devAsynInt32.c::processBo` takes the
3499    /// `newOutputCallbackValue` readback branch and never calls
3500    /// `processCallbackOutput`'s `write()` on a callback cycle; a
3501    /// put/FLNK/scan cycle still writes the setpoint.
3502    #[epics_macros_rs::epics_test]
3503    async fn readback_output_cycle_reads_back_and_skips_device_write() {
3504        use crate::server::device_support::{DeviceReadOutcome, DeviceSupport};
3505        use crate::server::record::ScanType;
3506        use crate::server::records::bo::BoRecord;
3507        use crate::types::EpicsValue;
3508        use std::sync::Arc as StdArc;
3509        use std::sync::atomic::{AtomicUsize, Ordering};
3510
3511        // Mock asyn-style readback device: read() pushes the driver's
3512        // callback value into VAL; write() counts how often the framework
3513        // asked it to push VAL back out to the driver.
3514        struct ReadbackDev {
3515            writes: StdArc<AtomicUsize>,
3516            readback_val: u16,
3517        }
3518        impl DeviceSupport for ReadbackDev {
3519            fn dtyp(&self) -> &str {
3520                "TestReadback"
3521            }
3522            // asyn:READBACK records follow driver-side changes regardless of
3523            // SCAN (PRs #60/#208) — the trait flag the I/O Intr wiring keys on.
3524            fn io_intr_scan_independent(&self) -> bool {
3525                true
3526            }
3527            // ...and take the C `newOutputCallbackValue` readback branch on
3528            // callback cycles (never re-write the setpoint). Devices that do
3529            // not declare this — e.g. devMotorAsyn — still write on callback
3530            // passes; that default-false path has its own regression tests.
3531            fn output_callback_readback(&self) -> bool {
3532                true
3533            }
3534            fn read(
3535                &mut self,
3536                record: &mut dyn crate::server::record::Record,
3537            ) -> CaResult<DeviceReadOutcome> {
3538                record.set_val(EpicsValue::Enum(self.readback_val))?;
3539                Ok(DeviceReadOutcome::computed(
3540                    crate::server::device_support::DeviceUdf::Defined,
3541                ))
3542            }
3543            fn write(&mut self, _record: &mut dyn crate::server::record::Record) -> CaResult<()> {
3544                self.writes.fetch_add(1, Ordering::SeqCst);
3545                Ok(())
3546            }
3547            fn set_record_info(&mut self, _name: &str, _scan: ScanType) {}
3548        }
3549
3550        let writes = StdArc::new(AtomicUsize::new(0));
3551        let db = Arc::new(PvDatabase::new());
3552        // bo VAL starts at 1 — the setpoint (e.g. Acquire=1).
3553        db.add_record("BO:RBK", Box::new(BoRecord::new(1)))
3554            .await
3555            .unwrap();
3556        {
3557            let rec = db.get_record("BO:RBK").unwrap();
3558            let mut inst = rec.write();
3559            // Non-soft DTYP so the read stage is eligible to run.
3560            inst.common.dtyp = "TestReadback".to_string();
3561            inst.device = Some(Box::new(ReadbackDev {
3562                writes: writes.clone(),
3563                readback_val: 0,
3564            }));
3565        }
3566
3567        // Driver-callback cycle: the driver reported Acquire=0 (acquisition
3568        // done). The record must read 0 back into VAL and must NOT write.
3569        {
3570            let mut visited = std::collections::HashSet::new();
3571            db.process_record_readback("BO:RBK", &mut visited, 0)
3572                .await
3573                .unwrap();
3574        }
3575        {
3576            let rec = db.get_record("BO:RBK").unwrap();
3577            let inst = rec.read();
3578            assert_eq!(
3579                inst.record.get_field("VAL"),
3580                Some(EpicsValue::Enum(0)),
3581                "readback cycle must pull the driver callback value (0) into VAL"
3582            );
3583        }
3584        assert_eq!(
3585            writes.load(Ordering::SeqCst),
3586            0,
3587            "readback cycle must NOT write VAL back to the driver (no re-trigger)"
3588        );
3589
3590        // Put/scan cycle: a normal process still writes the setpoint to the
3591        // driver exactly once (device_callback == false).
3592        {
3593            let rec = db.get_record("BO:RBK").unwrap();
3594            let mut inst = rec.write();
3595            inst.record.put_field("VAL", EpicsValue::Enum(1)).unwrap();
3596        }
3597        {
3598            let mut visited = std::collections::HashSet::new();
3599            db.process_record_with_links("BO:RBK", &mut visited, 0)
3600                .await
3601                .unwrap();
3602        }
3603        assert_eq!(
3604            writes.load(Ordering::SeqCst),
3605            1,
3606            "a put/scan cycle must write the setpoint to the driver exactly once"
3607        );
3608    }
3609}
3610
3611#[cfg(test)]
3612mod lifecycle_tests {
3613    //! The `iocState` boundaries, one case per legal and illegal edge —
3614    //! C `iocInit.c`'s guards (`iocRun` :247, `iocPause` :279,
3615    //! `iocShutdown` :723), not one case per narrative.
3616    //!
3617    //! Every case drives the process-global lifecycle, which is what C's
3618    //! file-static `iocState` is; nextest gives each test its own process,
3619    //! so they do not share it.
3620
3621    use super::*;
3622    use crate::server::scan::{ScanCtl, scan_ctl};
3623
3624    /// BOUNDARY: `iocVoid`. C refuses both transitions from it — `iocRun`
3625    /// because the state is neither `iocBuilt` nor `iocPaused`
3626    /// (`iocInit.c:247-250`), `iocPause` because it is not `iocRunning`
3627    /// (`:279-282`) — and leaves the state alone.
3628    #[test]
3629    fn a_void_ioc_refuses_run_and_pause() {
3630        assert_eq!(get_ioc_state(), IocState::Void);
3631        assert_eq!(ioc_run(), -1, "iocRun from iocVoid is C's -1");
3632        assert_eq!(ioc_pause(), -1, "iocPause from iocVoid is C's -1");
3633        assert_eq!(get_ioc_state(), IocState::Void, "a refusal changes nothing");
3634    }
3635
3636    /// BOUNDARY: `iocShutdown` from `iocVoid` returns 0 without announcing
3637    /// anything (`iocInit.c:723`). That early return is what makes it safe
3638    /// on every exit path, including one that never built an IOC.
3639    #[test]
3640    fn shutting_down_a_void_ioc_is_a_success() {
3641        assert_eq!(ioc_shutdown(), 0);
3642        assert_eq!(get_ioc_state(), IocState::Void);
3643    }
3644
3645    /// The full walk, and the scan gate at each stop. `note_scan_owner_started`
3646    /// stands in for the bring-up path here so the test needs no threads:
3647    /// what it drives is the same `ioc_run` the shell's command calls.
3648    #[test]
3649    fn the_lifecycle_walks_void_running_paused_running_void() {
3650        note_scan_owner_started();
3651        assert_eq!(get_ioc_state(), IocState::Running);
3652        assert_eq!(scan_ctl(), ScanCtl::Run);
3653
3654        assert_eq!(ioc_pause(), 0);
3655        assert_eq!(get_ioc_state(), IocState::Paused);
3656        assert_eq!(
3657            scan_ctl(),
3658            ScanCtl::Pause,
3659            "iocPause must close the gate every asynchronous scan source reads"
3660        );
3661
3662        assert_eq!(ioc_run(), 0);
3663        assert_eq!(get_ioc_state(), IocState::Running);
3664        assert_eq!(scan_ctl(), ScanCtl::Run);
3665
3666        assert_eq!(ioc_shutdown(), 0);
3667        assert_eq!(get_ioc_state(), IocState::Void);
3668        assert_eq!(scan_ctl(), ScanCtl::Exit);
3669    }
3670
3671    /// BOUNDARY: the illegal repeat of each transition. C answers a second
3672    /// `iocPause` with "IOC not running" and a second `iocRun` with "IOC
3673    /// not paused", both -1, and neither moves the state.
3674    #[test]
3675    fn a_repeated_transition_is_refused_from_its_own_end_state() {
3676        note_scan_owner_started();
3677        assert_eq!(ioc_run(), -1, "already running");
3678        assert_eq!(get_ioc_state(), IocState::Running);
3679
3680        assert_eq!(ioc_pause(), 0);
3681        assert_eq!(ioc_pause(), -1, "already paused");
3682        assert_eq!(get_ioc_state(), IocState::Paused);
3683    }
3684
3685    /// BOUNDARY: `iocShutdown` from `iocPaused`. C shuts down from any
3686    /// non-void state, not only from running.
3687    #[test]
3688    fn a_paused_ioc_can_be_shut_down() {
3689        note_scan_owner_started();
3690        assert_eq!(ioc_pause(), 0);
3691        assert_eq!(ioc_shutdown(), 0);
3692        assert_eq!(get_ioc_state(), IocState::Void);
3693    }
3694
3695    /// The gate is what `iocPause` buys: C `postEvent` queues nothing while
3696    /// the facility is not running (`dbScan.c:536-539`), so a `SCAN="Event"`
3697    /// record does not process on a paused IOC and does again once it runs.
3698    #[epics_macros_rs::epics_test]
3699    async fn a_paused_ioc_does_not_process_its_event_records() {
3700        use crate::error::CaResult;
3701        use crate::server::record::{FieldDesc, ProcessOutcome, Record, ScanType};
3702        use crate::types::EpicsValue;
3703        use std::sync::atomic::{AtomicUsize, Ordering};
3704
3705        /// Counts its own `process()` calls.
3706        struct CountProbe(Arc<AtomicUsize>);
3707
3708        impl Record for CountProbe {
3709            fn record_type(&self) -> &'static str {
3710                "ioc_pause_probe"
3711            }
3712            fn process(&mut self) -> CaResult<ProcessOutcome> {
3713                self.0.fetch_add(1, Ordering::SeqCst);
3714                Ok(ProcessOutcome::complete())
3715            }
3716            fn get_field(&self, _name: &str) -> Option<EpicsValue> {
3717                None
3718            }
3719            fn put_field(&mut self, _name: &str, _value: EpicsValue) -> CaResult<()> {
3720                Ok(())
3721            }
3722            fn declared_fields(&self) -> &'static [FieldDesc] {
3723                &[]
3724            }
3725        }
3726
3727        let runs = Arc::new(AtomicUsize::new(0));
3728        let db = Arc::new(PvDatabase::new());
3729        db.add_record("EV", Box::new(CountProbe(Arc::clone(&runs))))
3730            .await
3731            .unwrap();
3732        {
3733            let rec = db.get_record("EV").unwrap();
3734            rec.write().common.scan = ScanType::Event;
3735        }
3736        db.update_scan_index("EV", ScanType::Passive, ScanType::Event, 0, 0);
3737
3738        note_scan_owner_started();
3739        db.post_event().await;
3740        let while_running = runs.load(Ordering::SeqCst);
3741        assert_eq!(while_running, 1, "a running IOC processes its Event list");
3742
3743        assert_eq!(ioc_pause(), 0);
3744        db.post_event().await;
3745        assert_eq!(
3746            runs.load(Ordering::SeqCst),
3747            while_running,
3748            "a posted event must not process anything while the IOC is paused"
3749        );
3750
3751        assert_eq!(ioc_run(), 0);
3752        db.post_event().await;
3753        assert_eq!(
3754            runs.load(Ordering::SeqCst),
3755            while_running + 1,
3756            "iocRun must reopen the gate"
3757        );
3758    }
3759}