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    /// a mask draw into a PBO destination the caller holds mapped reads it
205    /// back through `TensorTrait::map` -> `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. a mask draw reading back into a mapped PBO
288        // destination). 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    /// The PBOs this processor handed out, shared with its worker's
421    /// `GLProcessorST`: only these may be bound in the worker's context.
422    owned_pbos: std::sync::Arc<super::processor::OwnedPbos>,
423    /// Immutable capability surface (transfer backend, float render
424    /// support, serialization policy), captured once from the worker at
425    /// construction. See `PlatformCaps` in `platform/mod.rs`.
426    caps: super::platform::PlatformCaps,
427}
428
429unsafe impl Send for GLProcessorThreaded {}
430unsafe impl Sync for GLProcessorThreaded {}
431
432struct SendablePtr<T: Send> {
433    ptr: NonNull<T>,
434    len: usize,
435}
436
437unsafe impl<T> Send for SendablePtr<T> where T: Send {}
438
439/// Extract a human-readable message from a `catch_unwind` panic payload.
440fn panic_message(info: &(dyn std::any::Any + Send)) -> String {
441    if let Some(s) = info.downcast_ref::<&str>() {
442        s.to_string()
443    } else if let Some(s) = info.downcast_ref::<String>() {
444        s.clone()
445    } else {
446        "unknown panic".to_string()
447    }
448}
449
450fn reply_caught<T>(
451    result: std::thread::Result<crate::Result<T>>,
452    resp: tokio::sync::oneshot::Sender<crate::Result<T>>,
453    poisoned: &mut bool,
454    what: &str,
455) {
456    let _ = resp.send(match result {
457        Ok(res) => res,
458        Err(e) => {
459            *poisoned = true;
460            Err(crate::Error::Internal(format!(
461                "GL thread panicked during {what}: {}",
462                panic_message(e.as_ref()),
463            )))
464        }
465    });
466}
467
468fn reject_poisoned_message(msg: GLProcessorMessage) {
469    let poison_err =
470        crate::Error::Internal("GL context is poisoned after a prior panic".to_string());
471    match msg {
472        GLProcessorMessage::ImageConvertFenced(.., resp) => {
473            let _ = resp.send(Err(poison_err));
474        }
475        GLProcessorMessage::ImageConvert(.., resp) => {
476            let _ = resp.send(Err(poison_err));
477        }
478        GLProcessorMessage::Flush(resp) => {
479            let _ = resp.send(Err(poison_err));
480        }
481        GLProcessorMessage::DrawDecodedMasks(.., resp) => {
482            let _ = resp.send(Err(poison_err));
483        }
484        GLProcessorMessage::DrawProtoMasks(.., resp) => {
485            let _ = resp.send(Err(poison_err));
486        }
487        GLProcessorMessage::SetColors(_, resp) => {
488            let _ = resp.send(Err(poison_err));
489        }
490        GLProcessorMessage::SetInt8Interpolation(_, resp) => {
491            let _ = resp.send(Err(poison_err));
492        }
493        GLProcessorMessage::SetColorimetryMode(_, resp) => {
494            let _ = resp.send(Err(poison_err));
495        }
496        #[cfg(test)]
497        GLProcessorMessage::SetFailDstImport(_, resp) => {
498            let _ = resp.send(Err(poison_err));
499        }
500        GLProcessorMessage::EglCacheStats(resp) => {
501            let _ = resp.send(Err(poison_err));
502        }
503        GLProcessorMessage::ConvertStats(resp) => {
504            let _ = resp.send(Err(poison_err));
505        }
506        GLProcessorMessage::PboCreate(_, resp) => {
507            let _ = resp.send(Err(poison_err));
508        }
509        GLProcessorMessage::PboMap(_, _, resp) => {
510            let _ = resp.send(Err(poison_err));
511        }
512        GLProcessorMessage::PboUnmap(_, resp) => {
513            let _ = resp.send(Err(poison_err));
514        }
515        GLProcessorMessage::PboDelete(_) => {}
516        GLProcessorMessage::CudaRegisterBuffer(_, resp) => {
517            let _ = resp.send(None);
518        }
519        GLProcessorMessage::CudaMap(_, resp) => {
520            let _ = resp.send(None);
521        }
522        // GL context is poisoned/dead — the GL buffer is unusable, so unmapping/
523        // unregistering the CUDA interop is moot (the resource is reclaimed at
524        // process exit). Same accepted "GL is gone" no-op as PboDelete above.
525        GLProcessorMessage::CudaUnmap(_) => {}
526        GLProcessorMessage::CudaUnregister(_) => {}
527    }
528}
529
530fn run_gl_worker(
531    worker_id: u64,
532    owned_pbos: std::sync::Arc<super::processor::OwnedPbos>,
533    kind: Option<EglDisplayKind>,
534    capacity: Option<usize>,
535    mut recv: tokio::sync::mpsc::Receiver<GLProcessorMessage>,
536    create_ctx_send: tokio::sync::oneshot::Sender<Result<super::platform::PlatformCaps, Error>>,
537) {
538    // Creation runs under the global lifecycle lock on Linux
539    // (bring-up races on some embedded drivers); ANGLE serializes
540    // display-level entry points internally, so macOS needs none
541    // (validated by the A0 lifecycle-churn spike leg).
542    #[cfg(target_os = "linux")]
543    let init_result = {
544        let _guard = super::context::GL_MUTEX
545            .lock()
546            .unwrap_or_else(|e| e.into_inner());
547        GLProcessorST::new(kind, capacity)
548    };
549    // macOS: ANGLE/Metal serializes display-level entry points
550    // internally. Android: the system EGL is specification-conformant
551    // thread-safe. Neither needs the Linux lifecycle lock.
552    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
553    let init_result = GLProcessorST::new(kind, capacity);
554    // Windows: ANGLE's D3D11 backend drives one immediate device context
555    // (and one state manager) per display from every GL context and is not
556    // safe to use from two threads at once: a processor bringing up its
557    // context (shader compiles, texture allocation) while another converted
558    // crashed with an access violation. Lifecycle and messages therefore
559    // share NON_LINUX_GL_MUTEX there; per-message locking is the ANGLE Full
560    // policy anyway.
561    #[cfg(target_os = "windows")]
562    let init_result = {
563        let _guard = NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
564        GLProcessorST::new(kind, capacity)
565    };
566    let mut gl_converter = match init_result {
567        Ok(gl) => gl,
568        Err(e) => {
569            let _ = create_ctx_send.send(Err(e));
570            return;
571        }
572    };
573    gl_converter.owned_pbos = owned_pbos;
574    // Capability surface captured ONCE before the message loop —
575    // immutable for the worker's life, never re-probed per message.
576    let caps = gl_converter.platform_caps();
577    let _ = create_ctx_send.send(Ok(caps));
578    let mut poisoned = false;
579    // Tracks which PBO buffer_ids currently have an outstanding GL
580    // mapping, scoped to this one GL context/thread -- the ONE
581    // place every PboOps call against this context's buffers
582    // actually serializes, regardless of which `PboHandle`
583    // (edgefirst-tensor) issued the `PboMap`/`PboUnmap` message.
584    // `PboHandle::acquire_map`'s own per-handle `Mutex<MapState>`
585    // enforces "at most one mapping" WITHIN one handle, including
586    // read-sharing (several readers reuse ONE mapping without ever
587    // re-sending `PboMap` here) -- but it has no visibility into a
588    // SEPARATE `PboHandle` for the same real `buffer_id`, which
589    // task 11's cross-cdylib PBO import made newly reachable: a
590    // producer's own tensor and a reconstructed cross-package
591    // consumer tensor for the same `buffer_id` are two independent
592    // `PboHandle`s, each believing it is the only mapper.
593    // `glMapBufferRange` on an already-mapped buffer is undefined
594    // behaviour per the GL spec, so THIS set -- not the per-handle
595    // mutex, which never sees another handle's calls -- is what
596    // actually prevents that.
597    // Install this thread's GL-worker identity and its mapped-PBO set. Both
598    // the message path and the re-entrant inline path read them from here —
599    // see [`GL_WORKER`] for why the inline path has to exist at all.
600    GL_WORKER.with(|c| {
601        *c.borrow_mut() = Some(GlWorkerState {
602            id: worker_id,
603            // Set once the policy below is resolved.
604            serializes: false,
605            mapped: std::collections::HashSet::new(),
606        });
607    });
608    // Clear on every exit path, including unwind, so a recycled thread id can
609    // never inherit a dead worker's identity.
610    struct GlWorkerSlot;
611    impl Drop for GlWorkerSlot {
612        fn drop(&mut self) {
613            GL_WORKER.with(|c| *c.borrow_mut() = None);
614            GL_WORKER_POISON.with(|p| p.set(false));
615        }
616    }
617    let _gl_worker_slot = GlWorkerSlot;
618    // Per-message serialization policy: Full where the platform
619    // demands it (`caps.serialize_gl` — Vivante/galcore is not
620    // thread-safe for concurrent GL across contexts), LifecycleOnly
621    // everywhere else — multiple processors then run GL work in
622    // parallel, each on its own thread + context. See the GL_MUTEX
623    // doc comment in context.rs. EDGEFIRST_GL_SERIALIZE overrides
624    // per-driver defaults (full = escape hatch for unknown-bad
625    // drivers; lifecycle = force-parallel).
626    let serialize_per_msg = match std::env::var("EDGEFIRST_GL_SERIALIZE").as_deref() {
627        Ok("full") => true,
628        Ok("lifecycle") => {
629            // The per-message lock is what makes ANGLE's per-display state
630            // manager safe here: `LAST_ACTIVE_CONTEXT` and the per-message
631            // context re-sync it drives are documented as touched only under
632            // it, and concurrent GL on this backend has crashed this box.
633            #[cfg(target_os = "windows")]
634            log::warn!(
635                "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"
636            );
637            false
638        }
639        _ => caps.serialize_gl,
640    };
641    log::debug!(
642        "GL serialization policy: {}",
643        if serialize_per_msg {
644            "Full (per-message global lock)"
645        } else {
646            "LifecycleOnly (parallel across processors)"
647        }
648    );
649    GL_WORKER.with(|c| {
650        if let Some(state) = c.borrow_mut().as_mut() {
651            state.serializes = serialize_per_msg;
652        }
653    });
654    while let Some(msg) = recv.blocking_recv() {
655        // Full policy: one processor's message at a time, process-wide.
656        // Linux locks GL_MUTEX (messages must also exclude the locked
657        // lifecycle operations — on Vivante a convert racing a context
658        // bring-up is part of the driver bug class). macOS has no
659        // lifecycle lock (ANGLE serializes display entry points
660        // internally), so message-vs-message exclusion suffices —
661        // needed on virtualized GPUs, where concurrent GL across
662        // contexts mis-renders (paravirtual Metal, macOS CI runners).
663        #[cfg(target_os = "linux")]
664        let _guard = serialize_per_msg.then(|| {
665            super::context::GL_MUTEX
666                .lock()
667                .unwrap_or_else(|e| e.into_inner())
668        });
669        #[cfg(any(
670            target_os = "macos",
671            target_os = "ios",
672            target_os = "android",
673            target_os = "windows"
674        ))]
675        let _guard =
676            serialize_per_msg.then(|| NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner()));
677
678        // After a panic, the GL context is in an undefined state. Reject
679        // all subsequent messages with an error rather than risking wrong
680        // output or a GPU hang from corrupted GL state. This follows the
681        // same pattern as std::sync::Mutex poisoning.
682        if poisoned {
683            reject_poisoned_message(msg);
684            continue;
685        }
686
687        // Platform pre-message hook (ANGLE/D3D11 re-syncs this context's
688        // state when another processor's context issued the last commands;
689        // a no-op elsewhere). Runs under the lock taken above.
690        gl_converter.begin_gpu_pass();
691        handle_gl_message(msg, &mut gl_converter, &mut poisoned);
692        // A re-entrant inline PBO op reports its panic here: `inline_gl_op`
693        // converted it to an error so it could not unwind into the tensor
694        // crate, which means `reply_caught` never saw it.
695        poisoned |= GL_WORKER_POISON.with(|p| p.replace(false));
696        // Per-pass platform texture attachments (macOS pbuffer
697        // binds) are released once the message's GPU work has
698        // synced; deferred batches keep theirs until Flush.
699        gl_converter.end_gpu_pass_if_synced();
700    }
701    // Explicitly drop under the mutex so EGL teardown is serialized
702    // (Linux, and Windows for the D3D11 reason above; ANGLE teardown is
703    // display-internal on macOS).
704    #[cfg(target_os = "linux")]
705    let _guard = super::context::GL_MUTEX
706        .lock()
707        .unwrap_or_else(|e| e.into_inner());
708    #[cfg(target_os = "windows")]
709    let _guard = NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
710    drop(gl_converter);
711}
712
713/// Per-message serialization lock for the platforms without a Linux-style
714/// `GL_MUTEX` (macOS/iOS, Android, Windows) — taken per message under the
715/// Full policy, and on Windows also around processor bring-up/teardown.
716#[cfg(not(target_os = "linux"))]
717static NON_LINUX_GL_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
718
719/// Take [`NON_LINUX_GL_MUTEX`] on behalf of a caller outside the GL worker
720/// threads. Poison-tolerant, like every other user of it.
721///
722/// Windows leaf tests MUST hold this guard for their whole body: any test
723/// that brings up an ANGLE display or a GL context does so on the test's own
724/// thread, and the lib test binary runs other tests whose GL workers are
725/// alive at the same time. ANGLE's D3D11 backend drives one immediate device
726/// context per display from every GL context and is not safe to use from two
727/// threads at once — the access-violation pattern documented on
728/// [`run_gl_worker`], which takes the same lock around bring-up and teardown.
729#[cfg(target_os = "windows")]
730pub(super) fn lifecycle_guard() -> std::sync::MutexGuard<'static, ()> {
731    NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
732}
733
734fn handle_gl_message(
735    msg: GLProcessorMessage,
736    gl_converter: &mut GLProcessorST,
737    poisoned: &mut bool,
738) {
739    match msg {
740        GLProcessorMessage::ImageConvert(src, dst, rotation, flip, crop, defer, resp) => {
741            handle_image_convert(
742                src,
743                dst,
744                (rotation, flip, crop, defer),
745                resp,
746                gl_converter,
747                poisoned,
748            );
749        }
750        GLProcessorMessage::ImageConvertFenced(src, dst, rotation, flip, crop, resp) => {
751            handle_image_convert_fenced(
752                src,
753                dst,
754                (rotation, flip, crop),
755                resp,
756                gl_converter,
757                poisoned,
758            );
759        }
760        GLProcessorMessage::Flush(resp) => {
761            let result = std::panic::catch_unwind(AssertUnwindSafe(|| gl_converter.flush()));
762            reply_caught(result, resp, poisoned, "Flush");
763        }
764        GLProcessorMessage::DrawDecodedMasks(
765            dst,
766            det,
767            seg,
768            opacity,
769            bg,
770            letterbox,
771            color_mode,
772            resp,
773        ) => {
774            handle_draw_decoded_masks(
775                dst,
776                det,
777                seg,
778                (opacity, bg, letterbox, color_mode),
779                resp,
780                gl_converter,
781                poisoned,
782            );
783        }
784        GLProcessorMessage::DrawProtoMasks(
785            dst,
786            det,
787            proto_data,
788            opacity,
789            bg,
790            letterbox,
791            color_mode,
792            resp,
793        ) => {
794            handle_draw_proto_masks(
795                dst,
796                det,
797                proto_data,
798                (opacity, bg, letterbox, color_mode),
799                resp,
800                gl_converter,
801                poisoned,
802            );
803        }
804        GLProcessorMessage::SetColors(colors, resp) => {
805            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
806                gl_converter.set_class_colors(&colors)
807            }));
808            reply_caught(result, resp, poisoned, "SetColors");
809        }
810        GLProcessorMessage::SetColorimetryMode(mode, resp) => {
811            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
812                gl_converter.set_colorimetry_mode(mode);
813                Ok(())
814            }));
815            reply_caught(result, resp, poisoned, "SetColorimetryMode");
816        }
817        GLProcessorMessage::SetInt8Interpolation(mode, resp) => {
818            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
819                gl_converter.set_int8_interpolation_mode(mode);
820                Ok(())
821            }));
822            reply_caught(result, resp, poisoned, "SetInt8Interpolation");
823        }
824        #[cfg(test)]
825        GLProcessorMessage::SetFailDstImport(fail, resp) => {
826            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
827                gl_converter.fail_dst_import = fail;
828                Ok(())
829            }));
830            reply_caught(result, resp, poisoned, "SetFailDstImport");
831        }
832        GLProcessorMessage::EglCacheStats(resp) => {
833            let result =
834                std::panic::catch_unwind(AssertUnwindSafe(|| Ok(gl_converter.egl_cache_stats())));
835            reply_caught(result, resp, poisoned, "EglCacheStats");
836        }
837        GLProcessorMessage::ConvertStats(resp) => {
838            let result =
839                std::panic::catch_unwind(AssertUnwindSafe(|| Ok(gl_converter.convert_stats())));
840            reply_caught(result, resp, poisoned, "ConvertStats");
841        }
842        GLProcessorMessage::PboCreate(size, resp) => {
843            handle_pbo_create(size, resp, poisoned);
844        }
845        GLProcessorMessage::PboMap(buffer_id, size, resp) => {
846            handle_pbo_map(buffer_id, size, resp, poisoned);
847        }
848        GLProcessorMessage::PboUnmap(buffer_id, resp) => {
849            handle_pbo_unmap(buffer_id, resp, poisoned);
850        }
851        GLProcessorMessage::PboDelete(buffer_id) => {
852            handle_pbo_delete(buffer_id, poisoned);
853        }
854        GLProcessorMessage::CudaRegisterBuffer(buffer_id, resp) => {
855            // CUDA GL-interop must run on the GL-context thread.
856            let _ = resp.send(edgefirst_tensor::gl_register_buffer(buffer_id));
857        }
858        GLProcessorMessage::CudaMap(resource, resp) => {
859            // Auto-flush: if a batched `convert_deferred` is still
860            // owed its single GPU sync, complete it before CUDA maps
861            // the buffer — otherwise the device could read a render
862            // still in flight. This makes `cuda_map()` correct on a
863            // batched output with no API change (the caller pays the
864            // sync it would have owed anyway).
865            gl_converter.flush_pending();
866            let _ = resp.send(edgefirst_tensor::gl_map_resource(resource));
867        }
868        GLProcessorMessage::CudaUnmap(resource) => {
869            edgefirst_tensor::gl_unmap_resource(resource);
870        }
871        GLProcessorMessage::CudaUnregister(resource) => {
872            edgefirst_tensor::gl_unregister_resource(resource);
873        }
874    }
875}
876
877fn handle_image_convert(
878    src: SendablePtr<TensorDyn>,
879    mut dst: SendablePtr<TensorDyn>,
880    geom: (Rotation, Flip, Crop, bool),
881    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
882    gl_converter: &mut GLProcessorST,
883    poisoned: &mut bool,
884) {
885    let (rotation, flip, crop, defer) = geom;
886    // SAFETY: This is safe because the convert() function waits for the resp to
887    // be sent before dropping the borrow for src and dst
888    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
889        let src = unsafe { src.ptr.as_ref() };
890        let dst = unsafe { dst.ptr.as_mut() };
891        // A deferred convert skips the per-tile glFinish and
892        // leaves a single sync owed at Flush; an eager convert
893        // finishes as usual.
894        if defer {
895            gl_converter.convert_deferred(src, dst, rotation, flip, crop)
896        } else {
897            gl_converter.convert(src, dst, rotation, flip, crop)
898        }
899    }));
900    reply_caught(result, resp, poisoned, "ImageConvert");
901}
902
903fn handle_image_convert_fenced(
904    src: SendablePtr<TensorDyn>,
905    mut dst: SendablePtr<TensorDyn>,
906    geom: (Rotation, Flip, Crop),
907    resp: tokio::sync::oneshot::Sender<Result<Option<super::CompletionFence>, Error>>,
908    gl_converter: &mut GLProcessorST,
909    poisoned: &mut bool,
910) {
911    let (rotation, flip, crop) = geom;
912    // SAFETY: as ImageConvert — convert_with_fence()
913    // blocks on the reply before the borrows drop.
914    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
915        let src = unsafe { src.ptr.as_ref() };
916        let dst = unsafe { dst.ptr.as_mut() };
917        gl_converter.convert_fenced(src, dst, rotation, flip, crop)
918    }));
919    reply_caught(result, resp, poisoned, "ImageConvertFenced");
920}
921
922fn handle_draw_decoded_masks(
923    mut dst: SendablePtr<TensorDyn>,
924    det: SendablePtr<DetectBox>,
925    seg: SendablePtr<Segmentation>,
926    overlay: (
927        f32,
928        Option<SendablePtr<TensorDyn>>,
929        Option<[f32; 4]>,
930        crate::ColorMode,
931    ),
932    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
933    gl_converter: &mut GLProcessorST,
934    poisoned: &mut bool,
935) {
936    let (opacity, bg, letterbox, color_mode) = overlay;
937    // SAFETY: This is safe because the draw_decoded_masks() function waits for the
938    // resp to be sent before dropping the borrow for dst, detect,
939    // segmentation, and background
940    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
941        let dst = unsafe { dst.ptr.as_mut() };
942        let det = unsafe { std::slice::from_raw_parts(det.ptr.as_ptr(), det.len) };
943        let seg = unsafe { std::slice::from_raw_parts(seg.ptr.as_ptr(), seg.len) };
944        let bg_ref = bg.map(|p| unsafe { &*p.ptr.as_ptr() });
945        gl_converter.draw_decoded_masks(
946            dst,
947            det,
948            seg,
949            crate::MaskOverlay {
950                background: bg_ref,
951                opacity,
952                letterbox,
953                color_mode,
954            },
955        )
956    }));
957    reply_caught(result, resp, poisoned, "DrawDecodedMasks");
958}
959
960fn handle_draw_proto_masks(
961    mut dst: SendablePtr<TensorDyn>,
962    det: SendablePtr<DetectBox>,
963    proto_data: SendablePtr<ProtoData>,
964    overlay: (
965        f32,
966        Option<SendablePtr<TensorDyn>>,
967        Option<[f32; 4]>,
968        crate::ColorMode,
969    ),
970    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
971    gl_converter: &mut GLProcessorST,
972    poisoned: &mut bool,
973) {
974    let (opacity, bg, letterbox, color_mode) = overlay;
975    // SAFETY: Same safety invariant as DrawDecodedMasks — caller
976    // blocks on resp before dropping borrows.
977    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
978        let dst = unsafe { dst.ptr.as_mut() };
979        let det = unsafe { std::slice::from_raw_parts(det.ptr.as_ptr(), det.len) };
980        let bg_ref = bg.map(|p| unsafe { &*p.ptr.as_ptr() });
981        let proto_data = unsafe { proto_data.ptr.as_ref() };
982        gl_converter.draw_proto_masks(
983            dst,
984            det,
985            proto_data,
986            crate::MaskOverlay {
987                background: bg_ref,
988                opacity,
989                letterbox,
990                color_mode,
991            },
992        )
993    }));
994    reply_caught(result, resp, poisoned, "DrawProtoMasks");
995}
996
997fn handle_pbo_create(
998    size: usize,
999    resp: tokio::sync::oneshot::Sender<Result<u32, Error>>,
1000    poisoned: &mut bool,
1001) {
1002    let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
1003        let mut id: u32 = 0;
1004        edgefirst_gl::gl::GenBuffers(1, &mut id);
1005        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, id);
1006        edgefirst_gl::gl::BufferData(
1007            edgefirst_gl::gl::PIXEL_PACK_BUFFER,
1008            size as isize,
1009            std::ptr::null(),
1010            edgefirst_gl::gl::STREAM_COPY,
1011        );
1012        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
1013        match check_gl_error("PboCreate", 0) {
1014            Ok(()) => Ok(id),
1015            Err(e) => {
1016                edgefirst_gl::gl::DeleteBuffers(1, &id);
1017                Err(e)
1018            }
1019        }
1020    }));
1021    reply_caught(result, resp, poisoned, "PboCreate");
1022}
1023
1024/// Perform a PBO map on the calling thread, which MUST be the GL worker that
1025/// owns the buffer (context already current). Shared by the message handler and
1026/// the re-entrant inline path so the dedup rule is enforced identically.
1027fn pbo_map_on_gl_thread(
1028    buffer_id: u32,
1029    size: usize,
1030) -> Result<edgefirst_tensor::PboMapping, Error> {
1031    let already = with_worker_mapped(|set| !set.insert(buffer_id)).unwrap_or(false);
1032    if already {
1033        return Err(Error::OpenGl(format!(
1034            "PBO buffer {buffer_id} is already mapped in this GL context \
1035             (a producer and a reconstructed cross-package consumer may \
1036             both hold a live tensor over it)"
1037        )));
1038    }
1039    let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
1040        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, buffer_id);
1041        let ptr = edgefirst_gl::gl::MapBufferRange(
1042            edgefirst_gl::gl::PIXEL_PACK_BUFFER,
1043            0,
1044            size as isize,
1045            edgefirst_gl::gl::MAP_READ_BIT | edgefirst_gl::gl::MAP_WRITE_BIT,
1046        );
1047        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
1048        if ptr.is_null() {
1049            Err(Error::OpenGl("glMapBufferRange returned null".to_string()))
1050        } else {
1051            Ok(edgefirst_tensor::PboMapping {
1052                ptr: ptr as *mut u8,
1053                size,
1054            })
1055        }
1056    }));
1057    if !matches!(result, Ok(Ok(_))) {
1058        // The map did not take — release the reservation so a retry is not
1059        // permanently blocked by a mapping that never happened.
1060        with_worker_mapped(|set| set.remove(&buffer_id));
1061    }
1062    match result {
1063        Ok(result) => result,
1064        // Reaches whichever boundary the caller has: `handle_pbo_map` on the
1065        // message path, `inline_gl_op` on the re-entrant one.
1066        Err(panic) => std::panic::resume_unwind(panic),
1067    }
1068}
1069
1070/// Unmap counterpart of [`pbo_map_on_gl_thread`]; same thread requirement.
1071fn pbo_unmap_on_gl_thread(buffer_id: u32) -> Result<(), Error> {
1072    // Always release the reservation, matching `PboHandle::release_map`'s
1073    // "state returns to Unmapped either way" contract.
1074    with_worker_mapped(|set| set.remove(&buffer_id));
1075    unsafe {
1076        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, buffer_id);
1077        let ok = edgefirst_gl::gl::UnmapBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER);
1078        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
1079        if ok == edgefirst_gl::gl::FALSE {
1080            Err(Error::OpenGl(
1081                "PBO data was corrupted during mapping".into(),
1082            ))
1083        } else {
1084            check_gl_error("PboUnmap", 0)
1085        }
1086    }
1087}
1088
1089fn handle_pbo_map(
1090    buffer_id: u32,
1091    size: usize,
1092    resp: tokio::sync::oneshot::Sender<Result<edgefirst_tensor::PboMapping, Error>>,
1093    poisoned: &mut bool,
1094) {
1095    let result =
1096        std::panic::catch_unwind(AssertUnwindSafe(|| pbo_map_on_gl_thread(buffer_id, size)));
1097    reply_caught(result, resp, poisoned, "PboMap");
1098}
1099
1100fn handle_pbo_unmap(
1101    buffer_id: u32,
1102    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
1103    poisoned: &mut bool,
1104) {
1105    let result = std::panic::catch_unwind(AssertUnwindSafe(|| pbo_unmap_on_gl_thread(buffer_id)));
1106    reply_caught(result, resp, poisoned, "PboUnmap");
1107}
1108
1109fn handle_pbo_delete(buffer_id: u32, poisoned: &mut bool) {
1110    // Defensive: a live mapping should never reach here
1111    // (PboHandle::Drop unmaps before deleting), but a
1112    // stale reservation for a since-deleted (and
1113    // possibly buffer-id-recycled) buffer would
1114    // otherwise wrongly block every future map of a NEW
1115    // buffer that reuses the same id.
1116    with_worker_mapped(|set| set.remove(&buffer_id));
1117    if let Err(e) = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
1118        edgefirst_gl::gl::DeleteBuffers(1, &buffer_id);
1119    })) {
1120        *poisoned = true;
1121        log::error!(
1122            "GL thread panicked during PboDelete: {}",
1123            panic_message(e.as_ref()),
1124        );
1125    }
1126}
1127
1128impl GLProcessorThreaded {
1129    /// Creates a new OpenGL multi-threaded image converter whose import
1130    /// caches use the environment/default capacity.
1131    pub fn new(kind: Option<EglDisplayKind>) -> Result<Self, Error> {
1132        Self::with_cache_capacity(kind, None)
1133    }
1134
1135    /// As [`new`](Self::new), with an explicit per-cache import capacity —
1136    /// [`ImageProcessorConfig::egl_cache_capacity`], which documents what the
1137    /// number bounds and takes precedence over the environment.
1138    ///
1139    /// [`ImageProcessorConfig::egl_cache_capacity`]: crate::ImageProcessorConfig::egl_cache_capacity
1140    pub fn with_cache_capacity(
1141        kind: Option<EglDisplayKind>,
1142        capacity: Option<usize>,
1143    ) -> Result<Self, Error> {
1144        let worker_id = NEXT_GL_WORKER_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1145        let (send, recv) = tokio::sync::mpsc::channel::<GLProcessorMessage>(1);
1146
1147        let (create_ctx_send, create_ctx_recv) = tokio::sync::oneshot::channel();
1148
1149        let owned_pbos = std::sync::Arc::<super::processor::OwnedPbos>::default();
1150        let worker_owned = owned_pbos.clone();
1151        let handle = std::thread::spawn(move || {
1152            run_gl_worker(
1153                worker_id,
1154                worker_owned,
1155                kind,
1156                capacity,
1157                recv,
1158                create_ctx_send,
1159            );
1160        });
1161
1162        let caps = match create_ctx_recv.blocking_recv() {
1163            Ok(Err(e)) => return Err(e),
1164            Err(_) => {
1165                return Err(Error::Internal(
1166                    "GL converter error messaging closed without update".to_string(),
1167                ));
1168            }
1169            Ok(Ok(caps)) => caps,
1170        };
1171
1172        Ok(Self {
1173            handle: Some(handle),
1174            sender: Some(send),
1175            worker_id,
1176            owned_pbos,
1177            caps,
1178        })
1179    }
1180}
1181
1182impl ImageProcessorTrait for GLProcessorThreaded {
1183    fn convert(
1184        &mut self,
1185        src: &TensorDyn,
1186        dst: &mut TensorDyn,
1187        rotation: crate::Rotation,
1188        flip: Flip,
1189        crop: Crop,
1190    ) -> crate::Result<()> {
1191        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1192        self.sender
1193            .as_ref()
1194            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1195            .blocking_send(GLProcessorMessage::ImageConvert(
1196                SendablePtr {
1197                    ptr: NonNull::from(src),
1198                    len: 1,
1199                },
1200                SendablePtr {
1201                    ptr: NonNull::from(dst),
1202                    len: 1,
1203                },
1204                rotation,
1205                flip,
1206                crop,
1207                false, // eager: finish per convert
1208                err_send,
1209            ))
1210            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1211        err_recv.blocking_recv().map_err(|_| {
1212            Error::Internal("GL converter error messaging closed without update".to_string())
1213        })?
1214    }
1215
1216    fn convert_deferred(
1217        &mut self,
1218        src: &TensorDyn,
1219        dst: &mut TensorDyn,
1220        rotation: crate::Rotation,
1221        flip: Flip,
1222        crop: Crop,
1223    ) -> crate::Result<()> {
1224        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1225        self.sender
1226            .as_ref()
1227            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1228            .blocking_send(GLProcessorMessage::ImageConvert(
1229                SendablePtr {
1230                    ptr: NonNull::from(src),
1231                    len: 1,
1232                },
1233                SendablePtr {
1234                    ptr: NonNull::from(dst),
1235                    len: 1,
1236                },
1237                rotation,
1238                flip,
1239                crop,
1240                true, // defer: skip glFinish; flush() syncs the batch once
1241                err_send,
1242            ))
1243            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1244        err_recv.blocking_recv().map_err(|_| {
1245            Error::Internal("GL converter error messaging closed without update".to_string())
1246        })?
1247    }
1248
1249    fn flush(&mut self) -> crate::Result<()> {
1250        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1251        self.sender
1252            .as_ref()
1253            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1254            .blocking_send(GLProcessorMessage::Flush(err_send))
1255            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1256        err_recv.blocking_recv().map_err(|_| {
1257            Error::Internal("GL converter error messaging closed without update".to_string())
1258        })?
1259    }
1260
1261    fn draw_decoded_masks(
1262        &mut self,
1263        dst: &mut TensorDyn,
1264        detect: &[crate::DetectBox],
1265        segmentation: &[crate::Segmentation],
1266        overlay: crate::MaskOverlay<'_>,
1267    ) -> crate::Result<()> {
1268        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1269        self.sender
1270            .as_ref()
1271            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1272            .blocking_send(GLProcessorMessage::DrawDecodedMasks(
1273                SendablePtr {
1274                    ptr: NonNull::from(dst),
1275                    len: 1,
1276                },
1277                SendablePtr {
1278                    ptr: NonNull::new(detect.as_ptr() as *mut DetectBox).unwrap(),
1279                    len: detect.len(),
1280                },
1281                SendablePtr {
1282                    ptr: NonNull::new(segmentation.as_ptr() as *mut Segmentation).unwrap(),
1283                    len: segmentation.len(),
1284                },
1285                overlay.opacity,
1286                overlay.background.map(|bg| SendablePtr {
1287                    ptr: NonNull::from(bg).cast::<TensorDyn>(),
1288                    len: 1,
1289                }),
1290                overlay.letterbox,
1291                overlay.color_mode,
1292                err_send,
1293            ))
1294            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1295        err_recv.blocking_recv().map_err(|_| {
1296            Error::Internal("GL converter error messaging closed without update".to_string())
1297        })?
1298    }
1299
1300    fn draw_proto_masks(
1301        &mut self,
1302        dst: &mut TensorDyn,
1303        detect: &[DetectBox],
1304        proto_data: &ProtoData,
1305        overlay: crate::MaskOverlay<'_>,
1306    ) -> crate::Result<()> {
1307        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1308        self.sender
1309            .as_ref()
1310            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1311            .blocking_send(GLProcessorMessage::DrawProtoMasks(
1312                SendablePtr {
1313                    ptr: NonNull::from(dst),
1314                    len: 1,
1315                },
1316                SendablePtr {
1317                    ptr: NonNull::new(detect.as_ptr() as *mut DetectBox).unwrap(),
1318                    len: detect.len(),
1319                },
1320                SendablePtr {
1321                    ptr: NonNull::from(proto_data).cast::<ProtoData>(),
1322                    len: 1,
1323                },
1324                overlay.opacity,
1325                overlay.background.map(|bg| SendablePtr {
1326                    ptr: NonNull::from(bg).cast::<TensorDyn>(),
1327                    len: 1,
1328                }),
1329                overlay.letterbox,
1330                overlay.color_mode,
1331                err_send,
1332            ))
1333            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1334        err_recv.blocking_recv().map_err(|_| {
1335            Error::Internal("GL converter error messaging closed without update".to_string())
1336        })?
1337    }
1338
1339    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<(), crate::Error> {
1340        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1341        self.sender
1342            .as_ref()
1343            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1344            .blocking_send(GLProcessorMessage::SetColors(colors.to_vec(), err_send))
1345            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1346        err_recv.blocking_recv().map_err(|_| {
1347            Error::Internal("GL converter error messaging closed without update".to_string())
1348        })?
1349    }
1350}
1351
1352impl GLProcessorThreaded {
1353    /// Sets the colorimetry/performance trade-off (see
1354    /// [`crate::ColorimetryMode`]). The `EDGEFIRST_COLORIMETRY` environment
1355    /// variable takes precedence — when set, this call logs and keeps the
1356    /// env-selected mode.
1357    pub fn set_colorimetry_mode(&mut self, mode: crate::ColorimetryMode) -> Result<(), Error> {
1358        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1359        self.sender
1360            .as_ref()
1361            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1362            .blocking_send(GLProcessorMessage::SetColorimetryMode(mode, err_send))
1363            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1364        err_recv.blocking_recv().map_err(|_| {
1365            Error::Internal("GL converter error messaging closed without update".to_string())
1366        })?
1367    }
1368
1369    /// Test hook (issue #175): make every zero-copy DESTINATION import fail,
1370    /// the way a driver that refuses the buffer does, so the fallback to the
1371    /// mapped-texture readback is exercised on a host whose driver accepts
1372    /// every destination. The failure is injected at the import itself, so
1373    /// what a test drives is the production trigger.
1374    #[cfg(test)]
1375    pub(crate) fn set_fail_dst_import(&mut self, fail: bool) -> Result<(), Error> {
1376        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1377        self.sender
1378            .as_ref()
1379            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1380            .blocking_send(GLProcessorMessage::SetFailDstImport(fail, err_send))
1381            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1382        err_recv.blocking_recv().map_err(|_| {
1383            Error::Internal("GL converter error messaging closed without update".to_string())
1384        })?
1385    }
1386
1387    /// Sets the interpolation mode for int8 proto textures.
1388    pub fn set_int8_interpolation_mode(
1389        &mut self,
1390        mode: Int8InterpolationMode,
1391    ) -> Result<(), crate::Error> {
1392        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1393        self.sender
1394            .as_ref()
1395            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1396            .blocking_send(GLProcessorMessage::SetInt8Interpolation(mode, err_send))
1397            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1398        err_recv.blocking_recv().map_err(|_| {
1399            Error::Internal("GL converter error messaging closed without update".to_string())
1400        })?
1401    }
1402
1403    /// Snapshot the EGLImage cache counters (src, dst, NV R8) from the GL
1404    /// thread. Steady-state tests capture this after warmup and after an
1405    /// N-frame loop over a fixed buffer pool and assert
1406    /// [`total_misses`](super::cache::GlCacheStats::total_misses) stays flat —
1407    /// any increase means a convert re-imported a buffer it should have found
1408    /// cached (the cache-behavior equality gate for GL refactors).
1409    pub fn egl_cache_stats(&self) -> Result<super::cache::GlCacheStats, Error> {
1410        let (send, recv) = tokio::sync::oneshot::channel();
1411        self.sender
1412            .as_ref()
1413            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1414            .blocking_send(GLProcessorMessage::EglCacheStats(send))
1415            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1416        recv.blocking_recv().map_err(|_| {
1417            Error::Internal("GL converter error messaging closed without update".to_string())
1418        })?
1419    }
1420
1421    /// Convert and return a native sync-fence fd that signals when the
1422    /// GPU work completes, instead of blocking the CPU on `glFinish` —
1423    /// the GL→NPU handoff (`EGL_ANDROID_native_fence_sync`).
1424    ///
1425    /// `Ok(None)` means the convert completed with the normal blocking
1426    /// semantics (platform/driver has no native fence, or a pure-CPU-
1427    /// readback path was taken) — the destination is already safe to
1428    /// read. `Ok(Some(fd))` means the caller (or the NPU runtime, via
1429    /// `ANeuralNetworksExecution_startComputeWithDependencies`) must wait
1430    /// on the fd before consuming the destination buffer.
1431    pub fn convert_with_fence(
1432        &mut self,
1433        src: &TensorDyn,
1434        dst: &mut TensorDyn,
1435        rotation: crate::Rotation,
1436        flip: Flip,
1437        crop: Crop,
1438    ) -> Result<Option<super::CompletionFence>, Error> {
1439        use crate::ImageProcessorTrait as _;
1440        if !self.caps.native_fence_sync {
1441            // No fence on this display: the plain blocking convert IS the
1442            // fenced contract with `None` — skip the special round-trip.
1443            self.convert(src, dst, rotation, flip, crop)?;
1444            return Ok(None);
1445        }
1446        let (send, recv) = tokio::sync::oneshot::channel();
1447        self.sender
1448            .as_ref()
1449            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1450            .blocking_send(GLProcessorMessage::ImageConvertFenced(
1451                SendablePtr {
1452                    ptr: NonNull::from(src),
1453                    len: 1,
1454                },
1455                SendablePtr {
1456                    ptr: NonNull::from(dst),
1457                    len: 1,
1458                },
1459                rotation,
1460                flip,
1461                crop,
1462                send,
1463            ))
1464            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1465        recv.blocking_recv().map_err(|_| {
1466            Error::Internal("GL converter error messaging closed without update".to_string())
1467        })?
1468    }
1469
1470    /// Snapshot the per-convert source-feed counters from the GL thread.
1471    /// A pipeline that expects end-to-end zero-copy asserts
1472    /// [`src_uploads`](super::cache::ConvertStats::src_uploads) stays flat
1473    /// (every frame fed by import, never by a CPU map + upload).
1474    pub fn convert_stats(&self) -> Result<super::cache::ConvertStats, Error> {
1475        let (send, recv) = tokio::sync::oneshot::channel();
1476        self.sender
1477            .as_ref()
1478            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1479            .blocking_send(GLProcessorMessage::ConvertStats(send))
1480            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1481        recv.blocking_recv().map_err(|_| {
1482            Error::Internal("GL converter error messaging closed without update".to_string())
1483        })?
1484    }
1485
1486    /// Create a PBO-backed [`Tensor<u8>`](edgefirst_tensor::Tensor) image on the GL thread.
1487    pub fn create_pbo_image(
1488        &self,
1489        width: usize,
1490        height: usize,
1491        format: edgefirst_tensor::PixelFormat,
1492    ) -> Result<edgefirst_tensor::Tensor<u8>, Error> {
1493        let sender = self
1494            .sender
1495            .as_ref()
1496            .ok_or(Error::OpenGl("GL processor is shutting down".to_string()))?;
1497
1498        let size = pbo_elem_count(width, height, format)
1499            .filter(|&s| s != 0)
1500            .ok_or_else(|| Error::OpenGl("Invalid image dimensions".to_string()))?;
1501
1502        // Allocate PBO on the GL thread
1503        let (tx, rx) = tokio::sync::oneshot::channel();
1504        sender
1505            .blocking_send(GLProcessorMessage::PboCreate(size, tx))
1506            .map_err(|_| Error::OpenGl("GL thread channel closed".to_string()))?;
1507        let buffer_id = rx
1508            .blocking_recv()
1509            .map_err(|_| Error::OpenGl("GL thread did not respond".to_string()))??;
1510
1511        let ops: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1512            sender: sender.downgrade(),
1513            worker_id: self.worker_id,
1514        });
1515        let owned_ops = ops.clone();
1516
1517        let shape = pbo_shape(width, height, format);
1518
1519        let pbo_tensor =
1520            edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops)
1521                .map_err(|e| Error::OpenGl(format!("PBO tensor creation failed: {e:?}")))?;
1522        let mut tensor = edgefirst_tensor::Tensor::from_pbo(pbo_tensor)
1523            .map_err(|e| Error::OpenGl(format!("PBO tensor wrap failed: {e:?}")))?;
1524        tensor
1525            .set_format(format)
1526            .map_err(|e| Error::OpenGl(format!("Failed to set format on PBO tensor: {e:?}")))?;
1527        // Register the PBO with CUDA (best-effort; no-op without libcudart) so
1528        // `cuda_map()` can yield a device pointer — matching the float PBO path
1529        // in `create_pbo_image_dtype`. Without this, u8 PBOs were never
1530        // CUDA-interop-capable, so the codec's nvJPEG backend (and any CUDA
1531        // consumer) could not use a `create_image`-allocated PBO destination.
1532        // The i8 transmute by the caller preserves the attached handle.
1533        register_pbo_cuda(&mut tensor, buffer_id, size, sender);
1534        self.owned_pbos.insert(tensor.pbo_vtable_ptr(), &owned_ops);
1535        Ok(tensor)
1536    }
1537
1538    /// Create a PBO-backed [`TensorDyn`] image on the GL thread with the given dtype.
1539    ///
1540    /// Sizes the underlying GL buffer by `elems * dtype.size()` and wraps it in
1541    /// the appropriately-typed [`PboTensor`]. Supports `DType::U8`, `DType::F16`,
1542    /// and `DType::F32`; returns an error for other dtypes.
1543    pub(crate) fn create_pbo_image_dtype(
1544        &self,
1545        width: usize,
1546        height: usize,
1547        format: edgefirst_tensor::PixelFormat,
1548        dtype: edgefirst_tensor::DType,
1549    ) -> Result<TensorDyn, Error> {
1550        let sender = self
1551            .sender
1552            .as_ref()
1553            .ok_or(Error::OpenGl("GL processor is shutting down".to_string()))?;
1554
1555        let elems = pbo_elem_count(width, height, format)
1556            .filter(|&e| e != 0)
1557            .ok_or_else(|| Error::OpenGl("Invalid image dimensions".to_string()))?;
1558
1559        let size = elems
1560            .checked_mul(dtype.size())
1561            .ok_or_else(|| Error::OpenGl("PBO size overflow".to_string()))?;
1562
1563        // Allocate PBO on the GL thread
1564        let (tx, rx) = tokio::sync::oneshot::channel();
1565        sender
1566            .blocking_send(GLProcessorMessage::PboCreate(size, tx))
1567            .map_err(|_| Error::OpenGl("GL thread channel closed".to_string()))?;
1568        let buffer_id = rx
1569            .blocking_recv()
1570            .map_err(|_| Error::OpenGl("GL thread did not respond".to_string()))??;
1571
1572        let ops: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1573            sender: sender.downgrade(),
1574            worker_id: self.worker_id,
1575        });
1576        let owned_ops = ops.clone();
1577
1578        let shape = pbo_shape(width, height, format);
1579
1580        let map_err = |e: edgefirst_tensor::Error| {
1581            Error::OpenGl(format!("PBO tensor creation failed: {e:?}"))
1582        };
1583        let set_err = |e: edgefirst_tensor::Error| {
1584            Error::OpenGl(format!("Failed to set format on PBO tensor: {e:?}"))
1585        };
1586
1587        let tensor = match dtype {
1588            edgefirst_tensor::DType::U8 => {
1589                let pbo =
1590                    edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops)
1591                        .map_err(map_err)?;
1592                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1593                t.set_format(format).map_err(set_err)?;
1594                register_pbo_cuda(&mut t, buffer_id, size, sender);
1595                TensorDyn::from(t)
1596            }
1597            edgefirst_tensor::DType::F16 => {
1598                let pbo = edgefirst_tensor::PboTensor::<edgefirst_tensor::f16>::from_pbo(
1599                    buffer_id, size, &shape, None, ops,
1600                )
1601                .map_err(map_err)?;
1602                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1603                t.set_format(format).map_err(set_err)?;
1604                register_pbo_cuda(&mut t, buffer_id, size, sender);
1605                TensorDyn::from(t)
1606            }
1607            edgefirst_tensor::DType::F32 => {
1608                let pbo = edgefirst_tensor::PboTensor::<f32>::from_pbo(
1609                    buffer_id, size, &shape, None, ops,
1610                )
1611                .map_err(map_err)?;
1612                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1613                t.set_format(format).map_err(set_err)?;
1614                register_pbo_cuda(&mut t, buffer_id, size, sender);
1615                TensorDyn::from(t)
1616            }
1617            other => return Err(Error::OpenGl(format!("unsupported PBO dtype {other:?}"))),
1618        };
1619        self.owned_pbos.insert(tensor.pbo_vtable_ptr(), &owned_ops);
1620        Ok(tensor)
1621    }
1622
1623    /// Returns the active transfer backend.
1624    pub(crate) fn transfer_backend(&self) -> TransferBackend {
1625        self.caps.transfer_backend
1626    }
1627
1628    /// Report which float dtypes the GPU can render to.
1629    ///
1630    /// Values are probed once at construction time and adjusted for
1631    /// Vivante GC7000UL, whose float readback latency (170-320 ms) makes
1632    /// GL float destinations impractical; `ImageProcessor::convert()` falls
1633    /// back to CPU float output (normalized to `[0, 1]`) for these targets.
1634    pub(crate) fn supported_render_dtypes(&self) -> crate::RenderDtypeSupport {
1635        self.caps.render_dtypes
1636    }
1637}
1638
1639impl Drop for GLProcessorThreaded {
1640    fn drop(&mut self) {
1641        // Closing the channel ends the worker loop, which drops the
1642        // `GLProcessorST` it owns; that `Drop` pays any device submission an
1643        // open batch still owes. The join is what makes it ordered before this
1644        // returns -- and it has to happen on the worker, which is the thread
1645        // holding the context.
1646        drop(self.sender.take());
1647        let _ = self.handle.take().and_then(|h| h.join().ok());
1648    }
1649}
1650
1651// `pbo_elem_count` / `pbo_shape` are pure (no GL), so they are unit-testable
1652// without a GPU. The overflow→None arm of `pbo_elem_count` guards against an
1653// undersized PBO allocation, so it is worth pinning explicitly.
1654#[cfg(test)]
1655#[cfg_attr(coverage_nightly, coverage(off))]
1656mod tests {
1657    use super::{pbo_elem_count, pbo_shape, GLProcessorThreaded, GlPboOps};
1658    use edgefirst_tensor::{PixelFormat, TensorTrait};
1659
1660    #[test]
1661    fn elem_count_per_format() {
1662        // Packed RGBA: w*h*4.
1663        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Rgba), Some(8 * 4 * 4));
1664        // Packed RGB: w*h*3.
1665        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Rgb), Some(8 * 4 * 3));
1666        // NV12 semiplanar: w*h*3/2.
1667        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Nv12), Some(8 * 4 * 3 / 2));
1668        // NV16 semiplanar: w*h*2.
1669        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Nv16), Some(8 * 4 * 2));
1670    }
1671
1672    #[test]
1673    fn elem_count_overflow_is_none() {
1674        // w*h already overflows usize → None (never a wrapped, undersized count).
1675        assert_eq!(pbo_elem_count(usize::MAX, 2, PixelFormat::Rgba), None);
1676        // w*h fits but *channels overflows → None.
1677        assert_eq!(pbo_elem_count(usize::MAX, 1, PixelFormat::Rgb), None);
1678    }
1679
1680    #[test]
1681    fn shape_per_format() {
1682        // Planar: [channels, height, width].
1683        assert_eq!(pbo_shape(8, 4, PixelFormat::PlanarRgb), vec![3, 4, 8]);
1684        // SemiPlanar NV12: [height*3/2, width].
1685        assert_eq!(pbo_shape(8, 4, PixelFormat::Nv12), vec![4 * 3 / 2, 8]);
1686        // SemiPlanar NV16: [height*2, width].
1687        assert_eq!(pbo_shape(8, 4, PixelFormat::Nv16), vec![4 * 2, 8]);
1688        // Packed: [height, width, channels].
1689        assert_eq!(pbo_shape(8, 4, PixelFormat::Rgba), vec![4, 8, 4]);
1690    }
1691
1692    /// Regression test for the cross-handle PBO map race task 11's review
1693    /// found: `PboHandle::acquire_map`'s per-handle `Mutex<MapState>`
1694    /// (`edgefirst-tensor`) has no visibility into a SEPARATE `PboHandle`
1695    /// for the same real `buffer_id` -- exactly what a cross-cdylib
1696    /// reconstructed consumer tensor now is, alongside the producer's own
1697    /// tensor, since task 11 wired a real `kind::PBO` `import_descriptor`
1698    /// arm. Simulates that with two independent `PboTensor`s built
1699    /// directly (rather than through the full descriptor/vtable round
1700    /// trip, which needs a second compiled `.so` to be meaningful) that
1701    /// both point at the SAME real GL buffer via a fresh [`GlPboOps`]
1702    /// wrapping the SAME sender -- exactly the relationship `import_descriptor`'s
1703    /// PBO arm produces between a producer's handle and a reconstructed
1704    /// one. Without `mapped_pbo_buffers`, the second map would reach
1705    /// `glMapBufferRange` on an already-mapped buffer (undefined
1706    /// behaviour); this asserts it is refused instead, and that the
1707    /// refusal is a real, releasable reservation rather than a permanently
1708    /// stuck `buffer_id`.
1709    #[test]
1710    #[cfg(any(target_os = "linux", target_os = "windows"))] // PBO destinations: Linux + Windows
1711    fn concurrent_maps_from_two_independent_handles_on_one_buffer_do_not_corrupt_gl_state() {
1712        let gl = match GLProcessorThreaded::new(None) {
1713            Ok(gl) => gl,
1714            Err(e) => {
1715                crate::test_support::report_skip(&format!(
1716                    "concurrent_maps_from_two_independent_handles... - \
1717                     OpenGL not available: {e:?}"
1718                ));
1719                return;
1720            }
1721        };
1722        let tensor_a = match gl.create_pbo_image(64, 64, PixelFormat::Rgba) {
1723            Ok(t) => t,
1724            Err(e) => {
1725                crate::test_support::report_skip(&format!(
1726                    "concurrent_maps_from_two_independent_handles... - \
1727                     PBO not supported: {e:?}"
1728                ));
1729                return;
1730            }
1731        };
1732        let buffer_id = tensor_a.pbo_id().expect("just created a PBO tensor");
1733        let size = tensor_a.capacity_bytes();
1734        let shape = tensor_a.shape().to_vec();
1735
1736        // A second, independent PboHandle for the IDENTICAL real buffer --
1737        // same relationship a producer's handle and a reconstructed
1738        // consumer's handle have to the same GL context.
1739        let ops_b: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1740            sender: gl.sender.as_ref().unwrap().downgrade(),
1741            worker_id: gl.worker_id,
1742        });
1743        let tensor_b =
1744            edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops_b)
1745                .expect("from_pbo against a real, already-created buffer_id must succeed");
1746
1747        // tensor_a maps first and holds the mapping open.
1748        let map_a = tensor_a.map().expect("first map must succeed");
1749
1750        // tensor_b's own Mutex<MapState> starts at Unmapped and knows
1751        // nothing about tensor_a's outstanding mapping -- without the
1752        // GL-thread-level fix this reaches glMapBufferRange a second time.
1753        let map_b_result = tensor_b.map();
1754        // `glMapBufferRange` on an already-mapped buffer is UNDEFINED
1755        // BEHAVIOUR per the GL spec, not guaranteed to fail cleanly on
1756        // every driver -- so this asserts the SPECIFIC refusal
1757        // `mapped_pbo_buffers` produces (matching this file's own error
1758        // text), not merely "some error occurred", which a
1759        // driver-dependent null return could also produce and would make
1760        // this test pass even with the fix reverted.
1761        let Err(map_b_err) = map_b_result else {
1762            panic!(
1763                "a second, independent handle mapping the SAME real buffer while the \
1764                     first mapping is still open must be refused, not silently succeed"
1765            );
1766        };
1767        let msg = format!("{map_b_err:?}");
1768        assert!(
1769            msg.contains("already mapped"),
1770            "must be refused specifically by the GL-thread-level reservation check \
1771             (mapped_pbo_buffers), not some other error path: {msg}"
1772        );
1773
1774        drop(map_a);
1775
1776        // Once the first mapping releases, the SAME second handle must be
1777        // able to map successfully -- proves the refusal above was a real,
1778        // releasable reservation, not a permanently stuck buffer_id.
1779        let map_b_after = tensor_b.map();
1780        assert!(
1781            map_b_after.is_ok(),
1782            "after the first handle's mapping releases, the second handle must be able to \
1783             map the same buffer: {map_b_after:?}"
1784        );
1785    }
1786}