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
16// RTEMS-EXEC-MODEL-ALLOW(7): checked, not waived — all 7 ran and passed
17// on the exec backend (measured on this tree:
18// `EPICS_RS_BUILD_EXEC_BACKEND=thread cargo nextest run -p ad-core-rs
19// --all-features`, 345/345). ad-core-rs became a census subject when its
20// `build.rs` began deriving `tokio_backend`; nothing here builds a CA
21// server, and the reactor these obtain comes from `#[tokio::test]`
22// itself, which the backend does not remove.
23
24use std::collections::BTreeMap;
25use std::sync::Arc;
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::thread;
28
29use asyn_rs::error::AsynResult;
30use asyn_rs::port::{PortDriver, PortDriverBase, PortFlags};
31use asyn_rs::runtime::config::RuntimeConfig;
32use asyn_rs::runtime::port::{PortRuntimeHandle, create_port_runtime, port_runtime_unavailable};
33use asyn_rs::user::AsynUser;
34use epics_libcom_rs::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
35
36use asyn_rs::port_handle::PortHandle;
37
38use crate::ndarray::NDArray;
39use crate::ndarray_pool::NDArrayPool;
40use crate::params::ndarray_driver::NDArrayDriverParams;
41use asyn_rs::param::ParamValue;
42
43use super::channel::{
44    Admission, ArrayMessage, NDArrayOutput, NDArrayReceiver, NDArraySender, PublishOutcome,
45    ndarray_channel,
46};
47use super::params::PluginBaseParams;
48use super::wiring::{WiringRegistry, upstream_key};
49
50/// Message sent through the param channel from control plane to data plane.
51///
52/// The channel is FIFO, which is what gives [`PluginParamMsg::Barrier`] its
53/// meaning: when the data thread acknowledges a barrier, every `Change`
54/// enqueued before it has been fully applied (enable flips, wiring rewires,
55/// processor param updates).
56#[derive(Debug)]
57enum PluginParamMsg {
58    /// A param write to apply.
59    Change(usize, i32, ParamChangeValue),
60    /// Sync barrier — acknowledged (best-effort send of `()`) at full
61    /// quiescence: every `Change` enqueued before it has been applied (FIFO
62    /// channel) AND the array queue has drained. The second condition exists
63    /// because arrays travel a separate channel: without it, a param applied
64    /// while an older array still waits in the queue would retroactively
65    /// change how that array is processed.
66    Barrier(std::sync::mpsc::SyncSender<()>),
67}
68
69/// Value sent through the param change channel from control plane to data plane.
70#[derive(Debug, Clone)]
71pub enum ParamChangeValue {
72    Int32(i32),
73    Float64(f64),
74    Octet(String),
75}
76
77impl ParamChangeValue {
78    pub fn as_i32(&self) -> i32 {
79        match self {
80            ParamChangeValue::Int32(v) => *v,
81            ParamChangeValue::Float64(v) => *v as i32,
82            ParamChangeValue::Octet(_) => 0,
83        }
84    }
85
86    pub fn as_f64(&self) -> f64 {
87        match self {
88            ParamChangeValue::Int32(v) => *v as f64,
89            ParamChangeValue::Float64(v) => *v,
90            ParamChangeValue::Octet(_) => 0.0,
91        }
92    }
93
94    pub fn as_string(&self) -> Option<&str> {
95        match self {
96            ParamChangeValue::Octet(s) => Some(s),
97            _ => None,
98        }
99    }
100}
101
102/// A single parameter update produced by a plugin's process_array.
103pub enum ParamUpdate {
104    Int32 {
105        reason: usize,
106        addr: i32,
107        value: i32,
108    },
109    Float64 {
110        reason: usize,
111        addr: i32,
112        value: f64,
113    },
114    Octet {
115        reason: usize,
116        addr: i32,
117        value: String,
118    },
119    Float64Array {
120        reason: usize,
121        addr: i32,
122        value: Vec<f64>,
123    },
124}
125
126impl ParamUpdate {
127    /// Create an Int32 update at addr 0.
128    pub fn int32(reason: usize, value: i32) -> Self {
129        Self::Int32 {
130            reason,
131            addr: 0,
132            value,
133        }
134    }
135    /// Create a Float64 update at addr 0.
136    pub fn float64(reason: usize, value: f64) -> Self {
137        Self::Float64 {
138            reason,
139            addr: 0,
140            value,
141        }
142    }
143    /// Create an Int32 update at a specific addr.
144    pub fn int32_addr(reason: usize, addr: i32, value: i32) -> Self {
145        Self::Int32 {
146            reason,
147            addr,
148            value,
149        }
150    }
151    /// Create a Float64 update at a specific addr.
152    pub fn float64_addr(reason: usize, addr: i32, value: f64) -> Self {
153        Self::Float64 {
154            reason,
155            addr,
156            value,
157        }
158    }
159    /// Create a Float64Array update at addr 0.
160    pub fn float64_array(reason: usize, value: Vec<f64>) -> Self {
161        Self::Float64Array {
162            reason,
163            addr: 0,
164            value,
165        }
166    }
167    /// Create a Float64Array update at a specific addr.
168    pub fn float64_array_addr(reason: usize, addr: i32, value: Vec<f64>) -> Self {
169        Self::Float64Array {
170            reason,
171            addr,
172            value,
173        }
174    }
175    /// Create an Octet (string) update at addr 0.
176    pub fn octet(reason: usize, value: String) -> Self {
177        Self::Octet {
178            reason,
179            addr: 0,
180            value,
181        }
182    }
183    /// Create an Octet (string) update at a specific addr.
184    pub fn octet_addr(reason: usize, addr: i32, value: String) -> Self {
185        Self::Octet {
186            reason,
187            addr,
188            value,
189        }
190    }
191}
192
193/// Result of processing one array: output arrays + param updates to write back.
194pub struct ProcessResult {
195    pub output_arrays: Vec<Arc<NDArray>>,
196    pub param_updates: Vec<ParamUpdate>,
197    /// When `true`, the output arrays are *scattered* — delivered to a single
198    /// downstream consumer in round-robin order rather than broadcast to all.
199    /// The target consumer (and reroute-past-full / drop-on-last decisions) is
200    /// owned by the runtime delivery path, which holds the persistent cursor
201    /// (C++ `NDPluginScatter::nextClient_`); the processor only marks the frame
202    /// as a scatter frame.
203    pub scatter: bool,
204}
205
206impl ProcessResult {
207    /// Convenience: sink plugin with only param updates, no output arrays.
208    pub fn sink(param_updates: Vec<ParamUpdate>) -> Self {
209        Self {
210            output_arrays: vec![],
211            param_updates,
212            scatter: false,
213        }
214    }
215
216    /// Convenience: passthrough/transform plugin with output arrays but no param updates.
217    pub fn arrays(output_arrays: Vec<Arc<NDArray>>) -> Self {
218        Self {
219            output_arrays,
220            param_updates: vec![],
221            scatter: false,
222        }
223    }
224
225    /// Convenience: no outputs, no param updates.
226    pub fn empty() -> Self {
227        Self {
228            output_arrays: vec![],
229            param_updates: vec![],
230            scatter: false,
231        }
232    }
233
234    /// Convenience: scatter output — deliver to the next downstream consumer in
235    /// round-robin order (the runtime owns the cursor and reroute logic).
236    pub fn scatter(output_arrays: Vec<Arc<NDArray>>) -> Self {
237        Self {
238            output_arrays,
239            param_updates: vec![],
240            scatter: true,
241        }
242    }
243}
244
245/// Result of handling a control-plane param change.
246pub struct ParamChangeResult {
247    pub output_arrays: Vec<Arc<NDArray>>,
248    pub param_updates: Vec<ParamUpdate>,
249}
250
251impl ParamChangeResult {
252    pub fn updates(param_updates: Vec<ParamUpdate>) -> Self {
253        Self {
254            output_arrays: vec![],
255            param_updates,
256        }
257    }
258
259    pub fn arrays(output_arrays: Vec<Arc<NDArray>>) -> Self {
260        Self {
261            output_arrays,
262            param_updates: vec![],
263        }
264    }
265
266    pub fn combined(output_arrays: Vec<Arc<NDArray>>, param_updates: Vec<ParamUpdate>) -> Self {
267        Self {
268            output_arrays,
269            param_updates,
270        }
271    }
272
273    pub fn empty() -> Self {
274        Self {
275            output_arrays: vec![],
276            param_updates: vec![],
277        }
278    }
279}
280
281/// Pure processing logic.
282///
283/// `process_array` and `on_param_change` take `&self` and the trait requires
284/// `Sync` so that C's `NumThreads` worth of callback threads
285/// (`NDPluginDriver.cpp:996-1001`) can run one processor concurrently. A
286/// plugin that carries state owns the interior mutability for it — one lock
287/// over the whole per-frame state, not one lock per field, so a frame's
288/// reads and writes stay atomic against a concurrent frame.
289pub trait NDPluginProcess: Send + Sync + 'static {
290    /// Process one array. Return output arrays and param updates.
291    ///
292    /// May run concurrently with itself on `NumThreads` worker threads.
293    fn process_array(&self, array: &NDArray, pool: &NDArrayPool) -> ProcessResult;
294
295    /// Plugin type name for PLUGIN_TYPE param.
296    fn plugin_type(&self) -> &str;
297
298    /// Whether this plugin can process compressed (`codec != None`) arrays
299    /// (C++ `compressionAware_`, G3). Defaults to `false`: a plugin that
300    /// operates on raw pixels must not be handed compressed bytes — the
301    /// runtime drops compressed input and counts it into DroppedArrays.
302    /// A codec/file plugin that understands compressed data overrides this.
303    fn compression_aware(&self) -> bool {
304        false
305    }
306
307    /// Whether this plugin delivers arrays to downstream plugins, i.e. the
308    /// initial `NDArrayCallbacks` param value. Defaults to `true`: most plugins
309    /// do array callbacks. Terminal plugins that never deliver downstream
310    /// (`NDPluginStdArrays`, `NDPluginAttribute`, every `NDPluginFile` writer)
311    /// override this to `false` so the param reflects the behaviour, matching C
312    /// (e.g. `NDPluginFile.cpp:948` `setIntegerParam(NDArrayCallbacks, 0)`).
313    fn does_array_callbacks(&self) -> bool {
314        true
315    }
316
317    /// Register plugin-specific params on the base. Called once during construction.
318    fn register_params(
319        &mut self,
320        _base: &mut PortDriverBase,
321    ) -> Result<(), asyn_rs::error::AsynError> {
322        Ok(())
323    }
324
325    /// Called when a param changes. Reason is the param index.
326    /// Return param updates to be written back to the port driver.
327    fn on_param_change(&self, _reason: usize, _params: &PluginParamSnapshot) -> ParamChangeResult {
328        ParamChangeResult::empty()
329    }
330
331    /// Return a handle to the latest NDArray data for array reads.
332    /// Override this in plugins like NDPluginStdArrays that serve pixel data
333    /// via readInt8Array/readInt16Array/etc.
334    fn array_data_handle(&self) -> Option<Arc<parking_lot::Mutex<Option<Arc<NDArray>>>>> {
335        None
336    }
337}
338
339/// Read-only snapshot of param values available to the processing thread.
340pub struct PluginParamSnapshot {
341    pub enable_callbacks: bool,
342    /// The param reason that changed.
343    pub reason: usize,
344    /// The address (sub-device) that changed.
345    pub addr: i32,
346    /// The new value.
347    pub value: ParamChangeValue,
348}
349
350/// One buffered entry in the sort buffer: the output arrays for a uniqueId
351/// plus the instant they were inserted (for the per-element staleness
352/// deadline — C++ `sortedListElement::insertionTime_`).
353struct SortEntry {
354    arrays: Vec<Arc<NDArray>>,
355    inserted: std::time::Instant,
356}
357
358/// Sort buffer for reordering out-of-order output arrays by uniqueId.
359///
360/// Port of C++ `sortedNDArrayList_` semantics (NDPluginDriver.cpp).
361/// Only arrays that arrive *out of order* are buffered here — in-order
362/// arrays are emitted immediately by the caller (B2). The drain logic
363/// (`drain_ready`) releases the head while the next-expected uniqueId is
364/// contiguous OR the head has been buffered longer than `sort_time` (B3).
365struct SortBuffer {
366    /// Buffered out-of-order arrays keyed by uniqueId.
367    entries: BTreeMap<i32, SortEntry>,
368    /// uniqueId of the last array emitted downstream (C++ `prevUniqueId_`).
369    ///
370    /// Starts at C's sentinel so that "nothing emitted yet" is expressed here
371    /// and nowhere else: no real uniqueId is adjacent to it, so the first
372    /// output array is out of order by the same rule every later one is
373    /// judged by, and no caller needs a separate "is this the first?" branch.
374    prev_unique_id: i32,
375    /// Whether any array has been emitted yet (C++ `firstOutputArray_`).
376    first_output: bool,
377    /// Cumulative count of arrays emitted out of order (C++ DisorderedArrays).
378    disordered_arrays: i32,
379    /// Cumulative count of arrays dropped because the buffer was full
380    /// (C++ DroppedOutputArrays — sort-buffer-overflow portion).
381    dropped_output_arrays: i32,
382}
383
384impl SortBuffer {
385    fn new() -> Self {
386        Self {
387            entries: BTreeMap::new(),
388            prev_unique_id: -1000,
389            first_output: true,
390            disordered_arrays: 0,
391            dropped_output_arrays: 0,
392        }
393    }
394
395    /// True if `unique_id` follows `prev_unique_id` in order (C++ `orderOK`).
396    fn order_ok(&self, unique_id: i32) -> bool {
397        unique_id == self.prev_unique_id || unique_id == self.prev_unique_id + 1
398    }
399
400    /// Record that an array with `unique_id` was emitted downstream.
401    /// Updates `prev_unique_id` and counts a disorder if it was out of order.
402    fn note_emitted(&mut self, unique_id: i32) {
403        if !self.first_output && !self.order_ok(unique_id) {
404            self.disordered_arrays += 1;
405        }
406        self.first_output = false;
407        self.prev_unique_id = unique_id;
408    }
409
410    /// Insert an out-of-order array into the sort buffer.
411    ///
412    /// Returns `false` if the buffer was full and the array was dropped
413    /// (C++ NDPluginDriver.cpp:307-316), `true` if buffered.
414    fn insert(&mut self, unique_id: i32, arrays: Vec<Arc<NDArray>>, sort_size: i32) -> bool {
415        if sort_size > 0 && self.entries.len() as i32 >= sort_size {
416            self.dropped_output_arrays += 1;
417            return false;
418        }
419        self.entries
420            .entry(unique_id)
421            .or_insert_with(|| SortEntry {
422                arrays: Vec::new(),
423                inserted: std::time::Instant::now(),
424            })
425            .arrays
426            .extend(arrays);
427        true
428    }
429
430    /// Drain the buffer head-first while either the next expected uniqueId is
431    /// contiguous OR the head element has aged past `sort_time` seconds.
432    /// Port of C++ `sortingTask` loop (NDPluginDriver.cpp:619-670).
433    fn drain_ready(&mut self, sort_time: f64) -> Vec<(i32, Vec<Arc<NDArray>>)> {
434        let now = std::time::Instant::now();
435        let mut out = Vec::new();
436        while let Some((&head_id, entry)) = self.entries.iter().next() {
437            let delta = now.duration_since(entry.inserted).as_secs_f64();
438            let order_ok = self.order_ok(head_id);
439            if (!self.first_output && order_ok) || delta > sort_time {
440                let entry = self.entries.remove(&head_id).unwrap();
441                self.note_emitted(head_id);
442                out.push((head_id, entry.arrays));
443            } else {
444                break;
445            }
446        }
447        out
448    }
449
450    /// Drain every buffered array in uniqueId order, regardless of contiguity
451    /// or age. Used when sort mode is turned off.
452    fn drain_all(&mut self) -> Vec<(i32, Vec<Arc<NDArray>>)> {
453        let entries = std::mem::take(&mut self.entries);
454        let mut out = Vec::with_capacity(entries.len());
455        for (id, entry) in entries {
456            self.note_emitted(id);
457            out.push((id, entry.arrays));
458        }
459        out
460    }
461
462    /// Number of uniqueId entries currently buffered.
463    fn len(&self) -> i32 {
464        self.entries.len() as i32
465    }
466}
467
468/// Shared processor state protected by a mutex, accessible from both
469/// the data thread (non-blocking mode) and the caller thread (blocking mode).
470struct SharedProcessorInner {
471    output: Arc<parking_lot::Mutex<NDArrayOutput>>,
472    pool: Arc<NDArrayPool>,
473    ndarray_params: NDArrayDriverParams,
474    plugin_params: PluginBaseParams,
475    port_handle: PortHandle,
476    /// ArrayCounter — owned in the param library (C++ `NDArrayCounter`), held
477    /// here only as a working copy that is kept in sync with the param so a
478    /// control-plane write of `ARRAY_COUNTER` resets it (B12).
479    array_counter: i32,
480    /// Param index for STD_ARRAY_DATA (if this is a StdArrays plugin).
481    std_array_data_param: Option<usize>,
482    /// NDArrayCallbacks (C++ `NDArrayCallbacks`): when `false`, the plugin
483    /// still processes and updates its metadata params but does NOT deliver the
484    /// output array downstream — `endProcessCallbacks` (NDPluginDriver.cpp:
485    /// 257-265) returns before the sort/throttle/`doCallbacksGenericPointer`
486    /// path. Distinct from `enabled` (`EnableCallbacks`), which gates whether
487    /// the plugin processes the input at all.
488    array_callbacks: bool,
489    /// Sort mode: 0 = disabled, 1 = sorted output.
490    sort_mode: i32,
491    /// Sort time: seconds — per-element staleness deadline for the sort buffer.
492    sort_time: f64,
493    /// Sort size: maximum number of uniqueId entries in the sort buffer.
494    sort_size: i32,
495    /// Sort buffer for reordering output arrays by uniqueId.
496    sort_buffer: SortBuffer,
497    /// Cumulative count of dropped *input* arrays: a full queue or a
498    /// compressed array on a non-aware plugin. NOT a MinCallbackTime throttle,
499    /// which C counts nowhere. Shared with every upstream sender and with the
500    /// admission gate, which is where both drops are decided.
501    dropped_arrays: Arc<std::sync::atomic::AtomicI32>,
502    /// Output byte-rate limit (C++ `MaxByteRate`); 0 disables throttling.
503    max_byte_rate: f64,
504    /// Token-bucket throttler enforcing `max_byte_rate` on the output path (G7).
505    throttler: super::throttler::Throttler,
506    /// Last *input* array, cached for ProcessPlugin re-injection
507    /// (C++ `pPrevInputArray_`, G5). Released on `EnableCallbacks=0` (B6).
508    prev_input_array: Option<Arc<NDArray>>,
509    /// Last array the plugin handed to `endProcessCallbacks` (C++
510    /// `pArrays[0]`), cached whatever NDArrayCallbacks, the MaxByteRate
511    /// throttle or the sort buffer then decide about it
512    /// (NDPluginDriver.cpp:259-264, :275-278). It is the dimensions template
513    /// `NDPoolPreAllocBuffers` allocates from.
514    last_output_array: Option<Arc<NDArray>>,
515    /// Shadow of `POOL_NUM_PRE_ALLOC_BUFFERS`, the count C reads back inside
516    /// `preAllocateBuffers` (asynNDArrayDriver.cpp:723).
517    num_pre_alloc_buffers: usize,
518    /// Previous array dimensions, for firing an NDDimensions int32-array
519    /// callback when dimensions change (C++ `dimsPrev_`, G8).
520    dims_prev: Vec<i32>,
521    /// Source address selected via the NDArrayAddr PV (C++ `NDArrayAddr`, G6).
522    nd_array_addr: i32,
523    /// MaxThreads — the clamp ceiling for NumThreads (C++ `MaxThreads`).
524    max_threads: i32,
525    /// NumThreads — validated/clamped to [1, MaxThreads] on write (G4).
526    num_threads: i32,
527}
528
529impl SharedProcessorInner {
530    /// Byte cost of an array for throttling (C++ `NDPluginDriver::throttled`):
531    /// compressed size when a codec is present, else total raw bytes.
532    fn array_byte_cost(array: &NDArray) -> f64 {
533        match &array.codec {
534            Some(c) => c.compressed_size as f64,
535            None => array.info().total_bytes as f64,
536        }
537    }
538
539    /// Apply the output throttle to one array. Returns `true` if the array
540    /// should be emitted, `false` if it was dropped (and counts the drop).
541    fn throttle_ok(&mut self, array: &NDArray) -> bool {
542        if self.max_byte_rate == 0.0 {
543            return true;
544        }
545        let cost = Self::array_byte_cost(array);
546        if self.throttler.try_take(cost) {
547            true
548        } else {
549            self.sort_buffer.dropped_output_arrays += 1;
550            false
551        }
552    }
553
554    /// Route output arrays through the throttle, the in-order fast path, and
555    /// the sort buffer. Returns arrays ready to emit *now*, in order.
556    ///
557    /// Port of C++ `endProcessCallbacks` (NDPluginDriver.cpp:295-328): an
558    /// array whose uniqueId is contiguous with `prevUniqueId_` is emitted
559    /// immediately (B2); only out-of-order arrays enter the sort buffer.
560    /// Disordered arrays are counted at emission time in both modes (B4).
561    fn route_output_arrays(&mut self, arrays: Vec<Arc<NDArray>>) -> Vec<Arc<NDArray>> {
562        let mut ready = Vec::new();
563        for arr in arrays {
564            if !self.throttle_ok(&arr) {
565                continue; // G7: dropped by MaxByteRate throttle
566            }
567            let uid = arr.unique_id;
568            if self.sort_mode != 0 && !self.sort_buffer.order_ok(uid) {
569                // Out of order with sort mode on: buffer it (B2/B3).
570                self.sort_buffer.insert(uid, vec![arr], self.sort_size);
571            } else {
572                // In order (or sort mode off): emit immediately, count disorder.
573                self.sort_buffer.note_emitted(uid);
574                ready.push(arr);
575            }
576        }
577        // After emitting in-order arrays, the sort buffer head may now be
578        // contiguous — release any newly-ready run (C++ sortingTask).
579        if self.sort_mode != 0 {
580            for (_id, mut bucket) in self.sort_buffer.drain_ready(self.sort_time) {
581                ready.append(&mut bucket);
582            }
583        }
584        ready
585    }
586
587    /// C `pPrevInputArray_`, set in `beginProcessCallbacks`
588    /// (NDPluginDriver.cpp:231-234) — so only an array that got past the
589    /// admission gate and is about to be processed is ever cached, and
590    /// `ProcessPlugin` re-injects the last PROCESSED input rather than the
591    /// last one offered.
592    ///
593    /// The rule for callers: whichever thread is about to run the work calls
594    /// this first. That is the callback thread for a queued frame and the
595    /// writer's own thread for `blockingCallbacks`, which is exactly the pair
596    /// C reaches `processCallbacks` from (`:419-422` vs `processTask`).
597    /// Nothing else may write it — an enqueue-time write would name a frame
598    /// still waiting in the queue.
599    fn cache_input_array(&mut self, array: &Arc<NDArray>) {
600        self.prev_input_array = Some(Arc::clone(array));
601    }
602
603    /// Process array and return a `ProcessOutput`. Does NOT send to actor.
604    /// Direct interrupts (std_array_data_param) happen here (sync).
605    /// The returned output must be published and flushed by the caller in async context.
606    fn process_and_publish<P: NDPluginProcess>(
607        &mut self,
608        processor: &P,
609        array: &Arc<NDArray>,
610    ) -> ProcessOutput {
611        self.cache_input_array(array);
612        let t0 = std::time::Instant::now();
613        let result = processor.process_array(array, &self.pool);
614        let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
615        self.post_process(array, result, elapsed_ms)
616    }
617
618    /// The completion half: route the produced arrays and build the param
619    /// batch. Runs on the data loop, never on a callback worker, so the
620    /// counters and the sort buffer keep a single owner however many workers
621    /// are running.
622    fn post_process(
623        &mut self,
624        array: &Arc<NDArray>,
625        result: ProcessResult,
626        elapsed_ms: f64,
627    ) -> ProcessOutput {
628        // C++ endProcessCallbacks (NDPluginDriver.cpp:257-265): when
629        // NDArrayCallbacks==0 the method caches the array and returns BEFORE the
630        // throttle / sort-admission / `doCallbacksGenericPointer` path. So a
631        // non-delivering frame must not enter the MaxByteRate throttle or the
632        // sort buffer — only the metadata params (beginProcessCallbacks) are
633        // published. Route (throttle + sort) only when delivering.
634        //
635        // NDPluginStdArrays is the exception: it sets NDArrayCallbacks=0 yet
636        // still serves its typed-array waveforms (STD_ARRAY_DATA). Those are
637        // NOT the `doCallbacksGenericPointer` downstream path — they fire
638        // regardless of NDArrayCallbacks and ARE subject to the MaxByteRate
639        // throttle (NDPluginStdArrays.cpp:58 per-interface `throttled()`). So
640        // route whenever we deliver downstream OR serve the StdArray waveforms.
641        let produced = result.output_arrays.len();
642        // C caches `pArrays[0]` on both branches of `endProcessCallbacks` and
643        // before it consults the throttle or the sort buffer, so what a client
644        // pre-allocates against is the last array the plugin produced, not the
645        // last one it delivered.
646        if let Some(last) = result.output_arrays.last() {
647            self.last_output_array = Some(Arc::clone(last));
648        }
649        let ready = if self.array_callbacks || self.std_array_data_param.is_some() {
650            self.route_output_arrays(result.output_arrays)
651        } else {
652            Vec::new()
653        };
654        // A StdArrays frame that produced a waveform which the MaxByteRate
655        // throttle then dropped (`produced > 0` but `ready` empty) must not
656        // advance ArrayCounter — C nets it back out
657        // (NDPluginStdArrays.cpp:202-211).
658        let count_frame =
659            !(self.std_array_data_param.is_some() && produced > 0 && ready.is_empty());
660        let mut output = self.build_publish_batch(
661            ready,
662            result.param_updates,
663            result.scatter,
664            Some(array.as_ref()),
665            elapsed_ms,
666            self.array_callbacks,
667            count_frame,
668        );
669        output.batch.merge(self.build_status_params_batch());
670        output
671    }
672
673    /// The pool branch of C `asynNDArrayDriver::writeInt32`
674    /// (asynNDArrayDriver.cpp:684-694). A plugin port answers it exactly as a
675    /// driver port does, because `NDPluginDriver::writeInt32` hands every
676    /// parameter below `FIRST_NDPLUGIN_PARAM` straight to the base class
677    /// (NDPluginDriver.cpp:690-692) — so `PoolPollStats`, `PoolEmptyFreeList`
678    /// and `PoolPreAllocBuffers` are live PVs on a plugin, not decoration.
679    ///
680    /// Returns the readback batch when `reason` was one of the three, `None`
681    /// otherwise so the caller can go on matching.
682    fn pool_control_write(&mut self, reason: usize, value: i32) -> Option<ProcessOutput> {
683        let p = &self.ndarray_params;
684        let (empty, poll, pre_alloc, num_pre_alloc) = (
685            p.pool_empty_free_list,
686            p.pool_poll_stats,
687            p.pool_pre_alloc,
688            p.pool_num_pre_alloc_buffers,
689        );
690        let mut batch = ParamBatch::empty();
691        if reason == num_pre_alloc {
692            // Shadow the count so `PoolPreAllocBuffers` can read it without a
693            // round trip to the param table; not itself a control write.
694            self.num_pre_alloc_buffers = value.max(0) as usize;
695            return None;
696        } else if reason == empty {
697            self.pool.empty_free_list();
698        } else if reason == pre_alloc {
699            // C refuses with asynError when no array has been produced yet —
700            // there are no dimensions to allocate against
701            // (asynNDArrayDriver.cpp:715-721).
702            if let Some(template) = self.last_output_array.clone() {
703                let _ = self
704                    .pool
705                    .pre_allocate_buffers(&template, self.num_pre_alloc_buffers);
706            }
707            // C clears the command PV once it has run (`:688`).
708            batch.addr0.push(asyn_rs::request::ParamSetValue::new(
709                pre_alloc,
710                0,
711                ParamValue::Int32(0),
712            ));
713        } else if reason != poll {
714            return None;
715        }
716        batch
717            .addr0
718            .extend(crate::driver::ndarray_driver::pool_stats_values(
719                &self.ndarray_params,
720                &self.pool,
721            ));
722        Some(ProcessOutput {
723            arrays: vec![],
724            scatter: false,
725            batch,
726        })
727    }
728
729    /// A param batch carrying only the current DroppedArrays / queue counters,
730    /// used when an array is dropped before processing (B5).
731    fn dropped_arrays_only_batch(&self) -> ProcessOutput {
732        ProcessOutput {
733            arrays: vec![],
734            scatter: false,
735            batch: self.build_status_params_batch(),
736        }
737    }
738
739    /// Flush the sort buffer head-first while contiguous or stale (C++
740    /// sortingTask periodic tick). Does NOT drain non-contiguous fresh arrays.
741    fn tick_sort_buffer(&mut self) -> ProcessOutput {
742        let entries = self.sort_buffer.drain_ready(self.sort_time);
743        self.emit_drained(entries)
744    }
745
746    /// Drain the entire sort buffer in uniqueId order (sort mode turned off).
747    fn flush_sort_buffer(&mut self) -> ProcessOutput {
748        let entries = self.sort_buffer.drain_all();
749        self.emit_drained(entries)
750    }
751
752    fn emit_drained(&mut self, entries: Vec<(i32, Vec<Arc<NDArray>>)>) -> ProcessOutput {
753        let mut all_arrays = Vec::new();
754        let mut combined = ParamBatch::empty();
755        for (_unique_id, arrays) in entries {
756            // Sort-buffer entries were admitted only while NDArrayCallbacks was
757            // on (route_output_arrays runs past the delivery gate); the C++
758            // sort thread delivers them regardless of the *current* flag, so
759            // they always deliver here.
760            let output = self.build_publish_batch(arrays, vec![], false, None, 0.0, true, true);
761            all_arrays.extend(output.arrays);
762            combined.merge(output.batch);
763        }
764        combined.merge(self.build_sort_params_batch());
765        ProcessOutput {
766            arrays: all_arrays,
767            scatter: false,
768            batch: combined,
769        }
770    }
771
772    fn build_sort_params_batch(&self) -> ParamBatch {
773        use asyn_rs::request::ParamSetValue;
774        let sort_free = self.sort_size - self.sort_buffer.len();
775        ParamBatch {
776            addr0: vec![
777                ParamSetValue::new(
778                    self.plugin_params.sort_free,
779                    0,
780                    ParamValue::Int32(sort_free),
781                ),
782                ParamSetValue::new(
783                    self.plugin_params.disordered_arrays,
784                    0,
785                    ParamValue::Int32(self.sort_buffer.disordered_arrays),
786                ),
787                ParamSetValue::new(
788                    self.plugin_params.dropped_output_arrays,
789                    0,
790                    ParamValue::Int32(self.sort_buffer.dropped_output_arrays),
791                ),
792            ],
793            extra: std::collections::HashMap::new(),
794        }
795    }
796
797    /// Build a param batch carrying the runtime status counters:
798    /// DroppedArrays (G1) plus the sort/disorder counters.
799    /// Put a control-plane write of a mirrored counter back into the working
800    /// copy the data path increments.
801    ///
802    /// C++ holds all four of these in the param library and increments each
803    /// with a read-modify-write — `NDArrayCounter` at
804    /// `NDPluginDriver.cpp:206-208`, `DroppedArrays` at `:384-389` and
805    /// `:436-441`, `DroppedOutputArrays` read at `:268` and stored at
806    /// `:291-292` and `:306-307`, `DisorderedArrays` at `:319-321` and
807    /// `:650-652` — so a PV write to any of them resets the count and the next
808    /// increment continues from the written value. Rust keeps a working copy
809    /// per counter for the data path, which makes the param the source of
810    /// truth only if every write lands back here.
811    ///
812    /// This is the one place that does that, so a mirrored counter cannot be
813    /// published without its reset path: adding a counter means adding an arm
814    /// here (B12 for `ArrayCounter`, B15 for the other three).
815    ///
816    /// Returns `true` if `reason` named a mirrored counter. `QueueFree` is
817    /// deliberately absent — it is recomputed from the queue depth on every
818    /// tick in C and in Rust alike, so a write to it is overwritten rather
819    /// than retained.
820    fn resync_counter(&mut self, reason: usize, value: i32) -> bool {
821        if reason == self.ndarray_params.array_counter {
822            self.array_counter = value;
823        } else if reason == self.plugin_params.dropped_arrays {
824            self.dropped_arrays
825                .store(value, std::sync::atomic::Ordering::Release);
826        } else if reason == self.plugin_params.dropped_output_arrays {
827            self.sort_buffer.dropped_output_arrays = value;
828        } else if reason == self.plugin_params.disordered_arrays {
829            self.sort_buffer.disordered_arrays = value;
830        } else {
831            return false;
832        }
833        true
834    }
835
836    fn build_status_params_batch(&self) -> ParamBatch {
837        use asyn_rs::request::ParamSetValue;
838        let mut batch = self.build_sort_params_batch();
839        batch.addr0.push(ParamSetValue::new(
840            self.plugin_params.dropped_arrays,
841            0,
842            ParamValue::Int32(
843                self.dropped_arrays
844                    .load(std::sync::atomic::Ordering::Acquire),
845            ),
846        ));
847        batch
848    }
849
850    /// Build a ProcessOutput: fires direct interrupts (sync) and collects
851    /// param updates into a batch. Does NOT publish arrays — the caller
852    /// must publish them in async context.
853    ///
854    /// `deliver` is the NDArrayCallbacks gate (C++ `endProcessCallbacks`,
855    /// NDPluginDriver.cpp:257-265): when `false`, the downstream array
856    /// delivery — the STD_ARRAY_DATA generic-pointer interrupt and the returned
857    /// `ProcessOutput.arrays` — is suppressed, while the metadata params from
858    /// `beginProcessCallbacks` (counter, dims, datatype, …) are still set.
859    fn build_publish_batch(
860        &mut self,
861        output_arrays: Vec<Arc<NDArray>>,
862        param_updates: Vec<ParamUpdate>,
863        scatter: bool,
864        fallback_array: Option<&NDArray>,
865        elapsed_ms: f64,
866        deliver: bool,
867        count_frame: bool,
868    ) -> ProcessOutput {
869        use asyn_rs::request::ParamSetValue;
870
871        let mut addr0: Vec<ParamSetValue> = Vec::new();
872        let mut extra: std::collections::HashMap<i32, Vec<ParamSetValue>> =
873            std::collections::HashMap::new();
874
875        if let Some(report_arr) = output_arrays.first().map(|a| a.as_ref()).or(fallback_array) {
876            // A StdArrays frame whose waveform output the MaxByteRate throttle
877            // dropped (`count_frame == false`) must not bump ArrayCounter — C
878            // decrements it back so clients monitoring ArrayCounter see no new
879            // data (NDPluginStdArrays.cpp:202-211).
880            if count_frame {
881                self.array_counter += 1;
882            }
883
884            // Fire the StdArray waveform interrupt directly (C EPICS pattern).
885            // This is NDPluginStdArrays' typed-array callback
886            // (NDPluginStdArrays.cpp:71-73 `arrayInterruptCallback`), NOT the
887            // `doCallbacksGenericPointer` downstream path: C fires it whether or
888            // not NDArrayCallbacks is set (StdArrays defaults NDArrayCallbacks=0),
889            // so it must NOT be gated by `deliver`. It fires only with the
890            // routed/served output (`output_arrays.first()`), never the
891            // `fallback_array`: C skips the interface callback on throttle
892            // (NDPluginStdArrays.cpp:58), so a throttled frame leaves
893            // `output_arrays` empty and serves nothing.
894            if let (Some(param), Some(served)) = (
895                self.std_array_data_param,
896                output_arrays.first().map(|a| a.as_ref()),
897            ) {
898                use crate::ndarray::NDDataBuffer;
899                use asyn_rs::param::ParamValue;
900                let value = match &served.data {
901                    NDDataBuffer::I8(v) => {
902                        Some(ParamValue::Int8Array(std::sync::Arc::from(v.as_slice())))
903                    }
904                    NDDataBuffer::U8(v) => Some(ParamValue::Int8Array(std::sync::Arc::from(
905                        v.iter().map(|&x| x as i8).collect::<Vec<_>>().as_slice(),
906                    ))),
907                    NDDataBuffer::I16(v) => {
908                        Some(ParamValue::Int16Array(std::sync::Arc::from(v.as_slice())))
909                    }
910                    NDDataBuffer::U16(v) => Some(ParamValue::Int16Array(std::sync::Arc::from(
911                        v.iter().map(|&x| x as i16).collect::<Vec<_>>().as_slice(),
912                    ))),
913                    NDDataBuffer::I32(v) => {
914                        Some(ParamValue::Int32Array(std::sync::Arc::from(v.as_slice())))
915                    }
916                    NDDataBuffer::U32(v) => Some(ParamValue::Int32Array(std::sync::Arc::from(
917                        v.iter().map(|&x| x as i32).collect::<Vec<_>>().as_slice(),
918                    ))),
919                    NDDataBuffer::I64(v) => {
920                        Some(ParamValue::Int64Array(std::sync::Arc::from(v.as_slice())))
921                    }
922                    NDDataBuffer::U64(v) => Some(ParamValue::Int64Array(std::sync::Arc::from(
923                        v.iter().map(|&x| x as i64).collect::<Vec<_>>().as_slice(),
924                    ))),
925                    NDDataBuffer::F32(v) => {
926                        Some(ParamValue::Float32Array(std::sync::Arc::from(v.as_slice())))
927                    }
928                    NDDataBuffer::F64(v) => {
929                        Some(ParamValue::Float64Array(std::sync::Arc::from(v.as_slice())))
930                    }
931                };
932                if let Some(value) = value {
933                    let ts = served.timestamp.to_system_time();
934                    self.port_handle
935                        .interrupts()
936                        .notify(asyn_rs::interrupt::InterruptValue {
937                            reason: param,
938                            addr: 0,
939                            value,
940                            timestamp: ts,
941                            uint32_changed_mask: 0,
942                            ..Default::default()
943                        });
944                }
945            }
946
947            let info = report_arr.info();
948            // B11: read ColorMode / BayerPattern from the NDArray attributes
949            // (C++ beginProcessCallbacks). `info()` already resolves the
950            // ColorMode attribute when present; fall back to it for the param.
951            let color_mode = report_arr
952                .attributes
953                .get("ColorMode")
954                .and_then(|a| a.value.as_i64())
955                .map(|v| v as i32)
956                .unwrap_or(info.color_mode as i32);
957            let bayer_pattern = report_arr
958                .attributes
959                .get("BayerPattern")
960                .and_then(|a| a.value.as_i64())
961                .map(|v| v as i32)
962                .unwrap_or(0);
963
964            // G8: fire an int32-array callback on NDDimensions when the array
965            // dimensions change (C++ beginProcessCallbacks dimsPrev_). C++ keeps
966            // a fixed `dimsPrev_[ND_ARRAY_MAX_DIMS]` zero-filled beyond `ndims`,
967            // compares element-wise over all 10 slots, and posts the full
968            // 10-element array (NDPluginDriver.cpp:220-231) — so a caget reads
969            // NORD=10 with trailing zeros, not `ndims`.
970            let mut cur_dims = vec![0i32; crate::ndarray::ND_ARRAY_MAX_DIMS];
971            for (slot, d) in cur_dims.iter_mut().zip(
972                report_arr
973                    .dims
974                    .iter()
975                    .take(crate::ndarray::ND_ARRAY_MAX_DIMS),
976            ) {
977                *slot = d.size as i32;
978            }
979            if cur_dims != self.dims_prev {
980                self.dims_prev = cur_dims.clone();
981                self.port_handle
982                    .interrupts()
983                    .notify(asyn_rs::interrupt::InterruptValue {
984                        reason: self.ndarray_params.array_dimensions,
985                        addr: 0,
986                        value: asyn_rs::param::ParamValue::Int32Array(std::sync::Arc::from(
987                            cur_dims.as_slice(),
988                        )),
989                        timestamp: report_arr.timestamp.to_system_time(),
990                        uint32_changed_mask: 0,
991                        ..Default::default()
992                    });
993            }
994
995            addr0.extend([
996                ParamSetValue::new(
997                    self.ndarray_params.array_counter,
998                    0,
999                    ParamValue::Int32(self.array_counter),
1000                ),
1001                ParamSetValue::new(
1002                    self.ndarray_params.unique_id,
1003                    0,
1004                    ParamValue::Int32(report_arr.unique_id),
1005                ),
1006                ParamSetValue::new(
1007                    self.ndarray_params.n_dimensions,
1008                    0,
1009                    ParamValue::Int32(report_arr.dims.len() as i32),
1010                ),
1011                ParamSetValue::new(
1012                    self.ndarray_params.array_size_x,
1013                    0,
1014                    ParamValue::Int32(info.x_size as i32),
1015                ),
1016                ParamSetValue::new(
1017                    self.ndarray_params.array_size_y,
1018                    0,
1019                    ParamValue::Int32(info.y_size as i32),
1020                ),
1021                ParamSetValue::new(
1022                    self.ndarray_params.array_size_z,
1023                    0,
1024                    ParamValue::Int32(info.color_size as i32),
1025                ),
1026                ParamSetValue::new(
1027                    self.ndarray_params.array_size,
1028                    0,
1029                    ParamValue::Int32(info.total_bytes as i32),
1030                ),
1031                ParamSetValue::new(
1032                    self.ndarray_params.data_type,
1033                    0,
1034                    ParamValue::Int32(report_arr.data.data_type() as i32),
1035                ),
1036                ParamSetValue::new(
1037                    self.ndarray_params.color_mode,
1038                    0,
1039                    ParamValue::Int32(color_mode),
1040                ),
1041                ParamSetValue::new(
1042                    self.ndarray_params.bayer_pattern,
1043                    0,
1044                    ParamValue::Int32(bayer_pattern),
1045                ),
1046                ParamSetValue::new(
1047                    self.ndarray_params.timestamp_rbv,
1048                    0,
1049                    // C `setDoubleParam(NDTimeStamp, pArray->timeStamp)`
1050                    // (NDPluginDriver.cpp:217) — the standalone double, which a
1051                    // driver may set from its own clock; NDEpicsTSSec/nSec below
1052                    // carry epicsTS (`:218-219`).
1053                    ParamValue::Float64(report_arr.time_stamp),
1054                ),
1055                ParamSetValue::new(
1056                    self.ndarray_params.epics_ts_sec,
1057                    0,
1058                    ParamValue::Int32(report_arr.timestamp.sec as i32),
1059                ),
1060                ParamSetValue::new(
1061                    self.ndarray_params.epics_ts_nsec,
1062                    0,
1063                    ParamValue::Int32(report_arr.timestamp.nsec as i32),
1064                ),
1065            ]);
1066
1067            // NDCodec / NDCompressedSize — C++ beginProcessCallbacks
1068            // (NDPluginDriver.cpp:213-214) sets these on every array. An
1069            // uncompressed array carries an empty codec name and a
1070            // compressedSize equal to the raw byte count (matching the
1071            // driver-base path in ndarray_driver::prepare_array).
1072            match &report_arr.codec {
1073                Some(codec) => {
1074                    addr0.push(ParamSetValue::new(
1075                        self.ndarray_params.codec,
1076                        0,
1077                        ParamValue::Octet(codec.name.as_str().as_bytes().to_vec()),
1078                    ));
1079                    addr0.push(ParamSetValue::new(
1080                        self.ndarray_params.compressed_size,
1081                        0,
1082                        ParamValue::Int32(codec.compressed_size as i32),
1083                    ));
1084                }
1085                None => {
1086                    addr0.push(ParamSetValue::new(
1087                        self.ndarray_params.codec,
1088                        0,
1089                        ParamValue::Octet(Vec::new()),
1090                    ));
1091                    addr0.push(ParamSetValue::new(
1092                        self.ndarray_params.compressed_size,
1093                        0,
1094                        ParamValue::Int32(info.total_bytes as i32),
1095                    ));
1096                }
1097            }
1098        }
1099
1100        addr0.push(ParamSetValue::new(
1101            self.plugin_params.execution_time,
1102            0,
1103            ParamValue::Float64(elapsed_ms),
1104        ));
1105
1106        // ArrayRate_RBV is computed by a calc record in the DB template
1107        // (SCAN "1 second", reading ArrayCounter_RBV delta), not in Rust.
1108
1109        // Plugin-specific param updates.
1110        for update in &param_updates {
1111            match update {
1112                ParamUpdate::Int32 {
1113                    reason,
1114                    addr,
1115                    value,
1116                } => {
1117                    let pv = ParamSetValue::new(*reason, *addr, ParamValue::Int32(*value));
1118                    if *addr == 0 {
1119                        addr0.push(pv);
1120                    } else {
1121                        extra.entry(*addr).or_default().push(pv);
1122                    }
1123                }
1124                ParamUpdate::Float64 {
1125                    reason,
1126                    addr,
1127                    value,
1128                } => {
1129                    let pv = ParamSetValue::new(*reason, *addr, ParamValue::Float64(*value));
1130                    if *addr == 0 {
1131                        addr0.push(pv);
1132                    } else {
1133                        extra.entry(*addr).or_default().push(pv);
1134                    }
1135                }
1136                ParamUpdate::Octet {
1137                    reason,
1138                    addr,
1139                    value,
1140                } => {
1141                    let pv = ParamSetValue::new(
1142                        *reason,
1143                        *addr,
1144                        ParamValue::Octet(value.clone().into_bytes()),
1145                    );
1146                    if *addr == 0 {
1147                        addr0.push(pv);
1148                    } else {
1149                        extra.entry(*addr).or_default().push(pv);
1150                    }
1151                }
1152                ParamUpdate::Float64Array {
1153                    reason,
1154                    addr,
1155                    value,
1156                } => {
1157                    let pv = ParamSetValue::new(
1158                        *reason,
1159                        *addr,
1160                        ParamValue::Float64Array(value.clone().into()),
1161                    );
1162                    if *addr == 0 {
1163                        addr0.push(pv);
1164                    } else {
1165                        extra.entry(*addr).or_default().push(pv);
1166                    }
1167                }
1168            }
1169        }
1170
1171        ProcessOutput {
1172            // NDArrayCallbacks==0 suppresses downstream delivery (C++
1173            // endProcessCallbacks early-return); the metadata params above are
1174            // still published.
1175            arrays: if deliver { output_arrays } else { Vec::new() },
1176            scatter,
1177            batch: ParamBatch { addr0, extra },
1178        }
1179    }
1180}
1181
1182/// Output from processing: arrays to publish + param batch to flush.
1183struct ProcessOutput {
1184    arrays: Vec<Arc<NDArray>>,
1185    scatter: bool,
1186    batch: ParamBatch,
1187}
1188
1189impl ProcessOutput {
1190    /// Publish arrays to downstream senders (async, concurrent fan-out).
1191    ///
1192    /// Broadcast frames are published to every sender concurrently (independent
1193    /// backpressure per sender). Scatter frames are routed to a single consumer
1194    /// via `scatter_publish`, which advances the persistent `scatter_cursor`.
1195    /// Arrays are published in order — the next array's fan-out starts only
1196    /// after the previous one completes.
1197    async fn publish_arrays(&self, senders: &[NDArraySender], scatter_cursor: &mut usize) {
1198        for arr in &self.arrays {
1199            if self.scatter {
1200                Self::scatter_publish(arr, senders, scatter_cursor).await;
1201            } else {
1202                let futs = senders.iter().map(|s| s.publish(arr.clone()));
1203                futures_util::future::join_all(futs).await;
1204            }
1205        }
1206    }
1207
1208    /// Deliver one array to the next downstream consumer in round-robin order,
1209    /// rerouting past full queues — a port of C++
1210    /// `NDPluginScatter::doNDArrayCallbacks` (NDPluginScatter.cpp:59-90).
1211    ///
1212    /// `cursor` is the persistent `nextClient_`: it advances by one per
1213    /// *attempt*, so a frame that reroutes past a full consumer leaves the
1214    /// cursor pointing just past the consumer it actually delivered to (not
1215    /// merely one past the starting point). Walking begins at `cursor % n` and
1216    /// makes at most `n` attempts. A full (or disabled/closed) consumer is
1217    /// rerouted past unless this is the last attempt; only the last node is
1218    /// allowed to drop the array (C++ sets `auxStatus=asynSuccess` for the last
1219    /// node so its full queue drops rather than reroutes). Earlier full
1220    /// consumers are passed `is_last=false` so the rerouted-away drop is *not*
1221    /// counted (C++ `ignoreQueueFull`, NDPluginDriver.cpp:406,433-442).
1222    ///
1223    /// Routing is over the *enabled* senders only: a downstream with callbacks
1224    /// disabled is unregistered from the interrupt list in C
1225    /// (`setArrayInterrupt(0)`) and is therefore not a scatter target, so it
1226    /// must not consume a round-robin slot.
1227    async fn scatter_publish(arr: &Arc<NDArray>, senders: &[NDArraySender], cursor: &mut usize) {
1228        let active: Vec<&NDArraySender> = senders.iter().filter(|s| s.is_enabled()).collect();
1229        let n = active.len();
1230        if n == 0 {
1231            return;
1232        }
1233        for attempt in 0..n {
1234            let target = *cursor % n;
1235            *cursor = cursor.wrapping_add(1);
1236            let is_last = attempt == n - 1;
1237            match active[target].publish_scatter(arr.clone(), is_last).await {
1238                // Delivered: done. (In blocking mode publish always delivers,
1239                // so the loop breaks on the first attempt — matching C++ where
1240                // a blocking scatter calls processCallbacks inline and breaks.)
1241                PublishOutcome::Delivered => break,
1242                // Full / disabled / closed: reroute to the next consumer unless
1243                // this was the last attempt (then the array is dropped — already
1244                // counted by publish_scatter when is_last).
1245                PublishOutcome::DroppedQueueFull
1246                | PublishOutcome::Disabled
1247                | PublishOutcome::ChannelClosed => {
1248                    if is_last {
1249                        break;
1250                    }
1251                }
1252                // C reroutes on `auxStatus` alone (NDPluginScatter.cpp:86-90),
1253                // which `driverCallback` sets on a refused `trySend` and on
1254                // nothing else — a compressed or time-throttled array leaves
1255                // it clear, so the scatter treats the node as served.
1256                PublishOutcome::DroppedCompressed | PublishOutcome::Throttled => break,
1257            }
1258        }
1259    }
1260}
1261
1262/// Collected param updates ready to be flushed to the actor.
1263/// Produced by `build_publish_batch()`, consumed by async `flush()`.
1264struct ParamBatch {
1265    addr0: Vec<asyn_rs::request::ParamSetValue>,
1266    extra: std::collections::HashMap<i32, Vec<asyn_rs::request::ParamSetValue>>,
1267}
1268
1269impl ParamBatch {
1270    fn empty() -> Self {
1271        Self {
1272            addr0: Vec::new(),
1273            extra: std::collections::HashMap::new(),
1274        }
1275    }
1276
1277    fn merge(&mut self, other: ParamBatch) {
1278        self.addr0.extend(other.addr0);
1279        for (addr, updates) in other.extra {
1280            self.extra.entry(addr).or_default().extend(updates);
1281        }
1282    }
1283
1284    /// Flush via reliable async enqueue. Call from async context.
1285    async fn flush(self, port: &asyn_rs::port_handle::PortHandle) {
1286        if !self.addr0.is_empty() {
1287            if let Err(e) = port.set_params_and_notify(0, self.addr0).await {
1288                eprintln!("plugin param flush error (addr 0): {e}");
1289            }
1290        }
1291        for (addr, updates) in self.extra {
1292            if let Err(e) = port.set_params_and_notify(addr, updates).await {
1293                eprintln!("plugin param flush error (addr {addr}): {e}");
1294            }
1295        }
1296    }
1297}
1298
1299/// The `NDArrayAddr`, `maxThreads` and initial `numThreads` that C takes as
1300/// `NDPluginDriver` constructor arguments (NDPluginDriver.cpp:153, :158, :159).
1301///
1302/// The port's `*Configure` entry points take none of them, so the defaults
1303/// stand in: `maxThreads` floored at 1 as C floors it (`:117`) and published
1304/// to `MaxThreads` (`:158`), `numThreads` starting at 1 (`:159`). Both are
1305/// writable at run time, and a `NumThreads` or `QueueSize` write respawns the
1306/// callback-thread pool exactly as C does (`:730-733`). They are named rather
1307/// than inlined so the constructor's param block and `SharedProcessorInner`'s
1308/// initial state cannot drift — publishing one value to the RBV and running on
1309/// another is what a reader of MaxThreads_RBV would have no way to detect.
1310const PLUGIN_NDARRAY_ADDR: i32 = 0;
1311const PLUGIN_MAX_THREADS: i32 = 1;
1312const PLUGIN_NUM_THREADS: i32 = 1;
1313
1314/// PortDriver implementation for a plugin's control plane.
1315#[allow(dead_code)]
1316pub struct PluginPortDriver {
1317    base: PortDriverBase,
1318    ndarray_params: NDArrayDriverParams,
1319    plugin_params: PluginBaseParams,
1320    param_change_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1321    /// Optional handle to the latest NDArray for array read methods (used by StdArrays).
1322    array_data: Option<Arc<parking_lot::Mutex<Option<Arc<NDArray>>>>>,
1323    /// Param index for STD_ARRAY_DATA (triggers I/O Intr on ArrayData waveform).
1324    std_array_data_param: Option<usize>,
1325}
1326
1327impl PluginPortDriver {
1328    fn new<P: NDPluginProcess>(
1329        port_name: &str,
1330        plugin_type_name: &str,
1331        queue_size: usize,
1332        ndarray_port: &str,
1333        max_addr: usize,
1334        param_change_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1335        processor: &mut P,
1336        array_data: Option<Arc<parking_lot::Mutex<Option<Arc<NDArray>>>>>,
1337        pool: &NDArrayPool,
1338    ) -> AsynResult<Self> {
1339        let mut base = PortDriverBase::new(
1340            port_name,
1341            max_addr,
1342            PortFlags {
1343                can_block: true,
1344                ..Default::default()
1345            },
1346        );
1347
1348        let ndarray_params = NDArrayDriverParams::create(&mut base)?;
1349        let plugin_params = PluginBaseParams::create(&mut base)?;
1350
1351        // C++ `NDPluginDriver::NDPluginDriver` (NDPluginDriver.cpp:152-160)
1352        // initialises exactly these nine read-only / read-back params here,
1353        // under a comment that states the mechanism: "If a value is not set
1354        // here then the read request will return an error (uninitialized)".
1355        // `PluginPortDriver` does not override `read_int32`, so an unset one
1356        // reaches `get_int32_strict` (asyn-rs/src/port.rs:1663-1665) and the
1357        // record it feeds sits UDF/INVALID until something else happens to
1358        // write it — which for MaxThreads_RBV (SCAN "I/O Intr", no PINI, no
1359        // output partner) is never.
1360        base.set_string_param(plugin_params.nd_array_port, 0, ndarray_port)?;
1361        base.set_int32_param(plugin_params.nd_array_addr, 0, PLUGIN_NDARRAY_ADDR)?;
1362        base.set_int32_param(plugin_params.dropped_arrays, 0, 0)?;
1363        base.set_int32_param(plugin_params.dropped_output_arrays, 0, 0)?;
1364        base.set_int32_param(plugin_params.queue_size, 0, queue_size as i32)?;
1365        // C `:157` — an empty queue has every slot free. The param is spelled
1366        // QUEUE_FREE (params.rs:40) whatever the struct field is called, so 0
1367        // here published a permanently full queue on an idle plugin.
1368        base.set_int32_param(plugin_params.queue_use, 0, queue_size as i32)?;
1369        base.set_int32_param(plugin_params.max_threads, 0, PLUGIN_MAX_THREADS)?;
1370        base.set_int32_param(plugin_params.num_threads, 0, PLUGIN_NUM_THREADS)?;
1371        // C `:160` passes its `blockingCallbacks` constructor argument; the
1372        // port has no such argument and starts `blocking_mode` false.
1373        base.set_int32_param(plugin_params.blocking_callbacks, 0, 0)?;
1374
1375        // EnableCallbacks=0 (Disable) by default, matching EPICS ADCore.
1376        base.set_int32_param(plugin_params.enable_callbacks, 0, 0)?;
1377        base.set_string_param(plugin_params.plugin_type, 0, plugin_type_name)?;
1378
1379        // C++ `NDPluginDriver` derives from `asynNDArrayDriver`, so the base
1380        // constructor's read-only block (asynNDArrayDriver.cpp:954-1005) runs
1381        // for every plugin as well.
1382        crate::driver::ndarray_driver::init_read_only_params(
1383            &mut base,
1384            &ndarray_params,
1385            port_name,
1386        )?;
1387        crate::driver::ndarray_driver::refresh_pool_stats(&mut base, &ndarray_params, pool)?;
1388        // Not in C's block: NDArrayCallbacks is a database parameter there,
1389        // but a plugin that never emits arrays must publish 0 rather than let
1390        // the DB turn its output on.
1391        base.set_int32_param(
1392            ndarray_params.array_callbacks,
1393            0,
1394            processor.does_array_callbacks() as i32,
1395        )?;
1396        // Not in C's block either — `NDFullFileName_RBV` is only written by a
1397        // file plugin after a successful write.
1398        base.set_string_param(ndarray_params.full_file_name, 0, "")?;
1399        // Create STD_ARRAY_DATA param for StdArrays plugins (triggers I/O Intr on ArrayData waveform)
1400        let std_array_data_param = if array_data.is_some() {
1401            Some(base.create_param("STD_ARRAY_DATA", asyn_rs::param::ParamType::GenericPointer)?)
1402        } else {
1403            None
1404        };
1405
1406        // Let the processor register its plugin-specific params
1407        processor.register_params(&mut base)?;
1408
1409        Ok(Self {
1410            base,
1411            ndarray_params,
1412            plugin_params,
1413            param_change_tx,
1414            array_data,
1415            std_array_data_param,
1416        })
1417    }
1418}
1419
1420/// Copy source slice directly into destination buffer, returning elements copied.
1421fn copy_direct<T: Copy>(src: &[T], dst: &mut [T]) -> usize {
1422    let n = src.len().min(dst.len());
1423    dst[..n].copy_from_slice(&src[..n]);
1424    n
1425}
1426
1427/// Convert and copy source slice into destination buffer element-by-element.
1428fn copy_convert<S, D>(src: &[S], dst: &mut [D]) -> usize
1429where
1430    S: CastToF64 + Copy,
1431    D: CastFromF64 + Copy,
1432{
1433    let n = src.len().min(dst.len());
1434    for i in 0..n {
1435        dst[i] = D::cast_from_f64(src[i].cast_to_f64());
1436    }
1437    n
1438}
1439
1440/// Cast an integer source element to an integer destination element with C
1441/// cast semantics. C++ `NDArrayPool::convert` (`NDArrayPool.cpp:387`,
1442/// `convertType`: `*pDataOut++ = (dataTypeOut)(*pDataIn++)`; and `:465`,
1443/// `convertDim`) performs a plain C cast between integer types. A C cast:
1444///   - same-width sign change is a bitwise reinterpret
1445///     (`(epicsInt8)(epicsUInt8)255 == -1`);
1446///   - narrowing truncates to the low bits, wrapping
1447///     (`(epicsInt8)(epicsUInt16)300 == 44`);
1448///   - widening sign/zero-extends exactly.
1449///
1450/// Rust's `as` between integer types implements exactly these semantics. The
1451/// f64 round-trip in [`copy_convert`] does NOT: it saturates on narrowing
1452/// (`300.0 as i8 == 127`), diverging from C++. So every integer-source ->
1453/// integer-target NDArray array read must go through this C-cast path, not
1454/// `copy_convert`.
1455trait CCastTo<D> {
1456    fn ccast(self) -> D;
1457}
1458macro_rules! impl_ccast {
1459    ( $src:ty => $( $dst:ty ),+ ) => {
1460        $(
1461            impl CCastTo<$dst> for $src {
1462                #[inline]
1463                fn ccast(self) -> $dst {
1464                    self as $dst
1465                }
1466            }
1467        )+
1468    };
1469}
1470impl_ccast!(i8 => i16, i32, i64);
1471impl_ccast!(u8 => i8, i16, i32, i64);
1472impl_ccast!(i16 => i8, i32, i64);
1473impl_ccast!(u16 => i8, i16, i32, i64);
1474impl_ccast!(i32 => i8, i16, i64);
1475impl_ccast!(u32 => i8, i16, i32, i64);
1476impl_ccast!(i64 => i8, i16, i32);
1477impl_ccast!(u64 => i8, i16, i32, i64);
1478
1479/// Copy an integer source slice into an integer destination buffer using C
1480/// cast semantics (see [`CCastTo`]) — truncating on narrowing, never
1481/// saturating.
1482fn copy_ccast<S, D>(src: &[S], dst: &mut [D]) -> usize
1483where
1484    S: CCastTo<D> + Copy,
1485    D: Copy,
1486{
1487    let n = src.len().min(dst.len());
1488    for i in 0..n {
1489        dst[i] = src[i].ccast();
1490    }
1491    n
1492}
1493
1494/// Helper trait for `as f64` casts (handles lossy conversions like i64/u64).
1495trait CastToF64 {
1496    fn cast_to_f64(self) -> f64;
1497}
1498
1499impl CastToF64 for i8 {
1500    fn cast_to_f64(self) -> f64 {
1501        self as f64
1502    }
1503}
1504impl CastToF64 for u8 {
1505    fn cast_to_f64(self) -> f64 {
1506        self as f64
1507    }
1508}
1509impl CastToF64 for i16 {
1510    fn cast_to_f64(self) -> f64 {
1511        self as f64
1512    }
1513}
1514impl CastToF64 for u16 {
1515    fn cast_to_f64(self) -> f64 {
1516        self as f64
1517    }
1518}
1519impl CastToF64 for i32 {
1520    fn cast_to_f64(self) -> f64 {
1521        self as f64
1522    }
1523}
1524impl CastToF64 for u32 {
1525    fn cast_to_f64(self) -> f64 {
1526        self as f64
1527    }
1528}
1529impl CastToF64 for i64 {
1530    fn cast_to_f64(self) -> f64 {
1531        self as f64
1532    }
1533}
1534impl CastToF64 for u64 {
1535    fn cast_to_f64(self) -> f64 {
1536        self as f64
1537    }
1538}
1539impl CastToF64 for f32 {
1540    fn cast_to_f64(self) -> f64 {
1541        self as f64
1542    }
1543}
1544impl CastToF64 for f64 {
1545    fn cast_to_f64(self) -> f64 {
1546        self
1547    }
1548}
1549
1550/// Helper trait for `as` casts from f64.
1551trait CastFromF64 {
1552    fn cast_from_f64(v: f64) -> Self;
1553}
1554
1555impl CastFromF64 for i8 {
1556    fn cast_from_f64(v: f64) -> Self {
1557        v as i8
1558    }
1559}
1560impl CastFromF64 for i16 {
1561    fn cast_from_f64(v: f64) -> Self {
1562        v as i16
1563    }
1564}
1565impl CastFromF64 for i32 {
1566    fn cast_from_f64(v: f64) -> Self {
1567        v as i32
1568    }
1569}
1570impl CastFromF64 for i64 {
1571    fn cast_from_f64(v: f64) -> Self {
1572        v as i64
1573    }
1574}
1575impl CastFromF64 for f32 {
1576    fn cast_from_f64(v: f64) -> Self {
1577        v as f32
1578    }
1579}
1580impl CastFromF64 for f64 {
1581    fn cast_from_f64(v: f64) -> Self {
1582        v
1583    }
1584}
1585
1586/// Copy NDArray data into the output buffer with type conversion.
1587/// Returns the number of elements copied, or 0 if no data is available.
1588macro_rules! impl_read_array {
1589    (
1590        $self:expr, $buf:expr, $direct_variant:ident,
1591        ccast: [ $( $ccast_variant:ident ),* ],
1592        convert: [ $( $variant:ident ),* ]
1593    ) => {{
1594        use crate::ndarray::NDDataBuffer;
1595        let handle = match &$self.array_data {
1596            Some(h) => h,
1597            None => return Ok(0),
1598        };
1599        let guard = handle.lock();
1600        let array = match &*guard {
1601            Some(a) => a,
1602            None => return Ok(0),
1603        };
1604        let n = match &array.data {
1605            NDDataBuffer::$direct_variant(v) => copy_direct(v, $buf),
1606            $( NDDataBuffer::$ccast_variant(v) => copy_ccast(v, $buf), )*
1607            $( NDDataBuffer::$variant(v) => copy_convert(v, $buf), )*
1608        };
1609        Ok(n)
1610    }};
1611}
1612
1613impl PortDriver for PluginPortDriver {
1614    fn base(&self) -> &PortDriverBase {
1615        &self.base
1616    }
1617
1618    fn base_mut(&mut self) -> &mut PortDriverBase {
1619        &mut self.base
1620    }
1621
1622    fn io_write_int32(&mut self, user: &mut AsynUser, value: i32) -> AsynResult<()> {
1623        let reason = user.reason;
1624        let addr = user.addr;
1625        self.base.set_int32_param(reason, addr, value)?;
1626        self.base.call_param_callbacks(addr)?;
1627        // B14: reliable send on an unbounded channel — never drop param changes.
1628        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1629            reason,
1630            addr,
1631            ParamChangeValue::Int32(value),
1632        ));
1633        Ok(())
1634    }
1635
1636    fn io_write_float64(&mut self, user: &mut AsynUser, value: f64) -> AsynResult<()> {
1637        let reason = user.reason;
1638        let addr = user.addr;
1639        self.base.set_float64_param(reason, addr, value)?;
1640        self.base.call_param_callbacks(addr)?;
1641        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1642            reason,
1643            addr,
1644            ParamChangeValue::Float64(value),
1645        ));
1646        Ok(())
1647    }
1648
1649    fn io_write_octet(&mut self, user: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
1650        let reason = user.reason;
1651        let addr = user.addr;
1652        let s = String::from_utf8_lossy(data).into_owned();
1653        self.base.set_string_param(reason, addr, s.clone())?;
1654        self.base.call_param_callbacks(addr)?;
1655        let _ = self.param_change_tx.send(PluginParamMsg::Change(
1656            reason,
1657            addr,
1658            ParamChangeValue::Octet(s),
1659        ));
1660        Ok(data.len())
1661    }
1662
1663    fn read_int8_array(&mut self, _user: &AsynUser, buf: &mut [i8]) -> AsynResult<usize> {
1664        // Every integer source -> i8 is a C cast (truncating, per C++
1665        // NDArrayPool.cpp:387); float sources keep the numeric f64 conversion.
1666        impl_read_array!(
1667            self, buf, I8,
1668            ccast: [U8, I16, U16, I32, U32, I64, U64],
1669            convert: [F32, F64]
1670        )
1671    }
1672
1673    fn read_int16_array(&mut self, _user: &AsynUser, buf: &mut [i16]) -> AsynResult<usize> {
1674        impl_read_array!(
1675            self, buf, I16,
1676            ccast: [I8, U8, U16, I32, U32, I64, U64],
1677            convert: [F32, F64]
1678        )
1679    }
1680
1681    fn read_int32_array(&mut self, _user: &AsynUser, buf: &mut [i32]) -> AsynResult<usize> {
1682        impl_read_array!(
1683            self, buf, I32,
1684            ccast: [I8, U8, I16, U16, U32, I64, U64],
1685            convert: [F32, F64]
1686        )
1687    }
1688
1689    fn read_int64_array(&mut self, _user: &AsynUser, buf: &mut [i64]) -> AsynResult<usize> {
1690        impl_read_array!(
1691            self, buf, I64,
1692            ccast: [I8, U8, I16, U16, I32, U32, U64],
1693            convert: [F32, F64]
1694        )
1695    }
1696
1697    fn read_float32_array(&mut self, _user: &AsynUser, buf: &mut [f32]) -> AsynResult<usize> {
1698        impl_read_array!(
1699            self, buf, F32,
1700            ccast: [],
1701            convert: [I8, U8, I16, U16, I32, U32, I64, U64, F64]
1702        )
1703    }
1704
1705    fn read_float64_array(&mut self, _user: &AsynUser, buf: &mut [f64]) -> AsynResult<usize> {
1706        impl_read_array!(
1707            self, buf, F64,
1708            ccast: [],
1709            convert: [I8, U8, I16, U16, I32, U32, I64, U64, F32]
1710        )
1711    }
1712}
1713
1714/// Handle to a running plugin runtime. Provides access to sender and port handle.
1715#[derive(Clone)]
1716pub struct PluginRuntimeHandle {
1717    port_runtime: PortRuntimeHandle,
1718    array_sender: NDArraySender,
1719    array_output: Arc<parking_lot::Mutex<NDArrayOutput>>,
1720    port_name: String,
1721    param_tx: tokio::sync::mpsc::UnboundedSender<PluginParamMsg>,
1722    pub ndarray_params: NDArrayDriverParams,
1723    pub plugin_params: PluginBaseParams,
1724}
1725
1726impl PluginRuntimeHandle {
1727    pub fn port_runtime(&self) -> &PortRuntimeHandle {
1728        &self.port_runtime
1729    }
1730
1731    pub fn array_sender(&self) -> &NDArraySender {
1732        &self.array_sender
1733    }
1734
1735    pub fn array_output(&self) -> &Arc<parking_lot::Mutex<NDArrayOutput>> {
1736        &self.array_output
1737    }
1738
1739    /// Block until the plugin's data thread has applied every control-plane
1740    /// param change submitted before this call.
1741    ///
1742    /// `write_*_blocking` on the port handle returns once the port actor has
1743    /// recorded the write and queued it for the data plane; the data thread
1744    /// applies it (the EnableCallbacks flip, NDArrayPort/NDArrayAddr rewiring,
1745    /// processor param updates) asynchronously. This is the fence between the
1746    /// two planes: it enqueues a barrier behind every already-queued change
1747    /// and waits for the data thread to acknowledge it — the param channel is
1748    /// FIFO, so the ack implies every earlier change is fully applied.
1749    ///
1750    /// The ack additionally waits for the array queue to drain, so it also
1751    /// implies every array published before this call has been fully handled
1752    /// (processed or throttled). Under continuous array traffic the ack is
1753    /// therefore delayed until the queue momentarily empties.
1754    ///
1755    /// Returns `false` if the data thread has exited or `timeout` elapsed.
1756    pub fn wait_params_applied(&self, timeout: std::time::Duration) -> bool {
1757        let (ack_tx, ack_rx) = std::sync::mpsc::sync_channel(1);
1758        if self.param_tx.send(PluginParamMsg::Barrier(ack_tx)).is_err() {
1759            return false;
1760        }
1761        ack_rx.recv_timeout(timeout).is_ok()
1762    }
1763
1764    /// Apply C's `maxThreads` `*Configure` argument (NDPluginDriver.cpp:117,
1765    /// `:158`) to an already-built plugin.
1766    ///
1767    /// In C this is a constructor argument and nothing else: `writeInt32` has
1768    /// no `MaxThreads` arm, and `MaxThreads_RBV` is a `longin` with no output
1769    /// partner (NDPluginBase.template:290-295), so the configure line is an
1770    /// operator's only route to the ceiling. `create_plugin_runtime*` takes no
1771    /// such argument, so the configure command applies it here instead.
1772    ///
1773    /// It goes through the one owner allowed to assign `max_threads` — the
1774    /// data loop's `MAX_THREADS` arm, which floors the value, clamps
1775    /// `NumThreads` to the new ceiling and writes both readbacks back. Seeding
1776    /// the shared state from a second site instead would let the ceiling the
1777    /// pool runs on drift from the one `MaxThreads_RBV` publishes, which is
1778    /// the one disagreement a reader of that record cannot detect.
1779    ///
1780    /// Blocks until the data thread has applied it, so the ceiling is in place
1781    /// before the configure command returns, as it is in C.
1782    pub fn set_max_threads(&self, max_threads: i32) {
1783        if self
1784            .param_tx
1785            .send(PluginParamMsg::Change(
1786                self.plugin_params.max_threads,
1787                0,
1788                ParamChangeValue::Int32(max_threads),
1789            ))
1790            .is_err()
1791        {
1792            return;
1793        }
1794        let _ = self.wait_params_applied(std::time::Duration::from_secs(5));
1795    }
1796
1797    pub fn port_name(&self) -> &str {
1798        &self.port_name
1799    }
1800}
1801
1802/// Create a plugin runtime with control plane (PortActor) and data plane (processing thread).
1803///
1804/// Returns:
1805/// - `PluginRuntimeHandle` for wiring and control
1806/// - `PortRuntimeHandle` for param I/O
1807/// - `JoinHandle` for the data processing thread
1808pub fn create_plugin_runtime<P: NDPluginProcess>(
1809    port_name: &str,
1810    processor: P,
1811    pool: Arc<NDArrayPool>,
1812    queue_size: usize,
1813    ndarray_port: &str,
1814    wiring: Arc<WiringRegistry>,
1815) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
1816    create_plugin_runtime_multi_addr(
1817        port_name,
1818        processor,
1819        pool,
1820        queue_size,
1821        ndarray_port,
1822        wiring,
1823        1,
1824    )
1825}
1826
1827/// Create a plugin runtime with multi-addr support.
1828///
1829/// `max_addr` specifies the number of addresses (sub-devices) the port supports.
1830pub fn create_plugin_runtime_multi_addr<P: NDPluginProcess>(
1831    port_name: &str,
1832    mut processor: P,
1833    pool: Arc<NDArrayPool>,
1834    queue_size: usize,
1835    ndarray_port: &str,
1836    wiring: Arc<WiringRegistry>,
1837    max_addr: usize,
1838) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
1839    // Param change channel (control plane -> data plane)
1840    // B14: unbounded so control-plane param changes (e.g. autosave restoring
1841    // hundreds of PVs at IOC init) are never silently dropped before the
1842    // data plane sees them.
1843    let (param_tx, param_rx) = tokio::sync::mpsc::unbounded_channel::<PluginParamMsg>();
1844    let handle_param_tx = param_tx.clone();
1845
1846    // Capture plugin type and array data handle before mutable borrow
1847    let plugin_type_name = processor.plugin_type().to_string();
1848    let compression_aware = processor.compression_aware();
1849    let does_array_callbacks = processor.does_array_callbacks();
1850    let array_data = processor.array_data_handle();
1851
1852    // Create the port driver for control plane
1853    let driver = PluginPortDriver::new(
1854        port_name,
1855        &plugin_type_name,
1856        queue_size,
1857        ndarray_port,
1858        max_addr,
1859        param_tx,
1860        &mut processor,
1861        array_data,
1862        &pool,
1863    )
1864    .expect("failed to create plugin port driver");
1865
1866    let ndarray_params = driver.ndarray_params;
1867    let plugin_params = driver.plugin_params;
1868    let std_array_data_param = driver.std_array_data_param;
1869
1870    // Create port runtime (actor thread for param I/O).
1871    //
1872    // Constructor-shaped, so a failure here is fatal and cannot be anything
1873    // else: this function hands back the built plugin and has no error channel
1874    // to its `*Configure` caller. C's equivalent — `asynPortDriver`'s
1875    // constructor printing and `throw`ing on a failed `registerPort`
1876    // (asynPortDriver.cpp:4036-4040) — is caught by iocsh
1877    // (iocsh.cpp:1269-1279) and the script continues by default
1878    // (iocsh.cpp:995, :1123), leaving the C IOC serving without the port. We
1879    // deviate on purpose: see `port_runtime_unavailable`.
1880    let (port_runtime, _actor_jh) = create_port_runtime(driver, RuntimeConfig::default())
1881        .unwrap_or_else(|e| port_runtime_unavailable(port_name, &e));
1882
1883    // Clone port handle for the data thread to write params back
1884    let port_handle = port_runtime.port_handle().clone();
1885
1886    // Array channel (data plane)
1887    let (array_sender, array_rx) = ndarray_channel(port_name, queue_size);
1888
1889    // Shared mode flags
1890    let enabled = Arc::new(AtomicBool::new(false));
1891    let blocking_mode = Arc::new(AtomicBool::new(false));
1892
1893    // Shared processor (accessible from data thread)
1894    let array_output = Arc::new(parking_lot::Mutex::new(NDArrayOutput::new()));
1895    let array_output_for_handle = array_output.clone();
1896    // B13/G6: register this plugin's output so the WiringRegistry is the
1897    // single source of truth for runtime rewiring (PluginManager::add_plugin
1898    // would also register it, but direct callers must not bypass the
1899    // registry). Registered under every address in 0..max_addr so a
1900    // downstream plugin can select a non-zero NDArrayAddr.
1901    wiring.register_output_addrs(port_name, max_addr, array_output.clone());
1902    // G1/B1: the DroppedArrays counter is owned by this plugin and shared with
1903    // every upstream sender so full-queue drops on our input queue are counted.
1904    let dropped_arrays_counter = array_sender.dropped_arrays_counter().clone();
1905    // The pre-queue gate: C runs both halves of `driverCallback` on the
1906    // producer's thread before `trySend`, so they live with the sender.
1907    let admission = array_sender.admission().clone();
1908    admission.set_compression_aware(compression_aware);
1909    // The processor is shared with the callback threads rather than owned by
1910    // `SharedProcessorInner`: the whole point of `&self` on `process_array` is
1911    // that it runs off the data loop, outside this mutex.
1912    let processor = Arc::new(processor);
1913    let shared = Arc::new(parking_lot::Mutex::new(SharedProcessorInner {
1914        output: array_output,
1915        pool,
1916        ndarray_params,
1917        plugin_params,
1918        port_handle,
1919        array_counter: 0,
1920        std_array_data_param,
1921        // C++ default NDArrayCallbacks = 1 (deliver downstream); terminal
1922        // plugins (StdArrays/Attribute/File) override `does_array_callbacks` to 0.
1923        array_callbacks: does_array_callbacks,
1924        sort_mode: 0,
1925        sort_time: 0.0,
1926        sort_size: 10,
1927        sort_buffer: SortBuffer::new(),
1928        dropped_arrays: dropped_arrays_counter,
1929        max_byte_rate: 0.0,
1930        throttler: super::throttler::Throttler::new(0.0),
1931        prev_input_array: None,
1932        last_output_array: None,
1933        num_pre_alloc_buffers: 0,
1934        dims_prev: vec![0i32; crate::ndarray::ND_ARRAY_MAX_DIMS],
1935        nd_array_addr: PLUGIN_NDARRAY_ADDR,
1936        max_threads: PLUGIN_MAX_THREADS,
1937        num_threads: PLUGIN_NUM_THREADS,
1938    }));
1939
1940    let data_enabled = enabled.clone();
1941    let data_blocking = blocking_mode.clone();
1942
1943    let mut array_sender = array_sender;
1944    array_sender.set_mode_flags(enabled, blocking_mode);
1945    // The data loop owns queue replacement; the handle is weak so it does not
1946    // keep the channel open past the last real sender.
1947    let data_queue_handle = array_sender.self_queue_handle();
1948
1949    // Capture wiring info for data loop
1950    let sender_port_name = port_name.to_string();
1951    let initial_upstream = ndarray_port.to_string();
1952
1953    // Spawn data processing thread
1954    let data_jh = MandatoryThread::new(
1955        format!("plugin-data-{port_name}"),
1956        // `asynNDArrayDriver.cpp:878` — `if (priority <= 0) priority =
1957        // epicsThreadPriorityMedium`, and that is what `NDPluginDriver` hands
1958        // its callback threads (`NDPluginDriver.cpp:1000`).
1959        ThreadPriority::Medium,
1960        // `asynNDArrayDriver.cpp:876` — `if (stackSize <= 0) stackSize =
1961        // epicsThreadGetStackSize(epicsThreadStackMedium)`.
1962        StackSizeClass::Medium,
1963    )
1964    .spawn(move || {
1965        plugin_data_loop(
1966            shared,
1967            processor,
1968            array_rx,
1969            data_queue_handle,
1970            param_rx,
1971            plugin_params,
1972            data_enabled,
1973            data_blocking,
1974            sender_port_name,
1975            initial_upstream,
1976            wiring,
1977        );
1978    });
1979
1980    let handle = PluginRuntimeHandle {
1981        port_runtime,
1982        array_sender,
1983        array_output: array_output_for_handle,
1984        port_name: port_name.to_string(),
1985        param_tx: handle_param_tx,
1986        ndarray_params,
1987        plugin_params,
1988    };
1989
1990    (handle, data_jh)
1991}
1992
1993/// Build a param batch reporting the input-queue depth.
1994///
1995/// `QUEUE_SIZE` = total capacity, `QUEUE_FREE` = free slots. G2: the param is
1996/// named `QUEUE_FREE` and the reconciled semantics are *free slots*, matching
1997/// C++ `NDPluginDriverQueueFree = queueSize - pending()`.
1998fn queue_status_batch(
1999    plugin_params: &PluginBaseParams,
2000    max_capacity: usize,
2001    free: i32,
2002) -> ParamBatch {
2003    use asyn_rs::request::ParamSetValue;
2004    ParamBatch {
2005        addr0: vec![
2006            ParamSetValue::new(
2007                plugin_params.queue_size,
2008                0,
2009                ParamValue::Int32(max_capacity as i32),
2010            ),
2011            ParamSetValue::new(plugin_params.queue_use, 0, ParamValue::Int32(free)),
2012        ],
2013        extra: std::collections::HashMap::new(),
2014    }
2015}
2016
2017/// Write a validated/clamped int32 value back into the param library so the
2018/// RBV reflects the accepted value (G4 NumThreads/MaxThreads clamping).
2019async fn clamp_writeback(port: &PortHandle, reason: usize, value: i32) {
2020    use asyn_rs::request::ParamSetValue;
2021    let _ = port
2022        .set_params_and_notify(
2023            0,
2024            vec![ParamSetValue::new(
2025                reason,
2026                0,
2027                asyn_rs::param::ParamValue::Int32(value),
2028            )],
2029        )
2030        .await;
2031}
2032
2033/// One array handed to a callback thread.
2034///
2035/// The `ArrayMessage` travels with the work because dropping it is what
2036/// signals completion to a blocking sender and decrements the queued-array
2037/// counter — that must happen once the frame has been processed and published,
2038/// not at dispatch.
2039struct WorkItem {
2040    msg: ArrayMessage,
2041}
2042
2043/// A processed frame on its way back to the data loop.
2044struct DoneItem {
2045    msg: ArrayMessage,
2046    result: ProcessResult,
2047    elapsed_ms: f64,
2048}
2049
2050/// The port of C's `pToThreadMsgQ_` (NDPluginDriver.cpp:985): one queue, N
2051/// blocking receivers. `tokio::sync::mpsc` is single-consumer and
2052/// `crossbeam-channel` is not a dependency of this crate, so this is a plain
2053/// mutex plus condvar. It is unbounded because the real bound is the plugin's
2054/// input channel: the data loop dispatches at most `numThreads` frames before
2055/// it stops receiving, which is what leaves `QueueFree` meaning what C means
2056/// by it.
2057struct WorkQueue {
2058    inner: parking_lot::Mutex<WorkQueueInner>,
2059    ready: parking_lot::Condvar,
2060}
2061
2062struct WorkQueueInner {
2063    items: std::collections::VecDeque<WorkItem>,
2064    closed: bool,
2065}
2066
2067impl WorkQueue {
2068    fn new() -> Self {
2069        Self {
2070            inner: parking_lot::Mutex::new(WorkQueueInner {
2071                items: std::collections::VecDeque::new(),
2072                closed: false,
2073            }),
2074            ready: parking_lot::Condvar::new(),
2075        }
2076    }
2077
2078    fn push(&self, item: WorkItem) {
2079        self.inner.lock().items.push_back(item);
2080        self.ready.notify_one();
2081    }
2082
2083    /// Block until an item is available; `None` once the queue is closed and
2084    /// drained.
2085    fn pop(&self) -> Option<WorkItem> {
2086        let mut guard = self.inner.lock();
2087        loop {
2088            if let Some(item) = guard.items.pop_front() {
2089                return Some(item);
2090            }
2091            if guard.closed {
2092                return None;
2093            }
2094            self.ready.wait(&mut guard);
2095        }
2096    }
2097
2098    /// C sends one `ToThreadMessageExit` per thread (NDPluginDriver.cpp:1037);
2099    /// closing the queue wakes all of them at once.
2100    fn close(&self) {
2101        self.inner.lock().closed = true;
2102        self.ready.notify_all();
2103    }
2104}
2105
2106/// The callback threads of C's `createCallbackThreads`
2107/// (NDPluginDriver.cpp:996-1001): `numThreads` OS threads all receiving from
2108/// one queue, so a plugin whose `process_array` is slow overlaps frames
2109/// instead of serialising them.
2110struct WorkerPool {
2111    queue: Arc<WorkQueue>,
2112    threads: Vec<thread::JoinHandle<()>>,
2113    size: usize,
2114}
2115
2116impl WorkerPool {
2117    /// How many frames may be in flight at once — the dispatch bound the data
2118    /// loop applies so undispatched arrays stay counted in `QueueFree`.
2119    fn size(&self) -> usize {
2120        self.size
2121    }
2122
2123    fn dispatch(&self, msg: ArrayMessage) {
2124        self.queue.push(WorkItem { msg });
2125    }
2126
2127    /// Close the queue and join every worker, C's `deleteCallbackThreads`
2128    /// (NDPluginDriver.cpp:1015-1074).
2129    ///
2130    /// A worker never waits on the data loop — it pushes its `DoneItem` into
2131    /// an unbounded channel and returns — so joining from the loop cannot
2132    /// deadlock. Frames finished during the join stay in the done channel for
2133    /// the caller to drain before it swaps in the new pool.
2134    ///
2135    /// Idempotent, because `Drop` runs it again on a pool that was respawned
2136    /// out of the way.
2137    fn shutdown(&mut self) {
2138        self.queue.close();
2139        for t in self.threads.drain(..) {
2140            let _ = t.join();
2141        }
2142    }
2143}
2144
2145impl Drop for WorkerPool {
2146    /// A dropped pool must not leave its threads parked on the queue forever:
2147    /// the data loop drops it when the plugin's input channel closes.
2148    fn drop(&mut self) {
2149        self.shutdown();
2150    }
2151}
2152
2153/// Spawn `num_threads` callback threads over one shared queue.
2154fn spawn_worker_pool<P: NDPluginProcess>(
2155    port_name: &str,
2156    shared: Arc<parking_lot::Mutex<SharedProcessorInner>>,
2157    processor: Arc<P>,
2158    array_pool: Arc<NDArrayPool>,
2159    num_threads: usize,
2160) -> (WorkerPool, tokio::sync::mpsc::UnboundedReceiver<DoneItem>) {
2161    let queue = Arc::new(WorkQueue::new());
2162    let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
2163    let size = num_threads.max(1);
2164    let mut threads = Vec::with_capacity(size);
2165    for i in 0..size {
2166        let queue = Arc::clone(&queue);
2167        let shared = Arc::clone(&shared);
2168        let processor = Arc::clone(&processor);
2169        let array_pool = Arc::clone(&array_pool);
2170        let done_tx = done_tx.clone();
2171        // C names them `%s_Plugin_%d` from 1 (NDPluginDriver.cpp:997-998) and
2172        // gives them the driver's stack size and priority (`:1000`).
2173        let jh = MandatoryThread::new(
2174            format!("{port_name}_Plugin_{}", i + 1),
2175            ThreadPriority::Medium,
2176            StackSizeClass::Medium,
2177        )
2178        .spawn(move || {
2179            while let Some(item) = queue.pop() {
2180                // C's `beginProcessCallbacks` runs here, on the callback
2181                // thread at the top of `processCallbacks` — so the cached
2182                // input names the frame being worked on, not the newest one
2183                // queued behind it.
2184                shared.lock().cache_input_array(&item.msg.array);
2185                let t0 = std::time::Instant::now();
2186                let result = processor.process_array(&item.msg.array, &array_pool);
2187                let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
2188                if done_tx
2189                    .send(DoneItem {
2190                        msg: item.msg,
2191                        result,
2192                        elapsed_ms,
2193                    })
2194                    .is_err()
2195                {
2196                    break;
2197                }
2198            }
2199        });
2200        threads.push(jh);
2201    }
2202    (
2203        WorkerPool {
2204            queue,
2205            threads,
2206            size,
2207        },
2208        done_rx,
2209    )
2210}
2211
2212/// Finish a frame a callback thread has processed: fold the result into the
2213/// shared state and publish it.
2214///
2215/// Everything here runs on the data loop, so the counters, the sort buffer and
2216/// the output senders keep a single owner however many workers are running —
2217/// which is what C gets from taking the port lock around `processCallbacks`
2218/// while the plugin releases it for the time-consuming part
2219/// (NDPluginDriver.cpp:516-517).
2220async fn complete_frame(
2221    shared: &Arc<parking_lot::Mutex<SharedProcessorInner>>,
2222    item: DoneItem,
2223    scatter_cursor: &mut usize,
2224) {
2225    let DoneItem {
2226        msg,
2227        result,
2228        elapsed_ms,
2229    } = item;
2230    let (output, senders, port) = {
2231        let mut guard = shared.lock();
2232        let output = guard.post_process(&msg.array, result, elapsed_ms);
2233        let senders = guard.output.lock().senders_clone();
2234        let port = guard.port_handle.clone();
2235        (output, senders, port)
2236    };
2237    output.publish_arrays(&senders, scatter_cursor).await;
2238    output.batch.flush(&port).await;
2239    // msg dropped here → completion signaled (if tracked)
2240}
2241
2242fn plugin_data_loop<P: NDPluginProcess>(
2243    shared: Arc<parking_lot::Mutex<SharedProcessorInner>>,
2244    processor: Arc<P>,
2245    mut array_rx: NDArrayReceiver,
2246    // Lets a QueueSize write replace the queue behind every upstream port's
2247    // sender. Weak, so holding it cannot stop this loop seeing its own
2248    // shutdown.
2249    queue_handle: crate::plugin::channel::SelfQueueHandle,
2250    mut param_rx: tokio::sync::mpsc::UnboundedReceiver<PluginParamMsg>,
2251    plugin_params: PluginBaseParams,
2252    enabled: Arc<AtomicBool>,
2253    blocking_mode: Arc<AtomicBool>,
2254    sender_port_name: String,
2255    initial_upstream: String,
2256    wiring: Arc<WiringRegistry>,
2257) {
2258    let enable_callbacks_reason = plugin_params.enable_callbacks;
2259    let blocking_callbacks_reason = plugin_params.blocking_callbacks;
2260    let min_callback_time_reason = plugin_params.min_callback_time;
2261    let sort_mode_reason = plugin_params.sort_mode;
2262    let sort_time_reason = plugin_params.sort_time;
2263    let sort_size_reason = plugin_params.sort_size;
2264    let nd_array_port_reason = plugin_params.nd_array_port;
2265    let nd_array_addr_reason = plugin_params.nd_array_addr;
2266    let process_plugin_reason = plugin_params.process_plugin;
2267    let max_byte_rate_reason = plugin_params.max_byte_rate;
2268    let num_threads_reason = plugin_params.num_threads;
2269    let max_threads_reason = plugin_params.max_threads;
2270    let queue_size_reason = plugin_params.queue_size;
2271    let array_callbacks_reason = shared.lock().ndarray_params.array_callbacks;
2272    // G6: the upstream connection is keyed by (port, addr). `current_upstream`
2273    // is the base port name; `current_addr` is the selected NDArrayAddr; the
2274    // effective WiringRegistry key is computed by `upstream_key`.
2275    let mut current_upstream = initial_upstream;
2276    let mut current_addr: i32 = 0;
2277    let rt = tokio::runtime::Builder::new_current_thread()
2278        .enable_all()
2279        .build()
2280        .unwrap();
2281    rt.block_on(async {
2282        // Sort flush timer — starts disabled (very long interval).
2283        // Re-created when sort_time changes.
2284        let mut sort_flush_interval = tokio::time::interval(std::time::Duration::from_secs(3600));
2285        let mut sort_flush_active = false;
2286        // Last published QueueFree value — only flush the queue params when it
2287        // changes, so a steady queue depth does not spam param callbacks.
2288        let mut last_queue_free: Option<i32> = None;
2289        // Persistent scatter cursor (C++ NDPluginScatter::nextClient_): advances
2290        // per delivery *attempt* across frames so the round-robin survives
2291        // reroutes past full consumers. One per plugin instance, for its
2292        // lifetime — matching `nextClient_(1)` set once at construction.
2293        let mut scatter_cursor: usize = 0;
2294        // Barriers held until the array queue drains. A barrier acks only at
2295        // full quiescence — params applied AND no queued arrays — because a
2296        // param applied while an older array still waits in the queue would
2297        // retroactively change that array's processing (e.g. a
2298        // MinCallbackTime reset un-throttling it). See `PluginParamMsg`.
2299        let mut held_barriers: Vec<std::sync::mpsc::SyncSender<()>> = Vec::new();
2300        // The callback threads (C++ `createCallbackThreads`,
2301        // NDPluginDriver.cpp:996-1001) and the frames currently out with them.
2302        // `in_flight` is the dispatch bound: an array is left in `array_rx`
2303        // until a worker is free, so `QueueFree` keeps counting the same thing
2304        // C counts.
2305        let array_pool = shared.lock().pool.clone();
2306        let initial_threads = shared.lock().num_threads.max(1) as usize;
2307        let (mut pool, mut done_rx) = spawn_worker_pool(
2308            &sender_port_name,
2309            Arc::clone(&shared),
2310            Arc::clone(&processor),
2311            Arc::clone(&array_pool),
2312            initial_threads,
2313        );
2314        let mut in_flight: usize = 0;
2315        // The plugin's admission gate. Held across queue replacements: it
2316        // belongs to the plugin, not to the queue in front of it, so a
2317        // QueueSize write must not reset the MinCallbackTime clock.
2318        let admission = Arc::clone(array_rx.admission());
2319
2320        loop {
2321            // Release held barriers once the array queue is empty AND every
2322            // dispatched frame has come back — a barrier acks at quiescence,
2323            // and with N workers running an empty input queue no longer implies
2324            // the last array is finished.
2325            if !held_barriers.is_empty() && array_rx.pending() == 0 && in_flight == 0 {
2326                for ack in held_barriers.drain(..) {
2327                    let _ = ack.try_send(());
2328                }
2329            }
2330            tokio::select! {
2331                // Stop receiving once every callback thread is busy: the array
2332                // then stays queued, which is what C's blocking
2333                // `pToThreadMsgQ_->receive()` does on each of its N threads.
2334                msg = array_rx.recv_msg(), if in_flight < pool.size() => {
2335                    match msg {
2336                        Some(msg) => {
2337                            // Note there is no `enabled` check here. In C
2338                            // `EnableCallbacks=0` cancels the array interrupt
2339                            // (`:711-720` -> `setArrayInterrupt(0)`) and
2340                            // touches nothing else, so the callback threads go
2341                            // on emptying `pToThreadMsgQ_` — which is exactly
2342                            // what `deleteCallbackThreads` relies on when it
2343                            // disables the interrupt and then waits for
2344                            // `pending()` to reach zero (`:1026-1034`). The
2345                            // flag means "is the interrupt registered", and
2346                            // the producer is the only place that can answer.
2347                            //
2348                            // Every array that reaches here already passed both
2349                            // halves of C's `driverCallback`
2350                            // (NDPluginDriver.cpp:383-418): the producer ran
2351                            // them before `trySend`, which is where C runs
2352                            // them. Nothing is left to classify.
2353                            let port = shared.lock().port_handle.clone();
2354                            // G2: update QueueSize/QueueFree from the channel
2355                            // depth (C++ NDPluginDriver.cpp:512-513). QueueFree
2356                            // = max_capacity - pending. Only flush when the
2357                            // value changed to avoid no-op param callbacks.
2358                            let max_cap = array_rx.max_capacity();
2359                            let free = max_cap.saturating_sub(array_rx.pending()) as i32;
2360                            let queue_batch = if last_queue_free != Some(free) {
2361                                last_queue_free = Some(free);
2362                                Some(queue_status_batch(&plugin_params, max_cap, free))
2363                            } else {
2364                                None
2365                            };
2366                            pool.dispatch(msg);
2367                            in_flight += 1;
2368                            if let Some(qb) = queue_batch {
2369                                qb.flush(&port).await;
2370                            }
2371                        }
2372                        None => break,
2373                    }
2374                }
2375                done = done_rx.recv(), if in_flight > 0 => {
2376                    match done {
2377                        Some(item) => {
2378                            in_flight -= 1;
2379                            complete_frame(&shared, item, &mut scatter_cursor).await;
2380                        }
2381                        // Every worker is gone without a reply: nothing can
2382                        // come back, so stop waiting on frames that are lost.
2383                        None => in_flight = 0,
2384                    }
2385                }
2386                // A drop the producer counted. C publishes DroppedArrays from
2387                // `driverCallback` itself (`callParamCallbacks()` at `:449`),
2388                // on the producer's thread and whether or not the frame was
2389                // ever processed; here only the data loop may touch the port,
2390                // so the producer wakes it instead. Without this a run that
2391                // drops every array — every array compressed on a non-aware
2392                // plugin, say — would leave the readback reading 0 forever.
2393                _ = admission.counted_drop() => {
2394                    let (out, port) = {
2395                        let guard = shared.lock();
2396                        (guard.dropped_arrays_only_batch(), guard.port_handle.clone())
2397                    };
2398                    out.batch.flush(&port).await;
2399                }
2400                param = param_rx.recv() => {
2401                    match param {
2402                        // Barrier: every Change enqueued before it has been
2403                        // applied by the arms below (FIFO channel). Ack is
2404                        // deferred to the top-of-loop release, which also
2405                        // requires the array queue to be drained — a gone
2406                        // waiter is not an error.
2407                        Some(PluginParamMsg::Barrier(ack)) => {
2408                            held_barriers.push(ack);
2409                        }
2410                        Some(PluginParamMsg::Change(reason, addr, value)) => {
2411                            // The base-class pool branch, which a plugin port
2412                            // inherits (NDPluginDriver.cpp:690-692). Answered
2413                            // first because none of these is a plugin
2414                            // parameter and none of the arms below can match.
2415                            if let Some(out) = {
2416                                let mut guard = shared.lock();
2417                                guard.pool_control_write(reason, value.as_i32())
2418                            } {
2419                                let port = shared.lock().port_handle.clone();
2420                                out.batch.flush(&port).await;
2421                            }
2422                            if reason == enable_callbacks_reason {
2423                                let on = value.as_i32() != 0;
2424                                enabled.store(on, Ordering::Release);
2425                                // B6: disabling releases the cached input array
2426                                // (C++ writeInt32 NDPluginDriver.cpp:712-722).
2427                                if !on {
2428                                    shared.lock().prev_input_array = None;
2429                                }
2430                            }
2431                            if reason == blocking_callbacks_reason {
2432                                blocking_mode.store(value.as_i32() != 0, Ordering::Release);
2433                            }
2434                            // NDArrayCallbacks gates downstream array delivery
2435                            // (C++ endProcessCallbacks NDPluginDriver.cpp:
2436                            // 257-265). Processing still runs; only delivery is
2437                            // suppressed when 0.
2438                            if reason == array_callbacks_reason {
2439                                shared.lock().array_callbacks = value.as_i32() != 0;
2440                            }
2441                            // Handle MinCallbackTime param change
2442                            // The gate is on the producer side, where C reads
2443                            // MinCallbackTime (`:398`).
2444                            if reason == min_callback_time_reason {
2445                                admission.set_min_callback_time(value.as_f64());
2446                            }
2447                            // G7: MaxByteRate change resets the output throttler
2448                            // (C++ writeFloat64 NDPluginDriver.cpp:788-790).
2449                            if reason == max_byte_rate_reason {
2450                                let rate = value.as_f64();
2451                                let mut guard = shared.lock();
2452                                guard.max_byte_rate = rate;
2453                                guard.throttler.reset(rate);
2454                            }
2455                            // G4: NumThreads / MaxThreads are validated and
2456                            // clamped on write (C++ `createCallbackThreads`,
2457                            // NDPluginDriver.cpp:955-971), and the accepted
2458                            // value is written back so the RBV reports what is
2459                            // actually running.
2460                            if reason == max_threads_reason {
2461                                // Scope the guard so it is released before await.
2462                                let (port, clamped, mt) = {
2463                                    let mut guard = shared.lock();
2464                                    guard.max_threads = value.as_i32().max(1);
2465                                    let clamped =
2466                                        guard.num_threads.clamp(1, guard.max_threads);
2467                                    guard.num_threads = clamped;
2468                                    (guard.port_handle.clone(), clamped, guard.max_threads)
2469                                };
2470                                clamp_writeback(&port, num_threads_reason, clamped).await;
2471                                clamp_writeback(&port, max_threads_reason, mt).await;
2472                            }
2473                            if reason == num_threads_reason {
2474                                let (port, clamped) = {
2475                                    let mut guard = shared.lock();
2476                                    let clamped =
2477                                        value.as_i32().clamp(1, guard.max_threads.max(1));
2478                                    guard.num_threads = clamped;
2479                                    (guard.port_handle.clone(), clamped)
2480                                };
2481                                clamp_writeback(&port, num_threads_reason, clamped).await;
2482                            }
2483                            // C `writeInt32` (NDPluginDriver.cpp:730-733): a
2484                            // NumThreads or QueueSize write deletes the
2485                            // callback threads and creates them again. A
2486                            // MaxThreads write that clamps NumThreads downward
2487                            // lands here too, because the pool is respawned
2488                            // whenever the accepted NumThreads stops matching
2489                            // it.
2490                            //
2491                            // QueueSize respawns the threads too, because C
2492                            // creates queue and threads together; the queue
2493                            // itself was already replaced just above.
2494                            // C's QueueSize write ends in `new
2495                            // epicsMessageQueue(queueSize, ...)`
2496                            // (NDPluginDriver.cpp:730-733 -> :985), so the
2497                            // depth an operator asks for is the depth they
2498                            // get. Replace the queue first: publishes racing
2499                            // this land in one queue or the other and none is
2500                            // refused. Then drain whatever the old one still
2501                            // holds, so the resize costs no frames — C loses
2502                            // any that arrive while it waits for the old queue
2503                            // to empty with the interrupt switched off
2504                            // (`deleteCallbackThreads`).
2505                            if reason == queue_size_reason {
2506                                let want = value.as_i32().max(1) as usize;
2507                                let mut old_rx = queue_handle
2508                                    .replace_queue(want)
2509                                    .map(|new_rx| std::mem::replace(&mut array_rx, new_rx));
2510                                while let Some(old) = old_rx.as_mut() {
2511                                    if in_flight < pool.size() {
2512                                        if let Some(msg) = old.try_recv_msg() {
2513                                            // Already through the gate when it
2514                                            // was queued, and C's resize
2515                                            // processes the old queue to
2516                                            // empty with the interrupt already
2517                                            // off (`:1026-1034`) — so neither
2518                                            // the gate nor the enable flag is
2519                                            // consulted again here.
2520                                            pool.dispatch(msg);
2521                                            in_flight += 1;
2522                                            continue;
2523                                        }
2524                                    }
2525                                    if in_flight == 0 {
2526                                        break;
2527                                    }
2528                                    match done_rx.recv().await {
2529                                        Some(item) => {
2530                                            in_flight -= 1;
2531                                            complete_frame(
2532                                                &shared,
2533                                                item,
2534                                                &mut scatter_cursor,
2535                                            )
2536                                            .await;
2537                                        }
2538                                        None => {
2539                                            in_flight = 0;
2540                                            break;
2541                                        }
2542                                    }
2543                                }
2544                                // C republishes QueueFree at the new depth
2545                                // (`:1008`).
2546                                last_queue_free = None;
2547                            }
2548                            if reason == num_threads_reason
2549                                || reason == max_threads_reason
2550                                || reason == queue_size_reason
2551                            {
2552                                let want = shared.lock().num_threads.max(1) as usize;
2553                                if reason == queue_size_reason || want != pool.size() {
2554                                    let (new_pool, new_done) = spawn_worker_pool(
2555                                        &sender_port_name,
2556                                        Arc::clone(&shared),
2557                                        Arc::clone(&processor),
2558                                        Arc::clone(&array_pool),
2559                                        want,
2560                                    );
2561                                    let mut old_pool =
2562                                        std::mem::replace(&mut pool, new_pool);
2563                                    let mut old_done =
2564                                        std::mem::replace(&mut done_rx, new_done);
2565                                    old_pool.shutdown();
2566                                    // Every worker has exited, so `recv` runs
2567                                    // out exactly when the last frame the old
2568                                    // pool finished has been published.
2569                                    while let Some(item) = old_done.recv().await {
2570                                        in_flight = in_flight.saturating_sub(1);
2571                                        complete_frame(&shared, item, &mut scatter_cursor)
2572                                            .await;
2573                                    }
2574                                    // C republishes QueueFree after
2575                                    // createCallbackThreads (`:1006`).
2576                                    last_queue_free = None;
2577                                }
2578                            }
2579                            // G6: NDArrayAddr selects a source address of a
2580                            // multi-address driver — reconnect on change
2581                            // (C++ writeInt32 NDPluginDriver.cpp:724-728).
2582                            if reason == nd_array_addr_reason {
2583                                let new_addr = value.as_i32();
2584                                if new_addr != current_addr {
2585                                    let old_key = upstream_key(&current_upstream, current_addr);
2586                                    let new_key = upstream_key(&current_upstream, new_addr);
2587                                    shared.lock().nd_array_addr = new_addr;
2588                                    match wiring.rewire_by_name(
2589                                        &sender_port_name,
2590                                        &old_key,
2591                                        &new_key,
2592                                    ) {
2593                                        Ok(()) => current_addr = new_addr,
2594                                        Err(e) => {
2595                                            eprintln!("NDArrayAddr reconnect failed: {e}");
2596                                            shared.lock().nd_array_addr = current_addr;
2597                                        }
2598                                    }
2599                                }
2600                            }
2601                            // G5: ProcessPlugin re-injects the cached input
2602                            // array (C++ writeInt32 NDPluginDriver.cpp:739-746)
2603                            // by handing it to `driverCallback` (`:741`) — the
2604                            // same entry point a detector array arrives
2605                            // through. So it is not a shortcut past the queue:
2606                            // it is classified and enqueued like any arrival,
2607                            // waits behind whatever is already queued, is
2608                            // refused and counted when the queue is full, and
2609                            // runs on a callback thread. Only `driverCallback`'s
2610                            // blocking arm (`:419-422`) runs the work on the
2611                            // writer's own thread, which is the inline branch
2612                            // below.
2613                            if reason == process_plugin_reason && value.as_i32() != 0 {
2614                                let cached = shared.lock().prev_input_array.clone();
2615                                let blocking = blocking_mode.load(Ordering::Acquire);
2616                                match cached {
2617                                    Some(array) if !blocking => {
2618                                        // `try_enqueue` runs both gates before
2619                                        // it touches the queue, exactly where
2620                                        // `driverCallback` runs them, so a
2621                                        // re-injection inside the
2622                                        // MinCallbackTime window takes no queue
2623                                        // slot and a compressed one on a
2624                                        // non-aware plugin is counted without
2625                                        // taking one either.
2626                                        if let Some(
2627                                            PublishOutcome::DroppedQueueFull
2628                                            | PublishOutcome::DroppedCompressed,
2629                                        ) = queue_handle.try_enqueue(array)
2630                                        {
2631                                            // The counter the enqueue bumped is
2632                                            // the plugin's own; only the
2633                                            // readback is owed (C `:387-390`
2634                                            // and `:436-441`, then
2635                                            // `callParamCallbacks` at `:751`).
2636                                            let (out, port) = {
2637                                                let guard = shared.lock();
2638                                                (
2639                                                    guard.dropped_arrays_only_batch(),
2640                                                    guard.port_handle.clone(),
2641                                                )
2642                                            };
2643                                            out.batch.flush(&port).await;
2644                                        }
2645                                    }
2646                                    Some(array) => {
2647                                        // Blocking mode runs the work on the
2648                                        // writer's own thread, but C decides
2649                                        // admission BEFORE it reads
2650                                        // `blockingCallbacks` (`:385-407` vs
2651                                        // `:419`), so the same two gates apply
2652                                        // to the inline arm.
2653                                        match admission.classify(&array) {
2654                                            Admission::Throttled => {}
2655                                            Admission::DropCompressed => {
2656                                                let (out, port) = {
2657                                                    let guard = shared.lock();
2658                                                    guard
2659                                                        .dropped_arrays
2660                                                        .fetch_add(1, Ordering::AcqRel);
2661                                                    (
2662                                                        guard.dropped_arrays_only_batch(),
2663                                                        guard.port_handle.clone(),
2664                                                    )
2665                                                };
2666                                                out.batch.flush(&port).await;
2667                                            }
2668                                            Admission::Admit => {
2669                                                let (po, senders, port) = {
2670                                                    let mut guard = shared.lock();
2671                                                    let output = guard
2672                                                        .process_and_publish(&*processor, &array);
2673                                                    let senders =
2674                                                        guard.output.lock().senders_clone();
2675                                                    let port = guard.port_handle.clone();
2676                                                    (output, senders, port)
2677                                                };
2678                                                po.publish_arrays(&senders, &mut scatter_cursor)
2679                                                    .await;
2680                                                po.batch.flush(&port).await;
2681                                            }
2682                                        }
2683                                    }
2684                                    None => {
2685                                        // C parity: NDPluginDriver::writeInt32
2686                                        // (NDPluginDriver.cpp:743) logs this at
2687                                        // ASYN_TRACE_WARNING, which is OFF in the
2688                                        // default port trace mask. Gate through the
2689                                        // port's trace facility so iocInit stays
2690                                        // silent unless WARNING is enabled, instead
2691                                        // of an unconditional eprintln! that spams
2692                                        // stderr on every PINI ProcessPlugin trigger.
2693                                        // The asyn port registry that exposes the
2694                                        // per-port mask only exists with the `ioc`
2695                                        // integration; a bare plugin build has no
2696                                        // trace facility to consult, so it stays
2697                                        // silent there too.
2698                                        #[cfg(feature = "ioc")]
2699                                        if let Some(entry) =
2700                                            asyn_rs::asyn_record::get_port(&sender_port_name)
2701                                        {
2702                                            asyn_rs::asyn_trace!(
2703                                                entry.handle.trace(),
2704                                                sender_port_name.as_str(),
2705                                                asyn_rs::trace::TraceMask::WARNING,
2706                                                "plugin {sender_port_name}: ProcessPlugin \
2707                                                 requested but no input array cached"
2708                                            );
2709                                        }
2710                                    }
2711                                }
2712                            }
2713                            // B12/B15: a control-plane write of any mirrored
2714                            // counter resets it. `resync_counter` owns the whole
2715                            // family so none can be published without a reset.
2716                            shared.lock().resync_counter(reason, value.as_i32());
2717                            // Handle sort param changes
2718                            if reason == sort_mode_reason {
2719                                let mode = value.as_i32();
2720                                // Scope the guard so clippy can verify the lock
2721                                // is released before any await.
2722                                let flush_work = {
2723                                    let mut guard = shared.lock();
2724                                    guard.sort_mode = mode;
2725                                    if mode == 0 {
2726                                        let output = guard.flush_sort_buffer();
2727                                        let senders = guard.output.lock().senders_clone();
2728                                        let port = guard.port_handle.clone();
2729                                        sort_flush_active = false;
2730                                        Some((output, senders, port))
2731                                    } else {
2732                                        sort_flush_active = guard.sort_time > 0.0;
2733                                        if sort_flush_active {
2734                                            let dur = epics_libcom_rs::runtime::time::duration_from_secs(
2735                                                guard.sort_time,
2736                                            );
2737                                            sort_flush_interval = tokio::time::interval(dur);
2738                                        }
2739                                        None
2740                                    }
2741                                };
2742                                if let Some((output, senders, port)) = flush_work {
2743                                    output.publish_arrays(&senders, &mut scatter_cursor).await;
2744                                    output.batch.flush(&port).await;
2745                                }
2746                            }
2747                            if reason == sort_time_reason {
2748                                let t = value.as_f64();
2749                                let mut guard = shared.lock();
2750                                guard.sort_time = t;
2751                                if guard.sort_mode != 0 && t > 0.0 {
2752                                    sort_flush_active = true;
2753                                    let dur = epics_libcom_rs::runtime::time::duration_from_secs(t);
2754                                    sort_flush_interval = tokio::time::interval(dur);
2755                                } else {
2756                                    sort_flush_active = false;
2757                                }
2758                                drop(guard);
2759                            }
2760                            if reason == sort_size_reason {
2761                                shared.lock().sort_size = value.as_i32();
2762                            }
2763                            // Handle NDArrayPort rewiring — keyed by (port, addr).
2764                            if reason == nd_array_port_reason {
2765                                if let Some(new_port) = value.as_string() {
2766                                    if new_port != current_upstream {
2767                                        let old_key =
2768                                            upstream_key(&current_upstream, current_addr);
2769                                        let new_key = upstream_key(new_port, current_addr);
2770                                        match wiring.rewire_by_name(
2771                                            &sender_port_name,
2772                                            &old_key,
2773                                            &new_key,
2774                                        ) {
2775                                            Ok(()) => current_upstream = new_port.to_string(),
2776                                            Err(e) => {
2777                                                eprintln!("NDArrayPort rewire failed: {e}")
2778                                            }
2779                                        }
2780                                    }
2781                                }
2782                            }
2783                            let snapshot = PluginParamSnapshot {
2784                                enable_callbacks: enabled.load(Ordering::Acquire),
2785                                reason,
2786                                addr,
2787                                value,
2788                            };
2789                            let (process_output, senders, port) = {
2790                                let mut guard = shared.lock();
2791                                let t0 = std::time::Instant::now();
2792                                let result = processor.on_param_change(reason, &snapshot);
2793                                let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0;
2794                                let output = if !result.output_arrays.is_empty() || !result.param_updates.is_empty() {
2795                                    let deliver = guard.array_callbacks;
2796                                    Some(guard.build_publish_batch(result.output_arrays, result.param_updates, false, None, elapsed_ms, deliver, true))
2797                                } else {
2798                                    None
2799                                };
2800                                let senders = guard.output.lock().senders_clone();
2801                                (output, senders, guard.port_handle.clone())
2802                            };
2803                            if let Some(po) = process_output {
2804                                po.publish_arrays(&senders, &mut scatter_cursor).await;
2805                                po.batch.flush(&port).await;
2806                            }
2807                        }
2808                        None => break,
2809                    }
2810                }
2811                _ = sort_flush_interval.tick(), if sort_flush_active => {
2812                    // B3: drain head-first while contiguous or past the
2813                    // staleness deadline — NOT the whole buffer.
2814                    let (output, senders, port) = {
2815                        let mut guard = shared.lock();
2816                        let output = guard.tick_sort_buffer();
2817                        let senders = guard.output.lock().senders_clone();
2818                        let port = guard.port_handle.clone();
2819                        (output, senders, port)
2820                    };
2821                    output.publish_arrays(&senders, &mut scatter_cursor).await;
2822                    output.batch.flush(&port).await;
2823                }
2824            }
2825        }
2826    });
2827}
2828
2829/// Connect a downstream plugin's sender to a plugin runtime's output.
2830///
2831/// B13: the upstream's `array_output` is the same `Arc` that every
2832/// `create_plugin_runtime*` entry point registers in the `WiringRegistry`, so
2833/// adding a sender here mutates the registry-tracked output — the registry
2834/// remains the single source of truth for `rewire_by_name`.
2835pub fn wire_downstream(upstream: &PluginRuntimeHandle, downstream_sender: NDArraySender) {
2836    upstream.array_output().lock().add(downstream_sender);
2837}
2838
2839/// Create a plugin runtime with a pre-wired output (for testing and direct wiring).
2840pub fn create_plugin_runtime_with_output<P: NDPluginProcess>(
2841    port_name: &str,
2842    mut processor: P,
2843    pool: Arc<NDArrayPool>,
2844    queue_size: usize,
2845    output: NDArrayOutput,
2846    ndarray_port: &str,
2847    wiring: Arc<WiringRegistry>,
2848) -> (PluginRuntimeHandle, thread::JoinHandle<()>) {
2849    // B14: unbounded so control-plane param changes (e.g. autosave restoring
2850    // hundreds of PVs at IOC init) are never silently dropped before the
2851    // data plane sees them.
2852    let (param_tx, param_rx) = tokio::sync::mpsc::unbounded_channel::<PluginParamMsg>();
2853    let handle_param_tx = param_tx.clone();
2854
2855    let plugin_type_name = processor.plugin_type().to_string();
2856    let compression_aware = processor.compression_aware();
2857    let does_array_callbacks = processor.does_array_callbacks();
2858    let array_data = processor.array_data_handle();
2859    let driver = PluginPortDriver::new(
2860        port_name,
2861        &plugin_type_name,
2862        queue_size,
2863        ndarray_port,
2864        1,
2865        param_tx,
2866        &mut processor,
2867        array_data,
2868        &pool,
2869    )
2870    .expect("failed to create plugin port driver");
2871
2872    let ndarray_params = driver.ndarray_params;
2873    let plugin_params = driver.plugin_params;
2874    let std_array_data_param = driver.std_array_data_param;
2875
2876    // Fatal for the same reason as `create_plugin_runtime_multi_addr` above:
2877    // a constructor-shaped creator has nowhere to report to, and the only
2878    // alternative is a handle to a port that does not exist.
2879    let (port_runtime, _actor_jh) = create_port_runtime(driver, RuntimeConfig::default())
2880        .unwrap_or_else(|e| port_runtime_unavailable(port_name, &e));
2881
2882    let port_handle = port_runtime.port_handle().clone();
2883
2884    let (array_sender, array_rx) = ndarray_channel(port_name, queue_size);
2885
2886    let enabled = Arc::new(AtomicBool::new(false));
2887    let blocking_mode = Arc::new(AtomicBool::new(false));
2888
2889    let array_output = Arc::new(parking_lot::Mutex::new(output));
2890    let array_output_for_handle = array_output.clone();
2891    // B13: register this plugin's output so the WiringRegistry is the single
2892    // source of truth — an output created via this entry point is otherwise
2893    // invisible to runtime rewiring.
2894    wiring.register_output(port_name, array_output.clone());
2895    // G1/B1: DroppedArrays counter shared with upstream senders.
2896    let dropped_arrays_counter = array_sender.dropped_arrays_counter().clone();
2897    // The pre-queue gate: C runs both halves of `driverCallback` on the
2898    // producer's thread before `trySend`, so they live with the sender.
2899    let admission = array_sender.admission().clone();
2900    admission.set_compression_aware(compression_aware);
2901    // The processor is shared with the callback threads rather than owned by
2902    // `SharedProcessorInner`: the whole point of `&self` on `process_array` is
2903    // that it runs off the data loop, outside this mutex.
2904    let processor = Arc::new(processor);
2905    let shared = Arc::new(parking_lot::Mutex::new(SharedProcessorInner {
2906        output: array_output,
2907        pool,
2908        ndarray_params,
2909        plugin_params,
2910        port_handle,
2911        array_counter: 0,
2912        std_array_data_param,
2913        // C++ default NDArrayCallbacks = 1 (deliver downstream); terminal
2914        // plugins (StdArrays/Attribute/File) override `does_array_callbacks` to 0.
2915        array_callbacks: does_array_callbacks,
2916        sort_mode: 0,
2917        sort_time: 0.0,
2918        sort_size: 10,
2919        sort_buffer: SortBuffer::new(),
2920        dropped_arrays: dropped_arrays_counter,
2921        max_byte_rate: 0.0,
2922        throttler: super::throttler::Throttler::new(0.0),
2923        prev_input_array: None,
2924        last_output_array: None,
2925        num_pre_alloc_buffers: 0,
2926        dims_prev: vec![0i32; crate::ndarray::ND_ARRAY_MAX_DIMS],
2927        nd_array_addr: PLUGIN_NDARRAY_ADDR,
2928        max_threads: PLUGIN_MAX_THREADS,
2929        num_threads: PLUGIN_NUM_THREADS,
2930    }));
2931
2932    let data_enabled = enabled.clone();
2933    let data_blocking = blocking_mode.clone();
2934
2935    let mut array_sender = array_sender;
2936    array_sender.set_mode_flags(enabled, blocking_mode);
2937    // The data loop owns queue replacement; the handle is weak so it does not
2938    // keep the channel open past the last real sender.
2939    let data_queue_handle = array_sender.self_queue_handle();
2940
2941    // Capture wiring info for data loop
2942    let sender_port_name = port_name.to_string();
2943    let initial_upstream = ndarray_port.to_string();
2944
2945    let data_jh = MandatoryThread::new(
2946        format!("plugin-data-{port_name}"),
2947        // `asynNDArrayDriver.cpp:878` — `if (priority <= 0) priority =
2948        // epicsThreadPriorityMedium`, and that is what `NDPluginDriver` hands
2949        // its callback threads (`NDPluginDriver.cpp:1000`).
2950        ThreadPriority::Medium,
2951        // `asynNDArrayDriver.cpp:876` — `if (stackSize <= 0) stackSize =
2952        // epicsThreadGetStackSize(epicsThreadStackMedium)`.
2953        StackSizeClass::Medium,
2954    )
2955    .spawn(move || {
2956        plugin_data_loop(
2957            shared,
2958            processor,
2959            array_rx,
2960            data_queue_handle,
2961            param_rx,
2962            plugin_params,
2963            data_enabled,
2964            data_blocking,
2965            sender_port_name,
2966            initial_upstream,
2967            wiring,
2968        );
2969    });
2970
2971    let handle = PluginRuntimeHandle {
2972        port_runtime,
2973        array_sender,
2974        array_output: array_output_for_handle,
2975        port_name: port_name.to_string(),
2976        param_tx: handle_param_tx,
2977        ndarray_params,
2978        plugin_params,
2979    };
2980
2981    (handle, data_jh)
2982}
2983
2984#[cfg(test)]
2985mod tests {
2986    use super::*;
2987    use crate::ndarray::{NDDataType, NDDimension};
2988    use crate::plugin::channel::ndarray_channel;
2989
2990    /// # Invariant
2991    ///
2992    /// MUST: every `plugin-data-*` thread and every `<port>_Plugin_<n>`
2993    /// callback thread be created through [`MandatoryThread`], so that a
2994    /// thread the plugin cannot process without takes the process down rather
2995    /// than the caller's thread.
2996    ///
2997    /// The reason this is not the `errlog-and-continue` class:
2998    /// `NDPluginDriver::createCallbackThreads` builds its workers as
2999    /// `new epicsThread(...)` (`NDPluginDriver.cpp:1000`), whose constructor
3000    /// calls `epicsThreadCreateOpt` and `throw unableToCreateThread()` on
3001    /// failure (`epicsThread.cpp:214-220`) — a thrown failure, not a status
3002    /// code the plugin inspects and carries on from.
3003    ///
3004    /// Where C ends up is **not** where we do, and the difference is
3005    /// deliberate: iocsh catches whatever a command throws
3006    /// (`iocsh.cpp:1269-1279`, `"C++ error: ..."`) and a startup script's
3007    /// default `on error` is `Continue` (`iocsh.cpp:995`, `:1123`), so C runs
3008    /// the rest of st.cmd with the plugin's port registered and its worker
3009    /// threads absent — arrays queue to it and are never processed, silently,
3010    /// for the life of the IOC. `MandatoryThread::spawn` refuses that state.
3011    /// The `.expect` it replaced reached neither: on a `panic = "unwind"`
3012    /// target it unwound one thread and left the same zombie plugin behind.
3013    ///
3014    /// Contrast the auxiliary AD threads, which genuinely do errlog-and-continue
3015    /// and have no site here: the sorting thread (`NDPluginDriver.cpp:1089-1098`,
3016    /// `asynPrint` + `return asynError`), the queued-array counter
3017    /// (`asynNDArrayDriver.cpp:1013-1021`, `asynPrint` and no error at all) and
3018    /// the HDF5 flush task (`NDFileHDF5.cpp:2421-2429`, `printf` + `return`).
3019    ///
3020    /// Source inspection, because the defect is a call that is *absent*.
3021    #[test]
3022    fn plugin_data_threads_are_mandatory() {
3023        let prod =
3024            source_guard::production(include_str!("runtime.rs"), source_guard::Comments::Strip);
3025        assert_eq!(
3026            prod.matches("MandatoryThread::new(").count(),
3027            3,
3028            "`create_plugin_runtime_multi_addr`, `create_plugin_runtime_with_output` \
3029             and `spawn_worker_pool`"
3030        );
3031        let bare = concat!("thread", "::Builder::new()");
3032        let strays: Vec<&str> = prod
3033            .lines()
3034            .map(str::trim)
3035            .filter(|l| l.contains(bare) || l.contains(concat!("thread", "::spawn(")))
3036            .collect();
3037        assert!(
3038            strays.is_empty(),
3039            "a plugin data thread created outside `MandatoryThread` resolves its \
3040             own spawn failure locally: {strays:?}"
3041        );
3042    }
3043
3044    /// Passthrough processor: returns the input array as-is.
3045    struct PassthroughProcessor;
3046
3047    impl NDPluginProcess for PassthroughProcessor {
3048        fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
3049            ProcessResult::arrays(vec![Arc::new(array.clone())])
3050        }
3051        fn plugin_type(&self) -> &str {
3052            "Passthrough"
3053        }
3054    }
3055
3056    /// Sink processor: consumes arrays, returns nothing.
3057    struct SinkProcessor {
3058        count: std::sync::atomic::AtomicUsize,
3059    }
3060
3061    impl NDPluginProcess for SinkProcessor {
3062        fn process_array(&self, _array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
3063            self.count
3064                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3065            ProcessResult::empty()
3066        }
3067        fn plugin_type(&self) -> &str {
3068            "Sink"
3069        }
3070    }
3071
3072    fn make_test_array(id: i32) -> Arc<NDArray> {
3073        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
3074        arr.unique_id = id;
3075        Arc::new(arr)
3076    }
3077
3078    fn test_wiring() -> Arc<WiringRegistry> {
3079        Arc::new(WiringRegistry::new())
3080    }
3081
3082    /// Fence: wait until the data thread has applied every param change
3083    /// submitted so far. `write_*_blocking` only guarantees the change is
3084    /// queued for the data plane; asserting on data-plane behaviour without
3085    /// this fence is a race.
3086    fn params_applied(handle: &PluginRuntimeHandle) {
3087        assert!(
3088            handle.wait_params_applied(std::time::Duration::from_secs(10)),
3089            "data thread did not apply queued param changes"
3090        );
3091    }
3092
3093    /// Fence for tests that deliberately keep the input queue occupied.
3094    ///
3095    /// `params_applied` cannot serve there: its barrier also waits for the
3096    /// array queue to drain, which is the very state these tests hold. The
3097    /// param channel is FIFO, so a later write whose *accepted* value only the
3098    /// data thread can produce is enough — NumThreads is clamped to
3099    /// `[1, MaxThreads]` and written back (NDPluginDriver.cpp:955-971), so an
3100    /// out-of-range write reading back as the clamp proves every earlier param
3101    /// message has been applied.
3102    ///
3103    /// Timing out here is itself the finding these tests exist for: the data
3104    /// thread is the one that applies params, so a param that never lands
3105    /// means the loop is stuck inside work a callback thread should be doing.
3106    fn param_fifo_drained(handle: &PluginRuntimeHandle) {
3107        let port = handle.port_runtime().port_handle();
3108        port.write_int32_blocking(handle.plugin_params.num_threads, 0, 99)
3109            .unwrap();
3110        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
3111        while port
3112            .read_int32_blocking(handle.plugin_params.num_threads, 0)
3113            .unwrap()
3114            != 1
3115        {
3116            assert!(
3117                std::time::Instant::now() < deadline,
3118                "the data thread stopped applying param writes — it is busy \
3119                 inside work a callback thread should be doing"
3120            );
3121            std::thread::sleep(std::time::Duration::from_millis(1));
3122        }
3123    }
3124
3125    /// Poll `cond` until it holds; panic after 10 s. Waits on the observable
3126    /// state itself instead of sleeping a guessed duration, so a loaded
3127    /// machine cannot flake the test.
3128    fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
3129        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3130        while !cond() {
3131            assert!(
3132                std::time::Instant::now() < deadline,
3133                "timed out waiting for {what}"
3134            );
3135            std::thread::sleep(std::time::Duration::from_millis(2));
3136        }
3137    }
3138
3139    /// Enable callbacks on a plugin handle (plugins default to disabled) and
3140    /// fence until the data thread has actually flipped the enable flag.
3141    fn enable_callbacks(handle: &PluginRuntimeHandle) {
3142        handle
3143            .port_runtime()
3144            .port_handle()
3145            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
3146            .unwrap();
3147        params_applied(handle);
3148    }
3149
3150    /// Send an array via the sender from a sync test context.
3151    /// Uses a dedicated thread with a current-thread runtime to avoid
3152    /// interfering with the plugin's own runtime.
3153    fn send_array(sender: &NDArraySender, array: Arc<NDArray>) {
3154        let sender = sender.clone();
3155        let jh = std::thread::spawn(move || {
3156            let rt = tokio::runtime::Builder::new_current_thread()
3157                .enable_all()
3158                .build()
3159                .unwrap();
3160            rt.block_on(sender.publish(array));
3161        });
3162        jh.join().unwrap();
3163    }
3164
3165    #[test]
3166    fn test_passthrough_runtime() {
3167        let pool = Arc::new(NDArrayPool::new(1_000_000));
3168
3169        // Create downstream receiver
3170        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3171        let mut output = NDArrayOutput::new();
3172        output.add(downstream_sender);
3173
3174        let (handle, _data_jh) = create_plugin_runtime_with_output(
3175            "PASS1",
3176            PassthroughProcessor,
3177            pool,
3178            10,
3179            output,
3180            "",
3181            test_wiring(),
3182        );
3183        enable_callbacks(&handle);
3184
3185        // Send an array
3186        send_array(handle.array_sender(), make_test_array(42));
3187
3188        // Should come out the other side
3189        let received = downstream_rx.blocking_recv().unwrap();
3190        assert_eq!(received.unique_id, 42);
3191    }
3192
3193    #[test]
3194    fn test_sink_runtime() {
3195        let pool = Arc::new(NDArrayPool::new(1_000_000));
3196
3197        let (handle, _data_jh) = create_plugin_runtime(
3198            "SINK1",
3199            SinkProcessor {
3200                count: std::sync::atomic::AtomicUsize::new(0),
3201            },
3202            pool,
3203            10,
3204            "",
3205            test_wiring(),
3206        );
3207        enable_callbacks(&handle);
3208
3209        // Send arrays - they should be consumed silently
3210        send_array(handle.array_sender(), make_test_array(1));
3211        send_array(handle.array_sender(), make_test_array(2));
3212
3213        // ArrayCounter advances once per processed frame — both consumed.
3214        let port = handle.port_runtime().port_handle().clone();
3215        let counter = handle.ndarray_params.array_counter;
3216        wait_until("sink to process both arrays", || {
3217            port.read_int32_blocking(counter, 0).is_ok_and(|v| v == 2)
3218        });
3219        assert_eq!(handle.port_name(), "SINK1");
3220    }
3221
3222    #[test]
3223    fn test_plugin_type_param() {
3224        let pool = Arc::new(NDArrayPool::new(1_000_000));
3225
3226        let (handle, _data_jh) = create_plugin_runtime(
3227            "TYPE_TEST",
3228            PassthroughProcessor,
3229            pool,
3230            10,
3231            "",
3232            test_wiring(),
3233        );
3234
3235        // Verify port name
3236        assert_eq!(handle.port_name(), "TYPE_TEST");
3237        assert_eq!(handle.port_runtime().port_name(), "TYPE_TEST");
3238    }
3239
3240    #[test]
3241    fn test_ndtimestamp_param_is_the_standalone_double() {
3242        // R8-66 family: C `setDoubleParam(NDTimeStamp, pArray->timeStamp)`
3243        // (NDPluginDriver.cpp:217) publishes the array's standalone double —
3244        // which a driver with a hardware clock sets independently of epicsTS —
3245        // while NDEpicsTSSec/nSec carry epicsTS (`:218-219`). The plugin runtime
3246        // published `timestamp.as_f64()` for all three, so NDTimeStamp_RBV
3247        // reported the epicsTS-derived value.
3248        let pool = Arc::new(NDArrayPool::new(1_000_000));
3249        let (ds, _rx) = ndarray_channel("DS_TS", 10);
3250        let mut output = NDArrayOutput::new();
3251        output.add(ds);
3252        let (handle, _jh) = create_plugin_runtime_with_output(
3253            "TS_PARAM",
3254            PassthroughProcessor,
3255            pool,
3256            10,
3257            output,
3258            "",
3259            test_wiring(),
3260        );
3261        enable_callbacks(&handle);
3262        let port = handle.port_runtime().port_handle().clone();
3263
3264        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
3265        arr.timestamp = crate::timestamp::EpicsTimestamp {
3266            sec: 1234,
3267            nsec: 5678,
3268        };
3269        arr.time_stamp = 100.5; // hardware clock, unrelated to epicsTS
3270        send_array(handle.array_sender(), Arc::new(arr));
3271        std::thread::sleep(std::time::Duration::from_millis(50));
3272
3273        assert_eq!(
3274            port.read_float64_blocking(handle.ndarray_params.timestamp_rbv, 0)
3275                .unwrap(),
3276            100.5,
3277            "NDTimeStamp publishes pArray->timeStamp"
3278        );
3279        assert_eq!(
3280            port.read_int32_blocking(handle.ndarray_params.epics_ts_sec, 0)
3281                .unwrap(),
3282            1234
3283        );
3284        assert_eq!(
3285            port.read_int32_blocking(handle.ndarray_params.epics_ts_nsec, 0)
3286                .unwrap(),
3287            5678
3288        );
3289    }
3290
3291    #[test]
3292    fn test_shutdown_on_handle_drop() {
3293        let pool = Arc::new(NDArrayPool::new(1_000_000));
3294
3295        let (handle, data_jh) = create_plugin_runtime(
3296            "SHUTDOWN_TEST",
3297            PassthroughProcessor,
3298            pool,
3299            10,
3300            "",
3301            test_wiring(),
3302        );
3303
3304        // Drop the handle (closes sender channel, which should cause data thread to exit)
3305        let sender = handle.array_sender().clone();
3306        drop(handle);
3307        drop(sender);
3308
3309        // Data thread should terminate
3310        let result = data_jh.join();
3311        assert!(result.is_ok());
3312    }
3313
3314    #[test]
3315    fn test_wire_to_nonzero_ndarray_addr() {
3316        // G6: a multi-address upstream plugin registers its output under every
3317        // address in 0..max_addr. A downstream consumer must be able to select
3318        // NDArrayAddr=1 and actually receive arrays — previously the output was
3319        // registered under the bare port name only, so the "PORT:1" key was
3320        // missing and rewire failed with "not found".
3321        use crate::plugin::wiring::upstream_key;
3322        let pool = Arc::new(NDArrayPool::new(1_000_000));
3323        let wiring = test_wiring();
3324
3325        // Upstream plugin advertises 2 addresses.
3326        let (up_handle, _up_jh) = create_plugin_runtime_multi_addr(
3327            "UP_MULTI",
3328            PassthroughProcessor,
3329            pool,
3330            10,
3331            "",
3332            wiring.clone(),
3333            2,
3334        );
3335        enable_callbacks(&up_handle);
3336
3337        // The "PORT:1" key must resolve to the same output as the bare port.
3338        let addr0 = wiring.lookup_output("UP_MULTI");
3339        let addr1 = wiring.lookup_output(&upstream_key("UP_MULTI", 1));
3340        assert!(addr0.is_some(), "addr 0 output must be registered");
3341        assert!(
3342            addr1.is_some(),
3343            "addr 1 output must be registered for a max_addr=2 port"
3344        );
3345
3346        // Wire a downstream consumer to UP_MULTI address 1.
3347        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWN_ADDR1", 10);
3348        wiring
3349            .rewire(&downstream_sender, "", &upstream_key("UP_MULTI", 1))
3350            .expect("wiring a consumer to NDArrayAddr=1 must succeed");
3351
3352        // An array sent through the upstream must reach the addr-1 consumer.
3353        send_array(up_handle.array_sender(), make_test_array(99));
3354        let received = downstream_rx.blocking_recv().unwrap();
3355        assert_eq!(
3356            received.unique_id, 99,
3357            "consumer wired to NDArrayAddr=1 must receive upstream arrays"
3358        );
3359    }
3360
3361    #[test]
3362    fn test_nonblocking_passthrough() {
3363        let pool = Arc::new(NDArrayPool::new(1_000_000));
3364        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3365        let mut output = NDArrayOutput::new();
3366        output.add(downstream_sender);
3367
3368        let (handle, _data_jh) = create_plugin_runtime_with_output(
3369            "NB_TEST",
3370            PassthroughProcessor,
3371            pool,
3372            10,
3373            output,
3374            "",
3375            test_wiring(),
3376        );
3377        enable_callbacks(&handle);
3378
3379        send_array(handle.array_sender(), make_test_array(42));
3380
3381        let received = downstream_rx.blocking_recv().unwrap();
3382        assert_eq!(received.unique_id, 42);
3383    }
3384
3385    #[test]
3386    fn test_blocking_to_nonblocking_switch() {
3387        let pool = Arc::new(NDArrayPool::new(1_000_000));
3388        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3389        let mut output = NDArrayOutput::new();
3390        output.add(downstream_sender);
3391
3392        let (handle, _data_jh) = create_plugin_runtime_with_output(
3393            "SWITCH_TEST",
3394            PassthroughProcessor,
3395            pool,
3396            10,
3397            output,
3398            "",
3399            test_wiring(),
3400        );
3401        enable_callbacks(&handle);
3402
3403        // Start in blocking mode
3404        handle
3405            .port_runtime()
3406            .port_handle()
3407            .write_int32_blocking(handle.plugin_params.blocking_callbacks, 0, 1)
3408            .unwrap();
3409        params_applied(&handle);
3410
3411        send_array(handle.array_sender(), make_test_array(1));
3412        let received = downstream_rx.blocking_recv().unwrap();
3413        assert_eq!(received.unique_id, 1);
3414
3415        // Switch back to non-blocking
3416        handle
3417            .port_runtime()
3418            .port_handle()
3419            .write_int32_blocking(handle.plugin_params.blocking_callbacks, 0, 0)
3420            .unwrap();
3421        params_applied(&handle);
3422
3423        // Send in non-blocking mode — goes through channel to data thread
3424        send_array(handle.array_sender(), make_test_array(2));
3425        let received = downstream_rx.blocking_recv().unwrap();
3426        assert_eq!(received.unique_id, 2);
3427    }
3428
3429    #[test]
3430    fn test_enable_callbacks_disables_processing() {
3431        let pool = Arc::new(NDArrayPool::new(1_000_000));
3432        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3433        let mut output = NDArrayOutput::new();
3434        output.add(downstream_sender);
3435
3436        let (handle, _data_jh) = create_plugin_runtime_with_output(
3437            "ENABLE_TEST",
3438            PassthroughProcessor,
3439            pool,
3440            10,
3441            output,
3442            "",
3443            test_wiring(),
3444        );
3445
3446        // Disable callbacks
3447        handle
3448            .port_runtime()
3449            .port_handle()
3450            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 0)
3451            .unwrap();
3452        params_applied(&handle);
3453
3454        // Send array — should be silently dropped by sender (callbacks disabled)
3455        send_array(handle.array_sender(), make_test_array(99));
3456
3457        // Verify nothing received (with timeout)
3458        let rt = tokio::runtime::Builder::new_current_thread()
3459            .enable_all()
3460            .build()
3461            .unwrap();
3462        let result = rt.block_on(async {
3463            tokio::time::timeout(std::time::Duration::from_millis(100), downstream_rx.recv()).await
3464        });
3465        assert!(
3466            result.is_err(),
3467            "should not receive array when callbacks disabled"
3468        );
3469    }
3470
3471    /// Disabling a busy plugin does not throw away what it already accepted.
3472    ///
3473    /// C's `EnableCallbacks=0` cancels the array interrupt and nothing else
3474    /// (NDPluginDriver.cpp:711-720), so the callback threads keep emptying
3475    /// `pToThreadMsgQ_` — `deleteCallbackThreads` depends on precisely that,
3476    /// disabling the interrupt and then waiting for `pending()` to fall to
3477    /// zero (`:1026-1034`), which it never would if disabling discarded the
3478    /// backlog instead.
3479    ///
3480    /// The observable is the work: on an NDPluginFile those frames are files
3481    /// written, on an NDPluginStats they are counts a client reads back.
3482    #[test]
3483    fn disabling_callbacks_does_not_discard_the_queued_backlog() {
3484        const LAST: i32 = 5;
3485        let pool = Arc::new(NDArrayPool::new(1_000_000));
3486        let (processor, entered, gated) = GatedProcessor::new();
3487        let (handle, _jh) =
3488            create_plugin_runtime("DISABLE_MID", processor, pool, 8, "", test_wiring());
3489        let port = handle.port_runtime().port_handle();
3490        enable_callbacks(&handle);
3491
3492        // Park the one callback thread on frame 1, then build a backlog
3493        // behind it — frames the plugin has accepted but not yet worked on.
3494        send_array(handle.array_sender(), make_test_array(1));
3495        wait_until("frame 1 reached the callback thread", || {
3496            entered.load(Ordering::Acquire) == 1
3497        });
3498        for id in 2..=LAST {
3499            send_array(handle.array_sender(), make_test_array(id));
3500        }
3501        port.write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 0)
3502            .unwrap();
3503        param_fifo_drained(&handle);
3504
3505        // A frame offered after the disable is refused at the interrupt, the
3506        // one place C can refuse it.
3507        send_array(handle.array_sender(), make_test_array(LAST + 1));
3508        gated.release();
3509        wait_until("the backlog drained", || {
3510            gated.processed().len() == LAST as usize
3511        });
3512        params_applied(&handle);
3513
3514        assert_eq!(
3515            gated.processed(),
3516            (1..=LAST).collect::<Vec<_>>(),
3517            "everything already queued is processed; only new arrivals stop"
3518        );
3519        assert_eq!(
3520            port.read_int32_blocking(handle.ndarray_params.array_counter, 0)
3521                .unwrap(),
3522            LAST,
3523            "and each of them counts against ArrayCounter"
3524        );
3525        assert_eq!(
3526            port.read_int32_blocking(handle.plugin_params.dropped_arrays, 0)
3527                .unwrap(),
3528            0,
3529            "a frame refused at the interrupt is not a dropped array (C never \
3530             reaches driverCallback for it)"
3531        );
3532    }
3533
3534    /// A plugin port answers the pool-control PVs, because
3535    /// `NDPluginDriver::writeInt32` hands every parameter below
3536    /// `FIRST_NDPLUGIN_PARAM` to `asynNDArrayDriver::writeInt32`
3537    /// (NDPluginDriver.cpp:690-692), whose pool branch is at
3538    /// asynNDArrayDriver.cpp:684-694. Leaving them unhandled froze
3539    /// `PoolAllocBuffers_RBV` and friends at their construction values for the
3540    /// plugin's whole life, so a client polling a plugin's pool read 0 no
3541    /// matter what the pool was doing.
3542    ///
3543    /// One case per branch of that C `if`, not one per story.
3544    #[test]
3545    fn a_plugin_port_answers_the_pool_control_writes() {
3546        let pool = Arc::new(NDArrayPool::new(1_000_000));
3547        let (handle, _jh) = create_plugin_runtime(
3548            "POOLCTL",
3549            PassthroughProcessor,
3550            Arc::clone(&pool),
3551            4,
3552            "",
3553            test_wiring(),
3554        );
3555        let port = handle.port_runtime().port_handle();
3556        let p = handle.ndarray_params;
3557        let alloc = || port.read_int32_blocking(p.pool_alloc_buffers, 0).unwrap();
3558        let free = || port.read_int32_blocking(p.pool_free_buffers, 0).unwrap();
3559
3560        // PoolPollStats: the readbacks follow the pool.
3561        let held: Vec<_> = (0..3)
3562            .map(|_| {
3563                pool.alloc(vec![NDDimension::new(64)], NDDataType::UInt8)
3564                    .unwrap()
3565            })
3566            .collect();
3567        port.write_int32_blocking(p.pool_poll_stats, 0, 1).unwrap();
3568        params_applied(&handle);
3569        assert_eq!((alloc(), free()), (3, 0), "three buffers out, none free");
3570
3571        for arr in held {
3572            pool.release(arr);
3573        }
3574        port.write_int32_blocking(p.pool_poll_stats, 0, 1).unwrap();
3575        params_applied(&handle);
3576        assert_eq!((alloc(), free()), (3, 3), "returned buffers are free");
3577
3578        // PoolEmptyFreeList: drops them and republishes without a poll.
3579        port.write_int32_blocking(p.pool_empty_free_list, 0, 1)
3580            .unwrap();
3581        params_applied(&handle);
3582        assert_eq!((alloc(), free()), (0, 0), "the free list is gone");
3583
3584        // PoolPreAllocBuffers: needs an array to take dimensions from, so run
3585        // one frame through first (C refuses outright without `pArrays[0]`,
3586        // asynNDArrayDriver.cpp:715-721).
3587        enable_callbacks(&handle);
3588        send_array(handle.array_sender(), make_test_array(1));
3589        params_applied(&handle);
3590        port.write_int32_blocking(p.pool_num_pre_alloc_buffers, 0, 2)
3591            .unwrap();
3592        port.write_int32_blocking(p.pool_pre_alloc, 0, 1).unwrap();
3593        params_applied(&handle);
3594        assert_eq!(free(), 2, "two buffers pre-allocated onto the free list");
3595        assert_eq!(
3596            port.read_int32_blocking(p.pool_pre_alloc, 0).unwrap(),
3597            0,
3598            "the command PV clears itself once it has run"
3599        );
3600    }
3601
3602    #[test]
3603    fn test_downstream_receives_multiple() {
3604        let pool = Arc::new(NDArrayPool::new(1_000_000));
3605
3606        let (ds1, mut rx1) = ndarray_channel("DS1", 10);
3607        let (ds2, mut rx2) = ndarray_channel("DS2", 10);
3608        let mut output = NDArrayOutput::new();
3609        output.add(ds1);
3610        output.add(ds2);
3611
3612        let (handle, _data_jh) = create_plugin_runtime_with_output(
3613            "DS_TEST",
3614            PassthroughProcessor,
3615            pool,
3616            10,
3617            output,
3618            "",
3619            test_wiring(),
3620        );
3621        enable_callbacks(&handle);
3622
3623        send_array(handle.array_sender(), make_test_array(77));
3624
3625        // Both downstream receivers should have the array
3626        let r1 = rx1.blocking_recv().unwrap();
3627        let r2 = rx2.blocking_recv().unwrap();
3628        assert_eq!(r1.unique_id, 77);
3629        assert_eq!(r2.unique_id, 77);
3630    }
3631
3632    #[test]
3633    fn test_param_updates_after_send() {
3634        let pool = Arc::new(NDArrayPool::new(1_000_000));
3635
3636        struct ParamTracker;
3637        impl NDPluginProcess for ParamTracker {
3638            fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
3639                ProcessResult::arrays(vec![Arc::new(array.clone())])
3640            }
3641            fn plugin_type(&self) -> &str {
3642                "ParamTracker"
3643            }
3644        }
3645
3646        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
3647        let mut output = NDArrayOutput::new();
3648        output.add(downstream_sender);
3649
3650        let (handle, _data_jh) = create_plugin_runtime_with_output(
3651            "PARAM_TEST",
3652            ParamTracker,
3653            pool,
3654            10,
3655            output,
3656            "",
3657            test_wiring(),
3658        );
3659        enable_callbacks(&handle);
3660
3661        // Send array
3662        send_array(handle.array_sender(), make_test_array(1));
3663        let received = downstream_rx.blocking_recv().unwrap();
3664        assert_eq!(received.unique_id, 1);
3665
3666        // Write enable_callbacks — should not crash
3667        handle
3668            .port_runtime()
3669            .port_handle()
3670            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
3671            .unwrap();
3672        params_applied(&handle);
3673
3674        // Still works after param update
3675        send_array(handle.array_sender(), make_test_array(2));
3676        let received = downstream_rx.blocking_recv().unwrap();
3677        assert_eq!(received.unique_id, 2);
3678    }
3679
3680    #[test]
3681    fn test_sort_buffer_reorders_by_unique_id() {
3682        let mut buf = SortBuffer::new();
3683
3684        // Insert out of order: 3, 1, 2
3685        buf.insert(3, vec![make_test_array(3)], 10);
3686        buf.insert(1, vec![make_test_array(1)], 10);
3687        buf.insert(2, vec![make_test_array(2)], 10);
3688
3689        assert_eq!(buf.len(), 3);
3690
3691        let drained = buf.drain_all();
3692        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
3693        assert_eq!(ids, vec![1, 2, 3], "should drain in sorted uniqueId order");
3694        assert_eq!(buf.len(), 0);
3695        assert_eq!(buf.prev_unique_id, 3);
3696    }
3697
3698    #[test]
3699    fn test_sort_buffer_drain_ready_contiguous() {
3700        // B3: drain_ready releases the head while the next-expected uniqueId
3701        // is contiguous, even when later ids are still missing.
3702        let mut buf = SortBuffer::new();
3703        // Mark a prior emission (prev=0) so the contiguity path is active;
3704        // C++ only uses the deadline for the very first output array.
3705        buf.note_emitted(0);
3706        buf.insert(1, vec![make_test_array(1)], 10);
3707        buf.insert(2, vec![make_test_array(2)], 10);
3708        buf.insert(5, vec![make_test_array(5)], 10); // gap: 3,4 missing
3709
3710        // sort_time large → only contiguity drives release.
3711        let drained = buf.drain_ready(100.0);
3712        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
3713        assert_eq!(ids, vec![1, 2], "contiguous run released; id=5 held by gap");
3714        assert_eq!(buf.len(), 1);
3715    }
3716
3717    #[test]
3718    fn test_sort_buffer_drain_ready_deadline() {
3719        // B3: a stale head is released past sort_time even with a gap.
3720        let mut buf = SortBuffer::new();
3721        buf.note_emitted(1); // prev=1
3722        buf.insert(5, vec![make_test_array(5)], 10); // out of order
3723        std::thread::sleep(std::time::Duration::from_millis(30));
3724        // sort_time=0.01s → head aged past deadline → released.
3725        let drained = buf.drain_ready(0.01);
3726        let ids: Vec<i32> = drained.iter().map(|(id, _)| *id).collect();
3727        assert_eq!(ids, vec![5], "stale head released via deadline");
3728    }
3729
3730    #[test]
3731    fn test_sort_buffer_detects_disordered_on_emit() {
3732        // B4: disorder is counted at emission time.
3733        let mut buf = SortBuffer::new();
3734        buf.note_emitted(5); // prev=5, first_output now false
3735        buf.note_emitted(3); // 3 != 5 and != 6 → disordered
3736        assert_eq!(buf.disordered_arrays, 1);
3737        buf.note_emitted(4); // 4 != 3 and != 4? 4 == prev+1 → ordered
3738        assert_eq!(buf.disordered_arrays, 1);
3739    }
3740
3741    #[test]
3742    fn test_sort_buffer_drops_when_full() {
3743        let mut buf = SortBuffer::new();
3744
3745        // sort_size=2: third insert is refused.
3746        assert!(buf.insert(1, vec![make_test_array(1)], 2));
3747        assert!(buf.insert(2, vec![make_test_array(2)], 2));
3748        assert!(!buf.insert(3, vec![make_test_array(3)], 2));
3749
3750        assert_eq!(buf.len(), 2);
3751        assert_eq!(buf.dropped_output_arrays, 1);
3752    }
3753
3754    #[test]
3755    fn test_constructor_initialises_c_read_only_params() {
3756        // C++ NDPluginDriver.cpp:152-160 initialises these in the constructor
3757        // precisely so a read before any array flows returns a value instead
3758        // of "uninitialized". Nothing here enables callbacks or sends an
3759        // array: this is the idle plugin an operator sees right after
3760        // iocInit, which is when QueueFree_RBV read 0 (full) and
3761        // MaxThreads_RBV read UDF/INVALID.
3762        let pool = Arc::new(NDArrayPool::new(1_000_000));
3763        let (handle, _data_jh) = create_plugin_runtime_with_output(
3764            "CTOR_TEST",
3765            PassthroughProcessor,
3766            pool,
3767            20,
3768            NDArrayOutput::new(),
3769            "",
3770            test_wiring(),
3771        );
3772        let port = handle.port_runtime().port_handle();
3773        let read = |reason: usize| port.read_int32_blocking(reason, 0);
3774
3775        // C `:156` / `:157` — an idle queue is entirely free.
3776        assert_eq!(read(handle.plugin_params.queue_size).unwrap(), 20);
3777        assert_eq!(read(handle.plugin_params.queue_use).unwrap(), 20);
3778        // C `:153` / `:158` / `:159`. These have no other writer: MaxThreads
3779        // is a longin with SCAN "I/O Intr", no PINI and no output partner, so
3780        // an unset param leaves it UDF forever.
3781        assert_eq!(
3782            read(handle.plugin_params.nd_array_addr).unwrap(),
3783            PLUGIN_NDARRAY_ADDR
3784        );
3785        assert_eq!(
3786            read(handle.plugin_params.max_threads).unwrap(),
3787            PLUGIN_MAX_THREADS
3788        );
3789        assert_eq!(
3790            read(handle.plugin_params.num_threads).unwrap(),
3791            PLUGIN_NUM_THREADS
3792        );
3793        // C `:154` / `:155`.
3794        assert_eq!(read(handle.plugin_params.dropped_arrays).unwrap(), 0);
3795        assert_eq!(read(handle.plugin_params.dropped_output_arrays).unwrap(), 0);
3796    }
3797
3798    /// Observable half of `OverlapProcessor`, kept in the test so the
3799    /// processor itself can be moved into the runtime.
3800    #[derive(Clone, Default)]
3801    struct OverlapProbe {
3802        inside: Arc<parking_lot::Mutex<usize>>,
3803        gate: Arc<parking_lot::Condvar>,
3804        peak: Arc<std::sync::atomic::AtomicUsize>,
3805    }
3806
3807    /// Records the peak number of concurrent `process_array` calls by holding
3808    /// every frame until `want` of them are inside at once. A plugin that
3809    /// processes frames one at a time can never drive the peak above 1,
3810    /// whatever the timing.
3811    struct OverlapProcessor {
3812        probe: OverlapProbe,
3813        want: usize,
3814        budget: std::time::Duration,
3815    }
3816
3817    impl NDPluginProcess for OverlapProcessor {
3818        fn process_array(&self, _array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
3819            let mut inside = self.probe.inside.lock();
3820            *inside += 1;
3821            let peak = self
3822                .probe
3823                .peak
3824                .fetch_max(*inside, Ordering::AcqRel)
3825                .max(*inside);
3826            if peak >= self.want {
3827                self.probe.gate.notify_all();
3828            } else {
3829                // Wait on the *peak*, not on the live count: it only ever
3830                // rises, so a frame that returns before its neighbours wake
3831                // cannot re-park them.
3832                let deadline = std::time::Instant::now() + self.budget;
3833                while self.probe.peak.load(Ordering::Acquire) < self.want
3834                    && std::time::Instant::now() < deadline
3835                {
3836                    self.probe.gate.wait_until(&mut inside, deadline);
3837                }
3838            }
3839            *inside -= 1;
3840            ProcessResult::empty()
3841        }
3842
3843        fn plugin_type(&self) -> &str {
3844            "Overlap"
3845        }
3846    }
3847
3848    #[test]
3849    fn test_num_threads_write_spawns_overlapping_callback_threads() {
3850        // C runs `NumThreads` callback threads over one input queue
3851        // (NDPluginDriver.cpp:996-1001) and tears them down and respawns them
3852        // when NumThreads is written (`:730-733`). Four frames that each hold
3853        // until four are inside can only all return if four threads really
3854        // are running — which is what the NumThreads PV exists to buy.
3855        let pool = Arc::new(NDArrayPool::new(1_000_000));
3856        let probe = OverlapProbe::default();
3857        let (handle, _data_jh) = create_plugin_runtime_with_output(
3858            "NTHREADS_TEST",
3859            OverlapProcessor {
3860                probe: probe.clone(),
3861                want: 4,
3862                budget: std::time::Duration::from_secs(2),
3863            },
3864            pool,
3865            10,
3866            NDArrayOutput::new(),
3867            "",
3868            test_wiring(),
3869        );
3870        let port = handle.port_runtime().port_handle();
3871        // NumThreads is clamped to [1, MaxThreads] (C `createCallbackThreads`
3872        // NDPluginDriver.cpp:955-971), so MaxThreads has to move first.
3873        port.write_int32_blocking(handle.plugin_params.max_threads, 0, 4)
3874            .unwrap();
3875        params_applied(&handle);
3876        port.write_int32_blocking(handle.plugin_params.num_threads, 0, 4)
3877            .unwrap();
3878        params_applied(&handle);
3879        assert_eq!(
3880            port.read_int32_blocking(handle.plugin_params.num_threads, 0)
3881                .unwrap(),
3882            4,
3883            "NumThreads_RBV must report the accepted value"
3884        );
3885
3886        enable_callbacks(&handle);
3887        for id in 1..=4 {
3888            send_array(handle.array_sender(), make_test_array(id));
3889        }
3890        // Quiescence: the queue is empty AND every dispatched frame is back.
3891        params_applied(&handle);
3892
3893        assert_eq!(
3894            probe.peak.load(Ordering::Acquire),
3895            4,
3896            "four callback threads must process four frames concurrently"
3897        );
3898    }
3899
3900    #[test]
3901    fn test_default_num_threads_processes_one_frame_at_a_time() {
3902        // C's constructor starts NumThreads at 1 (NDPluginDriver.cpp:159), so
3903        // an untouched plugin must still serialise its frames.
3904        let pool = Arc::new(NDArrayPool::new(1_000_000));
3905        let probe = OverlapProbe::default();
3906        let (handle, _data_jh) = create_plugin_runtime_with_output(
3907            "NTHREADS1_TEST",
3908            OverlapProcessor {
3909                probe: probe.clone(),
3910                want: 2,
3911                budget: std::time::Duration::from_millis(300),
3912            },
3913            pool,
3914            10,
3915            NDArrayOutput::new(),
3916            "",
3917            test_wiring(),
3918        );
3919        enable_callbacks(&handle);
3920        for id in 1..=2 {
3921            send_array(handle.array_sender(), make_test_array(id));
3922        }
3923        params_applied(&handle);
3924
3925        assert_eq!(
3926            probe.peak.load(Ordering::Acquire),
3927            1,
3928            "one callback thread must not overlap frames"
3929        );
3930    }
3931
3932    #[test]
3933    fn test_constructor_initialises_the_ndarray_read_only_block() {
3934        // C++ `NDPluginDriver` derives from `asynNDArrayDriver`, so the base
3935        // constructor's block (asynNDArrayDriver.cpp:954-1005) runs for a
3936        // plugin as well. Read through the port handle, which is the same
3937        // path a record takes: an unwritten param comes back as an error.
3938        let pool = Arc::new(NDArrayPool::new(2_097_152));
3939        let (handle, _data_jh) = create_plugin_runtime_with_output(
3940            "NDCTOR_TEST",
3941            PassthroughProcessor,
3942            pool,
3943            20,
3944            NDArrayOutput::new(),
3945            "",
3946            test_wiring(),
3947        );
3948        let port = handle.port_runtime().port_handle();
3949        let p = &handle.ndarray_params;
3950        for (name, reason, want) in [
3951            ("ARRAY_SIZE_X", p.array_size_x, 0),
3952            ("ARRAY_SIZE_Y", p.array_size_y, 0),
3953            ("ARRAY_SIZE_Z", p.array_size_z, 0),
3954            ("ARRAY_SIZE", p.array_size, 0),
3955            ("ND_DIMENSIONS", p.n_dimensions, 0),
3956            (
3957                "COLOR_MODE",
3958                p.color_mode,
3959                crate::color::NDColorMode::Mono as i32,
3960            ),
3961            ("UNIQUE_ID", p.unique_id, 0),
3962            ("EPICS_TS_SEC", p.epics_ts_sec, 0),
3963            ("EPICS_TS_NSEC", p.epics_ts_nsec, 0),
3964            ("BAYER_PATTERN", p.bayer_pattern, 0),
3965            ("ARRAY_COUNTER", p.array_counter, 0),
3966            ("NUM_CAPTURED", p.num_captured, 0),
3967            ("FREE_CAPTURE", p.free_capture, 0),
3968            (
3969                "ND_ATTRIBUTES_STATUS",
3970                p.attributes_status,
3971                crate::driver::ndarray_driver::ATTR_STATUS_FILE_NOT_FOUND,
3972            ),
3973            ("NUM_QUEUED_ARRAYS", p.num_queued_arrays, 0),
3974            ("POOL_ALLOC_BUFFERS", p.pool_alloc_buffers, 0),
3975            ("POOL_FREE_BUFFERS", p.pool_free_buffers, 0),
3976        ] {
3977            assert_eq!(
3978                port.read_int32_blocking(reason, 0)
3979                    .unwrap_or_else(|e| panic!("{name} unset after construction: {e:?}")),
3980                want,
3981                "{name}"
3982            );
3983        }
3984        assert_eq!(
3985            port.read_float64_blocking(p.pool_max_memory, 0)
3986                .expect("POOL_MAX_MEMORY unset after construction"),
3987            2.0
3988        );
3989        assert_eq!(
3990            port.read_float64_blocking(p.pool_used_memory, 0)
3991                .expect("POOL_USED_MEMORY unset after construction"),
3992            0.0
3993        );
3994        assert_eq!(
3995            port.read_float64_blocking(p.timestamp_rbv, 0)
3996                .expect("TIME_STAMP unset after construction"),
3997            0.0
3998        );
3999    }
4000
4001    /// A full sort buffer refuses arrays from the very first frame, because
4002    /// C judges the first output array by the same `orderOK` rule as every
4003    /// later one: `prevUniqueId_` starts at -1000 (NDPluginDriver.cpp:99), so
4004    /// nothing a detector ever sends is adjacent to it.
4005    ///
4006    /// Waving the first array past the buffer instead makes every later one
4007    /// in-order behind it, and the buffer that C fills and overflows here
4008    /// stays empty for the plugin's whole life.
4009    #[test]
4010    fn a_full_sort_buffer_drops_output_arrays_from_the_first_frame() {
4011        const SORT_SIZE: i32 = 3;
4012        const SENT: i32 = 8;
4013        let pool = Arc::new(NDArrayPool::new(1_000_000));
4014        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 32);
4015        let mut output = NDArrayOutput::new();
4016        output.add(downstream_sender);
4017        let (handle, _jh) = create_plugin_runtime_with_output(
4018            "SORT_FULL",
4019            PassthroughProcessor,
4020            pool,
4021            10,
4022            output,
4023            "",
4024            test_wiring(),
4025        );
4026        let port = handle.port_runtime().port_handle();
4027        enable_callbacks(&handle);
4028        port.write_int32_blocking(handle.plugin_params.sort_size, 0, SORT_SIZE)
4029            .unwrap();
4030        // Long enough that the sort deadline cannot fire during the test, so
4031        // what is measured is the admission rule and not a race with it.
4032        port.write_float64_blocking(handle.plugin_params.sort_time, 0, 30.0)
4033            .unwrap();
4034        port.write_int32_blocking(handle.plugin_params.sort_mode, 0, 1)
4035            .unwrap();
4036        params_applied(&handle);
4037
4038        for id in 1..=SENT {
4039            send_array(handle.array_sender(), make_test_array(id));
4040        }
4041        params_applied(&handle);
4042
4043        let mut emitted = Vec::new();
4044        while let Some(m) = downstream_rx.try_recv_msg() {
4045            emitted.push(m.array.unique_id);
4046        }
4047        assert!(
4048            emitted.is_empty(),
4049            "nothing may leave before the sort deadline, got {emitted:?}"
4050        );
4051        assert_eq!(
4052            port.read_int32_blocking(handle.plugin_params.sort_free, 0)
4053                .unwrap(),
4054            0,
4055            "the buffer holds SORT_SIZE arrays"
4056        );
4057        assert_eq!(
4058            port.read_int32_blocking(handle.plugin_params.dropped_output_arrays, 0)
4059                .unwrap(),
4060            SENT - SORT_SIZE,
4061            "every array past the buffer's capacity is a dropped output array"
4062        );
4063    }
4064
4065    #[test]
4066    fn test_sort_mode_runtime_integration() {
4067        let pool = Arc::new(NDArrayPool::new(1_000_000));
4068        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
4069        let mut output = NDArrayOutput::new();
4070        output.add(downstream_sender);
4071
4072        let (handle, _data_jh) = create_plugin_runtime_with_output(
4073            "SORT_TEST",
4074            PassthroughProcessor,
4075            pool,
4076            10,
4077            output,
4078            "",
4079            test_wiring(),
4080        );
4081        enable_callbacks(&handle);
4082
4083        // Enable sort mode with sort_size=10 and a sort_time deadline.
4084        handle
4085            .port_runtime()
4086            .port_handle()
4087            .write_int32_blocking(handle.plugin_params.sort_size, 0, 10)
4088            .unwrap();
4089        handle
4090            .port_runtime()
4091            .port_handle()
4092            .write_float64_blocking(handle.plugin_params.sort_time, 0, 0.1)
4093            .unwrap();
4094        handle
4095            .port_runtime()
4096            .port_handle()
4097            .write_int32_blocking(handle.plugin_params.sort_mode, 0, 1)
4098            .unwrap();
4099        params_applied(&handle);
4100
4101        // Arrays 1,2,3 are all out of order against the -1000 sentinel, so
4102        // sorted mode buffers every one of them: nothing reaches the client
4103        // until the sort deadline releases the head (C sortingTask `:646`,
4104        // `deltaTime > sortTime`), and the rest then cascade out behind it
4105        // because each is adjacent to the one just emitted.
4106        send_array(handle.array_sender(), make_test_array(1));
4107        send_array(handle.array_sender(), make_test_array(2));
4108        send_array(handle.array_sender(), make_test_array(3));
4109
4110        let rt = tokio::runtime::Builder::new_current_thread()
4111            .enable_all()
4112            .build()
4113            .unwrap();
4114        let early = rt.block_on(async {
4115            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
4116        });
4117        assert!(
4118            early.is_err(),
4119            "the first array is held for the sort deadline, not emitted at once"
4120        );
4121        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 1);
4122        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 2);
4123        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 3);
4124
4125        // B3: now send out of order (5 before 4). prev=3, so 4 is in-order
4126        // and emitted immediately; 5 arrives first, is buffered, then 4
4127        // unblocks it.
4128        send_array(handle.array_sender(), make_test_array(5));
4129        send_array(handle.array_sender(), make_test_array(4));
4130        // 4 emitted immediately (in order), then 5 released by contiguity;
4131        // blocking_recv below is the wait.
4132        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 4);
4133        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 5);
4134    }
4135
4136    #[test]
4137    fn test_control_plane_write_resets_every_mirrored_counter() {
4138        // B12/B15 boundary test. C++ holds ArrayCounter, DroppedArrays,
4139        // DroppedOutputArrays and DisorderedArrays in the param library and
4140        // increments each with a read-modify-write, so a PV write resets the
4141        // count. Rust mirrors each in a working copy; if a write does not land
4142        // back in that copy, the next status publish silently reverts it.
4143        //
4144        // One case per counter, not one per scenario: the boundary is
4145        // "written value survives the next publish", and it is the same
4146        // boundary for all four regardless of how they came to be non-zero.
4147        let pool = Arc::new(NDArrayPool::new(1_000_000));
4148        let (handle, _data_jh) = create_plugin_runtime(
4149            "COUNTER_RESET_TEST",
4150            PassthroughProcessor,
4151            pool,
4152            10,
4153            "",
4154            test_wiring(),
4155        );
4156        enable_callbacks(&handle);
4157
4158        let port = handle.port_runtime().port_handle().clone();
4159        let counters: [(&str, usize); 4] = [
4160            ("ARRAY_COUNTER", handle.ndarray_params.array_counter),
4161            ("DROPPED_ARRAYS", handle.plugin_params.dropped_arrays),
4162            (
4163                "DROPPED_OUTPUT_ARRAYS",
4164                handle.plugin_params.dropped_output_arrays,
4165            ),
4166            ("DISORDERED_ARRAYS", handle.plugin_params.disordered_arrays),
4167        ];
4168
4169        for (name, reason) in counters {
4170            // Write a distinct non-zero value through the control plane.
4171            port.write_int32_blocking(reason, 0, 7).unwrap();
4172            params_applied(&handle);
4173
4174            // Drive one array so the data path publishes its status batch.
4175            // Before the fix the batch carried the working copy's stale 0 and
4176            // stomped the 7; ArrayCounter additionally increments, so it must
4177            // read 8 rather than restarting from 1.
4178            send_array(handle.array_sender(), make_test_array(1));
4179            params_applied(&handle);
4180
4181            let want = if reason == handle.ndarray_params.array_counter {
4182                8
4183            } else {
4184                7
4185            };
4186            wait_until(&format!("{name} to keep its control-plane value"), || {
4187                port.read_int32_blocking(reason, 0).is_ok_and(|v| v == want)
4188            });
4189            assert_eq!(
4190                port.read_int32_blocking(reason, 0).unwrap(),
4191                want,
4192                "{name}: a control-plane write was reverted by the next publish"
4193            );
4194        }
4195    }
4196
4197    #[test]
4198    fn test_throttle_drops_output_arrays() {
4199        // G7: with a tiny MaxByteRate, output arrays exceeding the byte budget
4200        // are dropped and counted into DroppedOutputArrays.
4201        let pool = Arc::new(NDArrayPool::new(1_000_000));
4202        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
4203        let mut output = NDArrayOutput::new();
4204        output.add(downstream_sender);
4205
4206        let (handle, _data_jh) = create_plugin_runtime_with_output(
4207            "THROTTLE_TEST",
4208            PassthroughProcessor,
4209            pool,
4210            10,
4211            output,
4212            "",
4213            test_wiring(),
4214        );
4215        enable_callbacks(&handle);
4216
4217        // MaxByteRate = 8 bytes/sec. Each test array is 4 bytes; the bucket
4218        // starts full at 8, so the first two pass and the rest are dropped.
4219        handle
4220            .port_runtime()
4221            .port_handle()
4222            .write_float64_blocking(handle.plugin_params.max_byte_rate, 0, 8.0)
4223            .unwrap();
4224        params_applied(&handle);
4225
4226        for id in 1..=5 {
4227            send_array(handle.array_sender(), make_test_array(id));
4228        }
4229        // ArrayCounter counts every processed frame (throttle drops happen on
4230        // the output side, after counting), and its flush follows the array
4231        // publish — so counter == 5 means everything that will ever reach the
4232        // downstream queue is already there.
4233        let port = handle.port_runtime().port_handle().clone();
4234        let counter = handle.ndarray_params.array_counter;
4235        wait_until("all 5 frames to be processed", || {
4236            port.read_int32_blocking(counter, 0).is_ok_and(|v| v == 5)
4237        });
4238
4239        // Drain whatever made it through — strictly fewer than 5.
4240        let rt = tokio::runtime::Builder::new_current_thread()
4241            .enable_all()
4242            .build()
4243            .unwrap();
4244        let mut received = 0;
4245        while rt
4246            .block_on(async {
4247                tokio::time::timeout(std::time::Duration::from_millis(20), downstream_rx.recv())
4248                    .await
4249            })
4250            .map(|o| o.is_some())
4251            .unwrap_or(false)
4252        {
4253            received += 1;
4254        }
4255        assert!(
4256            received < 5,
4257            "throttle must drop some arrays (got {received})"
4258        );
4259        assert!(received >= 1, "first array within budget must pass");
4260    }
4261
4262    #[test]
4263    fn test_process_plugin_reprocesses_last_input() {
4264        // G5: writing ProcessPlugin re-injects the cached last input array.
4265        let pool = Arc::new(NDArrayPool::new(1_000_000));
4266        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
4267        let mut output = NDArrayOutput::new();
4268        output.add(downstream_sender);
4269
4270        let (handle, _data_jh) = create_plugin_runtime_with_output(
4271            "PROCESS_PLUGIN_TEST",
4272            PassthroughProcessor,
4273            pool,
4274            10,
4275            output,
4276            "",
4277            test_wiring(),
4278        );
4279        enable_callbacks(&handle);
4280
4281        send_array(handle.array_sender(), make_test_array(7));
4282        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 7);
4283
4284        // Trigger ProcessPlugin — the cached input (id=7) is reprocessed.
4285        handle
4286            .port_runtime()
4287            .port_handle()
4288            .write_int32_blocking(handle.plugin_params.process_plugin, 0, 1)
4289            .unwrap();
4290        let reprocessed = downstream_rx.blocking_recv().unwrap();
4291        assert_eq!(
4292            reprocessed.unique_id, 7,
4293            "ProcessPlugin re-emits last input"
4294        );
4295    }
4296
4297    #[test]
4298    fn test_min_callback_time_throttle_not_counted() {
4299        // A MinCallbackTime-throttled array is silently skipped, NOT counted.
4300        // C++ driverCallback (NDPluginDriver.cpp:405-450) falls through the
4301        // `deltaTime <= minCallbackTime` gate straight to callParamCallbacks()
4302        // without touching DroppedArrays — that counter is incremented ONLY on
4303        // a compression-unaware array (:388) or a full message queue (:440).
4304        // Verify (a) the throttled array is not emitted and (b) DroppedArrays
4305        // stays zero.
4306        let pool = Arc::new(NDArrayPool::new(1_000_000));
4307        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
4308        let mut output = NDArrayOutput::new();
4309        output.add(downstream_sender);
4310
4311        let (handle, _data_jh) = create_plugin_runtime_with_output(
4312            "MIN_CB_TEST",
4313            PassthroughProcessor,
4314            pool,
4315            10,
4316            output,
4317            "",
4318            test_wiring(),
4319        );
4320        enable_callbacks(&handle);
4321        let dropped = handle.array_sender().dropped_arrays_counter().clone();
4322
4323        // 10s minimum between callbacks — only the first array gets through.
4324        handle
4325            .port_runtime()
4326            .port_handle()
4327            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 10.0)
4328            .unwrap();
4329        params_applied(&handle);
4330
4331        send_array(handle.array_sender(), make_test_array(1));
4332        send_array(handle.array_sender(), make_test_array(2));
4333
4334        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 1);
4335        // Fence the param channel: the barrier acks only once the array queue
4336        // has drained, so array 1's processing is finished before the negative
4337        // checks below. Array 2 was refused at the gate and never queued.
4338        params_applied(&handle);
4339        let rt = tokio::runtime::Builder::new_current_thread()
4340            .enable_all()
4341            .build()
4342            .unwrap();
4343        let second = rt.block_on(async {
4344            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
4345        });
4346        assert!(
4347            second.is_err(),
4348            "second array throttled out by MinCallbackTime"
4349        );
4350        assert_eq!(
4351            dropped.load(Ordering::Acquire),
4352            0,
4353            "a MinCallbackTime-throttled frame must NOT increment DroppedArrays"
4354        );
4355    }
4356
4357    #[test]
4358    fn test_array_callbacks_zero_withholds_downstream_delivery() {
4359        // ADC-2: NDArrayCallbacks==0 stops downstream NDArray delivery while
4360        // the plugin still processes and updates its metadata params — C++
4361        // endProcessCallbacks (NDPluginDriver.cpp:257-265) caches the array and
4362        // returns before doCallbacksGenericPointer. Distinct from
4363        // EnableCallbacks, which gates whether the plugin processes at all.
4364        let pool = Arc::new(NDArrayPool::new(1_000_000));
4365        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
4366        let mut output = NDArrayOutput::new();
4367        output.add(downstream_sender);
4368
4369        let (handle, _data_jh) = create_plugin_runtime_with_output(
4370            "ARRAY_CB_TEST",
4371            PassthroughProcessor,
4372            pool,
4373            10,
4374            output,
4375            "",
4376            test_wiring(),
4377        );
4378        enable_callbacks(&handle);
4379        let port = handle.port_runtime().port_handle().clone();
4380
4381        // Disable downstream array callbacks.
4382        port.write_int32_blocking(handle.ndarray_params.array_callbacks, 0, 0)
4383            .unwrap();
4384        params_applied(&handle);
4385
4386        send_array(handle.array_sender(), make_test_array(1));
4387        // Processing fence: the counter flush follows any downstream publish,
4388        // so once it reads 1, a delivery that was going to happen already has.
4389        wait_until("frame 1 to be processed", || {
4390            port.read_int32_blocking(handle.ndarray_params.array_counter, 0)
4391                .is_ok_and(|v| v == 1)
4392        });
4393
4394        // No downstream delivery.
4395        let rt = tokio::runtime::Builder::new_current_thread()
4396            .enable_all()
4397            .build()
4398            .unwrap();
4399        let got = rt.block_on(async {
4400            tokio::time::timeout(std::time::Duration::from_millis(50), downstream_rx.recv()).await
4401        });
4402        assert!(
4403            got.is_err(),
4404            "NDArrayCallbacks=0 must withhold downstream delivery"
4405        );
4406        // But the plugin still processed: ArrayCounter advanced.
4407        assert_eq!(
4408            port.read_int32_blocking(handle.ndarray_params.array_counter, 0)
4409                .unwrap(),
4410            1,
4411            "processing (and metadata params) must continue while delivery is off"
4412        );
4413
4414        // Re-enable: the next array IS delivered downstream.
4415        port.write_int32_blocking(handle.ndarray_params.array_callbacks, 0, 1)
4416            .unwrap();
4417        params_applied(&handle);
4418        send_array(handle.array_sender(), make_test_array(2));
4419        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 2);
4420    }
4421
4422    #[test]
4423    fn test_plugin_output_publishes_compressed_size() {
4424        // ADC-3: every processed array publishes NDCodec / NDCompressedSize
4425        // (C++ beginProcessCallbacks NDPluginDriver.cpp:213-214). An
4426        // uncompressed output carries an empty codec name and compressedSize ==
4427        // raw bytes; a compressed output carries the codec name and its
4428        // compressed size. CompressedSize_RBV (Int32) exercises both arms.
4429        struct CompressProcessor;
4430        impl NDPluginProcess for CompressProcessor {
4431            fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
4432                let mut out = array.clone();
4433                out.codec = Some(crate::codec::Codec {
4434                    name: crate::codec::CodecName::JPEG,
4435                    compressed_size: 7,
4436                    level: 0,
4437                    shuffle: 0,
4438                    compressor: 0,
4439                    original_data_type: NDDataType::UInt8,
4440                });
4441                ProcessResult::arrays(vec![Arc::new(out)])
4442            }
4443            fn plugin_type(&self) -> &str {
4444                "Compress"
4445            }
4446        }
4447
4448        // Uncompressed passthrough: a 4-byte UInt8 array → compressedSize == 4.
4449        {
4450            let pool = Arc::new(NDArrayPool::new(1_000_000));
4451            let (ds, _rx) = ndarray_channel("DS_RAW", 10);
4452            let mut output = NDArrayOutput::new();
4453            output.add(ds);
4454            let (handle, _jh) = create_plugin_runtime_with_output(
4455                "CODEC_RAW",
4456                PassthroughProcessor,
4457                pool,
4458                10,
4459                output,
4460                "",
4461                test_wiring(),
4462            );
4463            enable_callbacks(&handle);
4464            let port = handle.port_runtime().port_handle().clone();
4465            send_array(handle.array_sender(), make_test_array(1));
4466            // The read errors with ParamUndefined until the first flush — treat
4467            // that as "not yet".
4468            wait_until(
4469                "uncompressed output to publish CompressedSize == raw byte count",
4470                || {
4471                    port.read_int32_blocking(handle.ndarray_params.compressed_size, 0)
4472                        .is_ok_and(|v| v == 4)
4473                },
4474            );
4475        }
4476
4477        // Compressed output: compressedSize == codec.compressed_size (7).
4478        {
4479            let pool = Arc::new(NDArrayPool::new(1_000_000));
4480            let (ds, _rx) = ndarray_channel("DS_CMP", 10);
4481            let mut output = NDArrayOutput::new();
4482            output.add(ds);
4483            let (handle, _jh) = create_plugin_runtime_with_output(
4484                "CODEC_CMP",
4485                CompressProcessor,
4486                pool,
4487                10,
4488                output,
4489                "",
4490                test_wiring(),
4491            );
4492            enable_callbacks(&handle);
4493            let port = handle.port_runtime().port_handle().clone();
4494            send_array(handle.array_sender(), make_test_array(1));
4495            wait_until(
4496                "compressed output to publish CompressedSize == codec.compressed_size",
4497                || {
4498                    port.read_int32_blocking(handle.ndarray_params.compressed_size, 0)
4499                        .is_ok_and(|v| v == 7)
4500                },
4501            );
4502        }
4503    }
4504
4505    #[test]
4506    fn test_process_plugin_skips_throttled_input() {
4507        // a MinCallbackTime-throttled frame must NOT be cached as the
4508        // ProcessPlugin input. After array 1 is processed and array 2 is
4509        // dropped by the throttle, ProcessPlugin must re-inject array 1
4510        // (the last *processed* array), not the dropped array 2.
4511        let pool = Arc::new(NDArrayPool::new(1_000_000));
4512        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
4513        let mut output = NDArrayOutput::new();
4514        output.add(downstream_sender);
4515
4516        let (handle, _data_jh) = create_plugin_runtime_with_output(
4517            "PROCESS_THROTTLE_TEST",
4518            PassthroughProcessor,
4519            pool,
4520            10,
4521            output,
4522            "",
4523            test_wiring(),
4524        );
4525        enable_callbacks(&handle);
4526
4527        // 10s minimum between callbacks — only the first array is processed.
4528        handle
4529            .port_runtime()
4530            .port_handle()
4531            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 10.0)
4532            .unwrap();
4533        params_applied(&handle);
4534
4535        send_array(handle.array_sender(), make_test_array(1));
4536        send_array(handle.array_sender(), make_test_array(2));
4537
4538        // Array 1 was processed and emitted; array 2 was throttled out.
4539        assert_eq!(downstream_rx.blocking_recv().unwrap().unique_id, 1);
4540        // Fence before the reset below un-gates the throttle: array 2 was
4541        // refused at the gate on the producer's thread, so nothing of it is
4542        // still in flight, but array 1 may still be processing.
4543        params_applied(&handle);
4544
4545        // ProcessPlugin re-injects the cached input. The cache must still hold
4546        // array 1, because array 2 never passed the throttle gate. The
4547        // re-injected array itself is also subject to the throttle, so reset
4548        // MinCallbackTime to 0 first so the re-injected frame is processed.
4549        handle
4550            .port_runtime()
4551            .port_handle()
4552            .write_float64_blocking(handle.plugin_params.min_callback_time, 0, 0.0)
4553            .unwrap();
4554        // No fence needed: the MinCallbackTime reset and the ProcessPlugin
4555        // trigger below travel the same FIFO param channel, so the reset is
4556        // applied before the trigger by construction.
4557        handle
4558            .port_runtime()
4559            .port_handle()
4560            .write_int32_blocking(handle.plugin_params.process_plugin, 0, 1)
4561            .unwrap();
4562        let reprocessed = downstream_rx.blocking_recv().unwrap();
4563        assert_eq!(
4564            reprocessed.unique_id, 1,
4565            "ProcessPlugin must re-inject the last processed array (1), not the throttled array (2)"
4566        );
4567    }
4568
4569    #[test]
4570    fn test_g3_compressed_array_dropped_on_non_aware_plugin() {
4571        // G3: a non-compression-aware plugin drops a compressed array.
4572        let pool = Arc::new(NDArrayPool::new(1_000_000));
4573        let (downstream_sender, mut downstream_rx) = ndarray_channel("DOWNSTREAM", 10);
4574        let mut output = NDArrayOutput::new();
4575        output.add(downstream_sender);
4576
4577        let (handle, _data_jh) = create_plugin_runtime_with_output(
4578            "G3_TEST",
4579            PassthroughProcessor, // compression_aware() defaults to false
4580            pool,
4581            10,
4582            output,
4583            "",
4584            test_wiring(),
4585        );
4586        enable_callbacks(&handle);
4587
4588        // A compressed array must be dropped, not forwarded.
4589        let mut compressed = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
4590        compressed.unique_id = 1;
4591        compressed.codec = Some(crate::codec::Codec {
4592            name: crate::codec::CodecName::JPEG,
4593            compressed_size: 16,
4594            level: 0,
4595            shuffle: 0,
4596            compressor: 0,
4597            original_data_type: NDDataType::UInt8,
4598        });
4599        send_array(handle.array_sender(), Arc::new(compressed));
4600
4601        // An uncompressed array passes through normally.
4602        send_array(handle.array_sender(), make_test_array(2));
4603
4604        let r = downstream_rx.blocking_recv().unwrap();
4605        assert_eq!(
4606            r.unique_id, 2,
4607            "compressed array dropped; only the raw array reaches downstream"
4608        );
4609    }
4610
4611    #[test]
4612    fn test_drop_on_full_increments_dropped_counter() {
4613        // B1/G1: a slow downstream plugin with a tiny input queue drops arrays
4614        // when the queue is full; the drop is counted in the plugin's shared
4615        // DroppedArrays counter rather than back-pressuring the producer.
4616        struct SlowProcessor;
4617        impl NDPluginProcess for SlowProcessor {
4618            fn process_array(&self, _a: &NDArray, _p: &NDArrayPool) -> ProcessResult {
4619                std::thread::sleep(std::time::Duration::from_millis(200));
4620                ProcessResult::empty()
4621            }
4622            fn plugin_type(&self) -> &str {
4623                "Slow"
4624            }
4625        }
4626        let pool = Arc::new(NDArrayPool::new(1_000_000));
4627
4628        // Downstream plugin with queue size 1 and a slow processor.
4629        let (downstream_handle, _ds_jh) =
4630            create_plugin_runtime("B1_DOWNSTREAM", SlowProcessor, pool, 1, "", test_wiring());
4631        enable_callbacks(&downstream_handle);
4632        let ds_sender = downstream_handle.array_sender().clone();
4633        let dropped = ds_sender.dropped_arrays_counter().clone();
4634
4635        // First array is taken by the data loop (now sleeping 200ms); second
4636        // fills the 1-slot queue; the rest find a full queue → dropped.
4637        send_array(&ds_sender, make_test_array(1));
4638        send_array(&ds_sender, make_test_array(2));
4639        send_array(&ds_sender, make_test_array(3));
4640        send_array(&ds_sender, make_test_array(4));
4641
4642        assert!(
4643            dropped.load(Ordering::Acquire) >= 1,
4644            "arrays dropped on a full queue must be counted (got {})",
4645            dropped.load(Ordering::Acquire)
4646        );
4647    }
4648
4649    #[test]
4650    fn test_cross_width_narrowing_array_read_truncates() {
4651        // Cross-width integer narrowing array reads must TRUNCATE (wrapping),
4652        // matching the C cast in C++ NDArrayPool.cpp:387 `convertType`
4653        //   *pDataOut++ = (dataTypeOut)(*pDataIn++);
4654        // A C cast `(epicsInt8)(epicsUInt16)300` keeps the low 8 bits == 44.
4655        // The f64 round-trip in copy_convert would SATURATE (`300.0 as i8`
4656        // == 127) and diverge from C++ — copy_ccast must be used instead.
4657
4658        // U16 -> i8: 300 = 0x012C; low byte 0x2C = 44.
4659        let mut out = [0i8; 1];
4660        let n = copy_ccast(&[300u16], &mut out);
4661        assert_eq!(n, 1);
4662        assert_eq!(out[0], 44, "(epicsInt8)(epicsUInt16)300 == 44 (low 8 bits)");
4663        // copy_convert would have saturated:
4664        let mut sat = [0i8; 1];
4665        copy_convert(&[300u16], &mut sat);
4666        assert_eq!(sat[0], 127, "f64 round-trip saturates — the wrong behavior");
4667
4668        // I32 -> i8: 0x1234_5678 -> low byte 0x78 = 120.
4669        let mut out2 = [0i8; 1];
4670        copy_ccast(&[0x1234_5678i32], &mut out2);
4671        assert_eq!(out2[0], 0x78);
4672
4673        // I32 -> i8: -1 stays -1 (all-ones low byte).
4674        let mut out3 = [0i8; 1];
4675        copy_ccast(&[-1i32], &mut out3);
4676        assert_eq!(out3[0], -1);
4677
4678        // U16 -> i8: 0x00FF = 255 -> low byte 0xFF reinterpreted as i8 == -1.
4679        let mut out4 = [0i8; 1];
4680        copy_ccast(&[255u16], &mut out4);
4681        assert_eq!(out4[0], -1);
4682
4683        // I64 -> i32: 0x0000_0001_0000_002A -> low 32 bits == 42.
4684        let mut out5 = [0i32; 1];
4685        copy_ccast(&[0x0000_0001_0000_002Ai64], &mut out5);
4686        assert_eq!(out5[0], 42);
4687
4688        // U32 -> i16: 70000 = 0x0001_1170 -> low 16 bits 0x1170 == 4464.
4689        let mut out6 = [0i16; 1];
4690        copy_ccast(&[70000u32], &mut out6);
4691        assert_eq!(out6[0], 4464);
4692
4693        // Same-width sign change still works as a bitwise reinterpret:
4694        // U8 255 -> i8 -1.
4695        let mut out7 = [0i8; 1];
4696        copy_ccast(&[255u8], &mut out7);
4697        assert_eq!(out7[0], -1);
4698
4699        // F64 out-of-range -> i32 still routes through copy_convert (the
4700        // `convert:` arm for float sources). C++ converts float->int with a
4701        // C cast too, but the runtime keeps the f64 numeric path for float
4702        // sources; this asserts the integer-narrowing fix did not change the
4703        // float-source path.
4704        let mut fout = [0i32; 1];
4705        copy_convert(&[42.9f64], &mut fout);
4706        assert_eq!(fout[0], 42, "f64 -> i32 truncates toward zero");
4707    }
4708
4709    // ---- ADP-45: scatter overflow-reroute (C++ NDPluginScatter) ----
4710
4711    /// Run an async body on a throwaway current-thread runtime.
4712    fn block<F: std::future::Future>(f: F) -> F::Output {
4713        tokio::runtime::Builder::new_current_thread()
4714            .enable_all()
4715            .build()
4716            .unwrap()
4717            .block_on(f)
4718    }
4719
4720    #[test]
4721    fn test_scatter_reroutes_past_full_consumer() {
4722        // 3 consumers, queue size 1. Pre-fill A so its queue is full; a scatter
4723        // that would target A must reroute to B (C++ auxStatus=asynOverflow),
4724        // and the rerouted-away full queue must NOT count a dropped array
4725        // (driverCallback ignoreQueueFull, NDPluginDriver.cpp:406,433-442).
4726        let (sa, mut ra) = ndarray_channel("A", 1);
4727        let (sb, mut rb) = ndarray_channel("B", 1);
4728        let (sc, _rc) = ndarray_channel("C", 1);
4729        block(async {
4730            assert_eq!(
4731                sa.publish(make_test_array(99)).await,
4732                PublishOutcome::Delivered
4733            );
4734            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
4735            let mut cursor = 0usize;
4736            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
4737            // A rerouted (attempt 0), B delivered (attempt 1): cursor +2.
4738            assert_eq!(cursor, 2);
4739            assert_eq!(rb.recv().await.unwrap().unique_id, 1);
4740            // A still holds only its filler; the rerouted-away drop was not counted.
4741            assert_eq!(ra.recv().await.unwrap().unique_id, 99);
4742            assert_eq!(sa.dropped_arrays_counter().load(Ordering::Acquire), 0);
4743        });
4744    }
4745
4746    #[test]
4747    fn test_scatter_drops_on_last_when_all_full_counts_once() {
4748        // Both consumers full. The array is dropped on the last node only, and
4749        // the drop is counted exactly once (C++ sets auxStatus=asynSuccess for
4750        // the last node so its full queue drops and counts).
4751        let (sa, mut ra) = ndarray_channel("A", 1);
4752        let (sb, mut rb) = ndarray_channel("B", 1);
4753        block(async {
4754            sa.publish(make_test_array(91)).await;
4755            sb.publish(make_test_array(92)).await;
4756            let senders = vec![sa.clone(), sb.clone()];
4757            let mut cursor = 0usize;
4758            ProcessOutput::scatter_publish(&make_test_array(7), &senders, &mut cursor).await;
4759            assert_eq!(cursor, 2); // both attempted
4760            // A rerouted-away (not counted); B last (dropped, counted once).
4761            assert_eq!(sa.dropped_arrays_counter().load(Ordering::Acquire), 0);
4762            assert_eq!(sb.dropped_arrays_counter().load(Ordering::Acquire), 1);
4763            // Neither queue received frame 7 — both still hold their fillers.
4764            assert_eq!(ra.recv().await.unwrap().unique_id, 91);
4765            assert_eq!(rb.recv().await.unwrap().unique_id, 92);
4766        });
4767    }
4768
4769    #[test]
4770    fn test_scatter_cursor_advances_per_attempt_across_frames() {
4771        // A is permanently full; B and C are free. Frame 0 reroutes A->B, so
4772        // the persistent cursor (C++ nextClient_) ends past B. Frame 1 must
4773        // therefore start at C, NOT back at B: a per-frame cursor would send
4774        // frame 1 to B; the per-attempt cursor sends it to C.
4775        let (sa, _ra) = ndarray_channel("A", 1);
4776        let (sb, mut rb) = ndarray_channel("B", 10);
4777        let (sc, mut rc) = ndarray_channel("C", 10);
4778        block(async {
4779            sa.publish(make_test_array(90)).await; // fill A permanently
4780            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
4781            let mut cursor = 0usize;
4782            ProcessOutput::scatter_publish(&make_test_array(0), &senders, &mut cursor).await;
4783            assert_eq!(cursor, 2); // A(reroute) + B(deliver)
4784            assert_eq!(rb.recv().await.unwrap().unique_id, 0);
4785            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
4786            assert_eq!(cursor, 3); // C(deliver) on the first attempt
4787            assert_eq!(rc.recv().await.unwrap().unique_id, 1);
4788        });
4789    }
4790
4791    #[test]
4792    fn test_scatter_skips_disabled_consumer() {
4793        // A disabled downstream is unregistered from the interrupt list in C++
4794        // (setArrayInterrupt(0)) and must not consume a round-robin slot.
4795        let (sa, mut ra) = ndarray_channel("A", 10);
4796        let (mut sb, _rb) = ndarray_channel("B", 10);
4797        let (sc, mut rc) = ndarray_channel("C", 10);
4798        sb.set_mode_flags(
4799            Arc::new(AtomicBool::new(false)),
4800            Arc::new(AtomicBool::new(false)),
4801        );
4802        block(async {
4803            let senders = vec![sa.clone(), sb.clone(), sc.clone()];
4804            let mut cursor = 0usize;
4805            // Active set = [A, C] (n=2): frame 0 -> A, frame 1 -> C.
4806            ProcessOutput::scatter_publish(&make_test_array(0), &senders, &mut cursor).await;
4807            ProcessOutput::scatter_publish(&make_test_array(1), &senders, &mut cursor).await;
4808            assert_eq!(ra.recv().await.unwrap().unique_id, 0);
4809            assert_eq!(rc.recv().await.unwrap().unique_id, 1);
4810        });
4811    }
4812
4813    /// Holds every frame in `process_array` until released, so the input queue
4814    /// is the only thing absorbing the backlog.
4815    struct GatedProcessor {
4816        open: Arc<parking_lot::Mutex<bool>>,
4817        gate: Arc<parking_lot::Condvar>,
4818        /// Bumped on entry, ahead of the gate: a frame that has reached a
4819        /// callback thread at all shows up here even while the gate is shut.
4820        entered: Arc<std::sync::atomic::AtomicUsize>,
4821        /// uniqueIds in completion order.
4822        log: Arc<parking_lot::Mutex<Vec<i32>>>,
4823    }
4824
4825    impl GatedProcessor {
4826        fn new() -> (Self, Arc<std::sync::atomic::AtomicUsize>, GatedControls) {
4827            let controls = GatedControls {
4828                open: Arc::new(parking_lot::Mutex::new(false)),
4829                gate: Arc::new(parking_lot::Condvar::new()),
4830                log: Arc::new(parking_lot::Mutex::new(Vec::new())),
4831            };
4832            let entered = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4833            (
4834                Self {
4835                    open: controls.open.clone(),
4836                    gate: controls.gate.clone(),
4837                    entered: entered.clone(),
4838                    log: controls.log.clone(),
4839                },
4840                entered,
4841                controls,
4842            )
4843        }
4844    }
4845
4846    struct GatedControls {
4847        open: Arc<parking_lot::Mutex<bool>>,
4848        gate: Arc<parking_lot::Condvar>,
4849        log: Arc<parking_lot::Mutex<Vec<i32>>>,
4850    }
4851
4852    impl GatedControls {
4853        fn release(&self) {
4854            *self.open.lock() = true;
4855            self.gate.notify_all();
4856        }
4857        fn processed(&self) -> Vec<i32> {
4858            self.log.lock().clone()
4859        }
4860    }
4861
4862    impl NDPluginProcess for GatedProcessor {
4863        fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
4864            self.entered.fetch_add(1, Ordering::AcqRel);
4865            let mut open = self.open.lock();
4866            while !*open {
4867                self.gate.wait(&mut open);
4868            }
4869            drop(open);
4870            self.log.lock().push(array.unique_id);
4871            ProcessResult::empty()
4872        }
4873        fn plugin_type(&self) -> &str {
4874            "Gated"
4875        }
4876        fn does_array_callbacks(&self) -> bool {
4877            false
4878        }
4879    }
4880
4881    /// A QueueSize write must resize the input queue, not just the readback.
4882    ///
4883    /// C deletes `pToThreadMsgQ_` and news it at the new depth
4884    /// (NDPluginDriver.cpp:730-733 -> :985), so an operator who raises
4885    /// QueueSize gets a deeper queue. This port fixed the depth at
4886    /// construction, so the PV moved and the queue did not — a backlog past
4887    /// the ORIGINAL depth was still dropped and counted in DroppedArrays while
4888    /// QueueFree claimed room. That is the observable: same PV writes, same
4889    /// frames offered, arrays lost.
4890    #[test]
4891    fn test_queue_size_write_resizes_the_input_queue() {
4892        const OFFERED: usize = 20;
4893        let pool = Arc::new(NDArrayPool::new(1_000_000));
4894        let (processor, _entered, gated) = GatedProcessor::new();
4895        let (handle, _jh) =
4896            create_plugin_runtime("QSIZE_TEST", processor, pool, 2, "", test_wiring());
4897        let port = handle.port_runtime().port_handle();
4898        enable_callbacks(&handle);
4899
4900        // Raise the depth while nothing is in flight, exactly as an operator
4901        // would before starting acquisition.
4902        port.write_int32_blocking(handle.plugin_params.queue_size, 0, 32)
4903            .unwrap();
4904        params_applied(&handle);
4905        assert_eq!(
4906            port.read_int32_blocking(handle.plugin_params.queue_size, 0)
4907                .unwrap(),
4908            32,
4909            "QueueSize readback did not take"
4910        );
4911
4912        // One frame occupies the single worker; the remaining 19 must fit in
4913        // the queue the operator just asked for. The gate is shut, so nothing
4914        // drains while they are offered.
4915        for id in 0..OFFERED as i32 {
4916            send_array(handle.array_sender(), make_test_array(id));
4917        }
4918
4919        gated.release();
4920
4921        // Let the backlog drain, then count. A frame the queue refused never
4922        // reaches `process_array` at all.
4923        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
4924        while gated.processed().len() < OFFERED && std::time::Instant::now() < deadline {
4925            std::thread::sleep(std::time::Duration::from_millis(2));
4926        }
4927        params_applied(&handle);
4928
4929        let processed = gated.processed().len();
4930        let dropped = port
4931            .read_int32_blocking(handle.plugin_params.dropped_arrays, 0)
4932            .unwrap();
4933        assert_eq!(
4934            processed, OFFERED,
4935            "{OFFERED} frames offered to a queue the operator set to 32, but only {processed} \
4936             reached the plugin ({dropped} counted dropped): the write moved the PV, not the queue"
4937        );
4938        assert_eq!(dropped, 0, "DroppedArrays should not have moved");
4939    }
4940
4941    /// MinCallbackTime exists to relieve queue pressure, so a frame it gates
4942    /// must occupy no queue slot — and therefore can never push a later frame
4943    /// out of one. C decides this inside `driverCallback` before `trySend`
4944    /// (NDPluginDriver.cpp:404-433): a throttled array returns at `:449`
4945    /// having touched neither the queue nor `DroppedArrays`.
4946    ///
4947    /// Deciding it after `recv` instead inverts the observable: the throttled
4948    /// frames fill the queue, the queue refuses a later one, and the plugin
4949    /// reports a drop C never reports.
4950    #[test]
4951    fn min_callback_time_gate_takes_no_queue_slot() {
4952        const OFFERED: usize = 20;
4953        const DEPTH: usize = 2;
4954        let pool = Arc::new(NDArrayPool::new(1_000_000));
4955        let (processor, entered, gated) = GatedProcessor::new();
4956        let (handle, _jh) =
4957            create_plugin_runtime("MINCB_PRESSURE", processor, pool, DEPTH, "", test_wiring());
4958        let port = handle.port_runtime().port_handle();
4959        enable_callbacks(&handle);
4960        // Long enough that nothing after the first frame can pass the gate,
4961        // however slowly the test runs.
4962        port.write_float64_blocking(handle.plugin_params.min_callback_time, 0, 30.0)
4963            .unwrap();
4964        params_applied(&handle);
4965
4966        // Park the single callback thread on frame 0, so from here on the
4967        // queue is the only thing that could absorb an arrival.
4968        send_array(handle.array_sender(), make_test_array(0));
4969        wait_until("frame 0 reached the callback thread", || {
4970            entered.load(Ordering::Acquire) == 1
4971        });
4972        for id in 1..OFFERED as i32 {
4973            send_array(handle.array_sender(), make_test_array(id));
4974        }
4975        gated.release();
4976        params_applied(&handle);
4977
4978        assert_eq!(
4979            gated.processed(),
4980            vec![0],
4981            "only the frame that passed the gate is processed"
4982        );
4983        assert_eq!(
4984            port.read_int32_blocking(handle.plugin_params.dropped_arrays, 0)
4985                .unwrap(),
4986            0,
4987            "the throttle gate counts nothing: C reaches DroppedArrays only \
4988             from the compression check (:390) and a refused trySend (:440)"
4989        );
4990        assert_eq!(
4991            port.read_int32_blocking(handle.plugin_params.queue_use, 0)
4992                .unwrap(),
4993            DEPTH as i32,
4994            "and it consumed no queue slot on the way"
4995        );
4996    }
4997
4998    /// Every compressed array offered to a non-compression-aware plugin is
4999    /// counted, one per array (NDPluginDriver.cpp:383-394). The overflow
5000    /// episode cannot silence them: `auxStatus` is only read inside the
5001    /// `minCallbackTime` branch at `:405`, which the compression gate returns
5002    /// before ever reaching.
5003    ///
5004    /// Letting them into the queue instead collapses the count to the number
5005    /// of overflow episodes — here 3 rather than 12 — because they then reach
5006    /// `trySend` and arm the episode cell.
5007    #[test]
5008    fn every_compressed_array_counts_against_dropped_arrays() {
5009        const COMPRESSED: usize = 12;
5010        fn compressed_array(id: i32) -> Arc<NDArray> {
5011            let mut a = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
5012            a.unique_id = id;
5013            a.codec = Some(crate::codec::Codec {
5014                name: crate::codec::CodecName::JPEG,
5015                compressed_size: 16,
5016                level: 0,
5017                shuffle: 0,
5018                compressor: 0,
5019                original_data_type: NDDataType::UInt8,
5020            });
5021            Arc::new(a)
5022        }
5023        let pool = Arc::new(NDArrayPool::new(1_000_000));
5024        let (processor, entered, gated) = GatedProcessor::new();
5025        let (handle, _jh) =
5026            create_plugin_runtime("COMPR_PRESSURE", processor, pool, 2, "", test_wiring());
5027        let port = handle.port_runtime().port_handle();
5028        enable_callbacks(&handle);
5029
5030        // One raw frame parks the single callback thread, so the queue is the
5031        // only thing that could absorb what follows — the pressure that makes
5032        // the episode collapse visible.
5033        send_array(handle.array_sender(), make_test_array(0));
5034        wait_until("frame 0 reached the callback thread", || {
5035            entered.load(Ordering::Acquire) == 1
5036        });
5037        for id in 1..=COMPRESSED as i32 {
5038            send_array(handle.array_sender(), compressed_array(id));
5039        }
5040        gated.release();
5041        params_applied(&handle);
5042
5043        assert_eq!(
5044            port.read_int32_blocking(handle.plugin_params.dropped_arrays, 0)
5045                .unwrap(),
5046            COMPRESSED as i32,
5047            "one count per compressed array, not one per overflow episode"
5048        );
5049        assert_eq!(gated.processed(), vec![0], "and none of them was processed");
5050    }
5051
5052    /// ProcessPlugin re-injects through the queue, not around it.
5053    ///
5054    /// C hands the cached array to `driverCallback`
5055    /// (NDPluginDriver.cpp:741) — the detector's own entry point — so the
5056    /// re-processed frame takes its turn behind whatever is already queued and
5057    /// is run by a callback thread. Re-injecting on the data loop instead put
5058    /// it ahead of the backlog and ran it on a thread that is not a callback
5059    /// thread at all, so with every callback thread busy a client saw the
5060    /// duplicate arrive *before* frames the detector had already delivered.
5061    #[test]
5062    fn test_process_plugin_queues_behind_the_backlog() {
5063        let pool = Arc::new(NDArrayPool::new(1_000_000));
5064        let (processor, entered, gated) = GatedProcessor::new();
5065        let (handle, _jh) =
5066            create_plugin_runtime("PPORDER_TEST", processor, pool, 8, "", test_wiring());
5067        let port = handle.port_runtime().port_handle();
5068        enable_callbacks(&handle);
5069
5070        // Frame 1 occupies the single callback thread and is the array
5071        // ProcessPlugin will re-inject; 2 and 3 pile up behind it.
5072        send_array(handle.array_sender(), make_test_array(1));
5073        wait_until("frame 1 reached the callback thread", || {
5074            entered.load(Ordering::Acquire) == 1
5075        });
5076        send_array(handle.array_sender(), make_test_array(2));
5077        send_array(handle.array_sender(), make_test_array(3));
5078
5079        port.write_int32_blocking(handle.plugin_params.process_plugin, 0, 1)
5080            .unwrap();
5081        param_fifo_drained(&handle);
5082        assert_eq!(
5083            entered.load(Ordering::Acquire),
5084            1,
5085            "the re-injected frame entered process_array with the only callback \
5086             thread still busy, so it did not go through the queue"
5087        );
5088
5089        gated.release();
5090        wait_until("every frame drained", || gated.processed().len() == 4);
5091        params_applied(&handle);
5092        assert_eq!(
5093            gated.processed(),
5094            vec![1, 2, 3, 1],
5095            "the re-injected frame must arrive after the queued backlog"
5096        );
5097    }
5098
5099    /// A full queue refuses the re-injection and counts it.
5100    ///
5101    /// `driverCallback` `trySend`s (NDPluginDriver.cpp:430) and on failure
5102    /// bumps DroppedArrays (`:433-441`), and ProcessPlugin goes through it like
5103    /// any array. Running the work inline instead meant the one request C
5104    /// refuses always succeeded: the operator got an output frame where C
5105    /// gives them a dropped-array count.
5106    #[test]
5107    fn test_process_plugin_is_refused_when_the_queue_is_full() {
5108        let pool = Arc::new(NDArrayPool::new(1_000_000));
5109        let (processor, entered, gated) = GatedProcessor::new();
5110        let (handle, _jh) =
5111            create_plugin_runtime("PPFULL_TEST", processor, pool, 1, "", test_wiring());
5112        let port = handle.port_runtime().port_handle();
5113        enable_callbacks(&handle);
5114
5115        send_array(handle.array_sender(), make_test_array(1));
5116        wait_until("frame 1 reached the callback thread", || {
5117            entered.load(Ordering::Acquire) == 1
5118        });
5119        // Frame 2 fills the one-deep queue, so there is no room for a
5120        // re-injection.
5121        send_array(handle.array_sender(), make_test_array(2));
5122
5123        port.write_int32_blocking(handle.plugin_params.process_plugin, 0, 1)
5124            .unwrap();
5125        param_fifo_drained(&handle);
5126        assert_eq!(
5127            entered.load(Ordering::Acquire),
5128            1,
5129            "the re-injection was processed even though the queue was full"
5130        );
5131        assert_eq!(
5132            port.read_int32_blocking(handle.plugin_params.dropped_arrays, 0)
5133                .unwrap(),
5134            1,
5135            "a refused re-injection must be counted in DroppedArrays"
5136        );
5137
5138        gated.release();
5139        wait_until("every frame drained", || gated.processed().len() == 2);
5140        params_applied(&handle);
5141        assert_eq!(gated.processed(), vec![1, 2]);
5142    }
5143}