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