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