Skip to main content

spvirit_server/
pva_server.rs

1//! High-level PVAccess server — builder pattern for typed records.
2//!
3//! # Example
4//!
5//! ```rust,ignore
6//! use spvirit_server::PvaServer;
7//!
8//! let server = PvaServer::builder()
9//!     .ai("SIM:TEMPERATURE", 22.5)
10//!     .ao("SIM:SETPOINT", 25.0)
11//!     .bo("SIM:ENABLE", false)
12//!     .build();
13//!
14//! server.run().await?;
15//! ```
16
17use std::collections::HashMap;
18use std::future::Future;
19use std::net::IpAddr;
20use std::pin::Pin;
21use std::sync::Arc;
22use std::time::Duration;
23
24use regex::Regex;
25use tracing::info;
26
27use spvirit_types::{
28    NdCodec, NdDimension, NtEnum, NtNdArray as NtNdArrayType, NtScalar, NtScalarArray,
29    NtTable as NtTableType, NtTableColumn, NtTimeStamp, PvValue, ScalarArrayValue, ScalarValue,
30};
31
32use crate::db::{load_db, parse_db};
33use crate::handler::PvListMode;
34use crate::monitor::MonitorRegistry;
35use crate::pv::scalar_family_record_type;
36use crate::pvstore::{Source, SourceRegistry, StoreSource};
37use crate::server::{PvaServerConfig, run_pva_server_with_registry};
38use crate::simple_store::{LinkDef, OnPutCallback, ScanCallback, SimplePvStore};
39use crate::types::{DbCommonState, OutputMode, RecordData, RecordInstance, RecordType};
40
41// ─── PvaServerBuilder ────────────────────────────────────────────────────
42
43/// Builder for [`PvaServer`].
44///
45/// ```rust,ignore
46/// let server = PvaServer::builder()
47///     .ai("TEMP:READBACK", 22.5)
48///     .ao("TEMP:SETPOINT", 25.0)
49///     .bo("HEATER:ON", false)
50///     .port(5075)
51///     .build();
52/// ```
53pub struct PvaServerBuilder {
54    records: HashMap<String, RecordInstance>,
55    on_put: HashMap<String, OnPutCallback>,
56    scans: Vec<(String, Duration, ScanCallback)>,
57    links: Vec<LinkDef>,
58    extra_sources: Vec<(String, i32, Arc<dyn Source>)>,
59    /// The record store registered via [`PvaServerBuilder::ioc`], as the
60    /// names it owns (captured at registration so `build`'s disjointness
61    /// check can run synchronously) and the source itself.
62    ioc: Option<(Vec<String>, Arc<dyn Source>)>,
63    tcp_port: u16,
64    udp_port: u16,
65    listen_ip: Option<IpAddr>,
66    advertise_ip: Option<IpAddr>,
67    compute_alarms: bool,
68    beacon_period_secs: u64,
69    conn_timeout: Duration,
70    pvlist_mode: PvListMode,
71    pvlist_max: usize,
72    pvlist_allow_pattern: Option<Regex>,
73    start_hooks: Vec<crate::events::StartHook>,
74    event_handlers: Vec<(String, crate::events::EventHandler)>,
75    event_sinks: Vec<Arc<dyn crate::events::EventSink>>,
76}
77
78impl PvaServerBuilder {
79    fn new() -> Self {
80        Self {
81            records: HashMap::new(),
82            on_put: HashMap::new(),
83            scans: Vec::new(),
84            links: Vec::new(),
85            extra_sources: Vec::new(),
86            ioc: None,
87            tcp_port: 5075,
88            udp_port: 5076,
89            listen_ip: None,
90            advertise_ip: None,
91            compute_alarms: false,
92            beacon_period_secs: 15,
93            conn_timeout: Duration::from_secs(64000),
94            pvlist_mode: PvListMode::List,
95            pvlist_max: 1024,
96            pvlist_allow_pattern: None,
97            start_hooks: Vec::new(),
98            event_handlers: Vec::new(),
99            event_sinks: Vec::new(),
100        }
101    }
102
103    // ─── Typed record constructors ───────────────────────────────────
104
105    /// Add an `ai` (analog input, read-only) record.
106    pub fn ai(mut self, name: impl Into<String>, initial: f64) -> Self {
107        let name = name.into();
108        self.records.insert(
109            name.clone(),
110            make_scalar_record(&name, RecordType::Ai, ScalarValue::F64(initial)),
111        );
112        self
113    }
114
115    /// Add an `ao` (analog output, writable) record.
116    pub fn ao(mut self, name: impl Into<String>, initial: f64) -> Self {
117        let name = name.into();
118        self.records.insert(
119            name.clone(),
120            make_output_record(&name, RecordType::Ao, ScalarValue::F64(initial)),
121        );
122        self
123    }
124
125    /// Add a `bi` (binary input, read-only) record.
126    pub fn bi(mut self, name: impl Into<String>, initial: bool) -> Self {
127        let name = name.into();
128        self.records.insert(
129            name.clone(),
130            make_scalar_record(&name, RecordType::Bi, ScalarValue::Bool(initial)),
131        );
132        self
133    }
134
135    /// Add a `bo` (binary output, writable) record.
136    pub fn bo(mut self, name: impl Into<String>, initial: bool) -> Self {
137        let name = name.into();
138        self.records.insert(
139            name.clone(),
140            make_output_record(&name, RecordType::Bo, ScalarValue::Bool(initial)),
141        );
142        self
143    }
144
145    /// Add a `stringin` (string input, read-only) record.
146    pub fn string_in(mut self, name: impl Into<String>, initial: impl Into<String>) -> Self {
147        let name = name.into();
148        self.records.insert(
149            name.clone(),
150            make_scalar_record(
151                &name,
152                RecordType::StringIn,
153                ScalarValue::Str(initial.into()),
154            ),
155        );
156        self
157    }
158
159    /// Add a `stringout` (string output, writable) record.
160    pub fn string_out(mut self, name: impl Into<String>, initial: impl Into<String>) -> Self {
161        let name = name.into();
162        self.records.insert(
163            name.clone(),
164            make_output_record(
165                &name,
166                RecordType::StringOut,
167                ScalarValue::Str(initial.into()),
168            ),
169        );
170        self
171    }
172
173    /// Add a `waveform` record (array) with the given initial data.
174    pub fn waveform(mut self, name: impl Into<String>, data: ScalarArrayValue) -> Self {
175        let name = name.into();
176        self.records.insert(
177            name.clone(),
178            make_array_record(&name, RecordType::Waveform, data),
179        );
180        self
181    }
182
183    /// Add an `aai` (analog array input, read-only) record.
184    pub fn aai(mut self, name: impl Into<String>, data: ScalarArrayValue) -> Self {
185        let name = name.into();
186        self.records.insert(
187            name.clone(),
188            make_array_record(&name, RecordType::Aai, data),
189        );
190        self
191    }
192
193    /// Add an `aao` (analog array output, writable) record.
194    pub fn aao(mut self, name: impl Into<String>, data: ScalarArrayValue) -> Self {
195        let name = name.into();
196        self.records.insert(
197            name.clone(),
198            make_array_record(&name, RecordType::Aao, data),
199        );
200        self
201    }
202
203    /// Add a `subarray` record — a view into part of an array.
204    pub fn sub_array(
205        mut self,
206        name: impl Into<String>,
207        data: ScalarArrayValue,
208        indx: usize,
209        nelm: usize,
210    ) -> Self {
211        let name = name.into();
212        let ftvl = data.type_label().trim_end_matches("[]").to_string();
213        let malm = data.len();
214        let nord = nelm.min(malm.saturating_sub(indx));
215        self.records.insert(
216            name.clone(),
217            RecordInstance {
218                name: name.clone(),
219                record_type: RecordType::SubArray,
220                common: DbCommonState::default(),
221                data: RecordData::SubArray {
222                    nt: NtScalarArray::from_value(data),
223                    inp: None,
224                    ftvl,
225                    malm,
226                    nelm,
227                    nord,
228                    indx,
229                },
230                raw_fields: HashMap::new(),
231            },
232        );
233        self
234    }
235
236    /// Add an NTTable record.
237    pub fn nt_table(
238        mut self,
239        name: impl Into<String>,
240        columns: Vec<(String, ScalarArrayValue)>,
241    ) -> Self {
242        let name = name.into();
243        let labels: Vec<String> = columns.iter().map(|(n, _)| n.clone()).collect();
244        let cols: Vec<NtTableColumn> = columns
245            .into_iter()
246            .map(|(n, v)| NtTableColumn { name: n, values: v })
247            .collect();
248        self.records.insert(
249            name.clone(),
250            RecordInstance {
251                name: name.clone(),
252                record_type: RecordType::NtTable,
253                common: DbCommonState::default(),
254                data: RecordData::NtTable {
255                    nt: NtTableType {
256                        labels,
257                        columns: cols,
258                        descriptor: None,
259                        alarm: None,
260                        time_stamp: None,
261                    },
262                    inp: None,
263                    out: None,
264                    omsl: OutputMode::Supervisory,
265                },
266                raw_fields: HashMap::new(),
267            },
268        );
269        self
270    }
271
272    /// Add an NTNDArray record.
273    pub fn nt_ndarray(
274        mut self,
275        name: impl Into<String>,
276        data: ScalarArrayValue,
277        dims: Vec<(i32, i32)>,
278    ) -> Self {
279        let name = name.into();
280        let dimension: Vec<NdDimension> = dims
281            .into_iter()
282            .map(|(size, offset)| NdDimension {
283                size,
284                offset,
285                full_size: size,
286                binning: 1,
287                reverse: false,
288            })
289            .collect();
290        let uncompressed_size = (data.len() * data.element_size_bytes().max(1)) as i64;
291        self.records.insert(
292            name.clone(),
293            RecordInstance {
294                name: name.clone(),
295                record_type: RecordType::NtNdArray,
296                common: DbCommonState::default(),
297                data: RecordData::NtNdArray {
298                    nt: NtNdArrayType {
299                        value: data,
300                        codec: NdCodec {
301                            name: String::new(),
302                            parameters: Default::default(),
303                        },
304                        compressed_size: uncompressed_size,
305                        uncompressed_size,
306                        dimension,
307                        unique_id: 0,
308                        data_time_stamp: NtTimeStamp {
309                            seconds_past_epoch: 0,
310                            nanoseconds: 0,
311                            user_tag: 0,
312                        },
313                        attribute: vec![],
314                        descriptor: None,
315                        alarm: None,
316                        time_stamp: None,
317                        display: None,
318                    },
319                    inp: None,
320                    out: None,
321                    omsl: OutputMode::Supervisory,
322                },
323                raw_fields: HashMap::new(),
324            },
325        );
326        self
327    }
328
329    /// Add an `mbbi` (multi-bit binary input, read-only) NTEnum record.
330    pub fn mbbi(mut self, name: impl Into<String>, choices: Vec<String>, initial: i32) -> Self {
331        let name = name.into();
332        self.records.insert(
333            name.clone(),
334            RecordInstance {
335                name: name.clone(),
336                record_type: RecordType::Mbbi,
337                common: DbCommonState::default(),
338                data: RecordData::NtEnum {
339                    nt: NtEnum::new(initial, choices),
340                    inp: None,
341                    out: None,
342                    omsl: OutputMode::Supervisory,
343                },
344                raw_fields: HashMap::new(),
345            },
346        );
347        self
348    }
349
350    /// Add an `mbbo` (multi-bit binary output, writable) NTEnum record.
351    pub fn mbbo(mut self, name: impl Into<String>, choices: Vec<String>, initial: i32) -> Self {
352        let name = name.into();
353        self.records.insert(
354            name.clone(),
355            RecordInstance {
356                name: name.clone(),
357                record_type: RecordType::Mbbo,
358                common: DbCommonState::default(),
359                data: RecordData::NtEnum {
360                    nt: NtEnum::new(initial, choices),
361                    inp: None,
362                    out: None,
363                    omsl: OutputMode::Supervisory,
364                },
365                raw_fields: HashMap::new(),
366            },
367        );
368        self
369    }
370
371    /// Add a generic structure record with a custom struct ID and fields.
372    pub fn generic(
373        mut self,
374        name: impl Into<String>,
375        struct_id: impl Into<String>,
376        fields: Vec<(String, PvValue)>,
377    ) -> Self {
378        let name = name.into();
379        self.records.insert(
380            name.clone(),
381            RecordInstance {
382                name: name.clone(),
383                record_type: RecordType::Generic,
384                common: DbCommonState::default(),
385                data: RecordData::Generic {
386                    struct_id: struct_id.into(),
387                    fields,
388                    inp: None,
389                    out: None,
390                    omsl: OutputMode::Supervisory,
391                },
392                raw_fields: HashMap::new(),
393            },
394        );
395        self
396    }
397
398    // ─── .db file loading ────────────────────────────────────────────
399
400    /// Load records from an EPICS `.db` file.
401    ///
402    /// A malformed `.db` is a startup configuration error, not a runtime
403    /// condition to log and shrug off: `build()` returns `PvaServer`, not a
404    /// `Result`, so there is no channel to report the failure through other
405    /// than refusing to start. Silently continuing with zero records from
406    /// this file would leave the server up and answering PVA requests while
407    /// serving none of the PVs its `.db` was supposed to define, which is
408    /// worse than failing loudly at startup. `load_db`'s error already names
409    /// the file and the line that failed to parse (see `DbParseError`'s
410    /// `Display`), so that detail reaches the panic message unmodified.
411    pub fn db_file(mut self, path: impl AsRef<str>) -> Self {
412        match load_db(path.as_ref()) {
413            Ok(records) => {
414                self.records.extend(records);
415            }
416            Err(e) => {
417                panic!("failed to load db file '{}': {e}", path.as_ref());
418            }
419        }
420        self
421    }
422
423    /// Parse records from an EPICS `.db` string.
424    ///
425    /// See [`Self::db_file`]'s doc comment for why a parse failure panics
426    /// here rather than logging and continuing with zero records.
427    pub fn db_string(mut self, content: &str) -> Self {
428        match parse_db(content) {
429            Ok(records) => {
430                self.records.extend(records);
431            }
432            Err(e) => {
433                panic!("failed to parse db string: {e}");
434            }
435        }
436        self
437    }
438
439    // ─── Callbacks ───────────────────────────────────────────────────
440
441    /// Register a callback invoked when a PUT is applied to the named PV.
442    pub fn on_put<F>(mut self, name: impl Into<String>, callback: F) -> Self
443    where
444        F: Fn(&str, &spvirit_codec::spvd_decode::DecodedValue) + Send + Sync + 'static,
445    {
446        self.on_put.insert(name.into(), Arc::new(callback));
447        self
448    }
449
450    /// Register a periodic scan callback that produces a new value for a PV.
451    pub fn scan<F>(mut self, name: impl Into<String>, period: Duration, callback: F) -> Self
452    where
453        F: Fn(&str) -> ScalarValue + Send + Sync + 'static,
454    {
455        self.scans.push((name.into(), period, Arc::new(callback)));
456        self
457    }
458
459    /// Register a hook to run once at startup, before the server serves.
460    ///
461    /// Hooks run in registration order, each to completion, before scan tasks
462    /// spawn and before the listener accepts. A hook that panics aborts
463    /// startup.
464    ///
465    /// ```rust,ignore
466    /// .on_start(|store| Box::pin(async move {
467    ///     store.set_value("SETPOINT", ScalarValue::F64(22.5)).await;
468    /// }))
469    /// ```
470    pub fn on_start<F>(mut self, hook: F) -> Self
471    where
472        F: Fn(Arc<SimplePvStore>) -> Pin<Box<dyn Future<Output = ()> + Send>>
473            + Send
474            + Sync
475            + 'static,
476    {
477        self.start_hooks.push(Arc::new(hook));
478        self
479    }
480
481    /// Register a handler for a named event.
482    ///
483    /// Handlers are deferred: `post_event` queues them and returns. They run
484    /// one at a time, in registration order, on the dispatcher.
485    ///
486    /// ```rust,ignore
487    /// .on_event("SHUTTER", |store, event| Box::pin(async move { /* ... */ }))
488    /// ```
489    pub fn on_event<F>(mut self, event: impl Into<String>, handler: F) -> Self
490    where
491        F: Fn(Arc<SimplePvStore>, String) -> Pin<Box<dyn Future<Output = ()> + Send>>
492            + Send
493            + Sync
494            + 'static,
495    {
496        self.event_handlers.push((event.into(), Arc::new(handler)));
497        self
498    }
499
500    /// Register an [`EventSink`](crate::events::EventSink) — an inline
501    /// consumer awaited by `post_event` before any handler is queued.
502    ///
503    /// Sinks were previously reachable only through
504    /// [`PvaServer::events`](PvaServer::events) on an already-built server,
505    /// which leaves no seam for callers that only ever hold a builder (or a
506    /// [`ServeBuilder`]/[`RunningServer`]). Registration order across
507    /// builder-registered and post-build sinks is call order.
508    pub fn event_sink(mut self, sink: Arc<dyn crate::events::EventSink>) -> Self {
509        self.event_sinks.push(sink);
510        self
511    }
512
513    /// Link an output PV to one or more input PVs.
514    ///
515    /// Whenever any input PV changes (via `set_value`, protocol PUT, or
516    /// another link), the `compute` callback is invoked with the current
517    /// values of **all** inputs (in order) and the result is written to
518    /// the output PV.
519    ///
520    /// ```rust,ignore
521    /// .link("CALC:SUM", &["INPUT:A", "INPUT:B"], |values| {
522    ///     let a = values[0].as_f64().unwrap_or(0.0);
523    ///     let b = values[1].as_f64().unwrap_or(0.0);
524    ///     ScalarValue::F64(a + b)
525    /// })
526    /// ```
527    pub fn link<F>(mut self, output: impl Into<String>, inputs: &[&str], compute: F) -> Self
528    where
529        F: Fn(&[ScalarValue]) -> ScalarValue + Send + Sync + 'static,
530    {
531        self.links.push(LinkDef {
532            output: output.into(),
533            inputs: inputs.iter().map(|s| s.to_string()).collect(),
534            compute: Arc::new(compute),
535        });
536        self
537    }
538
539    // ─── External sources ────────────────────────────────────────────
540
541    /// Register an additional [`Source`] at the given priority.
542    ///
543    /// Lower `order` values are checked first during PV name resolution.
544    /// The built-in `SimplePvStore` (records added via `.ai()`, `.ao()`, etc.)
545    /// is always registered at order 0.
546    ///
547    /// ```rust,ignore
548    /// .source("hardware", -10, Arc::new(HardwareSource::new()))
549    /// ```
550    pub fn source(mut self, label: impl Into<String>, order: i32, source: Arc<dyn Source>) -> Self {
551        self.extra_sources.push((label.into(), order, source));
552        self
553    }
554
555    /// Register a processing engine as a second *store*.
556    ///
557    /// Unlike [`PvaServerBuilder::source`], this asserts the engine owns its
558    /// record names outright: `build` panics if any of them collide with a
559    /// record added to the builtin store (`.ai()`, `.db_file()` and
560    /// friends), or if a `.scan`, `.link` or `on_put` handler names one of
561    /// them. Those callbacks drive
562    /// the builtin store's direct-write semantics and would be silently
563    /// inert against an engine record.
564    ///
565    /// Generic rather than `Arc<dyn StoreSource>` so the names can be read
566    /// before the value is erased to `Arc<dyn Source>`.
567    ///
568    /// # Panics
569    /// If called more than once.
570    pub fn ioc<S: StoreSource + 'static>(mut self, ioc: Arc<S>) -> Self {
571        assert!(
572            self.ioc.is_none(),
573            "PvaServerBuilder::ioc may only be called once; \
574             register additional engines with .source()"
575        );
576        let names = ioc.record_names();
577        let source: Arc<dyn Source> = ioc;
578        self.ioc = Some((names, source));
579        self
580    }
581
582    // ─── Configuration ───────────────────────────────────────────────
583
584    /// Set the TCP port (default 5075).
585    pub fn port(mut self, port: u16) -> Self {
586        self.tcp_port = port;
587        self
588    }
589
590    /// Set the UDP search port (default 5076).
591    pub fn udp_port(mut self, port: u16) -> Self {
592        self.udp_port = port;
593        self
594    }
595
596    /// Set the IP address to listen on.
597    pub fn listen_ip(mut self, ip: IpAddr) -> Self {
598        self.listen_ip = Some(ip);
599        self
600    }
601
602    /// Set the IP address to advertise in search responses.
603    pub fn advertise_ip(mut self, ip: IpAddr) -> Self {
604        self.advertise_ip = Some(ip);
605        self
606    }
607
608    /// Enable alarm computation from limits.
609    pub fn compute_alarms(mut self, enabled: bool) -> Self {
610        self.compute_alarms = enabled;
611        self
612    }
613
614    /// Set the beacon broadcast period in seconds (default 15).
615    pub fn beacon_period(mut self, secs: u64) -> Self {
616        self.beacon_period_secs = secs;
617        self
618    }
619
620    /// Set the idle connection timeout (default ~18 hours).
621    pub fn conn_timeout(mut self, timeout: Duration) -> Self {
622        self.conn_timeout = timeout;
623        self
624    }
625
626    /// Set the PV list mode (default [`PvListMode::List`]).
627    pub fn pvlist_mode(mut self, mode: PvListMode) -> Self {
628        self.pvlist_mode = mode;
629        self
630    }
631
632    /// Set the maximum number of PV names in pvlist responses (default 1024).
633    pub fn pvlist_max(mut self, max: usize) -> Self {
634        self.pvlist_max = max;
635        self
636    }
637
638    /// Set a regex filter for PV names exposed by pvlist.
639    pub fn pvlist_allow_pattern(mut self, pattern: Regex) -> Self {
640        self.pvlist_allow_pattern = Some(pattern);
641        self
642    }
643
644    /// Build the [`PvaServer`].
645    pub fn build(self) -> PvaServer {
646        if let Some((ioc_names, _)) = &self.ioc {
647            let engine: std::collections::HashSet<&str> =
648                ioc_names.iter().map(String::as_str).collect();
649
650            // Sorted, because `records` and `on_put` are HashMaps: an
651            // unsorted diagnostic would name the same fault differently on
652            // each run.
653            let mut overlap: Vec<&str> = self
654                .records
655                .keys()
656                .map(String::as_str)
657                .filter(|n| engine.contains(n))
658                .collect();
659            overlap.sort_unstable();
660            assert!(
661                overlap.is_empty(),
662                "the builtin store and the engine store both own {}: stores must be \
663                 disjoint. Remove the builtin-store record (`.ai()`, `.db_file()` and \
664                 friends), or rename the engine's record.",
665                overlap.join(", ")
666            );
667
668            let mut misdirected: Vec<String> = Vec::new();
669            for (name, _, _) in &self.scans {
670                if engine.contains(name.as_str()) {
671                    misdirected.push(format!(".scan(\"{name}\")"));
672                }
673            }
674            for link in &self.links {
675                if engine.contains(link.output.as_str()) {
676                    misdirected.push(format!(".link(\"{}\", …)", link.output));
677                }
678                for input in &link.inputs {
679                    if engine.contains(input.as_str()) {
680                        misdirected.push(format!(".link(…, input \"{input}\")"));
681                    }
682                }
683            }
684            for name in self.on_put.keys() {
685                if engine.contains(name.as_str()) {
686                    misdirected.push(format!(".on_put(\"{name}\")"));
687                }
688            }
689            misdirected.sort_unstable();
690            misdirected.dedup();
691            assert!(
692                misdirected.is_empty(),
693                "these builtin-store callbacks name a record the engine store owns, so \
694                 they would never fire: {}. Express the behaviour in the engine's .db \
695                 instead.",
696                misdirected.join(", ")
697            );
698        }
699
700        let store = Arc::new(SimplePvStore::new(
701            self.records,
702            self.on_put,
703            self.links,
704            self.compute_alarms,
705        ));
706
707        let mut config = PvaServerConfig::default();
708        config.tcp_port = self.tcp_port;
709        config.udp_port = self.udp_port;
710        config.compute_alarms = self.compute_alarms;
711        if let Some(ip) = self.listen_ip {
712            config.listen_ip = ip;
713        }
714        config.advertise_ip = self.advertise_ip;
715        config.beacon_period_secs = self.beacon_period_secs;
716        config.conn_timeout = self.conn_timeout;
717        config.pvlist_mode = self.pvlist_mode;
718        config.pvlist_max = self.pvlist_max;
719        config.pvlist_allow_pattern = self.pvlist_allow_pattern;
720
721        let events = Arc::new(crate::events::Events::new());
722        for (name, handler) in self.event_handlers {
723            events.add_handler(name, handler);
724        }
725        for sink in self.event_sinks {
726            events.add_sink(sink);
727        }
728
729        PvaServer {
730            store,
731            extra_sources: self.extra_sources,
732            ioc: self.ioc.map(|(_, source)| source),
733            config,
734            scans: self.scans,
735            monitor_registry: Arc::new(std::sync::OnceLock::new()),
736            events,
737            start_hooks: self.start_hooks,
738        }
739    }
740}
741
742/// Best-effort rendering of a `catch_unwind` payload.
743///
744/// `panic!("...")` payloads are `String` (formatted) or `&'static str`
745/// (literal); anything else came from `panic_any` and has no text.
746fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
747    if let Some(s) = payload.downcast_ref::<String>() {
748        s.clone()
749    } else if let Some(s) = payload.downcast_ref::<&'static str>() {
750        (*s).to_string()
751    } else {
752        "panic payload was not a string".to_string()
753    }
754}
755
756// ─── PvaServer ───────────────────────────────────────────────────────────
757
758/// High-level PVAccess server.
759///
760/// Built via [`PvaServer::builder()`] with typed record constructors,
761/// `.db_file()` loading, `.on_put()` / `.scan()` callbacks, and a
762/// simple `.run()` to start serving.
763///
764/// ```rust,ignore
765/// let server = PvaServer::builder()
766///     .ai("SIM:TEMP", 22.5)
767///     .ao("SIM:SP", 25.0)
768///     .build();
769///
770/// // Read/write PVs from another task:
771/// let store = server.store();
772/// store.set_value("SIM:TEMP", ScalarValue::F64(23.1)).await;
773///
774/// server.run().await?;
775/// ```
776pub struct PvaServer {
777    store: Arc<SimplePvStore>,
778    extra_sources: Vec<(String, i32, Arc<dyn Source>)>,
779    /// The engine registered via [`PvaServerBuilder::ioc`], if any.
780    ioc: Option<Arc<dyn Source>>,
781    config: PvaServerConfig,
782    scans: Vec<(String, Duration, ScanCallback)>,
783    /// The monitor registry, lazily created on first access (by whichever
784    /// runs first among `set_monitor_registry`, `monitor_registry`,
785    /// `run_start_hooks`, or `serve_after_start_hooks`) and shared from
786    /// then on via the `OnceLock`. This guarantees `run_start_hooks` (which
787    /// installs it on the store before any hook runs) and
788    /// `serve_after_start_hooks` (which passes it to the source registry
789    /// and the running protocol) always agree on the exact same instance,
790    /// even when they're invoked as two separate calls (as Python's
791    /// `start_background` does) rather than back-to-back inside `run()`.
792    monitor_registry: Arc<std::sync::OnceLock<Arc<MonitorRegistry>>>,
793    events: Arc<crate::events::Events>,
794    start_hooks: Vec<crate::events::StartHook>,
795}
796
797impl PvaServer {
798    /// Create a builder for configuring a [`PvaServer`].
799    pub fn builder() -> PvaServerBuilder {
800        PvaServerBuilder::new()
801    }
802
803    /// Get a reference to the underlying store for runtime get/put.
804    pub fn store(&self) -> &Arc<SimplePvStore> {
805        &self.store
806    }
807
808    /// The server's event registry — register sinks or post events.
809    pub fn events(&self) -> &Arc<crate::events::Events> {
810        &self.events
811    }
812
813    /// Post a named event.
814    ///
815    /// Async because sinks are awaited inline: when this returns, every sink
816    /// has finished — records on that event have processed — and handlers
817    /// are queued, not necessarily run. Making the caller `.await` is what
818    /// buys the guarantee; a sync wrapper that spawned and returned would
819    /// silently drop it.
820    pub async fn post_event(&self, event: &str) {
821        self.events.post(event).await;
822    }
823
824    /// Run every `on_start` hook to completion, in registration order.
825    ///
826    /// Returns `Err` naming the hook and carrying the panic message if one
827    /// panics — including any label the hook panicked with (Python source
828    /// hooks panic with `on_start hook for source '<label>' raised: ...`).
829    ///
830    /// Also installs the monitor registry onto the store before any hook
831    /// runs (idempotently — `SimplePvStore::set_registry` is safe to call
832    /// more than once), so a hook that writes to the store notifies
833    /// subscribed monitors, and a hook that reads the registry off the
834    /// store never sees `None`. This must happen here rather than only in
835    /// `serve_after_start_hooks`, since `run_start_hooks` can be — and, via
836    /// Python's `start_background`, is — called on its own ahead of it.
837    pub async fn run_start_hooks(&self) -> Result<(), String> {
838        self.store.set_registry(self.resolved_monitor_registry()).await;
839        for (i, hook) in self.start_hooks.iter().enumerate() {
840            let fut = hook(self.store.clone());
841            let result =
842                futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(fut)).await;
843            if let Err(payload) = result {
844                // Fold the panic payload into the message. Without this the
845                // real cause ("ValueError: DB connection refused", or the
846                // source label a Python source hook panics with) reached the
847                // user only via the default panic hook on stderr, and the
848                // returned error named the hook by an index that does not
849                // correspond to anything the user wrote — builder hooks and
850                // source hooks share one list.
851                return Err(format!(
852                    "on_start hook #{i} panicked; aborting startup: {}",
853                    panic_message(payload.as_ref())
854                ));
855            }
856        }
857        Ok(())
858    }
859
860    /// Mint a typed handle to any record in this server's store — the
861    /// pre-`run()` counterpart of [`RunningServer::pv`].
862    pub async fn pv<T: crate::pv::PvScalar>(
863        &self,
864        name: &str,
865    ) -> Result<crate::pv::Pv<T>, crate::pv::PvError> {
866        crate::pv::Pv::attach(&self.store, name).await
867    }
868
869    /// Mint an array handle to any record in this server's store — the
870    /// pre-`run()` counterpart of [`RunningServer::array_pv`].
871    pub async fn array_pv(&self, name: &str) -> Result<crate::pv::PvArray, crate::pv::PvError> {
872        crate::pv::PvArray::attach(&self.store, name).await
873    }
874
875    /// Register an additional [`Source`] after building the server.
876    ///
877    /// This is useful when the source needs a reference to the store
878    /// (which is only available after `.build()`).
879    ///
880    /// ```rust,ignore
881    /// let server = PvaServer::builder().ai("X", 0.0).build();
882    /// let store = server.store().clone();
883    /// server.add_source("agg", 10, Arc::new(MyAggSource::new(store)));
884    /// server.run().await?;
885    /// ```
886    pub fn add_source(&mut self, label: impl Into<String>, order: i32, source: Arc<dyn Source>) {
887        self.extra_sources.push((label.into(), order, source));
888    }
889
890    /// Pre-supply the [`MonitorRegistry`] that [`Self::run`] will use.
891    ///
892    /// This lets external code (for example Python `Source` adapters)
893    /// hold onto the registry and publish monitor updates to subscribed
894    /// PVAccess clients from outside `run()`.
895    ///
896    /// Must be called before the registry has been resolved by any other
897    /// path (`monitor_registry()`, `run_start_hooks()`, or
898    /// `serve_after_start_hooks()`) — in normal usage this is always right
899    /// after `build()`, before any of those run. If the registry was
900    /// already resolved, this is a no-op: the earlier instance wins.
901    pub fn set_monitor_registry(&mut self, registry: Arc<MonitorRegistry>) {
902        let _ = self.monitor_registry.set(registry);
903    }
904
905    /// Get a shared handle to the [`MonitorRegistry`] that will be used
906    /// when [`Self::run`] starts.  Creates (and stores) a new registry
907    /// on first call so external code can register before run.
908    pub fn monitor_registry(&mut self) -> Arc<MonitorRegistry> {
909        self.resolved_monitor_registry()
910    }
911
912    /// Get-or-create the single [`MonitorRegistry`] instance this server
913    /// will use for its whole lifetime. Shared by `monitor_registry()`,
914    /// `run_start_hooks()`, and `serve_after_start_hooks()` so they always
915    /// agree on the exact same `Arc`, regardless of call order.
916    fn resolved_monitor_registry(&self) -> Arc<MonitorRegistry> {
917        self.monitor_registry
918            .get_or_init(|| Arc::new(MonitorRegistry::new()))
919            .clone()
920    }
921
922    /// Start the PVA server (UDP search + TCP handler + beacon + scan tasks).
923    ///
924    /// This blocks until the server is shut down or an error occurs.
925    pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
926        // 1. Run every on_start hook to completion. Nothing else has started,
927        //    so a hook observes a quiescent store and no client can see a
928        //    pre-initialisation value.
929        self.run_start_hooks()
930            .await
931            .map_err(|e| -> Box<dyn std::error::Error> { Box::<dyn std::error::Error>::from(e) })?;
932
933        self.serve_after_start_hooks().await
934    }
935
936    /// Continue startup on the assumption that `run_start_hooks()` has
937    /// already completed successfully.
938    ///
939    /// Builds the source registry, spawns scan tasks, starts the event
940    /// dispatcher, and binds/accepts connections — i.e. everything `run()`
941    /// does except the hook phase. Exists so a caller that needs to surface
942    /// an `on_start` failure synchronously (e.g. Python's
943    /// `start_background`, which must raise before returning rather than
944    /// only logging from a background thread) can run the hooks itself,
945    /// check the result, and only then hand the server off to a background
946    /// task — without running the hooks a second time.
947    ///
948    /// Calling this without having run the start hooks first silently skips
949    /// them; callers that need the hooks-first guarantee should use `run()`
950    /// or call `run_start_hooks()` first themselves.
951    pub async fn serve_after_start_hooks(self) -> Result<(), Box<dyn std::error::Error>> {
952        // Resolve (or reuse, if run_start_hooks or monitor_registry() already
953        // did) the single monitor registry instance so scan tasks and the
954        // running protocol notify PVAccess monitor clients through the same
955        // registry the store was already given.
956        let registry = self.resolved_monitor_registry();
957        self.store.set_registry(registry.clone()).await;
958
959        // Build the source registry with the built-in store at order 0.
960        let sources = Arc::new(SourceRegistry::new());
961        sources.add_store("builtin", 0, self.store.clone()).await;
962
963        if let Some(ioc) = &self.ioc {
964            // Order 5: after the builtin store, before `record-fields`, so
965            // the IOC's own `.FIELD` routing answers for its records and
966            // tier 1's field source answers for the builtin store's.
967            sources.add_store("ioc", 5, ioc.clone()).await;
968        }
969
970        // IOC/QSRV-style record field access (<name>.<FIELD>, <FIELD>$) so
971        // tools like the EPICS Archiver Appliance can fetch record metadata.
972        let field_provider: Arc<dyn crate::field_provider::RecordFieldProvider> =
973            self.store.clone();
974        sources
975            .add(
976                "record-fields",
977                10,
978                Arc::new(crate::record_fields::RecordFieldSource::new(field_provider)),
979            )
980            .await;
981
982        // Register any extra sources provided via .source().
983        for (label, order, source) in &self.extra_sources {
984            sources.add(label.clone(), *order, source.clone()).await;
985        }
986
987        // 2. Spawn scan tasks.
988        for (name, period, callback) in &self.scans {
989            let store = self.store.clone();
990            let name = name.clone();
991            let period = *period;
992            let callback = callback.clone();
993            tokio::spawn(async move {
994                let mut interval = tokio::time::interval(period);
995                loop {
996                    interval.tick().await;
997                    let new_val = callback(&name);
998                    store.set_value(&name, new_val).await;
999                }
1000            });
1001        }
1002
1003        // 3. Start the event dispatcher.
1004        self.events.start_dispatcher(self.store.clone());
1005
1006        let pv_count = self.store.pv_names().await.len();
1007        info!(
1008            "PvaServer starting: {} PVs on port {}",
1009            pv_count, self.config.tcp_port
1010        );
1011
1012        // 4. Bind and accept.
1013        run_pva_server_with_registry(sources, self.config, registry).await
1014    }
1015}
1016
1017// ─── Handle-based (`Pv<T>`) entry point ──────────────────────────────────
1018
1019impl PvaServer {
1020    /// Serve a collection of typed PV handles. Shorthand entry point for the
1021    /// handle-based API; combine with `.db_file()`, `.source()`, etc.
1022    pub fn serve(pvs: impl IntoIterator<Item = impl Into<crate::pv::AnyPv>>) -> ServeBuilder {
1023        ServeBuilder {
1024            inner: PvaServerBuilder::new(),
1025            handles: Vec::new(),
1026        }
1027        .pvs(pvs)
1028    }
1029}
1030
1031/// Builder for the PV-handle API. Wraps [`PvaServerBuilder`] and adds handle
1032/// binding at `build()` time.
1033pub struct ServeBuilder {
1034    inner: PvaServerBuilder,
1035    handles: Vec<crate::pv::AnyPv>,
1036}
1037
1038impl ServeBuilder {
1039    pub fn pvs(mut self, pvs: impl IntoIterator<Item = impl Into<crate::pv::AnyPv>>) -> Self {
1040        self.handles.extend(pvs.into_iter().map(Into::into));
1041        self
1042    }
1043    pub fn db_file(mut self, path: impl AsRef<str>) -> Self {
1044        self.inner = self.inner.db_file(path);
1045        self
1046    }
1047    pub fn db_string(mut self, content: &str) -> Self {
1048        self.inner = self.inner.db_string(content);
1049        self
1050    }
1051    pub fn source(mut self, label: impl Into<String>, order: i32, source: Arc<dyn Source>) -> Self {
1052        self.inner = self.inner.source(label, order, source);
1053        self
1054    }
1055    pub fn port(mut self, port: u16) -> Self {
1056        self.inner = self.inner.port(port);
1057        self
1058    }
1059    pub fn udp_port(mut self, port: u16) -> Self {
1060        self.inner = self.inner.udp_port(port);
1061        self
1062    }
1063    pub fn listen_ip(mut self, ip: IpAddr) -> Self {
1064        self.inner = self.inner.listen_ip(ip);
1065        self
1066    }
1067    pub fn advertise_ip(mut self, ip: IpAddr) -> Self {
1068        self.inner = self.inner.advertise_ip(ip);
1069        self
1070    }
1071    pub fn compute_alarms(mut self, enabled: bool) -> Self {
1072        self.inner = self.inner.compute_alarms(enabled);
1073        self
1074    }
1075    pub fn beacon_period(mut self, secs: u64) -> Self {
1076        self.inner = self.inner.beacon_period(secs);
1077        self
1078    }
1079
1080    /// Register a startup hook. See [`PvaServerBuilder::on_start`].
1081    pub fn on_start<F>(mut self, hook: F) -> Self
1082    where
1083        F: Fn(Arc<SimplePvStore>) -> Pin<Box<dyn Future<Output = ()> + Send>>
1084            + Send
1085            + Sync
1086            + 'static,
1087    {
1088        self.inner = self.inner.on_start(hook);
1089        self
1090    }
1091
1092    /// Register an event handler. See [`PvaServerBuilder::on_event`].
1093    pub fn on_event<F>(mut self, event: impl Into<String>, handler: F) -> Self
1094    where
1095        F: Fn(Arc<SimplePvStore>, String) -> Pin<Box<dyn Future<Output = ()> + Send>>
1096            + Send
1097            + Sync
1098            + 'static,
1099    {
1100        self.inner = self.inner.on_event(event, handler);
1101        self
1102    }
1103
1104    /// Register an inline event sink. See [`PvaServerBuilder::event_sink`].
1105    pub fn event_sink(mut self, sink: Arc<dyn crate::events::EventSink>) -> Self {
1106        self.inner = self.inner.event_sink(sink);
1107        self
1108    }
1109
1110    /// Materialise records, links and scans from the handles, build the
1111    /// server, then bind every handle to the store.
1112    ///
1113    /// Async because registering PUT validators post-build goes through
1114    /// `SimplePvStore::set_validator`, which is async (an `RwLock` write);
1115    /// there is no synchronous alternative and `spvirit-server` does not
1116    /// depend on `futures`, so this awaits inline rather than blocking.
1117    pub async fn build(mut self) -> PvaServer {
1118        let mut validators: Vec<(String, crate::simple_store::PutValidator)> = Vec::new();
1119        for h in &self.handles {
1120            let name = h.name().to_string();
1121            if let Some(rec) = h.take_record() {
1122                self.inner.records.insert(name.clone(), rec);
1123            }
1124            if let Some(v) = h.take_validator() {
1125                validators.push((name.clone(), v));
1126            }
1127            if let Some((period, cb)) = h.take_scan() {
1128                self.inner.scans.push((name.clone(), period, cb));
1129            }
1130            if let Some((inputs, compute)) = h.take_calc() {
1131                self.inner.links.push(LinkDef {
1132                    output: name.clone(),
1133                    inputs,
1134                    compute,
1135                });
1136            }
1137        }
1138        let server = self.inner.build();
1139        let store = server.store().clone();
1140        for h in &self.handles {
1141            h.bind(&store);
1142        }
1143        for (name, v) in validators {
1144            store.set_validator(name, v).await;
1145        }
1146        server
1147    }
1148
1149    /// Build and run (blocks until shutdown).
1150    pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
1151        self.build().await.run().await
1152    }
1153
1154    /// Build, run the `on_start` hooks to completion, then spawn the server;
1155    /// returns a handle for typed access and shutdown.
1156    ///
1157    /// Returns `Err` if a hook aborts startup, mirroring Python's
1158    /// `start_background`. This phase is awaited here rather than inside the
1159    /// spawned task for two reasons: a hook that aborts must surface to the
1160    /// caller instead of only reaching a `tracing::error!` they may have no
1161    /// subscriber for, and hooks must have finished before `start()` returns
1162    /// so `RunningServer::pv(...)` cannot read pre-hook values.
1163    ///
1164    /// A failure *after* the hook phase (a bind error, say) still cannot be
1165    /// returned here — the server is serving by then — and is logged from
1166    /// the spawned task as before.
1167    pub async fn start(self) -> Result<RunningServer, String> {
1168        let server = self.build().await;
1169        let store = server.store().clone();
1170        let events = server.events().clone();
1171        server.run_start_hooks().await?;
1172        // Start the dispatcher here rather than leaving it to
1173        // `serve_after_start_hooks` in the spawned task: otherwise
1174        // `RunningServer::post_event` immediately after `start()` races the
1175        // spawn, and `events().drain()` would report a dispatcher that has
1176        // not started yet. `start_dispatcher` is idempotent.
1177        events.start_dispatcher(store.clone());
1178        let handle = tokio::spawn(async move {
1179            if let Err(e) = server.serve_after_start_hooks().await {
1180                tracing::error!("PvaServer exited with error: {e}");
1181            }
1182        });
1183        Ok(RunningServer {
1184            store,
1185            events,
1186            handle,
1187        })
1188    }
1189}
1190
1191/// A started server: mint typed handles, then `abort()` to stop.
1192pub struct RunningServer {
1193    store: Arc<SimplePvStore>,
1194    /// Kept independently of the `PvaServer`, which `start()` moves into the
1195    /// spawned task — otherwise `ServeBuilder::on_event` could register
1196    /// handlers that nothing could ever fire. Python's `PyServer` keeps the
1197    /// same handle for the same reason.
1198    events: Arc<crate::events::Events>,
1199    handle: tokio::task::JoinHandle<()>,
1200}
1201
1202impl RunningServer {
1203    /// Mint a typed handle to any served record (handle-built or `.db`-loaded).
1204    pub async fn pv<T: crate::pv::PvScalar>(
1205        &self,
1206        name: &str,
1207    ) -> Result<crate::pv::Pv<T>, crate::pv::PvError> {
1208        crate::pv::Pv::attach(&self.store, name).await
1209    }
1210
1211    /// Mint an array handle to any served record (handle-built or `.db`-loaded).
1212    pub async fn array_pv(&self, name: &str) -> Result<crate::pv::PvArray, crate::pv::PvError> {
1213        crate::pv::PvArray::attach(&self.store, name).await
1214    }
1215
1216    /// Add a scalar record to the running server at runtime. The wire type is
1217    /// taken from the `ScalarValue` variant; `writable` selects an output
1218    /// record family (client PUTs allowed) vs an input family (read-only).
1219    /// Returns a bound handle to the new record. Replaces any existing record
1220    /// with the same name.
1221    pub async fn add_scalar(
1222        &self,
1223        name: &str,
1224        value: ScalarValue,
1225        writable: bool,
1226    ) -> crate::pv::Pv<ScalarValue> {
1227        let rt = scalar_family_record_type(&value, writable);
1228        let record = if writable {
1229            make_output_record(name, rt, value)
1230        } else {
1231            make_scalar_record(name, rt, value)
1232        };
1233        self.store.insert(name.to_string(), record).await;
1234        crate::pv::Pv::attach(&self.store, name)
1235            .await
1236            .expect("record just inserted")
1237    }
1238
1239    /// Add an array record to the running server at runtime. `writable`
1240    /// selects `aao` (client PUTs allowed) vs `aai` (read-only). Element type
1241    /// comes from the `ScalarArrayValue` variant. Returns a bound handle.
1242    /// Replaces any existing record with the same name.
1243    pub async fn add_array(
1244        &self,
1245        name: &str,
1246        value: ScalarArrayValue,
1247        writable: bool,
1248    ) -> crate::pv::PvArray {
1249        let rt = if writable {
1250            RecordType::Aao
1251        } else {
1252            RecordType::Aai
1253        };
1254        let record = make_array_record(name, rt, value);
1255        self.store.insert(name.to_string(), record).await;
1256        crate::pv::PvArray::attach(&self.store, name)
1257            .await
1258            .expect("record just inserted")
1259    }
1260
1261    /// Add an NTEnum record at runtime. `writable` selects an `mbbo`
1262    /// (output) vs `mbbi` (input) record type; note both accept client PUTs
1263    /// at the store layer. Replaces any existing record with the same name.
1264    pub async fn add_enum(&self, name: &str, choices: Vec<String>, index: i32, writable: bool) {
1265        let record = make_enum_record(name, choices, index, writable);
1266        self.store.insert(name.to_string(), record).await;
1267    }
1268
1269    /// Add an NTTable record at runtime from named, typed columns. Tables are
1270    /// always writable at the store layer. Replaces any existing record with
1271    /// the same name.
1272    pub async fn add_table(&self, name: &str, columns: Vec<(String, ScalarArrayValue)>) {
1273        let record = make_table_record(name, columns);
1274        self.store.insert(name.to_string(), record).await;
1275    }
1276
1277    pub fn store(&self) -> &Arc<SimplePvStore> {
1278        &self.store
1279    }
1280
1281    /// The running server's event registry — register sinks or handlers, or
1282    /// read the drop/failure counters.
1283    pub fn events(&self) -> &Arc<crate::events::Events> {
1284        &self.events
1285    }
1286
1287    /// Post a named event. See [`PvaServer::post_event`].
1288    pub async fn post_event(&self, event: &str) {
1289        self.events.post(event).await;
1290    }
1291
1292    pub fn abort(&self) {
1293        self.handle.abort();
1294    }
1295}
1296
1297// ─── Record construction helpers ─────────────────────────────────────────
1298
1299pub(crate) fn make_scalar_record(
1300    name: &str,
1301    record_type: RecordType,
1302    value: ScalarValue,
1303) -> RecordInstance {
1304    let nt = NtScalar::from_value(value);
1305    let data = match record_type {
1306        RecordType::Ai => RecordData::Ai {
1307            nt,
1308            inp: None,
1309            siml: None,
1310            siol: None,
1311            simm: false,
1312        },
1313        RecordType::Bi => RecordData::Bi {
1314            nt,
1315            inp: None,
1316            znam: "Off".to_string(),
1317            onam: "On".to_string(),
1318            siml: None,
1319            siol: None,
1320            simm: false,
1321        },
1322        RecordType::StringIn => RecordData::StringIn {
1323            nt,
1324            inp: None,
1325            siml: None,
1326            siol: None,
1327            simm: false,
1328        },
1329        // longin reuses the Ai data shape (NtScalar input record)
1330        RecordType::LongIn => RecordData::Ai {
1331            nt,
1332            inp: None,
1333            siml: None,
1334            siol: None,
1335            simm: false,
1336        },
1337        _ => panic!("make_scalar_record: unsupported type {record_type:?}"),
1338    };
1339    RecordInstance {
1340        name: name.to_string(),
1341        record_type,
1342        common: DbCommonState::default(),
1343        data,
1344        raw_fields: HashMap::new(),
1345    }
1346}
1347
1348pub(crate) fn make_output_record(
1349    name: &str,
1350    record_type: RecordType,
1351    value: ScalarValue,
1352) -> RecordInstance {
1353    let nt = NtScalar::from_value(value);
1354    let data = match record_type {
1355        RecordType::Ao => RecordData::Ao {
1356            nt,
1357            out: None,
1358            dol: None,
1359            omsl: OutputMode::Supervisory,
1360            drvl: None,
1361            drvh: None,
1362            oroc: None,
1363            siml: None,
1364            siol: None,
1365            simm: false,
1366        },
1367        RecordType::Bo => RecordData::Bo {
1368            nt,
1369            out: None,
1370            dol: None,
1371            omsl: OutputMode::Supervisory,
1372            znam: "Off".to_string(),
1373            onam: "On".to_string(),
1374            siml: None,
1375            siol: None,
1376            simm: false,
1377        },
1378        RecordType::StringOut => RecordData::StringOut {
1379            nt,
1380            out: None,
1381            dol: None,
1382            omsl: OutputMode::Supervisory,
1383            siml: None,
1384            siol: None,
1385            simm: false,
1386        },
1387        // longout reuses the Ao data shape (NtScalar output record)
1388        RecordType::LongOut => RecordData::Ao {
1389            nt,
1390            out: None,
1391            dol: None,
1392            omsl: OutputMode::Supervisory,
1393            drvl: None,
1394            drvh: None,
1395            oroc: None,
1396            siml: None,
1397            siol: None,
1398            simm: false,
1399        },
1400        _ => panic!("make_output_record: unsupported type {record_type:?}"),
1401    };
1402    RecordInstance {
1403        name: name.to_string(),
1404        record_type,
1405        common: DbCommonState::default(),
1406        data,
1407        raw_fields: HashMap::new(),
1408    }
1409}
1410
1411/// Build an array-backed record (`waveform`/`aai`/`aao`) with ftvl/nelm/nord
1412/// inferred from `data`. Shared by the classic builder (`.waveform`/`.aai`/
1413/// `.aao`) and `PvArray`'s constructors so the inference lives in one place.
1414pub(crate) fn make_array_record(
1415    name: &str,
1416    record_type: RecordType,
1417    data: ScalarArrayValue,
1418) -> RecordInstance {
1419    let ftvl = data.type_label().trim_end_matches("[]").to_string();
1420    let nelm = data.len();
1421    let nt = NtScalarArray::from_value(data);
1422    let record_data = match record_type {
1423        RecordType::Waveform => RecordData::Waveform {
1424            nt,
1425            inp: None,
1426            ftvl,
1427            nelm,
1428            nord: nelm,
1429        },
1430        RecordType::Aai => RecordData::Aai {
1431            nt,
1432            inp: None,
1433            ftvl,
1434            nelm,
1435            nord: nelm,
1436        },
1437        RecordType::Aao => RecordData::Aao {
1438            nt,
1439            out: None,
1440            dol: None,
1441            omsl: OutputMode::Supervisory,
1442            ftvl,
1443            nelm,
1444            nord: nelm,
1445        },
1446        _ => panic!("make_array_record: unsupported type {record_type:?}"),
1447    };
1448    RecordInstance {
1449        name: name.to_string(),
1450        record_type,
1451        common: DbCommonState::default(),
1452        data: record_data,
1453        raw_fields: HashMap::new(),
1454    }
1455}
1456
1457pub(crate) fn make_enum_record(
1458    name: &str,
1459    choices: Vec<String>,
1460    index: i32,
1461    writable: bool,
1462) -> RecordInstance {
1463    RecordInstance {
1464        name: name.to_string(),
1465        record_type: if writable { RecordType::Mbbo } else { RecordType::Mbbi },
1466        common: DbCommonState::default(),
1467        data: RecordData::NtEnum {
1468            nt: NtEnum::new(index, choices),
1469            inp: None,
1470            out: None,
1471            omsl: OutputMode::Supervisory,
1472        },
1473        raw_fields: HashMap::new(),
1474    }
1475}
1476
1477pub(crate) fn make_table_record(
1478    name: &str,
1479    columns: Vec<(String, ScalarArrayValue)>,
1480) -> RecordInstance {
1481    let labels: Vec<String> = columns.iter().map(|(n, _)| n.clone()).collect();
1482    let cols: Vec<NtTableColumn> = columns
1483        .into_iter()
1484        .map(|(n, v)| NtTableColumn { name: n, values: v })
1485        .collect();
1486    RecordInstance {
1487        name: name.to_string(),
1488        record_type: RecordType::NtTable,
1489        common: DbCommonState::default(),
1490        data: RecordData::NtTable {
1491            nt: NtTableType { labels, columns: cols, descriptor: None, alarm: None, time_stamp: None },
1492            inp: None,
1493            out: None,
1494            omsl: OutputMode::Supervisory,
1495        },
1496        raw_fields: HashMap::new(),
1497    }
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502    use super::*;
1503
1504    #[tokio::test]
1505    async fn on_start_hooks_are_stored_and_runnable_in_order() {
1506        use std::sync::Mutex;
1507        let log = std::sync::Arc::new(Mutex::new(Vec::new()));
1508
1509        let l1 = log.clone();
1510        let l2 = log.clone();
1511        let server = PvaServer::builder()
1512            .ai("T:A", 1.0)
1513            .on_start(move |_store| {
1514                let l = l1.clone();
1515                Box::pin(async move { l.lock().unwrap().push("first"); })
1516            })
1517            .on_start(move |_store| {
1518                let l = l2.clone();
1519                Box::pin(async move { l.lock().unwrap().push("second"); })
1520            })
1521            .build();
1522
1523        server.run_start_hooks().await.expect("hooks must succeed");
1524
1525        assert_eq!(log.lock().unwrap().as_slice(), &["first", "second"]);
1526    }
1527
1528    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1529    async fn serve_builder_start_surfaces_a_start_hook_abort() {
1530        // Previously start() spawned run() and discarded its Result, so a
1531        // hook that aborted startup produced a healthy-looking RunningServer,
1532        // a tracing::error! the caller may never see, and a server that never
1533        // bound. Python's start_background already refuses to do that.
1534        const RUN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
1535        let started = tokio::time::timeout(
1536            RUN_TIMEOUT,
1537            PvaServer::serve(Vec::<crate::pv::AnyPv>::new())
1538                .port(0)
1539                .udp_port(0)
1540                .on_start(|_store| Box::pin(async { panic!("init failed: no DB") }))
1541                .start(),
1542        )
1543        .await
1544        .expect("ServeBuilder::start() did not return within RUN_TIMEOUT");
1545
1546        let err = started.err().expect("an aborting hook must fail start()");
1547        assert!(
1548            err.contains("init failed: no DB"),
1549            "start() must surface the hook's cause, got: {err}"
1550        );
1551    }
1552
1553    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1554    async fn serve_builder_start_returns_only_after_hooks_have_run() {
1555        // RunningServer::pv(...) must never be able to read a pre-hook value.
1556        const RUN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
1557        let server = tokio::time::timeout(
1558            RUN_TIMEOUT,
1559            PvaServer::serve(Vec::<crate::pv::AnyPv>::new())
1560                .port(0)
1561                .udp_port(0)
1562                .on_start(|store| {
1563                    Box::pin(async move {
1564                        store.insert(
1565                            "T:HOOKED".to_string(),
1566                            make_scalar_record(
1567                                "T:HOOKED",
1568                                RecordType::Ai,
1569                                ScalarValue::F64(7.0),
1570                            ),
1571                        )
1572                        .await;
1573                    })
1574                })
1575                .start(),
1576        )
1577        .await
1578        .expect("ServeBuilder::start() did not return within RUN_TIMEOUT")
1579        .expect("hooks must succeed");
1580
1581        assert_eq!(
1582            server.store().get_value("T:HOOKED").await,
1583            Some(ScalarValue::F64(7.0)),
1584            "start() must not return before on_start hooks have finished"
1585        );
1586        server.abort();
1587    }
1588
1589    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1590    async fn running_server_can_post_to_a_serve_builder_handler() {
1591        // ServeBuilder::on_event registered handlers that nothing could ever
1592        // fire: start() moved the PvaServer into the spawned task and
1593        // RunningServer had no events()/post_event().
1594        const RUN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
1595        let server = tokio::time::timeout(
1596            RUN_TIMEOUT,
1597            PvaServer::serve(Vec::<crate::pv::AnyPv>::new())
1598                .port(0)
1599                .udp_port(0)
1600                .on_event("SHUTTER", |store, _event| {
1601                    Box::pin(async move {
1602                        store
1603                            .insert(
1604                                "T:FIRED".to_string(),
1605                                make_scalar_record(
1606                                    "T:FIRED",
1607                                    RecordType::Ai,
1608                                    ScalarValue::F64(1.0),
1609                                ),
1610                            )
1611                            .await;
1612                    })
1613                })
1614                .start(),
1615        )
1616        .await
1617        .expect("ServeBuilder::start() did not return within RUN_TIMEOUT")
1618        .expect("hooks must succeed");
1619
1620        server.post_event("SHUTTER").await;
1621        server.events().drain().await;
1622
1623        assert_eq!(
1624            server.store().get_value("T:FIRED").await,
1625            Some(ScalarValue::F64(1.0)),
1626            "a ServeBuilder-registered handler must be reachable from the handle"
1627        );
1628        server.abort();
1629    }
1630
1631    #[tokio::test]
1632    async fn a_builder_registered_sink_receives_posted_events() {
1633        use std::sync::Mutex;
1634        struct RecordingSink(Mutex<Vec<String>>);
1635        impl crate::events::EventSink for RecordingSink {
1636            fn on_event(
1637                &self,
1638                event: &str,
1639            ) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
1640                let event = event.to_string();
1641                Box::pin(async move { self.0.lock().unwrap().push(event) })
1642            }
1643        }
1644
1645        let sink = Arc::new(RecordingSink(Mutex::new(Vec::new())));
1646        let server = PvaServer::builder()
1647            .ai("T:A", 1.0)
1648            .event_sink(sink.clone())
1649            .build();
1650
1651        server.post_event("SHUTTER").await;
1652
1653        assert_eq!(sink.0.lock().unwrap().as_slice(), &["SHUTTER".to_string()]);
1654    }
1655
1656    #[tokio::test]
1657    async fn a_panicking_on_start_hook_reports_its_cause() {
1658        // The panic payload is the only place the real cause lives — for a
1659        // Python hook it is "on_start hook raised: ValueError: ...", and for
1660        // a Python *source* hook it also carries the source label. Losing it
1661        // left the user with an index into a list they never wrote.
1662        let server = PvaServer::builder()
1663            .ai("T:A", 1.0)
1664            .on_start(|_store| {
1665                Box::pin(async {
1666                    panic!("on_start hook for source 'db' raised: DB connection refused")
1667                })
1668            })
1669            .build();
1670
1671        let err = server
1672            .run_start_hooks()
1673            .await
1674            .expect_err("a panicking hook must abort startup");
1675
1676        assert!(
1677            err.contains("DB connection refused"),
1678            "error must carry the panic's cause, got: {err}"
1679        );
1680        assert!(
1681            err.contains("source 'db'"),
1682            "error must preserve the hook label the panic carried, got: {err}"
1683        );
1684    }
1685
1686    #[tokio::test]
1687    async fn on_start_hook_can_write_the_store() {
1688        let server = PvaServer::builder()
1689            .ao("T:SP", 0.0)
1690            .on_start(|store| {
1691                Box::pin(async move {
1692                    store.set_value("T:SP", ScalarValue::F64(22.5)).await;
1693                })
1694            })
1695            .build();
1696
1697        server.run_start_hooks().await.expect("hooks must succeed");
1698
1699        assert_eq!(
1700            server.store().get_value("T:SP").await,
1701            Some(ScalarValue::F64(22.5))
1702        );
1703    }
1704
1705    #[tokio::test]
1706    async fn on_start_hook_write_reaches_a_subscribed_monitor() {
1707        // Regression test for a real ordering bug: splitting run() into
1708        // run_start_hooks() + serve_after_start_hooks() briefly moved
1709        // `self.store.set_registry(...)` to run *after* the hook phase,
1710        // so a hook writing the store during startup would not notify any
1711        // subscriber, and a hook reading the registry off the store would
1712        // see `None`. Fixed by resolving/installing the registry inside
1713        // run_start_hooks() itself (see `resolved_monitor_registry`).
1714        use crate::state::MonitorSub;
1715
1716        let mut server = PvaServer::builder()
1717            .ao("T:SP", 0.0)
1718            .on_start(|store| {
1719                Box::pin(async move {
1720                    store.set_value("T:SP", ScalarValue::F64(42.0)).await;
1721                })
1722            })
1723            .build();
1724
1725        // Pre-create the registry and fake a subscriber directly, the way a
1726        // real PVA client connection would register one via
1727        // `update_monitor_subscription` -- but without needing a live
1728        // socket for this test.
1729        let registry = server.monitor_registry();
1730        let (tx, mut rx) = tokio::sync::mpsc::channel(4);
1731        registry.conns.lock().await.insert(1, tx);
1732        registry.monitors.lock().await.insert(
1733            "T:SP".to_string(),
1734            vec![MonitorSub {
1735                conn_id: 1,
1736                ioid: 0,
1737                version: 2,
1738                is_be: true,
1739                running: true,
1740                pipeline_enabled: false,
1741                nfree: 0,
1742                filtered_desc: None,
1743                last_snapshot: None,
1744            }],
1745        );
1746
1747        server.run_start_hooks().await.expect("hooks must succeed");
1748
1749        let msg = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1750            .await
1751            .expect("monitor update did not arrive within 5s -- on_start hook's write did not reach the registry")
1752            .expect("monitor channel closed unexpectedly");
1753        assert!(!msg.is_empty(), "monitor update message must not be empty");
1754    }
1755
1756    #[tokio::test]
1757    async fn post_event_reaches_a_builder_registered_handler() {
1758        use std::sync::Mutex;
1759        let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
1760        let s = seen.clone();
1761
1762        let server = PvaServer::builder()
1763            .ai("T:A", 1.0)
1764            .on_event("SHUTTER", move |_store, event| {
1765                let s = s.clone();
1766                Box::pin(async move { s.lock().unwrap().push(event); })
1767            })
1768            .build();
1769
1770        server.events().start_dispatcher(server.store().clone());
1771        server.post_event("SHUTTER").await;
1772        server.events().drain().await;
1773
1774        assert_eq!(seen.lock().unwrap().as_slice(), &["SHUTTER".to_string()]);
1775    }
1776
1777    #[tokio::test]
1778    async fn scan_tasks_do_not_run_before_start_hooks_finish() {
1779        use std::sync::Mutex;
1780        use std::time::Duration;
1781        let log = std::sync::Arc::new(Mutex::new(Vec::new()));
1782
1783        let l = log.clone();
1784        let scan_log = log.clone();
1785        let server = PvaServer::builder()
1786            .ai("T:TICK", 0.0)
1787            .port(0)
1788            .on_start(move |_store| {
1789                let l = l.clone();
1790                Box::pin(async move {
1791                    // Yield repeatedly: a scan task spawned too early would
1792                    // interleave here.
1793                    for _ in 0..50 {
1794                        tokio::task::yield_now().await;
1795                    }
1796                    l.lock().unwrap().push("hook-done");
1797                })
1798            })
1799            .scan("T:TICK", Duration::from_millis(1), move |_name| {
1800                scan_log.lock().unwrap().push("scan");
1801                ScalarValue::F64(1.0)
1802            })
1803            .build();
1804
1805        // Output must be Send for tokio::spawn; discard the Result inline
1806        // (mirrors ServeBuilder::start's spawn) since this test only checks
1807        // ordering via `log`, not the run() outcome.
1808        let handle = tokio::spawn(async move {
1809            let _ = server.run().await;
1810        });
1811        tokio::time::sleep(Duration::from_millis(100)).await;
1812        handle.abort();
1813
1814        let entries = log.lock().unwrap().clone();
1815        assert_eq!(
1816            entries.first(),
1817            Some(&"hook-done"),
1818            "start hook must complete before the first scan tick; got {entries:?}"
1819        );
1820    }
1821
1822    #[tokio::test]
1823    async fn no_client_can_connect_before_start_hooks_finish() {
1824        use std::sync::atomic::{AtomicBool, Ordering};
1825        use std::time::Duration;
1826
1827        // The hook blocks on this gate; the test releases it only after
1828        // confirming the listener has not yet bound.
1829        let gate = std::sync::Arc::new(tokio::sync::Notify::new());
1830        let hook_entered = std::sync::Arc::new(AtomicBool::new(false));
1831
1832        let g = gate.clone();
1833        let entered = hook_entered.clone();
1834        let server = PvaServer::builder()
1835            .ai("T:GATED", 0.0)
1836            .port(0)
1837            .on_start(move |store| {
1838                let g = g.clone();
1839                let entered = entered.clone();
1840                Box::pin(async move {
1841                    entered.store(true, Ordering::SeqCst);
1842                    g.notified().await;
1843                    store.set_value("T:GATED", ScalarValue::F64(99.0)).await;
1844                })
1845            })
1846            .build();
1847
1848        let store = server.store().clone();
1849        // Output must be Send for tokio::spawn; discard the Result inline
1850        // (mirrors ServeBuilder::start's spawn) since this test only checks
1851        // the gating via `store`, not the run() outcome.
1852        let handle = tokio::spawn(async move {
1853            let _ = server.run().await;
1854        });
1855
1856        // Give run() time to reach the hook and block there.
1857        tokio::time::sleep(Duration::from_millis(50)).await;
1858        assert!(hook_entered.load(Ordering::SeqCst), "hook should have started");
1859        assert_eq!(
1860            store.get_value("T:GATED").await,
1861            Some(ScalarValue::F64(0.0)),
1862            "hook has not finished, so the initial value is still in place"
1863        );
1864
1865        gate.notify_waiters();
1866        tokio::time::sleep(Duration::from_millis(50)).await;
1867        assert_eq!(
1868            store.get_value("T:GATED").await,
1869            Some(ScalarValue::F64(99.0)),
1870            "hook must have completed once released"
1871        );
1872
1873        handle.abort();
1874    }
1875
1876    #[tokio::test]
1877    async fn run_aborts_when_a_start_hook_panics() {
1878        let server = PvaServer::builder()
1879            .ai("T:A", 1.0)
1880            .port(0)
1881            .on_start(|_store| Box::pin(async { panic!("init failed"); }))
1882            .build();
1883
1884        // Bounded, like `Events::drain()` (events.rs:176): if a future
1885        // regression ever swallows the hook's error again, `run()` falls
1886        // through to `run_pva_server_with_registry` and serves forever —
1887        // this must surface as a named panic, not a hanging `cargo test`.
1888        const RUN_TIMEOUT: Duration = Duration::from_secs(5);
1889        let result = tokio::time::timeout(RUN_TIMEOUT, server.run())
1890            .await
1891            .expect(
1892                "run() did not return within 5s — the panicking on_start hook's \
1893                 error was likely swallowed, so run() fell through to bind the \
1894                 listener and is now serving forever instead of aborting startup",
1895            );
1896
1897        assert!(result.is_err(), "run() must fail when a start hook panics");
1898        let msg = result.unwrap_err().to_string();
1899        assert!(
1900            msg.contains("on_start"),
1901            "error must name the failing hook, got: {msg}"
1902        );
1903    }
1904
1905    #[tokio::test]
1906    async fn serve_builder_forwards_on_start_and_on_event() {
1907        use std::sync::Mutex;
1908        let log = std::sync::Arc::new(Mutex::new(Vec::new()));
1909        let l1 = log.clone();
1910        let l2 = log.clone();
1911
1912        let temp = Pv::ai("T:TEMP", 20.0);
1913        let server = PvaServer::serve([temp])
1914            .on_start(move |_store| {
1915                let l = l1.clone();
1916                Box::pin(async move { l.lock().unwrap().push("started"); })
1917            })
1918            .on_event("GO", move |_store, _event| {
1919                let l = l2.clone();
1920                Box::pin(async move { l.lock().unwrap().push("evented"); })
1921            })
1922            .build()
1923            .await;
1924
1925        server.run_start_hooks().await.expect("hooks must succeed");
1926        server.events().start_dispatcher(server.store().clone());
1927        server.post_event("GO").await;
1928        server.events().drain().await;
1929
1930        assert_eq!(log.lock().unwrap().as_slice(), &["started", "evented"]);
1931    }
1932
1933    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1934    async fn running_server_add_scalar_and_array() {
1935        use spvirit_types::{ScalarArrayValue, ScalarValue};
1936
1937        let server = PvaServer::serve(Vec::<crate::pv::AnyPv>::new())
1938            .port(0)
1939            .udp_port(0)
1940            .start()
1941            .await
1942            .expect("server start hooks must succeed");
1943
1944        // add a writable u32 scalar
1945        let h = server.add_scalar("RT:U32", ScalarValue::U32(7), true).await;
1946        assert_eq!(h.get().await.unwrap(), ScalarValue::U32(7));
1947        // exact wire type preserved
1948        assert!(matches!(
1949            server.store().get_value("RT:U32").await,
1950            Some(ScalarValue::U32(7))
1951        ));
1952
1953        // add a read-only i16 scalar; family maps to an input record
1954        let _ = server.add_scalar("RT:I16", ScalarValue::I16(-3), false).await;
1955        assert!(matches!(
1956            server.store().get_value("RT:I16").await,
1957            Some(ScalarValue::I16(-3))
1958        ));
1959
1960        // add a writable f64 array
1961        let a = server
1962            .add_array("RT:ARR", ScalarArrayValue::F64(vec![1.0, 2.0, 3.0]), true)
1963            .await;
1964        a.set(ScalarArrayValue::F64(vec![4.0, 5.0])).await.unwrap();
1965        assert!(matches!(
1966            server.store().get_nt("RT:ARR").await,
1967            Some(spvirit_types::NtPayload::ScalarArray(_))
1968        ));
1969
1970        server.abort();
1971    }
1972
1973    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1974    async fn running_server_add_enum_and_table() {
1975        use spvirit_types::{NtPayload, ScalarArrayValue};
1976
1977        let server = PvaServer::serve(Vec::<crate::pv::AnyPv>::new())
1978            .port(0)
1979            .udp_port(0)
1980            .start()
1981            .await
1982            .expect("server start hooks must succeed");
1983
1984        // writable enum -> mbbo, choices + index preserved
1985        server
1986            .add_enum("RT:ENUM", vec!["OFF".into(), "ON".into(), "TRIP".into()], 1, true)
1987            .await;
1988        match server.store().get_nt("RT:ENUM").await {
1989            Some(NtPayload::Enum(e)) => {
1990                assert_eq!(e.index, 1);
1991                assert_eq!(e.choices, vec!["OFF", "ON", "TRIP"]);
1992            }
1993            other => panic!("expected enum, got {other:?}"),
1994        }
1995
1996        // read-only enum -> mbbi; still writable() at the store layer (documented)
1997        server.add_enum("RT:ENUM_RO", vec!["A".into(), "B".into()], 0, false).await;
1998        assert!(matches!(
1999            server.store().get_nt("RT:ENUM_RO").await,
2000            Some(NtPayload::Enum(_))
2001        ));
2002
2003        // table with two typed columns
2004        server
2005            .add_table(
2006                "RT:TBL",
2007                vec![
2008                    ("id".into(), ScalarArrayValue::I32(vec![1, 2, 3])),
2009                    ("x".into(), ScalarArrayValue::F64(vec![0.5, 1.5, 2.5])),
2010                ],
2011            )
2012            .await;
2013        match server.store().get_nt("RT:TBL").await {
2014            Some(NtPayload::Table(t)) => {
2015                assert_eq!(t.labels, vec!["id", "x"]);
2016                assert_eq!(t.columns.len(), 2);
2017            }
2018            other => panic!("expected table, got {other:?}"),
2019        }
2020
2021        server.abort();
2022    }
2023
2024    #[test]
2025    fn builder_creates_records() {
2026        let server = PvaServer::builder()
2027            .ai("T:AI", 1.0)
2028            .ao("T:AO", 2.0)
2029            .bi("T:BI", true)
2030            .bo("T:BO", false)
2031            .string_in("T:SI", "hello")
2032            .string_out("T:SO", "world")
2033            .build();
2034
2035        let rt = tokio::runtime::Builder::new_current_thread()
2036            .enable_all()
2037            .build()
2038            .unwrap();
2039        let names = rt.block_on(server.store.pv_names());
2040        assert_eq!(names.len(), 6);
2041    }
2042
2043    #[test]
2044    fn builder_defaults() {
2045        let server = PvaServer::builder().build();
2046        assert_eq!(server.config.tcp_port, 5075);
2047        assert_eq!(server.config.udp_port, 5076);
2048        assert!(!server.config.compute_alarms);
2049    }
2050
2051    #[test]
2052    fn builder_port_override() {
2053        let server = PvaServer::builder().port(9075).udp_port(9076).build();
2054        assert_eq!(server.config.tcp_port, 9075);
2055        assert_eq!(server.config.udp_port, 9076);
2056    }
2057
2058    #[test]
2059    fn builder_db_string() {
2060        let db = r#"
2061            record(ai, "TEST:VAL") {
2062                field(VAL, "3.14")
2063            }
2064        "#;
2065        let server = PvaServer::builder().db_string(db).build();
2066        let rt = tokio::runtime::Builder::new_current_thread()
2067            .enable_all()
2068            .build()
2069            .unwrap();
2070        assert!(rt.block_on(server.store.get_value("TEST:VAL")).is_some());
2071    }
2072
2073    #[test]
2074    #[should_panic(expected = "failed to parse db string")]
2075    fn a_malformed_db_string_aborts_the_builder_rather_than_serving_nothing() {
2076        // Finding 2: a parse failure must not be swallowed into a server
2077        // with zero records from this source -- it must abort loudly at
2078        // startup instead.
2079        let _ = PvaServer::builder().db_string("not a valid db line").build();
2080    }
2081
2082    #[test]
2083    fn a_malformed_db_file_aborts_the_builder_rather_than_serving_nothing() {
2084        let dir = std::env::temp_dir();
2085        let path = dir.join(format!(
2086            "spvirit-bad-{}-{}.db",
2087            std::process::id(),
2088            std::time::SystemTime::now()
2089                .duration_since(std::time::UNIX_EPOCH)
2090                .unwrap()
2091                .as_nanos()
2092        ));
2093        std::fs::write(&path, "not a valid db line").expect("write temp db file");
2094        let path_str = path.to_string_lossy().into_owned();
2095        let result = std::panic::catch_unwind(|| {
2096            let _ = PvaServer::builder().db_file(&path_str).build();
2097        });
2098        let _ = std::fs::remove_file(&path);
2099        let payload = result.expect_err("db_file must panic on a malformed file");
2100        let message = payload
2101            .downcast_ref::<String>()
2102            .cloned()
2103            .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
2104            .unwrap_or_default();
2105        assert!(
2106            message.contains("failed to load db file"),
2107            "panic message must name the failure, got: {message}"
2108        );
2109    }
2110
2111    #[test]
2112    fn builder_waveform() {
2113        let data = ScalarArrayValue::F64(vec![1.0, 2.0, 3.0]);
2114        let server = PvaServer::builder().waveform("T:WF", data).build();
2115        let rt = tokio::runtime::Builder::new_current_thread()
2116            .enable_all()
2117            .build()
2118            .unwrap();
2119        let names = rt.block_on(server.store.pv_names());
2120        assert!(names.contains(&"T:WF".to_string()));
2121    }
2122
2123    #[test]
2124    fn builder_scan_callback() {
2125        let server = PvaServer::builder()
2126            .ai("SCAN:V", 0.0)
2127            .scan("SCAN:V", Duration::from_secs(1), |_name| {
2128                ScalarValue::F64(42.0)
2129            })
2130            .build();
2131        assert_eq!(server.scans.len(), 1);
2132    }
2133
2134    #[test]
2135    fn builder_on_put_callback() {
2136        let server = PvaServer::builder()
2137            .ao("PUT:V", 0.0)
2138            .on_put("PUT:V", |_name, _val| {})
2139            .build();
2140        // on_put is stored in the SimplePvStore, not directly inspectable,
2141        // but the server built without panic.
2142        let rt = tokio::runtime::Builder::new_current_thread()
2143            .enable_all()
2144            .build()
2145            .unwrap();
2146        assert!(rt.block_on(server.store.get_value("PUT:V")).is_some());
2147    }
2148
2149    #[test]
2150    fn store_runtime_get_set() {
2151        let server = PvaServer::builder().ao("RT:V", 0.0).build();
2152        let rt = tokio::runtime::Builder::new_current_thread()
2153            .enable_all()
2154            .build()
2155            .unwrap();
2156        let store = server.store().clone();
2157        rt.block_on(async {
2158            assert_eq!(store.get_value("RT:V").await, Some(ScalarValue::F64(0.0)));
2159            store.set_value("RT:V", ScalarValue::F64(99.0)).await;
2160            assert_eq!(store.get_value("RT:V").await, Some(ScalarValue::F64(99.0)));
2161        });
2162    }
2163
2164    #[test]
2165    fn link_propagates_on_set_value() {
2166        let server = PvaServer::builder()
2167            .ao("INPUT:A", 1.0)
2168            .ao("INPUT:B", 2.0)
2169            .ai("CALC:SUM", 0.0)
2170            .link("CALC:SUM", &["INPUT:A", "INPUT:B"], |values| {
2171                let a = match &values[0] {
2172                    ScalarValue::F64(v) => *v,
2173                    _ => 0.0,
2174                };
2175                let b = match &values[1] {
2176                    ScalarValue::F64(v) => *v,
2177                    _ => 0.0,
2178                };
2179                ScalarValue::F64(a + b)
2180            })
2181            .build();
2182
2183        let rt = tokio::runtime::Builder::new_current_thread()
2184            .enable_all()
2185            .build()
2186            .unwrap();
2187        let store = server.store().clone();
2188        rt.block_on(async {
2189            // Writing INPUT:A should recompute CALC:SUM = 10 + 2.
2190            store.set_value("INPUT:A", ScalarValue::F64(10.0)).await;
2191            assert_eq!(
2192                store.get_value("CALC:SUM").await,
2193                Some(ScalarValue::F64(12.0))
2194            );
2195
2196            // Writing INPUT:B should recompute CALC:SUM = 10 + 5.
2197            store.set_value("INPUT:B", ScalarValue::F64(5.0)).await;
2198            assert_eq!(
2199                store.get_value("CALC:SUM").await,
2200                Some(ScalarValue::F64(15.0))
2201            );
2202        });
2203    }
2204
2205    use crate::pv::{AnyPv, Pv};
2206
2207    #[tokio::test]
2208    async fn serve_builder_binds_handles_and_registers_everything() {
2209        let temp = Pv::ai("S:T", 22.5).mdel(0.1);
2210        let sp = Pv::ao("S:SP", 25.0).on_put(|_pv, _v: f64| Ok(()));
2211        let a = Pv::ai("S:A", 1.0);
2212        let b = Pv::ai("S:B", 2.0);
2213        let sum = Pv::calc("S:SUM", &[&a, &b], |vals| vals.iter().sum());
2214
2215        // Rust can't infer the `impl Into<AnyPv>` target type per-element
2216        // inside an array literal (E0283), so each handle is converted
2217        // explicitly here rather than via a bare `.into()`.
2218        let server = PvaServer::serve([AnyPv::from(temp.clone()), AnyPv::from(sp)])
2219            .pvs([
2220                AnyPv::from(a.clone()),
2221                AnyPv::from(b),
2222                AnyPv::from(sum.clone()),
2223            ])
2224            .build()
2225            .await;
2226
2227        // handles are bound: typed set/get works against the built store
2228        temp.set(23.0).await.unwrap();
2229        assert_eq!(temp.get().await, Ok(23.0));
2230
2231        // calc evaluated on input change
2232        a.set(10.0).await.unwrap();
2233        assert_eq!(sum.get().await, Ok(12.0));
2234
2235        // record made it into the store with its raw fields
2236        let rec = server.store().get_record("S:T").await.unwrap();
2237        assert_eq!(rec.raw_fields.get("MDEL").map(String::as_str), Some("0.1"));
2238    }
2239
2240    #[tokio::test]
2241    async fn running_server_mints_handles_to_db_records() {
2242        // parse_db is line-oriented (one `record(...)`/`field(...)`
2243        // statement per line); a packed one-liner is an "unrecognised line"
2244        // parse error (which now aborts the whole file/string rather than
2245        // silently dropping the one bad record, see `db_string`'s doc
2246        // comment), so this uses the same multi-line shape as the other
2247        // db_string tests in this module.
2248        let server = PvaServer::serve(Vec::<AnyPv>::new())
2249            .db_string("record(ao, \"DB:X\") {\n    field(VAL, \"2.5\")\n}")
2250            .build()
2251            .await;
2252        let store = server.store().clone();
2253        let h: crate::pv::Pv<f64> = crate::pv::Pv::attach(&store, "DB:X").await.unwrap();
2254        assert_eq!(h.get().await, Ok(2.5));
2255    }
2256
2257    #[tokio::test]
2258    async fn homogeneous_iterator_feeds_serve_without_manual_erasure() {
2259        let bpms: Vec<Pv<f64>> = (0..100)
2260            .map(|i| Pv::ai(format!("BPM:{i:03}:X"), 0.0))
2261            .collect();
2262        let server = PvaServer::serve(bpms.iter().cloned()).build().await;
2263        assert_eq!(server.store().pv_names().await.len(), 100);
2264        bpms[42].set(1.23).await.unwrap();
2265        assert_eq!(bpms[42].get().await, Ok(1.23));
2266    }
2267
2268    #[tokio::test]
2269    async fn pva_server_mints_typed_handles_pre_run() {
2270        let server = PvaServer::serve([AnyPv::from(Pv::ai("PRE:X", 5.0))])
2271            .build()
2272            .await;
2273        let h: crate::pv::Pv<f64> = server.pv("PRE:X").await.unwrap();
2274        assert_eq!(h.get().await, Ok(5.0));
2275        assert!(matches!(
2276            server.pv::<bool>("PRE:X").await,
2277            Err(crate::pv::PvError::TypeMismatch { .. })
2278        ));
2279    }
2280}