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}