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}