Skip to main content

edgefirst_image/gl/
threaded.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use edgefirst_tensor::{DetectBox, ProtoData, Segmentation};
5use std::panic::AssertUnwindSafe;
6use std::ptr::NonNull;
7use std::thread::JoinHandle;
8use tokio::sync::mpsc::{Sender, WeakSender};
9
10use super::processor::GLProcessorST;
11use super::shaders::check_gl_error;
12use super::{EglDisplayKind, Int8InterpolationMode, TransferBackend};
13use crate::{Crop, Error, Flip, ImageProcessorTrait, Rotation};
14use edgefirst_tensor::TensorDyn;
15
16#[allow(clippy::type_complexity)]
17enum GLProcessorMessage {
18    ImageConvert(
19        SendablePtr<TensorDyn>,
20        SendablePtr<TensorDyn>,
21        Rotation,
22        Flip,
23        Crop,
24        bool, // defer: skip the per-tile glFinish (batch); flush syncs once
25        tokio::sync::oneshot::Sender<Result<(), Error>>,
26    ),
27    /// Convert without the terminal CPU sync, replying with a native
28    /// fence fd guarding the GPU work (the GL→NPU handoff). `Ok(None)` =
29    /// converted with blocking semantics (no fence on this platform).
30    ImageConvertFenced(
31        SendablePtr<TensorDyn>,
32        SendablePtr<TensorDyn>,
33        Rotation,
34        Flip,
35        Crop,
36        tokio::sync::oneshot::Sender<Result<Option<super::CompletionFence>, Error>>,
37    ),
38    /// Complete any deferred batch render with a single GPU sync.
39    Flush(tokio::sync::oneshot::Sender<Result<(), Error>>),
40    SetColors(
41        Vec<[u8; 4]>,
42        tokio::sync::oneshot::Sender<Result<(), Error>>,
43    ),
44    DrawDecodedMasks(
45        SendablePtr<TensorDyn>,
46        SendablePtr<DetectBox>,
47        SendablePtr<Segmentation>,
48        f32,                            // opacity
49        Option<SendablePtr<TensorDyn>>, // background
50        Option<[f32; 4]>,               // letterbox
51        crate::ColorMode,
52        tokio::sync::oneshot::Sender<Result<(), Error>>,
53    ),
54    DrawProtoMasks(
55        SendablePtr<TensorDyn>,
56        SendablePtr<DetectBox>,
57        SendablePtr<ProtoData>,
58        f32,                            // opacity
59        Option<SendablePtr<TensorDyn>>, // background
60        Option<[f32; 4]>,               // letterbox
61        crate::ColorMode,
62        tokio::sync::oneshot::Sender<Result<(), Error>>,
63    ),
64    SetInt8Interpolation(
65        Int8InterpolationMode,
66        tokio::sync::oneshot::Sender<Result<(), Error>>,
67    ),
68    /// Set the colorimetry/performance trade-off (`ColorimetryMode`).
69    SetColorimetryMode(
70        crate::ColorimetryMode,
71        tokio::sync::oneshot::Sender<Result<(), Error>>,
72    ),
73    /// Test hook (issue #175): make every zero-copy destination import fail.
74    #[cfg(test)]
75    SetFailDstImport(bool, tokio::sync::oneshot::Sender<Result<(), Error>>),
76    /// Snapshot the EGLImage cache counters (steady-state import assertions).
77    EglCacheStats(tokio::sync::oneshot::Sender<Result<super::cache::GlCacheStats, Error>>),
78    /// Snapshot the per-convert source-feed counters (zero-copy observability).
79    ConvertStats(tokio::sync::oneshot::Sender<Result<super::cache::ConvertStats, Error>>),
80    PboCreate(
81        usize, // buffer size in bytes
82        tokio::sync::oneshot::Sender<Result<u32, Error>>,
83    ),
84    PboMap(
85        u32,   // buffer_id
86        usize, // size
87        tokio::sync::oneshot::Sender<Result<edgefirst_tensor::PboMapping, Error>>,
88    ),
89    PboUnmap(
90        u32, // buffer_id
91        tokio::sync::oneshot::Sender<Result<(), Error>>,
92    ),
93    PboDelete(u32), // fire-and-forget, no reply
94    CudaRegisterBuffer(u32, tokio::sync::oneshot::Sender<Option<usize>>),
95    CudaMap(usize, tokio::sync::oneshot::Sender<Option<(usize, usize)>>),
96    CudaUnmap(usize),      // fire-and-forget
97    CudaUnregister(usize), // fire-and-forget
98}
99
100/// Compute the flat element count for a PBO image buffer of the given format.
101///
102/// NV12 and NV16 are semiplanar with non-trivial element counts; all other
103/// formats use `width * height * channels`.
104///
105/// Returns `None` on `usize` overflow. A wrapped element count would size
106/// the PBO too small and corrupt memory on readback, so callers must treat
107/// `None` as an invalid-dimensions error (the same way they already reject
108/// a zero count).
109fn pbo_elem_count(
110    width: usize,
111    height: usize,
112    format: edgefirst_tensor::PixelFormat,
113) -> Option<usize> {
114    // Element count == product of the canonical image shape (the PBO holds u8,
115    // one byte per element). Delegating to `allocation_shape` keeps the combined
116    // semi-planar height in lockstep with every other consumer (and is exact
117    // for odd heights — `height*3/2` truncation under-sized odd-H NV12 by a
118    // row, and the old NV24 arm fell through to `channels` = far too small).
119    // `checked_mul` so a wrapped count can't silently undersize the PBO.
120    format
121        .allocation_shape(width, height)?
122        .iter()
123        .try_fold(1usize, |acc, &d| acc.checked_mul(d))
124}
125
126/// Compute the tensor shape for a PBO image of the given format — the canonical
127/// [`PixelFormat::allocation_shape`] (Planar `[C,H,W]`, SemiPlanar `[total_h, W]`
128/// with the odd-height-exact combined-plane height, Packed `[H,W,C]`).
129fn pbo_shape(width: usize, height: usize, format: edgefirst_tensor::PixelFormat) -> Vec<usize> {
130    format
131        .allocation_shape(width, height)
132        .unwrap_or_else(|| vec![height, width, format.channels()])
133}
134
135/// Best-effort registration of a freshly-created PBO with CUDA GL interop.
136///
137/// When libcudart is present, asks the GL worker (where the GL context is
138/// current) to call `cudaGraphicsGLRegisterBuffer` for `buffer_id`, then
139/// attaches a [`CudaHandle`] so `cuda_map()` can later yield a linear,
140/// 256-byte-aligned device pointer for TensorRT. On any failure (no CUDA,
141/// channel closed, registration rejected) the handle is left unset and the
142/// PBO is still usable as a normal CPU/GL buffer.
143fn register_pbo_cuda<T>(
144    tensor: &mut edgefirst_tensor::Tensor<T>,
145    buffer_id: u32,
146    size: usize,
147    sender: &Sender<GLProcessorMessage>,
148) where
149    T: num_traits::Num + Clone + std::fmt::Debug + Send + Sync + edgefirst_tensor::Element,
150{
151    if !edgefirst_tensor::is_cuda_available() {
152        return;
153    }
154    let (tx, rx) = tokio::sync::oneshot::channel();
155    if sender
156        .blocking_send(GLProcessorMessage::CudaRegisterBuffer(buffer_id, tx))
157        .is_ok()
158    {
159        if let Ok(Some(resource)) = rx.blocking_recv() {
160            let ops = std::sync::Arc::new(GlCudaOps {
161                sender: sender.downgrade(),
162            });
163            tensor.set_cuda_handle(edgefirst_tensor::CudaHandle::new_gl(
164                resource as *mut std::ffi::c_void,
165                size,
166                ops,
167            ));
168        }
169    }
170}
171
172/// Implements PboOps by sending commands to the GL thread.
173///
174/// Uses a `WeakSender` so that PBO images don't keep the GL thread's channel
175/// alive. When the `GLProcessorThreaded` is dropped, its `Sender` is the last
176/// strong reference — dropping it closes the channel and lets the GL thread
177/// exit. PBO operations after that return `PboDisconnected`.
178struct GlPboOps {
179    sender: WeakSender<GLProcessorMessage>,
180    /// Which GL worker owns the context these buffers live in. Compared
181    /// against [`GL_WORKER`] so a re-entrant map is serviced inline ONLY by
182    /// the worker whose context actually holds the buffer — a different
183    /// processor's worker must still go through the channel.
184    worker_id: u64,
185}
186
187/// Next id handed to a GL worker. Ids are per-process and never reused.
188static NEXT_GL_WORKER_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
189
190/// Identity and PBO bookkeeping of the GL worker running on this thread.
191struct GlWorkerState {
192    id: u64,
193    /// Full per-message serialization: this thread holds the process-wide GL
194    /// lock for the whole message, so no other worker can run until it returns.
195    serializes: bool,
196    /// Buffers currently `glMapBufferRange`-ed against this context.
197    mapped: std::collections::HashSet<u32>,
198}
199
200thread_local! {
201    /// `Some(state)` on a GL worker thread, `None` everywhere else.
202    ///
203    /// This exists because a GL message handler can itself need a PBO map:
204    /// `handle_image_convert` -> `convert` -> `draw_src_texture` ->
205    /// `TensorTrait::map_read` on a PBO-backed source -> `PboHandle::acquire_map`
206    /// -> `GlPboOps::map_buffer`. Routing that through the channel would have the
207    /// worker `blocking_send` to the queue it is itself the only consumer of —
208    /// a self-deadlock (observed: a 1-in-5 hang on the desktop NVIDIA PBO path).
209    /// The worker already holds the context current on this thread, so it
210    /// performs the map directly instead.
211    ///
212    /// The mapped-buffer set lives here rather than on the worker's stack so the
213    /// inline path and the message path share one view of which buffers are
214    /// mapped — `glMapBufferRange` on an already-mapped buffer is undefined
215    /// behaviour, and the two paths must not be able to double-map.
216    static GL_WORKER: std::cell::RefCell<Option<GlWorkerState>> =
217        const { std::cell::RefCell::new(None) };
218}
219
220/// The mapped-PBO set for the current GL worker thread, or `None` off-worker.
221/// The closure must not re-enter `GL_WORKER` (keep it to set bookkeeping — no
222/// GL calls inside).
223fn with_worker_mapped<R>(f: impl FnOnce(&mut std::collections::HashSet<u32>) -> R) -> Option<R> {
224    GL_WORKER.with(|c| c.borrow_mut().as_mut().map(|s| f(&mut s.mapped)))
225}
226
227/// True when the calling thread is the GL worker that owns `worker_id`.
228fn on_owning_gl_worker(worker_id: u64) -> bool {
229    GL_WORKER.with(|c| matches!(*c.borrow(), Some(ref s) if s.id == worker_id))
230}
231
232/// True when this thread is a GL worker holding the process-wide per-message
233/// lock, so no other worker can make progress until this message returns.
234fn on_serializing_gl_worker() -> bool {
235    GL_WORKER.with(|c| matches!(*c.borrow(), Some(ref s) if s.serializes))
236}
237
238/// A PBO owned by another worker's context, reached from a worker holding the
239/// process-wide per-message lock. The owning worker cannot take that lock until
240/// this message returns, so sending to it would never be answered.
241fn cross_context_pbo_error(op: &str, buffer_id: u32) -> edgefirst_tensor::Error {
242    edgefirst_tensor::Error::InvalidOperation(format!(
243        "PBO {buffer_id}: cannot {op} a buffer owned by another GL context while \
244         this one holds the serialization lock; convert it through the processor \
245         that created it"
246    ))
247}
248
249thread_local! {
250    /// Set when a re-entrant inline PBO op panicked; the worker loop folds it
251    /// into `poisoned` after the message that raised it.
252    static GL_WORKER_POISON: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
253}
254
255/// Run a re-entrant PBO op inline on the owning worker, turning a panic into
256/// an error exactly as the channel path's `reply_caught` does.
257///
258/// The unwind MUST stop here. `PboHandle::acquire_map` publishes
259/// `MapState::Mapping` before calling `map_buffer` and clears it only on
260/// return, so a panic crossing it strands that handle in `Mapping` and every
261/// later map on it waits on the condvar forever.
262fn inline_gl_op<T>(what: &str, f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
263    match std::panic::catch_unwind(AssertUnwindSafe(f)) {
264        Ok(result) => result,
265        Err(e) => {
266            GL_WORKER_POISON.with(|p| p.set(true));
267            Err(Error::Internal(format!(
268                "GL thread panicked during {what}: {}",
269                panic_message(e.as_ref()),
270            )))
271        }
272    }
273}
274
275// SAFETY: GlPboOps runs every GL operation on the thread owning the context --
276// via the channel, or inline when that thread is already this one (see
277// `GL_WORKER`). `map_buffer` returns a CPU-visible pointer from `glMapBufferRange`
278// that remains valid until `unmap_buffer` calls `glUnmapBuffer` on the GL thread.
279// `delete_buffer` sends a fire-and-forget deletion command to the GL thread.
280unsafe impl edgefirst_tensor::PboOps for GlPboOps {
281    fn map_buffer(
282        &self,
283        buffer_id: u32,
284        size: usize,
285    ) -> edgefirst_tensor::Result<edgefirst_tensor::PboMapping> {
286        // Re-entrant call from inside a GL message handler on the worker that
287        // owns this context (e.g. `draw_src_texture` mapping a PBO-backed
288        // source mid-convert). The context is already current on this thread,
289        // and sending would block on the queue this thread alone drains.
290        if on_owning_gl_worker(self.worker_id) {
291            return inline_gl_op("PboMap", || pbo_map_on_gl_thread(buffer_id, size)).map_err(|e| {
292                edgefirst_tensor::Error::NotImplemented(format!("GL PBO map failed: {e:?}"))
293            });
294        }
295        // Foreign context, and this thread holds the lock the owner needs.
296        if on_serializing_gl_worker() {
297            return Err(cross_context_pbo_error("map", buffer_id));
298        }
299        let sender = self
300            .sender
301            .upgrade()
302            .ok_or(edgefirst_tensor::Error::PboDisconnected)?;
303        let (tx, rx) = tokio::sync::oneshot::channel();
304        sender
305            .blocking_send(GLProcessorMessage::PboMap(buffer_id, size, tx))
306            .map_err(|_| edgefirst_tensor::Error::PboDisconnected)?;
307        rx.blocking_recv()
308            .map_err(|_| edgefirst_tensor::Error::PboDisconnected)?
309            .map_err(|e| {
310                edgefirst_tensor::Error::NotImplemented(format!("GL PBO map failed: {e:?}"))
311            })
312    }
313
314    fn unmap_buffer(&self, buffer_id: u32) -> edgefirst_tensor::Result<()> {
315        // See `map_buffer` — same re-entrancy, same reasoning.
316        if on_owning_gl_worker(self.worker_id) {
317            return inline_gl_op("PboUnmap", || pbo_unmap_on_gl_thread(buffer_id)).map_err(|e| {
318                edgefirst_tensor::Error::NotImplemented(format!("GL PBO unmap failed: {e:?}"))
319            });
320        }
321        // See `map_buffer`.
322        if on_serializing_gl_worker() {
323            return Err(cross_context_pbo_error("unmap", buffer_id));
324        }
325        let sender = self
326            .sender
327            .upgrade()
328            .ok_or(edgefirst_tensor::Error::PboDisconnected)?;
329        let (tx, rx) = tokio::sync::oneshot::channel();
330        sender
331            .blocking_send(GLProcessorMessage::PboUnmap(buffer_id, tx))
332            .map_err(|_| edgefirst_tensor::Error::PboDisconnected)?;
333        rx.blocking_recv()
334            .map_err(|_| edgefirst_tensor::Error::PboDisconnected)?
335            .map_err(|e| {
336                edgefirst_tensor::Error::NotImplemented(format!("GL PBO unmap failed: {e:?}"))
337            })
338    }
339
340    fn delete_buffer(&self, buffer_id: u32) {
341        // See `map_buffer` — a drop mid-convert must not queue to itself.
342        if on_owning_gl_worker(self.worker_id) {
343            with_worker_mapped(|set| set.remove(&buffer_id));
344            // Reached from `PboHandle::Drop`; an unwind out of a drop that is
345            // itself unwinding aborts the process.
346            let _ = inline_gl_op("PboDelete", || {
347                unsafe { edgefirst_gl::gl::DeleteBuffers(1, &buffer_id) };
348                Ok(())
349            });
350            return;
351        }
352        if let Some(sender) = self.sender.upgrade() {
353            // See `map_buffer`: blocking here would deadlock the same way.
354            if on_serializing_gl_worker() {
355                if sender
356                    .try_send(GLProcessorMessage::PboDelete(buffer_id))
357                    .is_err()
358                {
359                    log::warn!(
360                        "PBO {buffer_id}: cross-context delete skipped to avoid a deadlock; \
361                         the buffer is freed when its context is destroyed"
362                    );
363                }
364                return;
365            }
366            let _ = sender.blocking_send(GLProcessorMessage::PboDelete(buffer_id));
367        }
368    }
369}
370
371/// Routes CUDA GL-interop map/unmap/unregister to the GL thread.
372///
373/// Mirrors [`GlPboOps`]: holds a [`WeakSender`] so a registered PBO doesn't
374/// keep the GL thread's channel alive. The CUDA primitives
375/// (`cudaGraphicsMapResources` etc.) require the GL context to be current,
376/// so they must execute on the dedicated GL worker thread.
377struct GlCudaOps {
378    sender: WeakSender<GLProcessorMessage>,
379}
380
381impl edgefirst_tensor::CudaGlOps for GlCudaOps {
382    fn map(&self, resource: *mut std::ffi::c_void) -> Option<(*mut std::ffi::c_void, usize)> {
383        let sender = self.sender.upgrade()?;
384        let (tx, rx) = tokio::sync::oneshot::channel();
385        sender
386            .blocking_send(GLProcessorMessage::CudaMap(resource as usize, tx))
387            .ok()?;
388        rx.blocking_recv()
389            .ok()?
390            .map(|(p, l)| (p as *mut std::ffi::c_void, l))
391    }
392
393    fn unmap(&self, resource: *mut std::ffi::c_void) {
394        if let Some(sender) = self.sender.upgrade() {
395            let _ = sender.blocking_send(GLProcessorMessage::CudaUnmap(resource as usize));
396        }
397    }
398
399    fn unregister(&self, resource: *mut std::ffi::c_void) {
400        if let Some(sender) = self.sender.upgrade() {
401            let _ = sender.blocking_send(GLProcessorMessage::CudaUnregister(resource as usize));
402        }
403    }
404}
405
406/// OpenGL multi-threaded image converter. The actual conversion is done in a
407/// separate rendering thread, as OpenGL contexts are not thread-safe. This can
408/// be safely sent between threads. The `convert()` call sends the conversion
409/// request to the rendering thread and waits for the result.
410#[derive(Debug)]
411pub struct GLProcessorThreaded {
412    // This is only None when the converter is being dropped.
413    handle: Option<JoinHandle<()>>,
414
415    // This is only None when the converter is being dropped.
416    sender: Option<Sender<GLProcessorMessage>>,
417    /// Identity of this processor's worker thread, handed to every `GlPboOps`
418    /// it creates so a re-entrant map can be matched to the owning context.
419    worker_id: u64,
420    /// Immutable capability surface (transfer backend, float render
421    /// support, serialization policy), captured once from the worker at
422    /// construction. See `PlatformCaps` in `platform/mod.rs`.
423    caps: super::platform::PlatformCaps,
424}
425
426unsafe impl Send for GLProcessorThreaded {}
427unsafe impl Sync for GLProcessorThreaded {}
428
429struct SendablePtr<T: Send> {
430    ptr: NonNull<T>,
431    len: usize,
432}
433
434unsafe impl<T> Send for SendablePtr<T> where T: Send {}
435
436/// Extract a human-readable message from a `catch_unwind` panic payload.
437fn panic_message(info: &(dyn std::any::Any + Send)) -> String {
438    if let Some(s) = info.downcast_ref::<&str>() {
439        s.to_string()
440    } else if let Some(s) = info.downcast_ref::<String>() {
441        s.clone()
442    } else {
443        "unknown panic".to_string()
444    }
445}
446
447fn reply_caught<T>(
448    result: std::thread::Result<crate::Result<T>>,
449    resp: tokio::sync::oneshot::Sender<crate::Result<T>>,
450    poisoned: &mut bool,
451    what: &str,
452) {
453    let _ = resp.send(match result {
454        Ok(res) => res,
455        Err(e) => {
456            *poisoned = true;
457            Err(crate::Error::Internal(format!(
458                "GL thread panicked during {what}: {}",
459                panic_message(e.as_ref()),
460            )))
461        }
462    });
463}
464
465fn reject_poisoned_message(msg: GLProcessorMessage) {
466    let poison_err =
467        crate::Error::Internal("GL context is poisoned after a prior panic".to_string());
468    match msg {
469        GLProcessorMessage::ImageConvertFenced(.., resp) => {
470            let _ = resp.send(Err(poison_err));
471        }
472        GLProcessorMessage::ImageConvert(.., resp) => {
473            let _ = resp.send(Err(poison_err));
474        }
475        GLProcessorMessage::Flush(resp) => {
476            let _ = resp.send(Err(poison_err));
477        }
478        GLProcessorMessage::DrawDecodedMasks(.., resp) => {
479            let _ = resp.send(Err(poison_err));
480        }
481        GLProcessorMessage::DrawProtoMasks(.., resp) => {
482            let _ = resp.send(Err(poison_err));
483        }
484        GLProcessorMessage::SetColors(_, resp) => {
485            let _ = resp.send(Err(poison_err));
486        }
487        GLProcessorMessage::SetInt8Interpolation(_, resp) => {
488            let _ = resp.send(Err(poison_err));
489        }
490        GLProcessorMessage::SetColorimetryMode(_, resp) => {
491            let _ = resp.send(Err(poison_err));
492        }
493        #[cfg(test)]
494        GLProcessorMessage::SetFailDstImport(_, resp) => {
495            let _ = resp.send(Err(poison_err));
496        }
497        GLProcessorMessage::EglCacheStats(resp) => {
498            let _ = resp.send(Err(poison_err));
499        }
500        GLProcessorMessage::ConvertStats(resp) => {
501            let _ = resp.send(Err(poison_err));
502        }
503        GLProcessorMessage::PboCreate(_, resp) => {
504            let _ = resp.send(Err(poison_err));
505        }
506        GLProcessorMessage::PboMap(_, _, resp) => {
507            let _ = resp.send(Err(poison_err));
508        }
509        GLProcessorMessage::PboUnmap(_, resp) => {
510            let _ = resp.send(Err(poison_err));
511        }
512        GLProcessorMessage::PboDelete(_) => {}
513        GLProcessorMessage::CudaRegisterBuffer(_, resp) => {
514            let _ = resp.send(None);
515        }
516        GLProcessorMessage::CudaMap(_, resp) => {
517            let _ = resp.send(None);
518        }
519        // GL context is poisoned/dead — the GL buffer is unusable, so unmapping/
520        // unregistering the CUDA interop is moot (the resource is reclaimed at
521        // process exit). Same accepted "GL is gone" no-op as PboDelete above.
522        GLProcessorMessage::CudaUnmap(_) => {}
523        GLProcessorMessage::CudaUnregister(_) => {}
524    }
525}
526
527fn run_gl_worker(
528    worker_id: u64,
529    kind: Option<EglDisplayKind>,
530    capacity: Option<usize>,
531    mut recv: tokio::sync::mpsc::Receiver<GLProcessorMessage>,
532    create_ctx_send: tokio::sync::oneshot::Sender<Result<super::platform::PlatformCaps, Error>>,
533) {
534    // Creation runs under the global lifecycle lock on Linux
535    // (bring-up races on some embedded drivers); ANGLE serializes
536    // display-level entry points internally, so macOS needs none
537    // (validated by the A0 lifecycle-churn spike leg).
538    #[cfg(target_os = "linux")]
539    let init_result = {
540        let _guard = super::context::GL_MUTEX
541            .lock()
542            .unwrap_or_else(|e| e.into_inner());
543        GLProcessorST::new(kind, capacity)
544    };
545    // macOS: ANGLE/Metal serializes display-level entry points
546    // internally. Android: the system EGL is specification-conformant
547    // thread-safe. Neither needs the Linux lifecycle lock.
548    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
549    let init_result = GLProcessorST::new(kind, capacity);
550    // Windows: ANGLE's D3D11 backend drives one immediate device context
551    // (and one state manager) per display from every GL context and is not
552    // safe to use from two threads at once: a processor bringing up its
553    // context (shader compiles, texture allocation) while another converted
554    // crashed with an access violation. Lifecycle and messages therefore
555    // share NON_LINUX_GL_MUTEX there; per-message locking is the ANGLE Full
556    // policy anyway.
557    #[cfg(target_os = "windows")]
558    let init_result = {
559        let _guard = NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
560        GLProcessorST::new(kind, capacity)
561    };
562    let mut gl_converter = match init_result {
563        Ok(gl) => gl,
564        Err(e) => {
565            let _ = create_ctx_send.send(Err(e));
566            return;
567        }
568    };
569    // Capability surface captured ONCE before the message loop —
570    // immutable for the worker's life, never re-probed per message.
571    let caps = gl_converter.platform_caps();
572    let _ = create_ctx_send.send(Ok(caps));
573    let mut poisoned = false;
574    // Tracks which PBO buffer_ids currently have an outstanding GL
575    // mapping, scoped to this one GL context/thread -- the ONE
576    // place every PboOps call against this context's buffers
577    // actually serializes, regardless of which `PboHandle`
578    // (edgefirst-tensor) issued the `PboMap`/`PboUnmap` message.
579    // `PboHandle::acquire_map`'s own per-handle `Mutex<MapState>`
580    // enforces "at most one mapping" WITHIN one handle, including
581    // read-sharing (several readers reuse ONE mapping without ever
582    // re-sending `PboMap` here) -- but it has no visibility into a
583    // SEPARATE `PboHandle` for the same real `buffer_id`, which
584    // task 11's cross-cdylib PBO import made newly reachable: a
585    // producer's own tensor and a reconstructed cross-package
586    // consumer tensor for the same `buffer_id` are two independent
587    // `PboHandle`s, each believing it is the only mapper.
588    // `glMapBufferRange` on an already-mapped buffer is undefined
589    // behaviour per the GL spec, so THIS set -- not the per-handle
590    // mutex, which never sees another handle's calls -- is what
591    // actually prevents that.
592    // Install this thread's GL-worker identity and its mapped-PBO set. Both
593    // the message path and the re-entrant inline path read them from here —
594    // see [`GL_WORKER`] for why the inline path has to exist at all.
595    GL_WORKER.with(|c| {
596        *c.borrow_mut() = Some(GlWorkerState {
597            id: worker_id,
598            // Set once the policy below is resolved.
599            serializes: false,
600            mapped: std::collections::HashSet::new(),
601        });
602    });
603    // Clear on every exit path, including unwind, so a recycled thread id can
604    // never inherit a dead worker's identity.
605    struct GlWorkerSlot;
606    impl Drop for GlWorkerSlot {
607        fn drop(&mut self) {
608            GL_WORKER.with(|c| *c.borrow_mut() = None);
609            GL_WORKER_POISON.with(|p| p.set(false));
610        }
611    }
612    let _gl_worker_slot = GlWorkerSlot;
613    // Per-message serialization policy: Full where the platform
614    // demands it (`caps.serialize_gl` — Vivante/galcore is not
615    // thread-safe for concurrent GL across contexts), LifecycleOnly
616    // everywhere else — multiple processors then run GL work in
617    // parallel, each on its own thread + context. See the GL_MUTEX
618    // doc comment in context.rs. EDGEFIRST_GL_SERIALIZE overrides
619    // per-driver defaults (full = escape hatch for unknown-bad
620    // drivers; lifecycle = force-parallel).
621    let serialize_per_msg = match std::env::var("EDGEFIRST_GL_SERIALIZE").as_deref() {
622        Ok("full") => true,
623        Ok("lifecycle") => {
624            // The per-message lock is what makes ANGLE's per-display state
625            // manager safe here: `LAST_ACTIVE_CONTEXT` and the per-message
626            // context re-sync it drives are documented as touched only under
627            // it, and concurrent GL on this backend has crashed this box.
628            #[cfg(target_os = "windows")]
629            log::warn!(
630                "EDGEFIRST_GL_SERIALIZE=lifecycle drops the per-message lock the ANGLE/D3D11 context re-sync relies on; concurrent GL across processors is not supported on this backend"
631            );
632            false
633        }
634        _ => caps.serialize_gl,
635    };
636    log::debug!(
637        "GL serialization policy: {}",
638        if serialize_per_msg {
639            "Full (per-message global lock)"
640        } else {
641            "LifecycleOnly (parallel across processors)"
642        }
643    );
644    GL_WORKER.with(|c| {
645        if let Some(state) = c.borrow_mut().as_mut() {
646            state.serializes = serialize_per_msg;
647        }
648    });
649    while let Some(msg) = recv.blocking_recv() {
650        // Full policy: one processor's message at a time, process-wide.
651        // Linux locks GL_MUTEX (messages must also exclude the locked
652        // lifecycle operations — on Vivante a convert racing a context
653        // bring-up is part of the driver bug class). macOS has no
654        // lifecycle lock (ANGLE serializes display entry points
655        // internally), so message-vs-message exclusion suffices —
656        // needed on virtualized GPUs, where concurrent GL across
657        // contexts mis-renders (paravirtual Metal, macOS CI runners).
658        #[cfg(target_os = "linux")]
659        let _guard = serialize_per_msg.then(|| {
660            super::context::GL_MUTEX
661                .lock()
662                .unwrap_or_else(|e| e.into_inner())
663        });
664        #[cfg(any(
665            target_os = "macos",
666            target_os = "ios",
667            target_os = "android",
668            target_os = "windows"
669        ))]
670        let _guard =
671            serialize_per_msg.then(|| NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner()));
672
673        // After a panic, the GL context is in an undefined state. Reject
674        // all subsequent messages with an error rather than risking wrong
675        // output or a GPU hang from corrupted GL state. This follows the
676        // same pattern as std::sync::Mutex poisoning.
677        if poisoned {
678            reject_poisoned_message(msg);
679            continue;
680        }
681
682        // Platform pre-message hook (ANGLE/D3D11 re-syncs this context's
683        // state when another processor's context issued the last commands;
684        // a no-op elsewhere). Runs under the lock taken above.
685        gl_converter.begin_gpu_pass();
686        handle_gl_message(msg, &mut gl_converter, &mut poisoned);
687        // A re-entrant inline PBO op reports its panic here: `inline_gl_op`
688        // converted it to an error so it could not unwind into the tensor
689        // crate, which means `reply_caught` never saw it.
690        poisoned |= GL_WORKER_POISON.with(|p| p.replace(false));
691        // Per-pass platform texture attachments (macOS pbuffer
692        // binds) are released once the message's GPU work has
693        // synced; deferred batches keep theirs until Flush.
694        gl_converter.end_gpu_pass_if_synced();
695    }
696    // Explicitly drop under the mutex so EGL teardown is serialized
697    // (Linux, and Windows for the D3D11 reason above; ANGLE teardown is
698    // display-internal on macOS).
699    #[cfg(target_os = "linux")]
700    let _guard = super::context::GL_MUTEX
701        .lock()
702        .unwrap_or_else(|e| e.into_inner());
703    #[cfg(target_os = "windows")]
704    let _guard = NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
705    drop(gl_converter);
706}
707
708/// Per-message serialization lock for the platforms without a Linux-style
709/// `GL_MUTEX` (macOS/iOS, Android, Windows) — taken per message under the
710/// Full policy, and on Windows also around processor bring-up/teardown.
711#[cfg(not(target_os = "linux"))]
712static NON_LINUX_GL_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
713
714/// Take [`NON_LINUX_GL_MUTEX`] on behalf of a caller outside the GL worker
715/// threads. Poison-tolerant, like every other user of it.
716///
717/// Windows leaf tests MUST hold this guard for their whole body: any test
718/// that brings up an ANGLE display or a GL context does so on the test's own
719/// thread, and the lib test binary runs other tests whose GL workers are
720/// alive at the same time. ANGLE's D3D11 backend drives one immediate device
721/// context per display from every GL context and is not safe to use from two
722/// threads at once — the access-violation pattern documented on
723/// [`run_gl_worker`], which takes the same lock around bring-up and teardown.
724#[cfg(target_os = "windows")]
725pub(super) fn lifecycle_guard() -> std::sync::MutexGuard<'static, ()> {
726    NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
727}
728
729fn handle_gl_message(
730    msg: GLProcessorMessage,
731    gl_converter: &mut GLProcessorST,
732    poisoned: &mut bool,
733) {
734    match msg {
735        GLProcessorMessage::ImageConvert(src, dst, rotation, flip, crop, defer, resp) => {
736            handle_image_convert(
737                src,
738                dst,
739                (rotation, flip, crop, defer),
740                resp,
741                gl_converter,
742                poisoned,
743            );
744        }
745        GLProcessorMessage::ImageConvertFenced(src, dst, rotation, flip, crop, resp) => {
746            handle_image_convert_fenced(
747                src,
748                dst,
749                (rotation, flip, crop),
750                resp,
751                gl_converter,
752                poisoned,
753            );
754        }
755        GLProcessorMessage::Flush(resp) => {
756            let result = std::panic::catch_unwind(AssertUnwindSafe(|| gl_converter.flush()));
757            reply_caught(result, resp, poisoned, "Flush");
758        }
759        GLProcessorMessage::DrawDecodedMasks(
760            dst,
761            det,
762            seg,
763            opacity,
764            bg,
765            letterbox,
766            color_mode,
767            resp,
768        ) => {
769            handle_draw_decoded_masks(
770                dst,
771                det,
772                seg,
773                (opacity, bg, letterbox, color_mode),
774                resp,
775                gl_converter,
776                poisoned,
777            );
778        }
779        GLProcessorMessage::DrawProtoMasks(
780            dst,
781            det,
782            proto_data,
783            opacity,
784            bg,
785            letterbox,
786            color_mode,
787            resp,
788        ) => {
789            handle_draw_proto_masks(
790                dst,
791                det,
792                proto_data,
793                (opacity, bg, letterbox, color_mode),
794                resp,
795                gl_converter,
796                poisoned,
797            );
798        }
799        GLProcessorMessage::SetColors(colors, resp) => {
800            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
801                gl_converter.set_class_colors(&colors)
802            }));
803            reply_caught(result, resp, poisoned, "SetColors");
804        }
805        GLProcessorMessage::SetColorimetryMode(mode, resp) => {
806            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
807                gl_converter.set_colorimetry_mode(mode);
808                Ok(())
809            }));
810            reply_caught(result, resp, poisoned, "SetColorimetryMode");
811        }
812        GLProcessorMessage::SetInt8Interpolation(mode, resp) => {
813            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
814                gl_converter.set_int8_interpolation_mode(mode);
815                Ok(())
816            }));
817            reply_caught(result, resp, poisoned, "SetInt8Interpolation");
818        }
819        #[cfg(test)]
820        GLProcessorMessage::SetFailDstImport(fail, resp) => {
821            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
822                gl_converter.fail_dst_import = fail;
823                Ok(())
824            }));
825            reply_caught(result, resp, poisoned, "SetFailDstImport");
826        }
827        GLProcessorMessage::EglCacheStats(resp) => {
828            let result =
829                std::panic::catch_unwind(AssertUnwindSafe(|| Ok(gl_converter.egl_cache_stats())));
830            reply_caught(result, resp, poisoned, "EglCacheStats");
831        }
832        GLProcessorMessage::ConvertStats(resp) => {
833            let result =
834                std::panic::catch_unwind(AssertUnwindSafe(|| Ok(gl_converter.convert_stats())));
835            reply_caught(result, resp, poisoned, "ConvertStats");
836        }
837        GLProcessorMessage::PboCreate(size, resp) => {
838            handle_pbo_create(size, resp, poisoned);
839        }
840        GLProcessorMessage::PboMap(buffer_id, size, resp) => {
841            handle_pbo_map(buffer_id, size, resp, poisoned);
842        }
843        GLProcessorMessage::PboUnmap(buffer_id, resp) => {
844            handle_pbo_unmap(buffer_id, resp, poisoned);
845        }
846        GLProcessorMessage::PboDelete(buffer_id) => {
847            handle_pbo_delete(buffer_id, poisoned);
848        }
849        GLProcessorMessage::CudaRegisterBuffer(buffer_id, resp) => {
850            // CUDA GL-interop must run on the GL-context thread.
851            let _ = resp.send(edgefirst_tensor::gl_register_buffer(buffer_id));
852        }
853        GLProcessorMessage::CudaMap(resource, resp) => {
854            // Auto-flush: if a batched `convert_deferred` is still
855            // owed its single GPU sync, complete it before CUDA maps
856            // the buffer — otherwise the device could read a render
857            // still in flight. This makes `cuda_map()` correct on a
858            // batched output with no API change (the caller pays the
859            // sync it would have owed anyway).
860            gl_converter.flush_pending();
861            let _ = resp.send(edgefirst_tensor::gl_map_resource(resource));
862        }
863        GLProcessorMessage::CudaUnmap(resource) => {
864            edgefirst_tensor::gl_unmap_resource(resource);
865        }
866        GLProcessorMessage::CudaUnregister(resource) => {
867            edgefirst_tensor::gl_unregister_resource(resource);
868        }
869    }
870}
871
872fn handle_image_convert(
873    src: SendablePtr<TensorDyn>,
874    mut dst: SendablePtr<TensorDyn>,
875    geom: (Rotation, Flip, Crop, bool),
876    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
877    gl_converter: &mut GLProcessorST,
878    poisoned: &mut bool,
879) {
880    let (rotation, flip, crop, defer) = geom;
881    // SAFETY: This is safe because the convert() function waits for the resp to
882    // be sent before dropping the borrow for src and dst
883    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
884        let src = unsafe { src.ptr.as_ref() };
885        let dst = unsafe { dst.ptr.as_mut() };
886        // A deferred convert skips the per-tile glFinish and
887        // leaves a single sync owed at Flush; an eager convert
888        // finishes as usual.
889        if defer {
890            gl_converter.convert_deferred(src, dst, rotation, flip, crop)
891        } else {
892            gl_converter.convert(src, dst, rotation, flip, crop)
893        }
894    }));
895    reply_caught(result, resp, poisoned, "ImageConvert");
896}
897
898fn handle_image_convert_fenced(
899    src: SendablePtr<TensorDyn>,
900    mut dst: SendablePtr<TensorDyn>,
901    geom: (Rotation, Flip, Crop),
902    resp: tokio::sync::oneshot::Sender<Result<Option<super::CompletionFence>, Error>>,
903    gl_converter: &mut GLProcessorST,
904    poisoned: &mut bool,
905) {
906    let (rotation, flip, crop) = geom;
907    // SAFETY: as ImageConvert — convert_with_fence()
908    // blocks on the reply before the borrows drop.
909    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
910        let src = unsafe { src.ptr.as_ref() };
911        let dst = unsafe { dst.ptr.as_mut() };
912        gl_converter.convert_fenced(src, dst, rotation, flip, crop)
913    }));
914    reply_caught(result, resp, poisoned, "ImageConvertFenced");
915}
916
917fn handle_draw_decoded_masks(
918    mut dst: SendablePtr<TensorDyn>,
919    det: SendablePtr<DetectBox>,
920    seg: SendablePtr<Segmentation>,
921    overlay: (
922        f32,
923        Option<SendablePtr<TensorDyn>>,
924        Option<[f32; 4]>,
925        crate::ColorMode,
926    ),
927    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
928    gl_converter: &mut GLProcessorST,
929    poisoned: &mut bool,
930) {
931    let (opacity, bg, letterbox, color_mode) = overlay;
932    // SAFETY: This is safe because the draw_decoded_masks() function waits for the
933    // resp to be sent before dropping the borrow for dst, detect,
934    // segmentation, and background
935    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
936        let dst = unsafe { dst.ptr.as_mut() };
937        let det = unsafe { std::slice::from_raw_parts(det.ptr.as_ptr(), det.len) };
938        let seg = unsafe { std::slice::from_raw_parts(seg.ptr.as_ptr(), seg.len) };
939        let bg_ref = bg.map(|p| unsafe { &*p.ptr.as_ptr() });
940        gl_converter.draw_decoded_masks(
941            dst,
942            det,
943            seg,
944            crate::MaskOverlay {
945                background: bg_ref,
946                opacity,
947                letterbox,
948                color_mode,
949            },
950        )
951    }));
952    reply_caught(result, resp, poisoned, "DrawDecodedMasks");
953}
954
955fn handle_draw_proto_masks(
956    mut dst: SendablePtr<TensorDyn>,
957    det: SendablePtr<DetectBox>,
958    proto_data: SendablePtr<ProtoData>,
959    overlay: (
960        f32,
961        Option<SendablePtr<TensorDyn>>,
962        Option<[f32; 4]>,
963        crate::ColorMode,
964    ),
965    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
966    gl_converter: &mut GLProcessorST,
967    poisoned: &mut bool,
968) {
969    let (opacity, bg, letterbox, color_mode) = overlay;
970    // SAFETY: Same safety invariant as DrawDecodedMasks — caller
971    // blocks on resp before dropping borrows.
972    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
973        let dst = unsafe { dst.ptr.as_mut() };
974        let det = unsafe { std::slice::from_raw_parts(det.ptr.as_ptr(), det.len) };
975        let bg_ref = bg.map(|p| unsafe { &*p.ptr.as_ptr() });
976        let proto_data = unsafe { proto_data.ptr.as_ref() };
977        gl_converter.draw_proto_masks(
978            dst,
979            det,
980            proto_data,
981            crate::MaskOverlay {
982                background: bg_ref,
983                opacity,
984                letterbox,
985                color_mode,
986            },
987        )
988    }));
989    reply_caught(result, resp, poisoned, "DrawProtoMasks");
990}
991
992fn handle_pbo_create(
993    size: usize,
994    resp: tokio::sync::oneshot::Sender<Result<u32, Error>>,
995    poisoned: &mut bool,
996) {
997    let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
998        let mut id: u32 = 0;
999        edgefirst_gl::gl::GenBuffers(1, &mut id);
1000        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, id);
1001        edgefirst_gl::gl::BufferData(
1002            edgefirst_gl::gl::PIXEL_PACK_BUFFER,
1003            size as isize,
1004            std::ptr::null(),
1005            edgefirst_gl::gl::STREAM_COPY,
1006        );
1007        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
1008        match check_gl_error("PboCreate", 0) {
1009            Ok(()) => Ok(id),
1010            Err(e) => {
1011                edgefirst_gl::gl::DeleteBuffers(1, &id);
1012                Err(e)
1013            }
1014        }
1015    }));
1016    reply_caught(result, resp, poisoned, "PboCreate");
1017}
1018
1019/// Perform a PBO map on the calling thread, which MUST be the GL worker that
1020/// owns the buffer (context already current). Shared by the message handler and
1021/// the re-entrant inline path so the dedup rule is enforced identically.
1022fn pbo_map_on_gl_thread(
1023    buffer_id: u32,
1024    size: usize,
1025) -> Result<edgefirst_tensor::PboMapping, Error> {
1026    let already = with_worker_mapped(|set| !set.insert(buffer_id)).unwrap_or(false);
1027    if already {
1028        return Err(Error::OpenGl(format!(
1029            "PBO buffer {buffer_id} is already mapped in this GL context \
1030             (a producer and a reconstructed cross-package consumer may \
1031             both hold a live tensor over it)"
1032        )));
1033    }
1034    let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
1035        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, buffer_id);
1036        let ptr = edgefirst_gl::gl::MapBufferRange(
1037            edgefirst_gl::gl::PIXEL_PACK_BUFFER,
1038            0,
1039            size as isize,
1040            edgefirst_gl::gl::MAP_READ_BIT | edgefirst_gl::gl::MAP_WRITE_BIT,
1041        );
1042        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
1043        if ptr.is_null() {
1044            Err(Error::OpenGl("glMapBufferRange returned null".to_string()))
1045        } else {
1046            Ok(edgefirst_tensor::PboMapping {
1047                ptr: ptr as *mut u8,
1048                size,
1049            })
1050        }
1051    }));
1052    if !matches!(result, Ok(Ok(_))) {
1053        // The map did not take — release the reservation so a retry is not
1054        // permanently blocked by a mapping that never happened.
1055        with_worker_mapped(|set| set.remove(&buffer_id));
1056    }
1057    match result {
1058        Ok(result) => result,
1059        // Reaches whichever boundary the caller has: `handle_pbo_map` on the
1060        // message path, `inline_gl_op` on the re-entrant one.
1061        Err(panic) => std::panic::resume_unwind(panic),
1062    }
1063}
1064
1065/// Unmap counterpart of [`pbo_map_on_gl_thread`]; same thread requirement.
1066fn pbo_unmap_on_gl_thread(buffer_id: u32) -> Result<(), Error> {
1067    // Always release the reservation, matching `PboHandle::release_map`'s
1068    // "state returns to Unmapped either way" contract.
1069    with_worker_mapped(|set| set.remove(&buffer_id));
1070    unsafe {
1071        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, buffer_id);
1072        let ok = edgefirst_gl::gl::UnmapBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER);
1073        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
1074        if ok == edgefirst_gl::gl::FALSE {
1075            Err(Error::OpenGl(
1076                "PBO data was corrupted during mapping".into(),
1077            ))
1078        } else {
1079            check_gl_error("PboUnmap", 0)
1080        }
1081    }
1082}
1083
1084fn handle_pbo_map(
1085    buffer_id: u32,
1086    size: usize,
1087    resp: tokio::sync::oneshot::Sender<Result<edgefirst_tensor::PboMapping, Error>>,
1088    poisoned: &mut bool,
1089) {
1090    let result =
1091        std::panic::catch_unwind(AssertUnwindSafe(|| pbo_map_on_gl_thread(buffer_id, size)));
1092    reply_caught(result, resp, poisoned, "PboMap");
1093}
1094
1095fn handle_pbo_unmap(
1096    buffer_id: u32,
1097    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
1098    poisoned: &mut bool,
1099) {
1100    let result = std::panic::catch_unwind(AssertUnwindSafe(|| pbo_unmap_on_gl_thread(buffer_id)));
1101    reply_caught(result, resp, poisoned, "PboUnmap");
1102}
1103
1104fn handle_pbo_delete(buffer_id: u32, poisoned: &mut bool) {
1105    // Defensive: a live mapping should never reach here
1106    // (PboHandle::Drop unmaps before deleting), but a
1107    // stale reservation for a since-deleted (and
1108    // possibly buffer-id-recycled) buffer would
1109    // otherwise wrongly block every future map of a NEW
1110    // buffer that reuses the same id.
1111    with_worker_mapped(|set| set.remove(&buffer_id));
1112    if let Err(e) = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
1113        edgefirst_gl::gl::DeleteBuffers(1, &buffer_id);
1114    })) {
1115        *poisoned = true;
1116        log::error!(
1117            "GL thread panicked during PboDelete: {}",
1118            panic_message(e.as_ref()),
1119        );
1120    }
1121}
1122
1123impl GLProcessorThreaded {
1124    /// Creates a new OpenGL multi-threaded image converter whose import
1125    /// caches use the environment/default capacity.
1126    pub fn new(kind: Option<EglDisplayKind>) -> Result<Self, Error> {
1127        Self::with_cache_capacity(kind, None)
1128    }
1129
1130    /// As [`new`](Self::new), with an explicit per-cache import capacity —
1131    /// [`ImageProcessorConfig::egl_cache_capacity`], which documents what the
1132    /// number bounds and takes precedence over the environment.
1133    ///
1134    /// [`ImageProcessorConfig::egl_cache_capacity`]: crate::ImageProcessorConfig::egl_cache_capacity
1135    pub fn with_cache_capacity(
1136        kind: Option<EglDisplayKind>,
1137        capacity: Option<usize>,
1138    ) -> Result<Self, Error> {
1139        let worker_id = NEXT_GL_WORKER_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1140        let (send, recv) = tokio::sync::mpsc::channel::<GLProcessorMessage>(1);
1141
1142        let (create_ctx_send, create_ctx_recv) = tokio::sync::oneshot::channel();
1143
1144        let handle = std::thread::spawn(move || {
1145            run_gl_worker(worker_id, kind, capacity, recv, create_ctx_send);
1146        });
1147
1148        let caps = match create_ctx_recv.blocking_recv() {
1149            Ok(Err(e)) => return Err(e),
1150            Err(_) => {
1151                return Err(Error::Internal(
1152                    "GL converter error messaging closed without update".to_string(),
1153                ));
1154            }
1155            Ok(Ok(caps)) => caps,
1156        };
1157
1158        Ok(Self {
1159            handle: Some(handle),
1160            sender: Some(send),
1161            worker_id,
1162            caps,
1163        })
1164    }
1165}
1166
1167impl ImageProcessorTrait for GLProcessorThreaded {
1168    fn convert(
1169        &mut self,
1170        src: &TensorDyn,
1171        dst: &mut TensorDyn,
1172        rotation: crate::Rotation,
1173        flip: Flip,
1174        crop: Crop,
1175    ) -> crate::Result<()> {
1176        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1177        self.sender
1178            .as_ref()
1179            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1180            .blocking_send(GLProcessorMessage::ImageConvert(
1181                SendablePtr {
1182                    ptr: NonNull::from(src),
1183                    len: 1,
1184                },
1185                SendablePtr {
1186                    ptr: NonNull::from(dst),
1187                    len: 1,
1188                },
1189                rotation,
1190                flip,
1191                crop,
1192                false, // eager: finish per convert
1193                err_send,
1194            ))
1195            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1196        err_recv.blocking_recv().map_err(|_| {
1197            Error::Internal("GL converter error messaging closed without update".to_string())
1198        })?
1199    }
1200
1201    fn convert_deferred(
1202        &mut self,
1203        src: &TensorDyn,
1204        dst: &mut TensorDyn,
1205        rotation: crate::Rotation,
1206        flip: Flip,
1207        crop: Crop,
1208    ) -> crate::Result<()> {
1209        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1210        self.sender
1211            .as_ref()
1212            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1213            .blocking_send(GLProcessorMessage::ImageConvert(
1214                SendablePtr {
1215                    ptr: NonNull::from(src),
1216                    len: 1,
1217                },
1218                SendablePtr {
1219                    ptr: NonNull::from(dst),
1220                    len: 1,
1221                },
1222                rotation,
1223                flip,
1224                crop,
1225                true, // defer: skip glFinish; flush() syncs the batch once
1226                err_send,
1227            ))
1228            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1229        err_recv.blocking_recv().map_err(|_| {
1230            Error::Internal("GL converter error messaging closed without update".to_string())
1231        })?
1232    }
1233
1234    fn flush(&mut self) -> crate::Result<()> {
1235        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1236        self.sender
1237            .as_ref()
1238            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1239            .blocking_send(GLProcessorMessage::Flush(err_send))
1240            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1241        err_recv.blocking_recv().map_err(|_| {
1242            Error::Internal("GL converter error messaging closed without update".to_string())
1243        })?
1244    }
1245
1246    fn draw_decoded_masks(
1247        &mut self,
1248        dst: &mut TensorDyn,
1249        detect: &[crate::DetectBox],
1250        segmentation: &[crate::Segmentation],
1251        overlay: crate::MaskOverlay<'_>,
1252    ) -> crate::Result<()> {
1253        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1254        self.sender
1255            .as_ref()
1256            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1257            .blocking_send(GLProcessorMessage::DrawDecodedMasks(
1258                SendablePtr {
1259                    ptr: NonNull::from(dst),
1260                    len: 1,
1261                },
1262                SendablePtr {
1263                    ptr: NonNull::new(detect.as_ptr() as *mut DetectBox).unwrap(),
1264                    len: detect.len(),
1265                },
1266                SendablePtr {
1267                    ptr: NonNull::new(segmentation.as_ptr() as *mut Segmentation).unwrap(),
1268                    len: segmentation.len(),
1269                },
1270                overlay.opacity,
1271                overlay.background.map(|bg| SendablePtr {
1272                    ptr: NonNull::from(bg).cast::<TensorDyn>(),
1273                    len: 1,
1274                }),
1275                overlay.letterbox,
1276                overlay.color_mode,
1277                err_send,
1278            ))
1279            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1280        err_recv.blocking_recv().map_err(|_| {
1281            Error::Internal("GL converter error messaging closed without update".to_string())
1282        })?
1283    }
1284
1285    fn draw_proto_masks(
1286        &mut self,
1287        dst: &mut TensorDyn,
1288        detect: &[DetectBox],
1289        proto_data: &ProtoData,
1290        overlay: crate::MaskOverlay<'_>,
1291    ) -> crate::Result<()> {
1292        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1293        self.sender
1294            .as_ref()
1295            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1296            .blocking_send(GLProcessorMessage::DrawProtoMasks(
1297                SendablePtr {
1298                    ptr: NonNull::from(dst),
1299                    len: 1,
1300                },
1301                SendablePtr {
1302                    ptr: NonNull::new(detect.as_ptr() as *mut DetectBox).unwrap(),
1303                    len: detect.len(),
1304                },
1305                SendablePtr {
1306                    ptr: NonNull::from(proto_data).cast::<ProtoData>(),
1307                    len: 1,
1308                },
1309                overlay.opacity,
1310                overlay.background.map(|bg| SendablePtr {
1311                    ptr: NonNull::from(bg).cast::<TensorDyn>(),
1312                    len: 1,
1313                }),
1314                overlay.letterbox,
1315                overlay.color_mode,
1316                err_send,
1317            ))
1318            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1319        err_recv.blocking_recv().map_err(|_| {
1320            Error::Internal("GL converter error messaging closed without update".to_string())
1321        })?
1322    }
1323
1324    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<(), crate::Error> {
1325        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1326        self.sender
1327            .as_ref()
1328            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1329            .blocking_send(GLProcessorMessage::SetColors(colors.to_vec(), err_send))
1330            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1331        err_recv.blocking_recv().map_err(|_| {
1332            Error::Internal("GL converter error messaging closed without update".to_string())
1333        })?
1334    }
1335}
1336
1337impl GLProcessorThreaded {
1338    /// Sets the colorimetry/performance trade-off (see
1339    /// [`crate::ColorimetryMode`]). The `EDGEFIRST_COLORIMETRY` environment
1340    /// variable takes precedence — when set, this call logs and keeps the
1341    /// env-selected mode.
1342    pub fn set_colorimetry_mode(&mut self, mode: crate::ColorimetryMode) -> Result<(), Error> {
1343        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1344        self.sender
1345            .as_ref()
1346            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1347            .blocking_send(GLProcessorMessage::SetColorimetryMode(mode, err_send))
1348            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1349        err_recv.blocking_recv().map_err(|_| {
1350            Error::Internal("GL converter error messaging closed without update".to_string())
1351        })?
1352    }
1353
1354    /// Test hook (issue #175): make every zero-copy DESTINATION import fail,
1355    /// the way a driver that refuses the buffer does, so the fallback to the
1356    /// mapped-texture readback is exercised on a host whose driver accepts
1357    /// every destination. The failure is injected at the import itself, so
1358    /// what a test drives is the production trigger.
1359    #[cfg(test)]
1360    pub(crate) fn set_fail_dst_import(&mut self, fail: bool) -> Result<(), Error> {
1361        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1362        self.sender
1363            .as_ref()
1364            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1365            .blocking_send(GLProcessorMessage::SetFailDstImport(fail, err_send))
1366            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1367        err_recv.blocking_recv().map_err(|_| {
1368            Error::Internal("GL converter error messaging closed without update".to_string())
1369        })?
1370    }
1371
1372    /// Sets the interpolation mode for int8 proto textures.
1373    pub fn set_int8_interpolation_mode(
1374        &mut self,
1375        mode: Int8InterpolationMode,
1376    ) -> Result<(), crate::Error> {
1377        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1378        self.sender
1379            .as_ref()
1380            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1381            .blocking_send(GLProcessorMessage::SetInt8Interpolation(mode, err_send))
1382            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1383        err_recv.blocking_recv().map_err(|_| {
1384            Error::Internal("GL converter error messaging closed without update".to_string())
1385        })?
1386    }
1387
1388    /// Snapshot the EGLImage cache counters (src, dst, NV R8) from the GL
1389    /// thread. Steady-state tests capture this after warmup and after an
1390    /// N-frame loop over a fixed buffer pool and assert
1391    /// [`total_misses`](super::cache::GlCacheStats::total_misses) stays flat —
1392    /// any increase means a convert re-imported a buffer it should have found
1393    /// cached (the cache-behavior equality gate for GL refactors).
1394    pub fn egl_cache_stats(&self) -> Result<super::cache::GlCacheStats, Error> {
1395        let (send, recv) = tokio::sync::oneshot::channel();
1396        self.sender
1397            .as_ref()
1398            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1399            .blocking_send(GLProcessorMessage::EglCacheStats(send))
1400            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1401        recv.blocking_recv().map_err(|_| {
1402            Error::Internal("GL converter error messaging closed without update".to_string())
1403        })?
1404    }
1405
1406    /// Convert and return a native sync-fence fd that signals when the
1407    /// GPU work completes, instead of blocking the CPU on `glFinish` —
1408    /// the GL→NPU handoff (`EGL_ANDROID_native_fence_sync`).
1409    ///
1410    /// `Ok(None)` means the convert completed with the normal blocking
1411    /// semantics (platform/driver has no native fence, or a pure-CPU-
1412    /// readback path was taken) — the destination is already safe to
1413    /// read. `Ok(Some(fd))` means the caller (or the NPU runtime, via
1414    /// `ANeuralNetworksExecution_startComputeWithDependencies`) must wait
1415    /// on the fd before consuming the destination buffer.
1416    pub fn convert_with_fence(
1417        &mut self,
1418        src: &TensorDyn,
1419        dst: &mut TensorDyn,
1420        rotation: crate::Rotation,
1421        flip: Flip,
1422        crop: Crop,
1423    ) -> Result<Option<super::CompletionFence>, Error> {
1424        use crate::ImageProcessorTrait as _;
1425        if !self.caps.native_fence_sync {
1426            // No fence on this display: the plain blocking convert IS the
1427            // fenced contract with `None` — skip the special round-trip.
1428            self.convert(src, dst, rotation, flip, crop)?;
1429            return Ok(None);
1430        }
1431        let (send, recv) = tokio::sync::oneshot::channel();
1432        self.sender
1433            .as_ref()
1434            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1435            .blocking_send(GLProcessorMessage::ImageConvertFenced(
1436                SendablePtr {
1437                    ptr: NonNull::from(src),
1438                    len: 1,
1439                },
1440                SendablePtr {
1441                    ptr: NonNull::from(dst),
1442                    len: 1,
1443                },
1444                rotation,
1445                flip,
1446                crop,
1447                send,
1448            ))
1449            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1450        recv.blocking_recv().map_err(|_| {
1451            Error::Internal("GL converter error messaging closed without update".to_string())
1452        })?
1453    }
1454
1455    /// Snapshot the per-convert source-feed counters from the GL thread.
1456    /// A pipeline that expects end-to-end zero-copy asserts
1457    /// [`src_uploads`](super::cache::ConvertStats::src_uploads) stays flat
1458    /// (every frame fed by import, never by a CPU map + upload).
1459    pub fn convert_stats(&self) -> Result<super::cache::ConvertStats, Error> {
1460        let (send, recv) = tokio::sync::oneshot::channel();
1461        self.sender
1462            .as_ref()
1463            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1464            .blocking_send(GLProcessorMessage::ConvertStats(send))
1465            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1466        recv.blocking_recv().map_err(|_| {
1467            Error::Internal("GL converter error messaging closed without update".to_string())
1468        })?
1469    }
1470
1471    /// Create a PBO-backed [`Tensor<u8>`](edgefirst_tensor::Tensor) image on the GL thread.
1472    pub fn create_pbo_image(
1473        &self,
1474        width: usize,
1475        height: usize,
1476        format: edgefirst_tensor::PixelFormat,
1477    ) -> Result<edgefirst_tensor::Tensor<u8>, Error> {
1478        let sender = self
1479            .sender
1480            .as_ref()
1481            .ok_or(Error::OpenGl("GL processor is shutting down".to_string()))?;
1482
1483        let size = pbo_elem_count(width, height, format)
1484            .filter(|&s| s != 0)
1485            .ok_or_else(|| Error::OpenGl("Invalid image dimensions".to_string()))?;
1486
1487        // Allocate PBO on the GL thread
1488        let (tx, rx) = tokio::sync::oneshot::channel();
1489        sender
1490            .blocking_send(GLProcessorMessage::PboCreate(size, tx))
1491            .map_err(|_| Error::OpenGl("GL thread channel closed".to_string()))?;
1492        let buffer_id = rx
1493            .blocking_recv()
1494            .map_err(|_| Error::OpenGl("GL thread did not respond".to_string()))??;
1495
1496        let ops: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1497            sender: sender.downgrade(),
1498            worker_id: self.worker_id,
1499        });
1500
1501        let shape = pbo_shape(width, height, format);
1502
1503        let pbo_tensor =
1504            edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops)
1505                .map_err(|e| Error::OpenGl(format!("PBO tensor creation failed: {e:?}")))?;
1506        let mut tensor = edgefirst_tensor::Tensor::from_pbo(pbo_tensor)
1507            .map_err(|e| Error::OpenGl(format!("PBO tensor wrap failed: {e:?}")))?;
1508        tensor
1509            .set_format(format)
1510            .map_err(|e| Error::OpenGl(format!("Failed to set format on PBO tensor: {e:?}")))?;
1511        // Register the PBO with CUDA (best-effort; no-op without libcudart) so
1512        // `cuda_map()` can yield a device pointer — matching the float PBO path
1513        // in `create_pbo_image_dtype`. Without this, u8 PBOs were never
1514        // CUDA-interop-capable, so the codec's nvJPEG backend (and any CUDA
1515        // consumer) could not use a `create_image`-allocated PBO destination.
1516        // The i8 transmute by the caller preserves the attached handle.
1517        register_pbo_cuda(&mut tensor, buffer_id, size, sender);
1518        Ok(tensor)
1519    }
1520
1521    /// Create a PBO-backed [`TensorDyn`] image on the GL thread with the given dtype.
1522    ///
1523    /// Sizes the underlying GL buffer by `elems * dtype.size()` and wraps it in
1524    /// the appropriately-typed [`PboTensor`]. Supports `DType::U8`, `DType::F16`,
1525    /// and `DType::F32`; returns an error for other dtypes.
1526    pub(crate) fn create_pbo_image_dtype(
1527        &self,
1528        width: usize,
1529        height: usize,
1530        format: edgefirst_tensor::PixelFormat,
1531        dtype: edgefirst_tensor::DType,
1532    ) -> Result<TensorDyn, Error> {
1533        let sender = self
1534            .sender
1535            .as_ref()
1536            .ok_or(Error::OpenGl("GL processor is shutting down".to_string()))?;
1537
1538        let elems = pbo_elem_count(width, height, format)
1539            .filter(|&e| e != 0)
1540            .ok_or_else(|| Error::OpenGl("Invalid image dimensions".to_string()))?;
1541
1542        let size = elems
1543            .checked_mul(dtype.size())
1544            .ok_or_else(|| Error::OpenGl("PBO size overflow".to_string()))?;
1545
1546        // Allocate PBO on the GL thread
1547        let (tx, rx) = tokio::sync::oneshot::channel();
1548        sender
1549            .blocking_send(GLProcessorMessage::PboCreate(size, tx))
1550            .map_err(|_| Error::OpenGl("GL thread channel closed".to_string()))?;
1551        let buffer_id = rx
1552            .blocking_recv()
1553            .map_err(|_| Error::OpenGl("GL thread did not respond".to_string()))??;
1554
1555        let ops: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1556            sender: sender.downgrade(),
1557            worker_id: self.worker_id,
1558        });
1559
1560        let shape = pbo_shape(width, height, format);
1561
1562        let map_err = |e: edgefirst_tensor::Error| {
1563            Error::OpenGl(format!("PBO tensor creation failed: {e:?}"))
1564        };
1565        let set_err = |e: edgefirst_tensor::Error| {
1566            Error::OpenGl(format!("Failed to set format on PBO tensor: {e:?}"))
1567        };
1568
1569        match dtype {
1570            edgefirst_tensor::DType::U8 => {
1571                let pbo =
1572                    edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops)
1573                        .map_err(map_err)?;
1574                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1575                t.set_format(format).map_err(set_err)?;
1576                register_pbo_cuda(&mut t, buffer_id, size, sender);
1577                Ok(TensorDyn::from(t))
1578            }
1579            edgefirst_tensor::DType::F16 => {
1580                let pbo = edgefirst_tensor::PboTensor::<edgefirst_tensor::f16>::from_pbo(
1581                    buffer_id, size, &shape, None, ops,
1582                )
1583                .map_err(map_err)?;
1584                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1585                t.set_format(format).map_err(set_err)?;
1586                register_pbo_cuda(&mut t, buffer_id, size, sender);
1587                Ok(TensorDyn::from(t))
1588            }
1589            edgefirst_tensor::DType::F32 => {
1590                let pbo = edgefirst_tensor::PboTensor::<f32>::from_pbo(
1591                    buffer_id, size, &shape, None, ops,
1592                )
1593                .map_err(map_err)?;
1594                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1595                t.set_format(format).map_err(set_err)?;
1596                register_pbo_cuda(&mut t, buffer_id, size, sender);
1597                Ok(TensorDyn::from(t))
1598            }
1599            other => Err(Error::OpenGl(format!("unsupported PBO dtype {other:?}"))),
1600        }
1601    }
1602
1603    /// Returns the active transfer backend.
1604    pub(crate) fn transfer_backend(&self) -> TransferBackend {
1605        self.caps.transfer_backend
1606    }
1607
1608    /// Report which float dtypes the GPU can render to.
1609    ///
1610    /// Values are probed once at construction time and adjusted for
1611    /// Vivante GC7000UL, whose float readback latency (170-320 ms) makes
1612    /// GL float destinations impractical; `ImageProcessor::convert()` falls
1613    /// back to CPU float output (normalized to `[0, 1]`) for these targets.
1614    pub(crate) fn supported_render_dtypes(&self) -> crate::RenderDtypeSupport {
1615        self.caps.render_dtypes
1616    }
1617}
1618
1619impl Drop for GLProcessorThreaded {
1620    fn drop(&mut self) {
1621        // Closing the channel ends the worker loop, which drops the
1622        // `GLProcessorST` it owns; that `Drop` pays any device submission an
1623        // open batch still owes. The join is what makes it ordered before this
1624        // returns -- and it has to happen on the worker, which is the thread
1625        // holding the context.
1626        drop(self.sender.take());
1627        let _ = self.handle.take().and_then(|h| h.join().ok());
1628    }
1629}
1630
1631// `pbo_elem_count` / `pbo_shape` are pure (no GL), so they are unit-testable
1632// without a GPU. The overflow→None arm of `pbo_elem_count` guards against an
1633// undersized PBO allocation, so it is worth pinning explicitly.
1634#[cfg(test)]
1635#[cfg_attr(coverage_nightly, coverage(off))]
1636mod tests {
1637    use super::{pbo_elem_count, pbo_shape, GLProcessorThreaded, GlPboOps};
1638    use edgefirst_tensor::{PixelFormat, TensorTrait};
1639
1640    #[test]
1641    fn elem_count_per_format() {
1642        // Packed RGBA: w*h*4.
1643        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Rgba), Some(8 * 4 * 4));
1644        // Packed RGB: w*h*3.
1645        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Rgb), Some(8 * 4 * 3));
1646        // NV12 semiplanar: w*h*3/2.
1647        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Nv12), Some(8 * 4 * 3 / 2));
1648        // NV16 semiplanar: w*h*2.
1649        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Nv16), Some(8 * 4 * 2));
1650    }
1651
1652    #[test]
1653    fn elem_count_overflow_is_none() {
1654        // w*h already overflows usize → None (never a wrapped, undersized count).
1655        assert_eq!(pbo_elem_count(usize::MAX, 2, PixelFormat::Rgba), None);
1656        // w*h fits but *channels overflows → None.
1657        assert_eq!(pbo_elem_count(usize::MAX, 1, PixelFormat::Rgb), None);
1658    }
1659
1660    #[test]
1661    fn shape_per_format() {
1662        // Planar: [channels, height, width].
1663        assert_eq!(pbo_shape(8, 4, PixelFormat::PlanarRgb), vec![3, 4, 8]);
1664        // SemiPlanar NV12: [height*3/2, width].
1665        assert_eq!(pbo_shape(8, 4, PixelFormat::Nv12), vec![4 * 3 / 2, 8]);
1666        // SemiPlanar NV16: [height*2, width].
1667        assert_eq!(pbo_shape(8, 4, PixelFormat::Nv16), vec![4 * 2, 8]);
1668        // Packed: [height, width, channels].
1669        assert_eq!(pbo_shape(8, 4, PixelFormat::Rgba), vec![4, 8, 4]);
1670    }
1671
1672    /// Regression test for the cross-handle PBO map race task 11's review
1673    /// found: `PboHandle::acquire_map`'s per-handle `Mutex<MapState>`
1674    /// (`edgefirst-tensor`) has no visibility into a SEPARATE `PboHandle`
1675    /// for the same real `buffer_id` -- exactly what a cross-cdylib
1676    /// reconstructed consumer tensor now is, alongside the producer's own
1677    /// tensor, since task 11 wired a real `kind::PBO` `import_descriptor`
1678    /// arm. Simulates that with two independent `PboTensor`s built
1679    /// directly (rather than through the full descriptor/vtable round
1680    /// trip, which needs a second compiled `.so` to be meaningful) that
1681    /// both point at the SAME real GL buffer via a fresh [`GlPboOps`]
1682    /// wrapping the SAME sender -- exactly the relationship `import_descriptor`'s
1683    /// PBO arm produces between a producer's handle and a reconstructed
1684    /// one. Without `mapped_pbo_buffers`, the second map would reach
1685    /// `glMapBufferRange` on an already-mapped buffer (undefined
1686    /// behaviour); this asserts it is refused instead, and that the
1687    /// refusal is a real, releasable reservation rather than a permanently
1688    /// stuck `buffer_id`.
1689    #[test]
1690    #[cfg(any(target_os = "linux", target_os = "windows"))] // PBO destinations: Linux + Windows
1691    fn concurrent_maps_from_two_independent_handles_on_one_buffer_do_not_corrupt_gl_state() {
1692        let gl = match GLProcessorThreaded::new(None) {
1693            Ok(gl) => gl,
1694            Err(e) => {
1695                crate::test_support::report_skip(&format!(
1696                    "concurrent_maps_from_two_independent_handles... - \
1697                     OpenGL not available: {e:?}"
1698                ));
1699                return;
1700            }
1701        };
1702        let tensor_a = match gl.create_pbo_image(64, 64, PixelFormat::Rgba) {
1703            Ok(t) => t,
1704            Err(e) => {
1705                crate::test_support::report_skip(&format!(
1706                    "concurrent_maps_from_two_independent_handles... - \
1707                     PBO not supported: {e:?}"
1708                ));
1709                return;
1710            }
1711        };
1712        let buffer_id = tensor_a.pbo_id().expect("just created a PBO tensor");
1713        let size = tensor_a.capacity_bytes();
1714        let shape = tensor_a.shape().to_vec();
1715
1716        // A second, independent PboHandle for the IDENTICAL real buffer --
1717        // same relationship a producer's handle and a reconstructed
1718        // consumer's handle have to the same GL context.
1719        let ops_b: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1720            sender: gl.sender.as_ref().unwrap().downgrade(),
1721            worker_id: gl.worker_id,
1722        });
1723        let tensor_b =
1724            edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops_b)
1725                .expect("from_pbo against a real, already-created buffer_id must succeed");
1726
1727        // tensor_a maps first and holds the mapping open.
1728        let map_a = tensor_a.map().expect("first map must succeed");
1729
1730        // tensor_b's own Mutex<MapState> starts at Unmapped and knows
1731        // nothing about tensor_a's outstanding mapping -- without the
1732        // GL-thread-level fix this reaches glMapBufferRange a second time.
1733        let map_b_result = tensor_b.map();
1734        // `glMapBufferRange` on an already-mapped buffer is UNDEFINED
1735        // BEHAVIOUR per the GL spec, not guaranteed to fail cleanly on
1736        // every driver -- so this asserts the SPECIFIC refusal
1737        // `mapped_pbo_buffers` produces (matching this file's own error
1738        // text), not merely "some error occurred", which a
1739        // driver-dependent null return could also produce and would make
1740        // this test pass even with the fix reverted.
1741        let Err(map_b_err) = map_b_result else {
1742            panic!(
1743                "a second, independent handle mapping the SAME real buffer while the \
1744                     first mapping is still open must be refused, not silently succeed"
1745            );
1746        };
1747        let msg = format!("{map_b_err:?}");
1748        assert!(
1749            msg.contains("already mapped"),
1750            "must be refused specifically by the GL-thread-level reservation check \
1751             (mapped_pbo_buffers), not some other error path: {msg}"
1752        );
1753
1754        drop(map_a);
1755
1756        // Once the first mapping releases, the SAME second handle must be
1757        // able to map successfully -- proves the refusal above was a real,
1758        // releasable reservation, not a permanently stuck buffer_id.
1759        let map_b_after = tensor_b.map();
1760        assert!(
1761            map_b_after.is_ok(),
1762            "after the first handle's mapping releases, the second handle must be able to \
1763             map the same buffer: {map_b_after:?}"
1764        );
1765    }
1766}