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") => false,
452        _ => caps.serialize_gl,
453    };
454    log::debug!(
455        "GL serialization policy: {}",
456        if serialize_per_msg {
457            "Full (per-message global lock)"
458        } else {
459            "LifecycleOnly (parallel across processors)"
460        }
461    );
462    while let Some(msg) = recv.blocking_recv() {
463        // Full policy: one processor's message at a time, process-wide.
464        // Linux locks GL_MUTEX (messages must also exclude the locked
465        // lifecycle operations — on Vivante a convert racing a context
466        // bring-up is part of the driver bug class). macOS has no
467        // lifecycle lock (ANGLE serializes display entry points
468        // internally), so message-vs-message exclusion suffices —
469        // needed on virtualized GPUs, where concurrent GL across
470        // contexts mis-renders (paravirtual Metal, macOS CI runners).
471        #[cfg(target_os = "linux")]
472        let _guard = serialize_per_msg.then(|| {
473            super::context::GL_MUTEX
474                .lock()
475                .unwrap_or_else(|e| e.into_inner())
476        });
477        #[cfg(any(
478            target_os = "macos",
479            target_os = "ios",
480            target_os = "android",
481            target_os = "windows"
482        ))]
483        let _guard =
484            serialize_per_msg.then(|| NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner()));
485
486        // After a panic, the GL context is in an undefined state. Reject
487        // all subsequent messages with an error rather than risking wrong
488        // output or a GPU hang from corrupted GL state. This follows the
489        // same pattern as std::sync::Mutex poisoning.
490        if poisoned {
491            reject_poisoned_message(msg);
492            continue;
493        }
494
495        // Platform pre-message hook (ANGLE/D3D11 re-syncs this context's
496        // state when another processor's context issued the last commands;
497        // a no-op elsewhere). Runs under the lock taken above.
498        gl_converter.begin_gpu_pass();
499        handle_gl_message(
500            msg,
501            &mut gl_converter,
502            &mut mapped_pbo_buffers,
503            &mut poisoned,
504        );
505        // Per-pass platform texture attachments (macOS pbuffer
506        // binds) are released once the message's GPU work has
507        // synced; deferred batches keep theirs until Flush.
508        gl_converter.end_gpu_pass_if_synced();
509    }
510    // Explicitly drop under the mutex so EGL teardown is serialized
511    // (Linux, and Windows for the D3D11 reason above; ANGLE teardown is
512    // display-internal on macOS).
513    #[cfg(target_os = "linux")]
514    let _guard = super::context::GL_MUTEX
515        .lock()
516        .unwrap_or_else(|e| e.into_inner());
517    #[cfg(target_os = "windows")]
518    let _guard = NON_LINUX_GL_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
519    drop(gl_converter);
520}
521
522/// Per-message serialization lock for the platforms without a Linux-style
523/// `GL_MUTEX` (macOS/iOS, Android, Windows) — taken per message under the
524/// Full policy, and on Windows also around processor bring-up/teardown.
525#[cfg(not(target_os = "linux"))]
526static NON_LINUX_GL_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
527
528fn handle_gl_message(
529    msg: GLProcessorMessage,
530    gl_converter: &mut GLProcessorST,
531    mapped_pbo_buffers: &mut std::collections::HashSet<u32>,
532    poisoned: &mut bool,
533) {
534    match msg {
535        GLProcessorMessage::ImageConvert(src, dst, rotation, flip, crop, defer, resp) => {
536            handle_image_convert(
537                src,
538                dst,
539                (rotation, flip, crop, defer),
540                resp,
541                gl_converter,
542                poisoned,
543            );
544        }
545        GLProcessorMessage::ImageConvertFenced(src, dst, rotation, flip, crop, resp) => {
546            handle_image_convert_fenced(
547                src,
548                dst,
549                (rotation, flip, crop),
550                resp,
551                gl_converter,
552                poisoned,
553            );
554        }
555        GLProcessorMessage::Flush(resp) => {
556            let result = std::panic::catch_unwind(AssertUnwindSafe(|| gl_converter.flush()));
557            reply_caught(result, resp, poisoned, "Flush");
558        }
559        GLProcessorMessage::DrawDecodedMasks(
560            dst,
561            det,
562            seg,
563            opacity,
564            bg,
565            letterbox,
566            color_mode,
567            resp,
568        ) => {
569            handle_draw_decoded_masks(
570                dst,
571                det,
572                seg,
573                (opacity, bg, letterbox, color_mode),
574                resp,
575                gl_converter,
576                poisoned,
577            );
578        }
579        GLProcessorMessage::DrawProtoMasks(
580            dst,
581            det,
582            proto_data,
583            opacity,
584            bg,
585            letterbox,
586            color_mode,
587            resp,
588        ) => {
589            handle_draw_proto_masks(
590                dst,
591                det,
592                proto_data,
593                (opacity, bg, letterbox, color_mode),
594                resp,
595                gl_converter,
596                poisoned,
597            );
598        }
599        GLProcessorMessage::SetColors(colors, resp) => {
600            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
601                gl_converter.set_class_colors(&colors)
602            }));
603            reply_caught(result, resp, poisoned, "SetColors");
604        }
605        GLProcessorMessage::SetColorimetryMode(mode, resp) => {
606            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
607                gl_converter.set_colorimetry_mode(mode);
608                Ok(())
609            }));
610            reply_caught(result, resp, poisoned, "SetColorimetryMode");
611        }
612        GLProcessorMessage::SetInt8Interpolation(mode, resp) => {
613            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
614                gl_converter.set_int8_interpolation_mode(mode);
615                Ok(())
616            }));
617            reply_caught(result, resp, poisoned, "SetInt8Interpolation");
618        }
619        GLProcessorMessage::EglCacheStats(resp) => {
620            let result =
621                std::panic::catch_unwind(AssertUnwindSafe(|| Ok(gl_converter.egl_cache_stats())));
622            reply_caught(result, resp, poisoned, "EglCacheStats");
623        }
624        GLProcessorMessage::ConvertStats(resp) => {
625            let result =
626                std::panic::catch_unwind(AssertUnwindSafe(|| Ok(gl_converter.convert_stats())));
627            reply_caught(result, resp, poisoned, "ConvertStats");
628        }
629        GLProcessorMessage::PboCreate(size, resp) => {
630            handle_pbo_create(size, resp, poisoned);
631        }
632        GLProcessorMessage::PboMap(buffer_id, size, resp) => {
633            handle_pbo_map(buffer_id, size, resp, mapped_pbo_buffers, poisoned);
634        }
635        GLProcessorMessage::PboUnmap(buffer_id, resp) => {
636            handle_pbo_unmap(buffer_id, resp, mapped_pbo_buffers, poisoned);
637        }
638        GLProcessorMessage::PboDelete(buffer_id) => {
639            handle_pbo_delete(buffer_id, mapped_pbo_buffers, poisoned);
640        }
641        GLProcessorMessage::CudaRegisterBuffer(buffer_id, resp) => {
642            // CUDA GL-interop must run on the GL-context thread.
643            let _ = resp.send(edgefirst_tensor::gl_register_buffer(buffer_id));
644        }
645        GLProcessorMessage::CudaMap(resource, resp) => {
646            // Auto-flush: if a batched `convert_deferred` is still
647            // owed its single GPU sync, complete it before CUDA maps
648            // the buffer — otherwise the device could read a render
649            // still in flight. This makes `cuda_map()` correct on a
650            // batched output with no API change (the caller pays the
651            // sync it would have owed anyway).
652            gl_converter.flush_pending();
653            let _ = resp.send(edgefirst_tensor::gl_map_resource(resource));
654        }
655        GLProcessorMessage::CudaUnmap(resource) => {
656            edgefirst_tensor::gl_unmap_resource(resource);
657        }
658        GLProcessorMessage::CudaUnregister(resource) => {
659            edgefirst_tensor::gl_unregister_resource(resource);
660        }
661    }
662}
663
664fn handle_image_convert(
665    src: SendablePtr<TensorDyn>,
666    mut dst: SendablePtr<TensorDyn>,
667    geom: (Rotation, Flip, Crop, bool),
668    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
669    gl_converter: &mut GLProcessorST,
670    poisoned: &mut bool,
671) {
672    let (rotation, flip, crop, defer) = geom;
673    // SAFETY: This is safe because the convert() function waits for the resp to
674    // be sent before dropping the borrow for src and dst
675    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
676        let src = unsafe { src.ptr.as_ref() };
677        let dst = unsafe { dst.ptr.as_mut() };
678        // A deferred convert skips the per-tile glFinish and
679        // leaves a single sync owed at Flush; an eager convert
680        // finishes as usual.
681        if defer {
682            gl_converter.convert_deferred(src, dst, rotation, flip, crop)
683        } else {
684            gl_converter.convert(src, dst, rotation, flip, crop)
685        }
686    }));
687    reply_caught(result, resp, poisoned, "ImageConvert");
688}
689
690fn handle_image_convert_fenced(
691    src: SendablePtr<TensorDyn>,
692    mut dst: SendablePtr<TensorDyn>,
693    geom: (Rotation, Flip, Crop),
694    resp: tokio::sync::oneshot::Sender<Result<Option<super::CompletionFence>, Error>>,
695    gl_converter: &mut GLProcessorST,
696    poisoned: &mut bool,
697) {
698    let (rotation, flip, crop) = geom;
699    // SAFETY: as ImageConvert — convert_with_fence()
700    // blocks on the reply before the borrows drop.
701    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
702        let src = unsafe { src.ptr.as_ref() };
703        let dst = unsafe { dst.ptr.as_mut() };
704        gl_converter.convert_fenced(src, dst, rotation, flip, crop)
705    }));
706    reply_caught(result, resp, poisoned, "ImageConvertFenced");
707}
708
709fn handle_draw_decoded_masks(
710    mut dst: SendablePtr<TensorDyn>,
711    det: SendablePtr<DetectBox>,
712    seg: SendablePtr<Segmentation>,
713    overlay: (
714        f32,
715        Option<SendablePtr<TensorDyn>>,
716        Option<[f32; 4]>,
717        crate::ColorMode,
718    ),
719    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
720    gl_converter: &mut GLProcessorST,
721    poisoned: &mut bool,
722) {
723    let (opacity, bg, letterbox, color_mode) = overlay;
724    // SAFETY: This is safe because the draw_decoded_masks() function waits for the
725    // resp to be sent before dropping the borrow for dst, detect,
726    // segmentation, and background
727    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
728        let dst = unsafe { dst.ptr.as_mut() };
729        let det = unsafe { std::slice::from_raw_parts(det.ptr.as_ptr(), det.len) };
730        let seg = unsafe { std::slice::from_raw_parts(seg.ptr.as_ptr(), seg.len) };
731        let bg_ref = bg.map(|p| unsafe { &*p.ptr.as_ptr() });
732        gl_converter.draw_decoded_masks(
733            dst,
734            det,
735            seg,
736            crate::MaskOverlay {
737                background: bg_ref,
738                opacity,
739                letterbox,
740                color_mode,
741            },
742        )
743    }));
744    reply_caught(result, resp, poisoned, "DrawDecodedMasks");
745}
746
747fn handle_draw_proto_masks(
748    mut dst: SendablePtr<TensorDyn>,
749    det: SendablePtr<DetectBox>,
750    proto_data: SendablePtr<ProtoData>,
751    overlay: (
752        f32,
753        Option<SendablePtr<TensorDyn>>,
754        Option<[f32; 4]>,
755        crate::ColorMode,
756    ),
757    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
758    gl_converter: &mut GLProcessorST,
759    poisoned: &mut bool,
760) {
761    let (opacity, bg, letterbox, color_mode) = overlay;
762    // SAFETY: Same safety invariant as DrawDecodedMasks — caller
763    // blocks on resp before dropping borrows.
764    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
765        let dst = unsafe { dst.ptr.as_mut() };
766        let det = unsafe { std::slice::from_raw_parts(det.ptr.as_ptr(), det.len) };
767        let bg_ref = bg.map(|p| unsafe { &*p.ptr.as_ptr() });
768        let proto_data = unsafe { proto_data.ptr.as_ref() };
769        gl_converter.draw_proto_masks(
770            dst,
771            det,
772            proto_data,
773            crate::MaskOverlay {
774                background: bg_ref,
775                opacity,
776                letterbox,
777                color_mode,
778            },
779        )
780    }));
781    reply_caught(result, resp, poisoned, "DrawProtoMasks");
782}
783
784fn handle_pbo_create(
785    size: usize,
786    resp: tokio::sync::oneshot::Sender<Result<u32, Error>>,
787    poisoned: &mut bool,
788) {
789    let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
790        let mut id: u32 = 0;
791        edgefirst_gl::gl::GenBuffers(1, &mut id);
792        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, id);
793        edgefirst_gl::gl::BufferData(
794            edgefirst_gl::gl::PIXEL_PACK_BUFFER,
795            size as isize,
796            std::ptr::null(),
797            edgefirst_gl::gl::STREAM_COPY,
798        );
799        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
800        match check_gl_error("PboCreate", 0) {
801            Ok(()) => Ok(id),
802            Err(e) => {
803                edgefirst_gl::gl::DeleteBuffers(1, &id);
804                Err(e)
805            }
806        }
807    }));
808    reply_caught(result, resp, poisoned, "PboCreate");
809}
810
811fn handle_pbo_map(
812    buffer_id: u32,
813    size: usize,
814    resp: tokio::sync::oneshot::Sender<Result<edgefirst_tensor::PboMapping, Error>>,
815    mapped_pbo_buffers: &mut std::collections::HashSet<u32>,
816    poisoned: &mut bool,
817) {
818    if !mapped_pbo_buffers.insert(buffer_id) {
819        // Already mapped by SOME PboHandle against this
820        // context -- refuse rather than call
821        // glMapBufferRange a second time (undefined
822        // behaviour). See `mapped_pbo_buffers`'s own
823        // doc comment above.
824        let _ = resp.send(Err(crate::Error::OpenGl(format!(
825            "PBO buffer {buffer_id} is already mapped in this GL context \
826             (a producer and a reconstructed cross-package consumer may \
827             both hold a live tensor over it)"
828        ))));
829        return;
830    }
831    let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
832        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, buffer_id);
833        let ptr = edgefirst_gl::gl::MapBufferRange(
834            edgefirst_gl::gl::PIXEL_PACK_BUFFER,
835            0,
836            size as isize,
837            edgefirst_gl::gl::MAP_READ_BIT | edgefirst_gl::gl::MAP_WRITE_BIT,
838        );
839        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
840        if ptr.is_null() {
841            Err(crate::Error::OpenGl(
842                "glMapBufferRange returned null".to_string(),
843            ))
844        } else {
845            Ok(edgefirst_tensor::PboMapping {
846                ptr: ptr as *mut u8,
847                size,
848            })
849        }
850    }));
851    let mapped_ok = matches!(result, Ok(Ok(_)));
852    if !mapped_ok {
853        // The map did not actually take -- release the
854        // reservation so a retry (or the OTHER handle
855        // that lost this race) is not permanently
856        // blocked by a mapping that never happened.
857        mapped_pbo_buffers.remove(&buffer_id);
858    }
859    reply_caught(result, resp, poisoned, "PboMap");
860}
861
862fn handle_pbo_unmap(
863    buffer_id: u32,
864    resp: tokio::sync::oneshot::Sender<Result<(), Error>>,
865    mapped_pbo_buffers: &mut std::collections::HashSet<u32>,
866    poisoned: &mut bool,
867) {
868    // Always release the reservation, regardless of
869    // whether the GL unmap itself reports success below
870    // -- mirrors `PboHandle::release_map`'s own
871    // "state returns to Unmapped either way" contract
872    // (edgefirst-tensor), so a failed unmap cannot
873    // permanently strand this buffer_id as "mapped".
874    mapped_pbo_buffers.remove(&buffer_id);
875    let result = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
876        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, buffer_id);
877        let ok = edgefirst_gl::gl::UnmapBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER);
878        edgefirst_gl::gl::BindBuffer(edgefirst_gl::gl::PIXEL_PACK_BUFFER, 0);
879        if ok == edgefirst_gl::gl::FALSE {
880            Err(Error::OpenGl(
881                "PBO data was corrupted during mapping".into(),
882            ))
883        } else {
884            check_gl_error("PboUnmap", 0)
885        }
886    }));
887    reply_caught(result, resp, poisoned, "PboUnmap");
888}
889
890fn handle_pbo_delete(
891    buffer_id: u32,
892    mapped_pbo_buffers: &mut std::collections::HashSet<u32>,
893    poisoned: &mut bool,
894) {
895    // Defensive: a live mapping should never reach here
896    // (PboHandle::Drop unmaps before deleting), but a
897    // stale reservation for a since-deleted (and
898    // possibly buffer-id-recycled) buffer would
899    // otherwise wrongly block every future map of a NEW
900    // buffer that reuses the same id.
901    mapped_pbo_buffers.remove(&buffer_id);
902    if let Err(e) = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
903        edgefirst_gl::gl::DeleteBuffers(1, &buffer_id);
904    })) {
905        *poisoned = true;
906        log::error!(
907            "GL thread panicked during PboDelete: {}",
908            panic_message(e.as_ref()),
909        );
910    }
911}
912
913impl GLProcessorThreaded {
914    /// Creates a new OpenGL multi-threaded image converter whose import
915    /// caches use the environment/default capacity.
916    pub fn new(kind: Option<EglDisplayKind>) -> Result<Self, Error> {
917        Self::with_cache_capacity(kind, None)
918    }
919
920    /// As [`new`](Self::new), with an explicit per-cache import capacity —
921    /// [`ImageProcessorConfig::egl_cache_capacity`], which documents what the
922    /// number bounds and takes precedence over the environment.
923    ///
924    /// [`ImageProcessorConfig::egl_cache_capacity`]: crate::ImageProcessorConfig::egl_cache_capacity
925    pub fn with_cache_capacity(
926        kind: Option<EglDisplayKind>,
927        capacity: Option<usize>,
928    ) -> Result<Self, Error> {
929        let (send, recv) = tokio::sync::mpsc::channel::<GLProcessorMessage>(1);
930
931        let (create_ctx_send, create_ctx_recv) = tokio::sync::oneshot::channel();
932
933        let handle = std::thread::spawn(move || {
934            run_gl_worker(kind, capacity, recv, create_ctx_send);
935        });
936
937        let caps = match create_ctx_recv.blocking_recv() {
938            Ok(Err(e)) => return Err(e),
939            Err(_) => {
940                return Err(Error::Internal(
941                    "GL converter error messaging closed without update".to_string(),
942                ));
943            }
944            Ok(Ok(caps)) => caps,
945        };
946
947        Ok(Self {
948            handle: Some(handle),
949            sender: Some(send),
950            caps,
951        })
952    }
953}
954
955impl ImageProcessorTrait for GLProcessorThreaded {
956    fn convert(
957        &mut self,
958        src: &TensorDyn,
959        dst: &mut TensorDyn,
960        rotation: crate::Rotation,
961        flip: Flip,
962        crop: Crop,
963    ) -> crate::Result<()> {
964        let (err_send, err_recv) = tokio::sync::oneshot::channel();
965        self.sender
966            .as_ref()
967            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
968            .blocking_send(GLProcessorMessage::ImageConvert(
969                SendablePtr {
970                    ptr: NonNull::from(src),
971                    len: 1,
972                },
973                SendablePtr {
974                    ptr: NonNull::from(dst),
975                    len: 1,
976                },
977                rotation,
978                flip,
979                crop,
980                false, // eager: finish per convert
981                err_send,
982            ))
983            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
984        err_recv.blocking_recv().map_err(|_| {
985            Error::Internal("GL converter error messaging closed without update".to_string())
986        })?
987    }
988
989    fn convert_deferred(
990        &mut self,
991        src: &TensorDyn,
992        dst: &mut TensorDyn,
993        rotation: crate::Rotation,
994        flip: Flip,
995        crop: Crop,
996    ) -> crate::Result<()> {
997        let (err_send, err_recv) = tokio::sync::oneshot::channel();
998        self.sender
999            .as_ref()
1000            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1001            .blocking_send(GLProcessorMessage::ImageConvert(
1002                SendablePtr {
1003                    ptr: NonNull::from(src),
1004                    len: 1,
1005                },
1006                SendablePtr {
1007                    ptr: NonNull::from(dst),
1008                    len: 1,
1009                },
1010                rotation,
1011                flip,
1012                crop,
1013                true, // defer: skip glFinish; flush() syncs the batch once
1014                err_send,
1015            ))
1016            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1017        err_recv.blocking_recv().map_err(|_| {
1018            Error::Internal("GL converter error messaging closed without update".to_string())
1019        })?
1020    }
1021
1022    fn flush(&mut self) -> crate::Result<()> {
1023        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1024        self.sender
1025            .as_ref()
1026            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1027            .blocking_send(GLProcessorMessage::Flush(err_send))
1028            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1029        err_recv.blocking_recv().map_err(|_| {
1030            Error::Internal("GL converter error messaging closed without update".to_string())
1031        })?
1032    }
1033
1034    fn draw_decoded_masks(
1035        &mut self,
1036        dst: &mut TensorDyn,
1037        detect: &[crate::DetectBox],
1038        segmentation: &[crate::Segmentation],
1039        overlay: crate::MaskOverlay<'_>,
1040    ) -> crate::Result<()> {
1041        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1042        self.sender
1043            .as_ref()
1044            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1045            .blocking_send(GLProcessorMessage::DrawDecodedMasks(
1046                SendablePtr {
1047                    ptr: NonNull::from(dst),
1048                    len: 1,
1049                },
1050                SendablePtr {
1051                    ptr: NonNull::new(detect.as_ptr() as *mut DetectBox).unwrap(),
1052                    len: detect.len(),
1053                },
1054                SendablePtr {
1055                    ptr: NonNull::new(segmentation.as_ptr() as *mut Segmentation).unwrap(),
1056                    len: segmentation.len(),
1057                },
1058                overlay.opacity,
1059                overlay.background.map(|bg| SendablePtr {
1060                    ptr: NonNull::from(bg).cast::<TensorDyn>(),
1061                    len: 1,
1062                }),
1063                overlay.letterbox,
1064                overlay.color_mode,
1065                err_send,
1066            ))
1067            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1068        err_recv.blocking_recv().map_err(|_| {
1069            Error::Internal("GL converter error messaging closed without update".to_string())
1070        })?
1071    }
1072
1073    fn draw_proto_masks(
1074        &mut self,
1075        dst: &mut TensorDyn,
1076        detect: &[DetectBox],
1077        proto_data: &ProtoData,
1078        overlay: crate::MaskOverlay<'_>,
1079    ) -> crate::Result<()> {
1080        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1081        self.sender
1082            .as_ref()
1083            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1084            .blocking_send(GLProcessorMessage::DrawProtoMasks(
1085                SendablePtr {
1086                    ptr: NonNull::from(dst),
1087                    len: 1,
1088                },
1089                SendablePtr {
1090                    ptr: NonNull::new(detect.as_ptr() as *mut DetectBox).unwrap(),
1091                    len: detect.len(),
1092                },
1093                SendablePtr {
1094                    ptr: NonNull::from(proto_data).cast::<ProtoData>(),
1095                    len: 1,
1096                },
1097                overlay.opacity,
1098                overlay.background.map(|bg| SendablePtr {
1099                    ptr: NonNull::from(bg).cast::<TensorDyn>(),
1100                    len: 1,
1101                }),
1102                overlay.letterbox,
1103                overlay.color_mode,
1104                err_send,
1105            ))
1106            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1107        err_recv.blocking_recv().map_err(|_| {
1108            Error::Internal("GL converter error messaging closed without update".to_string())
1109        })?
1110    }
1111
1112    fn set_class_colors(&mut self, colors: &[[u8; 4]]) -> Result<(), crate::Error> {
1113        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1114        self.sender
1115            .as_ref()
1116            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1117            .blocking_send(GLProcessorMessage::SetColors(colors.to_vec(), err_send))
1118            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1119        err_recv.blocking_recv().map_err(|_| {
1120            Error::Internal("GL converter error messaging closed without update".to_string())
1121        })?
1122    }
1123}
1124
1125impl GLProcessorThreaded {
1126    /// Sets the colorimetry/performance trade-off (see
1127    /// [`crate::ColorimetryMode`]). The `EDGEFIRST_COLORIMETRY` environment
1128    /// variable takes precedence — when set, this call logs and keeps the
1129    /// env-selected mode.
1130    pub fn set_colorimetry_mode(&mut self, mode: crate::ColorimetryMode) -> Result<(), Error> {
1131        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1132        self.sender
1133            .as_ref()
1134            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1135            .blocking_send(GLProcessorMessage::SetColorimetryMode(mode, err_send))
1136            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1137        err_recv.blocking_recv().map_err(|_| {
1138            Error::Internal("GL converter error messaging closed without update".to_string())
1139        })?
1140    }
1141
1142    /// Sets the interpolation mode for int8 proto textures.
1143    pub fn set_int8_interpolation_mode(
1144        &mut self,
1145        mode: Int8InterpolationMode,
1146    ) -> Result<(), crate::Error> {
1147        let (err_send, err_recv) = tokio::sync::oneshot::channel();
1148        self.sender
1149            .as_ref()
1150            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1151            .blocking_send(GLProcessorMessage::SetInt8Interpolation(mode, err_send))
1152            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1153        err_recv.blocking_recv().map_err(|_| {
1154            Error::Internal("GL converter error messaging closed without update".to_string())
1155        })?
1156    }
1157
1158    /// Snapshot the EGLImage cache counters (src, dst, NV R8) from the GL
1159    /// thread. Steady-state tests capture this after warmup and after an
1160    /// N-frame loop over a fixed buffer pool and assert
1161    /// [`total_misses`](super::cache::GlCacheStats::total_misses) stays flat —
1162    /// any increase means a convert re-imported a buffer it should have found
1163    /// cached (the cache-behavior equality gate for GL refactors).
1164    pub fn egl_cache_stats(&self) -> Result<super::cache::GlCacheStats, Error> {
1165        let (send, recv) = tokio::sync::oneshot::channel();
1166        self.sender
1167            .as_ref()
1168            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1169            .blocking_send(GLProcessorMessage::EglCacheStats(send))
1170            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1171        recv.blocking_recv().map_err(|_| {
1172            Error::Internal("GL converter error messaging closed without update".to_string())
1173        })?
1174    }
1175
1176    /// Convert and return a native sync-fence fd that signals when the
1177    /// GPU work completes, instead of blocking the CPU on `glFinish` —
1178    /// the GL→NPU handoff (`EGL_ANDROID_native_fence_sync`).
1179    ///
1180    /// `Ok(None)` means the convert completed with the normal blocking
1181    /// semantics (platform/driver has no native fence, or a pure-CPU-
1182    /// readback path was taken) — the destination is already safe to
1183    /// read. `Ok(Some(fd))` means the caller (or the NPU runtime, via
1184    /// `ANeuralNetworksExecution_startComputeWithDependencies`) must wait
1185    /// on the fd before consuming the destination buffer.
1186    pub fn convert_with_fence(
1187        &mut self,
1188        src: &TensorDyn,
1189        dst: &mut TensorDyn,
1190        rotation: crate::Rotation,
1191        flip: Flip,
1192        crop: Crop,
1193    ) -> Result<Option<super::CompletionFence>, Error> {
1194        use crate::ImageProcessorTrait as _;
1195        if !self.caps.native_fence_sync {
1196            // No fence on this display: the plain blocking convert IS the
1197            // fenced contract with `None` — skip the special round-trip.
1198            self.convert(src, dst, rotation, flip, crop)?;
1199            return Ok(None);
1200        }
1201        let (send, recv) = tokio::sync::oneshot::channel();
1202        self.sender
1203            .as_ref()
1204            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1205            .blocking_send(GLProcessorMessage::ImageConvertFenced(
1206                SendablePtr {
1207                    ptr: NonNull::from(src),
1208                    len: 1,
1209                },
1210                SendablePtr {
1211                    ptr: NonNull::from(dst),
1212                    len: 1,
1213                },
1214                rotation,
1215                flip,
1216                crop,
1217                send,
1218            ))
1219            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1220        recv.blocking_recv().map_err(|_| {
1221            Error::Internal("GL converter error messaging closed without update".to_string())
1222        })?
1223    }
1224
1225    /// Snapshot the per-convert source-feed counters from the GL thread.
1226    /// A pipeline that expects end-to-end zero-copy asserts
1227    /// [`src_uploads`](super::cache::ConvertStats::src_uploads) stays flat
1228    /// (every frame fed by import, never by a CPU map + upload).
1229    pub fn convert_stats(&self) -> Result<super::cache::ConvertStats, Error> {
1230        let (send, recv) = tokio::sync::oneshot::channel();
1231        self.sender
1232            .as_ref()
1233            .ok_or_else(|| Error::Internal("GL processor is shutting down".to_string()))?
1234            .blocking_send(GLProcessorMessage::ConvertStats(send))
1235            .map_err(|_| Error::Internal("GL converter thread exited".to_string()))?;
1236        recv.blocking_recv().map_err(|_| {
1237            Error::Internal("GL converter error messaging closed without update".to_string())
1238        })?
1239    }
1240
1241    /// Create a PBO-backed [`Tensor<u8>`](edgefirst_tensor::Tensor) image on the GL thread.
1242    pub fn create_pbo_image(
1243        &self,
1244        width: usize,
1245        height: usize,
1246        format: edgefirst_tensor::PixelFormat,
1247    ) -> Result<edgefirst_tensor::Tensor<u8>, Error> {
1248        let sender = self
1249            .sender
1250            .as_ref()
1251            .ok_or(Error::OpenGl("GL processor is shutting down".to_string()))?;
1252
1253        let size = pbo_elem_count(width, height, format)
1254            .filter(|&s| s != 0)
1255            .ok_or_else(|| Error::OpenGl("Invalid image dimensions".to_string()))?;
1256
1257        // Allocate PBO on the GL thread
1258        let (tx, rx) = tokio::sync::oneshot::channel();
1259        sender
1260            .blocking_send(GLProcessorMessage::PboCreate(size, tx))
1261            .map_err(|_| Error::OpenGl("GL thread channel closed".to_string()))?;
1262        let buffer_id = rx
1263            .blocking_recv()
1264            .map_err(|_| Error::OpenGl("GL thread did not respond".to_string()))??;
1265
1266        let ops: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1267            sender: sender.downgrade(),
1268        });
1269
1270        let shape = pbo_shape(width, height, format);
1271
1272        let pbo_tensor =
1273            edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops)
1274                .map_err(|e| Error::OpenGl(format!("PBO tensor creation failed: {e:?}")))?;
1275        let mut tensor = edgefirst_tensor::Tensor::from_pbo(pbo_tensor)
1276            .map_err(|e| Error::OpenGl(format!("PBO tensor wrap failed: {e:?}")))?;
1277        tensor
1278            .set_format(format)
1279            .map_err(|e| Error::OpenGl(format!("Failed to set format on PBO tensor: {e:?}")))?;
1280        // Register the PBO with CUDA (best-effort; no-op without libcudart) so
1281        // `cuda_map()` can yield a device pointer — matching the float PBO path
1282        // in `create_pbo_image_dtype`. Without this, u8 PBOs were never
1283        // CUDA-interop-capable, so the codec's nvJPEG backend (and any CUDA
1284        // consumer) could not use a `create_image`-allocated PBO destination.
1285        // The i8 transmute by the caller preserves the attached handle.
1286        register_pbo_cuda(&mut tensor, buffer_id, size, sender);
1287        Ok(tensor)
1288    }
1289
1290    /// Create a PBO-backed [`TensorDyn`] image on the GL thread with the given dtype.
1291    ///
1292    /// Sizes the underlying GL buffer by `elems * dtype.size()` and wraps it in
1293    /// the appropriately-typed [`PboTensor`]. Supports `DType::U8`, `DType::F16`,
1294    /// and `DType::F32`; returns an error for other dtypes.
1295    pub(crate) fn create_pbo_image_dtype(
1296        &self,
1297        width: usize,
1298        height: usize,
1299        format: edgefirst_tensor::PixelFormat,
1300        dtype: edgefirst_tensor::DType,
1301    ) -> Result<TensorDyn, Error> {
1302        let sender = self
1303            .sender
1304            .as_ref()
1305            .ok_or(Error::OpenGl("GL processor is shutting down".to_string()))?;
1306
1307        let elems = pbo_elem_count(width, height, format)
1308            .filter(|&e| e != 0)
1309            .ok_or_else(|| Error::OpenGl("Invalid image dimensions".to_string()))?;
1310
1311        let size = elems
1312            .checked_mul(dtype.size())
1313            .ok_or_else(|| Error::OpenGl("PBO size overflow".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 map_err = |e: edgefirst_tensor::Error| {
1331            Error::OpenGl(format!("PBO tensor creation failed: {e:?}"))
1332        };
1333        let set_err = |e: edgefirst_tensor::Error| {
1334            Error::OpenGl(format!("Failed to set format on PBO tensor: {e:?}"))
1335        };
1336
1337        match dtype {
1338            edgefirst_tensor::DType::U8 => {
1339                let pbo =
1340                    edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops)
1341                        .map_err(map_err)?;
1342                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1343                t.set_format(format).map_err(set_err)?;
1344                register_pbo_cuda(&mut t, buffer_id, size, sender);
1345                Ok(TensorDyn::from(t))
1346            }
1347            edgefirst_tensor::DType::F16 => {
1348                let pbo = edgefirst_tensor::PboTensor::<edgefirst_tensor::f16>::from_pbo(
1349                    buffer_id, size, &shape, None, ops,
1350                )
1351                .map_err(map_err)?;
1352                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1353                t.set_format(format).map_err(set_err)?;
1354                register_pbo_cuda(&mut t, buffer_id, size, sender);
1355                Ok(TensorDyn::from(t))
1356            }
1357            edgefirst_tensor::DType::F32 => {
1358                let pbo = edgefirst_tensor::PboTensor::<f32>::from_pbo(
1359                    buffer_id, size, &shape, None, ops,
1360                )
1361                .map_err(map_err)?;
1362                let mut t = edgefirst_tensor::Tensor::from_pbo(pbo).map_err(map_err)?;
1363                t.set_format(format).map_err(set_err)?;
1364                register_pbo_cuda(&mut t, buffer_id, size, sender);
1365                Ok(TensorDyn::from(t))
1366            }
1367            other => Err(Error::OpenGl(format!("unsupported PBO dtype {other:?}"))),
1368        }
1369    }
1370
1371    /// Returns the active transfer backend.
1372    pub(crate) fn transfer_backend(&self) -> TransferBackend {
1373        self.caps.transfer_backend
1374    }
1375
1376    /// Report which float dtypes the GPU can render to.
1377    ///
1378    /// Values are probed once at construction time and adjusted for
1379    /// Vivante GC7000UL, whose float readback latency (170-320 ms) makes
1380    /// GL float destinations impractical; `ImageProcessor::convert()` falls
1381    /// back to CPU float output (normalized to `[0, 1]`) for these targets.
1382    pub(crate) fn supported_render_dtypes(&self) -> crate::RenderDtypeSupport {
1383        self.caps.render_dtypes
1384    }
1385}
1386
1387impl Drop for GLProcessorThreaded {
1388    fn drop(&mut self) {
1389        drop(self.sender.take());
1390        let _ = self.handle.take().and_then(|h| h.join().ok());
1391    }
1392}
1393
1394// `pbo_elem_count` / `pbo_shape` are pure (no GL), so they are unit-testable
1395// without a GPU. The overflow→None arm of `pbo_elem_count` guards against an
1396// undersized PBO allocation, so it is worth pinning explicitly.
1397#[cfg(test)]
1398#[cfg_attr(coverage_nightly, coverage(off))]
1399mod tests {
1400    use super::{pbo_elem_count, pbo_shape, GLProcessorThreaded, GlPboOps};
1401    use edgefirst_tensor::{PixelFormat, TensorTrait};
1402
1403    #[test]
1404    fn elem_count_per_format() {
1405        // Packed RGBA: w*h*4.
1406        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Rgba), Some(8 * 4 * 4));
1407        // Packed RGB: w*h*3.
1408        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Rgb), Some(8 * 4 * 3));
1409        // NV12 semiplanar: w*h*3/2.
1410        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Nv12), Some(8 * 4 * 3 / 2));
1411        // NV16 semiplanar: w*h*2.
1412        assert_eq!(pbo_elem_count(8, 4, PixelFormat::Nv16), Some(8 * 4 * 2));
1413    }
1414
1415    #[test]
1416    fn elem_count_overflow_is_none() {
1417        // w*h already overflows usize → None (never a wrapped, undersized count).
1418        assert_eq!(pbo_elem_count(usize::MAX, 2, PixelFormat::Rgba), None);
1419        // w*h fits but *channels overflows → None.
1420        assert_eq!(pbo_elem_count(usize::MAX, 1, PixelFormat::Rgb), None);
1421    }
1422
1423    #[test]
1424    fn shape_per_format() {
1425        // Planar: [channels, height, width].
1426        assert_eq!(pbo_shape(8, 4, PixelFormat::PlanarRgb), vec![3, 4, 8]);
1427        // SemiPlanar NV12: [height*3/2, width].
1428        assert_eq!(pbo_shape(8, 4, PixelFormat::Nv12), vec![4 * 3 / 2, 8]);
1429        // SemiPlanar NV16: [height*2, width].
1430        assert_eq!(pbo_shape(8, 4, PixelFormat::Nv16), vec![4 * 2, 8]);
1431        // Packed: [height, width, channels].
1432        assert_eq!(pbo_shape(8, 4, PixelFormat::Rgba), vec![4, 8, 4]);
1433    }
1434
1435    /// Regression test for the cross-handle PBO map race task 11's review
1436    /// found: `PboHandle::acquire_map`'s per-handle `Mutex<MapState>`
1437    /// (`edgefirst-tensor`) has no visibility into a SEPARATE `PboHandle`
1438    /// for the same real `buffer_id` -- exactly what a cross-cdylib
1439    /// reconstructed consumer tensor now is, alongside the producer's own
1440    /// tensor, since task 11 wired a real `kind::PBO` `import_descriptor`
1441    /// arm. Simulates that with two independent `PboTensor`s built
1442    /// directly (rather than through the full descriptor/vtable round
1443    /// trip, which needs a second compiled `.so` to be meaningful) that
1444    /// both point at the SAME real GL buffer via a fresh [`GlPboOps`]
1445    /// wrapping the SAME sender -- exactly the relationship `import_descriptor`'s
1446    /// PBO arm produces between a producer's handle and a reconstructed
1447    /// one. Without `mapped_pbo_buffers`, the second map would reach
1448    /// `glMapBufferRange` on an already-mapped buffer (undefined
1449    /// behaviour); this asserts it is refused instead, and that the
1450    /// refusal is a real, releasable reservation rather than a permanently
1451    /// stuck `buffer_id`.
1452    #[test]
1453    #[cfg(any(target_os = "linux", target_os = "windows"))] // PBO destinations: Linux + Windows
1454    fn concurrent_maps_from_two_independent_handles_on_one_buffer_do_not_corrupt_gl_state() {
1455        let gl = match GLProcessorThreaded::new(None) {
1456            Ok(gl) => gl,
1457            Err(e) => {
1458                eprintln!(
1459                    "SKIPPED: concurrent_maps_from_two_independent_handles... - \
1460                     OpenGL not available: {e:?}"
1461                );
1462                return;
1463            }
1464        };
1465        let tensor_a = match gl.create_pbo_image(64, 64, PixelFormat::Rgba) {
1466            Ok(t) => t,
1467            Err(e) => {
1468                eprintln!(
1469                    "SKIPPED: concurrent_maps_from_two_independent_handles... - \
1470                     PBO not supported: {e:?}"
1471                );
1472                return;
1473            }
1474        };
1475        let pbo_a = tensor_a.as_pbo().expect("just created a PBO tensor");
1476        let buffer_id = pbo_a.buffer_id();
1477        let size = tensor_a.capacity_bytes();
1478        let shape = tensor_a.shape().to_vec();
1479
1480        // A second, independent PboHandle for the IDENTICAL real buffer --
1481        // same relationship a producer's handle and a reconstructed
1482        // consumer's handle have to the same GL context.
1483        let ops_b: std::sync::Arc<dyn edgefirst_tensor::PboOps> = std::sync::Arc::new(GlPboOps {
1484            sender: gl.sender.as_ref().unwrap().downgrade(),
1485        });
1486        let tensor_b =
1487            edgefirst_tensor::PboTensor::<u8>::from_pbo(buffer_id, size, &shape, None, ops_b)
1488                .expect("from_pbo against a real, already-created buffer_id must succeed");
1489
1490        // tensor_a maps first and holds the mapping open.
1491        let map_a = tensor_a.map().expect("first map must succeed");
1492
1493        // tensor_b's own Mutex<MapState> starts at Unmapped and knows
1494        // nothing about tensor_a's outstanding mapping -- without the
1495        // GL-thread-level fix this reaches glMapBufferRange a second time.
1496        let map_b_result = tensor_b.map();
1497        // `glMapBufferRange` on an already-mapped buffer is UNDEFINED
1498        // BEHAVIOUR per the GL spec, not guaranteed to fail cleanly on
1499        // every driver -- so this asserts the SPECIFIC refusal
1500        // `mapped_pbo_buffers` produces (matching this file's own error
1501        // text), not merely "some error occurred", which a
1502        // driver-dependent null return could also produce and would make
1503        // this test pass even with the fix reverted.
1504        let Err(map_b_err) = map_b_result else {
1505            panic!(
1506                "a second, independent handle mapping the SAME real buffer while the \
1507                     first mapping is still open must be refused, not silently succeed"
1508            );
1509        };
1510        let msg = format!("{map_b_err:?}");
1511        assert!(
1512            msg.contains("already mapped"),
1513            "must be refused specifically by the GL-thread-level reservation check \
1514             (mapped_pbo_buffers), not some other error path: {msg}"
1515        );
1516
1517        drop(map_a);
1518
1519        // Once the first mapping releases, the SAME second handle must be
1520        // able to map successfully -- proves the refusal above was a real,
1521        // releasable reservation, not a permanently stuck buffer_id.
1522        let map_b_after = tensor_b.map();
1523        assert!(
1524            map_b_after.is_ok(),
1525            "after the first handle's mapping releases, the second handle must be able to \
1526             map the same buffer: {map_b_after:?}"
1527        );
1528    }
1529}