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