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