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