Skip to main content

edgefirst_tensor/
pbo.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::{
5    error::{Error, Result},
6    BufferIdentity, IdentityKind, TensorMemory, TensorTrait,
7};
8use log::trace;
9use num_traits::Num;
10use std::{
11    ffi::c_void,
12    fmt,
13    marker::PhantomData,
14    ops::Deref,
15    ptr::NonNull,
16    sync::{Arc, Condvar, Mutex},
17};
18// Used by `PboOpsVtable` and friends (the cross-cdylib capsule protocol's
19// PBO support), which are compiled into both backends since task P2a — see
20// that type's own doc comment.
21use std::{os::raw::c_int, sync::OnceLock};
22
23/// Raw mapped pointer from a PBO. CPU-accessible while the buffer is mapped.
24/// The pointer is only valid between map and unmap calls.
25pub struct PboMapping {
26    pub ptr: *mut u8,
27    pub size: usize,
28}
29
30// SAFETY: PboMapping is only created by PboOps::map_buffer which runs on the
31// GL thread, but the resulting pointer is used on the caller's thread. This is
32// safe because glMapBufferRange returns a CPU-visible pointer that can be
33// accessed from any thread while the buffer remains mapped.
34unsafe impl Send for PboMapping {}
35
36/// Trait for PBO GL operations, implemented by the image crate.
37///
38/// All methods are blocking — they send commands to the GL thread
39/// and wait for completion. Implementations must ensure GL context
40/// is current on the thread that executes the actual GL calls.
41///
42/// # Safety
43///
44/// Implementations must ensure:
45/// - `map_buffer` returns a valid, aligned pointer to `size` bytes of
46///   CPU-accessible memory that remains valid until `unmap_buffer` is called.
47/// - `unmap_buffer` invalidates the pointer and releases the mapping.
48/// - `delete_buffer` frees the GL buffer resources.
49pub unsafe trait PboOps: Send + Sync {
50    /// Map the PBO for CPU read/write access.
51    /// The returned PboMapping is valid until `unmap_buffer` is called.
52    fn map_buffer(&self, buffer_id: u32, size: usize) -> Result<PboMapping>;
53
54    /// Unmap a previously mapped PBO. Must be called before GL operations
55    /// on this buffer (GLES 3.0 requirement).
56    fn unmap_buffer(&self, buffer_id: u32) -> Result<()>;
57
58    /// Delete the PBO. Fire-and-forget — no reply needed.
59    /// Called from PboTensor's Drop impl.
60    fn delete_buffer(&self, buffer_id: u32);
61}
62
63/// C-ABI-safe callback table for a PBO's GL operations — the same
64/// technique `tensor-capi`'s `EfTensorVtable` uses to let one
65/// independently-compiled `cdylib` dispatch into a tensor implementation it
66/// did not compile, applied here to [`PboOps`].
67///
68/// **Why `PboOps` itself cannot cross a `cdylib` boundary.** `Arc<dyn
69/// PboOps>`'s vtable pointer addresses a table Rust generates per
70/// (concrete-type, trait) pair at compile time — its exact layout is not
71/// part of any stable ABI. Two independently-compiled `cdylib`s (e.g.
72/// `edgefirst.image`'s and `edgefirst.codec`'s Python extension modules,
73/// each statically linking its own copy of this crate — confirmed by their
74/// separate `.so` files on disk, one `edgefirst-tensor` copy embedded in
75/// each) are not guaranteed to agree on that layout, and Rust's own "no
76/// stable ABI" position is exactly why `tensor-capi` exists at all for
77/// tensors. A same-process-only registry (buffer_id -> `Arc<dyn PboOps>`,
78/// the design this replaces) does not solve this: it would need to be a
79/// process-wide `static`, and `crates/tensor/tests/no_global_state.rs`
80/// exists specifically to reject that class of state, with the exact
81/// cross-package PBO scenario named in its own docstring as the motivating
82/// example. `#[repr(C)]` plus `extern "C" fn` pointers are the one
83/// representation the platform C ABI guarantees is stable across separate
84/// compilations, so that is what crosses instead.
85///
86/// **Lifetime, and why there is no registry, no leak, and nothing to
87/// unregister.** A `PboOpsVtable` is built once, lazily, as a field of the
88/// `PboHandle` whose `ops` it dispatches into (see [`PboTensor::pbo_vtable`]),
89/// and its address is only ever handed out through
90/// [`crate::TensorDesc::ptr`] under [`crate::protocol::kind::PBO`] — valid
91/// for exactly as long as that field's existing contract already promises
92/// (the producer's capsule keepalive holding the producing tensor alive;
93/// see [`crate::TensorDyn::import_descriptor`]'s doc comment, which already
94/// establishes this for every other kind's use of `ptr`). This struct's own
95/// address IS the `PboHandle`'s address plus a fixed field offset, so it
96/// disappears exactly when `PboHandle` does — no separate allocation to
97/// leak, no registry entry to remove on drop.
98///
99/// Compiled into both backends. It was `static`-only while the
100/// cross-package capsule protocol had no `dynamic` counterpart; task P2a
101/// gave it one (`TensorDyn::descriptor_pinned`/`import_descriptor` now
102/// exist on both), so the gate came off. Nothing here is
103/// backend-specific — it is a C-ABI view onto `PboOps`, which was always
104/// a backend-agnostic GL extension point.
105#[repr(C)]
106pub struct PboOpsVtable {
107    /// Opaque to every caller except this struct's own function pointers —
108    /// a caller crossing a `cdylib` boundary calls through them and never
109    /// dereferences `ctx` itself.
110    ctx: *const c_void,
111    map_buffer_fn: unsafe extern "C" fn(
112        ctx: *const c_void,
113        buffer_id: u32,
114        size: usize,
115        out_ptr: *mut *mut u8,
116        out_len: *mut usize,
117    ) -> c_int,
118    unmap_buffer_fn: unsafe extern "C" fn(ctx: *const c_void, buffer_id: u32) -> c_int,
119    // No `delete_buffer_fn`: deliberately absent, not merely unused. A
120    // consumer reconstructing a `PboTensor` from this vtable does not own
121    // the GL buffer -- it borrows it for the descriptor's lifetime (see
122    // `ImportedPboOps`'s own doc comment) -- so there is no legitimate
123    // caller for it on that side, and the producer's own `PboHandle::Drop`
124    // already calls the real `delete_buffer` directly through its own
125    // `Arc<dyn PboOps>`, never through this vtable. Including a pointer
126    // nothing may safely call is worse than omitting it.
127}
128
129// SAFETY: every field is either a raw pointer this struct never
130// dereferences itself (only its own `extern "C" fn`s do, and those forward
131// into `PboOps`, itself `Send + Sync`) or a plain function pointer — both
132// safe to share and move across threads.
133unsafe impl Send for PboOpsVtable {}
134unsafe impl Sync for PboOpsVtable {}
135
136impl PboOpsVtable {
137    /// Build a vtable dispatching into `ops`. `ctx` addresses `ops` itself,
138    /// so `ops` must already be at its final, stable address — a field of a
139    /// `PboHandle` that already lives inside its own `Arc` (see
140    /// [`PboTensor::pbo_vtable`]), since nothing here pins or moves it.
141    fn new(ops: &Arc<dyn PboOps>) -> Self {
142        PboOpsVtable {
143            ctx: ops as *const Arc<dyn PboOps> as *const c_void,
144            map_buffer_fn: vt_map_buffer,
145            unmap_buffer_fn: vt_unmap_buffer,
146        }
147    }
148
149    /// # Safety
150    /// `self` must be a table this module built (via [`Self::new`]) whose
151    /// `ctx` is still live — the `PboHandle` it addresses must not have
152    /// dropped. A raw pointer reconstructed from a [`crate::TensorDesc`]
153    /// satisfies this for exactly as long as that descriptor's own borrow
154    /// contract does.
155    unsafe fn map_buffer(&self, buffer_id: u32, size: usize) -> Result<PboMapping> {
156        let mut ptr: *mut u8 = std::ptr::null_mut();
157        let mut len: usize = 0;
158        // SAFETY: caller's contract above.
159        let rc = unsafe { (self.map_buffer_fn)(self.ctx, buffer_id, size, &mut ptr, &mut len) };
160        if rc != 0 {
161            return Err(vtable_errno_to_error(rc));
162        }
163        Ok(PboMapping { ptr, size: len })
164    }
165
166    /// # Safety
167    /// Same as [`Self::map_buffer`].
168    unsafe fn unmap_buffer(&self, buffer_id: u32) -> Result<()> {
169        // SAFETY: caller's contract above.
170        let rc = unsafe { (self.unmap_buffer_fn)(self.ctx, buffer_id) };
171        if rc != 0 {
172            return Err(vtable_errno_to_error(rc));
173        }
174        Ok(())
175    }
176}
177
178/// `0` success; `-1` the GL context is gone ([`Error::PboDisconnected`]);
179/// any other value a generic failure. This boundary is internal to the
180/// cross-package protocol (not `tensor-capi`'s public C ABI), so it does
181/// not need POSIX errno parity — only enough to distinguish "gone" (retry
182/// is pointless) from "the underlying `PboOps` call itself returned `Err`".
183/// `0` success; `-1` the GL context is gone ([`Error::PboDisconnected`]);
184/// any other value a generic failure, deliberately without the real
185/// variant or message. This boundary is internal to the cross-package
186/// protocol (not `tensor-capi`'s public C ABI), so it does not need POSIX
187/// errno parity -- only enough to distinguish "gone" (retry is pointless)
188/// from "the underlying `PboOps` call itself returned some other `Err`".
189///
190/// **Tried and reverted: a `tensor-capi`-style thread-local "last error"
191/// string.** `tensor-capi`'s `ef_tensor_last_error_message` works precisely
192/// because `libedgefirst_tensor.so` is the ONE shared library every
193/// consumer dynamically links -- one compiled copy, one thread-local slot,
194/// genuinely shared. `edgefirst-tensor` is the opposite: this crate is
195/// statically linked separately into every consumer (the whole reason this
196/// vtable exists at all), so a `thread_local!` declared here gets a
197/// SEPARATE storage slot per linked copy -- the producer's `vt_map_buffer`
198/// (compiled into the producer's `.so`) would write into ITS copy's slot,
199/// and the consumer's `vtable_errno_to_error` (compiled into a DIFFERENT
200/// `.so`) would read its OWN, never-written slot. It would silently work
201/// in a same-process test and silently do nothing across the real `.so`
202/// boundary this whole file exists for -- worse than the coarse errno it
203/// would replace. A real fix needs the message to travel as plain data
204/// (an out-param the producer writes into caller-owned memory, with its
205/// own ownership/lifetime convention) -- real engineering, not the "cheap"
206/// fix this was flagged as; left as coarse on purpose rather than shipping
207/// something that looks richer and is not.
208fn vtable_errno_to_error(rc: c_int) -> Error {
209    match rc {
210        -1 => Error::PboDisconnected,
211        n => Error::NotImplemented(format!("cross-package PBO operation failed: errno {n}")),
212    }
213}
214
215fn error_to_vtable_errno(e: &Error) -> c_int {
216    match e {
217        Error::PboDisconnected => -1,
218        _ => 1,
219    }
220}
221
222/// The `extern "C"` half of [`PboOpsVtable::new`]'s two dispatch
223/// pointers — generic over any `Arc<dyn PboOps>`, so no producer needs to
224/// write its own. `catch_unwind`-shielded: a panic unwinding across an
225/// `extern "C"` boundary is undefined behaviour, the same reasoning
226/// `tensor-capi`'s own exported functions are built around.
227unsafe extern "C" fn vt_map_buffer(
228    ctx: *const c_void,
229    buffer_id: u32,
230    size: usize,
231    out_ptr: *mut *mut u8,
232    out_len: *mut usize,
233) -> c_int {
234    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
235        // SAFETY: the caller's contract (`PboOpsVtable::map_buffer`)
236        // guarantees `ctx` still addresses a live `Arc<dyn PboOps>`.
237        let ops = unsafe { &*(ctx as *const Arc<dyn PboOps>) };
238        ops.map_buffer(buffer_id, size)
239    }));
240    match result {
241        Ok(Ok(mapping)) => {
242            // SAFETY: `out_ptr`/`out_len` are valid out-params for the
243            // duration of this call, per this function's own contract with
244            // its caller (`PboOpsVtable::map_buffer`).
245            unsafe {
246                *out_ptr = mapping.ptr;
247                *out_len = mapping.size;
248            }
249            0
250        }
251        Ok(Err(e)) => error_to_vtable_errno(&e),
252        Err(_) => 1, // shielded panic: report a generic failure, not UB
253    }
254}
255
256unsafe extern "C" fn vt_unmap_buffer(ctx: *const c_void, buffer_id: u32) -> c_int {
257    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
258        // SAFETY: see `vt_map_buffer`.
259        let ops = unsafe { &*(ctx as *const Arc<dyn PboOps>) };
260        ops.unmap_buffer(buffer_id)
261    }));
262    match result {
263        Ok(Ok(())) => 0,
264        Ok(Err(e)) => error_to_vtable_errno(&e),
265        Err(_) => 1,
266    }
267}
268
269/// Adapts a [`PboOpsVtable`] reconstructed from a cross-package
270/// [`crate::TensorDesc`] back into a [`PboOps`] implementation, so
271/// [`PboTensor::from_pbo`] needs no separate cross-package constructor —
272/// [`import_pbo_ops`] builds the exact same `Arc<dyn PboOps>` shape this
273/// crate already uses for a same-process PBO.
274///
275/// `delete_buffer` is deliberately a no-op: this tensor does not own the
276/// GL buffer, it borrows it for the descriptor's lifetime (the producer's
277/// own capsule-keepalive contract), so dropping it must not delete a
278/// buffer the producer's own tensor may still be using. The producer's own
279/// `PboHandle::Drop` is the only thing that ever calls the real
280/// `delete_buffer`.
281struct ImportedPboOps {
282    vtable: NonNull<PboOpsVtable>,
283}
284
285// SAFETY: the vtable this addresses is `Send + Sync` (see its own impl),
286// and this struct never mutates through the pointer, only calls through
287// its function pointers, which are themselves safe to call from any thread
288// (see `PboOps`'s own trait-level contract, which the real implementation
289// behind this vtable already upholds).
290unsafe impl Send for ImportedPboOps {}
291unsafe impl Sync for ImportedPboOps {}
292
293// SAFETY: forwards every call through `PboOpsVtable`'s own dispatch, which
294// forwards into a real `PboOps` implementation upholding this trait's
295// contract already — this wrapper adds no new unsafety beyond the pointer
296// dereference `import_pbo_ops`'s own safety contract covers.
297unsafe impl PboOps for ImportedPboOps {
298    fn map_buffer(&self, buffer_id: u32, size: usize) -> Result<PboMapping> {
299        // SAFETY: `import_pbo_ops` (this type's only constructor) upholds
300        // `PboOpsVtable::map_buffer`'s safety contract.
301        unsafe { self.vtable.as_ref().map_buffer(buffer_id, size) }
302    }
303
304    fn unmap_buffer(&self, buffer_id: u32) -> Result<()> {
305        // SAFETY: see `map_buffer` above.
306        unsafe { self.vtable.as_ref().unmap_buffer(buffer_id) }
307    }
308
309    fn delete_buffer(&self, _buffer_id: u32) {
310        // No-op: see this type's own doc comment.
311    }
312}
313
314/// Reconstruct a live [`PboOps`] from a [`crate::TensorDesc::ptr`] carrying
315/// a [`PboOpsVtable`] address under [`crate::protocol::kind::PBO`].
316///
317/// # Safety
318/// `vtable_ptr` must be a genuine `*const PboOpsVtable` this module built
319/// (i.e. it came from a real `TensorDesc.ptr` under `kind::PBO`, not a
320/// forged or reinterpreted value), and the `PboHandle` it addresses must
321/// stay alive for as long as the returned `Arc<dyn PboOps>` (and anything
322/// built from it) is used — the same capsule-keepalive contract
323/// `TensorDesc::ptr` already documents for every other kind.
324pub(crate) unsafe fn import_pbo_ops(vtable_ptr: *const c_void) -> Result<Arc<dyn PboOps>> {
325    let Some(vtable) = NonNull::new(vtable_ptr as *mut PboOpsVtable) else {
326        return Err(Error::InvalidArgument(
327            "PBO descriptor carries no ops vtable".into(),
328        ));
329    };
330    Ok(Arc::new(ImportedPboOps { vtable }))
331}
332
333/// Opaque handle to a PBO's GL resources.
334struct PboHandle {
335    ops: Arc<dyn PboOps>,
336    buffer_id: u32,
337    size: usize,
338    map_state: Mutex<MapState>,
339    /// Wakes threads waiting out an in-flight GL map/unmap. Needed because
340    /// `map_state` is deliberately NOT held across `PboOps` calls — see
341    /// [`PboHandle::acquire_map`].
342    map_cv: Condvar,
343    /// C-ABI vtable dispatching into `ops`, for cross-cdylib export via
344    /// [`crate::TensorDesc`]. Built once, lazily, on first
345    /// [`PboTensor::pbo_vtable`] call — see [`PboOpsVtable`]'s own doc
346    /// comment for why this exists and what it replaces (a same-process-only
347    /// registry, which cannot cross a `cdylib` boundary at all). Present on
348    /// both backends — see [`PboOpsVtable`]'s own doc comment.
349    abi_vtable: OnceLock<PboOpsVtable>,
350}
351
352/// CPU-map state of a PBO's single GL buffer.
353///
354/// A GL buffer has exactly one mapping at a time -- but **this state
355/// machine enforces that only within one `PboHandle`**, including its own
356/// read-sharing (below); it has no visibility into a *different*
357/// `PboHandle` for the same real `buffer_id`. Two `PboHandle`s legitimately
358/// exist for one buffer today: a producer's own tensor and a tensor
359/// [`crate::TensorDyn::import_descriptor`]'s `kind::PBO` arm reconstructs
360/// from it cross-cdylib. What actually prevents *those* two from both
361/// reaching `glMapBufferRange` on the same buffer is a second, separate
362/// enforcement point downstream of this one: the real `PboOps`
363/// implementor's own serialization of every call against one real buffer
364/// (in `edgefirst-image`, `GLProcessorThreaded`'s message loop tracks
365/// outstanding `buffer_id`s and refuses a second concurrent `PboMap`,
366/// because its one GL thread is the only place every call -- from any
367/// `PboHandle`, in any linked copy of this crate -- actually converges).
368/// This state machine's own job stays exactly what it says below: same-
369/// handle mapping/read-sharing bookkeeping.
370///
371/// So these describe that one mapping's ownership rather than per-[`PboMap`] state:
372///
373/// * `Unmapped` — no CPU mapping exists.
374/// * `Exclusive` — one writable map holds it. Nothing else may map until
375///   that map drops.
376/// * `Shared` — `readers` read-only maps over ONE mapping, unmapped when
377///   the last of them drops.
378///
379/// Read sharing exists because tiled (SAHI) pre-processing has several
380/// worker threads convert different crops of the SAME source tensor
381/// concurrently, each taking `map_read()`. Read-only holders cannot observe
382/// one another's writes because there are none, so one mapping serves them
383/// all. Under a single-map rule every reader but one failed with
384/// [`Error::PboMapped`], which left callers no option but to serialize the
385/// workers that share a source.
386enum MapState {
387    Unmapped,
388    /// A thread is inside `PboOps::map_buffer` for this buffer right now.
389    /// Others must wait it out rather than start a second GL map.
390    Mapping,
391    Exclusive,
392    Shared {
393        readers: usize,
394        ptr: PboPtr,
395    },
396    /// A thread is inside `PboOps::unmap_buffer` for this buffer right now.
397    Unmapping,
398}
399
400impl PboHandle {
401    /// Acquire a CPU mapping of this buffer, returning the base pointer.
402    ///
403    /// Read-only acquisitions join an existing read-only mapping (bumping
404    /// its refcount) or create one. Writable acquisitions are exclusive.
405    /// Returns [`Error::PboMapped`] whenever the request cannot share what
406    /// is already held: anything against an exclusive map, or a writable
407    /// request against readers.
408    ///
409    /// # Locking
410    ///
411    /// `map_state` is NEVER held across a [`PboOps`] call. Those calls are
412    /// blocking round-trips to the GL thread, and the work the GL thread
413    /// runs (a convert, say) maps tensors itself — so a thread that waited
414    /// on GL while holding this mutex would deadlock against the very GL
415    /// thread it is waiting for. The in-flight GL call is published as
416    /// [`MapState::Mapping`] / [`MapState::Unmapping`] instead, and other
417    /// threads wait on `map_cv` until it resolves.
418    fn acquire_map(&self, writable: bool) -> Result<PboPtr> {
419        loop {
420            let mut guard = self.map_state.lock().expect("PBO map state mutex poisoned");
421            // Wait out any GL map/unmap another thread is mid-way through.
422            while matches!(*guard, MapState::Mapping | MapState::Unmapping) {
423                guard = self
424                    .map_cv
425                    .wait(guard)
426                    .expect("PBO map state mutex poisoned");
427            }
428            match &mut *guard {
429                MapState::Shared { readers, ptr } if !writable => {
430                    *readers += 1;
431                    return Ok(PboPtr(ptr.0));
432                }
433                MapState::Shared { .. } | MapState::Exclusive => return Err(Error::PboMapped),
434                // Ruled out by the wait above; re-check rather than assume.
435                MapState::Mapping | MapState::Unmapping => continue,
436                MapState::Unmapped => *guard = MapState::Mapping,
437            }
438            drop(guard);
439
440            // No lock held here — see this function's Locking note.
441            let mapped = self.ops.map_buffer(self.buffer_id, self.size);
442            let base = match mapped {
443                Ok(mapping) => NonNull::new(mapping.ptr as *mut c_void),
444                Err(e) => {
445                    self.finish_map(None, writable);
446                    return Err(e);
447                }
448            };
449            let Some(base) = base else {
450                // GL reported success but handed back a null pointer. It
451                // considers the buffer mapped, so release it rather than
452                // strand it mapped forever.
453                //
454                // Order matters: the cleanup unmap runs while this thread
455                // still holds the `Mapping` claim, so waiters stay parked
456                // and cannot start a fresh `map_buffer` that would race
457                // this unmap on the GL queue. Only once it has completed is
458                // `Unmapped` published.
459                let _ = self.ops.unmap_buffer(self.buffer_id);
460                self.finish_map(None, writable);
461                return Err(Error::InvalidSize(self.size));
462            };
463            self.finish_map(Some(base), writable);
464            return Ok(PboPtr(base));
465        }
466    }
467
468    /// Publish the outcome of a GL map this thread had claimed via
469    /// [`MapState::Mapping`], waking anyone who waited on it. `None` means
470    /// the map failed and the buffer is unmapped again.
471    fn finish_map(&self, base: Option<NonNull<c_void>>, writable: bool) {
472        let mut guard = self.map_state.lock().expect("PBO map state mutex poisoned");
473        *guard = match base {
474            None => MapState::Unmapped,
475            Some(_) if writable => MapState::Exclusive,
476            Some(base) => MapState::Shared {
477                readers: 1,
478                ptr: PboPtr(base),
479            },
480        };
481        self.map_cv.notify_all();
482    }
483
484    /// Release one acquisition, unmapping the GL buffer once the last
485    /// holder is gone. Same locking rule as [`Self::acquire_map`]: the GL
486    /// unmap runs with no lock held.
487    fn release_map(&self) {
488        let mut guard = self.map_state.lock().expect("PBO map state mutex poisoned");
489        let last_holder = match &mut *guard {
490            MapState::Shared { readers, .. } => {
491                *readers = readers.saturating_sub(1);
492                *readers == 0
493            }
494            MapState::Exclusive => true,
495            // Nothing held — a release with no matching acquire. (A caller
496            // holding an acquisition cannot observe Mapping/Unmapping:
497            // only the last holder enters Unmapping, and it is this one.)
498            MapState::Unmapped | MapState::Mapping | MapState::Unmapping => false,
499        };
500        if !last_holder {
501            return;
502        }
503        *guard = MapState::Unmapping;
504        drop(guard);
505
506        trace!("Unmapping PBO buffer_id={}", self.buffer_id);
507        if let Err(e) = self.ops.unmap_buffer(self.buffer_id) {
508            log::warn!("Failed to unmap PBO buffer {}: {e}", self.buffer_id);
509        }
510
511        let mut guard = self.map_state.lock().expect("PBO map state mutex poisoned");
512        *guard = MapState::Unmapped;
513        self.map_cv.notify_all();
514    }
515
516    /// Whether any CPU mapping is currently held or being established.
517    fn is_mapped(&self) -> bool {
518        !matches!(
519            *self.map_state.lock().expect("PBO map state mutex poisoned"),
520            MapState::Unmapped
521        )
522    }
523}
524
525impl Drop for PboHandle {
526    fn drop(&mut self) {
527        self.ops.delete_buffer(self.buffer_id);
528    }
529}
530
531/// A tensor backed by an OpenGL Pixel Buffer Object.
532pub struct PboTensor<T>
533where
534    T: Num + Clone + fmt::Debug + Send + Sync,
535{
536    pub name: String,
537    pub shape: Vec<usize>,
538    handle: Arc<PboHandle>,
539    identity: BufferIdentity,
540    /// Byte offset of this tensor's window into the shared GL buffer. Non-zero
541    /// only for sub-views (`view`/`batch`), which share the `Arc<PboHandle>` and
542    /// `BufferIdentity` and address a sub-region by this offset — mirrors
543    /// `DmaTensor::mmap_offset` / `IoSurfaceTensor::view_offset`.
544    pub(crate) view_offset: usize,
545    _marker: PhantomData<T>,
546}
547
548impl<T> fmt::Debug for PboTensor<T>
549where
550    T: Num + Clone + fmt::Debug + Send + Sync,
551{
552    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553        f.debug_struct("PboTensor")
554            .field("name", &self.name)
555            .field("shape", &self.shape)
556            .field("buffer_id", &self.handle.buffer_id)
557            .field("size", &self.handle.size)
558            .finish()
559    }
560}
561
562unsafe impl<T> Send for PboTensor<T> where T: Num + Clone + fmt::Debug + Send + Sync {}
563unsafe impl<T> Sync for PboTensor<T> where T: Num + Clone + fmt::Debug + Send + Sync {}
564
565impl<T> PboTensor<T>
566where
567    T: Num + Clone + fmt::Debug + Send + Sync,
568{
569    /// Create a new PBO tensor from an already-allocated GL buffer.
570    ///
571    /// Called by the image crate after creating the PBO on the GL thread.
572    /// Users should not call this directly — use `ImageProcessor::create_image()`.
573    ///
574    /// # Errors
575    ///
576    /// Returns `Error::ShapeMismatch` if `size` does not equal
577    /// `shape.iter().product::<usize>() * std::mem::size_of::<T>()`.
578    /// Returns `Error::InvalidSize` if `size` is zero.
579    pub fn from_pbo(
580        buffer_id: u32,
581        size: usize,
582        shape: &[usize],
583        name: Option<&str>,
584        ops: Arc<dyn PboOps>,
585    ) -> Result<Self> {
586        if size == 0 {
587            return Err(Error::InvalidSize(0));
588        }
589        let expected = shape.iter().product::<usize>() * std::mem::size_of::<T>();
590        // Allow `size >= expected`: PBOs allocated with a 64-byte-aligned row
591        // stride may be larger than the shape product.  Reject only if the
592        // allocation is strictly smaller than the logical content.
593        if size < expected {
594            return Err(Error::ShapeMismatch(format!(
595                "PBO size {size} is smaller than shape {shape:?} * sizeof({}) = {expected}",
596                std::any::type_name::<T>(),
597            )));
598        }
599        let name = name.unwrap_or("pbo_tensor").to_owned();
600        Ok(Self {
601            name,
602            shape: shape.to_vec(),
603            handle: Arc::new(PboHandle {
604                ops,
605                buffer_id,
606                size,
607                map_state: Mutex::new(MapState::Unmapped),
608                map_cv: Condvar::new(),
609                abi_vtable: OnceLock::new(),
610            }),
611            // A GL buffer name is meaningful only inside its creating
612            // context (no system-wide key exists), but it is unique among
613            // this context's live PBOs, which is exactly what the
614            // per-context import cache needs to key on.
615            identity: BufferIdentity::derived(IdentityKind::Pbo, buffer_id as u64),
616            view_offset: 0,
617            _marker: PhantomData,
618        })
619    }
620
621    /// Returns the GL buffer ID for this PBO.
622    pub fn buffer_id(&self) -> u32 {
623        self.handle.buffer_id
624    }
625
626    /// The C-ABI vtable dispatching into this PBO's `ops`, for cross-cdylib
627    /// export via [`crate::TensorDesc`]. See [`PboOpsVtable`]'s own doc
628    /// comment. Called from both backends' `pbo_vtable_ptr`
629    /// (`lib.rs`/`static_backend.rs`, and `dynamic_backend.rs` via
630    /// `with_pbo!`).
631    pub(crate) fn pbo_vtable(&self) -> &PboOpsVtable {
632        self.handle
633            .abi_vtable
634            .get_or_init(|| PboOpsVtable::new(&self.handle.ops))
635    }
636
637    /// A type-erased keepalive holding this PBO's `Arc<PboHandle>` alive --
638    /// the same shape as [`crate::pin::HostPin`]'s own `Keepalive` (an
639    /// `Arc<dyn Send + Sync>`), for the identical reason: [`PboOpsVtable::
640    /// new`] hands out `ctx`, a raw pointer straight into this `PboHandle`'s
641    /// own `ops` field, and that pointer is only valid for as long as the
642    /// `PboHandle` it addresses is alive. Cloning this `Arc` (not the
643    /// `PboHandle`'s *contents*) is what a cross-package capsule holds
644    /// alongside the descriptor, exactly the way `pin` already does for the
645    /// `HOST` kind -- see `TensorCapsulePayload` (`edgefirst-python-common`).
646    /// Type-erased so this crate's public surface never has to name
647    /// `PboHandle`, which stays private.
648    ///
649    /// Called from both backends' `pbo_keepalive`
650    /// (`lib.rs`/`static_backend.rs`, and `dynamic_backend.rs` via
651    /// `with_pbo!`).
652    pub(crate) fn pbo_keepalive(&self) -> Arc<dyn Send + Sync> {
653        self.handle.clone()
654    }
655
656    /// Returns true unless the PBO is fully unmapped.
657    ///
658    /// That covers an exclusive map, one or more read-only holders, AND a
659    /// map or unmap another thread currently has in flight — during those
660    /// windows the GL buffer is (or is about to be) mapped, and no other
661    /// mapping may begin, so reporting `false` would be misleading. Use it
662    /// as "is this buffer free for GL operations?", not as "does a CPU
663    /// pointer exist right now".
664    pub fn is_mapped(&self) -> bool {
665        self.handle.is_mapped()
666    }
667}
668
669impl<T> TensorTrait<T> for PboTensor<T>
670where
671    T: Num + Clone + fmt::Debug + Send + Sync,
672{
673    fn new(_shape: &[usize], _name: Option<&str>) -> Result<Self> {
674        Err(Error::NotImplemented(
675            "PboTensor cannot be created directly — use ImageProcessor::create_image()".to_owned(),
676        ))
677    }
678
679    #[cfg(unix)]
680    fn from_fd(_fd: std::os::fd::OwnedFd, _shape: &[usize], _name: Option<&str>) -> Result<Self> {
681        Err(Error::NotImplemented(
682            "PboTensor does not support from_fd".to_owned(),
683        ))
684    }
685
686    #[cfg(unix)]
687    fn clone_fd(&self) -> Result<std::os::fd::OwnedFd> {
688        Err(Error::NotImplemented(
689            "PboTensor does not support clone_fd".to_owned(),
690        ))
691    }
692
693    fn memory(&self) -> TensorMemory {
694        TensorMemory::Pbo
695    }
696
697    fn name(&self) -> String {
698        self.name.clone()
699    }
700
701    fn shape(&self) -> &[usize] {
702        &self.shape
703    }
704
705    fn reshape(&mut self, shape: &[usize]) -> Result<()> {
706        if shape.is_empty() {
707            return Err(Error::InvalidSize(0));
708        }
709        let new_size = shape.iter().product::<usize>() * std::mem::size_of::<T>();
710        if new_size != self.handle.size {
711            return Err(Error::ShapeMismatch(format!(
712                "Cannot reshape incompatible shape: {:?} to {:?}",
713                self.shape, shape
714            )));
715        }
716        self.shape = shape.to_vec();
717        self.view_offset = 0;
718        Ok(())
719    }
720
721    fn capacity_bytes(&self) -> usize {
722        self.handle.size
723    }
724
725    /// Capacity-based reconfigure (mirrors Mem/Shm/DMA/IOSurface): allow any
726    /// shape whose byte size fits the PBO allocation, so an oversized reusable
727    /// pool can be `configure_image`d to a smaller image. Without this PBO fell
728    /// back to the strict-`reshape` default and rejected pool reuse.
729    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
730        if shape.is_empty() {
731            return Err(Error::InvalidSize(0));
732        }
733        let needed = shape.iter().product::<usize>() * std::mem::size_of::<T>();
734        if needed > self.handle.size {
735            return Err(Error::InsufficientCapacity {
736                needed,
737                capacity: self.handle.size,
738            });
739        }
740        self.shape = shape.to_vec();
741        Ok(())
742    }
743
744    fn map_with<'a>(&self, access: crate::CpuAccess) -> Result<crate::view::HostView<'a, T>>
745    where
746        T: 'a,
747    {
748        self.map_internal(None, access)
749    }
750
751    fn buffer_identity(&self) -> &BufferIdentity {
752        &self.identity
753    }
754
755    /// Zero-copy sub-region view sharing this PBO's GL buffer (via the
756    /// `Arc<PboHandle>`) and [`BufferIdentity`], positioned at `offset_bytes`
757    /// from this tensor's own window with logical `shape`. The GL backend keys
758    /// the import on the shared identity and addresses the window via
759    /// `glViewport` / the staged copy; a CPU map adds `view_offset` to the
760    /// mapped base. Mirrors [`DmaTensor::view`](crate::TensorTrait::view).
761    ///
762    /// # Errors
763    ///
764    /// - [`Error::InvalidOperation`] if `offset_bytes` is mis-aligned for `T`.
765    /// - [`Error::InsufficientCapacity`] if the window exceeds the allocation.
766    fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self> {
767        if !offset_bytes.is_multiple_of(std::mem::align_of::<T>()) {
768            return Err(Error::InvalidOperation(format!(
769                "PboTensor::view: offset {offset_bytes} not aligned to align_of::<T>()={}",
770                std::mem::align_of::<T>()
771            )));
772        }
773        let abs_offset = self
774            .view_offset
775            .checked_add(offset_bytes)
776            .ok_or(Error::InvalidSize(offset_bytes))?;
777        let logical = shape.iter().product::<usize>() * std::mem::size_of::<T>();
778        let needed = abs_offset
779            .checked_add(logical)
780            .ok_or(Error::InvalidSize(logical))?;
781        if needed > self.handle.size {
782            return Err(Error::InsufficientCapacity {
783                needed,
784                capacity: self.handle.size,
785            });
786        }
787        Ok(Self {
788            name: self.name.clone(),
789            shape: shape.to_vec(),
790            handle: Arc::clone(&self.handle),
791            identity: self.identity.clone(),
792            view_offset: abs_offset,
793            _marker: PhantomData,
794        })
795    }
796}
797
798impl<T> PboTensor<T>
799where
800    T: Num + Clone + fmt::Debug + Send + Sync,
801{
802    /// Map the PBO so `as_slice()` exposes the full padded buffer (`byte_size`
803    /// bytes) rather than the shape-derived logical count. Mirrors
804    /// [`DmaTensor::map_with_byte_size`]: a CPU producer (e.g. the JPEG decoder)
805    /// or a strided convert source iterates rows via `effective_row_stride()`
806    /// without running past the slice. Crate-private; the only caller is
807    /// `Tensor::map()`, which already checks `byte_size <= capacity_bytes()`.
808    ///
809    /// `static`-only: `Tensor::map()` (`impl<T> TensorMapTrait<T> for
810    /// Tensor<T>` in `lib.rs`) is itself `#[cfg(feature = "static")]`. Only
811    /// this one accessor gates -- `PboTensor` and the rest of its API stay
812    /// available under `dynamic` too, since `PboOps`/`PboMapping` are a
813    /// backend-agnostic GL extension point `edgefirst-image` implements
814    /// regardless of which tensor backend it links.
815    #[cfg(feature = "static")]
816    pub(crate) fn map_with_byte_size<'a>(
817        &self,
818        byte_size: usize,
819        access: crate::CpuAccess,
820    ) -> Result<crate::view::HostView<'a, T>>
821    where
822        T: 'a,
823    {
824        self.map_internal(Some(byte_size), access)
825    }
826
827    fn map_internal<'a>(
828        &self,
829        byte_size_override: Option<usize>,
830        access: crate::CpuAccess,
831    ) -> Result<crate::view::HostView<'a, T>>
832    where
833        T: 'a,
834    {
835        // Always map the full GL allocation (`handle.size`); the slice length is
836        // narrowed by `byte_size_override` (or the logical shape) at access time.
837        // scoped_pin acquires the GL map; its keepalive releases it exactly
838        // once when the last clone drops, which is what the `released` flag
839        // used to do by hand.
840        Ok(crate::view::HostView::new(
841            self.scoped_pin(access)?,
842            self.shape.clone(),
843            byte_size_override,
844            access,
845        ))
846    }
847}
848
849/// Non-null base address of a live GL buffer mapping.
850///
851/// Still used by `PboHandle`'s refcounted acquire/release even though the
852/// per-backend `PboMap` it once fed is gone: the handle serialises GL maps
853/// itself, and this is what it hands back.
854#[derive(Debug)]
855struct PboPtr(NonNull<c_void>);
856
857impl Deref for PboPtr {
858    type Target = NonNull<c_void>;
859    fn deref(&self) -> &Self::Target {
860        &self.0
861    }
862}
863
864unsafe impl Send for PboPtr {}
865
866/// Keepalive that holds a PBO's CPU map open for a [`HostPin`]'s lifetime.
867///
868/// A PBO's address is only valid between `glMapBufferRange` and
869/// `glUnmapBuffer`, so — unlike DMA-BUF or IOSurface — the map IS the lifetime.
870/// `PboTensor::scoped_pin` acquires it and this releases it when the last clone
871/// of the pin drops, which reproduces `PboMap::unmap`'s once-only release
872/// through `Arc` rather than a `released` flag.
873// Staged for the HostView collapse (Plan 2b Task 4): constructed once
874// map_with migrates to HostView. Kept compiling and reviewable rather than
875// landing the whole collapse in one unverifiable change.
876#[allow(dead_code)]
877pub(crate) struct PboMapLock {
878    handle: Arc<PboHandle>,
879}
880
881impl Drop for PboMapLock {
882    fn drop(&mut self) {
883        self.handle.release_map();
884    }
885}
886
887impl<T> PboTensor<T>
888where
889    T: Num + Clone + fmt::Debug + Send + Sync,
890{
891    /// Acquire a host address valid for as long as the returned pin lives.
892    ///
893    /// Private on purpose: this is NOT `pin_host`, which refuses PBO precisely
894    /// because the address cannot outlive the map. A guard built on this pin
895    /// releases the map when it drops, which is the only correct lifetime.
896    #[allow(dead_code)]
897    pub(crate) fn scoped_pin<'a>(&self, access: crate::CpuAccess) -> Result<crate::pin::HostPin<'a>>
898    where
899        T: 'a,
900    {
901        let ptr = self.handle.acquire_map(access.writes())?;
902        let base = unsafe { (ptr.as_ptr() as *mut u8).add(self.view_offset) };
903        let len = self.handle.size.saturating_sub(self.view_offset);
904        Ok(crate::pin::HostPin::new(
905            Arc::new(PboMapLock {
906                handle: Arc::clone(&self.handle),
907            }),
908            base,
909            len,
910        ))
911    }
912}
913
914// -- PboMap --
915
916impl<T> Clone for PboTensor<T>
917where
918    T: Num + Clone + fmt::Debug + Send + Sync,
919{
920    fn clone(&self) -> Self {
921        Self {
922            name: self.name.clone(),
923            shape: self.shape.clone(),
924            handle: Arc::clone(&self.handle),
925            identity: self.identity.clone(),
926            view_offset: self.view_offset,
927            _marker: PhantomData,
928        }
929    }
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935    use crate::TensorMapTrait;
936
937    /// Mock PboOps that uses a Vec<u8> as backing storage instead of GL.
938    ///
939    /// Counts map/unmap calls so tests can assert that N read-only holders
940    /// share ONE GL mapping rather than each taking their own.
941    struct MockPboOps {
942        storage: Mutex<Vec<u8>>,
943        maps: std::sync::atomic::AtomicUsize,
944        unmaps: std::sync::atomic::AtomicUsize,
945        deletes: std::sync::atomic::AtomicUsize,
946    }
947
948    impl MockPboOps {
949        fn new(size: usize) -> Arc<Self> {
950            Arc::new(Self {
951                storage: Mutex::new(vec![0u8; size]),
952                maps: std::sync::atomic::AtomicUsize::new(0),
953                unmaps: std::sync::atomic::AtomicUsize::new(0),
954                deletes: std::sync::atomic::AtomicUsize::new(0),
955            })
956        }
957
958        fn map_count(&self) -> usize {
959            self.maps.load(std::sync::atomic::Ordering::Acquire)
960        }
961
962        fn delete_count(&self) -> usize {
963            self.deletes.load(std::sync::atomic::Ordering::Acquire)
964        }
965
966        fn unmap_count(&self) -> usize {
967            self.unmaps.load(std::sync::atomic::Ordering::Acquire)
968        }
969    }
970
971    // SAFETY: the returned pointer addresses a `Vec<u8>` that is allocated
972    // once in `new` and never resized, so it stays valid for the whole life
973    // of the `MockPboOps` — which outlives every map handed out, since the
974    // tensor holds an `Arc` to it. The storage mutex is NOT what keeps the
975    // pointer alive (`map_buffer` releases it before returning); it only
976    // guards the length assertion.
977    unsafe impl PboOps for MockPboOps {
978        fn map_buffer(&self, _buffer_id: u32, size: usize) -> Result<PboMapping> {
979            self.maps.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
980            let storage = self.storage.lock().expect("lock");
981            assert_eq!(storage.len(), size);
982            Ok(PboMapping {
983                ptr: storage.as_ptr() as *mut u8,
984                size,
985            })
986        }
987
988        fn unmap_buffer(&self, _buffer_id: u32) -> Result<()> {
989            self.unmaps
990                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
991            Ok(())
992        }
993
994        fn delete_buffer(&self, _buffer_id: u32) {
995            self.deletes
996                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
997        }
998    }
999
1000    #[test]
1001    fn test_pbo_tensor_create_and_metadata() {
1002        let ops = MockPboOps::new(24);
1003        let tensor = PboTensor::<u8>::from_pbo(42, 24, &[2, 3, 4], Some("test_pbo"), ops).unwrap();
1004        assert_eq!(tensor.memory(), TensorMemory::Pbo);
1005        assert_eq!(tensor.name(), "test_pbo");
1006        assert_eq!(tensor.shape(), &[2, 3, 4]);
1007        assert_eq!(tensor.buffer_id(), 42);
1008        assert!(!tensor.is_mapped());
1009    }
1010
1011    #[test]
1012    fn test_pbo_tensor_map_write_read() {
1013        let ops = MockPboOps::new(12);
1014        let tensor = PboTensor::<u8>::from_pbo(1, 12, &[3, 4], Some("rw_test"), ops).unwrap();
1015        {
1016            let mut map = tensor.map().expect("map should succeed");
1017            assert_eq!(map.shape(), &[3, 4]);
1018            assert!(tensor.is_mapped());
1019            map.as_mut_slice().fill(0xAB);
1020            assert!(map.as_slice().iter().all(|&b| b == 0xAB));
1021        }
1022        assert!(!tensor.is_mapped());
1023    }
1024
1025    #[test]
1026    fn test_pbo_tensor_double_map_fails() {
1027        let ops = MockPboOps::new(8);
1028        let tensor = PboTensor::<u8>::from_pbo(2, 8, &[8], None, ops).unwrap();
1029        let _map1 = tensor.map().expect("first map should succeed");
1030        assert!(tensor.is_mapped());
1031        let result = tensor.map();
1032        assert!(result.is_err(), "second map while mapped should fail");
1033    }
1034
1035    /// Mock whose `map_buffer` reports success but hands back a null
1036    /// pointer — the one path where a claimed map has to be released again.
1037    #[derive(Default)]
1038    struct NullMappingOps {
1039        unmaps: std::sync::atomic::AtomicUsize,
1040    }
1041
1042    impl NullMappingOps {
1043        fn unmap_count(&self) -> usize {
1044            self.unmaps.load(std::sync::atomic::Ordering::Acquire)
1045        }
1046    }
1047
1048    // SAFETY: never produces a usable pointer, so no caller can dereference
1049    // one; the mapping it returns is rejected before reaching a slice.
1050    unsafe impl PboOps for NullMappingOps {
1051        fn map_buffer(&self, _buffer_id: u32, size: usize) -> Result<PboMapping> {
1052            Ok(PboMapping {
1053                ptr: std::ptr::null_mut(),
1054                size,
1055            })
1056        }
1057
1058        fn unmap_buffer(&self, _buffer_id: u32) -> Result<()> {
1059            self.unmaps
1060                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1061            Ok(())
1062        }
1063
1064        fn delete_buffer(&self, _buffer_id: u32) {}
1065    }
1066
1067    /// A map that "succeeds" with a null pointer leaves GL considering the
1068    /// buffer mapped, so it must be released and the state returned to
1069    /// unmapped — otherwise the buffer is stranded and every later map
1070    /// fails. The release happens before `Unmapped` is published, so no
1071    /// waiter can start a map that races the cleanup unmap.
1072    #[test]
1073    fn null_mapping_releases_the_buffer_and_resets_state() {
1074        let ops = Arc::new(NullMappingOps::default());
1075        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1076        let tensor = PboTensor::<u8>::from_pbo(12, 8, &[8], None, dyn_ops).unwrap();
1077
1078        let Err(err) = tensor.map_read() else {
1079            panic!("a null mapping must not succeed");
1080        };
1081        assert!(
1082            matches!(err, Error::InvalidSize(_)),
1083            "unexpected error: {err:?}"
1084        );
1085        assert_eq!(
1086            ops.unmap_count(),
1087            1,
1088            "GL still holds the buffer mapped — the failed map must release it"
1089        );
1090        assert!(
1091            !tensor.is_mapped(),
1092            "state must return to Unmapped so later maps can proceed"
1093        );
1094
1095        // Not stranded: the buffer can be claimed again.
1096        let Err(err) = tensor.map_read() else {
1097            panic!("still null, so it must still fail");
1098        };
1099        assert!(matches!(err, Error::InvalidSize(_)));
1100        assert_eq!(ops.unmap_count(), 2, "and released again");
1101    }
1102
1103    /// Several read-only holders share ONE GL mapping. This is what lets
1104    /// tiled pre-processing run several workers over one source tensor;
1105    /// before read sharing, every reader after the first failed.
1106    #[test]
1107    fn read_maps_share_one_gl_mapping() {
1108        let ops = MockPboOps::new(8);
1109        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1110        let tensor = PboTensor::<u8>::from_pbo(9, 8, &[8], None, dyn_ops).unwrap();
1111
1112        let r1 = tensor.map_read().expect("first read map");
1113        let r2 = tensor
1114            .map_read()
1115            .expect("second read map must share, not fail");
1116        let r3 = tensor
1117            .map_read()
1118            .expect("third read map must share, not fail");
1119
1120        assert_eq!(ops.map_count(), 1, "one GL mapping serves every reader");
1121        assert_eq!(ops.unmap_count(), 0, "nothing unmapped while readers live");
1122        assert!(tensor.is_mapped());
1123        // All readers address the same bytes.
1124        assert_eq!(r1.as_slice(), r2.as_slice());
1125        assert_eq!(r2.as_slice(), r3.as_slice());
1126
1127        drop(r1);
1128        drop(r2);
1129        assert_eq!(
1130            ops.unmap_count(),
1131            0,
1132            "the mapping outlives every reader but the last"
1133        );
1134        assert!(tensor.is_mapped());
1135
1136        drop(r3);
1137        assert_eq!(ops.unmap_count(), 1, "last reader out unmaps, exactly once");
1138        assert!(!tensor.is_mapped());
1139    }
1140
1141    /// Read sharing must not weaken write exclusion in either direction.
1142    #[test]
1143    fn writers_and_readers_still_exclude_each_other() {
1144        let ops = MockPboOps::new(8);
1145        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1146        let tensor = PboTensor::<u8>::from_pbo(10, 8, &[8], None, dyn_ops).unwrap();
1147
1148        let reader = tensor.map_read().expect("read map");
1149        assert!(
1150            tensor.map().is_err(),
1151            "a writer must not join an existing reader set"
1152        );
1153        drop(reader);
1154
1155        let writer = tensor.map().expect("write map after readers drained");
1156        assert!(
1157            tensor.map_read().is_err(),
1158            "a reader must not join an exclusive writer"
1159        );
1160        assert!(
1161            tensor.map().is_err(),
1162            "a second writer must not join an exclusive writer"
1163        );
1164        drop(writer);
1165
1166        // Fully released — a fresh map of either kind succeeds again.
1167        assert!(tensor.map_read().is_ok());
1168    }
1169
1170    /// `unmap()` is public on the trait and also runs from `Drop`. With
1171    /// refcounted readers a double release would unmap a SIBLING reader's
1172    /// mapping, so each map must release at most once.
1173    #[test]
1174    fn explicit_unmap_then_drop_releases_only_once() {
1175        let ops = MockPboOps::new(8);
1176        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1177        let tensor = PboTensor::<u8>::from_pbo(11, 8, &[8], None, dyn_ops).unwrap();
1178
1179        let keeper = tensor.map_read().expect("reader that must survive");
1180        let mut early = tensor.map_read().expect("reader unmapped by hand");
1181        early.unmap();
1182        drop(early); // Drop must NOT release a second time.
1183
1184        assert_eq!(
1185            ops.unmap_count(),
1186            0,
1187            "one reader still holds the mapping — it must not be unmapped"
1188        );
1189        assert!(tensor.is_mapped(), "the surviving reader still holds it");
1190        // The survivor's pointer is still valid and readable.
1191        assert_eq!(keeper.as_slice().len(), 8);
1192
1193        drop(keeper);
1194        assert_eq!(ops.unmap_count(), 1);
1195        assert!(!tensor.is_mapped());
1196    }
1197
1198    #[test]
1199    fn test_pbo_tensor_reshape() {
1200        let ops = MockPboOps::new(24);
1201        let mut tensor = PboTensor::<u8>::from_pbo(3, 24, &[2, 3, 4], None, ops).unwrap();
1202        tensor
1203            .reshape(&[4, 6])
1204            .expect("compatible reshape should succeed");
1205        assert_eq!(tensor.shape(), &[4, 6]);
1206        let result = tensor.reshape(&[100]);
1207        assert!(result.is_err(), "incompatible reshape should fail");
1208    }
1209
1210    #[test]
1211    fn test_pbo_tensor_set_logical_shape_capacity_based() {
1212        // A 24-byte PBO can be reconfigured to any shape that fits (unlike the
1213        // strict `reshape`), so an oversized reusable pool can be
1214        // `configure_image`d to a smaller image (the native-chroma decode pool).
1215        let ops = MockPboOps::new(24);
1216        let mut tensor = PboTensor::<u8>::from_pbo(7, 24, &[24], None, ops).unwrap();
1217        // Smaller-than-capacity logical shape is accepted (reshape would reject).
1218        tensor
1219            .set_logical_shape(&[4, 5])
1220            .expect("shape within capacity should succeed");
1221        assert_eq!(tensor.shape(), &[4, 5]);
1222        // Exactly-capacity is fine.
1223        tensor.set_logical_shape(&[24]).unwrap();
1224        // Over-capacity is rejected.
1225        assert!(
1226            tensor.set_logical_shape(&[5, 5]).is_err(),
1227            "shape exceeding PBO capacity must be rejected"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_pbo_tensor_buffer_identity() {
1233        let ops1 = MockPboOps::new(8);
1234        let ops2 = MockPboOps::new(8);
1235        let t1 = PboTensor::<u8>::from_pbo(1, 8, &[8], None, ops1).unwrap();
1236        let t2 = PboTensor::<u8>::from_pbo(2, 8, &[8], None, ops2).unwrap();
1237        assert_eq!(t1.buffer_identity().kind(), IdentityKind::Pbo);
1238        assert_ne!(t1.buffer_identity().id(), t2.buffer_identity().id());
1239    }
1240
1241    #[test]
1242    fn two_pbos_with_the_same_gl_name_share_an_identity() {
1243        // The identity is derived from the GL buffer name, not a per-call
1244        // counter -- two independent `from_pbo` wraps of the same name (the
1245        // in-process analog of two independently-linked copies of this crate
1246        // each importing the same PBO) must agree, or the per-context import
1247        // cache misses on every wrap instead of reusing the bound texture.
1248        let ops1 = MockPboOps::new(8);
1249        let ops2 = MockPboOps::new(8);
1250        let t1 = PboTensor::<u8>::from_pbo(7, 8, &[8], None, ops1).unwrap();
1251        let t2 = PboTensor::<u8>::from_pbo(7, 8, &[8], None, ops2).unwrap();
1252        assert_eq!(t1.buffer_identity().id(), t2.buffer_identity().id());
1253    }
1254
1255    #[test]
1256    fn test_pbo_tensor_new_returns_error() {
1257        let result = PboTensor::<u8>::new(&[8], None);
1258        assert!(result.is_err(), "PboTensor::new() should fail");
1259    }
1260
1261    #[cfg(unix)]
1262    #[test]
1263    fn test_pbo_tensor_fd_ops_return_error() {
1264        let ops = MockPboOps::new(8);
1265        let tensor = PboTensor::<u8>::from_pbo(1, 8, &[8], None, ops).unwrap();
1266        assert!(tensor.clone_fd().is_err());
1267    }
1268
1269    #[test]
1270    fn test_pbo_tensor_from_pbo_size_mismatch() {
1271        let ops = MockPboOps::new(24);
1272        let result = PboTensor::<u8>::from_pbo(1, 24, &[2, 3, 5], None, ops);
1273        assert!(result.is_err(), "mismatched size/shape should fail");
1274    }
1275
1276    #[test]
1277    fn test_pbo_tensor_from_pbo_zero_size() {
1278        let ops = MockPboOps::new(0);
1279        let result = PboTensor::<u8>::from_pbo(1, 0, &[0], None, ops);
1280        assert!(result.is_err(), "zero size should fail");
1281    }
1282
1283    #[test]
1284    fn test_pbo_via_tensor_enum() {
1285        let ops = MockPboOps::new(12);
1286        let pbo = PboTensor::<u8>::from_pbo(10, 12, &[3, 4], Some("enum_test"), ops).unwrap();
1287        let tensor = crate::Tensor::wrap(crate::TensorStorage::Pbo(pbo));
1288        assert_eq!(tensor.memory(), TensorMemory::Pbo);
1289        assert_eq!(tensor.name(), "enum_test");
1290        assert_eq!(tensor.shape(), &[3, 4]);
1291        let mut map = tensor.map().expect("map via enum");
1292        map.as_mut_slice().fill(42);
1293        assert!(map.as_slice().iter().all(|&b| b == 42));
1294    }
1295
1296    /// The Task-11 acceptance criterion at the Rust level: export a
1297    /// PBO-backed tensor's descriptor, reconstruct it, and read back real
1298    /// bytes -- not just a non-error. Models the producer/consumer split
1299    /// the cross-package capsule protocol exists for (`edgefirst.image`
1300    /// allocates and writes; a different `cdylib`, `edgefirst.codec`,
1301    /// reconstructs from just the `TensorDesc` and reads real bytes back),
1302    /// but both halves run in this one process, which is all `cargo test`
1303    /// can exercise. The genuine two-`.so` case -- the reason this needed a
1304    /// `#[repr(C)]` vtable rather than a same-process registry -- is what
1305    /// `tests/interop/test_cross_package.py::
1306    /// test_decode_into_gpu_backed_tensor_from_another_package` and
1307    /// `tests/image/test_image.py::test_decode_image_pipeline` prove.
1308    #[test]
1309    fn cross_package_pbo_round_trips_through_tensor_desc_with_real_bytes() {
1310        let ops = MockPboOps::new(8);
1311        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1312        let producer = PboTensor::<u8>::from_pbo(42, 8, &[8], None, dyn_ops).unwrap();
1313        {
1314            let mut m = producer.map().expect("map producer");
1315            m.as_mut_slice().copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
1316        }
1317
1318        // The producer side: build a real TensorDesc the way
1319        // `Tensor.__edgefirst_tensor__()` does under the hood.
1320        let producer_dyn =
1321            crate::TensorDyn::U8(crate::Tensor::wrap(crate::TensorStorage::Pbo(producer)));
1322        let desc = producer_dyn.descriptor();
1323        assert_eq!(desc.kind, crate::protocol::kind::PBO);
1324        assert_eq!(desc.handle, 42);
1325        assert!(
1326            !desc.ptr.is_null(),
1327            "a PBO descriptor must carry an ops vtable -- see TensorDesc::ptr's doc comment"
1328        );
1329
1330        // The consumer side: reconstruct from ONLY the descriptor, exactly
1331        // what `import_descriptor` does for a genuinely foreign handle --
1332        // no access to `producer_dyn` itself from here on.
1333        let imported = crate::TensorDyn::import_descriptor(&desc)
1334            .expect("import_descriptor must reconstruct a PBO-backed tensor from its own vtable");
1335        assert_eq!(imported.memory(), TensorMemory::Pbo);
1336
1337        let m = imported
1338            .map_bytes(crate::CpuAccess::Read)
1339            .expect("map the reconstructed tensor");
1340        assert_eq!(
1341            m.as_slice(),
1342            &[1u8, 2, 3, 4, 5, 6, 7, 8],
1343            "the reconstructed tensor must read the SAME bytes the producer wrote through the \
1344             SAME GL buffer, not a copy or garbage"
1345        );
1346    }
1347
1348    /// Hazard 1, made structurally moot rather than merely handled: GL
1349    /// buffer ids are scoped per-context, so two DIFFERENT contexts can
1350    /// legitimately hand out the SAME buffer_id for UNRELATED buffers. A
1351    /// registry keyed on buffer_id alone would resolve the second import to
1352    /// the first context's ops -- a wrong-buffer bug with no error. This
1353    /// design has no id to key anything on: the descriptor's `ptr` IS the
1354    /// address of the specific `PboHandle` it was built from, so two
1355    /// tensors sharing a buffer_id (simulating two independent contexts
1356    /// that happen to collide on the number) cannot cross-resolve, full
1357    /// stop -- there is no shared table for them to collide *in*.
1358    #[test]
1359    fn two_pbo_tensors_sharing_a_buffer_id_do_not_cross_resolve() {
1360        let ops_a = MockPboOps::new(4);
1361        let ops_b = MockPboOps::new(4);
1362        ops_a.storage.lock().unwrap().copy_from_slice(&[0xAA; 4]);
1363        ops_b.storage.lock().unwrap().copy_from_slice(&[0xBB; 4]);
1364        let dyn_a: Arc<dyn PboOps> = ops_a.clone();
1365        let dyn_b: Arc<dyn PboOps> = ops_b.clone();
1366
1367        // SAME buffer_id, two entirely unrelated backing buffers -- exactly
1368        // the scenario two independent GL contexts could produce.
1369        let tensor_a = PboTensor::<u8>::from_pbo(7, 4, &[4], None, dyn_a).unwrap();
1370        let tensor_b = PboTensor::<u8>::from_pbo(7, 4, &[4], None, dyn_b).unwrap();
1371
1372        let dyn_a_wrapped =
1373            crate::TensorDyn::U8(crate::Tensor::wrap(crate::TensorStorage::Pbo(tensor_a)));
1374        let dyn_b_wrapped =
1375            crate::TensorDyn::U8(crate::Tensor::wrap(crate::TensorStorage::Pbo(tensor_b)));
1376        let desc_a = dyn_a_wrapped.descriptor();
1377        let desc_b = dyn_b_wrapped.descriptor();
1378        assert_eq!(
1379            desc_a.handle, desc_b.handle,
1380            "both descriptors share the same buffer_id by construction"
1381        );
1382        assert_ne!(
1383            desc_a.ptr.0, desc_b.ptr.0,
1384            "but each carries its own, distinct vtable address -- there is no shared key \
1385             for them to collide on"
1386        );
1387
1388        let imported_a = crate::TensorDyn::import_descriptor(&desc_a).unwrap();
1389        let imported_b = crate::TensorDyn::import_descriptor(&desc_b).unwrap();
1390        assert_eq!(
1391            imported_a
1392                .map_bytes(crate::CpuAccess::Read)
1393                .unwrap()
1394                .as_slice(),
1395            &[0xAA; 4],
1396            "importing desc_a must reach ops_a's buffer, never ops_b's"
1397        );
1398        assert_eq!(
1399            imported_b
1400                .map_bytes(crate::CpuAccess::Read)
1401                .unwrap()
1402                .as_slice(),
1403            &[0xBB; 4],
1404            "importing desc_b must reach ops_b's buffer, never ops_a's"
1405        );
1406    }
1407
1408    /// Hazard 2: an `Arc<dyn PboOps>` registry would hold its own strong
1409    /// reference to `ops`, so the failure mode is a **leak** -- specifically
1410    /// a GL context kept alive past its own teardown, unless something
1411    /// explicitly removes the entry when the registering `PboTensor` drops.
1412    /// This design has no separate reference to remove: `PboOpsVtable::ctx`
1413    /// is a raw pointer into the `PboHandle`'s own `ops` field, not a fresh
1414    /// `Arc` clone, so building and exporting a descriptor must not change
1415    /// `ops`'s strong count at all, and dropping the producing tensor must
1416    /// release its one real reference immediately -- proven directly via
1417    /// `Arc::strong_count`, not merely "nothing crashed".
1418    #[test]
1419    fn dropping_the_producing_pbo_tensor_leaks_nothing_the_vtable_touched() {
1420        let ops = MockPboOps::new(8);
1421        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1422        assert_eq!(
1423            Arc::strong_count(&ops),
1424            2,
1425            "this test's own handle, plus the clone about to move into from_pbo"
1426        );
1427
1428        let producer = PboTensor::<u8>::from_pbo(99, 8, &[8], None, dyn_ops).unwrap();
1429        assert_eq!(
1430            Arc::strong_count(&ops),
1431            2,
1432            "from_pbo must not clone ops again"
1433        );
1434
1435        let producer_dyn =
1436            crate::TensorDyn::U8(crate::Tensor::wrap(crate::TensorStorage::Pbo(producer)));
1437        // Force the vtable to build -- the exact operation hazard 2 worried
1438        // about: does exporting a descriptor create a NEW strong reference
1439        // to `ops` that nothing ever releases?
1440        let _desc = producer_dyn.descriptor();
1441        assert_eq!(
1442            Arc::strong_count(&ops),
1443            2,
1444            "building/exporting the vtable must not clone ops -- see PboOpsVtable's own \
1445             doc comment for why ctx is a raw pointer, not an Arc clone"
1446        );
1447
1448        assert_eq!(ops.delete_count(), 0, "not dropped yet");
1449        drop(producer_dyn);
1450        assert_eq!(
1451            Arc::strong_count(&ops),
1452            1,
1453            "dropping the producing tensor must release its ops reference immediately -- \
1454             nothing the vtable machinery built kept it alive past that"
1455        );
1456        assert_eq!(
1457            ops.delete_count(),
1458            1,
1459            "the real delete_buffer must still fire exactly once, from PboHandle::Drop, \
1460             completely unaffected by whether a descriptor was ever exported"
1461        );
1462    }
1463}