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/// PortDriver implementation for a plugin's control plane.
1173#[allow(dead_code)]
1174pub struct PluginPortDriver {
1175    base: PortDriverBase,
1176    ndarray_params: NDArrayDriverParams,
1177    plugin_params: PluginBaseParams,
1178    param_change_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1179    /// Optional handle to the latest NDArray for array read methods (used by StdArrays).
1180    array_data: Option<Arc<parking_lot::Mutex<Option<Arc<NDArray>>>>>,
1181    /// Param index for STD_ARRAY_DATA (triggers I/O Intr on ArrayData waveform).
1182    std_array_data_param: Option<usize>,
1183}
1184
1185impl PluginPortDriver {
1186    fn new<P: NDPluginProcess>(
1187        port_name: &str,
1188        plugin_type_name: &str,
1189        queue_size: usize,
1190        ndarray_port: &str,
1191        max_addr: usize,
1192        param_change_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1193        processor: &mut P,
1194        array_data: Option<Arc<parking_lot::Mutex<Option<Arc<NDArray>>>>>,
1195    ) -> AsynResult<Self> {
1196        let mut base = PortDriverBase::new(
1197            port_name,
1198            max_addr,
1199            PortFlags {
1200                can_block: true,
1201                ..Default::default()
1202            },
1203        );
1204
1205        let ndarray_params = NDArrayDriverParams::create(&mut base)?;
1206        let plugin_params = PluginBaseParams::create(&mut base)?;
1207
1208        // Set defaults (EnableCallbacks=0: Disable by default, matching EPICS ADCore)
1209        base.set_int32_param(plugin_params.enable_callbacks, 0, 0)?;
1210        base.set_int32_param(plugin_params.blocking_callbacks, 0, 0)?;
1211        base.set_int32_param(plugin_params.queue_size, 0, queue_size as i32)?;
1212        base.set_int32_param(plugin_params.dropped_arrays, 0, 0)?;
1213        base.set_int32_param(plugin_params.queue_use, 0, 0)?;
1214        base.set_string_param(plugin_params.plugin_type, 0, plugin_type_name.into())?;
1215        base.set_int32_param(
1216            ndarray_params.array_callbacks,
1217            0,
1218            processor.does_array_callbacks() as i32,
1219        )?;
1220        base.set_int32_param(ndarray_params.write_file, 0, 0)?;
1221        base.set_int32_param(ndarray_params.read_file, 0, 0)?;
1222        base.set_int32_param(ndarray_params.capture, 0, 0)?;
1223        base.set_int32_param(ndarray_params.file_write_status, 0, 0)?;
1224        base.set_string_param(ndarray_params.file_write_message, 0, "".into())?;
1225        base.set_string_param(ndarray_params.file_path, 0, "".into())?;
1226        base.set_string_param(ndarray_params.file_name, 0, "".into())?;
1227        base.set_int32_param(ndarray_params.file_number, 0, 0)?;
1228        base.set_int32_param(ndarray_params.auto_increment, 0, 0)?;
1229        base.set_string_param(ndarray_params.file_template, 0, "%s%s_%3.3d.dat".into())?;
1230        base.set_string_param(ndarray_params.full_file_name, 0, "".into())?;
1231        base.set_int32_param(ndarray_params.create_dir, 0, 0)?;
1232        base.set_string_param(ndarray_params.temp_suffix, 0, "".into())?;
1233
1234        // Set plugin identity params
1235        base.set_string_param(ndarray_params.port_name_self, 0, port_name.into())?;
1236        base.set_string_param(
1237            ndarray_params.ad_core_version,
1238            0,
1239            env!("CARGO_PKG_VERSION").into(),
1240        )?;
1241        base.set_string_param(
1242            ndarray_params.driver_version,
1243            0,
1244            env!("CARGO_PKG_VERSION").into(),
1245        )?;
1246        if !ndarray_port.is_empty() {
1247            base.set_string_param(plugin_params.nd_array_port, 0, ndarray_port.into())?;
1248        }
1249
1250        // Create STD_ARRAY_DATA param for StdArrays plugins (triggers I/O Intr on ArrayData waveform)
1251        let std_array_data_param = if array_data.is_some() {
1252            Some(base.create_param("STD_ARRAY_DATA", asyn_rs::param::ParamType::GenericPointer)?)
1253        } else {
1254            None
1255        };
1256
1257        // Let the processor register its plugin-specific params
1258        processor.register_params(&mut base)?;
1259
1260        Ok(Self {
1261            base,
1262            ndarray_params,
1263            plugin_params,
1264            param_change_tx,
1265            array_data,
1266            std_array_data_param,
1267        })
1268    }
1269}
1270
1271/// Copy source slice directly into destination buffer, returning elements copied.
1272fn copy_direct<T: Copy>(src: &[T], dst: &mut [T]) -> usize {
1273    let n = src.len().min(dst.len());
1274    dst[..n].copy_from_slice(&src[..n]);
1275    n
1276}
1277
1278/// Convert and copy source slice into destination buffer element-by-element.
1279fn copy_convert<S, D>(src: &[S], dst: &mut [D]) -> usize
1280where
1281    S: CastToF64 + Copy,
1282    D: CastFromF64 + Copy,
1283{
1284    let n = src.len().min(dst.len());
1285    for i in 0..n {
1286        dst[i] = D::cast_from_f64(src[i].cast_to_f64());
1287    }
1288    n
1289}
1290
1291/// Cast an integer source element to an integer destination element with C
1292/// cast semantics. C++ `NDArrayPool::convert` (`NDArrayPool.cpp:388`,
1293/// `convertType`: `*pDataOut++ = (dataTypeOut)(*pDataIn++)`; and `:466`,
1294/// `convertDim`) performs a plain C cast between integer types. A C cast:
1295///   - same-width sign change is a bitwise reinterpret
1296///     (`(epicsInt8)(epicsUInt8)255 == -1`);
1297///   - narrowing truncates to the low bits, wrapping
1298///     (`(epicsInt8)(epicsUInt16)300 == 44`);
1299///   - widening sign/zero-extends exactly.
1300///
1301/// Rust's `as` between integer types implements exactly these semantics. The
1302/// f64 round-trip in [`copy_convert`] does NOT: it saturates on narrowing
1303/// (`300.0 as i8 == 127`), diverging from C++. So every integer-source ->
1304/// integer-target NDArray array read must go through this C-cast path, not
1305/// `copy_convert`.
1306trait CCastTo<D> {
1307    fn ccast(self) -> D;
1308}
1309macro_rules! impl_ccast {
1310    ( $src:ty => $( $dst:ty ),+ ) => {
1311        $(
1312            impl CCastTo<$dst> for $src {
1313                #[inline]
1314                fn ccast(self) -> $dst {
1315                    self as $dst
1316                }
1317            }
1318        )+
1319    };
1320}
1321impl_ccast!(i8 => i16, i32, i64);
1322impl_ccast!(u8 => i8, i16, i32, i64);
1323impl_ccast!(i16 => i8, i32, i64);
1324impl_ccast!(u16 => i8, i16, i32, i64);
1325impl_ccast!(i32 => i8, i16, i64);
1326impl_ccast!(u32 => i8, i16, i32, i64);
1327impl_ccast!(i64 => i8, i16, i32);
1328impl_ccast!(u64 => i8, i16, i32, i64);
1329
1330/// Copy an integer source slice into an integer destination buffer using C
1331/// cast semantics (see [`CCastTo`]) — truncating on narrowing, never
1332/// saturating.
1333fn copy_ccast<S, D>(src: &[S], dst: &mut [D]) -> usize
1334where
1335    S: CCastTo<D> + Copy,
1336    D: Copy,
1337{
1338    let n = src.len().min(dst.len());
1339    for i in 0..n {
1340        dst[i] = src[i].ccast();
1341    }
1342    n
1343}
1344
1345/// Helper trait for `as f64` casts (handles lossy conversions like i64/u64).
1346trait CastToF64 {
1347    fn cast_to_f64(self) -> f64;
1348}
1349
1350impl CastToF64 for i8 {
1351    fn cast_to_f64(self) -> f64 {
1352        self as f64
1353    }
1354}
1355impl CastToF64 for u8 {
1356    fn cast_to_f64(self) -> f64 {
1357        self as f64
1358    }
1359}
1360impl CastToF64 for i16 {
1361    fn cast_to_f64(self) -> f64 {
1362        self as f64
1363    }
1364}
1365impl CastToF64 for u16 {
1366    fn cast_to_f64(self) -> f64 {
1367        self as f64
1368    }
1369}
1370impl CastToF64 for i32 {
1371    fn cast_to_f64(self) -> f64 {
1372        self as f64
1373    }
1374}
1375impl CastToF64 for u32 {
1376    fn cast_to_f64(self) -> f64 {
1377        self as f64
1378    }
1379}
1380impl CastToF64 for i64 {
1381    fn cast_to_f64(self) -> f64 {
1382        self as f64
1383    }
1384}
1385impl CastToF64 for u64 {
1386    fn cast_to_f64(self) -> f64 {
1387        self as f64
1388    }
1389}
1390impl CastToF64 for f32 {
1391    fn cast_to_f64(self) -> f64 {
1392        self as f64
1393    }
1394}
1395impl CastToF64 for f64 {
1396    fn cast_to_f64(self) -> f64 {
1397        self
1398    }
1399}
1400
1401/// Helper trait for `as` casts from f64.
1402trait CastFromF64 {
1403    fn cast_from_f64(v: f64) -> Self;
1404}
1405
1406impl CastFromF64 for i8 {
1407    fn cast_from_f64(v: f64) -> Self {
1408        v as i8
1409    }
1410}
1411impl CastFromF64 for i16 {
1412    fn cast_from_f64(v: f64) -> Self {
1413        v as i16
1414    }
1415}
1416impl CastFromF64 for i32 {
1417    fn cast_from_f64(v: f64) -> Self {
1418        v as i32
1419    }
1420}
1421impl CastFromF64 for i64 {
1422    fn cast_from_f64(v: f64) -> Self {
1423        v as i64
1424    }
1425}
1426impl CastFromF64 for f32 {
1427    fn cast_from_f64(v: f64) -> Self {
1428        v as f32
1429    }
1430}
1431impl CastFromF64 for f64 {
1432    fn cast_from_f64(v: f64) -> Self {
1433        v
1434    }
1435}
1436
1437/// Copy NDArray data into the output buffer with type conversion.
1438/// Returns the number of elements copied, or 0 if no data is available.
1439macro_rules! impl_read_array {
1440    (
1441        $self:expr, $buf:expr, $direct_variant:ident,
1442        ccast: [ $( $ccast_variant:ident ),* ],
1443        convert: [ $( $variant:ident ),* ]
1444    ) => {{
1445        use crate::ndarray::NDDataBuffer;
1446        let handle = match &$self.array_data {
1447            Some(h) => h,
1448            None => return Ok(0),
1449        };
1450        let guard = handle.lock();
1451        let array = match &*guard {
1452            Some(a) => a,
1453            None => return Ok(0),
1454        };
1455        let n = match &array.data {
1456            NDDataBuffer::$direct_variant(v) => copy_direct(v, $buf),
1457            $( NDDataBuffer::$ccast_variant(v) => copy_ccast(v, $buf), )*
1458            $( NDDataBuffer::$variant(v) => copy_convert(v, $buf), )*
1459        };
1460        Ok(n)
1461    }};
1462}
1463
1464impl PortDriver for PluginPortDriver {
1465    fn base(&self) -> &PortDriverBase {
1466        &self.base
1467    }
1468
1469    fn base_mut(&mut self) -> &mut PortDriverBase {
1470        &mut self.base
1471    }
1472
1473    fn io_write_int32(&mut self, user: &mut AsynUser, value: i32) -> AsynResult<()> {
1474        let reason = user.reason;
1475        let addr = user.addr;
1476        self.base.set_int32_param(reason, addr, value)?;
1477        self.base.call_param_callbacks(addr)?;
1478        // B14: reliable send on an unbounded channel — never drop param changes.
1479        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1480            reason,
1481            addr,
1482            ParamChangeValue::Int32(value),
1483        ));
1484        Ok(())
1485    }
1486
1487    fn io_write_float64(&mut self, user: &mut AsynUser, value: f64) -> AsynResult<()> {
1488        let reason = user.reason;
1489        let addr = user.addr;
1490        self.base.set_float64_param(reason, addr, value)?;
1491        self.base.call_param_callbacks(addr)?;
1492        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1493            reason,
1494            addr,
1495            ParamChangeValue::Float64(value),
1496        ));
1497        Ok(())
1498    }
1499
1500    fn io_write_octet(&mut self, user: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
1501        let reason = user.reason;
1502        let addr = user.addr;
1503        let s = String::from_utf8_lossy(data).into_owned();
1504        self.base.set_string_param(reason, addr, s.clone())?;
1505        self.base.call_param_callbacks(addr)?;
1506        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1507            reason,
1508            addr,
1509            ParamChangeValue::Octet(s),
1510        ));
1511        Ok(data.len())
1512    }
1513
1514    fn read_int8_array(&mut self, _user: &AsynUser, buf: &mut [i8]) -> AsynResult<usize> {
1515        // Every integer source -> i8 is a C cast (truncating, per C++
1516        // NDArrayPool.cpp:388); float sources keep the numeric f64 conversion.
1517        impl_read_array!(
1518            self, buf, I8,
1519            ccast: [U8, I16, U16, I32, U32, I64, U64],
1520            convert: [F32, F64]
1521        )
1522    }
1523
1524    fn read_int16_array(&mut self, _user: &AsynUser, buf: &mut [i16]) -> AsynResult<usize> {
1525        impl_read_array!(
1526            self, buf, I16,
1527            ccast: [I8, U8, U16, I32, U32, I64, U64],
1528            convert: [F32, F64]
1529        )
1530    }
1531
1532    fn read_int32_array(&mut self, _user: &AsynUser, buf: &mut [i32]) -> AsynResult<usize> {
1533        impl_read_array!(
1534            self, buf, I32,
1535            ccast: [I8, U8, I16, U16, U32, I64, U64],
1536            convert: [F32, F64]
1537        )
1538    }
1539
1540    fn read_int64_array(&mut self, _user: &AsynUser, buf: &mut [i64]) -> AsynResult<usize> {
1541        impl_read_array!(
1542            self, buf, I64,
1543            ccast: [I8, U8, I16, U16, I32, U32, U64],
1544            convert: [F32, F64]
1545        )
1546    }
1547
1548    fn read_float32_array(&mut self, _user: &AsynUser, buf: &mut [f32]) -> AsynResult<usize> {
1549        impl_read_array!(
1550            self, buf, F32,
1551            ccast: [],
1552            convert: [I8, U8, I16, U16, I32, U32, I64, U64, F64]
1553        )
1554    }
1555
1556    fn read_float64_array(&mut self, _user: &AsynUser, buf: &mut [f64]) -> AsynResult<usize> {
1557        impl_read_array!(
1558            self, buf, F64,
1559            ccast: [],
1560            convert: [I8, U8, I16, U16, I32, U32, I64, U64, F32]
1561        )
1562    }
1563}
1564
1565/// Handle to a running plugin runtime. Provides access to sender and port handle.
1566#[derive(Clone)]
1567pub struct PluginRuntimeHandle {
1568    port_runtime: PortRuntimeHandle,
1569    array_sender: NDArraySender,
1570    array_output: Arc<parking_lot::Mutex<NDArrayOutput>>,
1571    port_name: String,
1572    param_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1573    pub ndarray_params: NDArrayDriverParams,
1574    pub plugin_params: PluginBaseParams,
1575}
1576
1577impl PluginRuntimeHandle {
1578    pub fn port_runtime(&self) -> &PortRuntimeHandle {
1579        &self.port_runtime
1580    }
1581
1582    pub fn array_sender(&self) -> &NDArraySender {
1583        &self.array_sender
1584    }
1585
1586    pub fn array_output(&self) -> &Arc<parking_lot::Mutex<NDArrayOutput>> {
1587        &self.array_output
1588    }
1589
1590    /// Block until the plugin's data thread has applied every control-plane
1591    /// param change submitted before this call.
1592    ///
1593    /// `write_*_blocking` on the port handle returns once the port actor has
1594    /// recorded the write and queued it for the data plane; the data thread
1595    /// applies it (the EnableCallbacks flip, NDArrayPort/NDArrayAddr rewiring,
1596    /// processor param updates) asynchronously. This is the fence between the
1597    /// two planes: it enqueues a barrier behind every already-queued change
1598    /// and waits for the data thread to acknowledge it — the param channel is
1599    /// FIFO, so the ack implies every earlier change is fully applied.
1600    ///
1601    /// The ack additionally waits for the array queue to drain, so it also
1602    /// implies every array published before this call has been fully handled
1603    /// (processed or throttled). Under continuous array traffic the ack is
1604    /// therefore delayed until the queue momentarily empties.
1605    ///
1606    /// Returns `false` if the data thread has exited or `timeout` elapsed.
1607    pub fn wait_params_applied(&self, timeout: std::time::Duration) -> bool {
1608        let (ack_tx, ack_rx) = std::sync::mpsc::sync_channel(1);
1609        if self.param_tx.send(PluginParamMsg::Barrier(ack_tx)).is_err() {
1610            return false;
1611        }
1612        ack_rx.recv_timeout(timeout).is_ok()
1613    }
1614
1615    pub fn port_name(&self) -> &str {
1616        &self.port_name
1617    }
1618}
1619
1620/// Create a plugin runtime with control plane (PortActor) and data plane (processing thread).
1621///
1622/// Returns:
1623/// - `PluginRuntimeHandle` for wiring and control
1624/// - `PortRuntimeHandle` for param I/O
1625/// - `JoinHandle` for the data processing thread
1626pub fn create_plugin_runtime<P: NDPluginProcess>(
1627    port_name: &str,
1628    processor: P,
1629    pool: Arc<NDArrayPool>,
1630    queue_size: usize,
1631    ndarray_port: &str,
1632    wiring: Arc<WiringRegistry>,
1633) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
1634    create_plugin_runtime_multi_addr(
1635        port_name,
1636        processor,
1637        pool,
1638        queue_size,
1639        ndarray_port,
1640        wiring,
1641        1,
1642    )
1643}
1644
1645/// Create a plugin runtime with multi-addr support.
1646///
1647/// `max_addr` specifies the number of addresses (sub-devices) the port supports.
1648pub fn create_plugin_runtime_multi_addr<P: NDPluginProcess>(
1649    port_name: &str,
1650    mut processor: P,
1651    pool: Arc<NDArrayPool>,
1652    queue_size: usize,
1653    ndarray_port: &str,
1654    wiring: Arc<WiringRegistry>,
1655    max_addr: usize,
1656) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
1657    // Param change channel (control plane -> data plane)
1658    // B14: unbounded so control-plane param changes (e.g. autosave restoring
1659    // hundreds of PVs at IOC init) are never silently dropped before the
1660    // data plane sees them.
1661    let (param_tx, param_rx) = tokio::sync::mpsc::unbounded_channel::<PluginParamMsg>();
1662    let handle_param_tx = param_tx.clone();
1663
1664    // Capture plugin type and array data handle before mutable borrow
1665    let plugin_type_name = processor.plugin_type().to_string();
1666    let compression_aware = processor.compression_aware();
1667    let does_array_callbacks = processor.does_array_callbacks();
1668    let array_data = processor.array_data_handle();
1669
1670    // Create the port driver for control plane
1671    let driver = PluginPortDriver::new(
1672        port_name,
1673        &plugin_type_name,
1674        queue_size,
1675        ndarray_port,
1676        max_addr,
1677        param_tx,
1678        &mut processor,
1679        array_data,
1680    )
1681    .expect("failed to create plugin port driver");
1682
1683    let ndarray_params = driver.ndarray_params;
1684    let plugin_params = driver.plugin_params;
1685    let std_array_data_param = driver.std_array_data_param;
1686
1687    // Create port runtime (actor thread for param I/O).
1688    //
1689    // Constructor-shaped, so a failure here is fatal and cannot be anything
1690    // else: this function hands back the built plugin and has no error channel
1691    // to its `*Configure` caller. C's equivalent — `asynPortDriver`'s
1692    // constructor printing and `throw`ing on a failed `registerPort`
1693    // (asynPortDriver.cpp:4036-4040) — is caught by iocsh
1694    // (iocsh.cpp:1274-1284) and the script continues by default
1695    // (iocsh.cpp:1001, :1129), leaving the C IOC serving without the port. We
1696    // deviate on purpose: see `port_runtime_unavailable`.
1697    let (port_runtime, _actor_jh) = create_port_runtime(driver, RuntimeConfig::default())
1698        .unwrap_or_else(|e| port_runtime_unavailable(port_name, &e));
1699
1700    // Clone port handle for the data thread to write params back
1701    let port_handle = port_runtime.port_handle().clone();
1702
1703    // Array channel (data plane)
1704    let (array_sender, array_rx) = ndarray_channel(port_name, queue_size);
1705
1706    // Shared mode flags
1707    let enabled = Arc::new(AtomicBool::new(false));
1708    let blocking_mode = Arc::new(AtomicBool::new(false));
1709
1710    // Shared processor (accessible from data thread)
1711    let array_output = Arc::new(parking_lot::Mutex::new(NDArrayOutput::new()));
1712    let array_output_for_handle = array_output.clone();
1713    // B13/G6: register this plugin's output so the WiringRegistry is the
1714    // single source of truth for runtime rewiring (PluginManager::add_plugin
1715    // would also register it, but direct callers must not bypass the
1716    // registry). Registered under every address in 0..max_addr so a
1717    // downstream plugin can select a non-zero NDArrayAddr.
1718    wiring.register_output_addrs(port_name, max_addr, array_output.clone());
1719    // G1/B1: the DroppedArrays counter is owned by this plugin and shared with
1720    // every upstream sender so full-queue drops on our input queue are counted.
1721    let dropped_arrays_counter = array_sender.dropped_arrays_counter().clone();
1722    let shared = Arc::new(parking_lot::Mutex::new(SharedProcessorInner {
1723        processor,
1724        output: array_output,
1725        pool,
1726        ndarray_params,
1727        plugin_params,
1728        port_handle,
1729        array_counter: 0,
1730        std_array_data_param,
1731        // C++ default NDArrayCallbacks = 1 (deliver downstream); terminal
1732        // plugins (StdArrays/Attribute/File) override `does_array_callbacks` to 0.
1733        array_callbacks: does_array_callbacks,
1734        min_callback_time: 0.0,
1735        last_process_time: None,
1736        sort_mode: 0,
1737        sort_time: 0.0,
1738        sort_size: 10,
1739        sort_buffer: SortBuffer::new(),
1740        dropped_arrays: dropped_arrays_counter,
1741        compression_aware,
1742        max_byte_rate: 0.0,
1743        throttler: super::throttler::Throttler::new(0.0),
1744        prev_input_array: None,
1745        dims_prev: vec![0i32; crate::ndarray::ND_ARRAY_MAX_DIMS],
1746        nd_array_addr: 0,
1747        max_threads: 1,
1748        num_threads: 1,
1749    }));
1750
1751    let data_enabled = enabled.clone();
1752    let data_blocking = blocking_mode.clone();
1753
1754    let mut array_sender = array_sender;
1755    array_sender.set_mode_flags(enabled, blocking_mode);
1756
1757    // Capture wiring info for data loop
1758    let sender_port_name = port_name.to_string();
1759    let initial_upstream = ndarray_port.to_string();
1760
1761    // Spawn data processing thread
1762    let data_jh = MandatoryThread::new(
1763        format!("plugin-data-{port_name}"),
1764        // `asynNDArrayDriver.cpp:878` — `if (priority <= 0) priority =
1765        // epicsThreadPriorityMedium`, and that is what `NDPluginDriver` hands
1766        // its callback threads (`NDPluginDriver.cpp:1016`).
1767        ThreadPriority::Medium,
1768        // `asynNDArrayDriver.cpp:876` — `if (stackSize <= 0) stackSize =
1769        // epicsThreadGetStackSize(epicsThreadStackMedium)`.
1770        StackSizeClass::Medium,
1771    )
1772    .spawn(move || {
1773        plugin_data_loop(
1774            shared,
1775            array_rx,
1776            param_rx,
1777            plugin_params,
1778            ndarray_params.array_counter,
1779            data_enabled,
1780            data_blocking,
1781            sender_port_name,
1782            initial_upstream,
1783            wiring,
1784        );
1785    });
1786
1787    let handle = PluginRuntimeHandle {
1788        port_runtime,
1789        array_sender,
1790        array_output: array_output_for_handle,
1791        port_name: port_name.to_string(),
1792        param_tx: handle_param_tx,
1793        ndarray_params,
1794        plugin_params,
1795    };
1796
1797    (handle, data_jh)
1798}
1799
1800/// Build a param batch reporting the input-queue depth.
1801///
1802/// `QUEUE_SIZE` = total capacity, `QUEUE_FREE` = free slots. G2: the param is
1803/// named `QUEUE_FREE` and the reconciled semantics are *free slots*, matching
1804/// C++ `NDPluginDriverQueueFree = queueSize - pending()`.
1805fn queue_status_batch(
1806    plugin_params: &PluginBaseParams,
1807    max_capacity: usize,
1808    free: i32,
1809) -> ParamBatch {
1810    use asyn_rs::request::ParamSetValue;
1811    ParamBatch {
1812        addr0: vec![
1813            ParamSetValue::new(
1814                plugin_params.queue_size,
1815                0,
1816                ParamValue::Int32(max_capacity as i32),
1817            ),
1818            ParamSetValue::new(plugin_params.queue_use, 0, ParamValue::Int32(free)),
1819        ],
1820        extra: std::collections::HashMap::new(),
1821    }
1822}
1823
1824/// Write a validated/clamped int32 value back into the param library so the
1825/// RBV reflects the accepted value (G4 NumThreads/MaxThreads clamping).
1826async fn clamp_writeback(port: &PortHandle, reason: usize, value: i32) {
1827    use asyn_rs::request::ParamSetValue;
1828    let _ = port
1829        .set_params_and_notify(
1830            0,
1831            vec![ParamSetValue::new(
1832                reason,
1833                0,
1834                asyn_rs::param::ParamValue::Int32(value),
1835            )],
1836        )
1837        .await;
1838}
1839
1840fn plugin_data_loop<P: NDPluginProcess>(
1841    shared: Arc<parking_lot::Mutex<SharedProcessorInner<P>>>,
1842    mut array_rx: NDArrayReceiver,
1843    mut param_rx: tokio::sync::mpsc::UnboundedReceiver<PluginParamMsg>,
1844    plugin_params: PluginBaseParams,
1845    array_counter_reason: usize,
1846    enabled: Arc<AtomicBool>,
1847    blocking_mode: Arc<AtomicBool>,
1848    sender_port_name: String,
1849    initial_upstream: String,
1850    wiring: Arc<WiringRegistry>,
1851) {
1852    let enable_callbacks_reason = plugin_params.enable_callbacks;
1853    let blocking_callbacks_reason = plugin_params.blocking_callbacks;
1854    let min_callback_time_reason = plugin_params.min_callback_time;
1855    let sort_mode_reason = plugin_params.sort_mode;
1856    let sort_time_reason = plugin_params.sort_time;
1857    let sort_size_reason = plugin_params.sort_size;
1858    let nd_array_port_reason = plugin_params.nd_array_port;
1859    let nd_array_addr_reason = plugin_params.nd_array_addr;
1860    let process_plugin_reason = plugin_params.process_plugin;
1861    let max_byte_rate_reason = plugin_params.max_byte_rate;
1862    let num_threads_reason = plugin_params.num_threads;
1863    let max_threads_reason = plugin_params.max_threads;
1864    let array_callbacks_reason = shared.lock().ndarray_params.array_callbacks;
1865    // G6: the upstream connection is keyed by (port, addr). `current_upstream`
1866    // is the base port name; `current_addr` is the selected NDArrayAddr; the
1867    // effective WiringRegistry key is computed by `upstream_key`.
1868    let mut current_upstream = initial_upstream;
1869    let mut current_addr: i32 = 0;
1870    let rt = tokio::runtime::Builder::new_current_thread()
1871        .enable_all()
1872        .build()
1873        .unwrap();
1874    rt.block_on(async {
1875        // Sort flush timer — starts disabled (very long interval).
1876        // Re-created when sort_time changes.
1877        let mut sort_flush_interval = tokio::time::interval(std::time::Duration::from_secs(3600));
1878        let mut sort_flush_active = false;
1879        // Last published QueueFree value — only flush the queue params when it
1880        // changes, so a steady queue depth does not spam param callbacks.
1881        let mut last_queue_free: Option<i32> = None;
1882        // Persistent scatter cursor (C++ NDPluginScatter::nextClient_): advances
1883        // per delivery *attempt* across frames so the round-robin survives
1884        // reroutes past full consumers. One per plugin instance, for its
1885        // lifetime — matching `nextClient_(1)` set once at construction.
1886        let mut scatter_cursor: usize = 0;
1887        // Barriers held until the array queue drains. A barrier acks only at
1888        // full quiescence — params applied AND no queued arrays — because a
1889        // param applied while an older array still waits in the queue would
1890        // retroactively change that array's processing (e.g. a
1891        // MinCallbackTime reset un-throttling it). See `PluginParamMsg`.
1892        let mut held_barriers: Vec<std::sync::mpsc::SyncSender<()>> = Vec::new();
1893
1894        loop {
1895            // Release held barriers once the array queue is empty. This runs
1896            // after every arm, and the single-task loop guarantees any
1897            // already-dequeued array has fully finished processing by now.
1898            if !held_barriers.is_empty() && array_rx.pending() == 0 {
1899                for ack in held_barriers.drain(..) {
1900                    let _ = ack.try_send(());
1901                }
1902            }
1903            tokio::select! {
1904                msg = array_rx.recv_msg() => {
1905                    match msg {
1906                        Some(msg) => {
1907                            // B6: quiesce is synchronous — if callbacks were
1908                            // disabled (the param arm flips `enabled` before
1909                            // any further array message is handled), drop the
1910                            // array here without processing.
1911                            if !enabled.load(Ordering::Acquire) {
1912                                continue;
1913                            }
1914                            // Process array and collect output (sync, under lock).
1915                            let (process_output, senders, port) = {
1916                                let mut guard = shared.lock();
1917                                // G3: a non-compression-aware plugin must drop
1918                                // a compressed array (codec set) and count it
1919                                // (C++ driverCallback NDPluginDriver.cpp:383-394).
1920                                let compressed = msg.array.codec.is_some();
1921                                let output = if compressed && !guard.compression_aware {
1922                                    guard
1923                                        .dropped_arrays
1924                                        .fetch_add(1, Ordering::AcqRel);
1925                                    Some(guard.dropped_arrays_only_batch())
1926                                } else {
1927                                    // R2/G5: process_and_publish caches the
1928                                    // input array for ProcessPlugin only after
1929                                    // the MinCallbackTime gate passes — a
1930                                    // throttled frame is never cached.
1931                                    guard.process_and_publish(&msg.array)
1932                                };
1933                                let senders = guard.output.lock().senders_clone();
1934                                let port = guard.port_handle.clone();
1935                                (output, senders, port)
1936                            };
1937                            // G2: update QueueSize/QueueFree from the channel
1938                            // depth (C++ NDPluginDriver.cpp:512-513). QueueFree
1939                            // = max_capacity - pending. Only flush when the
1940                            // value changed to avoid no-op param callbacks.
1941                            let max_cap = array_rx.max_capacity();
1942                            let free = max_cap.saturating_sub(array_rx.pending()) as i32;
1943                            let queue_batch = if last_queue_free != Some(free) {
1944                                last_queue_free = Some(free);
1945                                Some(queue_status_batch(&plugin_params, max_cap, free))
1946                            } else {
1947                                None
1948                            };
1949                            // msg dropped here → completion signaled (if tracked)
1950                            // Publish arrays and flush params outside the lock, in async context.
1951                            if let Some(po) = process_output {
1952                                po.publish_arrays(&senders, &mut scatter_cursor).await;
1953                                po.batch.flush(&port).await;
1954                            }
1955                            if let Some(qb) = queue_batch {
1956                                qb.flush(&port).await;
1957                            }
1958                        }
1959                        None => break,
1960                    }
1961                }
1962                param = param_rx.recv() => {
1963                    match param {
1964                        // Barrier: every Change enqueued before it has been
1965                        // applied by the arms below (FIFO channel). Ack is
1966                        // deferred to the top-of-loop release, which also
1967                        // requires the array queue to be drained — a gone
1968                        // waiter is not an error.
1969                        Some(PluginParamMsg::Barrier(ack)) => {
1970                            held_barriers.push(ack);
1971                        }
1972                        Some(PluginParamMsg::Change(reason, addr, value)) => {
1973                            if reason == enable_callbacks_reason {
1974                                let on = value.as_i32() != 0;
1975                                enabled.store(on, Ordering::Release);
1976                                // B6: disabling releases the cached input array
1977                                // (C++ writeInt32 NDPluginDriver.cpp:712-722).
1978                                if !on {
1979                                    shared.lock().prev_input_array = None;
1980                                }
1981                            }
1982                            if reason == blocking_callbacks_reason {
1983                                blocking_mode.store(value.as_i32() != 0, Ordering::Release);
1984                            }
1985                            // NDArrayCallbacks gates downstream array delivery
1986                            // (C++ endProcessCallbacks NDPluginDriver.cpp:
1987                            // 257-265). Processing still runs; only delivery is
1988                            // suppressed when 0.
1989                            if reason == array_callbacks_reason {
1990                                shared.lock().array_callbacks = value.as_i32() != 0;
1991                            }
1992                            // Handle MinCallbackTime param change
1993                            if reason == min_callback_time_reason {
1994                                shared.lock().min_callback_time = value.as_f64();
1995                            }
1996                            // G7: MaxByteRate change resets the output throttler
1997                            // (C++ writeFloat64 NDPluginDriver.cpp:788-790).
1998                            if reason == max_byte_rate_reason {
1999                                let rate = value.as_f64();
2000                                let mut guard = shared.lock();
2001                                guard.max_byte_rate = rate;
2002                                guard.throttler.reset(rate);
2003                            }
2004                            // G4: NumThreads / MaxThreads are validated and
2005                            // clamped on write. The Rust port is intentionally
2006                            // single-threaded per plugin (one tokio task) — see
2007                            // the module note — so NumThreads is clamped to
2008                            // [1, MaxThreads] and the clamped value written back
2009                            // rather than spawning a worker pool.
2010                            if reason == max_threads_reason {
2011                                // Scope the guard so it is released before await.
2012                                let (port, clamped, mt) = {
2013                                    let mut guard = shared.lock();
2014                                    guard.max_threads = value.as_i32().max(1);
2015                                    let clamped =
2016                                        guard.num_threads.clamp(1, guard.max_threads);
2017                                    guard.num_threads = clamped;
2018                                    (guard.port_handle.clone(), clamped, guard.max_threads)
2019                                };
2020                                clamp_writeback(&port, num_threads_reason, clamped).await;
2021                                clamp_writeback(&port, max_threads_reason, mt).await;
2022                            }
2023                            if reason == num_threads_reason {
2024                                let (port, clamped) = {
2025                                    let mut guard = shared.lock();
2026                                    let clamped =
2027                                        value.as_i32().clamp(1, guard.max_threads.max(1));
2028                                    guard.num_threads = clamped;
2029                                    (guard.port_handle.clone(), clamped)
2030                                };
2031                                clamp_writeback(&port, num_threads_reason, clamped).await;
2032                            }
2033                            // G6: NDArrayAddr selects a source address of a
2034                            // multi-address driver — reconnect on change
2035                            // (C++ writeInt32 NDPluginDriver.cpp:724-728).
2036                            if reason == nd_array_addr_reason {
2037                                let new_addr = value.as_i32();
2038                                if new_addr != current_addr {
2039                                    let old_key = upstream_key(&current_upstream, current_addr);
2040                                    let new_key = upstream_key(&current_upstream, new_addr);
2041                                    shared.lock().nd_array_addr = new_addr;
2042                                    match wiring.rewire_by_name(
2043                                        &sender_port_name,
2044                                        &old_key,
2045                                        &new_key,
2046                                    ) {
2047                                        Ok(()) => current_addr = new_addr,
2048                                        Err(e) => {
2049                                            eprintln!("NDArrayAddr reconnect failed: {e}");
2050                                            shared.lock().nd_array_addr = current_addr;
2051                                        }
2052                                    }
2053                                }
2054                            }
2055                            // G5: ProcessPlugin re-injects the cached input
2056                            // array (C++ writeInt32 NDPluginDriver.cpp:739-746).
2057                            if reason == process_plugin_reason && value.as_i32() != 0 {
2058                                let (process_output, senders, port) = {
2059                                    let mut guard = shared.lock();
2060                                    let output = guard.process_plugin();
2061                                    let senders = guard.output.lock().senders_clone();
2062                                    let port = guard.port_handle.clone();
2063                                    (output, senders, port)
2064                                };
2065                                if let Some(po) = process_output {
2066                                    po.publish_arrays(&senders, &mut scatter_cursor).await;
2067                                    po.batch.flush(&port).await;
2068                                } else {
2069                                    // C parity: NDPluginDriver::writeInt32
2070                                    // (NDPluginDriver.cpp:743) logs this at
2071                                    // ASYN_TRACE_WARNING, which is OFF in the
2072                                    // default port trace mask. Gate through the
2073                                    // port's trace facility so iocInit stays
2074                                    // silent unless WARNING is enabled, instead
2075                                    // of an unconditional eprintln! that spams
2076                                    // stderr on every PINI ProcessPlugin trigger.
2077                                    // The asyn port registry that exposes the
2078                                    // per-port mask only exists with the `ioc`
2079                                    // integration; a bare plugin build has no
2080                                    // trace facility to consult, so it stays
2081                                    // silent there too.
2082                                    #[cfg(feature = "ioc")]
2083                                    if let Some(entry) =
2084                                        asyn_rs::asyn_record::get_port(&sender_port_name)
2085                                    {
2086                                        asyn_rs::asyn_trace!(
2087                                            entry.trace,
2088                                            sender_port_name.as_str(),
2089                                            asyn_rs::trace::TraceMask::WARNING,
2090                                            "plugin {sender_port_name}: ProcessPlugin \
2091                                             requested but no input array cached"
2092                                        );
2093                                    }
2094                                }
2095                            }
2096                            // B12: a control-plane write of ArrayCounter resets
2097                            // the working counter (C++ keeps NDArrayCounter in
2098                            // the param library; beginProcessCallbacks reads it).
2099                            if reason == array_counter_reason {
2100                                shared.lock().array_counter = value.as_i32();
2101                            }
2102                            // Handle sort param changes
2103                            if reason == sort_mode_reason {
2104                                let mode = value.as_i32();
2105                                // Scope the guard so clippy can verify the lock
2106                                // is released before any await.
2107                                let flush_work = {
2108                                    let mut guard = shared.lock();
2109                                    guard.sort_mode = mode;
2110                                    if mode == 0 {
2111                                        let output = guard.flush_sort_buffer();
2112                                        let senders = guard.output.lock().senders_clone();
2113                                        let port = guard.port_handle.clone();
2114                                        sort_flush_active = false;
2115                                        Some((output, senders, port))
2116                                    } else {
2117                                        sort_flush_active = guard.sort_time > 0.0;
2118                                        if sort_flush_active {
2119                                            let dur = std::time::Duration::from_secs_f64(guard.sort_time);
2120                                            sort_flush_interval = tokio::time::interval(dur);
2121                                        }
2122                                        None
2123                                    }
2124                                };
2125                                if let Some((output, senders, port)) = flush_work {
2126                                    output.publish_arrays(&senders, &mut scatter_cursor).await;
2127                                    output.batch.flush(&port).await;
2128                                }
2129                            }
2130                            if reason == sort_time_reason {
2131                                let t = value.as_f64();
2132                                let mut guard = shared.lock();
2133                                guard.sort_time = t;
2134                                if guard.sort_mode != 0 && t > 0.0 {
2135                                    sort_flush_active = true;
2136                                    let dur = std::time::Duration::from_secs_f64(t);
2137                                    sort_flush_interval = tokio::time::interval(dur);
2138                                } else {
2139                                    sort_flush_active = false;
2140                                }
2141                                drop(guard);
2142                            }
2143                            if reason == sort_size_reason {
2144                                shared.lock().sort_size = value.as_i32();
2145                            }
2146                            // Handle NDArrayPort rewiring — keyed by (port, addr).
2147                            if reason == nd_array_port_reason {
2148                                if let Some(new_port) = value.as_string() {
2149                                    if new_port != current_upstream {
2150                                        let old_key =
2151                                            upstream_key(&current_upstream, current_addr);
2152                                        let new_key = upstream_key(new_port, current_addr);
2153                                        match wiring.rewire_by_name(
2154                                            &sender_port_name,
2155                                            &old_key,
2156                                            &new_key,
2157                                        ) {
2158                                            Ok(()) => current_upstream = new_port.to_string(),
2159                                            Err(e) => {
2160                                                eprintln!("NDArrayPort rewire failed: {e}")
2161                                            }
2162                                        }
2163                                    }
2164                                }
2165                            }
2166                            let snapshot = PluginParamSnapshot {
2167                                enable_callbacks: enabled.load(Ordering::Acquire),
2168                                reason,
2169                                addr,
2170                                value,
2171                            };
2172                            let (process_output, senders, port) = {
2173                                let mut guard = shared.lock();
2174                                let t0 = std::time::Instant::now();
2175                                let result = guard.processor.on_param_change(reason, &snapshot);
2176                                let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
2177                                let output = if !result.output_arrays.is_empty() || !result.param_updates.is_empty() {
2178                                    let deliver = guard.array_callbacks;
2179                                    Some(guard.build_publish_batch(result.output_arrays, result.param_updates, false, None, elapsed_ms, deliver, true))
2180                                } else {
2181                                    None
2182                                };
2183                                let senders = guard.output.lock().senders_clone();
2184                                (output, senders, guard.port_handle.clone())
2185                            };
2186                            if let Some(po) = process_output {
2187                                po.publish_arrays(&senders, &mut scatter_cursor).await;
2188                                po.batch.flush(&port).await;
2189                            }
2190                        }
2191                        None => break,
2192                    }
2193                }
2194                _ = sort_flush_interval.tick(), if sort_flush_active => {
2195                    // B3: drain head-first while contiguous or past the
2196                    // staleness deadline — NOT the whole buffer.
2197                    let (output, senders, port) = {
2198                        let mut guard = shared.lock();
2199                        let output = guard.tick_sort_buffer();
2200                        let senders = guard.output.lock().senders_clone();
2201                        let port = guard.port_handle.clone();
2202                        (output, senders, port)
2203                    };
2204                    output.publish_arrays(&senders, &mut scatter_cursor).await;
2205                    output.batch.flush(&port).await;
2206                }
2207            }
2208        }
2209    });
2210}
2211
2212/// Connect a downstream plugin's sender to a plugin runtime's output.
2213///
2214/// B13: the upstream's `array_output` is the same `Arc` that every
2215/// `create_plugin_runtime*` entry point registers in the `WiringRegistry`, so
2216/// adding a sender here mutates the registry-tracked output — the registry
2217/// remains the single source of truth for `rewire_by_name`.
2218pub fn wire_downstream(upstream: &PluginRuntimeHandle, downstream_sender: NDArraySender) {
2219    upstream.array_output().lock().add(downstream_sender);
2220}
2221
2222/// Create a plugin runtime with a pre-wired output (for testing and direct wiring).
2223pub fn create_plugin_runtime_with_output<P: NDPluginProcess>(
2224    port_name: &str,
2225    mut processor: P,
2226    pool: Arc<NDArrayPool>,
2227    queue_size: usize,
2228    output: NDArrayOutput,
2229    ndarray_port: &str,
2230    wiring: Arc<WiringRegistry>,
2231) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
2232    // B14: unbounded so control-plane param changes (e.g. autosave restoring
2233    // hundreds of PVs at IOC init) are never silently dropped before the
2234    // data plane sees them.
2235    let (param_tx, param_rx) = tokio::sync::mpsc::unbounded_channel::<PluginParamMsg>();
2236    let handle_param_tx = param_tx.clone();
2237
2238    let plugin_type_name = processor.plugin_type().to_string();
2239    let compression_aware = processor.compression_aware();
2240    let does_array_callbacks = processor.does_array_callbacks();
2241    let array_data = processor.array_data_handle();
2242    let driver = PluginPortDriver::new(
2243        port_name,
2244        &plugin_type_name,
2245        queue_size,
2246        ndarray_port,
2247        1,
2248        param_tx,
2249        &mut processor,
2250        array_data,
2251    )
2252    .expect("failed to create plugin port driver");
2253
2254    let ndarray_params = driver.ndarray_params;
2255    let plugin_params = driver.plugin_params;
2256    let std_array_data_param = driver.std_array_data_param;
2257
2258    // Fatal for the same reason as `create_plugin_runtime_multi_addr` above:
2259    // a constructor-shaped creator has nowhere to report to, and the only
2260    // alternative is a handle to a port that does not exist.
2261    let (port_runtime, _actor_jh) = create_port_runtime(driver, RuntimeConfig::default())
2262        .unwrap_or_else(|e| port_runtime_unavailable(port_name, &e));
2263
2264    let port_handle = port_runtime.port_handle().clone();
2265
2266    let (array_sender, array_rx) = ndarray_channel(port_name, queue_size);
2267
2268    let enabled = Arc::new(AtomicBool::new(false));
2269    let blocking_mode = Arc::new(AtomicBool::new(false));
2270
2271    let array_output = Arc::new(parking_lot::Mutex::new(output));
2272    let array_output_for_handle = array_output.clone();
2273    // B13: register this plugin's output so the WiringRegistry is the single
2274    // source of truth — an output created via this entry point is otherwise
2275    // invisible to runtime rewiring.
2276    wiring.register_output(port_name, array_output.clone());
2277    // G1/B1: DroppedArrays counter shared with upstream senders.
2278    let dropped_arrays_counter = array_sender.dropped_arrays_counter().clone();
2279    let shared = Arc::new(parking_lot::Mutex::new(SharedProcessorInner {
2280        processor,
2281        output: array_output,
2282        pool,
2283        ndarray_params,
2284        plugin_params,
2285        port_handle,
2286        array_counter: 0,
2287        std_array_data_param,
2288        // C++ default NDArrayCallbacks = 1 (deliver downstream); terminal
2289        // plugins (StdArrays/Attribute/File) override `does_array_callbacks` to 0.
2290        array_callbacks: does_array_callbacks,
2291        min_callback_time: 0.0,
2292        last_process_time: None,
2293        sort_mode: 0,
2294        sort_time: 0.0,
2295        sort_size: 10,
2296        sort_buffer: SortBuffer::new(),
2297        dropped_arrays: dropped_arrays_counter,
2298        compression_aware,
2299        max_byte_rate: 0.0,
2300        throttler: super::throttler::Throttler::new(0.0),
2301        prev_input_array: None,
2302        dims_prev: vec![0i32; crate::ndarray::ND_ARRAY_MAX_DIMS],
2303        nd_array_addr: 0,
2304        max_threads: 1,
2305        num_threads: 1,
2306    }));
2307
2308    let data_enabled = enabled.clone();
2309    let data_blocking = blocking_mode.clone();
2310
2311    let mut array_sender = array_sender;
2312    array_sender.set_mode_flags(enabled, blocking_mode);
2313
2314    // Capture wiring info for data loop
2315    let sender_port_name = port_name.to_string();
2316    let initial_upstream = ndarray_port.to_string();
2317
2318    let data_jh = MandatoryThread::new(
2319        format!("plugin-data-{port_name}"),
2320        // `asynNDArrayDriver.cpp:878` — `if (priority <= 0) priority =
2321        // epicsThreadPriorityMedium`, and that is what `NDPluginDriver` hands
2322        // its callback threads (`NDPluginDriver.cpp:1016`).
2323        ThreadPriority::Medium,
2324        // `asynNDArrayDriver.cpp:876` — `if (stackSize <= 0) stackSize =
2325        // epicsThreadGetStackSize(epicsThreadStackMedium)`.
2326        StackSizeClass::Medium,
2327    )
2328    .spawn(move || {
2329        plugin_data_loop(
2330            shared,
2331            array_rx,
2332            param_rx,
2333            plugin_params,
2334            ndarray_params.array_counter,
2335            data_enabled,
2336            data_blocking,
2337            sender_port_name,
2338            initial_upstream,
2339            wiring,
2340        );
2341    });
2342
2343    let handle = PluginRuntimeHandle {
2344        port_runtime,
2345        array_sender,
2346        array_output: array_output_for_handle,
2347        port_name: port_name.to_string(),
2348        param_tx: handle_param_tx,
2349        ndarray_params,
2350        plugin_params,
2351    };
2352
2353    (handle, data_jh)
2354}
2355
2356#[cfg(test)]
2357mod tests {
2358    use super::*;
2359    use crate::ndarray::{NDDataType, NDDimension};
2360    use crate::plugin::channel::ndarray_channel;
2361
2362    /// # Invariant
2363    ///
2364    /// MUST: every `plugin-data-*` thread be created through
2365    /// [`MandatoryThread`], so that a thread the plugin cannot process without
2366    /// takes the process down rather than the caller's thread.
2367    ///
2368    /// The reason this is not the `errlog-and-continue` class:
2369    /// `NDPluginDriver::createCallbackThreads` builds its workers as
2370    /// `new epicsThread(...)` (`NDPluginDriver.cpp:1016`), whose constructor
2371    /// calls `epicsThreadCreateOpt` and `throw unableToCreateThread()` on
2372    /// failure (`epicsThread.cpp:214-220`) — a thrown failure, not a status
2373    /// code the plugin inspects and carries on from.
2374    ///
2375    /// Where C ends up is **not** where we do, and the difference is
2376    /// deliberate: iocsh catches whatever a command throws
2377    /// (`iocsh.cpp:1274-1284`, `"C++ error: ..."`) and a startup script's
2378    /// default `on error` is `Continue` (`iocsh.cpp:1001`, `:1129`), so C runs
2379    /// the rest of st.cmd with the plugin's port registered and its worker
2380    /// threads absent — arrays queue to it and are never processed, silently,
2381    /// for the life of the IOC. `MandatoryThread::spawn` refuses that state.
2382    /// The `.expect` it replaced reached neither: on a `panic = "unwind"`
2383    /// target it unwound one thread and left the same zombie plugin behind.
2384    ///
2385    /// Contrast the auxiliary AD threads, which genuinely do errlog-and-continue
2386    /// and have no site here: the sorting thread (`NDPluginDriver.cpp:1105-1114`,
2387    /// `asynPrint` + `return asynError`), the queued-array counter
2388    /// (`asynNDArrayDriver.cpp:1013-1021`, `asynPrint` and no error at all) and
2389    /// the HDF5 flush task (`NDFileHDF5.cpp:2423-2431`, `printf` + `return`).
2390    ///
2391    /// Source inspection, because the defect is a call that is *absent*.
2392    #[test]
2393    fn plugin_data_threads_are_mandatory() {
2394        let prod = match include_str!("runtime.rs").find("\n#[cfg(test)]") {
2395            Some(i) => &include_str!("runtime.rs")[..i],
2396            None => include_str!("runtime.rs"),
2397        };
2398        assert_eq!(
2399            prod.matches("MandatoryThread::new(").count(),
2400            2,
2401            "`create_plugin_runtime_multi_addr` and `create_plugin_runtime_with_output`"
2402        );
2403        let bare = concat!("thread", "::Builder::new()");
2404        let strays: Vec<&str> = prod
2405            .lines()
2406            .map(str::trim)
2407            .filter(|l| !l.starts_with("//"))
2408            .filter(|l| l.contains(bare) || l.contains(concat!("thread", "::spawn(")))
2409            .collect();
2410        assert!(
2411            strays.is_empty(),
2412            "a plugin data thread created outside `MandatoryThread` resolves its \
2413             own spawn failure locally: {strays:?}"
2414        );
2415    }
2416
2417    /// Passthrough processor: returns the input array as-is.
2418    struct PassthroughProcessor;
2419
2420    impl NDPluginProcess for PassthroughProcessor {
2421        fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
2422            ProcessResult::arrays(vec![Arc::new(array.clone())])
2423        }
2424        fn plugin_type(&self) -> &str {
2425            "Passthrough"
2426        }
2427    }
2428
2429    /// Sink processor: consumes arrays, returns nothing.
2430    struct SinkProcessor {
2431        count: usize,
2432    }
2433
2434    impl NDPluginProcess for SinkProcessor {
2435        fn process_array(&mut self, _array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
2436            self.count += 1;
2437            ProcessResult::empty()
2438        }
2439        fn plugin_type(&self) -> &str {
2440            "Sink"
2441        }
2442    }
2443
2444    fn make_test_array(id: i32) -> Arc<NDArray> {
2445        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
2446        arr.unique_id = id;
2447        Arc::new(arr)
2448    }
2449
2450    fn test_wiring() -> Arc<WiringRegistry> {
2451        Arc::new(WiringRegistry::new())
2452    }
2453
2454    /// Fence: wait until the data thread has applied every param change
2455    /// submitted so far. `write_*_blocking` only guarantees the change is
2456    /// queued for the data plane; asserting on data-plane behaviour without
2457    /// this fence is a race.
2458    fn params_applied(handle: &PluginRuntimeHandle) {
2459        assert!(
2460            handle.wait_params_applied(std::time::Duration::from_secs(10)),
2461            "data thread did not apply queued param changes"
2462        );
2463    }
2464
2465    /// Poll `cond` until it holds; panic after 10 s. Waits on the observable
2466    /// state itself instead of sleeping a guessed duration, so a loaded
2467    /// machine cannot flake the test.
2468    fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
2469        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2470        while !cond() {
2471            assert!(
2472                std::time::Instant::now() < deadline,
2473                "timed out waiting for {what}"
2474            );
2475            std::thread::sleep(std::time::Duration::from_millis(2));
2476        }
2477    }
2478
2479    /// Enable callbacks on a plugin handle (plugins default to disabled) and
2480    /// fence until the data thread has actually flipped the enable flag.
2481    fn enable_callbacks(handle: &PluginRuntimeHandle) {
2482        handle
2483            .port_runtime()
2484            .port_handle()
2485            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
2486            .unwrap();
2487        params_applied(handle);
2488    }
2489
2490    /// Send an array via the sender from a sync test context.
2491    /// Uses a dedicated thread with a current-thread runtime to avoid
2492    /// interfering with the plugin's own runtime.
2493    fn send_array(sender: &NDArraySender, array: Arc<NDArray>) {
2494        let sender = sender.clone();
2495        let jh = std::thread::spawn(move || {
2496            let rt = tokio::runtime::Builder::new_current_thread()
2497                .enable_all()
2498                .build()
2499                .unwrap();
2500            rt.block_on(sender.publish(array));
2501        });
2502        jh.join().unwrap();
2503    }
2504
2505    #[test]
2506    fn test_passthrough_runtime() {
2507        let pool = Arc::new(NDArrayPool::new(1_000_000));
2508
2509        // Create downstream receiver
2510        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2511        let mut output = NDArrayOutput::new();
2512        output.add(downstream_sender);
2513
2514        let (handle, _data_jh) = create_plugin_runtime_with_output(
2515            "PASS1",
2516            PassthroughProcessor,
2517            pool,
2518            10,
2519            output,
2520            "",
2521            test_wiring(),
2522        );
2523        enable_callbacks(&handle);
2524
2525        // Send an array
2526        send_array(handle.array_sender(), make_test_array(42));
2527
2528        // Should come out the other side
2529        let received = downstream_rx.blocking_recv().unwrap();
2530        assert_eq!(received.unique_id, 42);
2531    }
2532
2533    #[test]
2534    fn test_sink_runtime() {
2535        let pool = Arc::new(NDArrayPool::new(1_000_000));
2536
2537        let (handle, _data_jh) = create_plugin_runtime(
2538            "SINK1",
2539            SinkProcessor { count: 0 },
2540            pool,
2541            10,
2542            "",
2543            test_wiring(),
2544        );
2545        enable_callbacks(&handle);
2546
2547        // Send arrays - they should be consumed silently
2548        send_array(handle.array_sender(), make_test_array(1));
2549        send_array(handle.array_sender(), make_test_array(2));
2550
2551        // ArrayCounter advances once per processed frame — both consumed.
2552        let port = handle.port_runtime().port_handle().clone();
2553        let counter = handle.ndarray_params.array_counter;
2554        wait_until("sink to process both arrays", || {
2555            port.read_int32_blocking(counter, 0).is_ok_and(|v| v == 2)
2556        });
2557        assert_eq!(handle.port_name(), "SINK1");
2558    }
2559
2560    #[test]
2561    fn test_plugin_type_param() {
2562        let pool = Arc::new(NDArrayPool::new(1_000_000));
2563
2564        let (handle, _data_jh) = create_plugin_runtime(
2565            "TYPE_TEST",
2566            PassthroughProcessor,
2567            pool,
2568            10,
2569            "",
2570            test_wiring(),
2571        );
2572
2573        // Verify port name
2574        assert_eq!(handle.port_name(), "TYPE_TEST");
2575        assert_eq!(handle.port_runtime().port_name(), "TYPE_TEST");
2576    }
2577
2578    #[test]
2579    fn test_ndtimestamp_param_is_the_standalone_double() {
2580        // R8-66 family: C `setDoubleParam(NDTimeStamp, pArray->timeStamp)`
2581        // (NDPluginDriver.cpp:217) publishes the array's standalone double —
2582        // which a driver with a hardware clock sets independently of epicsTS —
2583        // while NDEpicsTSSec/nSec carry epicsTS (`:218-219`). The plugin runtime
2584        // published `timestamp.as_f64()` for all three, so NDTimeStamp_RBV
2585        // reported the epicsTS-derived value.
2586        let pool = Arc::new(NDArrayPool::new(1_000_000));
2587        let (ds, _rx) = ndarray_channel("DS_TS", 10);
2588        let mut output = NDArrayOutput::new();
2589        output.add(ds);
2590        let (handle, _jh) = create_plugin_runtime_with_output(
2591            "TS_PARAM",
2592            PassthroughProcessor,
2593            pool,
2594            10,
2595            output,
2596            "",
2597            test_wiring(),
2598        );
2599        enable_callbacks(&handle);
2600        let port = handle.port_runtime().port_handle().clone();
2601
2602        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
2603        arr.timestamp = crate::timestamp::EpicsTimestamp {
2604            sec: 1234,
2605            nsec: 5678,
2606        };
2607        arr.time_stamp = 100.5; // hardware clock, unrelated to epicsTS
2608        send_array(handle.array_sender(), Arc::new(arr));
2609        std::thread::sleep(std::time::Duration::from_millis(50));
2610
2611        assert_eq!(
2612            port.read_float64_blocking(handle.ndarray_params.timestamp_rbv, 0)
2613                .unwrap(),
2614            100.5,
2615            "NDTimeStamp publishes pArray->timeStamp"
2616        );
2617        assert_eq!(
2618            port.read_int32_blocking(handle.ndarray_params.epics_ts_sec, 0)
2619                .unwrap(),
2620            1234
2621        );
2622        assert_eq!(
2623            port.read_int32_blocking(handle.ndarray_params.epics_ts_nsec, 0)
2624                .unwrap(),
2625            5678
2626        );
2627    }
2628
2629    #[test]
2630    fn test_shutdown_on_handle_drop() {
2631        let pool = Arc::new(NDArrayPool::new(1_000_000));
2632
2633        let (handle, data_jh) = create_plugin_runtime(
2634            "SHUTDOWN_TEST",
2635            PassthroughProcessor,
2636            pool,
2637            10,
2638            "",
2639            test_wiring(),
2640        );
2641
2642        // Drop the handle (closes sender channel, which should cause data thread to exit)
2643        let sender = handle.array_sender().clone();
2644        drop(handle);
2645        drop(sender);
2646
2647        // Data thread should terminate
2648        let result = data_jh.join();
2649        assert!(result.is_ok());
2650    }
2651
2652    #[test]
2653    fn test_wire_to_nonzero_ndarray_addr() {
2654        // G6: a multi-address upstream plugin registers its output under every
2655        // address in 0..max_addr. A downstream consumer must be able to select
2656        // NDArrayAddr=1 and actually receive arrays — previously the output was
2657        // registered under the bare port name only, so the "PORT:1" key was
2658        // missing and rewire failed with "not found".
2659        use crate::plugin::wiring::upstream_key;
2660        let pool = Arc::new(NDArrayPool::new(1_000_000));
2661        let wiring = test_wiring();
2662
2663        // Upstream plugin advertises 2 addresses.
2664        let (up_handle, _up_jh) = create_plugin_runtime_multi_addr(
2665            "UP_MULTI",
2666            PassthroughProcessor,
2667            pool,
2668            10,
2669            "",
2670            wiring.clone(),
2671            2,
2672        );
2673        enable_callbacks(&up_handle);
2674
2675        // The "PORT:1" key must resolve to the same output as the bare port.
2676        let addr0 = wiring.lookup_output("UP_MULTI");
2677        let addr1 = wiring.lookup_output(&upstream_key("UP_MULTI", 1));
2678        assert!(addr0.is_some(), "addr 0 output must be registered");
2679        assert!(
2680            addr1.is_some(),
2681            "addr 1 output must be registered for a max_addr=2 port"
2682        );
2683
2684        // Wire a downstream consumer to UP_MULTI address 1.
2685        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWN_ADDR1", 10);
2686        wiring
2687            .rewire(&downstream_sender, "", &upstream_key("UP_MULTI", 1))
2688            .expect("wiring a consumer to NDArrayAddr=1 must succeed");
2689
2690        // An array sent through the upstream must reach the addr-1 consumer.
2691        send_array(up_handle.array_sender(), make_test_array(99));
2692        let received = downstream_rx.blocking_recv().unwrap();
2693        assert_eq!(
2694            received.unique_id, 99,
2695            "consumer wired to NDArrayAddr=1 must receive upstream arrays"
2696        );
2697    }
2698
2699    #[test]
2700    fn test_nonblocking_passthrough() {
2701        let pool = Arc::new(NDArrayPool::new(1_000_000));
2702        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2703        let mut output = NDArrayOutput::new();
2704        output.add(downstream_sender);
2705
2706        let (handle, _data_jh) = create_plugin_runtime_with_output(
2707            "NB_TEST",
2708            PassthroughProcessor,
2709            pool,
2710            10,
2711            output,
2712            "",
2713            test_wiring(),
2714        );
2715        enable_callbacks(&handle);
2716
2717        send_array(handle.array_sender(), make_test_array(42));
2718
2719        let received = downstream_rx.blocking_recv().unwrap();
2720        assert_eq!(received.unique_id, 42);
2721    }
2722
2723    #[test]
2724    fn test_blocking_to_nonblocking_switch() {
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            "SWITCH_TEST",
2732            PassthroughProcessor,
2733            pool,
2734            10,
2735            output,
2736            "",
2737            test_wiring(),
2738        );
2739        enable_callbacks(&handle);
2740
2741        // Start in blocking mode
2742        handle
2743            .port_runtime()
2744            .port_handle()
2745            .write_int32_blocking(handle.plugin_params.blocking_callbacks, 0, 1)
2746            .unwrap();
2747        params_applied(&handle);
2748
2749        send_array(handle.array_sender(), make_test_array(1));
2750        let received = downstream_rx.blocking_recv().unwrap();
2751        assert_eq!(received.unique_id, 1);
2752
2753        // Switch back to non-blocking
2754        handle
2755            .port_runtime()
2756            .port_handle()
2757            .write_int32_blocking(handle.plugin_params.blocking_callbacks, 0, 0)
2758            .unwrap();
2759        params_applied(&handle);
2760
2761        // Send in non-blocking mode — goes through channel to data thread
2762        send_array(handle.array_sender(), make_test_array(2));
2763        let received = downstream_rx.blocking_recv().unwrap();
2764        assert_eq!(received.unique_id, 2);
2765    }
2766
2767    #[test]
2768    fn test_enable_callbacks_disables_processing() {
2769        let pool = Arc::new(NDArrayPool::new(1_000_000));
2770        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2771        let mut output = NDArrayOutput::new();
2772        output.add(downstream_sender);
2773
2774        let (handle, _data_jh) = create_plugin_runtime_with_output(
2775            "ENABLE_TEST",
2776            PassthroughProcessor,
2777            pool,
2778            10,
2779            output,
2780            "",
2781            test_wiring(),
2782        );
2783
2784        // Disable callbacks
2785        handle
2786            .port_runtime()
2787            .port_handle()
2788            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 0)
2789            .unwrap();
2790        params_applied(&handle);
2791
2792        // Send array — should be silently dropped by sender (callbacks disabled)
2793        send_array(handle.array_sender(), make_test_array(99));
2794
2795        // Verify nothing received (with timeout)
2796        let rt = tokio::runtime::Builder::new_current_thread()
2797            .enable_all()
2798            .build()
2799            .unwrap();
2800        let result = rt.block_on(async {
2801            tokio::time::timeout(std::time::Duration::from_millis(100), downstream_rx.recv()).await
2802        });
2803        assert!(
2804            result.is_err(),
2805            "should not receive array when callbacks disabled"
2806        );
2807    }
2808
2809    #[test]
2810    fn test_downstream_receives_multiple() {
2811        let pool = Arc::new(NDArrayPool::new(1_000_000));
2812
2813        let (ds1, mut rx1) = ndarray_channel("DS1", 10);
2814        let (ds2, mut rx2) = ndarray_channel("DS2", 10);
2815        let mut output = NDArrayOutput::new();
2816        output.add(ds1);
2817        output.add(ds2);
2818
2819        let (handle, _data_jh) = create_plugin_runtime_with_output(
2820            "DS_TEST",
2821            PassthroughProcessor,
2822            pool,
2823            10,
2824            output,
2825            "",
2826            test_wiring(),
2827        );
2828        enable_callbacks(&handle);
2829
2830        send_array(handle.array_sender(), make_test_array(77));
2831
2832        // Both downstream receivers should have the array
2833        let r1 = rx1.blocking_recv().unwrap();
2834        let r2 = rx2.blocking_recv().unwrap();
2835        assert_eq!(r1.unique_id, 77);
2836        assert_eq!(r2.unique_id, 77);
2837    }
2838
2839    #[test]
2840    fn test_param_updates_after_send() {
2841        let pool = Arc::new(NDArrayPool::new(1_000_000));
2842
2843        struct ParamTracker;
2844        impl NDPluginProcess for ParamTracker {
2845            fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
2846                ProcessResult::arrays(vec![Arc::new(array.clone())])
2847            }
2848            fn plugin_type(&self) -> &str {
2849                "ParamTracker"
2850            }
2851        }
2852
2853        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2854        let mut output = NDArrayOutput::new();
2855        output.add(downstream_sender);
2856
2857        let (handle, _data_jh) = create_plugin_runtime_with_output(
2858            "PARAM_TEST",
2859            ParamTracker,
2860            pool,
2861            10,
2862            output,
2863            "",
2864            test_wiring(),
2865        );
2866        enable_callbacks(&handle);
2867
2868        // Send array
2869        send_array(handle.array_sender(), make_test_array(1));
2870        let received = downstream_rx.blocking_recv().unwrap();
2871        assert_eq!(received.unique_id, 1);
2872
2873        // Write enable_callbacks — should not crash
2874        handle
2875            .port_runtime()
2876            .port_handle()
2877            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
2878            .unwrap();
2879        params_applied(&handle);
2880
2881        // Still works after param update
2882        send_array(handle.array_sender(), make_test_array(2));
2883        let received = downstream_rx.blocking_recv().unwrap();
2884        assert_eq!(received.unique_id, 2);
2885    }
2886
2887    #[test]
2888    fn test_sort_buffer_reorders_by_unique_id() {
2889        let mut buf = SortBuffer::new();
2890
2891        // Insert out of order: 3, 1, 2
2892        buf.insert(3, vec![make_test_array(3)], 10);
2893        buf.insert(1, vec![make_test_array(1)], 10);
2894        buf.insert(2, vec![make_test_array(2)], 10);
2895
2896        assert_eq!(buf.len(), 3);
2897
2898        let drained = buf.drain_all();
2899        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
2900        assert_eq!(ids, vec![1, 2, 3], "should drain in sorted uniqueId order");
2901        assert_eq!(buf.len(), 0);
2902        assert_eq!(buf.prev_unique_id, 3);
2903    }
2904
2905    #[test]
2906    fn test_sort_buffer_drain_ready_contiguous() {
2907        // B3: drain_ready releases the head while the next-expected uniqueId
2908        // is contiguous, even when later ids are still missing.
2909        let mut buf = SortBuffer::new();
2910        // Mark a prior emission (prev=0) so the contiguity path is active;
2911        // C++ only uses the deadline for the very first output array.
2912        buf.note_emitted(0);
2913        buf.insert(1, vec![make_test_array(1)], 10);
2914        buf.insert(2, vec![make_test_array(2)], 10);
2915        buf.insert(5, vec![make_test_array(5)], 10); // gap: 3,4 missing
2916
2917        // sort_time large → only contiguity drives release.
2918        let drained = buf.drain_ready(100.0);
2919        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
2920        assert_eq!(ids, vec![1, 2], "contiguous run released; id=5 held by gap");
2921        assert_eq!(buf.len(), 1);
2922    }
2923
2924    #[test]
2925    fn test_sort_buffer_drain_ready_deadline() {
2926        // B3: a stale head is released past sort_time even with a gap.
2927        let mut buf = SortBuffer::new();
2928        buf.note_emitted(1); // prev=1
2929        buf.insert(5, vec![make_test_array(5)], 10); // out of order
2930        std::thread::sleep(std::time::Duration::from_millis(30));
2931        // sort_time=0.01s → head aged past deadline → released.
2932        let drained = buf.drain_ready(0.01);
2933        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
2934        assert_eq!(ids, vec![5], "stale head released via deadline");
2935    }
2936
2937    #[test]
2938    fn test_sort_buffer_detects_disordered_on_emit() {
2939        // B4: disorder is counted at emission time.
2940        let mut buf = SortBuffer::new();
2941        buf.note_emitted(5); // prev=5, first_output now false
2942        buf.note_emitted(3); // 3 != 5 and != 6 → disordered
2943        assert_eq!(buf.disordered_arrays, 1);
2944        buf.note_emitted(4); // 4 != 3 and != 4? 4 == prev+1 → ordered
2945        assert_eq!(buf.disordered_arrays, 1);
2946    }
2947
2948    #[test]
2949    fn test_sort_buffer_drops_when_full() {
2950        let mut buf = SortBuffer::new();
2951
2952        // sort_size=2: third insert is refused.
2953        assert!(buf.insert(1, vec![make_test_array(1)], 2));
2954        assert!(buf.insert(2, vec![make_test_array(2)], 2));
2955        assert!(!buf.insert(3, vec![make_test_array(3)], 2));
2956
2957        assert_eq!(buf.len(), 2);
2958        assert_eq!(buf.dropped_output_arrays, 1);
2959    }
2960
2961    #[test]
2962    fn test_sort_mode_runtime_integration() {
2963        let pool = Arc::new(NDArrayPool::new(1_000_000));
2964        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
2965        let mut output = NDArrayOutput::new();
2966        output.add(downstream_sender);
2967
2968        let (handle, _data_jh) = create_plugin_runtime_with_output(
2969            "SORT_TEST",
2970            PassthroughProcessor,
2971            pool,
2972            10,
2973            output,
2974            "",
2975            test_wiring(),
2976        );
2977        enable_callbacks(&handle);
2978
2979        // Enable sort mode with sort_size=10 and a sort_time deadline.
2980        handle
2981            .port_runtime()
2982            .port_handle()
2983            .write_int32_blocking(handle.plugin_params.sort_size, 0, 10)
2984            .unwrap();
2985        handle
2986            .port_runtime()
2987            .port_handle()
2988            .write_float64_blocking(handle.plugin_params.sort_time, 0, 0.1)
2989            .unwrap();
2990        handle
2991            .port_runtime()
2992            .port_handle()
2993            .write_int32_blocking(handle.plugin_params.sort_mode, 0, 1)
2994            .unwrap();
2995        params_applied(&handle);
2996
2997        // B2: in-order arrays (1,2,3) must be emitted IMMEDIATELY via the
2998        // fast path — they are NOT delayed by the sort buffer.
2999        send_array(handle.array_sender(), make_test_array(1));
3000        send_array(handle.array_sender(), make_test_array(2));
3001        send_array(handle.array_sender(), make_test_array(3));
3002
3003        let rt = tokio::runtime::Builder::new_current_thread()
3004            .enable_all()
3005            .build()
3006            .unwrap();
3007        let fast = rt.block_on(async {
3008            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
3009        });
3010        assert!(
3011            fast.is_ok(),
3012            "in-order arrays must be emitted immediately, not buffered"
3013        );
3014        assert_eq!(fast.unwrap().unwrap().unique_id, 1);
3015        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 2);
3016        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 3);
3017
3018        // B3: now send out of order (5 before 4). prev=3, so 4 is in-order
3019        // and emitted immediately; 5 arrives first, is buffered, then 4
3020        // unblocks it.
3021        send_array(handle.array_sender(), make_test_array(5));
3022        send_array(handle.array_sender(), make_test_array(4));
3023        // 4 emitted immediately (in order), then 5 released by contiguity;
3024        // blocking_recv below is the wait.
3025        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 4);
3026        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 5);
3027    }
3028
3029    #[test]
3030    fn test_throttle_drops_output_arrays() {
3031        // G7: with a tiny MaxByteRate, output arrays exceeding the byte budget
3032        // are dropped and counted into DroppedOutputArrays.
3033        let pool = Arc::new(NDArrayPool::new(1_000_000));
3034        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3035        let mut output = NDArrayOutput::new();
3036        output.add(downstream_sender);
3037
3038        let (handle, _data_jh) = create_plugin_runtime_with_output(
3039            "THROTTLE_TEST",
3040            PassthroughProcessor,
3041            pool,
3042            10,
3043            output,
3044            "",
3045            test_wiring(),
3046        );
3047        enable_callbacks(&handle);
3048
3049        // MaxByteRate = 8 bytes/sec. Each test array is 4 bytes; the bucket
3050        // starts full at 8, so the first two pass and the rest are dropped.
3051        handle
3052            .port_runtime()
3053            .port_handle()
3054            .write_float64_blocking(handle.plugin_params.max_byte_rate, 0, 8.0)
3055            .unwrap();
3056        params_applied(&handle);
3057
3058        for id in 1..=5 {
3059            send_array(handle.array_sender(), make_test_array(id));
3060        }
3061        // ArrayCounter counts every processed frame (throttle drops happen on
3062        // the output side, after counting), and its flush follows the array
3063        // publish — so counter == 5 means everything that will ever reach the
3064        // downstream queue is already there.
3065        let port = handle.port_runtime().port_handle().clone();
3066        let counter = handle.ndarray_params.array_counter;
3067        wait_until("all 5 frames to be processed", || {
3068            port.read_int32_blocking(counter, 0).is_ok_and(|v| v == 5)
3069        });
3070
3071        // Drain whatever made it through — strictly fewer than 5.
3072        let rt = tokio::runtime::Builder::new_current_thread()
3073            .enable_all()
3074            .build()
3075            .unwrap();
3076        let mut received = 0;
3077        while rt
3078            .block_on(async {
3079                tokio::time::timeout(std::time::Duration::from_millis(20), downstream_rx.recv())
3080                    .await
3081            })
3082            .map(|o| o.is_some())
3083            .unwrap_or(false)
3084        {
3085            received += 1;
3086        }
3087        assert!(
3088            received < 5,
3089            "throttle must drop some arrays (got {received})"
3090        );
3091        assert!(received >= 1, "first array within budget must pass");
3092    }
3093
3094    #[test]
3095    fn test_process_plugin_reprocesses_last_input() {
3096        // G5: writing ProcessPlugin re-injects the cached last input array.
3097        let pool = Arc::new(NDArrayPool::new(1_000_000));
3098        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3099        let mut output = NDArrayOutput::new();
3100        output.add(downstream_sender);
3101
3102        let (handle, _data_jh) = create_plugin_runtime_with_output(
3103            "PROCESS_PLUGIN_TEST",
3104            PassthroughProcessor,
3105            pool,
3106            10,
3107            output,
3108            "",
3109            test_wiring(),
3110        );
3111        enable_callbacks(&handle);
3112
3113        send_array(handle.array_sender(), make_test_array(7));
3114        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 7);
3115
3116        // Trigger ProcessPlugin — the cached input (id=7) is reprocessed.
3117        handle
3118            .port_runtime()
3119            .port_handle()
3120            .write_int32_blocking(handle.plugin_params.process_plugin, 0, 1)
3121            .unwrap();
3122        let reprocessed = downstream_rx.blocking_recv().unwrap();
3123        assert_eq!(
3124            reprocessed.unique_id, 7,
3125            "ProcessPlugin re-emits last input"
3126        );
3127    }
3128
3129    #[test]
3130    fn test_min_callback_time_throttle_not_counted() {
3131        // A MinCallbackTime-throttled array is silently skipped, NOT counted.
3132        // C++ driverCallback (NDPluginDriver.cpp:405-450) falls through the
3133        // `deltaTime <= minCallbackTime` gate straight to callParamCallbacks()
3134        // without touching DroppedArrays — that counter is incremented ONLY on
3135        // a compression-unaware array (:388) or a full message queue (:440).
3136        // Verify (a) the throttled array is not emitted and (b) DroppedArrays
3137        // stays zero.
3138        let pool = Arc::new(NDArrayPool::new(1_000_000));
3139        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3140        let mut output = NDArrayOutput::new();
3141        output.add(downstream_sender);
3142
3143        let (handle, _data_jh) = create_plugin_runtime_with_output(
3144            "MIN_CB_TEST",
3145            PassthroughProcessor,
3146            pool,
3147            10,
3148            output,
3149            "",
3150            test_wiring(),
3151        );
3152        enable_callbacks(&handle);
3153        let dropped = handle.array_sender().dropped_arrays_counter().clone();
3154
3155        // 10s minimum between callbacks — only the first array gets through.
3156        handle
3157            .port_runtime()
3158            .port_handle()
3159            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 10.0)
3160            .unwrap();
3161        params_applied(&handle);
3162
3163        send_array(handle.array_sender(), make_test_array(1));
3164        send_array(handle.array_sender(), make_test_array(2));
3165
3166        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 1);
3167        // Fence the array queue: array 2 must have been consumed (throttled
3168        // out) before the negative checks below, or they could false-pass on
3169        // a not-yet-processed frame.
3170        params_applied(&handle);
3171        let rt = tokio::runtime::Builder::new_current_thread()
3172            .enable_all()
3173            .build()
3174            .unwrap();
3175        let second = rt.block_on(async {
3176            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
3177        });
3178        assert!(
3179            second.is_err(),
3180            "second array throttled out by MinCallbackTime"
3181        );
3182        assert_eq!(
3183            dropped.load(Ordering::Acquire),
3184            0,
3185            "a MinCallbackTime-throttled frame must NOT increment DroppedArrays"
3186        );
3187    }
3188
3189    #[test]
3190    fn test_array_callbacks_zero_withholds_downstream_delivery() {
3191        // ADC-2: NDArrayCallbacks==0 stops downstream NDArray delivery while
3192        // the plugin still processes and updates its metadata params — C++
3193        // endProcessCallbacks (NDPluginDriver.cpp:257-265) caches the array and
3194        // returns before doCallbacksGenericPointer. Distinct from
3195        // EnableCallbacks, which gates whether the plugin processes at all.
3196        let pool = Arc::new(NDArrayPool::new(1_000_000));
3197        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3198        let mut output = NDArrayOutput::new();
3199        output.add(downstream_sender);
3200
3201        let (handle, _data_jh) = create_plugin_runtime_with_output(
3202            "ARRAY_CB_TEST",
3203            PassthroughProcessor,
3204            pool,
3205            10,
3206            output,
3207            "",
3208            test_wiring(),
3209        );
3210        enable_callbacks(&handle);
3211        let port = handle.port_runtime().port_handle().clone();
3212
3213        // Disable downstream array callbacks.
3214        port.write_int32_blocking(handle.ndarray_params.array_callbacks, 0, 0)
3215            .unwrap();
3216        params_applied(&handle);
3217
3218        send_array(handle.array_sender(), make_test_array(1));
3219        // Processing fence: the counter flush follows any downstream publish,
3220        // so once it reads 1, a delivery that was going to happen already has.
3221        wait_until("frame 1 to be processed", || {
3222            port.read_int32_blocking(handle.ndarray_params.array_counter, 0)
3223                .is_ok_and(|v| v == 1)
3224        });
3225
3226        // No downstream delivery.
3227        let rt = tokio::runtime::Builder::new_current_thread()
3228            .enable_all()
3229            .build()
3230            .unwrap();
3231        let got = rt.block_on(async {
3232            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
3233        });
3234        assert!(
3235            got.is_err(),
3236            "NDArrayCallbacks=0 must withhold downstream delivery"
3237        );
3238        // But the plugin still processed: ArrayCounter advanced.
3239        assert_eq!(
3240            port.read_int32_blocking(handle.ndarray_params.array_counter, 0)
3241                .unwrap(),
3242            1,
3243            "processing (and metadata params) must continue while delivery is off"
3244        );
3245
3246        // Re-enable: the next array IS delivered downstream.
3247        port.write_int32_blocking(handle.ndarray_params.array_callbacks, 0, 1)
3248            .unwrap();
3249        params_applied(&handle);
3250        send_array(handle.array_sender(), make_test_array(2));
3251        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 2);
3252    }
3253
3254    #[test]
3255    fn test_plugin_output_publishes_compressed_size() {
3256        // ADC-3: every processed array publishes NDCodec / NDCompressedSize
3257        // (C++ beginProcessCallbacks NDPluginDriver.cpp:213-214). An
3258        // uncompressed output carries an empty codec name and compressedSize ==
3259        // raw bytes; a compressed output carries the codec name and its
3260        // compressed size. CompressedSize_RBV (Int32) exercises both arms.
3261        struct CompressProcessor;
3262        impl NDPluginProcess for CompressProcessor {
3263            fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
3264                let mut out = array.clone();
3265                out.codec = Some(crate::codec::Codec {
3266                    name: crate::codec::CodecName::JPEG,
3267                    compressed_size: 7,
3268                    level: 0,
3269                    shuffle: 0,
3270                    compressor: 0,
3271                    original_data_type: NDDataType::UInt8,
3272                });
3273                ProcessResult::arrays(vec![Arc::new(out)])
3274            }
3275            fn plugin_type(&self) -> &str {
3276                "Compress"
3277            }
3278        }
3279
3280        // Uncompressed passthrough: a 4-byte UInt8 array → compressedSize == 4.
3281        {
3282            let pool = Arc::new(NDArrayPool::new(1_000_000));
3283            let (ds, _rx) = ndarray_channel("DS_RAW", 10);
3284            let mut output = NDArrayOutput::new();
3285            output.add(ds);
3286            let (handle, _jh) = create_plugin_runtime_with_output(
3287                "CODEC_RAW",
3288                PassthroughProcessor,
3289                pool,
3290                10,
3291                output,
3292                "",
3293                test_wiring(),
3294            );
3295            enable_callbacks(&handle);
3296            let port = handle.port_runtime().port_handle().clone();
3297            send_array(handle.array_sender(), make_test_array(1));
3298            // The read errors with ParamUndefined until the first flush — treat
3299            // that as "not yet".
3300            wait_until(
3301                "uncompressed output to publish CompressedSize == raw byte count",
3302                || {
3303                    port.read_int32_blocking(handle.ndarray_params.compressed_size, 0)
3304                        .is_ok_and(|v| v == 4)
3305                },
3306            );
3307        }
3308
3309        // Compressed output: compressedSize == codec.compressed_size (7).
3310        {
3311            let pool = Arc::new(NDArrayPool::new(1_000_000));
3312            let (ds, _rx) = ndarray_channel("DS_CMP", 10);
3313            let mut output = NDArrayOutput::new();
3314            output.add(ds);
3315            let (handle, _jh) = create_plugin_runtime_with_output(
3316                "CODEC_CMP",
3317                CompressProcessor,
3318                pool,
3319                10,
3320                output,
3321                "",
3322                test_wiring(),
3323            );
3324            enable_callbacks(&handle);
3325            let port = handle.port_runtime().port_handle().clone();
3326            send_array(handle.array_sender(), make_test_array(1));
3327            wait_until(
3328                "compressed output to publish CompressedSize == codec.compressed_size",
3329                || {
3330                    port.read_int32_blocking(handle.ndarray_params.compressed_size, 0)
3331                        .is_ok_and(|v| v == 7)
3332                },
3333            );
3334        }
3335    }
3336
3337    #[test]
3338    fn test_process_plugin_skips_throttled_input() {
3339        // a MinCallbackTime-throttled frame must NOT be cached as the
3340        // ProcessPlugin input. After array 1 is processed and array 2 is
3341        // dropped by the throttle, ProcessPlugin must re-inject array 1
3342        // (the last *processed* array), not the dropped array 2.
3343        let pool = Arc::new(NDArrayPool::new(1_000_000));
3344        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3345        let mut output = NDArrayOutput::new();
3346        output.add(downstream_sender);
3347
3348        let (handle, _data_jh) = create_plugin_runtime_with_output(
3349            "PROCESS_THROTTLE_TEST",
3350            PassthroughProcessor,
3351            pool,
3352            10,
3353            output,
3354            "",
3355            test_wiring(),
3356        );
3357        enable_callbacks(&handle);
3358
3359        // 10s minimum between callbacks — only the first array is processed.
3360        handle
3361            .port_runtime()
3362            .port_handle()
3363            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 10.0)
3364            .unwrap();
3365        params_applied(&handle);
3366
3367        send_array(handle.array_sender(), make_test_array(1));
3368        send_array(handle.array_sender(), make_test_array(2));
3369
3370        // Array 1 was processed and emitted; array 2 was throttled out.
3371        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 1);
3372        // Fence the ARRAY queue: array 2 must be consumed (and throttled out)
3373        // under the 10s gate before the reset below un-gates it. The barrier
3374        // acks only once the array queue has drained.
3375        params_applied(&handle);
3376
3377        // ProcessPlugin re-injects the cached input. The cache must still hold
3378        // array 1, because array 2 never passed the throttle gate. The
3379        // re-injected array itself is also subject to the throttle, so reset
3380        // MinCallbackTime to 0 first so the re-injected frame is processed.
3381        handle
3382            .port_runtime()
3383            .port_handle()
3384            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 0.0)
3385            .unwrap();
3386        // No fence needed: the MinCallbackTime reset and the ProcessPlugin
3387        // trigger below travel the same FIFO param channel, so the reset is
3388        // applied before the trigger by construction.
3389        handle
3390            .port_runtime()
3391            .port_handle()
3392            .write_int32_blocking(handle.plugin_params.process_plugin, 0, 1)
3393            .unwrap();
3394        let reprocessed = downstream_rx.blocking_recv().unwrap();
3395        assert_eq!(
3396            reprocessed.unique_id, 1,
3397            "ProcessPlugin must re-inject the last processed array (1), not the throttled array (2)"
3398        );
3399    }
3400
3401    #[test]
3402    fn test_g3_compressed_array_dropped_on_non_aware_plugin() {
3403        // G3: a non-compression-aware plugin drops a compressed array.
3404        let pool = Arc::new(NDArrayPool::new(1_000_000));
3405        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3406        let mut output = NDArrayOutput::new();
3407        output.add(downstream_sender);
3408
3409        let (handle, _data_jh) = create_plugin_runtime_with_output(
3410            "G3_TEST",
3411            PassthroughProcessor, // compression_aware() defaults to false
3412            pool,
3413            10,
3414            output,
3415            "",
3416            test_wiring(),
3417        );
3418        enable_callbacks(&handle);
3419
3420        // A compressed array must be dropped, not forwarded.
3421        let mut compressed = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
3422        compressed.unique_id = 1;
3423        compressed.codec = Some(crate::codec::Codec {
3424            name: crate::codec::CodecName::JPEG,
3425            compressed_size: 16,
3426            level: 0,
3427            shuffle: 0,
3428            compressor: 0,
3429            original_data_type: NDDataType::UInt8,
3430        });
3431        send_array(handle.array_sender(), Arc::new(compressed));
3432
3433        // An uncompressed array passes through normally.
3434        send_array(handle.array_sender(), make_test_array(2));
3435
3436        let r = downstream_rx.blocking_recv().unwrap();
3437        assert_eq!(
3438            r.unique_id, 2,
3439            "compressed array dropped; only the raw array reaches downstream"
3440        );
3441    }
3442
3443    #[test]
3444    fn test_drop_on_full_increments_dropped_counter() {
3445        // B1/G1: a slow downstream plugin with a tiny input queue drops arrays
3446        // when the queue is full; the drop is counted in the plugin's shared
3447        // DroppedArrays counter rather than back-pressuring the producer.
3448        struct SlowProcessor;
3449        impl NDPluginProcess for SlowProcessor {
3450            fn process_array(&mut self, _a: &NDArray, _p: &NDArrayPool) -> ProcessResult {
3451                std::thread::sleep(std::time::Duration::from_millis(200));
3452                ProcessResult::empty()
3453            }
3454            fn plugin_type(&self) -> &str {
3455                "Slow"
3456            }
3457        }
3458        let pool = Arc::new(NDArrayPool::new(1_000_000));
3459
3460        // Downstream plugin with queue size 1 and a slow processor.
3461        let (downstream_handle, _ds_jh) =
3462            create_plugin_runtime("B1_DOWNSTREAM", SlowProcessor, pool, 1, "", test_wiring());
3463        enable_callbacks(&downstream_handle);
3464        let ds_sender = downstream_handle.array_sender().clone();
3465        let dropped = ds_sender.dropped_arrays_counter().clone();
3466
3467        // First array is taken by the data loop (now sleeping 200ms); second
3468        // fills the 1-slot queue; the rest find a full queue → dropped.
3469        send_array(&ds_sender, make_test_array(1));
3470        send_array(&ds_sender, make_test_array(2));
3471        send_array(&ds_sender, make_test_array(3));
3472        send_array(&ds_sender, make_test_array(4));
3473
3474        assert!(
3475            dropped.load(Ordering::Acquire) >= 1,
3476            "arrays dropped on a full queue must be counted (got {})",
3477            dropped.load(Ordering::Acquire)
3478        );
3479    }
3480
3481    #[test]
3482    fn test_cross_width_narrowing_array_read_truncates() {
3483        // Cross-width integer narrowing array reads must TRUNCATE (wrapping),
3484        // matching the C cast in C++ NDArrayPool.cpp:388 `convertType`
3485        //   *pDataOut++ = (dataTypeOut)(*pDataIn++);
3486        // A C cast `(epicsInt8)(epicsUInt16)300` keeps the low 8 bits == 44.
3487        // The f64 round-trip in copy_convert would SATURATE (`300.0 as i8`
3488        // == 127) and diverge from C++ — copy_ccast must be used instead.
3489
3490        // U16 -> i8: 300 = 0x012C; low byte 0x2C = 44.
3491        let mut out = [0i8; 1];
3492        let n = copy_ccast(&[300u16], &mut out);
3493        assert_eq!(n, 1);
3494        assert_eq!(out[0], 44, "(epicsInt8)(epicsUInt16)300 == 44 (low 8 bits)");
3495        // copy_convert would have saturated:
3496        let mut sat = [0i8; 1];
3497        copy_convert(&[300u16], &mut sat);
3498        assert_eq!(sat[0], 127, "f64 round-trip saturates — the wrong behavior");
3499
3500        // I32 -> i8: 0x1234_5678 -> low byte 0x78 = 120.
3501        let mut out2 = [0i8; 1];
3502        copy_ccast(&[0x1234_5678i32], &mut out2);
3503        assert_eq!(out2[0], 0x78);
3504
3505        // I32 -> i8: -1 stays -1 (all-ones low byte).
3506        let mut out3 = [0i8; 1];
3507        copy_ccast(&[-1i32], &mut out3);
3508        assert_eq!(out3[0], -1);
3509
3510        // U16 -> i8: 0x00FF = 255 -> low byte 0xFF reinterpreted as i8 == -1.
3511        let mut out4 = [0i8; 1];
3512        copy_ccast(&[255u16], &mut out4);
3513        assert_eq!(out4[0], -1);
3514
3515        // I64 -> i32: 0x0000_0001_0000_002A -> low 32 bits == 42.
3516        let mut out5 = [0i32; 1];
3517        copy_ccast(&[0x0000_0001_0000_002Ai64], &mut out5);
3518        assert_eq!(out5[0], 42);
3519
3520        // U32 -> i16: 70000 = 0x0001_1170 -> low 16 bits 0x1170 == 4464.
3521        let mut out6 = [0i16; 1];
3522        copy_ccast(&[70000u32], &mut out6);
3523        assert_eq!(out6[0], 4464);
3524
3525        // Same-width sign change still works as a bitwise reinterpret:
3526        // U8 255 -> i8 -1.
3527        let mut out7 = [0i8; 1];
3528        copy_ccast(&[255u8], &mut out7);
3529        assert_eq!(out7[0], -1);
3530
3531        // F64 out-of-range -> i32 still routes through copy_convert (the
3532        // `convert:` arm for float sources). C++ converts float->int with a
3533        // C cast too, but the runtime keeps the f64 numeric path for float
3534        // sources; this asserts the integer-narrowing fix did not change the
3535        // float-source path.
3536        let mut fout = [0i32; 1];
3537        copy_convert(&[42.9f64], &mut fout);
3538        assert_eq!(fout[0], 42, "f64 -> i32 truncates toward zero");
3539    }
3540
3541    // ---- ADP-45: scatter overflow-reroute (C++ NDPluginScatter) ----
3542
3543    /// Run an async body on a throwaway current-thread runtime.
3544    fn block<F: std::future::Future>(f: F) -> F::Output {
3545        tokio::runtime::Builder::new_current_thread()
3546            .enable_all()
3547            .build()
3548            .unwrap()
3549            .block_on(f)
3550    }
3551
3552    #[test]
3553    fn test_scatter_reroutes_past_full_consumer() {
3554        // 3 consumers, queue size 1. Pre-fill A so its queue is full; a scatter
3555        // that would target A must reroute to B (C++ auxStatus=asynOverflow),
3556        // and the rerouted-away full queue must NOT count a dropped array
3557        // (driverCallback ignoreQueueFull, NDPluginDriver.cpp:406,433-442).
3558        let (sa, mut ra) = ndarray_channel("A", 1);
3559        let (sb, mut rb) = ndarray_channel("B", 1);
3560        let (sc, _rc) = ndarray_channel("C", 1);
3561        block(async {
3562            assert_eq!(
3563                sa.publish(make_test_array(99)).await,
3564                PublishOutcome::Delivered
3565            );
3566            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
3567            let mut cursor = 0usize;
3568            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
3569            // A rerouted (attempt 0), B delivered (attempt 1): cursor +2.
3570            assert_eq!(cursor, 2);
3571            assert_eq!(rb.recv().await.unwrap().unique_id, 1);
3572            // A still holds only its filler; the rerouted-away drop was not counted.
3573            assert_eq!(ra.recv().await.unwrap().unique_id, 99);
3574            assert_eq!(sa.dropped_arrays_counter().load(Ordering::Acquire), 0);
3575        });
3576    }
3577
3578    #[test]
3579    fn test_scatter_drops_on_last_when_all_full_counts_once() {
3580        // Both consumers full. The array is dropped on the last node only, and
3581        // the drop is counted exactly once (C++ sets auxStatus=asynSuccess for
3582        // the last node so its full queue drops and counts).
3583        let (sa, mut ra) = ndarray_channel("A", 1);
3584        let (sb, mut rb) = ndarray_channel("B", 1);
3585        block(async {
3586            sa.publish(make_test_array(91)).await;
3587            sb.publish(make_test_array(92)).await;
3588            let senders = vec![sa.clone(), sb.clone()];
3589            let mut cursor = 0usize;
3590            ProcessOutput::scatter_publish(&make_test_array(7), &senders, &mut cursor).await;
3591            assert_eq!(cursor, 2); // both attempted
3592            // A rerouted-away (not counted); B last (dropped, counted once).
3593            assert_eq!(sa.dropped_arrays_counter().load(Ordering::Acquire), 0);
3594            assert_eq!(sb.dropped_arrays_counter().load(Ordering::Acquire), 1);
3595            // Neither queue received frame 7 — both still hold their fillers.
3596            assert_eq!(ra.recv().await.unwrap().unique_id, 91);
3597            assert_eq!(rb.recv().await.unwrap().unique_id, 92);
3598        });
3599    }
3600
3601    #[test]
3602    fn test_scatter_cursor_advances_per_attempt_across_frames() {
3603        // A is permanently full; B and C are free. Frame 0 reroutes A->B, so
3604        // the persistent cursor (C++ nextClient_) ends past B. Frame 1 must
3605        // therefore start at C, NOT back at B: a per-frame cursor would send
3606        // frame 1 to B; the per-attempt cursor sends it to C.
3607        let (sa, _ra) = ndarray_channel("A", 1);
3608        let (sb, mut rb) = ndarray_channel("B", 10);
3609        let (sc, mut rc) = ndarray_channel("C", 10);
3610        block(async {
3611            sa.publish(make_test_array(90)).await; // fill A permanently
3612            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
3613            let mut cursor = 0usize;
3614            ProcessOutput::scatter_publish(&make_test_array(0), &senders, &mut cursor).await;
3615            assert_eq!(cursor, 2); // A(reroute) + B(deliver)
3616            assert_eq!(rb.recv().await.unwrap().unique_id, 0);
3617            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
3618            assert_eq!(cursor, 3); // C(deliver) on the first attempt
3619            assert_eq!(rc.recv().await.unwrap().unique_id, 1);
3620        });
3621    }
3622
3623    #[test]
3624    fn test_scatter_skips_disabled_consumer() {
3625        // A disabled downstream is unregistered from the interrupt list in C++
3626        // (setArrayInterrupt(0)) and must not consume a round-robin slot.
3627        let (sa, mut ra) = ndarray_channel("A", 10);
3628        let (mut sb, _rb) = ndarray_channel("B", 10);
3629        let (sc, mut rc) = ndarray_channel("C", 10);
3630        sb.set_mode_flags(
3631            Arc::new(AtomicBool::new(false)),
3632            Arc::new(AtomicBool::new(false)),
3633        );
3634        block(async {
3635            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
3636            let mut cursor = 0usize;
3637            // Active set = [A, C] (n=2): frame 0 -> A, frame 1 -> C.
3638            ProcessOutput::scatter_publish(&make_test_array(0), &senders, &mut cursor).await;
3639            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
3640            assert_eq!(ra.recv().await.unwrap().unique_id, 0);
3641            assert_eq!(rc.recv().await.unwrap().unique_id, 1);
3642        });
3643    }
3644}