Skip to main content

ad_core_rs/plugin/
channel.rs

1// RTEMS-EXEC-MODEL-ALLOW(17): checked, not waived — all 17 ran and passed
2// on the exec backend (measured on this tree:
3// `EPICS_RS_BUILD_EXEC_BACKEND=thread cargo nextest run -p ad-core-rs
4// --all-features`, 345/345). ad-core-rs became a census subject when its
5// `build.rs` began deriving `tokio_backend`; nothing here builds a CA
6// server, and the reactor these obtain comes from `#[tokio::test]`
7// itself, which the backend does not remove.
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, AtomicUsize, Ordering};
10use std::time::Duration;
11
12use crate::ndarray::NDArray;
13
14/// Tracks the number of queued (in-flight) arrays across plugins.
15/// Used by drivers to perform a bounded wait at end of acquisition.
16pub struct QueuedArrayCounter {
17    count: AtomicUsize,
18    mutex: parking_lot::Mutex<()>,
19    condvar: parking_lot::Condvar,
20}
21
22impl QueuedArrayCounter {
23    /// Create a new counter starting at zero.
24    pub fn new() -> Self {
25        Self {
26            count: AtomicUsize::new(0),
27            mutex: parking_lot::Mutex::new(()),
28            condvar: parking_lot::Condvar::new(),
29        }
30    }
31
32    /// Increment the queued count (called before send).
33    pub fn increment(&self) {
34        self.count.fetch_add(1, Ordering::AcqRel);
35    }
36
37    /// Decrement the queued count. Notifies waiters when reaching zero.
38    pub fn decrement(&self) {
39        let prev = self.count.fetch_sub(1, Ordering::AcqRel);
40        if prev == 1 {
41            let _guard = self.mutex.lock();
42            self.condvar.notify_all();
43        }
44    }
45
46    /// Current queued count.
47    pub fn get(&self) -> usize {
48        self.count.load(Ordering::Acquire)
49    }
50
51    /// Wait until count reaches zero, or timeout expires.
52    /// Returns `true` if count is zero, `false` on timeout.
53    pub fn wait_until_zero(&self, timeout: Duration) -> bool {
54        let mut guard = self.mutex.lock();
55        if self.count.load(Ordering::Acquire) == 0 {
56            return true;
57        }
58        !self
59            .condvar
60            .wait_while_for(
61                &mut guard,
62                |_| self.count.load(Ordering::Acquire) != 0,
63                timeout,
64            )
65            .timed_out()
66    }
67}
68
69impl Default for QueuedArrayCounter {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75/// Array message with optional queued-array counter and completion signal.
76/// When dropped, decrements the counter (if present) — this signals that
77/// the downstream plugin has finished processing the array.
78pub struct ArrayMessage {
79    pub array: Arc<NDArray>,
80    pub(crate) counter: Option<Arc<QueuedArrayCounter>>,
81    /// When Some, the sender awaits this to confirm downstream processing completed.
82    /// Fired when ArrayMessage is dropped (i.e., after plugin process_array finishes).
83    pub(crate) done_tx: Option<tokio::sync::oneshot::Sender<()>>,
84}
85
86impl Drop for ArrayMessage {
87    fn drop(&mut self) {
88        if let Some(tx) = self.done_tx.take() {
89            let _ = tx.send(());
90        }
91        if let Some(c) = self.counter.take() {
92            c.decrement();
93        }
94    }
95}
96
97/// Outcome of a `publish` call, mirroring C++ `driverCallback` accounting.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum PublishOutcome {
100    /// The array was enqueued (and, in blocking mode, processed).
101    Delivered,
102    /// `enable_callbacks` was 0 — array not sent (not a drop, not counted).
103    Disabled,
104    /// The downstream queue was full and the array was dropped. The caller
105    /// must increment `DroppedArrays`, matching C++ `trySend` semantics.
106    DroppedQueueFull,
107    /// The array carried a codec and the downstream plugin is not compression
108    /// aware, so it was dropped and counted before ever reaching the queue
109    /// (C++ `driverCallback` NDPluginDriver.cpp:383-394).
110    DroppedCompressed,
111    /// The array arrived inside the downstream plugin's `MinCallbackTime`
112    /// window and was discarded without being counted (C++ `driverCallback`
113    /// falls through the `deltaTime > minCallbackTime` gate at :407 and
114    /// touches nothing).
115    Throttled,
116    /// The downstream channel was closed (receiver gone).
117    ChannelClosed,
118}
119
120/// What C++ `driverCallback` decides about an arriving array BEFORE it ever
121/// reaches `pToThreadMsgQ_` (NDPluginDriver.cpp:383-418): the compression gate
122/// at `:385`, then the `deltaTime > minCallbackTime` gate at `:407`, then the
123/// `lastProcessTime_` stamp at `:417` that a passing array leaves behind.
124///
125/// It lives on the producer's side of the queue because that is where C runs
126/// it, and the side is the whole observable. A compressed array on a
127/// non-aware plugin and an array inside the MinCallbackTime window occupy no
128/// queue slot in C, so they can never push a LATER array out of one. Deciding
129/// the same thing after `recv` instead turns MinCallbackTime — whose purpose
130/// is to relieve queue pressure — into a cause of it, and makes a compressed
131/// array's drop compete with the queue-full episode counter it should never
132/// have reached.
133///
134/// One instance per receiving plugin, shared by every producer that feeds it,
135/// exactly as `lastProcessTime_` is one member of one plugin however many
136/// drivers call back into it.
137pub struct ArrayAdmission {
138    /// C `compressionAware_`, fixed at construction.
139    compression_aware: AtomicBool,
140    /// C `NDPluginDriverMinCallbackTime`, seconds, as `f64` bits.
141    min_callback_time: AtomicU64,
142    /// C `lastProcessTime_`. Behind a mutex so the read of the gate and the
143    /// stamp that follows it are one step, which is what C's `this->lock()`
144    /// across the whole of `driverCallback` buys: two drivers calling back at
145    /// once cannot both pass one window.
146    last_process: parking_lot::Mutex<Option<std::time::Instant>>,
147    /// Raised whenever a producer counts a drop against `DroppedArrays`, so
148    /// the plugin's data loop can publish the readback even on a run where no
149    /// array is ever processed. C gets this for free: `driverCallback` ends in
150    /// `callParamCallbacks()` on every call, dropped or not (`:449`).
151    counted_drop: tokio::sync::Notify,
152}
153
154/// The three ways C++ `driverCallback` can dispose of an array before the
155/// queue: process it, drop and count it, or silently skip it.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub(crate) enum Admission {
158    /// Past both gates; `lastProcessTime_` has been stamped.
159    Admit,
160    /// Compressed input to a non-compression-aware plugin (`:385-394`).
161    DropCompressed,
162    /// Inside the MinCallbackTime window (`:407`).
163    Throttled,
164}
165
166impl Default for ArrayAdmission {
167    fn default() -> Self {
168        Self {
169            compression_aware: AtomicBool::new(false),
170            min_callback_time: AtomicU64::new(0.0f64.to_bits()),
171            last_process: parking_lot::Mutex::new(None),
172            counted_drop: tokio::sync::Notify::new(),
173        }
174    }
175}
176
177impl ArrayAdmission {
178    /// C `compressionAware_`; set once from the plugin's processor.
179    pub fn set_compression_aware(&self, aware: bool) {
180        self.compression_aware.store(aware, Ordering::Release);
181    }
182
183    /// C `setDoubleParam(NDPluginDriverMinCallbackTime, ...)`.
184    pub fn set_min_callback_time(&self, seconds: f64) {
185        self.min_callback_time
186            .store(seconds.to_bits(), Ordering::Release);
187    }
188
189    /// Classify one arriving array, stamping `lastProcessTime_` when it
190    /// passes. C stamps before the blocking/non-blocking branch and before
191    /// `trySend`, so an array that passes the gate and is then refused by a
192    /// full queue still resets the clock (`:417` vs `:433`).
193    pub(crate) fn classify(&self, array: &NDArray) -> Admission {
194        // The compression gate is FIRST in C, so a compressed array is dropped
195        // and counted even when it arrives inside a throttle window.
196        if array.codec.is_some() && !self.compression_aware.load(Ordering::Acquire) {
197            return Admission::DropCompressed;
198        }
199        let min = f64::from_bits(self.min_callback_time.load(Ordering::Acquire));
200        let mut last = self.last_process.lock();
201        if min > 0.0
202            && let Some(previous) = *last
203            && previous.elapsed().as_secs_f64() < min
204        {
205            return Admission::Throttled;
206        }
207        *last = Some(std::time::Instant::now());
208        Admission::Admit
209    }
210
211    /// Wake the data loop so it republishes `DroppedArrays`.
212    pub(crate) fn note_counted_drop(&self) {
213        self.counted_drop.notify_one();
214    }
215
216    /// Await the next counted drop. A drop raised while nobody was waiting
217    /// leaves a permit, so no readback is lost.
218    pub(crate) async fn counted_drop(&self) {
219        self.counted_drop.notified().await;
220    }
221}
222
223/// C++ `asynUser::auxStatus` on a producer's `pasynUser`, which is the only
224/// state `driverCallback` carries from one call to the next
225/// (NDPluginDriver.cpp:405-406, :433-434).
226///
227/// It exists so `DroppedArrays` counts one per overflow EPISODE, not one per
228/// dropped array: the first refusal arms the cell, every consecutive refusal
229/// reads it as `ignoreQueueFull` and stays silent, and the first successful
230/// enqueue leaves it disarmed so the next refusal opens a new episode. A
231/// detector running 1 kHz into a plugin that stalls for a second therefore
232/// adds 1 to the counter, not 1000.
233///
234/// One cell per PRODUCER, because that is what a `pasynUser` is. The array
235/// port edge and the plugin's own `ProcessPlugin` re-injection are two
236/// producers in C — `driverCallback` is reached with
237/// `pasynUserGenericPointer_` for the first and `pasynUserSelf` for the second
238/// (`:539-541` vs `:741`) — so they get two cells here, and one path's
239/// overflow never silences the other's count.
240#[derive(Debug, Default)]
241pub(crate) struct OverflowEpisode(AtomicBool);
242
243impl OverflowEpisode {
244    /// C `if (pasynUser->auxStatus == asynOverflow) ignoreQueueFull = true;`
245    /// immediately followed by `pasynUser->auxStatus = asynSuccess;` (:405-406)
246    /// — the read and the consume are one step, so no caller can read the flag
247    /// without clearing it.
248    fn take(&self) -> bool {
249        self.0.swap(false, Ordering::AcqRel)
250    }
251
252    /// C `pasynUser->auxStatus = asynOverflow;` on a refused `trySend` (:433),
253    /// and `NDPluginScatter`'s pre-arm of a node it means to reroute past
254    /// (NDPluginScatter.cpp:83).
255    fn arm(&self) {
256        self.0.store(true, Ordering::Release);
257    }
258
259    /// C `NDPluginScatter.cpp:84` — the last node is given `asynSuccess`, so
260    /// it counts its drop even if the previous round ended in overflow.
261    fn disarm(&self) {
262        self.0.store(false, Ordering::Release);
263    }
264}
265
266/// The `trySend` arm of C++ `driverCallback` (NDPluginDriver.cpp:423-442):
267/// enqueue, or drop the array and count it against the *receiving* plugin's
268/// `DroppedArrays` — unless this producer's [`OverflowEpisode`] says the
269/// previous call already opened the episode.
270///
271/// The one owner of that accounting: an upstream publish and the plugin's own
272/// `ProcessPlugin` re-injection both come through here, so a queue-full drop
273/// is counted the same however the array got to the queue.
274fn try_send_arm(
275    tx: &parking_lot::RwLock<tokio::sync::mpsc::Sender<ArrayMessage>>,
276    queued_counter: &Option<Arc<QueuedArrayCounter>>,
277    dropped_arrays: &AtomicI32,
278    array: Arc<NDArray>,
279    episode: &OverflowEpisode,
280    admission: &ArrayAdmission,
281) -> PublishOutcome {
282    // C reads and clears `auxStatus` before deciding anything else (:405-406),
283    // so a successful enqueue below ends the episode by simply not re-arming.
284    let ignore_queue_full = episode.take();
285    // Build the message only on the way into try_send so a full queue does
286    // not touch the counter.
287    if let Some(c) = queued_counter {
288        c.increment();
289    }
290    let msg = ArrayMessage {
291        array,
292        counter: queued_counter.clone(),
293        done_tx: None,
294    };
295    match tx.read().try_send(msg) {
296        Ok(()) => PublishOutcome::Delivered,
297        // `msg` is dropped here → counter decremented by ArrayMessage::drop.
298        Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
299            episode.arm();
300            if !ignore_queue_full {
301                dropped_arrays.fetch_add(1, Ordering::AcqRel);
302                admission.note_counted_drop();
303            }
304            PublishOutcome::DroppedQueueFull
305        }
306        Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => PublishOutcome::ChannelClosed,
307    }
308}
309
310/// Sender held by upstream.
311///
312/// # Default: drop-on-full (C++ parity)
313///
314/// By default `publish` uses a bounded `try_send`: when the downstream queue
315/// is full the array is **dropped** and `PublishOutcome::DroppedQueueFull` is
316/// returned, matching C++ `NDPluginDriver::driverCallback` `trySend` — a slow
317/// plugin drops frames rather than back-pressuring the detector driver.
318///
319/// # `blocking_callbacks=1`: reliable opt-in
320///
321/// When `blocking_callbacks` is set, `publish` instead uses a reliable
322/// `send().await` and waits for the downstream plugin to finish processing.
323/// This is the explicit opt-in for "never drop, apply back-pressure"
324/// behavior. It is NOT the default.
325#[derive(Clone)]
326pub struct NDArraySender {
327    /// The queue itself, behind a shared cell so it can be REPLACED.
328    ///
329    /// C keeps the input queue as `pToThreadMsgQ_`, a pointer inside the
330    /// plugin that every producer reaches through `driverCallback`; a
331    /// QueueSize write deletes it and news it at the new depth
332    /// (`NDPluginDriver.cpp:730-733` -> `:985`), and every producer follows
333    /// because they never held the queue, only the plugin. Upstream ports
334    /// here hold cloned `NDArraySender`s instead, so the shared cell is what
335    /// gives one replacement the same reach. A plain `Sender` field would
336    /// leave every existing clone addressing the old queue, which is a
337    /// QueueSize write that moves the readback and nothing else.
338    tx: Arc<parking_lot::RwLock<tokio::sync::mpsc::Sender<ArrayMessage>>>,
339    port_name: String,
340    enabled: Arc<AtomicBool>,
341    blocking_mode: Arc<AtomicBool>,
342    queued_counter: Option<Arc<QueuedArrayCounter>>,
343    /// Cumulative count of arrays dropped because this sender's downstream
344    /// input queue was full. Owned by the downstream plugin (which publishes
345    /// it to its `DROPPED_ARRAYS` param), shared back to every upstream
346    /// sender that feeds this plugin — matching C++ `driverCallback` which
347    /// increments the *receiving* plugin's `NDPluginDriverDroppedArrays`.
348    dropped_arrays: Arc<AtomicI32>,
349    /// This edge's `pasynUser->auxStatus`. Shared by every clone of the
350    /// sender, because in C the cell belongs to the receiving plugin's one
351    /// registered `pasynUserGenericPointer_` and not to whichever upstream
352    /// happens to be pushing.
353    overflow: Arc<OverflowEpisode>,
354    /// The receiving plugin's pre-queue gates. Consulted here rather than
355    /// after `recv` because that is where C consults them — see
356    /// [`ArrayAdmission`].
357    admission: Arc<ArrayAdmission>,
358}
359
360impl NDArraySender {
361    /// Publish an array downstream.
362    ///
363    /// - `enable_callbacks=0`: returns `Disabled`, array not sent.
364    /// - `blocking_callbacks=0` (default): bounded `try_send` — on a full queue
365    ///   the array is dropped and `DroppedQueueFull` is returned (C++ parity).
366    /// - `blocking_callbacks=1`: reliable `send().await` + awaits downstream
367    ///   processing completion (explicit opt-in, never drops).
368    pub async fn publish(&self, array: Arc<NDArray>) -> PublishOutcome {
369        self.publish_inner(array).await
370    }
371
372    /// Publish for the scatter reroute path. Mirrors C++ `NDPluginScatter`'s
373    /// `auxStatus` protocol: `doNDArrayCallbacks` writes the consumer's
374    /// `auxStatus` before EVERY call — `asynOverflow` for each node it means
375    /// to reroute past, `asynSuccess` for the last (NDPluginScatter.cpp:83-84)
376    /// — so a full-queue consumer that is not the last is skipped without
377    /// counting a dropped array. Scatter writes the same cell
378    /// `driverCallback` arms on its own refusals, which is why this is an
379    /// arm/disarm of the episode rather than a bypass flag: were they two
380    /// mechanisms, a scatter round following a natural overflow would silence
381    /// the last node too.
382    pub async fn publish_scatter(&self, array: Arc<NDArray>, is_last: bool) -> PublishOutcome {
383        if is_last {
384            self.overflow.disarm();
385        } else {
386            self.overflow.arm();
387        }
388        self.publish_inner(array).await
389    }
390
391    /// Shared publish body.
392    async fn publish_inner(&self, array: Arc<NDArray>) -> PublishOutcome {
393        if !self.enabled.load(Ordering::Acquire) {
394            return PublishOutcome::Disabled;
395        }
396
397        // C runs both gates before it even reads `blockingCallbacks`
398        // (NDPluginDriver.cpp:385-407 vs the branch at :419), so they apply to
399        // the inline and the queued mode alike.
400        match self.admission.classify(&array) {
401            Admission::Admit => {}
402            Admission::DropCompressed => {
403                self.dropped_arrays.fetch_add(1, Ordering::AcqRel);
404                self.admission.note_counted_drop();
405                return PublishOutcome::DroppedCompressed;
406            }
407            Admission::Throttled => return PublishOutcome::Throttled,
408        }
409
410        let blocking = self.blocking_mode.load(Ordering::Acquire);
411
412        if !blocking {
413            return try_send_arm(
414                &self.tx,
415                &self.queued_counter,
416                &self.dropped_arrays,
417                array,
418                &self.overflow,
419                &self.admission,
420            );
421        }
422
423        // Reliable blocking path: never drops, awaits completion. C clears
424        // `auxStatus` at :406 before branching on `blockingCallbacks` and the
425        // inline arm never touches the queue, so an episode ends here too.
426        self.overflow.disarm();
427        if let Some(ref c) = self.queued_counter {
428            c.increment();
429        }
430        let (done_tx, done_rx) = tokio::sync::oneshot::channel();
431        let msg = ArrayMessage {
432            array,
433            counter: self.queued_counter.clone(),
434            done_tx: Some(done_tx),
435        };
436        let tx = self.tx.read().clone();
437        if tx.send(msg).await.is_err() {
438            // Channel closed — counter was decremented by ArrayMessage::drop
439            return PublishOutcome::ChannelClosed;
440        }
441        let _ = done_rx.await;
442        PublishOutcome::Delivered
443    }
444
445    /// Whether this sender's plugin has callbacks enabled.
446    pub fn is_enabled(&self) -> bool {
447        self.enabled.load(Ordering::Acquire)
448    }
449
450    /// Whether this sender's plugin is in blocking mode.
451    pub fn is_blocking(&self) -> bool {
452        self.blocking_mode.load(Ordering::Acquire)
453    }
454
455    pub fn port_name(&self) -> &str {
456        &self.port_name
457    }
458
459    /// Set the queued-array counter for tracking in-flight arrays.
460    pub fn set_queued_counter(&mut self, counter: Arc<QueuedArrayCounter>) {
461        self.queued_counter = Some(counter);
462    }
463
464    /// Attach the downstream plugin's shared `DroppedArrays` counter so that
465    /// a full-queue drop on this sender is accounted to that plugin (C++ parity).
466    pub fn set_dropped_arrays_counter(&mut self, counter: Arc<AtomicI32>) {
467        self.dropped_arrays = counter;
468    }
469
470    /// The shared `DroppedArrays` counter for this sender's downstream queue.
471    pub fn dropped_arrays_counter(&self) -> &Arc<AtomicI32> {
472        &self.dropped_arrays
473    }
474
475    /// The receiving plugin's pre-queue gates, so the plugin runtime can keep
476    /// them current from its param loop.
477    pub fn admission(&self) -> &Arc<ArrayAdmission> {
478        &self.admission
479    }
480
481    /// Current capacity (free slots) of the downstream input queue.
482    pub fn capacity(&self) -> usize {
483        self.tx.read().capacity()
484    }
485
486    /// Maximum capacity of the downstream input queue.
487    pub fn max_capacity(&self) -> usize {
488        self.tx.read().max_capacity()
489    }
490
491    /// A non-owning handle for the one owner allowed to replace this queue.
492    ///
493    /// Weak on purpose. The data loop is that owner, and it also learns that
494    /// every upstream is gone by its receiver closing — which only happens
495    /// once the last `NDArraySender` drops. A strong handle held inside the
496    /// loop would keep a sender alive forever and the loop would never see
497    /// its own shutdown.
498    pub(crate) fn self_queue_handle(&self) -> SelfQueueHandle {
499        SelfQueueHandle {
500            tx: Arc::downgrade(&self.tx),
501            queued_counter: self.queued_counter.clone(),
502            dropped_arrays: self.dropped_arrays.clone(),
503            // NOT `self.overflow`: C reaches `driverCallback` with
504            // `pasynUserSelf` for a `ProcessPlugin` re-injection (:741) and
505            // with `pasynUserGenericPointer_` for an array-port callback
506            // (:539-541), so the two producers carry separate episodes.
507            overflow: OverflowEpisode::default(),
508            // The gates, unlike the episode, ARE shared: `lastProcessTime_`
509            // and `compressionAware_` are plugin members, so a re-injection
510            // competes for the same MinCallbackTime window a detector frame
511            // does.
512            admission: self.admission.clone(),
513        }
514    }
515
516    /// Set the enabled/blocking mode flags (used by plugin runtime wiring).
517    pub(crate) fn set_mode_flags(
518        &mut self,
519        enabled: Arc<AtomicBool>,
520        blocking_mode: Arc<AtomicBool>,
521    ) {
522        self.enabled = enabled;
523        self.blocking_mode = blocking_mode;
524    }
525}
526
527/// Receiver held by downstream plugin.
528pub struct NDArrayReceiver {
529    rx: tokio::sync::mpsc::Receiver<ArrayMessage>,
530    /// The same gate the producers consult, so the consumer end can publish
531    /// the parameters that feed it (MinCallbackTime) and observe the drops it
532    /// counts. One gate per plugin, shared by every producer — C keeps
533    /// `lastProcessTime_` and `compressionAware_` on the plugin instance and
534    /// every caller of `driverCallback` reads them under the plugin's lock.
535    admission: Arc<ArrayAdmission>,
536}
537
538impl NDArrayReceiver {
539    /// The admission gate this queue is fronted by.
540    pub fn admission(&self) -> &Arc<ArrayAdmission> {
541        &self.admission
542    }
543
544    /// Number of currently buffered (pending) messages in the input queue.
545    pub fn pending(&self) -> usize {
546        self.rx.len()
547    }
548
549    /// Maximum capacity of the input queue.
550    pub fn max_capacity(&self) -> usize {
551        self.rx.max_capacity()
552    }
553
554    /// Number of free slots in the input queue (`max_capacity - pending`).
555    pub fn capacity(&self) -> usize {
556        self.rx.capacity()
557    }
558
559    /// Blocking receive (for use in std::thread data processing loops).
560    pub fn blocking_recv(&mut self) -> Option<Arc<NDArray>> {
561        self.rx.blocking_recv().map(|msg| msg.array.clone())
562    }
563
564    /// Async receive.
565    pub async fn recv(&mut self) -> Option<Arc<NDArray>> {
566        self.rx.recv().await.map(|msg| msg.array.clone())
567    }
568
569    /// Receive the full ArrayMessage (crate-internal). The message's Drop
570    /// will signal completion when the caller is done with it.
571    pub(crate) async fn recv_msg(&mut self) -> Option<ArrayMessage> {
572        self.rx.recv().await
573    }
574
575    /// Take a buffered message without waiting. Used to drain a queue that has
576    /// just been replaced: the sender no longer points here, so `None` means
577    /// empty rather than "not yet".
578    pub(crate) fn try_recv_msg(&mut self) -> Option<ArrayMessage> {
579        self.rx.try_recv().ok()
580    }
581}
582
583/// Lets the plugin's data loop swap its own input queue for a deeper or
584/// shallower one without owning a sender.
585///
586/// This is C's `pToThreadMsgQ_`: a queue every producer reaches through the
587/// plugin rather than holding directly, so deleting it and re-creating it at
588/// the new depth on a QueueSize write (`NDPluginDriver.cpp:730-733` -> `:985`)
589/// moves every producer at once.
590pub(crate) struct SelfQueueHandle {
591    tx: std::sync::Weak<parking_lot::RwLock<tokio::sync::mpsc::Sender<ArrayMessage>>>,
592    queued_counter: Option<Arc<QueuedArrayCounter>>,
593    dropped_arrays: Arc<AtomicI32>,
594    /// This producer's own `pasynUserSelf->auxStatus`.
595    overflow: OverflowEpisode,
596    /// The same gates the array-port edge consults: C reaches `driverCallback`
597    /// for a `ProcessPlugin` re-injection too (`:741`), so the cached array is
598    /// re-classified rather than waved past.
599    admission: Arc<ArrayAdmission>,
600}
601
602impl SelfQueueHandle {
603    /// Point every live sender at a fresh queue of `capacity` and return its
604    /// receiver. `None` once every sender is gone — there is then no producer
605    /// left to redirect, and the caller is already shutting down.
606    ///
607    /// Publishes racing this land in one queue or the other and none is
608    /// refused; the caller owns draining whatever the old receiver still
609    /// holds. C instead switches its array interrupt off and waits for the old
610    /// queue to empty, losing whatever arrives in that window.
611    pub(crate) fn replace_queue(&self, capacity: usize) -> Option<NDArrayReceiver> {
612        let cell = self.tx.upgrade()?;
613        let (tx, rx) = tokio::sync::mpsc::channel(capacity.max(1));
614        *cell.write() = tx;
615        // The gate is a property of the plugin, not of the queue in front of
616        // it: a QueueSize write must not reset MinCallbackTime's clock.
617        Some(NDArrayReceiver {
618            rx,
619            admission: Arc::clone(&self.admission),
620        })
621    }
622
623    /// Put an array at the tail of the plugin's own input queue, dropping and
624    /// counting it against `DroppedArrays` when there is no room.
625    ///
626    /// This is how `ProcessPlugin` re-injects the cached input array: C hands
627    /// it to `driverCallback` (NDPluginDriver.cpp:741), the same entry point a
628    /// detector array arrives through, so it queues behind whatever is already
629    /// waiting, is refused when the queue is full, and is processed by a
630    /// callback thread rather than by the writer.
631    ///
632    /// Always the `trySend` arm, whatever `blockingCallbacks` says: the only
633    /// consumer of this queue is the caller, so awaiting delivery here would
634    /// be waiting on itself. C's blocking arm (`:419-422`) is not a queue
635    /// operation at all — it runs `processCallbacks` inline on the calling
636    /// thread — so the caller keeps its own inline path for that mode.
637    ///
638    /// `None` once every sender is gone, which is the caller shutting down.
639    pub(crate) fn try_enqueue(&self, array: Arc<NDArray>) -> Option<PublishOutcome> {
640        let cell = self.tx.upgrade()?;
641        match self.admission.classify(&array) {
642            Admission::Admit => {}
643            Admission::DropCompressed => {
644                self.dropped_arrays.fetch_add(1, Ordering::AcqRel);
645                self.admission.note_counted_drop();
646                return Some(PublishOutcome::DroppedCompressed);
647            }
648            Admission::Throttled => return Some(PublishOutcome::Throttled),
649        }
650        Some(try_send_arm(
651            &cell,
652            &self.queued_counter,
653            &self.dropped_arrays,
654            array,
655            &self.overflow,
656            &self.admission,
657        ))
658    }
659}
660
661/// Create a matched sender/receiver pair.
662pub fn ndarray_channel(port_name: &str, queue_size: usize) -> (NDArraySender, NDArrayReceiver) {
663    let (tx, rx) = tokio::sync::mpsc::channel(queue_size.max(1));
664    let admission = Arc::new(ArrayAdmission::default());
665    (
666        NDArraySender {
667            tx: Arc::new(parking_lot::RwLock::new(tx)),
668            port_name: port_name.to_string(),
669            enabled: Arc::new(AtomicBool::new(true)),
670            blocking_mode: Arc::new(AtomicBool::new(false)),
671            queued_counter: None,
672            dropped_arrays: Arc::new(AtomicI32::new(0)),
673            overflow: Arc::new(OverflowEpisode::default()),
674            admission: Arc::clone(&admission),
675        },
676        NDArrayReceiver { rx, admission },
677    )
678}
679
680/// Fan-out: publishes arrays to multiple downstream receivers.
681pub struct NDArrayOutput {
682    senders: Vec<NDArraySender>,
683}
684
685impl NDArrayOutput {
686    pub fn new() -> Self {
687        Self {
688            senders: Vec::new(),
689        }
690    }
691
692    pub fn add(&mut self, sender: NDArraySender) {
693        self.senders.push(sender);
694    }
695
696    pub fn remove(&mut self, port_name: &str) {
697        self.senders.retain(|s| s.port_name != port_name);
698    }
699
700    /// Remove a sender by port name and return it (if found).
701    pub fn take(&mut self, port_name: &str) -> Option<NDArraySender> {
702        let idx = self.senders.iter().position(|s| s.port_name == port_name)?;
703        Some(self.senders.swap_remove(idx))
704    }
705
706    /// Publish an array to all downstream receivers (async, concurrent).
707    ///
708    /// Each sender publishes independently. Returns the per-sender outcomes
709    /// so the caller can count `DroppedArrays` for any downstream whose queue
710    /// was full (C++ `driverCallback` semantics).
711    pub async fn publish(&self, array: Arc<NDArray>) -> Vec<PublishOutcome> {
712        let futs = self.senders.iter().map(|s| s.publish(array.clone()));
713        futures_util::future::join_all(futs).await
714    }
715
716    /// Publish an array to a single downstream receiver by index (for scatter/round-robin).
717    pub async fn publish_to(&self, index: usize, array: Arc<NDArray>) -> Option<PublishOutcome> {
718        if let Some(sender) = self.senders.get(index % self.senders.len().max(1)) {
719            Some(sender.publish(array).await)
720        } else {
721            None
722        }
723    }
724
725    pub fn num_senders(&self) -> usize {
726        self.senders.len()
727    }
728
729    /// Clone the senders list (for publishing outside a lock in async context).
730    pub(crate) fn senders_clone(&self) -> Vec<NDArraySender> {
731        self.senders.clone()
732    }
733}
734
735/// Cloneable async handle for publishing arrays to downstream plugins.
736///
737/// This is the public API for driver acquisition tasks.
738/// Internally it snapshots the sender list, releases the lock, then
739/// publishes to all senders concurrently.
740///
741/// # Example
742/// ```ignore
743/// if config.array_callbacks {
744///     publisher.publish(Arc::new(frame)).await;
745/// }
746/// ```
747#[derive(Clone)]
748pub struct ArrayPublisher {
749    output: Arc<parking_lot::Mutex<NDArrayOutput>>,
750}
751
752impl ArrayPublisher {
753    /// Create a publisher backed by the given output.
754    pub fn new(output: Arc<parking_lot::Mutex<NDArrayOutput>>) -> Self {
755        Self { output }
756    }
757
758    /// Publish an array to all downstream plugins (async, concurrent fan-out).
759    ///
760    /// Returns the per-downstream outcomes — a `DroppedQueueFull` entry means
761    /// that downstream plugin's input queue was full and the array was dropped
762    /// (C++ `driverCallback` `trySend`). The driver should count those as
763    /// `DroppedArrays`.
764    pub async fn publish(&self, array: Arc<NDArray>) -> Vec<PublishOutcome> {
765        let senders = self.output.lock().senders_clone();
766        let futs = senders.iter().map(|s| s.publish(array.clone()));
767        futures_util::future::join_all(futs).await
768    }
769}
770
771impl Default for NDArrayOutput {
772    fn default() -> Self {
773        Self::new()
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use crate::ndarray::{NDArray, NDDataType, NDDimension};
781
782    fn make_test_array(id: i32) -> Arc<NDArray> {
783        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
784        arr.unique_id = id;
785        Arc::new(arr)
786    }
787
788    #[tokio::test]
789    async fn test_publish_receive_basic() {
790        let (sender, mut receiver) = ndarray_channel("TEST", 10);
791        sender.publish(make_test_array(1)).await;
792        sender.publish(make_test_array(2)).await;
793
794        let a1 = receiver.recv().await.unwrap();
795        assert_eq!(a1.unique_id, 1);
796        let a2 = receiver.recv().await.unwrap();
797        assert_eq!(a2.unique_id, 2);
798    }
799
800    #[tokio::test]
801    async fn test_publish_blocking_no_drop() {
802        // In blocking_callbacks mode, reliable send().await is used: even a
803        // queue of 1 must not drop — the producer back-pressures instead.
804        let (sender, mut receiver) = ndarray_channel("TEST", 1);
805        sender.blocking_mode.store(true, Ordering::Release);
806
807        let s = sender.clone();
808        let pub_handle = tokio::spawn(async move {
809            s.publish(make_test_array(1)).await;
810            s.publish(make_test_array(2)).await;
811            s.publish(make_test_array(3)).await;
812        });
813
814        // Receive all 3 — no drops in blocking mode.
815        let a1 = receiver.recv().await.unwrap();
816        assert_eq!(a1.unique_id, 1);
817        let a2 = receiver.recv().await.unwrap();
818        assert_eq!(a2.unique_id, 2);
819        let a3 = receiver.recv().await.unwrap();
820        assert_eq!(a3.unique_id, 3);
821
822        pub_handle.await.unwrap();
823    }
824
825    #[tokio::test]
826    async fn test_publish_drops_on_full_queue() {
827        // B1: default (non-blocking) mode drops on a full queue and reports
828        // DroppedQueueFull, matching C++ trySend.
829        let (sender, _receiver) = ndarray_channel("TEST", 1);
830
831        // First publish fills the queue.
832        assert_eq!(
833            sender.publish(make_test_array(1)).await,
834            PublishOutcome::Delivered
835        );
836        // Second publish finds the queue full → dropped + counted.
837        assert_eq!(
838            sender.publish(make_test_array(2)).await,
839            PublishOutcome::DroppedQueueFull
840        );
841    }
842
843    #[tokio::test]
844    async fn test_drop_on_full_does_not_leak_counter() {
845        // A dropped array must not leave the queued-array counter incremented.
846        let counter = Arc::new(QueuedArrayCounter::new());
847        let (mut sender, _receiver) = ndarray_channel("TEST", 1);
848        sender.set_queued_counter(counter.clone());
849
850        sender.publish(make_test_array(1)).await; // delivered, counter=1
851        assert_eq!(counter.get(), 1);
852        let outcome = sender.publish(make_test_array(2)).await; // dropped
853        assert_eq!(outcome, PublishOutcome::DroppedQueueFull);
854        // Counter must still be 1 — the dropped message decremented on drop.
855        assert_eq!(counter.get(), 1);
856    }
857
858    #[tokio::test]
859    async fn test_blocking_callbacks_completion_wait() {
860        let (sender, mut receiver) = ndarray_channel("TEST", 10);
861        sender.blocking_mode.store(true, Ordering::Release);
862
863        let completed = Arc::new(AtomicBool::new(false));
864        let completed_clone = completed.clone();
865
866        // Spawn receiver that takes some time to process
867        let recv_handle = tokio::spawn(async move {
868            let msg = receiver.recv_msg().await.unwrap();
869            assert_eq!(msg.array.unique_id, 42);
870            // Simulate processing time
871            tokio::time::sleep(Duration::from_millis(50)).await;
872            completed_clone.store(true, Ordering::Release);
873            // msg dropped here → done_tx fires
874        });
875
876        // publish() should wait for completion
877        sender.publish(make_test_array(42)).await;
878
879        // By the time publish returns, downstream should have completed
880        assert!(completed.load(Ordering::Acquire));
881
882        recv_handle.await.unwrap();
883    }
884
885    #[tokio::test]
886    async fn test_fanout_three_receivers() {
887        let (s1, mut r1) = ndarray_channel("P1", 10);
888        let (s2, mut r2) = ndarray_channel("P2", 10);
889        let (s3, mut r3) = ndarray_channel("P3", 10);
890
891        let mut output = NDArrayOutput::new();
892        output.add(s1);
893        output.add(s2);
894        output.add(s3);
895
896        output.publish(make_test_array(42)).await;
897
898        assert_eq!(r1.recv().await.unwrap().unique_id, 42);
899        assert_eq!(r2.recv().await.unwrap().unique_id, 42);
900        assert_eq!(r3.recv().await.unwrap().unique_id, 42);
901    }
902
903    #[test]
904    fn test_blocking_recv() {
905        let rt = tokio::runtime::Builder::new_current_thread()
906            .enable_all()
907            .build()
908            .unwrap();
909        let (sender, mut receiver) = ndarray_channel("TEST", 10);
910
911        let handle = std::thread::spawn(move || {
912            let arr = receiver.blocking_recv().unwrap();
913            arr.unique_id
914        });
915
916        rt.block_on(sender.publish(make_test_array(99)));
917        let id = handle.join().unwrap();
918        assert_eq!(id, 99);
919    }
920
921    #[tokio::test]
922    async fn test_channel_closed_on_receiver_drop() {
923        let (sender, receiver) = ndarray_channel("TEST", 10);
924        drop(receiver);
925        // Sending to closed channel should not panic
926        sender.publish(make_test_array(1)).await;
927    }
928
929    #[test]
930    fn test_queued_counter_basic() {
931        let counter = QueuedArrayCounter::new();
932        assert_eq!(counter.get(), 0);
933        counter.increment();
934        assert_eq!(counter.get(), 1);
935        counter.increment();
936        assert_eq!(counter.get(), 2);
937        counter.decrement();
938        assert_eq!(counter.get(), 1);
939        counter.decrement();
940        assert_eq!(counter.get(), 0);
941    }
942
943    #[test]
944    fn test_queued_counter_wait_until_zero() {
945        let counter = Arc::new(QueuedArrayCounter::new());
946        counter.increment();
947        counter.increment();
948
949        let c = counter.clone();
950        let h = std::thread::spawn(move || {
951            std::thread::sleep(Duration::from_millis(10));
952            c.decrement();
953            std::thread::sleep(Duration::from_millis(10));
954            c.decrement();
955        });
956
957        assert!(counter.wait_until_zero(Duration::from_secs(5)));
958        h.join().unwrap();
959    }
960
961    #[test]
962    fn test_queued_counter_wait_timeout() {
963        let counter = Arc::new(QueuedArrayCounter::new());
964        counter.increment();
965        assert!(!counter.wait_until_zero(Duration::from_millis(10)));
966    }
967
968    #[tokio::test]
969    async fn test_publish_increments_counter() {
970        let counter = Arc::new(QueuedArrayCounter::new());
971        let (mut sender, mut _receiver) = ndarray_channel("TEST", 10);
972        sender.set_queued_counter(counter.clone());
973
974        sender.publish(make_test_array(1)).await;
975        assert_eq!(counter.get(), 1);
976        sender.publish(make_test_array(2)).await;
977        assert_eq!(counter.get(), 2);
978    }
979
980    #[tokio::test]
981    async fn test_message_drop_decrements() {
982        let counter = Arc::new(QueuedArrayCounter::new());
983        counter.increment();
984        let msg = ArrayMessage {
985            array: make_test_array(1),
986            counter: Some(counter.clone()),
987            done_tx: None,
988        };
989        assert_eq!(counter.get(), 1);
990        drop(msg);
991        assert_eq!(counter.get(), 0);
992    }
993
994    /// One drop per overflow EPISODE per producer, C's `ignoreQueueFull`
995    /// (NDPluginDriver.cpp:405, :433-441). Each case below is one boundary of
996    /// the episode's lifetime, not one scenario.
997    mod overflow_episode {
998        use super::*;
999
1000        fn dropped(sender: &NDArraySender) -> i32 {
1001            sender.dropped_arrays.load(Ordering::Acquire)
1002        }
1003
1004        #[tokio::test]
1005        async fn the_first_refusal_of_an_episode_counts() {
1006            let (sender, _receiver) = ndarray_channel("TEST", 1);
1007            sender.publish(make_test_array(1)).await; // fills the queue
1008            assert_eq!(
1009                sender.publish(make_test_array(2)).await,
1010                PublishOutcome::DroppedQueueFull
1011            );
1012            assert_eq!(dropped(&sender), 1);
1013        }
1014
1015        #[tokio::test]
1016        async fn consecutive_refusals_do_not_count_again() {
1017            // C: the refusal at :433 leaves `auxStatus = asynOverflow`, so the
1018            // next call reads `ignoreQueueFull` and skips `droppedArrays++`.
1019            // Without this a detector pushing into a stalled plugin inflates
1020            // the counter by one per FRAME instead of one per stall.
1021            let (sender, _receiver) = ndarray_channel("TEST", 1);
1022            sender.publish(make_test_array(1)).await;
1023            for id in 2..=20 {
1024                assert_eq!(
1025                    sender.publish(make_test_array(id)).await,
1026                    PublishOutcome::DroppedQueueFull
1027                );
1028            }
1029            assert_eq!(dropped(&sender), 1, "19 dropped arrays, one episode");
1030        }
1031
1032        #[tokio::test]
1033        async fn a_successful_enqueue_ends_the_episode() {
1034            // The other edge of the same cell: C never re-arms `auxStatus` on
1035            // the success path, and the unconditional `= asynSuccess` at :406
1036            // has already cleared it, so the next stall is a new episode.
1037            let (sender, mut receiver) = ndarray_channel("TEST", 1);
1038            sender.publish(make_test_array(1)).await;
1039            sender.publish(make_test_array(2)).await; // refused, counts
1040            sender.publish(make_test_array(3)).await; // refused, silent
1041            assert_eq!(dropped(&sender), 1);
1042
1043            receiver.recv().await.unwrap(); // drain: room again
1044            sender.publish(make_test_array(4)).await; // accepted → episode over
1045            sender.publish(make_test_array(5)).await; // refused: new episode
1046            assert_eq!(dropped(&sender), 2);
1047        }
1048
1049        #[tokio::test]
1050        async fn every_clone_of_a_sender_shares_one_episode() {
1051            // The cell is the receiving plugin's single registered
1052            // `pasynUserGenericPointer_` (:539-541), not per upstream: two
1053            // producers stalling on the same queue are one episode in C.
1054            let (sender, _receiver) = ndarray_channel("TEST", 1);
1055            let other = sender.clone();
1056            sender.publish(make_test_array(1)).await;
1057            sender.publish(make_test_array(2)).await;
1058            other.publish(make_test_array(3)).await;
1059            assert_eq!(dropped(&sender), 1);
1060        }
1061
1062        #[tokio::test]
1063        async fn the_reinjection_producer_carries_its_own_episode() {
1064            // C reaches `driverCallback` with `pasynUserSelf` for a
1065            // ProcessPlugin re-injection (:741) and with
1066            // `pasynUserGenericPointer_` for an array-port callback, so an
1067            // array-port overflow must not silence the re-injection's first
1068            // drop.
1069            let (sender, _receiver) = ndarray_channel("TEST", 1);
1070            let handle = sender.self_queue_handle();
1071            sender.publish(make_test_array(1)).await; // fills
1072            sender.publish(make_test_array(2)).await; // array-port episode opens
1073            assert_eq!(dropped(&sender), 1);
1074
1075            assert_eq!(
1076                handle.try_enqueue(make_test_array(3)),
1077                Some(PublishOutcome::DroppedQueueFull)
1078            );
1079            assert_eq!(
1080                dropped(&sender),
1081                2,
1082                "a separate producer, a separate episode"
1083            );
1084            handle.try_enqueue(make_test_array(4));
1085            assert_eq!(dropped(&sender), 2, "…which then runs its own episode");
1086        }
1087
1088        #[tokio::test]
1089        async fn a_scatter_reroute_arms_the_episode_and_the_last_node_still_counts() {
1090            // C `NDPluginScatter.cpp:83-84` writes the SAME cell: overflow for
1091            // a node it means to reroute past, success for the last. Because
1092            // it is one cell and not a second mechanism, the last node counts
1093            // its drop even though the round before it armed the flag.
1094            let (sender, _receiver) = ndarray_channel("TEST", 1);
1095            sender.publish(make_test_array(1)).await; // fills
1096            assert_eq!(
1097                sender.publish_scatter(make_test_array(2), false).await,
1098                PublishOutcome::DroppedQueueFull
1099            );
1100            assert_eq!(dropped(&sender), 0, "rerouted past, not dropped");
1101            assert_eq!(
1102                sender.publish_scatter(make_test_array(3), true).await,
1103                PublishOutcome::DroppedQueueFull
1104            );
1105            assert_eq!(dropped(&sender), 1, "the last node owns the drop");
1106        }
1107
1108        #[tokio::test]
1109        async fn the_blocking_arm_ends_an_open_episode() {
1110            // C clears `auxStatus` at :406, before it branches on
1111            // `blockingCallbacks`, and the blocking arm never touches the
1112            // queue — so switching to blocking mode and back leaves no stale
1113            // episode to swallow the next real drop.
1114            let (sender, mut receiver) = ndarray_channel("TEST", 1);
1115            sender.publish(make_test_array(1)).await;
1116            sender.publish(make_test_array(2)).await; // episode opens
1117            assert_eq!(dropped(&sender), 1);
1118
1119            receiver.recv().await.unwrap();
1120            sender.blocking_mode.store(true, Ordering::Release);
1121            let s = sender.clone();
1122            let pending = tokio::spawn(async move { s.publish(make_test_array(3)).await });
1123            let msg = receiver.recv_msg().await.unwrap();
1124            drop(msg);
1125            pending.await.unwrap();
1126
1127            sender.blocking_mode.store(false, Ordering::Release);
1128            sender.publish(make_test_array(4)).await; // fills
1129            sender.publish(make_test_array(5)).await; // refused: counts
1130            assert_eq!(dropped(&sender), 2);
1131        }
1132    }
1133}