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 ¶m_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(¤t_upstream, current_addr);
2586 let new_key = upstream_key(¤t_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(¤t_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}