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