Skip to main content

ad_core_rs/plugin/
runtime.rs

1//! Plugin runtime: control plane (PortActor) + data plane (processing thread).
2//!
3//! # Single-threaded data plane (intentional, G4)
4//!
5//! C++ `NDPluginDriver` runs `numThreads` worker threads sharing one input
6//! queue (`createCallbackThreads`). The Rust port deliberately runs **exactly
7//! one** per-plugin data thread driving a `tokio::select!` loop. This is an
8//! intentional design choice: a single owner of the processing state removes
9//! the C++ worker-pool races (shared `prevUniqueId_`, sort-buffer contention)
10//! and keeps array ordering trivially correct. The `NUM_THREADS` / `MAX_THREADS`
11//! PVs are therefore not backed by a real worker pool — instead `NumThreads`
12//! is validated and clamped to `[1, MaxThreads]` on write and the clamped
13//! value is written back, so the PV is honest about the accepted value rather
14//! than silently inert.
15
16use std::collections::BTreeMap;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::thread;
20
21use asyn_rs::error::AsynResult;
22use asyn_rs::port::{PortDriver, PortDriverBase, PortFlags};
23use asyn_rs::runtime::config::RuntimeConfig;
24use asyn_rs::runtime::port::{PortRuntimeHandle, create_port_runtime, port_runtime_unavailable};
25use asyn_rs::user::AsynUser;
26use epics_libcom_rs::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
27
28use asyn_rs::port_handle::PortHandle;
29
30use crate::ndarray::NDArray;
31use crate::ndarray_pool::NDArrayPool;
32use crate::params::ndarray_driver::NDArrayDriverParams;
33use asyn_rs::param::ParamValue;
34
35use super::channel::{
36    NDArrayOutput, NDArrayReceiver, NDArraySender, PublishOutcome, ndarray_channel,
37};
38use super::params::PluginBaseParams;
39use super::wiring::{WiringRegistry, upstream_key};
40
41/// Message sent through the param channel from control plane to data plane.
42///
43/// The channel is FIFO, which is what gives [`PluginParamMsg::Barrier`] its
44/// meaning: when the data thread acknowledges a barrier, every `Change`
45/// enqueued before it has been fully applied (enable flips, wiring rewires,
46/// processor param updates).
47#[derive(Debug)]
48enum PluginParamMsg {
49    /// A param write to apply.
50    Change(usize, i32, ParamChangeValue),
51    /// Sync barrier — acknowledged (best-effort send of `()`) at full
52    /// quiescence: every `Change` enqueued before it has been applied (FIFO
53    /// channel) AND the array queue has drained. The second condition exists
54    /// because arrays travel a separate channel: without it, a param applied
55    /// while an older array still waits in the queue would retroactively
56    /// change how that array is processed.
57    Barrier(std::sync::mpsc::SyncSender<()>),
58}
59
60/// Value sent through the param change channel from control plane to data plane.
61#[derive(Debug, Clone)]
62pub enum ParamChangeValue {
63    Int32(i32),
64    Float64(f64),
65    Octet(String),
66}
67
68impl ParamChangeValue {
69    pub fn as_i32(&self) -> i32 {
70        match self {
71            ParamChangeValue::Int32(v) => *v,
72            ParamChangeValue::Float64(v) => *v as i32,
73            ParamChangeValue::Octet(_) => 0,
74        }
75    }
76
77    pub fn as_f64(&self) -> f64 {
78        match self {
79            ParamChangeValue::Int32(v) => *v as f64,
80            ParamChangeValue::Float64(v) => *v,
81            ParamChangeValue::Octet(_) => 0.0,
82        }
83    }
84
85    pub fn as_string(&self) -> Option<&str> {
86        match self {
87            ParamChangeValue::Octet(s) => Some(s),
88            _ => None,
89        }
90    }
91}
92
93/// A single parameter update produced by a plugin's process_array.
94pub enum ParamUpdate {
95    Int32 {
96        reason: usize,
97        addr: i32,
98        value: i32,
99    },
100    Float64 {
101        reason: usize,
102        addr: i32,
103        value: f64,
104    },
105    Octet {
106        reason: usize,
107        addr: i32,
108        value: String,
109    },
110    Float64Array {
111        reason: usize,
112        addr: i32,
113        value: Vec<f64>,
114    },
115}
116
117impl ParamUpdate {
118    /// Create an Int32 update at addr 0.
119    pub fn int32(reason: usize, value: i32) -> Self {
120        Self::Int32 {
121            reason,
122            addr: 0,
123            value,
124        }
125    }
126    /// Create a Float64 update at addr 0.
127    pub fn float64(reason: usize, value: f64) -> Self {
128        Self::Float64 {
129            reason,
130            addr: 0,
131            value,
132        }
133    }
134    /// Create an Int32 update at a specific addr.
135    pub fn int32_addr(reason: usize, addr: i32, value: i32) -> Self {
136        Self::Int32 {
137            reason,
138            addr,
139            value,
140        }
141    }
142    /// Create a Float64 update at a specific addr.
143    pub fn float64_addr(reason: usize, addr: i32, value: f64) -> Self {
144        Self::Float64 {
145            reason,
146            addr,
147            value,
148        }
149    }
150    /// Create a Float64Array update at addr 0.
151    pub fn float64_array(reason: usize, value: Vec<f64>) -> Self {
152        Self::Float64Array {
153            reason,
154            addr: 0,
155            value,
156        }
157    }
158    /// Create a Float64Array update at a specific addr.
159    pub fn float64_array_addr(reason: usize, addr: i32, value: Vec<f64>) -> Self {
160        Self::Float64Array {
161            reason,
162            addr,
163            value,
164        }
165    }
166    /// Create an Octet (string) update at addr 0.
167    pub fn octet(reason: usize, value: String) -> Self {
168        Self::Octet {
169            reason,
170            addr: 0,
171            value,
172        }
173    }
174    /// Create an Octet (string) update at a specific addr.
175    pub fn octet_addr(reason: usize, addr: i32, value: String) -> Self {
176        Self::Octet {
177            reason,
178            addr,
179            value,
180        }
181    }
182}
183
184/// Result of processing one array: output arrays + param updates to write back.
185pub struct ProcessResult {
186    pub output_arrays: Vec<Arc<NDArray>>,
187    pub param_updates: Vec<ParamUpdate>,
188    /// When `true`, the output arrays are *scattered* — delivered to a single
189    /// downstream consumer in round-robin order rather than broadcast to all.
190    /// The target consumer (and reroute-past-full / drop-on-last decisions) is
191    /// owned by the runtime delivery path, which holds the persistent cursor
192    /// (C++ `NDPluginScatter::nextClient_`); the processor only marks the frame
193    /// as a scatter frame.
194    pub scatter: bool,
195}
196
197impl ProcessResult {
198    /// Convenience: sink plugin with only param updates, no output arrays.
199    pub fn sink(param_updates: Vec<ParamUpdate>) -> Self {
200        Self {
201            output_arrays: vec![],
202            param_updates,
203            scatter: false,
204        }
205    }
206
207    /// Convenience: passthrough/transform plugin with output arrays but no param updates.
208    pub fn arrays(output_arrays: Vec<Arc<NDArray>>) -> Self {
209        Self {
210            output_arrays,
211            param_updates: vec![],
212            scatter: false,
213        }
214    }
215
216    /// Convenience: no outputs, no param updates.
217    pub fn empty() -> Self {
218        Self {
219            output_arrays: vec![],
220            param_updates: vec![],
221            scatter: false,
222        }
223    }
224
225    /// Convenience: scatter output — deliver to the next downstream consumer in
226    /// round-robin order (the runtime owns the cursor and reroute logic).
227    pub fn scatter(output_arrays: Vec<Arc<NDArray>>) -> Self {
228        Self {
229            output_arrays,
230            param_updates: vec![],
231            scatter: true,
232        }
233    }
234}
235
236/// Result of handling a control-plane param change.
237pub struct ParamChangeResult {
238    pub output_arrays: Vec<Arc<NDArray>>,
239    pub param_updates: Vec<ParamUpdate>,
240}
241
242impl ParamChangeResult {
243    pub fn updates(param_updates: Vec<ParamUpdate>) -> Self {
244        Self {
245            output_arrays: vec![],
246            param_updates,
247        }
248    }
249
250    pub fn arrays(output_arrays: Vec<Arc<NDArray>>) -> Self {
251        Self {
252            output_arrays,
253            param_updates: vec![],
254        }
255    }
256
257    pub fn combined(output_arrays: Vec<Arc<NDArray>>, param_updates: Vec<ParamUpdate>) -> Self {
258        Self {
259            output_arrays,
260            param_updates,
261        }
262    }
263
264    pub fn empty() -> Self {
265        Self {
266            output_arrays: vec![],
267            param_updates: vec![],
268        }
269    }
270}
271
272/// Pure processing logic. No threading concerns.
273pub trait NDPluginProcess: Send + 'static {
274    /// Process one array. Return output arrays and param updates.
275    fn process_array(&mut self, array: &NDArray, pool: &NDArrayPool) -> ProcessResult;
276
277    /// Plugin type name for PLUGIN_TYPE param.
278    fn plugin_type(&self) -> &str;
279
280    /// Whether this plugin can process compressed (`codec != None`) arrays
281    /// (C++ `compressionAware_`, G3). Defaults to `false`: a plugin that
282    /// operates on raw pixels must not be handed compressed bytes — the
283    /// runtime drops compressed input and counts it into DroppedArrays.
284    /// A codec/file plugin that understands compressed data overrides this.
285    fn compression_aware(&self) -> bool {
286        false
287    }
288
289    /// Whether this plugin delivers arrays to downstream plugins, i.e. the
290    /// initial `NDArrayCallbacks` param value. Defaults to `true`: most plugins
291    /// do array callbacks. Terminal plugins that never deliver downstream
292    /// (`NDPluginStdArrays`, `NDPluginAttribute`, every `NDPluginFile` writer)
293    /// override this to `false` so the param reflects the behaviour, matching C
294    /// (e.g. `NDPluginFile.cpp:948` `setIntegerParam(NDArrayCallbacks, 0)`).
295    fn does_array_callbacks(&self) -> bool {
296        true
297    }
298
299    /// Register plugin-specific params on the base. Called once during construction.
300    fn register_params(
301        &mut self,
302        _base: &mut PortDriverBase,
303    ) -> Result<(), asyn_rs::error::AsynError> {
304        Ok(())
305    }
306
307    /// Called when a param changes. Reason is the param index.
308    /// Return param updates to be written back to the port driver.
309    fn on_param_change(
310        &mut self,
311        _reason: usize,
312        _params: &PluginParamSnapshot,
313    ) -> ParamChangeResult {
314        ParamChangeResult::empty()
315    }
316
317    /// Return a handle to the latest NDArray data for array reads.
318    /// Override this in plugins like NDPluginStdArrays that serve pixel data
319    /// via readInt8Array/readInt16Array/etc.
320    fn array_data_handle(&self) -> Option<Arc<parking_lot::Mutex<Option<Arc<NDArray>>>>> {
321        None
322    }
323}
324
325/// Read-only snapshot of param values available to the processing thread.
326pub struct PluginParamSnapshot {
327    pub enable_callbacks: bool,
328    /// The param reason that changed.
329    pub reason: usize,
330    /// The address (sub-device) that changed.
331    pub addr: i32,
332    /// The new value.
333    pub value: ParamChangeValue,
334}
335
336/// One buffered entry in the sort buffer: the output arrays for a uniqueId
337/// plus the instant they were inserted (for the per-element staleness
338/// deadline — C++ `sortedListElement::insertionTime_`).
339struct SortEntry {
340    arrays: Vec<Arc<NDArray>>,
341    inserted: std::time::Instant,
342}
343
344/// Sort buffer for reordering out-of-order output arrays by uniqueId.
345///
346/// Port of C++ `sortedNDArrayList_` semantics (NDPluginDriver.cpp).
347/// Only arrays that arrive *out of order* are buffered here — in-order
348/// arrays are emitted immediately by the caller (B2). The drain logic
349/// (`drain_ready`) releases the head while the next-expected uniqueId is
350/// contiguous OR the head has been buffered longer than `sort_time` (B3).
351struct SortBuffer {
352    /// Buffered out-of-order arrays keyed by uniqueId.
353    entries: BTreeMap<i32, SortEntry>,
354    /// uniqueId of the last array emitted downstream (C++ `prevUniqueId_`).
355    prev_unique_id: i32,
356    /// Whether any array has been emitted yet (C++ `firstOutputArray_`).
357    first_output: bool,
358    /// Cumulative count of arrays emitted out of order (C++ DisorderedArrays).
359    disordered_arrays: i32,
360    /// Cumulative count of arrays dropped because the buffer was full
361    /// (C++ DroppedOutputArrays — sort-buffer-overflow portion).
362    dropped_output_arrays: i32,
363}
364
365impl SortBuffer {
366    fn new() -> Self {
367        Self {
368            entries: BTreeMap::new(),
369            prev_unique_id: 0,
370            first_output: true,
371            disordered_arrays: 0,
372            dropped_output_arrays: 0,
373        }
374    }
375
376    /// True if `unique_id` follows `prev_unique_id` in order (C++ `orderOK`).
377    fn order_ok(&self, unique_id: i32) -> bool {
378        unique_id == self.prev_unique_id || unique_id == self.prev_unique_id + 1
379    }
380
381    /// Record that an array with `unique_id` was emitted downstream.
382    /// Updates `prev_unique_id` and counts a disorder if it was out of order.
383    fn note_emitted(&mut self, unique_id: i32) {
384        if !self.first_output && !self.order_ok(unique_id) {
385            self.disordered_arrays += 1;
386        }
387        self.first_output = false;
388        self.prev_unique_id = unique_id;
389    }
390
391    /// Insert an out-of-order array into the sort buffer.
392    ///
393    /// Returns `false` if the buffer was full and the array was dropped
394    /// (C++ NDPluginDriver.cpp:307-316), `true` if buffered.
395    fn insert(&mut self, unique_id: i32, arrays: Vec<Arc<NDArray>>, sort_size: i32) -> bool {
396        if sort_size > 0 && self.entries.len() as i32 >= sort_size {
397            self.dropped_output_arrays += 1;
398            return false;
399        }
400        self.entries
401            .entry(unique_id)
402            .or_insert_with(|| SortEntry {
403                arrays: Vec::new(),
404                inserted: std::time::Instant::now(),
405            })
406            .arrays
407            .extend(arrays);
408        true
409    }
410
411    /// Drain the buffer head-first while either the next expected uniqueId is
412    /// contiguous OR the head element has aged past `sort_time` seconds.
413    /// Port of C++ `sortingTask` loop (NDPluginDriver.cpp:619-670).
414    fn drain_ready(&mut self, sort_time: f64) -> Vec<(i32, Vec<Arc<NDArray>>)> {
415        let now = std::time::Instant::now();
416        let mut out = Vec::new();
417        while let Some((&head_id, entry)) = self.entries.iter().next() {
418            let delta = now.duration_since(entry.inserted).as_secs_f64();
419            let order_ok = self.order_ok(head_id);
420            if (!self.first_output && order_ok) || delta > sort_time {
421                let entry = self.entries.remove(&head_id).unwrap();
422                self.note_emitted(head_id);
423                out.push((head_id, entry.arrays));
424            } else {
425                break;
426            }
427        }
428        out
429    }
430
431    /// Drain every buffered array in uniqueId order, regardless of contiguity
432    /// or age. Used when sort mode is turned off.
433    fn drain_all(&mut self) -> Vec<(i32, Vec<Arc<NDArray>>)> {
434        let entries = std::mem::take(&mut self.entries);
435        let mut out = Vec::with_capacity(entries.len());
436        for (id, entry) in entries {
437            self.note_emitted(id);
438            out.push((id, entry.arrays));
439        }
440        out
441    }
442
443    /// Number of uniqueId entries currently buffered.
444    fn len(&self) -> i32 {
445        self.entries.len() as i32
446    }
447}
448
449/// Shared processor state protected by a mutex, accessible from both
450/// the data thread (non-blocking mode) and the caller thread (blocking mode).
451struct SharedProcessorInner<P: NDPluginProcess> {
452    processor: P,
453    output: Arc<parking_lot::Mutex<NDArrayOutput>>,
454    pool: Arc<NDArrayPool>,
455    ndarray_params: NDArrayDriverParams,
456    plugin_params: PluginBaseParams,
457    port_handle: PortHandle,
458    /// ArrayCounter — owned in the param library (C++ `NDArrayCounter`), held
459    /// here only as a working copy that is kept in sync with the param so a
460    /// control-plane write of `ARRAY_COUNTER` resets it (B12).
461    array_counter: i32,
462    /// Param index for STD_ARRAY_DATA (if this is a StdArrays plugin).
463    std_array_data_param: Option<usize>,
464    /// NDArrayCallbacks (C++ `NDArrayCallbacks`): when `false`, the plugin
465    /// still processes and updates its metadata params but does NOT deliver the
466    /// output array downstream — `endProcessCallbacks` (NDPluginDriver.cpp:
467    /// 257-265) returns before the sort/throttle/`doCallbacksGenericPointer`
468    /// path. Distinct from `enabled` (`EnableCallbacks`), which gates whether
469    /// the plugin processes the input at all.
470    array_callbacks: bool,
471    /// MinCallbackTime throttling: minimum seconds between process calls.
472    min_callback_time: f64,
473    /// Last time process_and_publish was called (for throttling).
474    last_process_time: Option<std::time::Instant>,
475    /// Sort mode: 0 = disabled, 1 = sorted output.
476    sort_mode: i32,
477    /// Sort time: seconds — per-element staleness deadline for the sort buffer.
478    sort_time: f64,
479    /// Sort size: maximum number of uniqueId entries in the sort buffer.
480    sort_size: i32,
481    /// Sort buffer for reordering output arrays by uniqueId.
482    sort_buffer: SortBuffer,
483    /// Cumulative count of dropped *input* arrays (full queue / compression
484    /// gate / MinCallbackTime throttle). Shared with every upstream sender so
485    /// full-queue drops are visible here (G1, B1, B5).
486    dropped_arrays: Arc<std::sync::atomic::AtomicI32>,
487    /// Whether this plugin can process compressed (`codec != None`) arrays.
488    /// A non-compression-aware plugin drops compressed input (G3).
489    compression_aware: bool,
490    /// Output byte-rate limit (C++ `MaxByteRate`); 0 disables throttling.
491    max_byte_rate: f64,
492    /// Token-bucket throttler enforcing `max_byte_rate` on the output path (G7).
493    throttler: super::throttler::Throttler,
494    /// Last *input* array, cached for ProcessPlugin re-injection
495    /// (C++ `pPrevInputArray_`, G5). Released on `EnableCallbacks=0` (B6).
496    prev_input_array: Option<Arc<NDArray>>,
497    /// Previous array dimensions, for firing an NDDimensions int32-array
498    /// callback when dimensions change (C++ `dimsPrev_`, G8).
499    dims_prev: Vec<i32>,
500    /// Source address selected via the NDArrayAddr PV (C++ `NDArrayAddr`, G6).
501    nd_array_addr: i32,
502    /// MaxThreads — the clamp ceiling for NumThreads (C++ `MaxThreads`).
503    max_threads: i32,
504    /// NumThreads — validated/clamped to [1, MaxThreads] on write (G4).
505    num_threads: i32,
506}
507
508impl<P: NDPluginProcess> SharedProcessorInner<P> {
509    fn should_throttle(&self) -> bool {
510        if self.min_callback_time <= 0.0 {
511            return false;
512        }
513        if let Some(last) = self.last_process_time {
514            last.elapsed().as_secs_f64() < self.min_callback_time
515        } else {
516            false
517        }
518    }
519
520    /// Byte cost of an array for throttling (C++ `NDPluginDriver::throttled`):
521    /// compressed size when a codec is present, else total raw bytes.
522    fn array_byte_cost(array: &NDArray) -> f64 {
523        match &array.codec {
524            Some(c) => c.compressed_size as f64,
525            None => array.info().total_bytes as f64,
526        }
527    }
528
529    /// Apply the output throttle to one array. Returns `true` if the array
530    /// should be emitted, `false` if it was dropped (and counts the drop).
531    fn throttle_ok(&mut self, array: &NDArray) -> bool {
532        if self.max_byte_rate == 0.0 {
533            return true;
534        }
535        let cost = Self::array_byte_cost(array);
536        if self.throttler.try_take(cost) {
537            true
538        } else {
539            self.sort_buffer.dropped_output_arrays += 1;
540            false
541        }
542    }
543
544    /// Route output arrays through the throttle, the in-order fast path, and
545    /// the sort buffer. Returns arrays ready to emit *now*, in order.
546    ///
547    /// Port of C++ `endProcessCallbacks` (NDPluginDriver.cpp:295-328): an
548    /// array whose uniqueId is contiguous with `prevUniqueId_` is emitted
549    /// immediately (B2); only out-of-order arrays enter the sort buffer.
550    /// Disordered arrays are counted at emission time in both modes (B4).
551    fn route_output_arrays(&mut self, arrays: Vec<Arc<NDArray>>) -> Vec<Arc<NDArray>> {
552        let mut ready = Vec::new();
553        for arr in arrays {
554            if !self.throttle_ok(&arr) {
555                continue; // G7: dropped by MaxByteRate throttle
556            }
557            let uid = arr.unique_id;
558            if self.sort_mode != 0
559                && !self.sort_buffer.first_output
560                && !self.sort_buffer.order_ok(uid)
561            {
562                // Out of order with sort mode on: buffer it (B2/B3).
563                self.sort_buffer.insert(uid, vec![arr], self.sort_size);
564            } else {
565                // In order (or sort mode off): emit immediately, count disorder.
566                self.sort_buffer.note_emitted(uid);
567                ready.push(arr);
568            }
569        }
570        // After emitting in-order arrays, the sort buffer head may now be
571        // contiguous — release any newly-ready run (C++ sortingTask).
572        if self.sort_mode != 0 {
573            for (_id, mut bucket) in self.sort_buffer.drain_ready(self.sort_time) {
574                ready.append(&mut bucket);
575            }
576        }
577        ready
578    }
579
580    /// Process array and return a `ProcessOutput`. Does NOT send to actor.
581    /// Direct interrupts (std_array_data_param) happen here (sync).
582    /// The returned output must be published and flushed by the caller in async context.
583    fn process_and_publish(&mut self, array: &Arc<NDArray>) -> Option<ProcessOutput> {
584        // A MinCallbackTime-throttled array is silently skipped: C++
585        // driverCallback (NDPluginDriver.cpp:405-450) falls through the
586        // `deltaTime <= minCallbackTime` gate straight to callParamCallbacks()
587        // without touching any param — DroppedArrays is incremented ONLY on a
588        // compression-unaware array (:388) or a full message queue (:440), not
589        // on throttle. Post nothing and do not count the frame.
590        if self.should_throttle() {
591            return None;
592        }
593        // R2/G5: cache the input array for ProcessPlugin re-injection only
594        // for arrays that actually pass the MinCallbackTime gate and are
595        // processed. C++ sets pPrevInputArray_ in beginProcessCallbacks,
596        // which runs inside processCallbacks — never for throttled frames.
597        self.prev_input_array = Some(Arc::clone(array));
598        let t0 = std::time::Instant::now();
599        let result = self.processor.process_array(array, &self.pool);
600        let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
601        self.last_process_time = Some(t0);
602
603        // C++ endProcessCallbacks (NDPluginDriver.cpp:257-265): when
604        // NDArrayCallbacks==0 the method caches the array and returns BEFORE the
605        // throttle / sort-admission / `doCallbacksGenericPointer` path. So a
606        // non-delivering frame must not enter the MaxByteRate throttle or the
607        // sort buffer — only the metadata params (beginProcessCallbacks) are
608        // published. Route (throttle + sort) only when delivering.
609        //
610        // NDPluginStdArrays is the exception: it sets NDArrayCallbacks=0 yet
611        // still serves its typed-array waveforms (STD_ARRAY_DATA). Those are
612        // NOT the `doCallbacksGenericPointer` downstream path — they fire
613        // regardless of NDArrayCallbacks and ARE subject to the MaxByteRate
614        // throttle (NDPluginStdArrays.cpp:58 per-interface `throttled()`). So
615        // route whenever we deliver downstream OR serve the StdArray waveforms.
616        let produced = result.output_arrays.len();
617        let ready = if self.array_callbacks || self.std_array_data_param.is_some() {
618            self.route_output_arrays(result.output_arrays)
619        } else {
620            Vec::new()
621        };
622        // A StdArrays frame that produced a waveform which the MaxByteRate
623        // throttle then dropped (`produced > 0` but `ready` empty) must not
624        // advance ArrayCounter — C nets it back out
625        // (NDPluginStdArrays.cpp:202-211).
626        let count_frame =
627            !(self.std_array_data_param.is_some() && produced > 0 && ready.is_empty());
628        let mut output = self.build_publish_batch(
629            ready,
630            result.param_updates,
631            result.scatter,
632            Some(array.as_ref()),
633            elapsed_ms,
634            self.array_callbacks,
635            count_frame,
636        );
637        output.batch.merge(self.build_status_params_batch());
638        Some(output)
639    }
640
641    /// A param batch carrying only the current DroppedArrays / queue counters,
642    /// used when an array is dropped before processing (B5).
643    fn dropped_arrays_only_batch(&self) -> ProcessOutput {
644        ProcessOutput {
645            arrays: vec![],
646            scatter: false,
647            batch: self.build_status_params_batch(),
648        }
649    }
650
651    /// Re-inject the cached previous input array through the normal process
652    /// path (C++ ProcessPlugin, NDPluginDriver.cpp:739-746, G5).
653    fn process_plugin(&mut self) -> Option<ProcessOutput> {
654        let prev = self.prev_input_array.clone()?;
655        self.process_and_publish(&prev)
656    }
657
658    /// Flush the sort buffer head-first while contiguous or stale (C++
659    /// sortingTask periodic tick). Does NOT drain non-contiguous fresh arrays.
660    fn tick_sort_buffer(&mut self) -> ProcessOutput {
661        let entries = self.sort_buffer.drain_ready(self.sort_time);
662        self.emit_drained(entries)
663    }
664
665    /// Drain the entire sort buffer in uniqueId order (sort mode turned off).
666    fn flush_sort_buffer(&mut self) -> ProcessOutput {
667        let entries = self.sort_buffer.drain_all();
668        self.emit_drained(entries)
669    }
670
671    fn emit_drained(&mut self, entries: Vec<(i32, Vec<Arc<NDArray>>)>) -> ProcessOutput {
672        let mut all_arrays = Vec::new();
673        let mut combined = ParamBatch::empty();
674        for (_unique_id, arrays) in entries {
675            // Sort-buffer entries were admitted only while NDArrayCallbacks was
676            // on (route_output_arrays runs past the delivery gate); the C++
677            // sort thread delivers them regardless of the *current* flag, so
678            // they always deliver here.
679            let output = self.build_publish_batch(arrays, vec![], false, None, 0.0, true, true);
680            all_arrays.extend(output.arrays);
681            combined.merge(output.batch);
682        }
683        combined.merge(self.build_sort_params_batch());
684        ProcessOutput {
685            arrays: all_arrays,
686            scatter: false,
687            batch: combined,
688        }
689    }
690
691    fn build_sort_params_batch(&self) -> ParamBatch {
692        use asyn_rs::request::ParamSetValue;
693        let sort_free = self.sort_size - self.sort_buffer.len();
694        ParamBatch {
695            addr0: vec![
696                ParamSetValue::new(
697                    self.plugin_params.sort_free,
698                    0,
699                    ParamValue::Int32(sort_free),
700                ),
701                ParamSetValue::new(
702                    self.plugin_params.disordered_arrays,
703                    0,
704                    ParamValue::Int32(self.sort_buffer.disordered_arrays),
705                ),
706                ParamSetValue::new(
707                    self.plugin_params.dropped_output_arrays,
708                    0,
709                    ParamValue::Int32(self.sort_buffer.dropped_output_arrays),
710                ),
711            ],
712            extra: std::collections::HashMap::new(),
713        }
714    }
715
716    /// Build a param batch carrying the runtime status counters:
717    /// DroppedArrays (G1) plus the sort/disorder counters.
718    fn build_status_params_batch(&self) -> ParamBatch {
719        use asyn_rs::request::ParamSetValue;
720        let mut batch = self.build_sort_params_batch();
721        batch.addr0.push(ParamSetValue::new(
722            self.plugin_params.dropped_arrays,
723            0,
724            ParamValue::Int32(
725                self.dropped_arrays
726                    .load(std::sync::atomic::Ordering::Acquire),
727            ),
728        ));
729        batch
730    }
731
732    /// Build a ProcessOutput: fires direct interrupts (sync) and collects
733    /// param updates into a batch. Does NOT publish arrays — the caller
734    /// must publish them in async context.
735    ///
736    /// `deliver` is the NDArrayCallbacks gate (C++ `endProcessCallbacks`,
737    /// NDPluginDriver.cpp:257-265): when `false`, the downstream array
738    /// delivery — the STD_ARRAY_DATA generic-pointer interrupt and the returned
739    /// `ProcessOutput.arrays` — is suppressed, while the metadata params from
740    /// `beginProcessCallbacks` (counter, dims, datatype, …) are still set.
741    fn build_publish_batch(
742        &mut self,
743        output_arrays: Vec<Arc<NDArray>>,
744        param_updates: Vec<ParamUpdate>,
745        scatter: bool,
746        fallback_array: Option<&NDArray>,
747        elapsed_ms: f64,
748        deliver: bool,
749        count_frame: bool,
750    ) -> ProcessOutput {
751        use asyn_rs::request::ParamSetValue;
752
753        let mut addr0: Vec<ParamSetValue> = Vec::new();
754        let mut extra: std::collections::HashMap<i32, Vec<ParamSetValue>> =
755            std::collections::HashMap::new();
756
757        if let Some(report_arr) = output_arrays.first().map(|a| a.as_ref()).or(fallback_array) {
758            // A StdArrays frame whose waveform output the MaxByteRate throttle
759            // dropped (`count_frame == false`) must not bump ArrayCounter — C
760            // decrements it back so clients monitoring ArrayCounter see no new
761            // data (NDPluginStdArrays.cpp:202-211).
762            if count_frame {
763                self.array_counter += 1;
764            }
765
766            // Fire the StdArray waveform interrupt directly (C EPICS pattern).
767            // This is NDPluginStdArrays' typed-array callback
768            // (NDPluginStdArrays.cpp:71-73 `arrayInterruptCallback`), NOT the
769            // `doCallbacksGenericPointer` downstream path: C fires it whether or
770            // not NDArrayCallbacks is set (StdArrays defaults NDArrayCallbacks=0),
771            // so it must NOT be gated by `deliver`. It fires only with the
772            // routed/served output (`output_arrays.first()`), never the
773            // `fallback_array`: C skips the interface callback on throttle
774            // (NDPluginStdArrays.cpp:58), so a throttled frame leaves
775            // `output_arrays` empty and serves nothing.
776            if let (Some(param), Some(served)) = (
777                self.std_array_data_param,
778                output_arrays.first().map(|a| a.as_ref()),
779            ) {
780                use crate::ndarray::NDDataBuffer;
781                use asyn_rs::param::ParamValue;
782                let value = match &served.data {
783                    NDDataBuffer::I8(v) => {
784                        Some(ParamValue::Int8Array(std::sync::Arc::from(v.as_slice())))
785                    }
786                    NDDataBuffer::U8(v) => Some(ParamValue::Int8Array(std::sync::Arc::from(
787                        v.iter().map(|&x| x as i8).collect::<Vec<_>>().as_slice(),
788                    ))),
789                    NDDataBuffer::I16(v) => {
790                        Some(ParamValue::Int16Array(std::sync::Arc::from(v.as_slice())))
791                    }
792                    NDDataBuffer::U16(v) => Some(ParamValue::Int16Array(std::sync::Arc::from(
793                        v.iter().map(|&x| x as i16).collect::<Vec<_>>().as_slice(),
794                    ))),
795                    NDDataBuffer::I32(v) => {
796                        Some(ParamValue::Int32Array(std::sync::Arc::from(v.as_slice())))
797                    }
798                    NDDataBuffer::U32(v) => Some(ParamValue::Int32Array(std::sync::Arc::from(
799                        v.iter().map(|&x| x as i32).collect::<Vec<_>>().as_slice(),
800                    ))),
801                    NDDataBuffer::I64(v) => {
802                        Some(ParamValue::Int64Array(std::sync::Arc::from(v.as_slice())))
803                    }
804                    NDDataBuffer::U64(v) => Some(ParamValue::Int64Array(std::sync::Arc::from(
805                        v.iter().map(|&x| x as i64).collect::<Vec<_>>().as_slice(),
806                    ))),
807                    NDDataBuffer::F32(v) => {
808                        Some(ParamValue::Float32Array(std::sync::Arc::from(v.as_slice())))
809                    }
810                    NDDataBuffer::F64(v) => {
811                        Some(ParamValue::Float64Array(std::sync::Arc::from(v.as_slice())))
812                    }
813                };
814                if let Some(value) = value {
815                    let ts = served.timestamp.to_system_time();
816                    self.port_handle
817                        .interrupts()
818                        .notify(asyn_rs::interrupt::InterruptValue {
819                            reason: param,
820                            addr: 0,
821                            value,
822                            timestamp: ts,
823                            uint32_changed_mask: 0,
824                            ..Default::default()
825                        });
826                }
827            }
828
829            let info = report_arr.info();
830            // B11: read ColorMode / BayerPattern from the NDArray attributes
831            // (C++ beginProcessCallbacks). `info()` already resolves the
832            // ColorMode attribute when present; fall back to it for the param.
833            let color_mode = report_arr
834                .attributes
835                .get("ColorMode")
836                .and_then(|a| a.value.as_i64())
837                .map(|v| v as i32)
838                .unwrap_or(info.color_mode as i32);
839            let bayer_pattern = report_arr
840                .attributes
841                .get("BayerPattern")
842                .and_then(|a| a.value.as_i64())
843                .map(|v| v as i32)
844                .unwrap_or(0);
845
846            // G8: fire an int32-array callback on NDDimensions when the array
847            // dimensions change (C++ beginProcessCallbacks dimsPrev_). C++ keeps
848            // a fixed `dimsPrev_[ND_ARRAY_MAX_DIMS]` zero-filled beyond `ndims`,
849            // compares element-wise over all 10 slots, and posts the full
850            // 10-element array (NDPluginDriver.cpp:220-231) — so a caget reads
851            // NORD=10 with trailing zeros, not `ndims`.
852            let mut cur_dims = vec![0i32; crate::ndarray::ND_ARRAY_MAX_DIMS];
853            for (slot, d) in cur_dims.iter_mut().zip(
854                report_arr
855                    .dims
856                    .iter()
857                    .take(crate::ndarray::ND_ARRAY_MAX_DIMS),
858            ) {
859                *slot = d.size as i32;
860            }
861            if cur_dims != self.dims_prev {
862                self.dims_prev = cur_dims.clone();
863                self.port_handle
864                    .interrupts()
865                    .notify(asyn_rs::interrupt::InterruptValue {
866                        reason: self.ndarray_params.array_dimensions,
867                        addr: 0,
868                        value: asyn_rs::param::ParamValue::Int32Array(std::sync::Arc::from(
869                            cur_dims.as_slice(),
870                        )),
871                        timestamp: report_arr.timestamp.to_system_time(),
872                        uint32_changed_mask: 0,
873                        ..Default::default()
874                    });
875            }
876
877            addr0.extend([
878                ParamSetValue::new(
879                    self.ndarray_params.array_counter,
880                    0,
881                    ParamValue::Int32(self.array_counter),
882                ),
883                ParamSetValue::new(
884                    self.ndarray_params.unique_id,
885                    0,
886                    ParamValue::Int32(report_arr.unique_id),
887                ),
888                ParamSetValue::new(
889                    self.ndarray_params.n_dimensions,
890                    0,
891                    ParamValue::Int32(report_arr.dims.len() as i32),
892                ),
893                ParamSetValue::new(
894                    self.ndarray_params.array_size_x,
895                    0,
896                    ParamValue::Int32(info.x_size as i32),
897                ),
898                ParamSetValue::new(
899                    self.ndarray_params.array_size_y,
900                    0,
901                    ParamValue::Int32(info.y_size as i32),
902                ),
903                ParamSetValue::new(
904                    self.ndarray_params.array_size_z,
905                    0,
906                    ParamValue::Int32(info.color_size as i32),
907                ),
908                ParamSetValue::new(
909                    self.ndarray_params.array_size,
910                    0,
911                    ParamValue::Int32(info.total_bytes as i32),
912                ),
913                ParamSetValue::new(
914                    self.ndarray_params.data_type,
915                    0,
916                    ParamValue::Int32(report_arr.data.data_type() as i32),
917                ),
918                ParamSetValue::new(
919                    self.ndarray_params.color_mode,
920                    0,
921                    ParamValue::Int32(color_mode),
922                ),
923                ParamSetValue::new(
924                    self.ndarray_params.bayer_pattern,
925                    0,
926                    ParamValue::Int32(bayer_pattern),
927                ),
928                ParamSetValue::new(
929                    self.ndarray_params.timestamp_rbv,
930                    0,
931                    // C `setDoubleParam(NDTimeStamp, pArray->timeStamp)`
932                    // (NDPluginDriver.cpp:217) — the standalone double, which a
933                    // driver may set from its own clock; NDEpicsTSSec/nSec below
934                    // carry epicsTS (`:218-219`).
935                    ParamValue::Float64(report_arr.time_stamp),
936                ),
937                ParamSetValue::new(
938                    self.ndarray_params.epics_ts_sec,
939                    0,
940                    ParamValue::Int32(report_arr.timestamp.sec as i32),
941                ),
942                ParamSetValue::new(
943                    self.ndarray_params.epics_ts_nsec,
944                    0,
945                    ParamValue::Int32(report_arr.timestamp.nsec as i32),
946                ),
947            ]);
948
949            // NDCodec / NDCompressedSize — C++ beginProcessCallbacks
950            // (NDPluginDriver.cpp:213-214) sets these on every array. An
951            // uncompressed array carries an empty codec name and a
952            // compressedSize equal to the raw byte count (matching the
953            // driver-base path in ndarray_driver::prepare_array).
954            match &report_arr.codec {
955                Some(codec) => {
956                    addr0.push(ParamSetValue::new(
957                        self.ndarray_params.codec,
958                        0,
959                        ParamValue::Octet(codec.name.as_str().to_string()),
960                    ));
961                    addr0.push(ParamSetValue::new(
962                        self.ndarray_params.compressed_size,
963                        0,
964                        ParamValue::Int32(codec.compressed_size as i32),
965                    ));
966                }
967                None => {
968                    addr0.push(ParamSetValue::new(
969                        self.ndarray_params.codec,
970                        0,
971                        ParamValue::Octet(String::new()),
972                    ));
973                    addr0.push(ParamSetValue::new(
974                        self.ndarray_params.compressed_size,
975                        0,
976                        ParamValue::Int32(info.total_bytes as i32),
977                    ));
978                }
979            }
980        }
981
982        addr0.push(ParamSetValue::new(
983            self.plugin_params.execution_time,
984            0,
985            ParamValue::Float64(elapsed_ms),
986        ));
987
988        // ArrayRate_RBV is computed by a calc record in the DB template
989        // (SCAN "1 second", reading ArrayCounter_RBV delta), not in Rust.
990
991        // Plugin-specific param updates.
992        for update in &param_updates {
993            match update {
994                ParamUpdate::Int32 {
995                    reason,
996                    addr,
997                    value,
998                } => {
999                    let pv = ParamSetValue::new(*reason, *addr, ParamValue::Int32(*value));
1000                    if *addr == 0 {
1001                        addr0.push(pv);
1002                    } else {
1003                        extra.entry(*addr).or_default().push(pv);
1004                    }
1005                }
1006                ParamUpdate::Float64 {
1007                    reason,
1008                    addr,
1009                    value,
1010                } => {
1011                    let pv = ParamSetValue::new(*reason, *addr, ParamValue::Float64(*value));
1012                    if *addr == 0 {
1013                        addr0.push(pv);
1014                    } else {
1015                        extra.entry(*addr).or_default().push(pv);
1016                    }
1017                }
1018                ParamUpdate::Octet {
1019                    reason,
1020                    addr,
1021                    value,
1022                } => {
1023                    let pv = ParamSetValue::new(*reason, *addr, ParamValue::Octet(value.clone()));
1024                    if *addr == 0 {
1025                        addr0.push(pv);
1026                    } else {
1027                        extra.entry(*addr).or_default().push(pv);
1028                    }
1029                }
1030                ParamUpdate::Float64Array {
1031                    reason,
1032                    addr,
1033                    value,
1034                } => {
1035                    let pv = ParamSetValue::new(
1036                        *reason,
1037                        *addr,
1038                        ParamValue::Float64Array(value.clone().into()),
1039                    );
1040                    if *addr == 0 {
1041                        addr0.push(pv);
1042                    } else {
1043                        extra.entry(*addr).or_default().push(pv);
1044                    }
1045                }
1046            }
1047        }
1048
1049        ProcessOutput {
1050            // NDArrayCallbacks==0 suppresses downstream delivery (C++
1051            // endProcessCallbacks early-return); the metadata params above are
1052            // still published.
1053            arrays: if deliver { output_arrays } else { Vec::new() },
1054            scatter,
1055            batch: ParamBatch { addr0, extra },
1056        }
1057    }
1058}
1059
1060/// Output from processing: arrays to publish + param batch to flush.
1061struct ProcessOutput {
1062    arrays: Vec<Arc<NDArray>>,
1063    scatter: bool,
1064    batch: ParamBatch,
1065}
1066
1067impl ProcessOutput {
1068    /// Publish arrays to downstream senders (async, concurrent fan-out).
1069    ///
1070    /// Broadcast frames are published to every sender concurrently (independent
1071    /// backpressure per sender). Scatter frames are routed to a single consumer
1072    /// via `scatter_publish`, which advances the persistent `scatter_cursor`.
1073    /// Arrays are published in order — the next array's fan-out starts only
1074    /// after the previous one completes.
1075    async fn publish_arrays(&self, senders: &[NDArraySender], scatter_cursor: &mut usize) {
1076        for arr in &self.arrays {
1077            if self.scatter {
1078                Self::scatter_publish(arr, senders, scatter_cursor).await;
1079            } else {
1080                let futs = senders.iter().map(|s| s.publish(arr.clone()));
1081                futures_util::future::join_all(futs).await;
1082            }
1083        }
1084    }
1085
1086    /// Deliver one array to the next downstream consumer in round-robin order,
1087    /// rerouting past full queues — a port of C++
1088    /// `NDPluginScatter::doNDArrayCallbacks` (NDPluginScatter.cpp:59-90).
1089    ///
1090    /// `cursor` is the persistent `nextClient_`: it advances by one per
1091    /// *attempt*, so a frame that reroutes past a full consumer leaves the
1092    /// cursor pointing just past the consumer it actually delivered to (not
1093    /// merely one past the starting point). Walking begins at `cursor % n` and
1094    /// makes at most `n` attempts. A full (or disabled/closed) consumer is
1095    /// rerouted past unless this is the last attempt; only the last node is
1096    /// allowed to drop the array (C++ sets `auxStatus=asynSuccess` for the last
1097    /// node so its full queue drops rather than reroutes). Earlier full
1098    /// consumers are passed `is_last=false` so the rerouted-away drop is *not*
1099    /// counted (C++ `ignoreQueueFull`, NDPluginDriver.cpp:406,433-442).
1100    ///
1101    /// Routing is over the *enabled* senders only: a downstream with callbacks
1102    /// disabled is unregistered from the interrupt list in C
1103    /// (`setArrayInterrupt(0)`) and is therefore not a scatter target, so it
1104    /// must not consume a round-robin slot.
1105    async fn scatter_publish(arr: &Arc<NDArray>, senders: &[NDArraySender], cursor: &mut usize) {
1106        let active: Vec<&NDArraySender> = senders.iter().filter(|s| s.is_enabled()).collect();
1107        let n = active.len();
1108        if n == 0 {
1109            return;
1110        }
1111        for attempt in 0..n {
1112            let target = *cursor % n;
1113            *cursor = cursor.wrapping_add(1);
1114            let is_last = attempt == n - 1;
1115            match active[target].publish_scatter(arr.clone(), is_last).await {
1116                // Delivered: done. (In blocking mode publish always delivers,
1117                // so the loop breaks on the first attempt — matching C++ where
1118                // a blocking scatter calls processCallbacks inline and breaks.)
1119                PublishOutcome::Delivered => break,
1120                // Full / disabled / closed: reroute to the next consumer unless
1121                // this was the last attempt (then the array is dropped — already
1122                // counted by publish_scatter when is_last).
1123                PublishOutcome::DroppedQueueFull
1124                | PublishOutcome::Disabled
1125                | PublishOutcome::ChannelClosed => {
1126                    if is_last {
1127                        break;
1128                    }
1129                }
1130            }
1131        }
1132    }
1133}
1134
1135/// Collected param updates ready to be flushed to the actor.
1136/// Produced by `build_publish_batch()`, consumed by async `flush()`.
1137struct ParamBatch {
1138    addr0: Vec<asyn_rs::request::ParamSetValue>,
1139    extra: std::collections::HashMap<i32, Vec<asyn_rs::request::ParamSetValue>>,
1140}
1141
1142impl ParamBatch {
1143    fn empty() -> Self {
1144        Self {
1145            addr0: Vec::new(),
1146            extra: std::collections::HashMap::new(),
1147        }
1148    }
1149
1150    fn merge(&mut self, other: ParamBatch) {
1151        self.addr0.extend(other.addr0);
1152        for (addr, updates) in other.extra {
1153            self.extra.entry(addr).or_default().extend(updates);
1154        }
1155    }
1156
1157    /// Flush via reliable async enqueue. Call from async context.
1158    async fn flush(self, port: &asyn_rs::port_handle::PortHandle) {
1159        if !self.addr0.is_empty() {
1160            if let Err(e) = port.set_params_and_notify(0, self.addr0).await {
1161                eprintln!("plugin param flush error (addr 0): {e}");
1162            }
1163        }
1164        for (addr, updates) in self.extra {
1165            if let Err(e) = port.set_params_and_notify(addr, updates).await {
1166                eprintln!("plugin param flush error (addr {addr}): {e}");
1167            }
1168        }
1169    }
1170}
1171
1172/// The `NDArrayAddr`, `maxThreads` and initial `numThreads` that C takes as
1173/// `NDPluginDriver` constructor arguments (NDPluginDriver.cpp:153, :158, :159).
1174///
1175/// The port's `*Configure` entry points take none of them: a plugin runtime
1176/// spawns exactly one `plugin-data-<port>` thread and its `WiringRegistry` is
1177/// keyed by port name, address 0. They are named rather than inlined so the
1178/// constructor's param block and `SharedProcessorInner`'s initial state cannot
1179/// drift — publishing one value to the RBV and running on another is what a
1180/// reader of MaxThreads_RBV would have no way to detect.
1181const PLUGIN_NDARRAY_ADDR: i32 = 0;
1182const PLUGIN_MAX_THREADS: i32 = 1;
1183const PLUGIN_NUM_THREADS: i32 = 1;
1184
1185/// PortDriver implementation for a plugin's control plane.
1186#[allow(dead_code)]
1187pub struct PluginPortDriver {
1188    base: PortDriverBase,
1189    ndarray_params: NDArrayDriverParams,
1190    plugin_params: PluginBaseParams,
1191    param_change_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1192    /// Optional handle to the latest NDArray for array read methods (used by StdArrays).
1193    array_data: Option<Arc<parking_lot::Mutex<Option<Arc<NDArray>>>>>,
1194    /// Param index for STD_ARRAY_DATA (triggers I/O Intr on ArrayData waveform).
1195    std_array_data_param: Option<usize>,
1196}
1197
1198impl PluginPortDriver {
1199    fn new<P: NDPluginProcess>(
1200        port_name: &str,
1201        plugin_type_name: &str,
1202        queue_size: usize,
1203        ndarray_port: &str,
1204        max_addr: usize,
1205        param_change_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1206        processor: &mut P,
1207        array_data: Option<Arc<parking_lot::Mutex<Option<Arc<NDArray>>>>>,
1208        pool: &NDArrayPool,
1209    ) -> AsynResult<Self> {
1210        let mut base = PortDriverBase::new(
1211            port_name,
1212            max_addr,
1213            PortFlags {
1214                can_block: true,
1215                ..Default::default()
1216            },
1217        );
1218
1219        let ndarray_params = NDArrayDriverParams::create(&mut base)?;
1220        let plugin_params = PluginBaseParams::create(&mut base)?;
1221
1222        // C++ `NDPluginDriver::NDPluginDriver` (NDPluginDriver.cpp:152-160)
1223        // initialises exactly these nine read-only / read-back params here,
1224        // under a comment that states the mechanism: "If a value is not set
1225        // here then the read request will return an error (uninitialized)".
1226        // `PluginPortDriver` does not override `read_int32`, so an unset one
1227        // reaches `get_int32_strict` (asyn-rs/src/port.rs:1663-1665) and the
1228        // record it feeds sits UDF/INVALID until something else happens to
1229        // write it — which for MaxThreads_RBV (SCAN "I/O Intr", no PINI, no
1230        // output partner) is never.
1231        base.set_string_param(plugin_params.nd_array_port, 0, ndarray_port.into())?;
1232        base.set_int32_param(plugin_params.nd_array_addr, 0, PLUGIN_NDARRAY_ADDR)?;
1233        base.set_int32_param(plugin_params.dropped_arrays, 0, 0)?;
1234        base.set_int32_param(plugin_params.dropped_output_arrays, 0, 0)?;
1235        base.set_int32_param(plugin_params.queue_size, 0, queue_size as i32)?;
1236        // C `:157` — an empty queue has every slot free. The param is spelled
1237        // QUEUE_FREE (params.rs:40) whatever the struct field is called, so 0
1238        // here published a permanently full queue on an idle plugin.
1239        base.set_int32_param(plugin_params.queue_use, 0, queue_size as i32)?;
1240        base.set_int32_param(plugin_params.max_threads, 0, PLUGIN_MAX_THREADS)?;
1241        base.set_int32_param(plugin_params.num_threads, 0, PLUGIN_NUM_THREADS)?;
1242        // C `:160` passes its `blockingCallbacks` constructor argument; the
1243        // port has no such argument and starts `blocking_mode` false.
1244        base.set_int32_param(plugin_params.blocking_callbacks, 0, 0)?;
1245
1246        // EnableCallbacks=0 (Disable) by default, matching EPICS ADCore.
1247        base.set_int32_param(plugin_params.enable_callbacks, 0, 0)?;
1248        base.set_string_param(plugin_params.plugin_type, 0, plugin_type_name.into())?;
1249
1250        // C++ `NDPluginDriver` derives from `asynNDArrayDriver`, so the base
1251        // constructor's read-only block (asynNDArrayDriver.cpp:954-1005) runs
1252        // for every plugin as well.
1253        crate::driver::ndarray_driver::init_read_only_params(
1254            &mut base,
1255            &ndarray_params,
1256            port_name,
1257        )?;
1258        crate::driver::ndarray_driver::refresh_pool_stats(&mut base, &ndarray_params, pool)?;
1259        // Not in C's block: NDArrayCallbacks is a database parameter there,
1260        // but a plugin that never emits arrays must publish 0 rather than let
1261        // the DB turn its output on.
1262        base.set_int32_param(
1263            ndarray_params.array_callbacks,
1264            0,
1265            processor.does_array_callbacks() as i32,
1266        )?;
1267        // Not in C's block either — `NDFullFileName_RBV` is only written by a
1268        // file plugin after a successful write.
1269        base.set_string_param(ndarray_params.full_file_name, 0, "".into())?;
1270        // Create STD_ARRAY_DATA param for StdArrays plugins (triggers I/O Intr on ArrayData waveform)
1271        let std_array_data_param = if array_data.is_some() {
1272            Some(base.create_param("STD_ARRAY_DATA", asyn_rs::param::ParamType::GenericPointer)?)
1273        } else {
1274            None
1275        };
1276
1277        // Let the processor register its plugin-specific params
1278        processor.register_params(&mut base)?;
1279
1280        Ok(Self {
1281            base,
1282            ndarray_params,
1283            plugin_params,
1284            param_change_tx,
1285            array_data,
1286            std_array_data_param,
1287        })
1288    }
1289}
1290
1291/// Copy source slice directly into destination buffer, returning elements copied.
1292fn copy_direct<T: Copy>(src: &[T], dst: &mut [T]) -> usize {
1293    let n = src.len().min(dst.len());
1294    dst[..n].copy_from_slice(&src[..n]);
1295    n
1296}
1297
1298/// Convert and copy source slice into destination buffer element-by-element.
1299fn copy_convert<S, D>(src: &[S], dst: &mut [D]) -> usize
1300where
1301    S: CastToF64 + Copy,
1302    D: CastFromF64 + Copy,
1303{
1304    let n = src.len().min(dst.len());
1305    for i in 0..n {
1306        dst[i] = D::cast_from_f64(src[i].cast_to_f64());
1307    }
1308    n
1309}
1310
1311/// Cast an integer source element to an integer destination element with C
1312/// cast semantics. C++ `NDArrayPool::convert` (`NDArrayPool.cpp:388`,
1313/// `convertType`: `*pDataOut++ = (dataTypeOut)(*pDataIn++)`; and `:466`,
1314/// `convertDim`) performs a plain C cast between integer types. A C cast:
1315///   - same-width sign change is a bitwise reinterpret
1316///     (`(epicsInt8)(epicsUInt8)255 == -1`);
1317///   - narrowing truncates to the low bits, wrapping
1318///     (`(epicsInt8)(epicsUInt16)300 == 44`);
1319///   - widening sign/zero-extends exactly.
1320///
1321/// Rust's `as` between integer types implements exactly these semantics. The
1322/// f64 round-trip in [`copy_convert`] does NOT: it saturates on narrowing
1323/// (`300.0 as i8 == 127`), diverging from C++. So every integer-source ->
1324/// integer-target NDArray array read must go through this C-cast path, not
1325/// `copy_convert`.
1326trait CCastTo<D> {
1327    fn ccast(self) -> D;
1328}
1329macro_rules! impl_ccast {
1330    ( $src:ty => $( $dst:ty ),+ ) => {
1331        $(
1332            impl CCastTo<$dst> for $src {
1333                #[inline]
1334                fn ccast(self) -> $dst {
1335                    self as $dst
1336                }
1337            }
1338        )+
1339    };
1340}
1341impl_ccast!(i8 => i16, i32, i64);
1342impl_ccast!(u8 => i8, i16, i32, i64);
1343impl_ccast!(i16 => i8, i32, i64);
1344impl_ccast!(u16 => i8, i16, i32, i64);
1345impl_ccast!(i32 => i8, i16, i64);
1346impl_ccast!(u32 => i8, i16, i32, i64);
1347impl_ccast!(i64 => i8, i16, i32);
1348impl_ccast!(u64 => i8, i16, i32, i64);
1349
1350/// Copy an integer source slice into an integer destination buffer using C
1351/// cast semantics (see [`CCastTo`]) — truncating on narrowing, never
1352/// saturating.
1353fn copy_ccast<S, D>(src: &[S], dst: &mut [D]) -> usize
1354where
1355    S: CCastTo<D> + Copy,
1356    D: Copy,
1357{
1358    let n = src.len().min(dst.len());
1359    for i in 0..n {
1360        dst[i] = src[i].ccast();
1361    }
1362    n
1363}
1364
1365/// Helper trait for `as f64` casts (handles lossy conversions like i64/u64).
1366trait CastToF64 {
1367    fn cast_to_f64(self) -> f64;
1368}
1369
1370impl CastToF64 for i8 {
1371    fn cast_to_f64(self) -> f64 {
1372        self as f64
1373    }
1374}
1375impl CastToF64 for u8 {
1376    fn cast_to_f64(self) -> f64 {
1377        self as f64
1378    }
1379}
1380impl CastToF64 for i16 {
1381    fn cast_to_f64(self) -> f64 {
1382        self as f64
1383    }
1384}
1385impl CastToF64 for u16 {
1386    fn cast_to_f64(self) -> f64 {
1387        self as f64
1388    }
1389}
1390impl CastToF64 for i32 {
1391    fn cast_to_f64(self) -> f64 {
1392        self as f64
1393    }
1394}
1395impl CastToF64 for u32 {
1396    fn cast_to_f64(self) -> f64 {
1397        self as f64
1398    }
1399}
1400impl CastToF64 for i64 {
1401    fn cast_to_f64(self) -> f64 {
1402        self as f64
1403    }
1404}
1405impl CastToF64 for u64 {
1406    fn cast_to_f64(self) -> f64 {
1407        self as f64
1408    }
1409}
1410impl CastToF64 for f32 {
1411    fn cast_to_f64(self) -> f64 {
1412        self as f64
1413    }
1414}
1415impl CastToF64 for f64 {
1416    fn cast_to_f64(self) -> f64 {
1417        self
1418    }
1419}
1420
1421/// Helper trait for `as` casts from f64.
1422trait CastFromF64 {
1423    fn cast_from_f64(v: f64) -> Self;
1424}
1425
1426impl CastFromF64 for i8 {
1427    fn cast_from_f64(v: f64) -> Self {
1428        v as i8
1429    }
1430}
1431impl CastFromF64 for i16 {
1432    fn cast_from_f64(v: f64) -> Self {
1433        v as i16
1434    }
1435}
1436impl CastFromF64 for i32 {
1437    fn cast_from_f64(v: f64) -> Self {
1438        v as i32
1439    }
1440}
1441impl CastFromF64 for i64 {
1442    fn cast_from_f64(v: f64) -> Self {
1443        v as i64
1444    }
1445}
1446impl CastFromF64 for f32 {
1447    fn cast_from_f64(v: f64) -> Self {
1448        v as f32
1449    }
1450}
1451impl CastFromF64 for f64 {
1452    fn cast_from_f64(v: f64) -> Self {
1453        v
1454    }
1455}
1456
1457/// Copy NDArray data into the output buffer with type conversion.
1458/// Returns the number of elements copied, or 0 if no data is available.
1459macro_rules! impl_read_array {
1460    (
1461        $self:expr, $buf:expr, $direct_variant:ident,
1462        ccast: [ $( $ccast_variant:ident ),* ],
1463        convert: [ $( $variant:ident ),* ]
1464    ) => {{
1465        use crate::ndarray::NDDataBuffer;
1466        let handle = match &$self.array_data {
1467            Some(h) => h,
1468            None => return Ok(0),
1469        };
1470        let guard = handle.lock();
1471        let array = match &*guard {
1472            Some(a) => a,
1473            None => return Ok(0),
1474        };
1475        let n = match &array.data {
1476            NDDataBuffer::$direct_variant(v) => copy_direct(v, $buf),
1477            $( NDDataBuffer::$ccast_variant(v) => copy_ccast(v, $buf), )*
1478            $( NDDataBuffer::$variant(v) => copy_convert(v, $buf), )*
1479        };
1480        Ok(n)
1481    }};
1482}
1483
1484impl PortDriver for PluginPortDriver {
1485    fn base(&self) -> &PortDriverBase {
1486        &self.base
1487    }
1488
1489    fn base_mut(&mut self) -> &mut PortDriverBase {
1490        &mut self.base
1491    }
1492
1493    fn io_write_int32(&mut self, user: &mut AsynUser, value: i32) -> AsynResult<()> {
1494        let reason = user.reason;
1495        let addr = user.addr;
1496        self.base.set_int32_param(reason, addr, value)?;
1497        self.base.call_param_callbacks(addr)?;
1498        // B14: reliable send on an unbounded channel — never drop param changes.
1499        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1500            reason,
1501            addr,
1502            ParamChangeValue::Int32(value),
1503        ));
1504        Ok(())
1505    }
1506
1507    fn io_write_float64(&mut self, user: &mut AsynUser, value: f64) -> AsynResult<()> {
1508        let reason = user.reason;
1509        let addr = user.addr;
1510        self.base.set_float64_param(reason, addr, value)?;
1511        self.base.call_param_callbacks(addr)?;
1512        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1513            reason,
1514            addr,
1515            ParamChangeValue::Float64(value),
1516        ));
1517        Ok(())
1518    }
1519
1520    fn io_write_octet(&mut self, user: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
1521        let reason = user.reason;
1522        let addr = user.addr;
1523        let s = String::from_utf8_lossy(data).into_owned();
1524        self.base.set_string_param(reason, addr, s.clone())?;
1525        self.base.call_param_callbacks(addr)?;
1526        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1527            reason,
1528            addr,
1529            ParamChangeValue::Octet(s),
1530        ));
1531        Ok(data.len())
1532    }
1533
1534    fn read_int8_array(&mut self, _user: &AsynUser, buf: &mut [i8]) -> AsynResult<usize> {
1535        // Every integer source -> i8 is a C cast (truncating, per C++
1536        // NDArrayPool.cpp:388); float sources keep the numeric f64 conversion.
1537        impl_read_array!(
1538            self, buf, I8,
1539            ccast: [U8, I16, U16, I32, U32, I64, U64],
1540            convert: [F32, F64]
1541        )
1542    }
1543
1544    fn read_int16_array(&mut self, _user: &AsynUser, buf: &mut [i16]) -> AsynResult<usize> {
1545        impl_read_array!(
1546            self, buf, I16,
1547            ccast: [I8, U8, U16, I32, U32, I64, U64],
1548            convert: [F32, F64]
1549        )
1550    }
1551
1552    fn read_int32_array(&mut self, _user: &AsynUser, buf: &mut [i32]) -> AsynResult<usize> {
1553        impl_read_array!(
1554            self, buf, I32,
1555            ccast: [I8, U8, I16, U16, U32, I64, U64],
1556            convert: [F32, F64]
1557        )
1558    }
1559
1560    fn read_int64_array(&mut self, _user: &AsynUser, buf: &mut [i64]) -> AsynResult<usize> {
1561        impl_read_array!(
1562            self, buf, I64,
1563            ccast: [I8, U8, I16, U16, I32, U32, U64],
1564            convert: [F32, F64]
1565        )
1566    }
1567
1568    fn read_float32_array(&mut self, _user: &AsynUser, buf: &mut [f32]) -> AsynResult<usize> {
1569        impl_read_array!(
1570            self, buf, F32,
1571            ccast: [],
1572            convert: [I8, U8, I16, U16, I32, U32, I64, U64, F64]
1573        )
1574    }
1575
1576    fn read_float64_array(&mut self, _user: &AsynUser, buf: &mut [f64]) -> AsynResult<usize> {
1577        impl_read_array!(
1578            self, buf, F64,
1579            ccast: [],
1580            convert: [I8, U8, I16, U16, I32, U32, I64, U64, F32]
1581        )
1582    }
1583}
1584
1585/// Handle to a running plugin runtime. Provides access to sender and port handle.
1586#[derive(Clone)]
1587pub struct PluginRuntimeHandle {
1588    port_runtime: PortRuntimeHandle,
1589    array_sender: NDArraySender,
1590    array_output: Arc<parking_lot::Mutex<NDArrayOutput>>,
1591    port_name: String,
1592    param_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1593    pub ndarray_params: NDArrayDriverParams,
1594    pub plugin_params: PluginBaseParams,
1595}
1596
1597impl PluginRuntimeHandle {
1598    pub fn port_runtime(&self) -> &PortRuntimeHandle {
1599        &self.port_runtime
1600    }
1601
1602    pub fn array_sender(&self) -> &NDArraySender {
1603        &self.array_sender
1604    }
1605
1606    pub fn array_output(&self) -> &Arc<parking_lot::Mutex<NDArrayOutput>> {
1607        &self.array_output
1608    }
1609
1610    /// Block until the plugin's data thread has applied every control-plane
1611    /// param change submitted before this call.
1612    ///
1613    /// `write_*_blocking` on the port handle returns once the port actor has
1614    /// recorded the write and queued it for the data plane; the data thread
1615    /// applies it (the EnableCallbacks flip, NDArrayPort/NDArrayAddr rewiring,
1616    /// processor param updates) asynchronously. This is the fence between the
1617    /// two planes: it enqueues a barrier behind every already-queued change
1618    /// and waits for the data thread to acknowledge it — the param channel is
1619    /// FIFO, so the ack implies every earlier change is fully applied.
1620    ///
1621    /// The ack additionally waits for the array queue to drain, so it also
1622    /// implies every array published before this call has been fully handled
1623    /// (processed or throttled). Under continuous array traffic the ack is
1624    /// therefore delayed until the queue momentarily empties.
1625    ///
1626    /// Returns `false` if the data thread has exited or `timeout` elapsed.
1627    pub fn wait_params_applied(&self, timeout: std::time::Duration) -> bool {
1628        let (ack_tx, ack_rx) = std::sync::mpsc::sync_channel(1);
1629        if self.param_tx.send(PluginParamMsg::Barrier(ack_tx)).is_err() {
1630            return false;
1631        }
1632        ack_rx.recv_timeout(timeout).is_ok()
1633    }
1634
1635    pub fn port_name(&self) -> &str {
1636        &self.port_name
1637    }
1638}
1639
1640/// Create a plugin runtime with control plane (PortActor) and data plane (processing thread).
1641///
1642/// Returns:
1643/// - `PluginRuntimeHandle` for wiring and control
1644/// - `PortRuntimeHandle` for param I/O
1645/// - `JoinHandle` for the data processing thread
1646pub fn create_plugin_runtime<P: NDPluginProcess>(
1647    port_name: &str,
1648    processor: P,
1649    pool: Arc<NDArrayPool>,
1650    queue_size: usize,
1651    ndarray_port: &str,
1652    wiring: Arc<WiringRegistry>,
1653) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
1654    create_plugin_runtime_multi_addr(
1655        port_name,
1656        processor,
1657        pool,
1658        queue_size,
1659        ndarray_port,
1660        wiring,
1661        1,
1662    )
1663}
1664
1665/// Create a plugin runtime with multi-addr support.
1666///
1667/// `max_addr` specifies the number of addresses (sub-devices) the port supports.
1668pub fn create_plugin_runtime_multi_addr<P: NDPluginProcess>(
1669    port_name: &str,
1670    mut processor: P,
1671    pool: Arc<NDArrayPool>,
1672    queue_size: usize,
1673    ndarray_port: &str,
1674    wiring: Arc<WiringRegistry>,
1675    max_addr: usize,
1676) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
1677    // Param change channel (control plane -> data plane)
1678    // B14: unbounded so control-plane param changes (e.g. autosave restoring
1679    // hundreds of PVs at IOC init) are never silently dropped before the
1680    // data plane sees them.
1681    let (param_tx, param_rx) = tokio::sync::mpsc::unbounded_channel::<PluginParamMsg>();
1682    let handle_param_tx = param_tx.clone();
1683
1684    // Capture plugin type and array data handle before mutable borrow
1685    let plugin_type_name = processor.plugin_type().to_string();
1686    let compression_aware = processor.compression_aware();
1687    let does_array_callbacks = processor.does_array_callbacks();
1688    let array_data = processor.array_data_handle();
1689
1690    // Create the port driver for control plane
1691    let driver = PluginPortDriver::new(
1692        port_name,
1693        &plugin_type_name,
1694        queue_size,
1695        ndarray_port,
1696        max_addr,
1697        param_tx,
1698        &mut processor,
1699        array_data,
1700        &pool,
1701    )
1702    .expect("failed to create plugin port driver");
1703
1704    let ndarray_params = driver.ndarray_params;
1705    let plugin_params = driver.plugin_params;
1706    let std_array_data_param = driver.std_array_data_param;
1707
1708    // Create port runtime (actor thread for param I/O).
1709    //
1710    // Constructor-shaped, so a failure here is fatal and cannot be anything
1711    // else: this function hands back the built plugin and has no error channel
1712    // to its `*Configure` caller. C's equivalent — `asynPortDriver`'s
1713    // constructor printing and `throw`ing on a failed `registerPort`
1714    // (asynPortDriver.cpp:4036-4040) — is caught by iocsh
1715    // (iocsh.cpp:1274-1284) and the script continues by default
1716    // (iocsh.cpp:1001, :1129), leaving the C IOC serving without the port. We
1717    // deviate on purpose: see `port_runtime_unavailable`.
1718    let (port_runtime, _actor_jh) = create_port_runtime(driver, RuntimeConfig::default())
1719        .unwrap_or_else(|e| port_runtime_unavailable(port_name, &e));
1720
1721    // Clone port handle for the data thread to write params back
1722    let port_handle = port_runtime.port_handle().clone();
1723
1724    // Array channel (data plane)
1725    let (array_sender, array_rx) = ndarray_channel(port_name, queue_size);
1726
1727    // Shared mode flags
1728    let enabled = Arc::new(AtomicBool::new(false));
1729    let blocking_mode = Arc::new(AtomicBool::new(false));
1730
1731    // Shared processor (accessible from data thread)
1732    let array_output = Arc::new(parking_lot::Mutex::new(NDArrayOutput::new()));
1733    let array_output_for_handle = array_output.clone();
1734    // B13/G6: register this plugin's output so the WiringRegistry is the
1735    // single source of truth for runtime rewiring (PluginManager::add_plugin
1736    // would also register it, but direct callers must not bypass the
1737    // registry). Registered under every address in 0..max_addr so a
1738    // downstream plugin can select a non-zero NDArrayAddr.
1739    wiring.register_output_addrs(port_name, max_addr, array_output.clone());
1740    // G1/B1: the DroppedArrays counter is owned by this plugin and shared with
1741    // every upstream sender so full-queue drops on our input queue are counted.
1742    let dropped_arrays_counter = array_sender.dropped_arrays_counter().clone();
1743    let shared = Arc::new(parking_lot::Mutex::new(SharedProcessorInner {
1744        processor,
1745        output: array_output,
1746        pool,
1747        ndarray_params,
1748        plugin_params,
1749        port_handle,
1750        array_counter: 0,
1751        std_array_data_param,
1752        // C++ default NDArrayCallbacks = 1 (deliver downstream); terminal
1753        // plugins (StdArrays/Attribute/File) override `does_array_callbacks` to 0.
1754        array_callbacks: does_array_callbacks,
1755        min_callback_time: 0.0,
1756        last_process_time: None,
1757        sort_mode: 0,
1758        sort_time: 0.0,
1759        sort_size: 10,
1760        sort_buffer: SortBuffer::new(),
1761        dropped_arrays: dropped_arrays_counter,
1762        compression_aware,
1763        max_byte_rate: 0.0,
1764        throttler: super::throttler::Throttler::new(0.0),
1765        prev_input_array: None,
1766        dims_prev: vec![0i32; crate::ndarray::ND_ARRAY_MAX_DIMS],
1767        nd_array_addr: PLUGIN_NDARRAY_ADDR,
1768        max_threads: PLUGIN_MAX_THREADS,
1769        num_threads: PLUGIN_NUM_THREADS,
1770    }));
1771
1772    let data_enabled = enabled.clone();
1773    let data_blocking = blocking_mode.clone();
1774
1775    let mut array_sender = array_sender;
1776    array_sender.set_mode_flags(enabled, blocking_mode);
1777
1778    // Capture wiring info for data loop
1779    let sender_port_name = port_name.to_string();
1780    let initial_upstream = ndarray_port.to_string();
1781
1782    // Spawn data processing thread
1783    let data_jh = MandatoryThread::new(
1784        format!("plugin-data-{port_name}"),
1785        // `asynNDArrayDriver.cpp:878` — `if (priority <= 0) priority =
1786        // epicsThreadPriorityMedium`, and that is what `NDPluginDriver` hands
1787        // its callback threads (`NDPluginDriver.cpp:1016`).
1788        ThreadPriority::Medium,
1789        // `asynNDArrayDriver.cpp:876` — `if (stackSize <= 0) stackSize =
1790        // epicsThreadGetStackSize(epicsThreadStackMedium)`.
1791        StackSizeClass::Medium,
1792    )
1793    .spawn(move || {
1794        plugin_data_loop(
1795            shared,
1796            array_rx,
1797            param_rx,
1798            plugin_params,
1799            ndarray_params.array_counter,
1800            data_enabled,
1801            data_blocking,
1802            sender_port_name,
1803            initial_upstream,
1804            wiring,
1805        );
1806    });
1807
1808    let handle = PluginRuntimeHandle {
1809        port_runtime,
1810        array_sender,
1811        array_output: array_output_for_handle,
1812        port_name: port_name.to_string(),
1813        param_tx: handle_param_tx,
1814        ndarray_params,
1815        plugin_params,
1816    };
1817
1818    (handle, data_jh)
1819}
1820
1821/// Build a param batch reporting the input-queue depth.
1822///
1823/// `QUEUE_SIZE` = total capacity, `QUEUE_FREE` = free slots. G2: the param is
1824/// named `QUEUE_FREE` and the reconciled semantics are *free slots*, matching
1825/// C++ `NDPluginDriverQueueFree = queueSize - pending()`.
1826fn queue_status_batch(
1827    plugin_params: &PluginBaseParams,
1828    max_capacity: usize,
1829    free: i32,
1830) -> ParamBatch {
1831    use asyn_rs::request::ParamSetValue;
1832    ParamBatch {
1833        addr0: vec![
1834            ParamSetValue::new(
1835                plugin_params.queue_size,
1836                0,
1837                ParamValue::Int32(max_capacity as i32),
1838            ),
1839            ParamSetValue::new(plugin_params.queue_use, 0, ParamValue::Int32(free)),
1840        ],
1841        extra: std::collections::HashMap::new(),
1842    }
1843}
1844
1845/// Write a validated/clamped int32 value back into the param library so the
1846/// RBV reflects the accepted value (G4 NumThreads/MaxThreads clamping).
1847async fn clamp_writeback(port: &PortHandle, reason: usize, value: i32) {
1848    use asyn_rs::request::ParamSetValue;
1849    let _ = port
1850        .set_params_and_notify(
1851            0,
1852            vec![ParamSetValue::new(
1853                reason,
1854                0,
1855                asyn_rs::param::ParamValue::Int32(value),
1856            )],
1857        )
1858        .await;
1859}
1860
1861fn plugin_data_loop<P: NDPluginProcess>(
1862    shared: Arc<parking_lot::Mutex<SharedProcessorInner<P>>>,
1863    mut array_rx: NDArrayReceiver,
1864    mut param_rx: tokio::sync::mpsc::UnboundedReceiver<PluginParamMsg>,
1865    plugin_params: PluginBaseParams,
1866    array_counter_reason: usize,
1867    enabled: Arc<AtomicBool>,
1868    blocking_mode: Arc<AtomicBool>,
1869    sender_port_name: String,
1870    initial_upstream: String,
1871    wiring: Arc<WiringRegistry>,
1872) {
1873    let enable_callbacks_reason = plugin_params.enable_callbacks;
1874    let blocking_callbacks_reason = plugin_params.blocking_callbacks;
1875    let min_callback_time_reason = plugin_params.min_callback_time;
1876    let sort_mode_reason = plugin_params.sort_mode;
1877    let sort_time_reason = plugin_params.sort_time;
1878    let sort_size_reason = plugin_params.sort_size;
1879    let nd_array_port_reason = plugin_params.nd_array_port;
1880    let nd_array_addr_reason = plugin_params.nd_array_addr;
1881    let process_plugin_reason = plugin_params.process_plugin;
1882    let max_byte_rate_reason = plugin_params.max_byte_rate;
1883    let num_threads_reason = plugin_params.num_threads;
1884    let max_threads_reason = plugin_params.max_threads;
1885    let array_callbacks_reason = shared.lock().ndarray_params.array_callbacks;
1886    // G6: the upstream connection is keyed by (port, addr). `current_upstream`
1887    // is the base port name; `current_addr` is the selected NDArrayAddr; the
1888    // effective WiringRegistry key is computed by `upstream_key`.
1889    let mut current_upstream = initial_upstream;
1890    let mut current_addr: i32 = 0;
1891    let rt = tokio::runtime::Builder::new_current_thread()
1892        .enable_all()
1893        .build()
1894        .unwrap();
1895    rt.block_on(async {
1896        // Sort flush timer — starts disabled (very long interval).
1897        // Re-created when sort_time changes.
1898        let mut sort_flush_interval = tokio::time::interval(std::time::Duration::from_secs(3600));
1899        let mut sort_flush_active = false;
1900        // Last published QueueFree value — only flush the queue params when it
1901        // changes, so a steady queue depth does not spam param callbacks.
1902        let mut last_queue_free: Option<i32> = None;
1903        // Persistent scatter cursor (C++ NDPluginScatter::nextClient_): advances
1904        // per delivery *attempt* across frames so the round-robin survives
1905        // reroutes past full consumers. One per plugin instance, for its
1906        // lifetime — matching `nextClient_(1)` set once at construction.
1907        let mut scatter_cursor: usize = 0;
1908        // Barriers held until the array queue drains. A barrier acks only at
1909        // full quiescence — params applied AND no queued arrays — because a
1910        // param applied while an older array still waits in the queue would
1911        // retroactively change that array's processing (e.g. a
1912        // MinCallbackTime reset un-throttling it). See `PluginParamMsg`.
1913        let mut held_barriers: Vec<std::sync::mpsc::SyncSender<()>> = Vec::new();
1914
1915        loop {
1916            // Release held barriers once the array queue is empty. This runs
1917            // after every arm, and the single-task loop guarantees any
1918            // already-dequeued array has fully finished processing by now.
1919            if !held_barriers.is_empty() && array_rx.pending() == 0 {
1920                for ack in held_barriers.drain(..) {
1921                    let _ = ack.try_send(());
1922                }
1923            }
1924            tokio::select! {
1925                msg = array_rx.recv_msg() => {
1926                    match msg {
1927                        Some(msg) => {
1928                            // B6: quiesce is synchronous — if callbacks were
1929                            // disabled (the param arm flips `enabled` before
1930                            // any further array message is handled), drop the
1931                            // array here without processing.
1932                            if !enabled.load(Ordering::Acquire) {
1933                                continue;
1934                            }
1935                            // Process array and collect output (sync, under lock).
1936                            let (process_output, senders, port) = {
1937                                let mut guard = shared.lock();
1938                                // G3: a non-compression-aware plugin must drop
1939                                // a compressed array (codec set) and count it
1940                                // (C++ driverCallback NDPluginDriver.cpp:383-394).
1941                                let compressed = msg.array.codec.is_some();
1942                                let output = if compressed && !guard.compression_aware {
1943                                    guard
1944                                        .dropped_arrays
1945                                        .fetch_add(1, Ordering::AcqRel);
1946                                    Some(guard.dropped_arrays_only_batch())
1947                                } else {
1948                                    // R2/G5: process_and_publish caches the
1949                                    // input array for ProcessPlugin only after
1950                                    // the MinCallbackTime gate passes — a
1951                                    // throttled frame is never cached.
1952                                    guard.process_and_publish(&msg.array)
1953                                };
1954                                let senders = guard.output.lock().senders_clone();
1955                                let port = guard.port_handle.clone();
1956                                (output, senders, port)
1957                            };
1958                            // G2: update QueueSize/QueueFree from the channel
1959                            // depth (C++ NDPluginDriver.cpp:512-513). QueueFree
1960                            // = max_capacity - pending. Only flush when the
1961                            // value changed to avoid no-op param callbacks.
1962                            let max_cap = array_rx.max_capacity();
1963                            let free = max_cap.saturating_sub(array_rx.pending()) as i32;
1964                            let queue_batch = if last_queue_free != Some(free) {
1965                                last_queue_free = Some(free);
1966                                Some(queue_status_batch(&plugin_params, max_cap, free))
1967                            } else {
1968                                None
1969                            };
1970                            // msg dropped here → completion signaled (if tracked)
1971                            // Publish arrays and flush params outside the lock, in async context.
1972                            if let Some(po) = process_output {
1973                                po.publish_arrays(&senders, &mut scatter_cursor).await;
1974                                po.batch.flush(&port).await;
1975                            }
1976                            if let Some(qb) = queue_batch {
1977                                qb.flush(&port).await;
1978                            }
1979                        }
1980                        None => break,
1981                    }
1982                }
1983                param = param_rx.recv() => {
1984                    match param {
1985                        // Barrier: every Change enqueued before it has been
1986                        // applied by the arms below (FIFO channel). Ack is
1987                        // deferred to the top-of-loop release, which also
1988                        // requires the array queue to be drained — a gone
1989                        // waiter is not an error.
1990                        Some(PluginParamMsg::Barrier(ack)) => {
1991                            held_barriers.push(ack);
1992                        }
1993                        Some(PluginParamMsg::Change(reason, addr, value)) => {
1994                            if reason == enable_callbacks_reason {
1995                                let on = value.as_i32() != 0;
1996                                enabled.store(on, Ordering::Release);
1997                                // B6: disabling releases the cached input array
1998                                // (C++ writeInt32 NDPluginDriver.cpp:712-722).
1999                                if !on {
2000                                    shared.lock().prev_input_array = None;
2001                                }
2002                            }
2003                            if reason == blocking_callbacks_reason {
2004                                blocking_mode.store(value.as_i32() != 0, Ordering::Release);
2005                            }
2006                            // NDArrayCallbacks gates downstream array delivery
2007                            // (C++ endProcessCallbacks NDPluginDriver.cpp:
2008                            // 257-265). Processing still runs; only delivery is
2009                            // suppressed when 0.
2010                            if reason == array_callbacks_reason {
2011                                shared.lock().array_callbacks = value.as_i32() != 0;
2012                            }
2013                            // Handle MinCallbackTime param change
2014                            if reason == min_callback_time_reason {
2015                                shared.lock().min_callback_time = value.as_f64();
2016                            }
2017                            // G7: MaxByteRate change resets the output throttler
2018                            // (C++ writeFloat64 NDPluginDriver.cpp:788-790).
2019                            if reason == max_byte_rate_reason {
2020                                let rate = value.as_f64();
2021                                let mut guard = shared.lock();
2022                                guard.max_byte_rate = rate;
2023                                guard.throttler.reset(rate);
2024                            }
2025                            // G4: NumThreads / MaxThreads are validated and
2026                            // clamped on write. The Rust port is intentionally
2027                            // single-threaded per plugin (one tokio task) — see
2028                            // the module note — so NumThreads is clamped to
2029                            // [1, MaxThreads] and the clamped value written back
2030                            // rather than spawning a worker pool.
2031                            if reason == max_threads_reason {
2032                                // Scope the guard so it is released before await.
2033                                let (port, clamped, mt) = {
2034                                    let mut guard = shared.lock();
2035                                    guard.max_threads = value.as_i32().max(1);
2036                                    let clamped =
2037                                        guard.num_threads.clamp(1, guard.max_threads);
2038                                    guard.num_threads = clamped;
2039                                    (guard.port_handle.clone(), clamped, guard.max_threads)
2040                                };
2041                                clamp_writeback(&port, num_threads_reason, clamped).await;
2042                                clamp_writeback(&port, max_threads_reason, mt).await;
2043                            }
2044                            if reason == num_threads_reason {
2045                                let (port, clamped) = {
2046                                    let mut guard = shared.lock();
2047                                    let clamped =
2048                                        value.as_i32().clamp(1, guard.max_threads.max(1));
2049                                    guard.num_threads = clamped;
2050                                    (guard.port_handle.clone(), clamped)
2051                                };
2052                                clamp_writeback(&port, num_threads_reason, clamped).await;
2053                            }
2054                            // G6: NDArrayAddr selects a source address of a
2055                            // multi-address driver — reconnect on change
2056                            // (C++ writeInt32 NDPluginDriver.cpp:724-728).
2057                            if reason == nd_array_addr_reason {
2058                                let new_addr = value.as_i32();
2059                                if new_addr != current_addr {
2060                                    let old_key = upstream_key(&current_upstream, current_addr);
2061                                    let new_key = upstream_key(&current_upstream, new_addr);
2062                                    shared.lock().nd_array_addr = new_addr;
2063                                    match wiring.rewire_by_name(
2064                                        &sender_port_name,
2065                                        &old_key,
2066                                        &new_key,
2067                                    ) {
2068                                        Ok(()) => current_addr = new_addr,
2069                                        Err(e) => {
2070                                            eprintln!("NDArrayAddr reconnect failed: {e}");
2071                                            shared.lock().nd_array_addr = current_addr;
2072                                        }
2073                                    }
2074                                }
2075                            }
2076                            // G5: ProcessPlugin re-injects the cached input
2077                            // array (C++ writeInt32 NDPluginDriver.cpp:739-746).
2078                            if reason == process_plugin_reason && value.as_i32() != 0 {
2079                                let (process_output, senders, port) = {
2080                                    let mut guard = shared.lock();
2081                                    let output = guard.process_plugin();
2082                                    let senders = guard.output.lock().senders_clone();
2083                                    let port = guard.port_handle.clone();
2084                                    (output, senders, port)
2085                                };
2086                                if let Some(po) = process_output {
2087                                    po.publish_arrays(&senders, &mut scatter_cursor).await;
2088                                    po.batch.flush(&port).await;
2089                                } else {
2090                                    // C parity: NDPluginDriver::writeInt32
2091                                    // (NDPluginDriver.cpp:743) logs this at
2092                                    // ASYN_TRACE_WARNING, which is OFF in the
2093                                    // default port trace mask. Gate through the
2094                                    // port's trace facility so iocInit stays
2095                                    // silent unless WARNING is enabled, instead
2096                                    // of an unconditional eprintln! that spams
2097                                    // stderr on every PINI ProcessPlugin trigger.
2098                                    // The asyn port registry that exposes the
2099                                    // per-port mask only exists with the `ioc`
2100                                    // integration; a bare plugin build has no
2101                                    // trace facility to consult, so it stays
2102                                    // silent there too.
2103                                    #[cfg(feature = "ioc")]
2104                                    if let Some(entry) =
2105                                        asyn_rs::asyn_record::get_port(&sender_port_name)
2106                                    {
2107                                        asyn_rs::asyn_trace!(
2108                                            entry.trace,
2109                                            sender_port_name.as_str(),
2110                                            asyn_rs::trace::TraceMask::WARNING,
2111                                            "plugin {sender_port_name}: ProcessPlugin \
2112                                             requested but no input array cached"
2113                                        );
2114                                    }
2115                                }
2116                            }
2117                            // B12: a control-plane write of ArrayCounter resets
2118                            // the working counter (C++ keeps NDArrayCounter in
2119                            // the param library; beginProcessCallbacks reads it).
2120                            if reason == array_counter_reason {
2121                                shared.lock().array_counter = value.as_i32();
2122                            }
2123                            // Handle sort param changes
2124                            if reason == sort_mode_reason {
2125                                let mode = value.as_i32();
2126                                // Scope the guard so clippy can verify the lock
2127                                // is released before any await.
2128                                let flush_work = {
2129                                    let mut guard = shared.lock();
2130                                    guard.sort_mode = mode;
2131                                    if mode == 0 {
2132                                        let output = guard.flush_sort_buffer();
2133                                        let senders = guard.output.lock().senders_clone();
2134                                        let port = guard.port_handle.clone();
2135                                        sort_flush_active = false;
2136                                        Some((output, senders, port))
2137                                    } else {
2138                                        sort_flush_active = guard.sort_time > 0.0;
2139                                        if sort_flush_active {
2140                                            let dur = epics_libcom_rs::runtime::time::duration_from_secs(
2141                                                guard.sort_time,
2142                                            );
2143                                            sort_flush_interval = tokio::time::interval(dur);
2144                                        }
2145                                        None
2146                                    }
2147                                };
2148                                if let Some((output, senders, port)) = flush_work {
2149                                    output.publish_arrays(&senders, &mut scatter_cursor).await;
2150                                    output.batch.flush(&port).await;
2151                                }
2152                            }
2153                            if reason == sort_time_reason {
2154                                let t = value.as_f64();
2155                                let mut guard = shared.lock();
2156                                guard.sort_time = t;
2157                                if guard.sort_mode != 0 && t > 0.0 {
2158                                    sort_flush_active = true;
2159                                    let dur = epics_libcom_rs::runtime::time::duration_from_secs(t);
2160                                    sort_flush_interval = tokio::time::interval(dur);
2161                                } else {
2162                                    sort_flush_active = false;
2163                                }
2164                                drop(guard);
2165                            }
2166                            if reason == sort_size_reason {
2167                                shared.lock().sort_size = value.as_i32();
2168                            }
2169                            // Handle NDArrayPort rewiring — keyed by (port, addr).
2170                            if reason == nd_array_port_reason {
2171                                if let Some(new_port) = value.as_string() {
2172                                    if new_port != current_upstream {
2173                                        let old_key =
2174                                            upstream_key(&current_upstream, current_addr);
2175                                        let new_key = upstream_key(new_port, current_addr);
2176                                        match wiring.rewire_by_name(
2177                                            &sender_port_name,
2178                                            &old_key,
2179                                            &new_key,
2180                                        ) {
2181                                            Ok(()) => current_upstream = new_port.to_string(),
2182                                            Err(e) => {
2183                                                eprintln!("NDArrayPort rewire failed: {e}")
2184                                            }
2185                                        }
2186                                    }
2187                                }
2188                            }
2189                            let snapshot = PluginParamSnapshot {
2190                                enable_callbacks: enabled.load(Ordering::Acquire),
2191                                reason,
2192                                addr,
2193                                value,
2194                            };
2195                            let (process_output, senders, port) = {
2196                                let mut guard = shared.lock();
2197                                let t0 = std::time::Instant::now();
2198                                let result = guard.processor.on_param_change(reason, &snapshot);
2199                                let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
2200                                let output = if !result.output_arrays.is_empty() || !result.param_updates.is_empty() {
2201                                    let deliver = guard.array_callbacks;
2202                                    Some(guard.build_publish_batch(result.output_arrays, result.param_updates, false, None, elapsed_ms, deliver, true))
2203                                } else {
2204                                    None
2205                                };
2206                                let senders = guard.output.lock().senders_clone();
2207                                (output, senders, guard.port_handle.clone())
2208                            };
2209                            if let Some(po) = process_output {
2210                                po.publish_arrays(&senders, &mut scatter_cursor).await;
2211                                po.batch.flush(&port).await;
2212                            }
2213                        }
2214                        None => break,
2215                    }
2216                }
2217                _ = sort_flush_interval.tick(), if sort_flush_active => {
2218                    // B3: drain head-first while contiguous or past the
2219                    // staleness deadline — NOT the whole buffer.
2220                    let (output, senders, port) = {
2221                        let mut guard = shared.lock();
2222                        let output = guard.tick_sort_buffer();
2223                        let senders = guard.output.lock().senders_clone();
2224                        let port = guard.port_handle.clone();
2225                        (output, senders, port)
2226                    };
2227                    output.publish_arrays(&senders, &mut scatter_cursor).await;
2228                    output.batch.flush(&port).await;
2229                }
2230            }
2231        }
2232    });
2233}
2234
2235/// Connect a downstream plugin's sender to a plugin runtime's output.
2236///
2237/// B13: the upstream's `array_output` is the same `Arc` that every
2238/// `create_plugin_runtime*` entry point registers in the `WiringRegistry`, so
2239/// adding a sender here mutates the registry-tracked output — the registry
2240/// remains the single source of truth for `rewire_by_name`.
2241pub fn wire_downstream(upstream: &PluginRuntimeHandle, downstream_sender: NDArraySender) {
2242    upstream.array_output().lock().add(downstream_sender);
2243}
2244
2245/// Create a plugin runtime with a pre-wired output (for testing and direct wiring).
2246pub fn create_plugin_runtime_with_output<P: NDPluginProcess>(
2247    port_name: &str,
2248    mut processor: P,
2249    pool: Arc<NDArrayPool>,
2250    queue_size: usize,
2251    output: NDArrayOutput,
2252    ndarray_port: &str,
2253    wiring: Arc<WiringRegistry>,
2254) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
2255    // B14: unbounded so control-plane param changes (e.g. autosave restoring
2256    // hundreds of PVs at IOC init) are never silently dropped before the
2257    // data plane sees them.
2258    let (param_tx, param_rx) = tokio::sync::mpsc::unbounded_channel::<PluginParamMsg>();
2259    let handle_param_tx = param_tx.clone();
2260
2261    let plugin_type_name = processor.plugin_type().to_string();
2262    let compression_aware = processor.compression_aware();
2263    let does_array_callbacks = processor.does_array_callbacks();
2264    let array_data = processor.array_data_handle();
2265    let driver = PluginPortDriver::new(
2266        port_name,
2267        &plugin_type_name,
2268        queue_size,
2269        ndarray_port,
2270        1,
2271        param_tx,
2272        &mut processor,
2273        array_data,
2274        &pool,
2275    )
2276    .expect("failed to create plugin port driver");
2277
2278    let ndarray_params = driver.ndarray_params;
2279    let plugin_params = driver.plugin_params;
2280    let std_array_data_param = driver.std_array_data_param;
2281
2282    // Fatal for the same reason as `create_plugin_runtime_multi_addr` above:
2283    // a constructor-shaped creator has nowhere to report to, and the only
2284    // alternative is a handle to a port that does not exist.
2285    let (port_runtime, _actor_jh) = create_port_runtime(driver, RuntimeConfig::default())
2286        .unwrap_or_else(|e| port_runtime_unavailable(port_name, &e));
2287
2288    let port_handle = port_runtime.port_handle().clone();
2289
2290    let (array_sender, array_rx) = ndarray_channel(port_name, queue_size);
2291
2292    let enabled = Arc::new(AtomicBool::new(false));
2293    let blocking_mode = Arc::new(AtomicBool::new(false));
2294
2295    let array_output = Arc::new(parking_lot::Mutex::new(output));
2296    let array_output_for_handle = array_output.clone();
2297    // B13: register this plugin's output so the WiringRegistry is the single
2298    // source of truth — an output created via this entry point is otherwise
2299    // invisible to runtime rewiring.
2300    wiring.register_output(port_name, array_output.clone());
2301    // G1/B1: DroppedArrays counter shared with upstream senders.
2302    let dropped_arrays_counter = array_sender.dropped_arrays_counter().clone();
2303    let shared = Arc::new(parking_lot::Mutex::new(SharedProcessorInner {
2304        processor,
2305        output: array_output,
2306        pool,
2307        ndarray_params,
2308        plugin_params,
2309        port_handle,
2310        array_counter: 0,
2311        std_array_data_param,
2312        // C++ default NDArrayCallbacks = 1 (deliver downstream); terminal
2313        // plugins (StdArrays/Attribute/File) override `does_array_callbacks` to 0.
2314        array_callbacks: does_array_callbacks,
2315        min_callback_time: 0.0,
2316        last_process_time: None,
2317        sort_mode: 0,
2318        sort_time: 0.0,
2319        sort_size: 10,
2320        sort_buffer: SortBuffer::new(),
2321        dropped_arrays: dropped_arrays_counter,
2322        compression_aware,
2323        max_byte_rate: 0.0,
2324        throttler: super::throttler::Throttler::new(0.0),
2325        prev_input_array: None,
2326        dims_prev: vec![0i32; crate::ndarray::ND_ARRAY_MAX_DIMS],
2327        nd_array_addr: PLUGIN_NDARRAY_ADDR,
2328        max_threads: PLUGIN_MAX_THREADS,
2329        num_threads: PLUGIN_NUM_THREADS,
2330    }));
2331
2332    let data_enabled = enabled.clone();
2333    let data_blocking = blocking_mode.clone();
2334
2335    let mut array_sender = array_sender;
2336    array_sender.set_mode_flags(enabled, blocking_mode);
2337
2338    // Capture wiring info for data loop
2339    let sender_port_name = port_name.to_string();
2340    let initial_upstream = ndarray_port.to_string();
2341
2342    let data_jh = MandatoryThread::new(
2343        format!("plugin-data-{port_name}"),
2344        // `asynNDArrayDriver.cpp:878` — `if (priority <= 0) priority =
2345        // epicsThreadPriorityMedium`, and that is what `NDPluginDriver` hands
2346        // its callback threads (`NDPluginDriver.cpp:1016`).
2347        ThreadPriority::Medium,
2348        // `asynNDArrayDriver.cpp:876` — `if (stackSize <= 0) stackSize =
2349        // epicsThreadGetStackSize(epicsThreadStackMedium)`.
2350        StackSizeClass::Medium,
2351    )
2352    .spawn(move || {
2353        plugin_data_loop(
2354            shared,
2355            array_rx,
2356            param_rx,
2357            plugin_params,
2358            ndarray_params.array_counter,
2359            data_enabled,
2360            data_blocking,
2361            sender_port_name,
2362            initial_upstream,
2363            wiring,
2364        );
2365    });
2366
2367    let handle = PluginRuntimeHandle {
2368        port_runtime,
2369        array_sender,
2370        array_output: array_output_for_handle,
2371        port_name: port_name.to_string(),
2372        param_tx: handle_param_tx,
2373        ndarray_params,
2374        plugin_params,
2375    };
2376
2377    (handle, data_jh)
2378}
2379
2380#[cfg(test)]
2381mod tests {
2382    use super::*;
2383    use crate::ndarray::{NDDataType, NDDimension};
2384    use crate::plugin::channel::ndarray_channel;
2385
2386    /// # Invariant
2387    ///
2388    /// MUST: every `plugin-data-*` thread be created through
2389    /// [`MandatoryThread`], so that a thread the plugin cannot process without
2390    /// takes the process down rather than the caller's thread.
2391    ///
2392    /// The reason this is not the `errlog-and-continue` class:
2393    /// `NDPluginDriver::createCallbackThreads` builds its workers as
2394    /// `new epicsThread(...)` (`NDPluginDriver.cpp:1016`), whose constructor
2395    /// calls `epicsThreadCreateOpt` and `throw unableToCreateThread()` on
2396    /// failure (`epicsThread.cpp:214-220`) — a thrown failure, not a status
2397    /// code the plugin inspects and carries on from.
2398    ///
2399    /// Where C ends up is **not** where we do, and the difference is
2400    /// deliberate: iocsh catches whatever a command throws
2401    /// (`iocsh.cpp:1274-1284`, `"C++ error: ..."`) and a startup script's
2402    /// default `on error` is `Continue` (`iocsh.cpp:1001`, `:1129`), so C runs
2403    /// the rest of st.cmd with the plugin's port registered and its worker
2404    /// threads absent — arrays queue to it and are never processed, silently,
2405    /// for the life of the IOC. `MandatoryThread::spawn` refuses that state.
2406    /// The `.expect` it replaced reached neither: on a `panic = "unwind"`
2407    /// target it unwound one thread and left the same zombie plugin behind.
2408    ///
2409    /// Contrast the auxiliary AD threads, which genuinely do errlog-and-continue
2410    /// and have no site here: the sorting thread (`NDPluginDriver.cpp:1105-1114`,
2411    /// `asynPrint` + `return asynError`), the queued-array counter
2412    /// (`asynNDArrayDriver.cpp:1013-1021`, `asynPrint` and no error at all) and
2413    /// the HDF5 flush task (`NDFileHDF5.cpp:2423-2431`, `printf` + `return`).
2414    ///
2415    /// Source inspection, because the defect is a call that is *absent*.
2416    #[test]
2417    fn plugin_data_threads_are_mandatory() {
2418        let prod = match include_str!("runtime.rs").find("\n#[cfg(test)]") {
2419            Some(i) => &include_str!("runtime.rs")[..i],
2420            None => include_str!("runtime.rs"),
2421        };
2422        assert_eq!(
2423            prod.matches("MandatoryThread::new(").count(),
2424            2,
2425            "`create_plugin_runtime_multi_addr` and `create_plugin_runtime_with_output`"
2426        );
2427        let bare = concat!("thread", "::Builder::new()");
2428        let strays: Vec<&str> = prod
2429            .lines()
2430            .map(str::trim)
2431            .filter(|l| !l.starts_with("//"))
2432            .filter(|l| l.contains(bare) || l.contains(concat!("thread", "::spawn(")))
2433            .collect();
2434        assert!(
2435            strays.is_empty(),
2436            "a plugin data thread created outside `MandatoryThread` resolves its \
2437             own spawn failure locally: {strays:?}"
2438        );
2439    }
2440
2441    /// Passthrough processor: returns the input array as-is.
2442    struct PassthroughProcessor;
2443
2444    impl NDPluginProcess for PassthroughProcessor {
2445        fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
2446            ProcessResult::arrays(vec![Arc::new(array.clone())])
2447        }
2448        fn plugin_type(&self) -> &str {
2449            "Passthrough"
2450        }
2451    }
2452
2453    /// Sink processor: consumes arrays, returns nothing.
2454    struct SinkProcessor {
2455        count: usize,
2456    }
2457
2458    impl NDPluginProcess for SinkProcessor {
2459        fn process_array(&mut self, _array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
2460            self.count += 1;
2461            ProcessResult::empty()
2462        }
2463        fn plugin_type(&self) -> &str {
2464            "Sink"
2465        }
2466    }
2467
2468    fn make_test_array(id: i32) -> Arc<NDArray> {
2469        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
2470        arr.unique_id = id;
2471        Arc::new(arr)
2472    }
2473
2474    fn test_wiring() -> Arc<WiringRegistry> {
2475        Arc::new(WiringRegistry::new())
2476    }
2477
2478    /// Fence: wait until the data thread has applied every param change
2479    /// submitted so far. `write_*_blocking` only guarantees the change is
2480    /// queued for the data plane; asserting on data-plane behaviour without
2481    /// this fence is a race.
2482    fn params_applied(handle: &PluginRuntimeHandle) {
2483        assert!(
2484            handle.wait_params_applied(std::time::Duration::from_secs(10)),
2485            "data thread did not apply queued param changes"
2486        );
2487    }
2488
2489    /// Poll `cond` until it holds; panic after 10 s. Waits on the observable
2490    /// state itself instead of sleeping a guessed duration, so a loaded
2491    /// machine cannot flake the test.
2492    fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
2493        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2494        while !cond() {
2495            assert!(
2496                std::time::Instant::now() < deadline,
2497                "timed out waiting for {what}"
2498            );
2499            std::thread::sleep(std::time::Duration::from_millis(2));
2500        }
2501    }
2502
2503    /// Enable callbacks on a plugin handle (plugins default to disabled) and
2504    /// fence until the data thread has actually flipped the enable flag.
2505    fn enable_callbacks(handle: &PluginRuntimeHandle) {
2506        handle
2507            .port_runtime()
2508            .port_handle()
2509            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
2510            .unwrap();
2511        params_applied(handle);
2512    }
2513
2514    /// Send an array via the sender from a sync test context.
2515    /// Uses a dedicated thread with a current-thread runtime to avoid
2516    /// interfering with the plugin's own runtime.
2517    fn send_array(sender: &NDArraySender, array: Arc<NDArray>) {
2518        let sender = sender.clone();
2519        let jh = std::thread::spawn(move || {
2520            let rt = tokio::runtime::Builder::new_current_thread()
2521                .enable_all()
2522                .build()
2523                .unwrap();
2524            rt.block_on(sender.publish(array));
2525        });
2526        jh.join().unwrap();
2527    }
2528
2529    #[test]
2530    fn test_passthrough_runtime() {
2531        let pool = Arc::new(NDArrayPool::new(1_000_000));
2532
2533        // Create downstream receiver
2534        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2535        let mut output = NDArrayOutput::new();
2536        output.add(downstream_sender);
2537
2538        let (handle, _data_jh) = create_plugin_runtime_with_output(
2539            "PASS1",
2540            PassthroughProcessor,
2541            pool,
2542            10,
2543            output,
2544            "",
2545            test_wiring(),
2546        );
2547        enable_callbacks(&handle);
2548
2549        // Send an array
2550        send_array(handle.array_sender(), make_test_array(42));
2551
2552        // Should come out the other side
2553        let received = downstream_rx.blocking_recv().unwrap();
2554        assert_eq!(received.unique_id, 42);
2555    }
2556
2557    #[test]
2558    fn test_sink_runtime() {
2559        let pool = Arc::new(NDArrayPool::new(1_000_000));
2560
2561        let (handle, _data_jh) = create_plugin_runtime(
2562            "SINK1",
2563            SinkProcessor { count: 0 },
2564            pool,
2565            10,
2566            "",
2567            test_wiring(),
2568        );
2569        enable_callbacks(&handle);
2570
2571        // Send arrays - they should be consumed silently
2572        send_array(handle.array_sender(), make_test_array(1));
2573        send_array(handle.array_sender(), make_test_array(2));
2574
2575        // ArrayCounter advances once per processed frame — both consumed.
2576        let port = handle.port_runtime().port_handle().clone();
2577        let counter = handle.ndarray_params.array_counter;
2578        wait_until("sink to process both arrays", || {
2579            port.read_int32_blocking(counter, 0).is_ok_and(|v| v == 2)
2580        });
2581        assert_eq!(handle.port_name(), "SINK1");
2582    }
2583
2584    #[test]
2585    fn test_plugin_type_param() {
2586        let pool = Arc::new(NDArrayPool::new(1_000_000));
2587
2588        let (handle, _data_jh) = create_plugin_runtime(
2589            "TYPE_TEST",
2590            PassthroughProcessor,
2591            pool,
2592            10,
2593            "",
2594            test_wiring(),
2595        );
2596
2597        // Verify port name
2598        assert_eq!(handle.port_name(), "TYPE_TEST");
2599        assert_eq!(handle.port_runtime().port_name(), "TYPE_TEST");
2600    }
2601
2602    #[test]
2603    fn test_ndtimestamp_param_is_the_standalone_double() {
2604        // R8-66 family: C `setDoubleParam(NDTimeStamp, pArray->timeStamp)`
2605        // (NDPluginDriver.cpp:217) publishes the array's standalone double —
2606        // which a driver with a hardware clock sets independently of epicsTS —
2607        // while NDEpicsTSSec/nSec carry epicsTS (`:218-219`). The plugin runtime
2608        // published `timestamp.as_f64()` for all three, so NDTimeStamp_RBV
2609        // reported the epicsTS-derived value.
2610        let pool = Arc::new(NDArrayPool::new(1_000_000));
2611        let (ds, _rx) = ndarray_channel("DS_TS", 10);
2612        let mut output = NDArrayOutput::new();
2613        output.add(ds);
2614        let (handle, _jh) = create_plugin_runtime_with_output(
2615            "TS_PARAM",
2616            PassthroughProcessor,
2617            pool,
2618            10,
2619            output,
2620            "",
2621            test_wiring(),
2622        );
2623        enable_callbacks(&handle);
2624        let port = handle.port_runtime().port_handle().clone();
2625
2626        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
2627        arr.timestamp = crate::timestamp::EpicsTimestamp {
2628            sec: 1234,
2629            nsec: 5678,
2630        };
2631        arr.time_stamp = 100.5; // hardware clock, unrelated to epicsTS
2632        send_array(handle.array_sender(), Arc::new(arr));
2633        std::thread::sleep(std::time::Duration::from_millis(50));
2634
2635        assert_eq!(
2636            port.read_float64_blocking(handle.ndarray_params.timestamp_rbv, 0)
2637                .unwrap(),
2638            100.5,
2639            "NDTimeStamp publishes pArray->timeStamp"
2640        );
2641        assert_eq!(
2642            port.read_int32_blocking(handle.ndarray_params.epics_ts_sec, 0)
2643                .unwrap(),
2644            1234
2645        );
2646        assert_eq!(
2647            port.read_int32_blocking(handle.ndarray_params.epics_ts_nsec, 0)
2648                .unwrap(),
2649            5678
2650        );
2651    }
2652
2653    #[test]
2654    fn test_shutdown_on_handle_drop() {
2655        let pool = Arc::new(NDArrayPool::new(1_000_000));
2656
2657        let (handle, data_jh) = create_plugin_runtime(
2658            "SHUTDOWN_TEST",
2659            PassthroughProcessor,
2660            pool,
2661            10,
2662            "",
2663            test_wiring(),
2664        );
2665
2666        // Drop the handle (closes sender channel, which should cause data thread to exit)
2667        let sender = handle.array_sender().clone();
2668        drop(handle);
2669        drop(sender);
2670
2671        // Data thread should terminate
2672        let result = data_jh.join();
2673        assert!(result.is_ok());
2674    }
2675
2676    #[test]
2677    fn test_wire_to_nonzero_ndarray_addr() {
2678        // G6: a multi-address upstream plugin registers its output under every
2679        // address in 0..max_addr. A downstream consumer must be able to select
2680        // NDArrayAddr=1 and actually receive arrays — previously the output was
2681        // registered under the bare port name only, so the "PORT:1" key was
2682        // missing and rewire failed with "not found".
2683        use crate::plugin::wiring::upstream_key;
2684        let pool = Arc::new(NDArrayPool::new(1_000_000));
2685        let wiring = test_wiring();
2686
2687        // Upstream plugin advertises 2 addresses.
2688        let (up_handle, _up_jh) = create_plugin_runtime_multi_addr(
2689            "UP_MULTI",
2690            PassthroughProcessor,
2691            pool,
2692            10,
2693            "",
2694            wiring.clone(),
2695            2,
2696        );
2697        enable_callbacks(&up_handle);
2698
2699        // The "PORT:1" key must resolve to the same output as the bare port.
2700        let addr0 = wiring.lookup_output("UP_MULTI");
2701        let addr1 = wiring.lookup_output(&upstream_key("UP_MULTI", 1));
2702        assert!(addr0.is_some(), "addr 0 output must be registered");
2703        assert!(
2704            addr1.is_some(),
2705            "addr 1 output must be registered for a max_addr=2 port"
2706        );
2707
2708        // Wire a downstream consumer to UP_MULTI address 1.
2709        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWN_ADDR1", 10);
2710        wiring
2711            .rewire(&downstream_sender, "", &upstream_key("UP_MULTI", 1))
2712            .expect("wiring a consumer to NDArrayAddr=1 must succeed");
2713
2714        // An array sent through the upstream must reach the addr-1 consumer.
2715        send_array(up_handle.array_sender(), make_test_array(99));
2716        let received = downstream_rx.blocking_recv().unwrap();
2717        assert_eq!(
2718            received.unique_id, 99,
2719            "consumer wired to NDArrayAddr=1 must receive upstream arrays"
2720        );
2721    }
2722
2723    #[test]
2724    fn test_nonblocking_passthrough() {
2725        let pool = Arc::new(NDArrayPool::new(1_000_000));
2726        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2727        let mut output = NDArrayOutput::new();
2728        output.add(downstream_sender);
2729
2730        let (handle, _data_jh) = create_plugin_runtime_with_output(
2731            "NB_TEST",
2732            PassthroughProcessor,
2733            pool,
2734            10,
2735            output,
2736            "",
2737            test_wiring(),
2738        );
2739        enable_callbacks(&handle);
2740
2741        send_array(handle.array_sender(), make_test_array(42));
2742
2743        let received = downstream_rx.blocking_recv().unwrap();
2744        assert_eq!(received.unique_id, 42);
2745    }
2746
2747    #[test]
2748    fn test_blocking_to_nonblocking_switch() {
2749        let pool = Arc::new(NDArrayPool::new(1_000_000));
2750        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2751        let mut output = NDArrayOutput::new();
2752        output.add(downstream_sender);
2753
2754        let (handle, _data_jh) = create_plugin_runtime_with_output(
2755            "SWITCH_TEST",
2756            PassthroughProcessor,
2757            pool,
2758            10,
2759            output,
2760            "",
2761            test_wiring(),
2762        );
2763        enable_callbacks(&handle);
2764
2765        // Start in blocking mode
2766        handle
2767            .port_runtime()
2768            .port_handle()
2769            .write_int32_blocking(handle.plugin_params.blocking_callbacks, 0, 1)
2770            .unwrap();
2771        params_applied(&handle);
2772
2773        send_array(handle.array_sender(), make_test_array(1));
2774        let received = downstream_rx.blocking_recv().unwrap();
2775        assert_eq!(received.unique_id, 1);
2776
2777        // Switch back to non-blocking
2778        handle
2779            .port_runtime()
2780            .port_handle()
2781            .write_int32_blocking(handle.plugin_params.blocking_callbacks, 0, 0)
2782            .unwrap();
2783        params_applied(&handle);
2784
2785        // Send in non-blocking mode — goes through channel to data thread
2786        send_array(handle.array_sender(), make_test_array(2));
2787        let received = downstream_rx.blocking_recv().unwrap();
2788        assert_eq!(received.unique_id, 2);
2789    }
2790
2791    #[test]
2792    fn test_enable_callbacks_disables_processing() {
2793        let pool = Arc::new(NDArrayPool::new(1_000_000));
2794        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2795        let mut output = NDArrayOutput::new();
2796        output.add(downstream_sender);
2797
2798        let (handle, _data_jh) = create_plugin_runtime_with_output(
2799            "ENABLE_TEST",
2800            PassthroughProcessor,
2801            pool,
2802            10,
2803            output,
2804            "",
2805            test_wiring(),
2806        );
2807
2808        // Disable callbacks
2809        handle
2810            .port_runtime()
2811            .port_handle()
2812            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 0)
2813            .unwrap();
2814        params_applied(&handle);
2815
2816        // Send array — should be silently dropped by sender (callbacks disabled)
2817        send_array(handle.array_sender(), make_test_array(99));
2818
2819        // Verify nothing received (with timeout)
2820        let rt = tokio::runtime::Builder::new_current_thread()
2821            .enable_all()
2822            .build()
2823            .unwrap();
2824        let result = rt.block_on(async {
2825            tokio::time::timeout(std::time::Duration::from_millis(100), downstream_rx.recv()).await
2826        });
2827        assert!(
2828            result.is_err(),
2829            "should not receive array when callbacks disabled"
2830        );
2831    }
2832
2833    #[test]
2834    fn test_downstream_receives_multiple() {
2835        let pool = Arc::new(NDArrayPool::new(1_000_000));
2836
2837        let (ds1, mut rx1) = ndarray_channel("DS1", 10);
2838        let (ds2, mut rx2) = ndarray_channel("DS2", 10);
2839        let mut output = NDArrayOutput::new();
2840        output.add(ds1);
2841        output.add(ds2);
2842
2843        let (handle, _data_jh) = create_plugin_runtime_with_output(
2844            "DS_TEST",
2845            PassthroughProcessor,
2846            pool,
2847            10,
2848            output,
2849            "",
2850            test_wiring(),
2851        );
2852        enable_callbacks(&handle);
2853
2854        send_array(handle.array_sender(), make_test_array(77));
2855
2856        // Both downstream receivers should have the array
2857        let r1 = rx1.blocking_recv().unwrap();
2858        let r2 = rx2.blocking_recv().unwrap();
2859        assert_eq!(r1.unique_id, 77);
2860        assert_eq!(r2.unique_id, 77);
2861    }
2862
2863    #[test]
2864    fn test_param_updates_after_send() {
2865        let pool = Arc::new(NDArrayPool::new(1_000_000));
2866
2867        struct ParamTracker;
2868        impl NDPluginProcess for ParamTracker {
2869            fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
2870                ProcessResult::arrays(vec![Arc::new(array.clone())])
2871            }
2872            fn plugin_type(&self) -> &str {
2873                "ParamTracker"
2874            }
2875        }
2876
2877        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2878        let mut output = NDArrayOutput::new();
2879        output.add(downstream_sender);
2880
2881        let (handle, _data_jh) = create_plugin_runtime_with_output(
2882            "PARAM_TEST",
2883            ParamTracker,
2884            pool,
2885            10,
2886            output,
2887            "",
2888            test_wiring(),
2889        );
2890        enable_callbacks(&handle);
2891
2892        // Send array
2893        send_array(handle.array_sender(), make_test_array(1));
2894        let received = downstream_rx.blocking_recv().unwrap();
2895        assert_eq!(received.unique_id, 1);
2896
2897        // Write enable_callbacks — should not crash
2898        handle
2899            .port_runtime()
2900            .port_handle()
2901            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
2902            .unwrap();
2903        params_applied(&handle);
2904
2905        // Still works after param update
2906        send_array(handle.array_sender(), make_test_array(2));
2907        let received = downstream_rx.blocking_recv().unwrap();
2908        assert_eq!(received.unique_id, 2);
2909    }
2910
2911    #[test]
2912    fn test_sort_buffer_reorders_by_unique_id() {
2913        let mut buf = SortBuffer::new();
2914
2915        // Insert out of order: 3, 1, 2
2916        buf.insert(3, vec![make_test_array(3)], 10);
2917        buf.insert(1, vec![make_test_array(1)], 10);
2918        buf.insert(2, vec![make_test_array(2)], 10);
2919
2920        assert_eq!(buf.len(), 3);
2921
2922        let drained = buf.drain_all();
2923        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
2924        assert_eq!(ids, vec![1, 2, 3], "should drain in sorted uniqueId order");
2925        assert_eq!(buf.len(), 0);
2926        assert_eq!(buf.prev_unique_id, 3);
2927    }
2928
2929    #[test]
2930    fn test_sort_buffer_drain_ready_contiguous() {
2931        // B3: drain_ready releases the head while the next-expected uniqueId
2932        // is contiguous, even when later ids are still missing.
2933        let mut buf = SortBuffer::new();
2934        // Mark a prior emission (prev=0) so the contiguity path is active;
2935        // C++ only uses the deadline for the very first output array.
2936        buf.note_emitted(0);
2937        buf.insert(1, vec![make_test_array(1)], 10);
2938        buf.insert(2, vec![make_test_array(2)], 10);
2939        buf.insert(5, vec![make_test_array(5)], 10); // gap: 3,4 missing
2940
2941        // sort_time large → only contiguity drives release.
2942        let drained = buf.drain_ready(100.0);
2943        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
2944        assert_eq!(ids, vec![1, 2], "contiguous run released; id=5 held by gap");
2945        assert_eq!(buf.len(), 1);
2946    }
2947
2948    #[test]
2949    fn test_sort_buffer_drain_ready_deadline() {
2950        // B3: a stale head is released past sort_time even with a gap.
2951        let mut buf = SortBuffer::new();
2952        buf.note_emitted(1); // prev=1
2953        buf.insert(5, vec![make_test_array(5)], 10); // out of order
2954        std::thread::sleep(std::time::Duration::from_millis(30));
2955        // sort_time=0.01s → head aged past deadline → released.
2956        let drained = buf.drain_ready(0.01);
2957        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
2958        assert_eq!(ids, vec![5], "stale head released via deadline");
2959    }
2960
2961    #[test]
2962    fn test_sort_buffer_detects_disordered_on_emit() {
2963        // B4: disorder is counted at emission time.
2964        let mut buf = SortBuffer::new();
2965        buf.note_emitted(5); // prev=5, first_output now false
2966        buf.note_emitted(3); // 3 != 5 and != 6 → disordered
2967        assert_eq!(buf.disordered_arrays, 1);
2968        buf.note_emitted(4); // 4 != 3 and != 4? 4 == prev+1 → ordered
2969        assert_eq!(buf.disordered_arrays, 1);
2970    }
2971
2972    #[test]
2973    fn test_sort_buffer_drops_when_full() {
2974        let mut buf = SortBuffer::new();
2975
2976        // sort_size=2: third insert is refused.
2977        assert!(buf.insert(1, vec![make_test_array(1)], 2));
2978        assert!(buf.insert(2, vec![make_test_array(2)], 2));
2979        assert!(!buf.insert(3, vec![make_test_array(3)], 2));
2980
2981        assert_eq!(buf.len(), 2);
2982        assert_eq!(buf.dropped_output_arrays, 1);
2983    }
2984
2985    #[test]
2986    fn test_constructor_initialises_c_read_only_params() {
2987        // C++ NDPluginDriver.cpp:152-160 initialises these in the constructor
2988        // precisely so a read before any array flows returns a value instead
2989        // of "uninitialized". Nothing here enables callbacks or sends an
2990        // array: this is the idle plugin an operator sees right after
2991        // iocInit, which is when QueueFree_RBV read 0 (full) and
2992        // MaxThreads_RBV read UDF/INVALID.
2993        let pool = Arc::new(NDArrayPool::new(1_000_000));
2994        let (handle, _data_jh) = create_plugin_runtime_with_output(
2995            "CTOR_TEST",
2996            PassthroughProcessor,
2997            pool,
2998            20,
2999            NDArrayOutput::new(),
3000            "",
3001            test_wiring(),
3002        );
3003        let port = handle.port_runtime().port_handle();
3004        let read = |reason: usize| port.read_int32_blocking(reason, 0);
3005
3006        // C `:156` / `:157` — an idle queue is entirely free.
3007        assert_eq!(read(handle.plugin_params.queue_size).unwrap(), 20);
3008        assert_eq!(read(handle.plugin_params.queue_use).unwrap(), 20);
3009        // C `:153` / `:158` / `:159`. These have no other writer: MaxThreads
3010        // is a longin with SCAN "I/O Intr", no PINI and no output partner, so
3011        // an unset param leaves it UDF forever.
3012        assert_eq!(
3013            read(handle.plugin_params.nd_array_addr).unwrap(),
3014            PLUGIN_NDARRAY_ADDR
3015        );
3016        assert_eq!(
3017            read(handle.plugin_params.max_threads).unwrap(),
3018            PLUGIN_MAX_THREADS
3019        );
3020        assert_eq!(
3021            read(handle.plugin_params.num_threads).unwrap(),
3022            PLUGIN_NUM_THREADS
3023        );
3024        // C `:154` / `:155`.
3025        assert_eq!(read(handle.plugin_params.dropped_arrays).unwrap(), 0);
3026        assert_eq!(read(handle.plugin_params.dropped_output_arrays).unwrap(), 0);
3027    }
3028
3029    #[test]
3030    fn test_constructor_initialises_the_ndarray_read_only_block() {
3031        // C++ `NDPluginDriver` derives from `asynNDArrayDriver`, so the base
3032        // constructor's block (asynNDArrayDriver.cpp:954-1005) runs for a
3033        // plugin as well. Read through the port handle, which is the same
3034        // path a record takes: an unwritten param comes back as an error.
3035        let pool = Arc::new(NDArrayPool::new(2_097_152));
3036        let (handle, _data_jh) = create_plugin_runtime_with_output(
3037            "NDCTOR_TEST",
3038            PassthroughProcessor,
3039            pool,
3040            20,
3041            NDArrayOutput::new(),
3042            "",
3043            test_wiring(),
3044        );
3045        let port = handle.port_runtime().port_handle();
3046        let p = &handle.ndarray_params;
3047        for (name, reason, want) in [
3048            ("ARRAY_SIZE_X", p.array_size_x, 0),
3049            ("ARRAY_SIZE_Y", p.array_size_y, 0),
3050            ("ARRAY_SIZE_Z", p.array_size_z, 0),
3051            ("ARRAY_SIZE", p.array_size, 0),
3052            ("ND_DIMENSIONS", p.n_dimensions, 0),
3053            (
3054                "COLOR_MODE",
3055                p.color_mode,
3056                crate::color::NDColorMode::Mono as i32,
3057            ),
3058            ("UNIQUE_ID", p.unique_id, 0),
3059            ("EPICS_TS_SEC", p.epics_ts_sec, 0),
3060            ("EPICS_TS_NSEC", p.epics_ts_nsec, 0),
3061            ("BAYER_PATTERN", p.bayer_pattern, 0),
3062            ("ARRAY_COUNTER", p.array_counter, 0),
3063            ("NUM_CAPTURED", p.num_captured, 0),
3064            ("FREE_CAPTURE", p.free_capture, 0),
3065            (
3066                "ND_ATTRIBUTES_STATUS",
3067                p.attributes_status,
3068                crate::driver::ndarray_driver::ATTR_STATUS_FILE_NOT_FOUND,
3069            ),
3070            ("NUM_QUEUED_ARRAYS", p.num_queued_arrays, 0),
3071            ("POOL_ALLOC_BUFFERS", p.pool_alloc_buffers, 0),
3072            ("POOL_FREE_BUFFERS", p.pool_free_buffers, 0),
3073        ] {
3074            assert_eq!(
3075                port.read_int32_blocking(reason, 0)
3076                    .unwrap_or_else(|e| panic!("{name} unset after construction: {e:?}")),
3077                want,
3078                "{name}"
3079            );
3080        }
3081        assert_eq!(
3082            port.read_float64_blocking(p.pool_max_memory, 0)
3083                .expect("POOL_MAX_MEMORY unset after construction"),
3084            2.0
3085        );
3086        assert_eq!(
3087            port.read_float64_blocking(p.pool_used_memory, 0)
3088                .expect("POOL_USED_MEMORY unset after construction"),
3089            0.0
3090        );
3091        assert_eq!(
3092            port.read_float64_blocking(p.timestamp_rbv, 0)
3093                .expect("TIME_STAMP unset after construction"),
3094            0.0
3095        );
3096    }
3097
3098    #[test]
3099    fn test_sort_mode_runtime_integration() {
3100        let pool = Arc::new(NDArrayPool::new(1_000_000));
3101        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3102        let mut output = NDArrayOutput::new();
3103        output.add(downstream_sender);
3104
3105        let (handle, _data_jh) = create_plugin_runtime_with_output(
3106            "SORT_TEST",
3107            PassthroughProcessor,
3108            pool,
3109            10,
3110            output,
3111            "",
3112            test_wiring(),
3113        );
3114        enable_callbacks(&handle);
3115
3116        // Enable sort mode with sort_size=10 and a sort_time deadline.
3117        handle
3118            .port_runtime()
3119            .port_handle()
3120            .write_int32_blocking(handle.plugin_params.sort_size, 0, 10)
3121            .unwrap();
3122        handle
3123            .port_runtime()
3124            .port_handle()
3125            .write_float64_blocking(handle.plugin_params.sort_time, 0, 0.1)
3126            .unwrap();
3127        handle
3128            .port_runtime()
3129            .port_handle()
3130            .write_int32_blocking(handle.plugin_params.sort_mode, 0, 1)
3131            .unwrap();
3132        params_applied(&handle);
3133
3134        // B2: in-order arrays (1,2,3) must be emitted IMMEDIATELY via the
3135        // fast path — they are NOT delayed by the sort buffer.
3136        send_array(handle.array_sender(), make_test_array(1));
3137        send_array(handle.array_sender(), make_test_array(2));
3138        send_array(handle.array_sender(), make_test_array(3));
3139
3140        let rt = tokio::runtime::Builder::new_current_thread()
3141            .enable_all()
3142            .build()
3143            .unwrap();
3144        let fast = rt.block_on(async {
3145            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
3146        });
3147        assert!(
3148            fast.is_ok(),
3149            "in-order arrays must be emitted immediately, not buffered"
3150        );
3151        assert_eq!(fast.unwrap().unwrap().unique_id, 1);
3152        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 2);
3153        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 3);
3154
3155        // B3: now send out of order (5 before 4). prev=3, so 4 is in-order
3156        // and emitted immediately; 5 arrives first, is buffered, then 4
3157        // unblocks it.
3158        send_array(handle.array_sender(), make_test_array(5));
3159        send_array(handle.array_sender(), make_test_array(4));
3160        // 4 emitted immediately (in order), then 5 released by contiguity;
3161        // blocking_recv below is the wait.
3162        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 4);
3163        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 5);
3164    }
3165
3166    #[test]
3167    fn test_throttle_drops_output_arrays() {
3168        // G7: with a tiny MaxByteRate, output arrays exceeding the byte budget
3169        // are dropped and counted into DroppedOutputArrays.
3170        let pool = Arc::new(NDArrayPool::new(1_000_000));
3171        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3172        let mut output = NDArrayOutput::new();
3173        output.add(downstream_sender);
3174
3175        let (handle, _data_jh) = create_plugin_runtime_with_output(
3176            "THROTTLE_TEST",
3177            PassthroughProcessor,
3178            pool,
3179            10,
3180            output,
3181            "",
3182            test_wiring(),
3183        );
3184        enable_callbacks(&handle);
3185
3186        // MaxByteRate = 8 bytes/sec. Each test array is 4 bytes; the bucket
3187        // starts full at 8, so the first two pass and the rest are dropped.
3188        handle
3189            .port_runtime()
3190            .port_handle()
3191            .write_float64_blocking(handle.plugin_params.max_byte_rate, 0, 8.0)
3192            .unwrap();
3193        params_applied(&handle);
3194
3195        for id in 1..=5 {
3196            send_array(handle.array_sender(), make_test_array(id));
3197        }
3198        // ArrayCounter counts every processed frame (throttle drops happen on
3199        // the output side, after counting), and its flush follows the array
3200        // publish — so counter == 5 means everything that will ever reach the
3201        // downstream queue is already there.
3202        let port = handle.port_runtime().port_handle().clone();
3203        let counter = handle.ndarray_params.array_counter;
3204        wait_until("all 5 frames to be processed", || {
3205            port.read_int32_blocking(counter, 0).is_ok_and(|v| v == 5)
3206        });
3207
3208        // Drain whatever made it through — strictly fewer than 5.
3209        let rt = tokio::runtime::Builder::new_current_thread()
3210            .enable_all()
3211            .build()
3212            .unwrap();
3213        let mut received = 0;
3214        while rt
3215            .block_on(async {
3216                tokio::time::timeout(std::time::Duration::from_millis(20), downstream_rx.recv())
3217                    .await
3218            })
3219            .map(|o| o.is_some())
3220            .unwrap_or(false)
3221        {
3222            received += 1;
3223        }
3224        assert!(
3225            received < 5,
3226            "throttle must drop some arrays (got {received})"
3227        );
3228        assert!(received >= 1, "first array within budget must pass");
3229    }
3230
3231    #[test]
3232    fn test_process_plugin_reprocesses_last_input() {
3233        // G5: writing ProcessPlugin re-injects the cached last input array.
3234        let pool = Arc::new(NDArrayPool::new(1_000_000));
3235        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3236        let mut output = NDArrayOutput::new();
3237        output.add(downstream_sender);
3238
3239        let (handle, _data_jh) = create_plugin_runtime_with_output(
3240            "PROCESS_PLUGIN_TEST",
3241            PassthroughProcessor,
3242            pool,
3243            10,
3244            output,
3245            "",
3246            test_wiring(),
3247        );
3248        enable_callbacks(&handle);
3249
3250        send_array(handle.array_sender(), make_test_array(7));
3251        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 7);
3252
3253        // Trigger ProcessPlugin — the cached input (id=7) is reprocessed.
3254        handle
3255            .port_runtime()
3256            .port_handle()
3257            .write_int32_blocking(handle.plugin_params.process_plugin, 0, 1)
3258            .unwrap();
3259        let reprocessed = downstream_rx.blocking_recv().unwrap();
3260        assert_eq!(
3261            reprocessed.unique_id, 7,
3262            "ProcessPlugin re-emits last input"
3263        );
3264    }
3265
3266    #[test]
3267    fn test_min_callback_time_throttle_not_counted() {
3268        // A MinCallbackTime-throttled array is silently skipped, NOT counted.
3269        // C++ driverCallback (NDPluginDriver.cpp:405-450) falls through the
3270        // `deltaTime <= minCallbackTime` gate straight to callParamCallbacks()
3271        // without touching DroppedArrays — that counter is incremented ONLY on
3272        // a compression-unaware array (:388) or a full message queue (:440).
3273        // Verify (a) the throttled array is not emitted and (b) DroppedArrays
3274        // stays zero.
3275        let pool = Arc::new(NDArrayPool::new(1_000_000));
3276        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3277        let mut output = NDArrayOutput::new();
3278        output.add(downstream_sender);
3279
3280        let (handle, _data_jh) = create_plugin_runtime_with_output(
3281            "MIN_CB_TEST",
3282            PassthroughProcessor,
3283            pool,
3284            10,
3285            output,
3286            "",
3287            test_wiring(),
3288        );
3289        enable_callbacks(&handle);
3290        let dropped = handle.array_sender().dropped_arrays_counter().clone();
3291
3292        // 10s minimum between callbacks — only the first array gets through.
3293        handle
3294            .port_runtime()
3295            .port_handle()
3296            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 10.0)
3297            .unwrap();
3298        params_applied(&handle);
3299
3300        send_array(handle.array_sender(), make_test_array(1));
3301        send_array(handle.array_sender(), make_test_array(2));
3302
3303        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 1);
3304        // Fence the array queue: array 2 must have been consumed (throttled
3305        // out) before the negative checks below, or they could false-pass on
3306        // a not-yet-processed frame.
3307        params_applied(&handle);
3308        let rt = tokio::runtime::Builder::new_current_thread()
3309            .enable_all()
3310            .build()
3311            .unwrap();
3312        let second = rt.block_on(async {
3313            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
3314        });
3315        assert!(
3316            second.is_err(),
3317            "second array throttled out by MinCallbackTime"
3318        );
3319        assert_eq!(
3320            dropped.load(Ordering::Acquire),
3321            0,
3322            "a MinCallbackTime-throttled frame must NOT increment DroppedArrays"
3323        );
3324    }
3325
3326    #[test]
3327    fn test_array_callbacks_zero_withholds_downstream_delivery() {
3328        // ADC-2: NDArrayCallbacks==0 stops downstream NDArray delivery while
3329        // the plugin still processes and updates its metadata params — C++
3330        // endProcessCallbacks (NDPluginDriver.cpp:257-265) caches the array and
3331        // returns before doCallbacksGenericPointer. Distinct from
3332        // EnableCallbacks, which gates whether the plugin processes at all.
3333        let pool = Arc::new(NDArrayPool::new(1_000_000));
3334        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3335        let mut output = NDArrayOutput::new();
3336        output.add(downstream_sender);
3337
3338        let (handle, _data_jh) = create_plugin_runtime_with_output(
3339            "ARRAY_CB_TEST",
3340            PassthroughProcessor,
3341            pool,
3342            10,
3343            output,
3344            "",
3345            test_wiring(),
3346        );
3347        enable_callbacks(&handle);
3348        let port = handle.port_runtime().port_handle().clone();
3349
3350        // Disable downstream array callbacks.
3351        port.write_int32_blocking(handle.ndarray_params.array_callbacks, 0, 0)
3352            .unwrap();
3353        params_applied(&handle);
3354
3355        send_array(handle.array_sender(), make_test_array(1));
3356        // Processing fence: the counter flush follows any downstream publish,
3357        // so once it reads 1, a delivery that was going to happen already has.
3358        wait_until("frame 1 to be processed", || {
3359            port.read_int32_blocking(handle.ndarray_params.array_counter, 0)
3360                .is_ok_and(|v| v == 1)
3361        });
3362
3363        // No downstream delivery.
3364        let rt = tokio::runtime::Builder::new_current_thread()
3365            .enable_all()
3366            .build()
3367            .unwrap();
3368        let got = rt.block_on(async {
3369            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
3370        });
3371        assert!(
3372            got.is_err(),
3373            "NDArrayCallbacks=0 must withhold downstream delivery"
3374        );
3375        // But the plugin still processed: ArrayCounter advanced.
3376        assert_eq!(
3377            port.read_int32_blocking(handle.ndarray_params.array_counter, 0)
3378                .unwrap(),
3379            1,
3380            "processing (and metadata params) must continue while delivery is off"
3381        );
3382
3383        // Re-enable: the next array IS delivered downstream.
3384        port.write_int32_blocking(handle.ndarray_params.array_callbacks, 0, 1)
3385            .unwrap();
3386        params_applied(&handle);
3387        send_array(handle.array_sender(), make_test_array(2));
3388        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 2);
3389    }
3390
3391    #[test]
3392    fn test_plugin_output_publishes_compressed_size() {
3393        // ADC-3: every processed array publishes NDCodec / NDCompressedSize
3394        // (C++ beginProcessCallbacks NDPluginDriver.cpp:213-214). An
3395        // uncompressed output carries an empty codec name and compressedSize ==
3396        // raw bytes; a compressed output carries the codec name and its
3397        // compressed size. CompressedSize_RBV (Int32) exercises both arms.
3398        struct CompressProcessor;
3399        impl NDPluginProcess for CompressProcessor {
3400            fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
3401                let mut out = array.clone();
3402                out.codec = Some(crate::codec::Codec {
3403                    name: crate::codec::CodecName::JPEG,
3404                    compressed_size: 7,
3405                    level: 0,
3406                    shuffle: 0,
3407                    compressor: 0,
3408                    original_data_type: NDDataType::UInt8,
3409                });
3410                ProcessResult::arrays(vec![Arc::new(out)])
3411            }
3412            fn plugin_type(&self) -> &str {
3413                "Compress"
3414            }
3415        }
3416
3417        // Uncompressed passthrough: a 4-byte UInt8 array → compressedSize == 4.
3418        {
3419            let pool = Arc::new(NDArrayPool::new(1_000_000));
3420            let (ds, _rx) = ndarray_channel("DS_RAW", 10);
3421            let mut output = NDArrayOutput::new();
3422            output.add(ds);
3423            let (handle, _jh) = create_plugin_runtime_with_output(
3424                "CODEC_RAW",
3425                PassthroughProcessor,
3426                pool,
3427                10,
3428                output,
3429                "",
3430                test_wiring(),
3431            );
3432            enable_callbacks(&handle);
3433            let port = handle.port_runtime().port_handle().clone();
3434            send_array(handle.array_sender(), make_test_array(1));
3435            // The read errors with ParamUndefined until the first flush — treat
3436            // that as "not yet".
3437            wait_until(
3438                "uncompressed output to publish CompressedSize == raw byte count",
3439                || {
3440                    port.read_int32_blocking(handle.ndarray_params.compressed_size, 0)
3441                        .is_ok_and(|v| v == 4)
3442                },
3443            );
3444        }
3445
3446        // Compressed output: compressedSize == codec.compressed_size (7).
3447        {
3448            let pool = Arc::new(NDArrayPool::new(1_000_000));
3449            let (ds, _rx) = ndarray_channel("DS_CMP", 10);
3450            let mut output = NDArrayOutput::new();
3451            output.add(ds);
3452            let (handle, _jh) = create_plugin_runtime_with_output(
3453                "CODEC_CMP",
3454                CompressProcessor,
3455                pool,
3456                10,
3457                output,
3458                "",
3459                test_wiring(),
3460            );
3461            enable_callbacks(&handle);
3462            let port = handle.port_runtime().port_handle().clone();
3463            send_array(handle.array_sender(), make_test_array(1));
3464            wait_until(
3465                "compressed output to publish CompressedSize == codec.compressed_size",
3466                || {
3467                    port.read_int32_blocking(handle.ndarray_params.compressed_size, 0)
3468                        .is_ok_and(|v| v == 7)
3469                },
3470            );
3471        }
3472    }
3473
3474    #[test]
3475    fn test_process_plugin_skips_throttled_input() {
3476        // a MinCallbackTime-throttled frame must NOT be cached as the
3477        // ProcessPlugin input. After array 1 is processed and array 2 is
3478        // dropped by the throttle, ProcessPlugin must re-inject array 1
3479        // (the last *processed* array), not the dropped array 2.
3480        let pool = Arc::new(NDArrayPool::new(1_000_000));
3481        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3482        let mut output = NDArrayOutput::new();
3483        output.add(downstream_sender);
3484
3485        let (handle, _data_jh) = create_plugin_runtime_with_output(
3486            "PROCESS_THROTTLE_TEST",
3487            PassthroughProcessor,
3488            pool,
3489            10,
3490            output,
3491            "",
3492            test_wiring(),
3493        );
3494        enable_callbacks(&handle);
3495
3496        // 10s minimum between callbacks — only the first array is processed.
3497        handle
3498            .port_runtime()
3499            .port_handle()
3500            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 10.0)
3501            .unwrap();
3502        params_applied(&handle);
3503
3504        send_array(handle.array_sender(), make_test_array(1));
3505        send_array(handle.array_sender(), make_test_array(2));
3506
3507        // Array 1 was processed and emitted; array 2 was throttled out.
3508        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 1);
3509        // Fence the ARRAY queue: array 2 must be consumed (and throttled out)
3510        // under the 10s gate before the reset below un-gates it. The barrier
3511        // acks only once the array queue has drained.
3512        params_applied(&handle);
3513
3514        // ProcessPlugin re-injects the cached input. The cache must still hold
3515        // array 1, because array 2 never passed the throttle gate. The
3516        // re-injected array itself is also subject to the throttle, so reset
3517        // MinCallbackTime to 0 first so the re-injected frame is processed.
3518        handle
3519            .port_runtime()
3520            .port_handle()
3521            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 0.0)
3522            .unwrap();
3523        // No fence needed: the MinCallbackTime reset and the ProcessPlugin
3524        // trigger below travel the same FIFO param channel, so the reset is
3525        // applied before the trigger by construction.
3526        handle
3527            .port_runtime()
3528            .port_handle()
3529            .write_int32_blocking(handle.plugin_params.process_plugin, 0, 1)
3530            .unwrap();
3531        let reprocessed = downstream_rx.blocking_recv().unwrap();
3532        assert_eq!(
3533            reprocessed.unique_id, 1,
3534            "ProcessPlugin must re-inject the last processed array (1), not the throttled array (2)"
3535        );
3536    }
3537
3538    #[test]
3539    fn test_g3_compressed_array_dropped_on_non_aware_plugin() {
3540        // G3: a non-compression-aware plugin drops a compressed array.
3541        let pool = Arc::new(NDArrayPool::new(1_000_000));
3542        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3543        let mut output = NDArrayOutput::new();
3544        output.add(downstream_sender);
3545
3546        let (handle, _data_jh) = create_plugin_runtime_with_output(
3547            "G3_TEST",
3548            PassthroughProcessor, // compression_aware() defaults to false
3549            pool,
3550            10,
3551            output,
3552            "",
3553            test_wiring(),
3554        );
3555        enable_callbacks(&handle);
3556
3557        // A compressed array must be dropped, not forwarded.
3558        let mut compressed = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
3559        compressed.unique_id = 1;
3560        compressed.codec = Some(crate::codec::Codec {
3561            name: crate::codec::CodecName::JPEG,
3562            compressed_size: 16,
3563            level: 0,
3564            shuffle: 0,
3565            compressor: 0,
3566            original_data_type: NDDataType::UInt8,
3567        });
3568        send_array(handle.array_sender(), Arc::new(compressed));
3569
3570        // An uncompressed array passes through normally.
3571        send_array(handle.array_sender(), make_test_array(2));
3572
3573        let r = downstream_rx.blocking_recv().unwrap();
3574        assert_eq!(
3575            r.unique_id, 2,
3576            "compressed array dropped; only the raw array reaches downstream"
3577        );
3578    }
3579
3580    #[test]
3581    fn test_drop_on_full_increments_dropped_counter() {
3582        // B1/G1: a slow downstream plugin with a tiny input queue drops arrays
3583        // when the queue is full; the drop is counted in the plugin's shared
3584        // DroppedArrays counter rather than back-pressuring the producer.
3585        struct SlowProcessor;
3586        impl NDPluginProcess for SlowProcessor {
3587            fn process_array(&mut self, _a: &NDArray, _p: &NDArrayPool) -> ProcessResult {
3588                std::thread::sleep(std::time::Duration::from_millis(200));
3589                ProcessResult::empty()
3590            }
3591            fn plugin_type(&self) -> &str {
3592                "Slow"
3593            }
3594        }
3595        let pool = Arc::new(NDArrayPool::new(1_000_000));
3596
3597        // Downstream plugin with queue size 1 and a slow processor.
3598        let (downstream_handle, _ds_jh) =
3599            create_plugin_runtime("B1_DOWNSTREAM", SlowProcessor, pool, 1, "", test_wiring());
3600        enable_callbacks(&downstream_handle);
3601        let ds_sender = downstream_handle.array_sender().clone();
3602        let dropped = ds_sender.dropped_arrays_counter().clone();
3603
3604        // First array is taken by the data loop (now sleeping 200ms); second
3605        // fills the 1-slot queue; the rest find a full queue → dropped.
3606        send_array(&ds_sender, make_test_array(1));
3607        send_array(&ds_sender, make_test_array(2));
3608        send_array(&ds_sender, make_test_array(3));
3609        send_array(&ds_sender, make_test_array(4));
3610
3611        assert!(
3612            dropped.load(Ordering::Acquire) >= 1,
3613            "arrays dropped on a full queue must be counted (got {})",
3614            dropped.load(Ordering::Acquire)
3615        );
3616    }
3617
3618    #[test]
3619    fn test_cross_width_narrowing_array_read_truncates() {
3620        // Cross-width integer narrowing array reads must TRUNCATE (wrapping),
3621        // matching the C cast in C++ NDArrayPool.cpp:388 `convertType`
3622        //   *pDataOut++ = (dataTypeOut)(*pDataIn++);
3623        // A C cast `(epicsInt8)(epicsUInt16)300` keeps the low 8 bits == 44.
3624        // The f64 round-trip in copy_convert would SATURATE (`300.0 as i8`
3625        // == 127) and diverge from C++ — copy_ccast must be used instead.
3626
3627        // U16 -> i8: 300 = 0x012C; low byte 0x2C = 44.
3628        let mut out = [0i8; 1];
3629        let n = copy_ccast(&[300u16], &mut out);
3630        assert_eq!(n, 1);
3631        assert_eq!(out[0], 44, "(epicsInt8)(epicsUInt16)300 == 44 (low 8 bits)");
3632        // copy_convert would have saturated:
3633        let mut sat = [0i8; 1];
3634        copy_convert(&[300u16], &mut sat);
3635        assert_eq!(sat[0], 127, "f64 round-trip saturates — the wrong behavior");
3636
3637        // I32 -> i8: 0x1234_5678 -> low byte 0x78 = 120.
3638        let mut out2 = [0i8; 1];
3639        copy_ccast(&[0x1234_5678i32], &mut out2);
3640        assert_eq!(out2[0], 0x78);
3641
3642        // I32 -> i8: -1 stays -1 (all-ones low byte).
3643        let mut out3 = [0i8; 1];
3644        copy_ccast(&[-1i32], &mut out3);
3645        assert_eq!(out3[0], -1);
3646
3647        // U16 -> i8: 0x00FF = 255 -> low byte 0xFF reinterpreted as i8 == -1.
3648        let mut out4 = [0i8; 1];
3649        copy_ccast(&[255u16], &mut out4);
3650        assert_eq!(out4[0], -1);
3651
3652        // I64 -> i32: 0x0000_0001_0000_002A -> low 32 bits == 42.
3653        let mut out5 = [0i32; 1];
3654        copy_ccast(&[0x0000_0001_0000_002Ai64], &mut out5);
3655        assert_eq!(out5[0], 42);
3656
3657        // U32 -> i16: 70000 = 0x0001_1170 -> low 16 bits 0x1170 == 4464.
3658        let mut out6 = [0i16; 1];
3659        copy_ccast(&[70000u32], &mut out6);
3660        assert_eq!(out6[0], 4464);
3661
3662        // Same-width sign change still works as a bitwise reinterpret:
3663        // U8 255 -> i8 -1.
3664        let mut out7 = [0i8; 1];
3665        copy_ccast(&[255u8], &mut out7);
3666        assert_eq!(out7[0], -1);
3667
3668        // F64 out-of-range -> i32 still routes through copy_convert (the
3669        // `convert:` arm for float sources). C++ converts float->int with a
3670        // C cast too, but the runtime keeps the f64 numeric path for float
3671        // sources; this asserts the integer-narrowing fix did not change the
3672        // float-source path.
3673        let mut fout = [0i32; 1];
3674        copy_convert(&[42.9f64], &mut fout);
3675        assert_eq!(fout[0], 42, "f64 -> i32 truncates toward zero");
3676    }
3677
3678    // ---- ADP-45: scatter overflow-reroute (C++ NDPluginScatter) ----
3679
3680    /// Run an async body on a throwaway current-thread runtime.
3681    fn block<F: std::future::Future>(f: F) -> F::Output {
3682        tokio::runtime::Builder::new_current_thread()
3683            .enable_all()
3684            .build()
3685            .unwrap()
3686            .block_on(f)
3687    }
3688
3689    #[test]
3690    fn test_scatter_reroutes_past_full_consumer() {
3691        // 3 consumers, queue size 1. Pre-fill A so its queue is full; a scatter
3692        // that would target A must reroute to B (C++ auxStatus=asynOverflow),
3693        // and the rerouted-away full queue must NOT count a dropped array
3694        // (driverCallback ignoreQueueFull, NDPluginDriver.cpp:406,433-442).
3695        let (sa, mut ra) = ndarray_channel("A", 1);
3696        let (sb, mut rb) = ndarray_channel("B", 1);
3697        let (sc, _rc) = ndarray_channel("C", 1);
3698        block(async {
3699            assert_eq!(
3700                sa.publish(make_test_array(99)).await,
3701                PublishOutcome::Delivered
3702            );
3703            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
3704            let mut cursor = 0usize;
3705            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
3706            // A rerouted (attempt 0), B delivered (attempt 1): cursor +2.
3707            assert_eq!(cursor, 2);
3708            assert_eq!(rb.recv().await.unwrap().unique_id, 1);
3709            // A still holds only its filler; the rerouted-away drop was not counted.
3710            assert_eq!(ra.recv().await.unwrap().unique_id, 99);
3711            assert_eq!(sa.dropped_arrays_counter().load(Ordering::Acquire), 0);
3712        });
3713    }
3714
3715    #[test]
3716    fn test_scatter_drops_on_last_when_all_full_counts_once() {
3717        // Both consumers full. The array is dropped on the last node only, and
3718        // the drop is counted exactly once (C++ sets auxStatus=asynSuccess for
3719        // the last node so its full queue drops and counts).
3720        let (sa, mut ra) = ndarray_channel("A", 1);
3721        let (sb, mut rb) = ndarray_channel("B", 1);
3722        block(async {
3723            sa.publish(make_test_array(91)).await;
3724            sb.publish(make_test_array(92)).await;
3725            let senders = vec![sa.clone(), sb.clone()];
3726            let mut cursor = 0usize;
3727            ProcessOutput::scatter_publish(&make_test_array(7), &senders, &mut cursor).await;
3728            assert_eq!(cursor, 2); // both attempted
3729            // A rerouted-away (not counted); B last (dropped, counted once).
3730            assert_eq!(sa.dropped_arrays_counter().load(Ordering::Acquire), 0);
3731            assert_eq!(sb.dropped_arrays_counter().load(Ordering::Acquire), 1);
3732            // Neither queue received frame 7 — both still hold their fillers.
3733            assert_eq!(ra.recv().await.unwrap().unique_id, 91);
3734            assert_eq!(rb.recv().await.unwrap().unique_id, 92);
3735        });
3736    }
3737
3738    #[test]
3739    fn test_scatter_cursor_advances_per_attempt_across_frames() {
3740        // A is permanently full; B and C are free. Frame 0 reroutes A->B, so
3741        // the persistent cursor (C++ nextClient_) ends past B. Frame 1 must
3742        // therefore start at C, NOT back at B: a per-frame cursor would send
3743        // frame 1 to B; the per-attempt cursor sends it to C.
3744        let (sa, _ra) = ndarray_channel("A", 1);
3745        let (sb, mut rb) = ndarray_channel("B", 10);
3746        let (sc, mut rc) = ndarray_channel("C", 10);
3747        block(async {
3748            sa.publish(make_test_array(90)).await; // fill A permanently
3749            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
3750            let mut cursor = 0usize;
3751            ProcessOutput::scatter_publish(&make_test_array(0), &senders, &mut cursor).await;
3752            assert_eq!(cursor, 2); // A(reroute) + B(deliver)
3753            assert_eq!(rb.recv().await.unwrap().unique_id, 0);
3754            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
3755            assert_eq!(cursor, 3); // C(deliver) on the first attempt
3756            assert_eq!(rc.recv().await.unwrap().unique_id, 1);
3757        });
3758    }
3759
3760    #[test]
3761    fn test_scatter_skips_disabled_consumer() {
3762        // A disabled downstream is unregistered from the interrupt list in C++
3763        // (setArrayInterrupt(0)) and must not consume a round-robin slot.
3764        let (sa, mut ra) = ndarray_channel("A", 10);
3765        let (mut sb, _rb) = ndarray_channel("B", 10);
3766        let (sc, mut rc) = ndarray_channel("C", 10);
3767        sb.set_mode_flags(
3768            Arc::new(AtomicBool::new(false)),
3769            Arc::new(AtomicBool::new(false)),
3770        );
3771        block(async {
3772            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
3773            let mut cursor = 0usize;
3774            // Active set = [A, C] (n=2): frame 0 -> A, frame 1 -> C.
3775            ProcessOutput::scatter_publish(&make_test_array(0), &senders, &mut cursor).await;
3776            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
3777            assert_eq!(ra.recv().await.unwrap().unique_id, 0);
3778            assert_eq!(rc.recv().await.unwrap().unique_id, 1);
3779        });
3780    }
3781}