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;
22// `PboHandle::abi_vtable`'s cell, and so `static`-only with it: under
23// `dynamic` the library owns the table (`ef_tensor_pbo_vtable`).
24#[cfg(feature = "static")]
25use std::sync::OnceLock;
26// The frozen client-state vocabulary the table is expressed over. Re-exported
27// (`crate::EfClientState` and friends) so a C entry point assembling the same
28// three parts names one type, not a copy of it.
29pub use edgefirst_tensor_abi::{EfClientState, EfPboMapFn, EfPboUnmapFn};
30
31/// The refusal both backends give when asked to pin a PBO's host address.
32///
33/// One string, two arms: `Tensor::pin_host` (`lib.rs`, `static`) refuses its
34/// own `TensorStorage::Pbo`, and `TensorDyn::pin_host`
35/// (`tensor_dyn/dynamic_backend.rs`) has to repeat the refusal because the
36/// ABI exposes `ef_tensor_map` and no `ef_tensor_pin_host`, so the library
37/// cannot tell a pin from a map. Shared as a constant rather than copied so
38/// the two cannot drift into refusing with different words.
39///
40/// The reason, at length: `glMapBufferRange`'s pointer is valid only until
41/// `glUnmapBuffer`, and this backend maps with `MAP_READ_BIT |
42/// MAP_WRITE_BIT` -- no `GL_MAP_PERSISTENT_BIT`, and no `glBufferStorage`
43/// anywhere. Holding the map open for a pin's lifetime would keep the buffer
44/// mapped across GL work, which is exactly what a PBO is not for. Persistent
45/// mapping (`EXT_buffer_storage` on GLES) would make this implementable, but
46/// that is a buffer-allocation feature, not wiring -- it changes how every
47/// PBO is created and needs fences for coherency.
48pub(crate) const PIN_HOST_PBO_REFUSAL: &str = "pin_host: a PBO has no host address outside \
49     glMapBufferRange/glUnmapBuffer, so it cannot hand out one that \
50     outlives a guard. Use map()/map_with(). Persistent mapping \
51     (EXT_buffer_storage, GL_MAP_PERSISTENT_BIT) is not used by this \
52     backend.";
53
54/// Raw mapped pointer from a PBO. CPU-accessible while the buffer is mapped.
55/// The pointer is only valid between map and unmap calls.
56pub struct PboMapping {
57    pub ptr: *mut u8,
58    pub size: usize,
59}
60
61// SAFETY: PboMapping is only created by PboOps::map_buffer which runs on the
62// GL thread, but the resulting pointer is used on the caller's thread. This is
63// safe because glMapBufferRange returns a CPU-visible pointer that can be
64// accessed from any thread while the buffer remains mapped.
65unsafe impl Send for PboMapping {}
66
67/// Trait for PBO GL operations, implemented by the image crate.
68///
69/// All methods are blocking. Implementations must ensure the GL context is
70/// current on the thread that executes the actual GL calls — by sending to the
71/// GL worker, or by running inline when the caller already IS that worker.
72///
73/// # Safety
74///
75/// Implementations must ensure:
76/// - `map_buffer` returns a valid, aligned pointer to `size` bytes of
77///   CPU-accessible memory that remains valid until `unmap_buffer` is called.
78/// - `unmap_buffer` invalidates the pointer and releases the mapping.
79/// - `delete_buffer` frees the GL buffer resources.
80/// - No method unwinds. `PboHandle::acquire_map` publishes `MapState::Mapping`
81///   before calling `map_buffer` and clears it only on return, so a panic
82///   crossing this boundary strands that handle and every later map on it waits
83///   on the condvar forever.
84pub unsafe trait PboOps: Send + Sync {
85    /// Map the PBO for CPU read/write access.
86    /// The returned PboMapping is valid until `unmap_buffer` is called.
87    ///
88    /// A returned `PboMapping::size` below `size` is treated as a FAILED
89    /// map, not a partial one: `PboHandle::acquire_map` refuses it and
90    /// unmaps the buffer. Callers of the resulting pin size their host
91    /// slice from the tensor's own allocation, so there is nowhere for a
92    /// shorter mapping to be recorded. Report the shortfall as an `Err`
93    /// rather than a short success if the implementation can tell.
94    fn map_buffer(&self, buffer_id: u32, size: usize) -> Result<PboMapping>;
95
96    /// Unmap a previously mapped PBO. Must be called before GL operations
97    /// on this buffer (GLES 3.0 requirement).
98    fn unmap_buffer(&self, buffer_id: u32) -> Result<()>;
99
100    /// Delete the PBO. Fire-and-forget — no reply needed.
101    /// Called from PboTensor's Drop impl.
102    fn delete_buffer(&self, buffer_id: u32);
103}
104
105/// C-ABI-safe callback table for a PBO's GL operations — the same
106/// technique `tensor-capi`'s `EfTensorVtable` uses to let one
107/// independently-compiled `cdylib` dispatch into a tensor implementation it
108/// did not compile, applied here to [`PboOps`].
109///
110/// **Why `PboOps` itself cannot cross a `cdylib` boundary.** `Arc<dyn
111/// PboOps>`'s vtable pointer addresses a table Rust generates per
112/// (concrete-type, trait) pair at compile time — its exact layout is not
113/// part of any stable ABI. Two independently-compiled `cdylib`s (e.g.
114/// `edgefirst.image`'s and `edgefirst.codec`'s Python extension modules,
115/// each statically linking its own copy of this crate — confirmed by their
116/// separate `.so` files on disk, one `edgefirst-tensor` copy embedded in
117/// each) are not guaranteed to agree on that layout, and Rust's own "no
118/// stable ABI" position is exactly why `tensor-capi` exists at all for
119/// tensors. A same-process-only registry (buffer_id -> `Arc<dyn PboOps>`,
120/// the design this replaces) does not solve this: it would need to be a
121/// process-wide `static`, and `crates/tensor/tests/no_global_state.rs`
122/// exists specifically to reject that class of state, with the exact
123/// cross-package PBO scenario named in its own docstring as the motivating
124/// example. `#[repr(C)]` plus `extern "C" fn` pointers are the one
125/// representation the platform C ABI guarantees is stable across separate
126/// compilations, so that is what crosses instead.
127///
128/// **Lifetime, and why there is no registry, no leak, and nothing to
129/// unregister.** A `PboOpsVtable` is built once, lazily, as a field of the
130/// `PboHandle` whose `ops` it dispatches into (see [`PboTensor::pbo_vtable`]),
131/// and its address is only ever handed out through
132/// [`crate::TensorDesc::ptr`] under [`crate::protocol::kind::PBO`] — valid
133/// for exactly as long as that field's existing contract already promises
134/// (the producer's capsule keepalive holding the producing tensor alive;
135/// see [`crate::TensorDyn::import_descriptor`]'s doc comment, which already
136/// establishes this for every other kind's use of `ptr`). This struct's own
137/// address IS the `PboHandle`'s address plus a fixed field offset, so it
138/// disappears exactly when `PboHandle` does — no separate allocation to
139/// leak, no registry entry to remove on drop. The external contract only has
140/// to hold for the *read*: an importer calls `state.retain` while reading
141/// the table and holds its own reference from then on (see
142/// [`client_state_pbo_ops`]).
143///
144/// Compiled into both backends. It was `static`-only while the
145/// cross-package capsule protocol had no `dynamic` counterpart; task P2a
146/// gave it one (`TensorDyn::descriptor_pinned`/`import_descriptor` now
147/// exist on both), so the gate came off. Nothing here is
148/// backend-specific — it is a C-ABI view onto `PboOps`, which was always
149/// a backend-agnostic GL extension point.
150#[repr(C)]
151pub struct PboOpsVtable {
152    /// The callback channel: an opaque `ctx` plus the pair that extends its
153    /// life. `ctx` addresses the producing [`PboHandle`]; only this module's
154    /// own `extern "C"` functions ever dereference it.
155    ///
156    /// **Borrowed, not owned.** [`Self::new`] takes no reference of its own,
157    /// because this struct lives in a `OnceLock` *inside* the very
158    /// `PboHandle` its `ctx` names — a count taken here could never reach
159    /// zero and every PBO would leak its GL buffer. An importer takes its
160    /// own reference through `state.retain` instead; see
161    /// [`client_state_pbo_ops`].
162    state: EfClientState,
163    map_buffer_fn: EfPboMapFn,
164    unmap_buffer_fn: EfPboUnmapFn,
165    // No `delete_buffer_fn`: deliberately absent, not merely unused. A
166    // consumer reconstructing a `PboTensor` from this vtable does not own
167    // the GL buffer -- it holds the channel alive through `state.retain`
168    // and lets the producer's own `PboHandle::Drop` do the deleting (see
169    // `ImportedPboOps`'s own doc comment) -- so there is no legitimate
170    // caller for it on that side. Including a pointer nothing may safely
171    // call is worse than omitting it.
172}
173
174// SAFETY: every field is either a raw pointer this struct never
175// dereferences itself (only its own `extern "C"` fns do, and those forward
176// into `PboOps`, itself `Send + Sync`) or a plain function pointer — both
177// safe to share and move across threads.
178unsafe impl Send for PboOpsVtable {}
179unsafe impl Sync for PboOpsVtable {}
180
181impl PboOpsVtable {
182    /// Build a vtable dispatching into `handle`'s `ops`.
183    ///
184    /// `ctx` is the `PboHandle`'s own address, taken with `Arc::as_ptr` so
185    /// `retain`/`release` can drive its strong count — a pointer into the
186    /// `ops` field (what this used to store) is not a value
187    /// `Arc::increment_strong_count` accepts. No reference is taken here;
188    /// see the field's own doc comment for why that would leak.
189    ///
190    /// `static`-only: under `dynamic` the library builds and owns the table.
191    #[cfg(feature = "static")]
192    fn new(handle: &Arc<PboHandle>) -> Self {
193        PboOpsVtable {
194            state: EfClientState {
195                ctx: Arc::as_ptr(handle) as *const c_void,
196                retain: Some(vt_retain),
197                release: Some(vt_release),
198            },
199            map_buffer_fn: vt_map_buffer,
200            unmap_buffer_fn: vt_unmap_buffer,
201        }
202    }
203}
204
205/// `0` success; `-1` the GL context is gone ([`Error::PboDisconnected`]);
206/// any other value a generic failure. This boundary is internal to the
207/// cross-package protocol (not `tensor-capi`'s public C ABI), so it does
208/// not need POSIX errno parity — only enough to distinguish "gone" (retry
209/// is pointless) from "the underlying `PboOps` call itself returned `Err`".
210/// `0` success; `-1` the GL context is gone ([`Error::PboDisconnected`]);
211/// any other value a generic failure, deliberately without the real
212/// variant or message. This boundary is internal to the cross-package
213/// protocol (not `tensor-capi`'s public C ABI), so it does not need POSIX
214/// errno parity -- only enough to distinguish "gone" (retry is pointless)
215/// from "the underlying `PboOps` call itself returned some other `Err`".
216///
217/// **Tried and reverted: a `tensor-capi`-style thread-local "last error"
218/// string.** `tensor-capi`'s `ef_tensor_last_error_message` works precisely
219/// because `libedgefirst_tensor.so` is the ONE shared library every
220/// consumer dynamically links -- one compiled copy, one thread-local slot,
221/// genuinely shared. `edgefirst-tensor` is the opposite: this crate is
222/// statically linked separately into every consumer (the whole reason this
223/// vtable exists at all), so a `thread_local!` declared here gets a
224/// SEPARATE storage slot per linked copy -- the producer's `vt_map_buffer`
225/// (compiled into the producer's `.so`) would write into ITS copy's slot,
226/// and the consumer's `vtable_errno_to_error` (compiled into a DIFFERENT
227/// `.so`) would read its OWN, never-written slot. It would silently work
228/// in a same-process test and silently do nothing across the real `.so`
229/// boundary this whole file exists for -- worse than the coarse errno it
230/// would replace. A real fix needs the message to travel as plain data
231/// (an out-param the producer writes into caller-owned memory, with its
232/// own ownership/lifetime convention) -- real engineering, not the "cheap"
233/// fix this was flagged as; left as coarse on purpose rather than shipping
234/// something that looks richer and is not.
235fn vtable_errno_to_error(rc: c_int) -> Error {
236    match rc {
237        -1 => Error::PboDisconnected,
238        n => Error::NotImplemented(format!("cross-package PBO operation failed: errno {n}")),
239    }
240}
241
242fn error_to_vtable_errno(e: &Error) -> c_int {
243    match e {
244        Error::PboDisconnected => -1,
245        _ => 1,
246    }
247}
248
249/// The `extern "C"` half of [`PboOpsVtable::new`]'s two dispatch
250/// pointers — generic over any `Arc<dyn PboOps>`, so no producer needs to
251/// write its own. `catch_unwind`-shielded: a panic unwinding across an
252/// `extern "C"` boundary is undefined behaviour, the same reasoning
253/// `tensor-capi`'s own exported functions are built around.
254unsafe extern "C" fn vt_map_buffer(
255    ctx: *const c_void,
256    buffer_id: u32,
257    size: usize,
258    out_ptr: *mut *mut u8,
259    out_len: *mut usize,
260) -> c_int {
261    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
262        // SAFETY: the caller's contract (`client_state_pbo_ops`, or this
263        // crate's own `PboOpsVtable`) guarantees `ctx` still addresses a
264        // live `PboHandle`.
265        let handle = unsafe { &*(ctx as *const PboHandle) };
266        handle.ops.map_buffer(buffer_id, size)
267    }));
268    match result {
269        Ok(Ok(mapping)) => {
270            // SAFETY: `out_ptr`/`out_len` are valid out-params for the
271            // duration of this call, per this function's own contract.
272            unsafe {
273                *out_ptr = mapping.ptr;
274                *out_len = mapping.size;
275            }
276            0
277        }
278        Ok(Err(e)) => error_to_vtable_errno(&e),
279        Err(_) => 1, // shielded panic: report a generic failure, not UB
280    }
281}
282
283unsafe extern "C" fn vt_unmap_buffer(ctx: *const c_void, buffer_id: u32) -> c_int {
284    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
285        // SAFETY: see `vt_map_buffer`.
286        let handle = unsafe { &*(ctx as *const PboHandle) };
287        handle.ops.unmap_buffer(buffer_id)
288    }));
289    match result {
290        Ok(Ok(())) => 0,
291        Ok(Err(e)) => error_to_vtable_errno(&e),
292        Err(_) => 1,
293    }
294}
295
296/// The `retain` half of [`PboOpsVtable::new`]'s [`EfClientState`].
297///
298/// Compiled into the producing copy of this crate, which is the only one
299/// that knows `PboHandle` — the whole reason `ctx` is opaque everywhere
300/// else. `catch_unwind`-shielded like its siblings: an unwind across an
301/// `extern "C"` boundary is undefined behaviour, and an `Arc` refcount
302/// operation cannot fail in any other way, so there is nothing to report.
303unsafe extern "C" fn vt_retain(ctx: *const c_void) {
304    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
305        // SAFETY: `ctx` came from `Arc::as_ptr` on a live `Arc<PboHandle>`
306        // (or from a matching `retain` the caller has not released yet),
307        // per `EfClientState`'s own contract.
308        unsafe { Arc::increment_strong_count(ctx as *const PboHandle) }
309    }));
310}
311
312/// The `release` half. See [`vt_retain`].
313unsafe extern "C" fn vt_release(ctx: *const c_void) {
314    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
315        // SAFETY: balances exactly one prior `vt_retain`.
316        unsafe { Arc::decrement_strong_count(ctx as *const PboHandle) }
317    }));
318}
319
320/// Adapts a [`PboOpsVtable`]'s three parts back into a [`PboOps`]
321/// implementation, so [`PboTensor::from_pbo`] needs no separate
322/// cross-package constructor.
323///
324/// **Holds the channel open.** Construction calls `state.retain` and
325/// [`Drop`] calls `state.release`, so this value keeps the producing
326/// `PboHandle` alive by itself. That is what lets a child outlive its
327/// parent, and it is the difference from the design this replaces, where
328/// the lifetime was enforced externally by a `pbo_keepalive()` `Arc` some
329/// unrelated object had to hold. The Python capsule still holds that
330/// keepalive; it is now belt-and-braces rather than the only thing standing
331/// between a consumer and freed memory.
332///
333/// `delete_buffer` is deliberately a no-op: this tensor does not own the GL
334/// buffer, it holds the *channel* to it (see [`EfClientState`]'s own
335/// contract), so dropping it must not delete a buffer the producer's own
336/// tensor may still be using. The producer's own `PboHandle::Drop` is the
337/// only thing that ever calls the real `delete_buffer`.
338struct ImportedPboOps {
339    state: EfClientState,
340    map_buffer_fn: EfPboMapFn,
341    unmap_buffer_fn: EfPboUnmapFn,
342}
343
344// SAFETY: the parts this holds are a raw pointer it never dereferences
345// itself (only the function pointers do, and those forward into a real
346// `PboOps` implementation, itself `Send + Sync`) plus plain function
347// pointers, safe to call from any thread — see `PboOps`'s own trait-level
348// contract, which the real implementation behind them already upholds.
349unsafe impl Send for ImportedPboOps {}
350unsafe impl Sync for ImportedPboOps {}
351
352impl Drop for ImportedPboOps {
353    fn drop(&mut self) {
354        if let Some(release) = self.state.release {
355            // SAFETY: balances the `retain` in `client_state_pbo_ops`,
356            // which is this type's only constructor.
357            unsafe { release(self.state.ctx) };
358        }
359    }
360}
361
362// SAFETY: forwards every call through the client's own op functions, which
363// forward into a real `PboOps` implementation upholding this trait's
364// contract already — this wrapper adds no unsafety beyond the calls
365// `client_state_pbo_ops`'s own safety contract covers.
366unsafe impl PboOps for ImportedPboOps {
367    fn map_buffer(&self, buffer_id: u32, size: usize) -> Result<PboMapping> {
368        let mut ptr: *mut u8 = std::ptr::null_mut();
369        let mut len: usize = 0;
370        // SAFETY: `client_state_pbo_ops` retained the channel, so `ctx` is
371        // live for as long as `self` is.
372        let rc =
373            unsafe { (self.map_buffer_fn)(self.state.ctx, buffer_id, size, &mut ptr, &mut len) };
374        if rc != 0 {
375            return Err(vtable_errno_to_error(rc));
376        }
377        Ok(PboMapping { ptr, size: len })
378    }
379
380    fn unmap_buffer(&self, buffer_id: u32) -> Result<()> {
381        // SAFETY: see `map_buffer` above.
382        let rc = unsafe { (self.unmap_buffer_fn)(self.state.ctx, buffer_id) };
383        if rc != 0 {
384            return Err(vtable_errno_to_error(rc));
385        }
386        Ok(())
387    }
388
389    fn delete_buffer(&self, _buffer_id: u32) {
390        // No-op: see this type's own doc comment.
391    }
392}
393
394/// The three parts of a [`PboOpsVtable`], read out of a descriptor's `ptr`
395/// or supplied directly by a C caller.
396pub struct PboOpsVtableParts {
397    pub state: EfClientState,
398    pub map_fn: EfPboMapFn,
399    pub unmap_fn: EfPboUnmapFn,
400}
401
402/// Read a [`crate::TensorDesc::ptr`]'s vtable into its parts, so a caller
403/// can hand them to a constructor rather than reassembling a
404/// `dyn PboOps` first.
405///
406/// # Safety
407/// `vtable_ptr` must be a table this
408/// module built, live for the duration of this call.
409pub unsafe fn read_pbo_vtable_parts(vtable_ptr: *const c_void) -> Result<PboOpsVtableParts> {
410    let Some(vtable) = NonNull::new(vtable_ptr as *mut PboOpsVtable) else {
411        return Err(Error::InvalidArgument(
412            "PBO descriptor carries no ops vtable".into(),
413        ));
414    };
415    // SAFETY: caller's contract -- the table is live for this call.
416    let v = unsafe { vtable.as_ref() };
417    Ok(PboOpsVtableParts {
418        state: v.state,
419        map_fn: v.map_buffer_fn,
420        unmap_fn: v.unmap_buffer_fn,
421    })
422}
423
424/// Build a live [`PboOps`] from a client's [`EfClientState`] and its two op
425/// functions — the one place the three parts are assembled, whether they
426/// arrived through a [`crate::TensorDesc`] under
427/// [`crate::protocol::kind::PBO`] or through `ef_tensor_wrap_pbo`'s
428/// arguments.
429///
430/// Takes its own reference on the channel (`state.retain`) and releases it
431/// when the returned `Arc`'s last clone drops.
432///
433/// # Errors
434/// [`Error::InvalidArgument`] for a NULL `ctx`, `retain` or `release` — a
435/// silently non-owning attach is exactly the class of quiet loss this
436/// design exists to remove.
437///
438/// # Safety
439/// `state.ctx` must be a genuine context the supplied `retain`/`release`
440/// understand, and `map_fn`/`unmap_fn` must uphold [`PboOps`]'s own
441/// contract for it.
442pub unsafe fn client_state_pbo_ops(
443    state: EfClientState,
444    map_fn: EfPboMapFn,
445    unmap_fn: EfPboUnmapFn,
446) -> Result<Arc<dyn PboOps>> {
447    if state.ctx.is_null() {
448        return Err(Error::InvalidArgument(
449            "PBO client state carries a NULL ctx".into(),
450        ));
451    }
452    let Some(retain) = state.retain else {
453        return Err(Error::InvalidArgument(
454            "PBO client state carries a NULL retain".into(),
455        ));
456    };
457    if state.release.is_none() {
458        return Err(Error::InvalidArgument(
459            "PBO client state carries a NULL release".into(),
460        ));
461    }
462    // SAFETY: the caller's contract above. Taken before the value is stored
463    // so `Drop`'s release always balances exactly one retain.
464    unsafe { retain(state.ctx) };
465    Ok(Arc::new(ImportedPboOps {
466        state,
467        map_buffer_fn: map_fn,
468        unmap_buffer_fn: unmap_fn,
469    }))
470}
471
472/// Opaque handle to a PBO's GL resources.
473struct PboHandle {
474    ops: Arc<dyn PboOps>,
475    buffer_id: u32,
476    size: usize,
477    map_state: Mutex<MapState>,
478    /// Wakes threads waiting out an in-flight GL map/unmap. Needed because
479    /// `map_state` is deliberately NOT held across `PboOps` calls — see
480    /// [`PboHandle::acquire_map`].
481    map_cv: Condvar,
482    /// C-ABI vtable dispatching into `ops`, for cross-cdylib export via
483    /// [`crate::TensorDesc`]. Built once, lazily, on first
484    /// [`PboTensor::pbo_vtable`] call — see [`PboOpsVtable`]'s own doc
485    /// comment for why this exists and what it replaces (a same-process-only
486    /// registry, which cannot cross a `cdylib` boundary at all).
487    ///
488    /// `static`-only since Stage B: under `dynamic` a PBO's storage lives
489    /// inside `libedgefirst_tensor.so`, which builds and owns the table
490    /// itself, and `TensorDyn::pbo_vtable_ptr` asks it through
491    /// `ef_tensor_pbo_vtable` rather than building one here.
492    #[cfg(feature = "static")]
493    abi_vtable: OnceLock<PboOpsVtable>,
494}
495
496/// CPU-map state of a PBO's single GL buffer.
497///
498/// A GL buffer has exactly one mapping at a time -- but **this state
499/// machine enforces that only within one `PboHandle`**, including its own
500/// read-sharing (below); it has no visibility into a *different*
501/// `PboHandle` for the same real `buffer_id`. Two `PboHandle`s legitimately
502/// exist for one buffer today: a producer's own tensor and a tensor
503/// [`crate::TensorDyn::import_descriptor`]'s `kind::PBO` arm reconstructs
504/// from it cross-cdylib. What actually prevents *those* two from both
505/// reaching `glMapBufferRange` on the same buffer is a second, separate
506/// enforcement point downstream of this one: the real `PboOps`
507/// implementor's own serialization of every call against one real buffer
508/// (in `edgefirst-image`, `GLProcessorThreaded`'s message loop tracks
509/// outstanding `buffer_id`s and refuses a second concurrent `PboMap`,
510/// because its one GL thread is the only place every call -- from any
511/// `PboHandle`, in any linked copy of this crate -- actually converges).
512/// This state machine's own job stays exactly what it says below: same-
513/// handle mapping/read-sharing bookkeeping.
514///
515/// So these describe that one mapping's ownership rather than per-[`PboMap`] state:
516///
517/// * `Unmapped` — no CPU mapping exists.
518/// * `Exclusive` — one writable map holds it. Nothing else may map until
519///   that map drops.
520/// * `Shared` — `readers` read-only maps over ONE mapping, unmapped when
521///   the last of them drops.
522///
523/// Read sharing exists because tiled (SAHI) pre-processing has several
524/// worker threads convert different crops of the SAME source tensor
525/// concurrently, each taking `map_read()`. Read-only holders cannot observe
526/// one another's writes because there are none, so one mapping serves them
527/// all. Under a single-map rule every reader but one failed with
528/// [`Error::PboMapped`], which left callers no option but to serialize the
529/// workers that share a source.
530enum MapState {
531    Unmapped,
532    /// A thread is inside `PboOps::map_buffer` for this buffer right now.
533    /// Others must wait it out rather than start a second GL map.
534    Mapping,
535    Exclusive,
536    Shared {
537        readers: usize,
538        ptr: PboPtr,
539    },
540    /// A thread is inside `PboOps::unmap_buffer` for this buffer right now.
541    Unmapping,
542}
543
544impl PboHandle {
545    /// Acquire a CPU mapping of this buffer, returning the base pointer.
546    ///
547    /// Read-only acquisitions join an existing read-only mapping (bumping
548    /// its refcount) or create one. Writable acquisitions are exclusive.
549    /// Returns [`Error::PboMapped`] whenever the request cannot share what
550    /// is already held: anything against an exclusive map, or a writable
551    /// request against readers.
552    ///
553    /// # Locking
554    ///
555    /// `map_state` is NEVER held across a [`PboOps`] call. Those calls are
556    /// blocking round-trips to the GL thread, and the work the GL thread
557    /// runs (a convert, say) maps tensors itself — so a thread that waited
558    /// on GL while holding this mutex would deadlock against the very GL
559    /// thread it is waiting for. The in-flight GL call is published as
560    /// [`MapState::Mapping`] / [`MapState::Unmapping`] instead, and other
561    /// threads wait on `map_cv` until it resolves.
562    fn acquire_map(&self, writable: bool) -> Result<PboPtr> {
563        loop {
564            let mut guard = self.map_state.lock().expect("PBO map state mutex poisoned");
565            // Wait out any GL map/unmap another thread is mid-way through.
566            while matches!(*guard, MapState::Mapping | MapState::Unmapping) {
567                guard = self
568                    .map_cv
569                    .wait(guard)
570                    .expect("PBO map state mutex poisoned");
571            }
572            match &mut *guard {
573                MapState::Shared { readers, ptr } if !writable => {
574                    *readers += 1;
575                    return Ok(PboPtr(ptr.0));
576                }
577                MapState::Shared { .. } | MapState::Exclusive => return Err(Error::PboMapped),
578                // Ruled out by the wait above; re-check rather than assume.
579                MapState::Mapping | MapState::Unmapping => continue,
580                MapState::Unmapped => *guard = MapState::Mapping,
581            }
582            drop(guard);
583
584            // No lock held here — see this function's Locking note.
585            let mapped = self.ops.map_buffer(self.buffer_id, self.size);
586            let base = match mapped {
587                // A SHORT mapping is a failed mapping. `scoped_pin` and
588                // `map_internal` both hand out `self.size` bytes from this
589                // base -- they have nothing else to go on -- so an
590                // implementation that reports success while mapping fewer
591                // bytes (a client's `map_fn` across the `ef_client_state`
592                // channel is untrusted here, and its `out_len` is its own
593                // claim) would turn into an out-of-bounds host slice with
594                // no error anywhere. Refuse it the same way a NULL pointer
595                // is refused below: GL still considers the buffer mapped,
596                // so release it rather than strand it.
597                Ok(mapping) if mapping.size < self.size => {
598                    let _ = self.ops.unmap_buffer(self.buffer_id);
599                    self.finish_map(None, writable);
600                    return Err(Error::InsufficientCapacity {
601                        needed: self.size,
602                        capacity: mapping.size,
603                    });
604                }
605                Ok(mapping) => NonNull::new(mapping.ptr as *mut c_void),
606                Err(e) => {
607                    self.finish_map(None, writable);
608                    return Err(e);
609                }
610            };
611            let Some(base) = base else {
612                // GL reported success but handed back a null pointer. It
613                // considers the buffer mapped, so release it rather than
614                // strand it mapped forever.
615                //
616                // Order matters: the cleanup unmap runs while this thread
617                // still holds the `Mapping` claim, so waiters stay parked
618                // and cannot start a fresh `map_buffer` that would race
619                // this unmap on the GL queue. Only once it has completed is
620                // `Unmapped` published.
621                let _ = self.ops.unmap_buffer(self.buffer_id);
622                self.finish_map(None, writable);
623                return Err(Error::InvalidSize(self.size));
624            };
625            self.finish_map(Some(base), writable);
626            return Ok(PboPtr(base));
627        }
628    }
629
630    /// Publish the outcome of a GL map this thread had claimed via
631    /// [`MapState::Mapping`], waking anyone who waited on it. `None` means
632    /// the map failed and the buffer is unmapped again.
633    fn finish_map(&self, base: Option<NonNull<c_void>>, writable: bool) {
634        let mut guard = self.map_state.lock().expect("PBO map state mutex poisoned");
635        *guard = match base {
636            None => MapState::Unmapped,
637            Some(_) if writable => MapState::Exclusive,
638            Some(base) => MapState::Shared {
639                readers: 1,
640                ptr: PboPtr(base),
641            },
642        };
643        self.map_cv.notify_all();
644    }
645
646    /// Release one acquisition, unmapping the GL buffer once the last
647    /// holder is gone. Same locking rule as [`Self::acquire_map`]: the GL
648    /// unmap runs with no lock held.
649    fn release_map(&self) {
650        let mut guard = self.map_state.lock().expect("PBO map state mutex poisoned");
651        let last_holder = match &mut *guard {
652            MapState::Shared { readers, .. } => {
653                *readers = readers.saturating_sub(1);
654                *readers == 0
655            }
656            MapState::Exclusive => true,
657            // Nothing held — a release with no matching acquire. (A caller
658            // holding an acquisition cannot observe Mapping/Unmapping:
659            // only the last holder enters Unmapping, and it is this one.)
660            MapState::Unmapped | MapState::Mapping | MapState::Unmapping => false,
661        };
662        if !last_holder {
663            return;
664        }
665        *guard = MapState::Unmapping;
666        drop(guard);
667
668        trace!("Unmapping PBO buffer_id={}", self.buffer_id);
669        if let Err(e) = self.ops.unmap_buffer(self.buffer_id) {
670            log::warn!("Failed to unmap PBO buffer {}: {e}", self.buffer_id);
671        }
672
673        let mut guard = self.map_state.lock().expect("PBO map state mutex poisoned");
674        *guard = MapState::Unmapped;
675        self.map_cv.notify_all();
676    }
677
678    /// Whether any CPU mapping is currently held or being established.
679    fn is_mapped(&self) -> bool {
680        !matches!(
681            *self.map_state.lock().expect("PBO map state mutex poisoned"),
682            MapState::Unmapped
683        )
684    }
685}
686
687impl Drop for PboHandle {
688    fn drop(&mut self) {
689        self.ops.delete_buffer(self.buffer_id);
690    }
691}
692
693/// A tensor backed by an OpenGL Pixel Buffer Object.
694pub struct PboTensor<T>
695where
696    T: Num + Clone + fmt::Debug + Send + Sync,
697{
698    pub name: String,
699    pub shape: Vec<usize>,
700    handle: Arc<PboHandle>,
701    identity: BufferIdentity,
702    /// Byte offset of this tensor's window into the shared GL buffer. Non-zero
703    /// only for sub-views (`view`/`batch`), which share the `Arc<PboHandle>` and
704    /// `BufferIdentity` and address a sub-region by this offset — mirrors
705    /// `DmaTensor::mmap_offset` / `IoSurfaceTensor::view_offset`.
706    pub(crate) view_offset: usize,
707    _marker: PhantomData<T>,
708}
709
710impl<T> fmt::Debug for PboTensor<T>
711where
712    T: Num + Clone + fmt::Debug + Send + Sync,
713{
714    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
715        f.debug_struct("PboTensor")
716            .field("name", &self.name)
717            .field("shape", &self.shape)
718            .field("buffer_id", &self.handle.buffer_id)
719            .field("size", &self.handle.size)
720            .finish()
721    }
722}
723
724unsafe impl<T> Send for PboTensor<T> where T: Num + Clone + fmt::Debug + Send + Sync {}
725unsafe impl<T> Sync for PboTensor<T> where T: Num + Clone + fmt::Debug + Send + Sync {}
726
727impl<T> PboTensor<T>
728where
729    T: Num + Clone + fmt::Debug + Send + Sync,
730{
731    /// Create a new PBO tensor from an already-allocated GL buffer.
732    ///
733    /// Called by the image crate after creating the PBO on the GL thread.
734    /// Users should not call this directly — use `ImageProcessor::create_image()`.
735    ///
736    /// # Errors
737    ///
738    /// Returns `Error::ShapeMismatch` if `size` does not equal
739    /// `shape.iter().product::<usize>() * std::mem::size_of::<T>()`.
740    /// Returns `Error::InvalidSize` if `size` is zero.
741    /// Returns `Error::InvalidShape` if that shape footprint overflows
742    /// `usize` — refused rather than wrapped to a small byte count an
743    /// undersized `size` would then satisfy.
744    pub fn from_pbo(
745        buffer_id: u32,
746        size: usize,
747        shape: &[usize],
748        name: Option<&str>,
749        ops: Arc<dyn PboOps>,
750    ) -> Result<Self> {
751        if size == 0 {
752            return Err(Error::InvalidSize(0));
753        }
754        // Checked, not `product() * size_of::<T>()`: this shape arrives
755        // straight from a C caller through `ef_tensor_wrap_pbo`, and a
756        // product that wraps `usize` in a release build computes a *small*
757        // footprint, slips past the `size <` test below, and mints a tensor
758        // whose logical extent runs far past the GL allocation. One shared
759        // helper with `reshape`/`set_logical_shape`/`view` so all four
760        // footprints in this file obey the same rule.
761        let expected = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(shape)?;
762        // Allow `size >= expected`: PBOs allocated with a 64-byte-aligned row
763        // stride may be larger than the shape product.  Reject only if the
764        // allocation is strictly smaller than the logical content.
765        if size < expected {
766            return Err(Error::ShapeMismatch(format!(
767                "PBO size {size} is smaller than shape {shape:?} * sizeof({}) = {expected}",
768                std::any::type_name::<T>(),
769            )));
770        }
771        let name = name.unwrap_or("pbo_tensor").to_owned();
772        Ok(Self {
773            name,
774            shape: shape.to_vec(),
775            handle: Arc::new(PboHandle {
776                ops,
777                buffer_id,
778                size,
779                map_state: Mutex::new(MapState::Unmapped),
780                map_cv: Condvar::new(),
781                #[cfg(feature = "static")]
782                abi_vtable: OnceLock::new(),
783            }),
784            // A GL buffer name is meaningful only inside its creating
785            // context (no system-wide key exists), but it is unique among
786            // this context's live PBOs, which is exactly what the
787            // per-context import cache needs to key on.
788            identity: BufferIdentity::derived(IdentityKind::Pbo, buffer_id as u64),
789            view_offset: 0,
790            _marker: PhantomData,
791        })
792    }
793
794    /// Returns the GL buffer ID for this PBO.
795    pub fn buffer_id(&self) -> u32 {
796        self.handle.buffer_id
797    }
798
799    /// The C-ABI vtable dispatching into this PBO's `ops`, for cross-cdylib
800    /// export via [`crate::TensorDesc`]. See [`PboOpsVtable`]'s own doc
801    /// comment. Called from `static`'s `pbo_vtable_ptr`
802    /// (`lib.rs`/`static_backend.rs`); `dynamic` reads the library's own
803    /// table through `ef_tensor_pbo_vtable` instead, so this is
804    /// `static`-only.
805    #[cfg(feature = "static")]
806    pub(crate) fn pbo_vtable(&self) -> &PboOpsVtable {
807        self.handle
808            .abi_vtable
809            .get_or_init(|| PboOpsVtable::new(&self.handle))
810    }
811
812    /// A type-erased keepalive holding this PBO's `Arc<PboHandle>` alive --
813    /// the same shape as [`crate::pin::HostPin`]'s own `Keepalive` (an
814    /// `Arc<dyn Send + Sync>`), for a now-narrower reason: [`PboOpsVtable::
815    /// new`] hands out `state.ctx`, this `PboHandle`'s own address, and that
816    /// pointer must still be live at the moment an importer reads the table
817    /// and calls `state.retain`. Past that call the importer holds its own
818    /// reference (see [`client_state_pbo_ops`]) and needs nothing external,
819    /// so this is belt-and-braces over the import window rather than the
820    /// only thing standing between a consumer and freed memory. Cloning this
821    /// `Arc` (not the `PboHandle`'s *contents*) is what a cross-package
822    /// capsule holds alongside the descriptor, exactly the way `pin` already
823    /// does for the `HOST` kind -- see `TensorCapsulePayload`
824    /// (`edgefirst-python-common`). Type-erased so this crate's public
825    /// surface never has to name `PboHandle`, which stays private.
826    ///
827    /// Called from `static`'s `pbo_keepalive`
828    /// (`lib.rs`/`static_backend.rs`); `dynamic` holds a retained reference
829    /// on the handle that owns the library-side `PboTensor` instead, so this
830    /// is `static`-only.
831    #[cfg(feature = "static")]
832    pub(crate) fn pbo_keepalive(&self) -> Arc<dyn Send + Sync> {
833        self.handle.clone()
834    }
835
836    /// Returns true unless the PBO is fully unmapped.
837    ///
838    /// That covers an exclusive map, one or more read-only holders, AND a
839    /// map or unmap another thread currently has in flight — during those
840    /// windows the GL buffer is (or is about to be) mapped, and no other
841    /// mapping may begin, so reporting `false` would be misleading. Use it
842    /// as "is this buffer free for GL operations?", not as "does a CPU
843    /// pointer exist right now".
844    pub fn is_mapped(&self) -> bool {
845        self.handle.is_mapped()
846    }
847}
848
849/// The C-ABI parts `ef_tensor_wrap_pbo` takes, from a [`PboTensor`] **at
850/// the origin** of its buffer.
851///
852/// "At the origin", not "whole-buffer": what
853/// [`PboTensor::into_client_parts`] guards is the byte offset, not the
854/// extent. `view(0, &[16])` on a 32-byte buffer passes and yields
855/// `shape = [16]`, `size = 32` — a correct, representable tensor, because
856/// `size` is the GL allocation's full byte count and is *documented* to
857/// exceed the shape's product (that is exactly how a stride-padded PBO is
858/// carried). A non-zero `view_offset` is the only thing this struct cannot
859/// express, and that is what is refused.
860///
861/// `state.ctx` **borrows** the channel this value still owns; the callee is
862/// expected to `retain` it (which [`client_state_pbo_ops`] does) before this
863/// struct drops. Dropping it without retaining releases the client's own
864/// last reference and deletes the GL buffer — which is why the `Arc` is a
865/// field here rather than something the caller has to remember to hold.
866///
867/// Deliberately neither `Send` nor `Sync`: `EfClientState` holds a raw
868/// pointer and `edgefirst-tensor-abi` adds no `unsafe impl`. That is right
869/// for a value whose only purpose is to be handed straight to a C entry
870/// point on the calling thread. If this ever needs to cross a thread, that
871/// is a design question to raise, not something to unblock with an
872/// `unsafe impl`.
873pub struct PboClientParts {
874    /// The callback channel, borrowing this value's own reference.
875    pub state: EfClientState,
876    pub map_fn: EfPboMapFn,
877    pub unmap_fn: EfPboUnmapFn,
878    pub buffer_id: u32,
879    /// The GL allocation's full byte count, which may exceed the shape's
880    /// product (a 64-byte-aligned row stride).
881    pub size: usize,
882    pub shape: Vec<usize>,
883    /// The client's own strong count on the channel, released when this
884    /// value drops. Private: `PboHandle` is not part of this crate's
885    /// public surface.
886    _handle: Arc<PboHandle>,
887}
888
889impl<T> PboTensor<T>
890where
891    T: Num + Clone + fmt::Debug + Send + Sync,
892{
893    /// Decompose this tensor into the parts a C entry point takes.
894    ///
895    /// The shape is carried through as-is and is **not** required to fill
896    /// the allocation: `size` is the GL buffer's full byte count and may
897    /// exceed the shape's product by design (a 64-byte-aligned row stride
898    /// is the usual reason). Only the *origin* is constrained.
899    ///
900    /// # Errors
901    /// [`Error::InvalidOperation`] for a sub-view (`view_offset != 0`).
902    /// A sub-view's window is not expressible in `ef_tensor_wrap_pbo`'s
903    /// argument list, and a wrap that silently dropped it would land the
904    /// far side at the parent's origin — issue #162 in reverse. Views are
905    /// taken on the far side (`Tensor::view`) once the buffer is wrapped,
906    /// so nothing needs this.
907    pub fn into_client_parts(self) -> Result<PboClientParts> {
908        if self.view_offset != 0 {
909            return Err(Error::InvalidOperation(format!(
910                "PboTensor::into_client_parts: cannot wrap a sub-view \
911                 (view_offset = {}); wrap the whole buffer and take the view \
912                 on the other side",
913                self.view_offset
914            )));
915        }
916        let state = EfClientState {
917            ctx: Arc::as_ptr(&self.handle) as *const c_void,
918            retain: Some(vt_retain),
919            release: Some(vt_release),
920        };
921        Ok(PboClientParts {
922            state,
923            map_fn: vt_map_buffer,
924            unmap_fn: vt_unmap_buffer,
925            buffer_id: self.handle.buffer_id,
926            size: self.handle.size,
927            shape: self.shape.clone(),
928            _handle: self.handle,
929        })
930    }
931}
932
933impl<T> TensorTrait<T> for PboTensor<T>
934where
935    T: Num + Clone + fmt::Debug + Send + Sync,
936{
937    fn new(_shape: &[usize], _name: Option<&str>) -> Result<Self> {
938        Err(Error::NotImplemented(
939            "PboTensor cannot be created directly — use ImageProcessor::create_image()".to_owned(),
940        ))
941    }
942
943    #[cfg(unix)]
944    fn from_fd(_fd: std::os::fd::OwnedFd, _shape: &[usize], _name: Option<&str>) -> Result<Self> {
945        Err(Error::NotImplemented(
946            "PboTensor does not support from_fd".to_owned(),
947        ))
948    }
949
950    #[cfg(unix)]
951    fn clone_fd(&self) -> Result<std::os::fd::OwnedFd> {
952        Err(Error::NotImplemented(
953            "PboTensor does not support clone_fd".to_owned(),
954        ))
955    }
956
957    fn memory(&self) -> TensorMemory {
958        TensorMemory::Pbo
959    }
960
961    fn name(&self) -> String {
962        self.name.clone()
963    }
964
965    fn shape(&self) -> &[usize] {
966        &self.shape
967    }
968
969    fn reshape(&mut self, shape: &[usize]) -> Result<()> {
970        if shape.is_empty() {
971            return Err(Error::InvalidSize(0));
972        }
973        let new_size = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(shape)?;
974        if new_size != self.handle.size {
975            return Err(Error::ShapeMismatch(format!(
976                "Cannot reshape incompatible shape: {:?} to {:?}",
977                self.shape, shape
978            )));
979        }
980        self.shape = shape.to_vec();
981        self.view_offset = 0;
982        Ok(())
983    }
984
985    fn capacity_bytes(&self) -> usize {
986        self.handle.size
987    }
988
989    /// Capacity-based reconfigure (mirrors Mem/Shm/DMA/IOSurface): allow any
990    /// shape whose byte size fits the PBO allocation, so an oversized reusable
991    /// pool can be `configure_image`d to a smaller image. Without this PBO fell
992    /// back to the strict-`reshape` default and rejected pool reuse.
993    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
994        if shape.is_empty() {
995            return Err(Error::InvalidSize(0));
996        }
997        let needed = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(shape)?;
998        if needed > self.handle.size {
999            return Err(Error::InsufficientCapacity {
1000                needed,
1001                capacity: self.handle.size,
1002            });
1003        }
1004        self.shape = shape.to_vec();
1005        Ok(())
1006    }
1007
1008    fn map_with<'a>(&self, access: crate::CpuAccess) -> Result<crate::view::HostView<'a, T>>
1009    where
1010        T: 'a,
1011    {
1012        self.map_internal(None, access)
1013    }
1014
1015    fn buffer_identity(&self) -> &BufferIdentity {
1016        &self.identity
1017    }
1018
1019    /// Zero-copy sub-region view sharing this PBO's GL buffer (via the
1020    /// `Arc<PboHandle>`) and [`BufferIdentity`], positioned at `offset_bytes`
1021    /// from this tensor's own window with logical `shape`. The GL backend keys
1022    /// the import on the shared identity and addresses the window via
1023    /// `glViewport` / the staged copy; a CPU map adds `view_offset` to the
1024    /// mapped base. Mirrors [`DmaTensor::view`](crate::TensorTrait::view).
1025    ///
1026    /// # Errors
1027    ///
1028    /// - [`Error::InvalidOperation`] if `offset_bytes` is mis-aligned for `T`.
1029    /// - [`Error::InsufficientCapacity`] if the window exceeds the allocation.
1030    fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self> {
1031        if !offset_bytes.is_multiple_of(std::mem::align_of::<T>()) {
1032            return Err(Error::InvalidOperation(format!(
1033                "PboTensor::view: offset {offset_bytes} not aligned to align_of::<T>()={}",
1034                std::mem::align_of::<T>()
1035            )));
1036        }
1037        let abs_offset = self
1038            .view_offset
1039            .checked_add(offset_bytes)
1040            .ok_or(Error::InvalidSize(offset_bytes))?;
1041        let logical = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(shape)?;
1042        let needed = abs_offset
1043            .checked_add(logical)
1044            .ok_or(Error::InvalidSize(logical))?;
1045        if needed > self.handle.size {
1046            return Err(Error::InsufficientCapacity {
1047                needed,
1048                capacity: self.handle.size,
1049            });
1050        }
1051        Ok(Self {
1052            name: self.name.clone(),
1053            shape: shape.to_vec(),
1054            handle: Arc::clone(&self.handle),
1055            identity: self.identity.clone(),
1056            view_offset: abs_offset,
1057            _marker: PhantomData,
1058        })
1059    }
1060}
1061
1062impl<T> PboTensor<T>
1063where
1064    T: Num + Clone + fmt::Debug + Send + Sync,
1065{
1066    /// Map the PBO so `as_slice()` exposes the full padded buffer (`byte_size`
1067    /// bytes) rather than the shape-derived logical count. Mirrors
1068    /// [`DmaTensor::map_with_byte_size`]: a CPU producer (e.g. the JPEG decoder)
1069    /// or a strided convert source iterates rows via `effective_row_stride()`
1070    /// without running past the slice. Crate-private; the only caller is
1071    /// `Tensor::map()`, which already checks `byte_size <= capacity_bytes()`.
1072    ///
1073    /// `static`-only: `Tensor::map()` (`impl<T> TensorMapTrait<T> for
1074    /// Tensor<T>` in `lib.rs`) is itself `#[cfg(feature = "static")]`. Only
1075    /// this one accessor gates -- `PboTensor` and the rest of its API stay
1076    /// available under `dynamic` too, since `PboOps`/`PboMapping` are a
1077    /// backend-agnostic GL extension point `edgefirst-image` implements
1078    /// regardless of which tensor backend it links.
1079    #[cfg(feature = "static")]
1080    pub(crate) fn map_with_byte_size<'a>(
1081        &self,
1082        byte_size: usize,
1083        access: crate::CpuAccess,
1084    ) -> Result<crate::view::HostView<'a, T>>
1085    where
1086        T: 'a,
1087    {
1088        self.map_internal(Some(byte_size), access)
1089    }
1090
1091    fn map_internal<'a>(
1092        &self,
1093        byte_size_override: Option<usize>,
1094        access: crate::CpuAccess,
1095    ) -> Result<crate::view::HostView<'a, T>>
1096    where
1097        T: 'a,
1098    {
1099        // Always map the full GL allocation (`handle.size`); the slice length is
1100        // narrowed by `byte_size_override` (or the logical shape) at access time.
1101        // scoped_pin acquires the GL map; its keepalive releases it exactly
1102        // once when the last clone drops, which is what the `released` flag
1103        // used to do by hand.
1104        Ok(crate::view::HostView::new(
1105            self.scoped_pin(access)?,
1106            self.shape.clone(),
1107            byte_size_override,
1108            access,
1109        ))
1110    }
1111}
1112
1113/// Non-null base address of a live GL buffer mapping.
1114///
1115/// Still used by `PboHandle`'s refcounted acquire/release even though the
1116/// per-backend `PboMap` it once fed is gone: the handle serialises GL maps
1117/// itself, and this is what it hands back.
1118#[derive(Debug)]
1119struct PboPtr(NonNull<c_void>);
1120
1121impl Deref for PboPtr {
1122    type Target = NonNull<c_void>;
1123    fn deref(&self) -> &Self::Target {
1124        &self.0
1125    }
1126}
1127
1128unsafe impl Send for PboPtr {}
1129
1130/// Keepalive that holds a PBO's CPU map open for a [`HostPin`]'s lifetime.
1131///
1132/// A PBO's address is only valid between `glMapBufferRange` and
1133/// `glUnmapBuffer`, so — unlike DMA-BUF or IOSurface — the map IS the lifetime.
1134/// `PboTensor::scoped_pin` acquires it and this releases it when the last clone
1135/// of the pin drops, which reproduces `PboMap::unmap`'s once-only release
1136/// through `Arc` rather than a `released` flag.
1137// Staged for the HostView collapse (Plan 2b Task 4): constructed once
1138// map_with migrates to HostView. Kept compiling and reviewable rather than
1139// landing the whole collapse in one unverifiable change.
1140#[allow(dead_code)]
1141pub(crate) struct PboMapLock {
1142    handle: Arc<PboHandle>,
1143}
1144
1145impl Drop for PboMapLock {
1146    fn drop(&mut self) {
1147        self.handle.release_map();
1148    }
1149}
1150
1151impl<T> PboTensor<T>
1152where
1153    T: Num + Clone + fmt::Debug + Send + Sync,
1154{
1155    /// Acquire a host address valid for as long as the returned pin lives.
1156    ///
1157    /// Private on purpose: this is NOT `pin_host`, which refuses PBO precisely
1158    /// because the address cannot outlive the map. A guard built on this pin
1159    /// releases the map when it drops, which is the only correct lifetime.
1160    #[allow(dead_code)]
1161    pub(crate) fn scoped_pin<'a>(&self, access: crate::CpuAccess) -> Result<crate::pin::HostPin<'a>>
1162    where
1163        T: 'a,
1164    {
1165        let ptr = self.handle.acquire_map(access.writes())?;
1166        let base = unsafe { (ptr.as_ptr() as *mut u8).add(self.view_offset) };
1167        let len = self.handle.size.saturating_sub(self.view_offset);
1168        Ok(crate::pin::HostPin::new(
1169            Arc::new(PboMapLock {
1170                handle: Arc::clone(&self.handle),
1171            }),
1172            base,
1173            len,
1174        ))
1175    }
1176}
1177
1178// -- PboMap --
1179
1180impl<T> Clone for PboTensor<T>
1181where
1182    T: Num + Clone + fmt::Debug + Send + Sync,
1183{
1184    fn clone(&self) -> Self {
1185        Self {
1186            name: self.name.clone(),
1187            shape: self.shape.clone(),
1188            handle: Arc::clone(&self.handle),
1189            identity: self.identity.clone(),
1190            view_offset: self.view_offset,
1191            _marker: PhantomData,
1192        }
1193    }
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use crate::TensorMapTrait;
1200
1201    /// Mock PboOps that uses a Vec<u8> as backing storage instead of GL.
1202    ///
1203    /// Counts map/unmap calls so tests can assert that N read-only holders
1204    /// share ONE GL mapping rather than each taking their own.
1205    struct MockPboOps {
1206        storage: Mutex<Vec<u8>>,
1207        maps: std::sync::atomic::AtomicUsize,
1208        unmaps: std::sync::atomic::AtomicUsize,
1209        deletes: std::sync::atomic::AtomicUsize,
1210    }
1211
1212    impl MockPboOps {
1213        fn new(size: usize) -> Arc<Self> {
1214            Arc::new(Self {
1215                storage: Mutex::new(vec![0u8; size]),
1216                maps: std::sync::atomic::AtomicUsize::new(0),
1217                unmaps: std::sync::atomic::AtomicUsize::new(0),
1218                deletes: std::sync::atomic::AtomicUsize::new(0),
1219            })
1220        }
1221
1222        fn map_count(&self) -> usize {
1223            self.maps.load(std::sync::atomic::Ordering::Acquire)
1224        }
1225
1226        fn delete_count(&self) -> usize {
1227            self.deletes.load(std::sync::atomic::Ordering::Acquire)
1228        }
1229
1230        fn unmap_count(&self) -> usize {
1231            self.unmaps.load(std::sync::atomic::Ordering::Acquire)
1232        }
1233    }
1234
1235    // SAFETY: the returned pointer addresses a `Vec<u8>` that is allocated
1236    // once in `new` and never resized, so it stays valid for the whole life
1237    // of the `MockPboOps` — which outlives every map handed out, since the
1238    // tensor holds an `Arc` to it. The storage mutex is NOT what keeps the
1239    // pointer alive (`map_buffer` releases it before returning); it only
1240    // guards the length assertion.
1241    unsafe impl PboOps for MockPboOps {
1242        fn map_buffer(&self, _buffer_id: u32, size: usize) -> Result<PboMapping> {
1243            self.maps.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1244            let storage = self.storage.lock().expect("lock");
1245            assert_eq!(storage.len(), size);
1246            Ok(PboMapping {
1247                ptr: storage.as_ptr() as *mut u8,
1248                size,
1249            })
1250        }
1251
1252        fn unmap_buffer(&self, _buffer_id: u32) -> Result<()> {
1253            self.unmaps
1254                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1255            Ok(())
1256        }
1257
1258        fn delete_buffer(&self, _buffer_id: u32) {
1259            self.deletes
1260                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1261        }
1262    }
1263
1264    #[test]
1265    fn test_pbo_tensor_create_and_metadata() {
1266        let ops = MockPboOps::new(24);
1267        let tensor = PboTensor::<u8>::from_pbo(42, 24, &[2, 3, 4], Some("test_pbo"), ops).unwrap();
1268        assert_eq!(tensor.memory(), TensorMemory::Pbo);
1269        assert_eq!(tensor.name(), "test_pbo");
1270        assert_eq!(tensor.shape(), &[2, 3, 4]);
1271        assert_eq!(tensor.buffer_id(), 42);
1272        assert!(!tensor.is_mapped());
1273    }
1274
1275    #[test]
1276    fn test_pbo_tensor_map_write_read() {
1277        let ops = MockPboOps::new(12);
1278        let tensor = PboTensor::<u8>::from_pbo(1, 12, &[3, 4], Some("rw_test"), ops).unwrap();
1279        {
1280            let mut map = tensor.map().expect("map should succeed");
1281            assert_eq!(map.shape(), &[3, 4]);
1282            assert!(tensor.is_mapped());
1283            map.as_mut_slice().fill(0xAB);
1284            assert!(map.as_slice().iter().all(|&b| b == 0xAB));
1285        }
1286        assert!(!tensor.is_mapped());
1287    }
1288
1289    #[test]
1290    fn test_pbo_tensor_double_map_fails() {
1291        let ops = MockPboOps::new(8);
1292        let tensor = PboTensor::<u8>::from_pbo(2, 8, &[8], None, ops).unwrap();
1293        let _map1 = tensor.map().expect("first map should succeed");
1294        assert!(tensor.is_mapped());
1295        let result = tensor.map();
1296        assert!(result.is_err(), "second map while mapped should fail");
1297    }
1298
1299    /// Mock whose `map_buffer` reports success but hands back a null
1300    /// pointer — the one path where a claimed map has to be released again.
1301    #[derive(Default)]
1302    struct NullMappingOps {
1303        unmaps: std::sync::atomic::AtomicUsize,
1304    }
1305
1306    impl NullMappingOps {
1307        fn unmap_count(&self) -> usize {
1308            self.unmaps.load(std::sync::atomic::Ordering::Acquire)
1309        }
1310    }
1311
1312    // SAFETY: never produces a usable pointer, so no caller can dereference
1313    // one; the mapping it returns is rejected before reaching a slice.
1314    unsafe impl PboOps for NullMappingOps {
1315        fn map_buffer(&self, _buffer_id: u32, size: usize) -> Result<PboMapping> {
1316            Ok(PboMapping {
1317                ptr: std::ptr::null_mut(),
1318                size,
1319            })
1320        }
1321
1322        fn unmap_buffer(&self, _buffer_id: u32) -> Result<()> {
1323            self.unmaps
1324                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1325            Ok(())
1326        }
1327
1328        fn delete_buffer(&self, _buffer_id: u32) {}
1329    }
1330
1331    /// An implementation that reports success while mapping FEWER bytes than
1332    /// were asked for. The bytes it does map are real, so a caller that
1333    /// trusted the success would read `size` bytes from a `size / 2`-byte
1334    /// allocation — the shape of the bug, not a null-pointer stand-in.
1335    #[derive(Default)]
1336    struct ShortMappingOps {
1337        storage: Mutex<Vec<u8>>,
1338        unmaps: std::sync::atomic::AtomicUsize,
1339    }
1340
1341    impl ShortMappingOps {
1342        fn unmap_count(&self) -> usize {
1343            self.unmaps.load(std::sync::atomic::Ordering::Acquire)
1344        }
1345    }
1346
1347    // SAFETY: the pointer addresses a `Vec<u8>` this value owns for its
1348    // whole life, and the mapping is rejected before reaching a slice.
1349    unsafe impl PboOps for ShortMappingOps {
1350        fn map_buffer(&self, _buffer_id: u32, size: usize) -> Result<PboMapping> {
1351            let short = size / 2;
1352            let mut storage = self.storage.lock().expect("lock");
1353            storage.resize(short, 0);
1354            Ok(PboMapping {
1355                ptr: storage.as_mut_ptr(),
1356                size: short,
1357            })
1358        }
1359
1360        fn unmap_buffer(&self, _buffer_id: u32) -> Result<()> {
1361            self.unmaps
1362                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1363            Ok(())
1364        }
1365
1366        fn delete_buffer(&self, _buffer_id: u32) {}
1367    }
1368
1369    /// A short mapping is a failed mapping: every consumer of the returned
1370    /// base (`scoped_pin`, `map_internal`) sizes its host slice from
1371    /// `handle.size`, so accepting `out_len < size` would hand out an
1372    /// out-of-bounds slice with no error anywhere. The buffer GL does
1373    /// consider mapped is released, exactly as for a null mapping.
1374    #[test]
1375    fn short_mapping_is_refused_and_the_buffer_released() {
1376        let ops = Arc::new(ShortMappingOps::default());
1377        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1378        let tensor = PboTensor::<u8>::from_pbo(13, 32, &[32], None, dyn_ops).unwrap();
1379
1380        let Err(err) = tensor.map_read() else {
1381            panic!("a mapping shorter than the allocation must not succeed");
1382        };
1383        assert!(
1384            matches!(
1385                err,
1386                Error::InsufficientCapacity {
1387                    needed: 32,
1388                    capacity: 16
1389                }
1390            ),
1391            "unexpected error: {err:?}"
1392        );
1393        assert_eq!(
1394            ops.unmap_count(),
1395            1,
1396            "GL still holds the buffer mapped — the refused map must release it"
1397        );
1398        assert!(
1399            !tensor.is_mapped(),
1400            "state must return to Unmapped so later maps can proceed"
1401        );
1402    }
1403
1404    /// A shape whose byte footprint wraps `usize` must be refused, not
1405    /// multiplied down to something an eight-byte allocation satisfies.
1406    /// `ef_tensor_wrap_pbo` forwards a C caller's `dims` here unchecked, so
1407    /// this is the boundary that has to hold: `2^63 * 4` is `0` modulo
1408    /// `usize`, and the accepted tensor would then report `2^65` elements
1409    /// over eight real bytes.
1410    #[test]
1411    fn from_pbo_refuses_a_shape_whose_footprint_overflows() {
1412        let ops = MockPboOps::new(8);
1413        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1414        let result = PboTensor::<u8>::from_pbo(14, 8, &[1usize << 63, 4], None, dyn_ops);
1415        let Err(err) = result else {
1416            panic!("an overflowing shape footprint must be refused");
1417        };
1418        assert!(
1419            matches!(err, Error::InvalidShape(_)),
1420            "unexpected error: {err:?}"
1421        );
1422    }
1423
1424    /// The same rule on the three reshaping paths, which recompute the
1425    /// footprint against an allocation that is already fixed.
1426    #[test]
1427    fn reshape_paths_refuse_an_overflowing_footprint() {
1428        let ops = MockPboOps::new(8);
1429        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1430        let mut tensor = PboTensor::<u8>::from_pbo(15, 8, &[8], None, dyn_ops).unwrap();
1431        let huge = [1usize << 63, 4];
1432
1433        assert!(
1434            matches!(tensor.reshape(&huge), Err(Error::InvalidShape(_))),
1435            "reshape must refuse a wrapping footprint"
1436        );
1437        assert!(
1438            matches!(tensor.set_logical_shape(&huge), Err(Error::InvalidShape(_))),
1439            "set_logical_shape must refuse a wrapping footprint"
1440        );
1441        assert!(
1442            matches!(tensor.view(0, &huge), Err(Error::InvalidShape(_))),
1443            "view must refuse a wrapping footprint"
1444        );
1445    }
1446
1447    /// A map that "succeeds" with a null pointer leaves GL considering the
1448    /// buffer mapped, so it must be released and the state returned to
1449    /// unmapped — otherwise the buffer is stranded and every later map
1450    /// fails. The release happens before `Unmapped` is published, so no
1451    /// waiter can start a map that races the cleanup unmap.
1452    #[test]
1453    fn null_mapping_releases_the_buffer_and_resets_state() {
1454        let ops = Arc::new(NullMappingOps::default());
1455        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1456        let tensor = PboTensor::<u8>::from_pbo(12, 8, &[8], None, dyn_ops).unwrap();
1457
1458        let Err(err) = tensor.map_read() else {
1459            panic!("a null mapping must not succeed");
1460        };
1461        assert!(
1462            matches!(err, Error::InvalidSize(_)),
1463            "unexpected error: {err:?}"
1464        );
1465        assert_eq!(
1466            ops.unmap_count(),
1467            1,
1468            "GL still holds the buffer mapped — the failed map must release it"
1469        );
1470        assert!(
1471            !tensor.is_mapped(),
1472            "state must return to Unmapped so later maps can proceed"
1473        );
1474
1475        // Not stranded: the buffer can be claimed again.
1476        let Err(err) = tensor.map_read() else {
1477            panic!("still null, so it must still fail");
1478        };
1479        assert!(matches!(err, Error::InvalidSize(_)));
1480        assert_eq!(ops.unmap_count(), 2, "and released again");
1481    }
1482
1483    /// Several read-only holders share ONE GL mapping. This is what lets
1484    /// tiled pre-processing run several workers over one source tensor;
1485    /// before read sharing, every reader after the first failed.
1486    #[test]
1487    fn read_maps_share_one_gl_mapping() {
1488        let ops = MockPboOps::new(8);
1489        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1490        let tensor = PboTensor::<u8>::from_pbo(9, 8, &[8], None, dyn_ops).unwrap();
1491
1492        let r1 = tensor.map_read().expect("first read map");
1493        let r2 = tensor
1494            .map_read()
1495            .expect("second read map must share, not fail");
1496        let r3 = tensor
1497            .map_read()
1498            .expect("third read map must share, not fail");
1499
1500        assert_eq!(ops.map_count(), 1, "one GL mapping serves every reader");
1501        assert_eq!(ops.unmap_count(), 0, "nothing unmapped while readers live");
1502        assert!(tensor.is_mapped());
1503        // All readers address the same bytes.
1504        assert_eq!(r1.as_slice(), r2.as_slice());
1505        assert_eq!(r2.as_slice(), r3.as_slice());
1506
1507        drop(r1);
1508        drop(r2);
1509        assert_eq!(
1510            ops.unmap_count(),
1511            0,
1512            "the mapping outlives every reader but the last"
1513        );
1514        assert!(tensor.is_mapped());
1515
1516        drop(r3);
1517        assert_eq!(ops.unmap_count(), 1, "last reader out unmaps, exactly once");
1518        assert!(!tensor.is_mapped());
1519    }
1520
1521    /// Read sharing must not weaken write exclusion in either direction.
1522    #[test]
1523    fn writers_and_readers_still_exclude_each_other() {
1524        let ops = MockPboOps::new(8);
1525        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1526        let tensor = PboTensor::<u8>::from_pbo(10, 8, &[8], None, dyn_ops).unwrap();
1527
1528        let reader = tensor.map_read().expect("read map");
1529        assert!(
1530            tensor.map().is_err(),
1531            "a writer must not join an existing reader set"
1532        );
1533        drop(reader);
1534
1535        let writer = tensor.map().expect("write map after readers drained");
1536        assert!(
1537            tensor.map_read().is_err(),
1538            "a reader must not join an exclusive writer"
1539        );
1540        assert!(
1541            tensor.map().is_err(),
1542            "a second writer must not join an exclusive writer"
1543        );
1544        drop(writer);
1545
1546        // Fully released — a fresh map of either kind succeeds again.
1547        assert!(tensor.map_read().is_ok());
1548    }
1549
1550    /// `unmap()` is public on the trait and also runs from `Drop`. With
1551    /// refcounted readers a double release would unmap a SIBLING reader's
1552    /// mapping, so each map must release at most once.
1553    #[test]
1554    fn explicit_unmap_then_drop_releases_only_once() {
1555        let ops = MockPboOps::new(8);
1556        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1557        let tensor = PboTensor::<u8>::from_pbo(11, 8, &[8], None, dyn_ops).unwrap();
1558
1559        let keeper = tensor.map_read().expect("reader that must survive");
1560        let mut early = tensor.map_read().expect("reader unmapped by hand");
1561        early.unmap();
1562        drop(early); // Drop must NOT release a second time.
1563
1564        assert_eq!(
1565            ops.unmap_count(),
1566            0,
1567            "one reader still holds the mapping — it must not be unmapped"
1568        );
1569        assert!(tensor.is_mapped(), "the surviving reader still holds it");
1570        // The survivor's pointer is still valid and readable.
1571        assert_eq!(keeper.as_slice().len(), 8);
1572
1573        drop(keeper);
1574        assert_eq!(ops.unmap_count(), 1);
1575        assert!(!tensor.is_mapped());
1576    }
1577
1578    #[test]
1579    fn test_pbo_tensor_reshape() {
1580        let ops = MockPboOps::new(24);
1581        let mut tensor = PboTensor::<u8>::from_pbo(3, 24, &[2, 3, 4], None, ops).unwrap();
1582        tensor
1583            .reshape(&[4, 6])
1584            .expect("compatible reshape should succeed");
1585        assert_eq!(tensor.shape(), &[4, 6]);
1586        let result = tensor.reshape(&[100]);
1587        assert!(result.is_err(), "incompatible reshape should fail");
1588    }
1589
1590    #[test]
1591    fn test_pbo_tensor_set_logical_shape_capacity_based() {
1592        // A 24-byte PBO can be reconfigured to any shape that fits (unlike the
1593        // strict `reshape`), so an oversized reusable pool can be
1594        // `configure_image`d to a smaller image (the native-chroma decode pool).
1595        let ops = MockPboOps::new(24);
1596        let mut tensor = PboTensor::<u8>::from_pbo(7, 24, &[24], None, ops).unwrap();
1597        // Smaller-than-capacity logical shape is accepted (reshape would reject).
1598        tensor
1599            .set_logical_shape(&[4, 5])
1600            .expect("shape within capacity should succeed");
1601        assert_eq!(tensor.shape(), &[4, 5]);
1602        // Exactly-capacity is fine.
1603        tensor.set_logical_shape(&[24]).unwrap();
1604        // Over-capacity is rejected.
1605        assert!(
1606            tensor.set_logical_shape(&[5, 5]).is_err(),
1607            "shape exceeding PBO capacity must be rejected"
1608        );
1609    }
1610
1611    #[test]
1612    fn test_pbo_tensor_buffer_identity() {
1613        let ops1 = MockPboOps::new(8);
1614        let ops2 = MockPboOps::new(8);
1615        let t1 = PboTensor::<u8>::from_pbo(1, 8, &[8], None, ops1).unwrap();
1616        let t2 = PboTensor::<u8>::from_pbo(2, 8, &[8], None, ops2).unwrap();
1617        assert_eq!(t1.buffer_identity().kind(), IdentityKind::Pbo);
1618        assert_ne!(t1.buffer_identity().id(), t2.buffer_identity().id());
1619    }
1620
1621    #[test]
1622    fn two_pbos_with_the_same_gl_name_share_an_identity() {
1623        // The identity is derived from the GL buffer name, not a per-call
1624        // counter -- two independent `from_pbo` wraps of the same name (the
1625        // in-process analog of two independently-linked copies of this crate
1626        // each importing the same PBO) must agree, or the per-context import
1627        // cache misses on every wrap instead of reusing the bound texture.
1628        let ops1 = MockPboOps::new(8);
1629        let ops2 = MockPboOps::new(8);
1630        let t1 = PboTensor::<u8>::from_pbo(7, 8, &[8], None, ops1).unwrap();
1631        let t2 = PboTensor::<u8>::from_pbo(7, 8, &[8], None, ops2).unwrap();
1632        assert_eq!(t1.buffer_identity().id(), t2.buffer_identity().id());
1633    }
1634
1635    #[test]
1636    fn test_pbo_tensor_new_returns_error() {
1637        let result = PboTensor::<u8>::new(&[8], None);
1638        assert!(result.is_err(), "PboTensor::new() should fail");
1639    }
1640
1641    #[cfg(unix)]
1642    #[test]
1643    fn test_pbo_tensor_fd_ops_return_error() {
1644        let ops = MockPboOps::new(8);
1645        let tensor = PboTensor::<u8>::from_pbo(1, 8, &[8], None, ops).unwrap();
1646        assert!(tensor.clone_fd().is_err());
1647    }
1648
1649    #[test]
1650    fn test_pbo_tensor_from_pbo_size_mismatch() {
1651        let ops = MockPboOps::new(24);
1652        let result = PboTensor::<u8>::from_pbo(1, 24, &[2, 3, 5], None, ops);
1653        assert!(result.is_err(), "mismatched size/shape should fail");
1654    }
1655
1656    #[test]
1657    fn test_pbo_tensor_from_pbo_zero_size() {
1658        let ops = MockPboOps::new(0);
1659        let result = PboTensor::<u8>::from_pbo(1, 0, &[0], None, ops);
1660        assert!(result.is_err(), "zero size should fail");
1661    }
1662
1663    #[test]
1664    fn test_pbo_via_tensor_enum() {
1665        let ops = MockPboOps::new(12);
1666        let pbo = PboTensor::<u8>::from_pbo(10, 12, &[3, 4], Some("enum_test"), ops).unwrap();
1667        let tensor = crate::Tensor::wrap(crate::TensorStorage::Pbo(pbo));
1668        assert_eq!(tensor.memory(), TensorMemory::Pbo);
1669        assert_eq!(tensor.name(), "enum_test");
1670        assert_eq!(tensor.shape(), &[3, 4]);
1671        let mut map = tensor.map().expect("map via enum");
1672        map.as_mut_slice().fill(42);
1673        assert!(map.as_slice().iter().all(|&b| b == 42));
1674    }
1675
1676    /// The Task-11 acceptance criterion at the Rust level: export a
1677    /// PBO-backed tensor's descriptor, reconstruct it, and read back real
1678    /// bytes -- not just a non-error. Models the producer/consumer split
1679    /// the cross-package capsule protocol exists for (`edgefirst.image`
1680    /// allocates and writes; a different `cdylib`, `edgefirst.codec`,
1681    /// reconstructs from just the `TensorDesc` and reads real bytes back),
1682    /// but both halves run in this one process, which is all `cargo test`
1683    /// can exercise. The genuine two-`.so` case -- the reason this needed a
1684    /// `#[repr(C)]` vtable rather than a same-process registry -- is what
1685    /// `tests/interop/test_cross_package.py::
1686    /// test_decode_into_gpu_backed_tensor_from_another_package` and
1687    /// `tests/image/test_image.py::test_decode_image_pipeline` prove.
1688    #[test]
1689    fn cross_package_pbo_round_trips_through_tensor_desc_with_real_bytes() {
1690        let ops = MockPboOps::new(8);
1691        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1692        let producer = PboTensor::<u8>::from_pbo(42, 8, &[8], None, dyn_ops).unwrap();
1693        {
1694            let mut m = producer.map().expect("map producer");
1695            m.as_mut_slice().copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
1696        }
1697
1698        // The producer side: build a real TensorDesc the way
1699        // `Tensor.__edgefirst_tensor__()` does under the hood.
1700        let producer_dyn =
1701            crate::TensorDyn::U8(crate::Tensor::wrap(crate::TensorStorage::Pbo(producer)));
1702        let desc = producer_dyn.descriptor();
1703        assert_eq!(desc.kind, crate::protocol::kind::PBO);
1704        assert_eq!(desc.handle, 42);
1705        assert!(
1706            !desc.ptr.is_null(),
1707            "a PBO descriptor must carry an ops vtable -- see TensorDesc::ptr's doc comment"
1708        );
1709
1710        // The consumer side: reconstruct from ONLY the descriptor, exactly
1711        // what `import_descriptor` does for a genuinely foreign handle --
1712        // no access to `producer_dyn` itself from here on.
1713        let imported = crate::TensorDyn::import_descriptor(&desc)
1714            .expect("import_descriptor must reconstruct a PBO-backed tensor from its own vtable");
1715        assert_eq!(imported.memory(), TensorMemory::Pbo);
1716
1717        let m = imported
1718            .map_bytes(crate::CpuAccess::Read)
1719            .expect("map the reconstructed tensor");
1720        assert_eq!(
1721            m.as_slice(),
1722            &[1u8, 2, 3, 4, 5, 6, 7, 8],
1723            "the reconstructed tensor must read the SAME bytes the producer wrote through the \
1724             SAME GL buffer, not a copy or garbage"
1725        );
1726    }
1727
1728    /// Hazard 1, made structurally moot rather than merely handled: GL
1729    /// buffer ids are scoped per-context, so two DIFFERENT contexts can
1730    /// legitimately hand out the SAME buffer_id for UNRELATED buffers. A
1731    /// registry keyed on buffer_id alone would resolve the second import to
1732    /// the first context's ops -- a wrong-buffer bug with no error. This
1733    /// design has no id to key anything on: the descriptor's `ptr` IS the
1734    /// address of the specific `PboHandle` it was built from, so two
1735    /// tensors sharing a buffer_id (simulating two independent contexts
1736    /// that happen to collide on the number) cannot cross-resolve, full
1737    /// stop -- there is no shared table for them to collide *in*.
1738    #[test]
1739    fn two_pbo_tensors_sharing_a_buffer_id_do_not_cross_resolve() {
1740        let ops_a = MockPboOps::new(4);
1741        let ops_b = MockPboOps::new(4);
1742        ops_a.storage.lock().unwrap().copy_from_slice(&[0xAA; 4]);
1743        ops_b.storage.lock().unwrap().copy_from_slice(&[0xBB; 4]);
1744        let dyn_a: Arc<dyn PboOps> = ops_a.clone();
1745        let dyn_b: Arc<dyn PboOps> = ops_b.clone();
1746
1747        // SAME buffer_id, two entirely unrelated backing buffers -- exactly
1748        // the scenario two independent GL contexts could produce.
1749        let tensor_a = PboTensor::<u8>::from_pbo(7, 4, &[4], None, dyn_a).unwrap();
1750        let tensor_b = PboTensor::<u8>::from_pbo(7, 4, &[4], None, dyn_b).unwrap();
1751
1752        let dyn_a_wrapped =
1753            crate::TensorDyn::U8(crate::Tensor::wrap(crate::TensorStorage::Pbo(tensor_a)));
1754        let dyn_b_wrapped =
1755            crate::TensorDyn::U8(crate::Tensor::wrap(crate::TensorStorage::Pbo(tensor_b)));
1756        let desc_a = dyn_a_wrapped.descriptor();
1757        let desc_b = dyn_b_wrapped.descriptor();
1758        assert_eq!(
1759            desc_a.handle, desc_b.handle,
1760            "both descriptors share the same buffer_id by construction"
1761        );
1762        assert_ne!(
1763            desc_a.ptr.0, desc_b.ptr.0,
1764            "but each carries its own, distinct vtable address -- there is no shared key \
1765             for them to collide on"
1766        );
1767
1768        let imported_a = crate::TensorDyn::import_descriptor(&desc_a).unwrap();
1769        let imported_b = crate::TensorDyn::import_descriptor(&desc_b).unwrap();
1770        assert_eq!(
1771            imported_a
1772                .map_bytes(crate::CpuAccess::Read)
1773                .unwrap()
1774                .as_slice(),
1775            &[0xAA; 4],
1776            "importing desc_a must reach ops_a's buffer, never ops_b's"
1777        );
1778        assert_eq!(
1779            imported_b
1780                .map_bytes(crate::CpuAccess::Read)
1781                .unwrap()
1782                .as_slice(),
1783            &[0xBB; 4],
1784            "importing desc_b must reach ops_b's buffer, never ops_a's"
1785        );
1786    }
1787
1788    /// Hazard 2: an `Arc<dyn PboOps>` registry would hold its own strong
1789    /// reference to `ops`, so the failure mode is a **leak** -- specifically
1790    /// a GL context kept alive past its own teardown, unless something
1791    /// explicitly removes the entry when the registering `PboTensor` drops.
1792    /// This design has no separate reference to remove: `PboOpsVtable::ctx`
1793    /// is a raw pointer into the `PboHandle`'s own `ops` field, not a fresh
1794    /// `Arc` clone, so building and exporting a descriptor must not change
1795    /// `ops`'s strong count at all, and dropping the producing tensor must
1796    /// release its one real reference immediately -- proven directly via
1797    /// `Arc::strong_count`, not merely "nothing crashed".
1798    #[test]
1799    fn dropping_the_producing_pbo_tensor_leaks_nothing_the_vtable_touched() {
1800        let ops = MockPboOps::new(8);
1801        let dyn_ops: Arc<dyn PboOps> = ops.clone();
1802        assert_eq!(
1803            Arc::strong_count(&ops),
1804            2,
1805            "this test's own handle, plus the clone about to move into from_pbo"
1806        );
1807
1808        let producer = PboTensor::<u8>::from_pbo(99, 8, &[8], None, dyn_ops).unwrap();
1809        assert_eq!(
1810            Arc::strong_count(&ops),
1811            2,
1812            "from_pbo must not clone ops again"
1813        );
1814
1815        let producer_dyn =
1816            crate::TensorDyn::U8(crate::Tensor::wrap(crate::TensorStorage::Pbo(producer)));
1817        // Force the vtable to build -- the exact operation hazard 2 worried
1818        // about: does exporting a descriptor create a NEW strong reference
1819        // to `ops` that nothing ever releases?
1820        let _desc = producer_dyn.descriptor();
1821        assert_eq!(
1822            Arc::strong_count(&ops),
1823            2,
1824            "building/exporting the vtable must not clone ops -- see PboOpsVtable's own \
1825             doc comment for why ctx is a raw pointer, not an Arc clone"
1826        );
1827
1828        assert_eq!(ops.delete_count(), 0, "not dropped yet");
1829        drop(producer_dyn);
1830        assert_eq!(
1831            Arc::strong_count(&ops),
1832            1,
1833            "dropping the producing tensor must release its ops reference immediately -- \
1834             nothing the vtable machinery built kept it alive past that"
1835        );
1836        assert_eq!(
1837            ops.delete_count(),
1838            1,
1839            "the real delete_buffer must still fire exactly once, from PboHandle::Drop, \
1840             completely unaffected by whether a descriptor was ever exported"
1841        );
1842    }
1843
1844    /// The callback channel a `PboOpsVtable` hands out is self-sufficient:
1845    /// an importer that retains it keeps the producer's `PboHandle` — and
1846    /// therefore the real GL buffer — alive after the producing tensor has
1847    /// dropped, and the buffer is deleted exactly once, when the last
1848    /// reference goes.
1849    ///
1850    /// This is the case today's design cannot express. `PboOpsVtable::new`
1851    /// used to hand out a bare borrowed pointer with the lifetime enforced
1852    /// *externally* by a `pbo_keepalive()` `Arc` some other object had to
1853    /// hold; nothing in the vtable itself could extend it. A child that
1854    /// outlived its parent read freed memory. Against a change that added
1855    /// the retain but forgot the `release` in `ImportedPboOps::drop`,
1856    /// `delete_count()` stays 0 and the LAST assertion fails instead.
1857    #[test]
1858    fn an_imported_channel_keeps_the_producers_buffer_alive() {
1859        let ops = MockPboOps::new(64);
1860        let producer = PboTensor::<u8>::from_pbo(21, 64, &[64], None, ops.clone())
1861            .expect("PboTensor::from_pbo");
1862        let vtable_ptr = producer.pbo_vtable() as *const PboOpsVtable as *const c_void;
1863
1864        // SAFETY: `vtable_ptr` came from `pbo_vtable()` on a live tensor.
1865        let parts = unsafe { read_pbo_vtable_parts(vtable_ptr) }.expect("read_pbo_vtable_parts");
1866        // SAFETY: the parts came from a table this module built.
1867        let imported = unsafe { client_state_pbo_ops(parts.state, parts.map_fn, parts.unmap_fn) }
1868            .expect("client_state_pbo_ops");
1869        let child = PboTensor::<u8>::from_pbo(21, 64, &[64], None, imported)
1870            .expect("reconstruct across the boundary");
1871
1872        // The producing tensor goes away first — the case the external
1873        // keepalive design could not express at all.
1874        drop(producer);
1875        assert_eq!(
1876            ops.delete_count(),
1877            0,
1878            "the producer's PboHandle must still be alive: the imported \
1879             channel holds a reference to it"
1880        );
1881
1882        // The child still works through the retained channel.
1883        {
1884            let mut m = child.map().expect("map through the imported channel");
1885            m.as_mut_slice()[0] = 0xA5;
1886        }
1887        assert_eq!(ops.map_count(), 1, "the map went through the real ops");
1888        assert_eq!(ops.unmap_count(), 1, "so did the unmap");
1889
1890        drop(child);
1891        assert_eq!(
1892            ops.delete_count(),
1893            1,
1894            "the GL buffer is deleted exactly once, when the last reference \
1895             to the channel goes"
1896        );
1897    }
1898
1899    /// `into_client_parts` hands out a *borrowed* channel: the parts carry
1900    /// the producer's own `Arc`, so a callee that retains before they drop
1901    /// keeps the GL buffer, and a callee that never retains does not.
1902    ///
1903    /// This is the shape `ef_tensor_wrap_pbo` consumes, exercised here
1904    /// against `client_state_pbo_ops` — the same assembly point a C caller
1905    /// reaches.
1906    #[test]
1907    fn client_parts_hand_over_a_channel_the_callee_retains() {
1908        let ops = MockPboOps::new(32);
1909        let tensor = PboTensor::<u8>::from_pbo(7, 32, &[32], None, ops.clone())
1910            .expect("PboTensor::from_pbo");
1911        let parts = tensor.into_client_parts().expect("into_client_parts");
1912        // Before the reattach, and on its own line: consuming the tensor must
1913        // not have dropped the channel. `PboClientParts::_handle` is what
1914        // keeps it, and a regression that removed that field would otherwise
1915        // survive to the `client_state_pbo_ops` call below and retain a dead
1916        // `Arc` -- undefined behaviour instead of a test failure.
1917        assert_eq!(
1918            ops.delete_count(),
1919            0,
1920            "into_client_parts must hand over a live channel, not a dead one"
1921        );
1922        assert_eq!(parts.buffer_id, 7);
1923        assert_eq!(parts.size, 32);
1924        assert_eq!(parts.shape, vec![32]);
1925
1926        // SAFETY: the parts came from a live `PboTensor`, so they satisfy
1927        // `client_state_pbo_ops`'s contract by construction.
1928        let reattached = unsafe { client_state_pbo_ops(parts.state, parts.map_fn, parts.unmap_fn) }
1929            .expect("client_state_pbo_ops");
1930
1931        // The producer's own last reference goes. The callee retained, so
1932        // the GL buffer is still there.
1933        drop(parts);
1934        assert_eq!(
1935            ops.delete_count(),
1936            0,
1937            "the callee's retain must outlive the parts it was handed"
1938        );
1939
1940        reattached
1941            .map_buffer(7, 32)
1942            .expect("map through the channel");
1943        reattached.unmap_buffer(7).expect("unmap");
1944        assert_eq!(ops.map_count(), 1);
1945
1946        drop(reattached);
1947        assert_eq!(
1948            ops.delete_count(),
1949            1,
1950            "the buffer goes exactly once, when the last reference does"
1951        );
1952    }
1953
1954    /// A sub-view has a window `ef_tensor_wrap_pbo`'s argument list cannot
1955    /// carry, so it is refused rather than silently wrapped at the parent's
1956    /// origin — #162 in reverse.
1957    #[test]
1958    fn client_parts_refuse_a_sub_view() {
1959        let ops = MockPboOps::new(32);
1960        let tensor =
1961            PboTensor::<u8>::from_pbo(8, 32, &[32], None, ops).expect("PboTensor::from_pbo");
1962        let view = tensor.view(16, &[16]).expect("view");
1963        let Err(err) = view.into_client_parts() else {
1964            panic!("a sub-view must not wrap silently");
1965        };
1966        assert!(
1967            matches!(err, Error::InvalidOperation(_)),
1968            "expected InvalidOperation, got {err:?}"
1969        );
1970    }
1971
1972    /// `TensorDyn::from_client_pbo` refuses a NULL `ctx`, `retain` or
1973    /// `release` with `Error::InvalidArgument` — the **static twin** of
1974    /// `dynamic_primitives.rs`'s
1975    /// `dynamic_from_client_pbo_refuses_an_incomplete_channel`.
1976    ///
1977    /// Both assert the same variant on the same call deliberately: a
1978    /// consumer must not get a different `Error` for the same malformed
1979    /// channel depending on which backend it linked. Under `dynamic` that
1980    /// only holds because the C entry point classifies the failure and
1981    /// `ffi_error` rebuilds it from the class rather than guessing.
1982    #[test]
1983    fn from_client_pbo_refuses_an_incomplete_channel() {
1984        let ops = MockPboOps::new(16);
1985        let tensor =
1986            PboTensor::<u8>::from_pbo(9, 16, &[16], None, ops).expect("PboTensor::from_pbo");
1987        let parts = tensor.into_client_parts().expect("into_client_parts");
1988        // A genuine `ctx`, for the reason
1989        // `a_client_state_missing_any_part_is_refused` gives below.
1990        let full = parts.state;
1991        for (name, state) in [
1992            (
1993                "ctx",
1994                EfClientState {
1995                    ctx: std::ptr::null(),
1996                    ..full
1997                },
1998            ),
1999            (
2000                "retain",
2001                EfClientState {
2002                    retain: None,
2003                    ..full
2004                },
2005            ),
2006            (
2007                "release",
2008                EfClientState {
2009                    release: None,
2010                    ..full
2011                },
2012            ),
2013        ] {
2014            // SAFETY: `full` came from a live `PboTensor`, so every variant
2015            // of it is a state this constructor may legally inspect.
2016            let r = unsafe {
2017                crate::TensorDyn::from_client_pbo(
2018                    state,
2019                    parts.map_fn,
2020                    parts.unmap_fn,
2021                    9,
2022                    16,
2023                    &[16],
2024                    crate::DType::U8,
2025                )
2026            };
2027            let Err(err) = r else {
2028                panic!("a NULL {name} must be refused");
2029            };
2030            assert!(
2031                matches!(err, Error::InvalidArgument(_)),
2032                "NULL {name}: expected InvalidArgument, got {err:?}"
2033            );
2034        }
2035    }
2036
2037    /// The lower half of the same contract: `client_state_pbo_ops` itself,
2038    /// which both backends' `from_client_pbo` delegate to. A NULL `ctx`,
2039    /// `retain` or `release` is a loud `InvalidArgument`, not a silently
2040    /// non-owning attach — spec §6, the failure class this design exists to
2041    /// remove.
2042    #[test]
2043    fn a_client_state_missing_any_part_is_refused() {
2044        let ops = MockPboOps::new(16);
2045        let tensor =
2046            PboTensor::<u8>::from_pbo(9, 16, &[16], None, ops).expect("PboTensor::from_pbo");
2047        let parts = tensor.into_client_parts().expect("into_client_parts");
2048        // A *genuine* `ctx` on purpose. If one of the checks below ever
2049        // regressed, the call would retain a real `Arc` -- a refcount the
2050        // `else` arm's panic reports cleanly -- rather than drive
2051        // `Arc::increment_strong_count` through a forged pointer, which
2052        // would turn a test failure into undefined behaviour.
2053        let full = parts.state;
2054        for (name, state) in [
2055            (
2056                "ctx",
2057                EfClientState {
2058                    ctx: std::ptr::null(),
2059                    ..full
2060                },
2061            ),
2062            (
2063                "retain",
2064                EfClientState {
2065                    retain: None,
2066                    ..full
2067                },
2068            ),
2069            (
2070                "release",
2071                EfClientState {
2072                    release: None,
2073                    ..full
2074                },
2075            ),
2076        ] {
2077            // SAFETY: `full` came from a live `PboTensor`, so every variant
2078            // of it is a state `client_state_pbo_ops` may legally inspect.
2079            let Err(err) = (unsafe { client_state_pbo_ops(state, parts.map_fn, parts.unmap_fn) })
2080            else {
2081                panic!("a NULL {name} must be refused");
2082            };
2083            assert!(
2084                matches!(err, Error::InvalidArgument(_)),
2085                "NULL {name}: expected InvalidArgument, got {err:?}"
2086            );
2087        }
2088    }
2089
2090    /// `set_plane_offset` must move a PBO's CPU-map window, not merely
2091    /// record a number on the wrapper.
2092    ///
2093    /// The PBO mirror of `set_plane_offset_moves_the_iosurface_map_window`
2094    /// (`crates/tensor/src/iosurface.rs`). `PboTensor::view_offset` is the
2095    /// offset `scoped_pin` adds to the mapped base, and `PboTensor::view`
2096    /// already set it on the fresh-view path — so, exactly like IOSurface,
2097    /// only *restoring* an offset onto an already-built tensor was broken,
2098    /// which is why a freshly created view was right and only the
2099    /// descriptor round trip was wrong. Before the `Pbo` arm this falls
2100    /// into `set_plane_offset`'s `_ => {}` and the map starts at the
2101    /// parent's origin.
2102    #[test]
2103    fn set_plane_offset_moves_the_pbo_map_window() {
2104        use crate::{Tensor, TensorTrait};
2105
2106        const PARENT: usize = 256;
2107        const OFFSET: usize = 64;
2108        const WINDOW: usize = 64;
2109
2110        // Ramp the buffer so byte i holds i: a window at the wrong origin
2111        // cannot coincidentally match the expected bytes.
2112        let ops = MockPboOps::new(PARENT);
2113        let whole = Tensor::<u8>::from_pbo(
2114            PboTensor::<u8>::from_pbo(31, PARENT, &[PARENT], None, ops.clone()).expect("from_pbo"),
2115        )
2116        .expect("wrap");
2117        {
2118            let mut m = whole.map().expect("map the whole buffer");
2119            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
2120                *b = (i & 0xff) as u8;
2121            }
2122        }
2123
2124        // Re-wrap the WHOLE buffer, as an import does: the handle names the
2125        // parent, so this lands at its origin.
2126        let mut rebuilt = Tensor::<u8>::from_pbo(
2127            PboTensor::<u8>::from_pbo(31, PARENT, &[PARENT], None, ops.clone()).expect("from_pbo"),
2128        )
2129        .expect("wrap");
2130        rebuilt
2131            .set_logical_shape(&[WINDOW])
2132            .expect("narrow to the window's shape");
2133        {
2134            let m = rebuilt.map().expect("map before the offset");
2135            assert_eq!(
2136                m.as_slice()[0],
2137                0,
2138                "precondition: a bare wrap starts at the parent's origin"
2139            );
2140        }
2141
2142        // The restore under test.
2143        rebuilt.set_plane_offset(OFFSET);
2144        assert_eq!(
2145            rebuilt.plane_offset(),
2146            Some(OFFSET),
2147            "the wrapper field records the offset on every backing"
2148        );
2149
2150        let m = rebuilt.map().expect("map after the offset");
2151        let s = m.as_slice();
2152        assert_eq!(s.len(), WINDOW, "the window exposes its logical length");
2153        assert_ne!(
2154            s[0], 0,
2155            "map() still starts at the parent's origin — set_plane_offset was \
2156             a no-op on PBO storage (issue #161)"
2157        );
2158        for (i, b) in s.iter().enumerate() {
2159            assert_eq!(
2160                *b,
2161                ((i + OFFSET) & 0xff) as u8,
2162                "window byte {i} must be parent[{}]",
2163                i + OFFSET
2164            );
2165        }
2166    }
2167
2168    /// A nested `subview` must not compound its offset now that
2169    /// `set_plane_offset` writes through to PBO storage.
2170    ///
2171    /// The double-apply guard, and the reason it needs checking rather than
2172    /// assuming: `Tensor::subview` sets the offset *twice* by two different
2173    /// routes — `PboTensor::view` computes `view_offset + offset_bytes`
2174    /// inside `TensorStorage::view`, and then `subview` calls
2175    /// `set_plane_offset(plane_offset + offset_bytes)` on the result. Those
2176    /// are the same number only while the wrapper field and the storage
2177    /// field stay in sync, which is what the new arm makes true. Two levels,
2178    /// because a single level cannot tell an idempotent write apart from one
2179    /// that compounds from zero.
2180    #[test]
2181    fn nested_pbo_subviews_do_not_compound_their_plane_offset() {
2182        use crate::{Tensor, TensorTrait};
2183
2184        let ops = MockPboOps::new(256);
2185        let parent = Tensor::<u8>::from_pbo(
2186            PboTensor::<u8>::from_pbo(32, 256, &[256], None, ops).expect("from_pbo"),
2187        )
2188        .expect("wrap");
2189        {
2190            let mut m = parent.map().expect("map parent");
2191            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
2192                *b = (i & 0xff) as u8;
2193            }
2194        }
2195
2196        let first = parent.subview(32, &[128]).expect("first subview");
2197        assert_eq!(first.plane_offset(), Some(32));
2198        let second = first.subview(16, &[64]).expect("nested subview");
2199        assert_eq!(
2200            second.plane_offset(),
2201            Some(48),
2202            "a nested subview composes 32 + 16, it does not compound"
2203        );
2204        let m = second.map().expect("map the nested view");
2205        for (i, b) in m.as_slice().iter().enumerate() {
2206            assert_eq!(*b, ((i + 48) & 0xff) as u8, "nested byte {i}");
2207        }
2208    }
2209
2210    /// `set_format` must clear the PBO's storage-internal window, not only
2211    /// the wrapper field.
2212    ///
2213    /// The clear that pairs with the setter above: without it, changing the
2214    /// format leaves `plane_offset() == None` while `map()` still starts at
2215    /// the old offset — a *stale* window rather than a lost one, which is
2216    /// strictly harder to notice.
2217    #[test]
2218    fn set_format_clears_the_pbo_map_window() {
2219        use crate::{PixelFormat, Tensor, TensorTrait};
2220
2221        let ops = MockPboOps::new(256);
2222        let mut t = Tensor::<u8>::from_pbo(
2223            PboTensor::<u8>::from_pbo(33, 256, &[256], None, ops).expect("from_pbo"),
2224        )
2225        .expect("wrap");
2226        {
2227            let mut m = t.map().expect("map");
2228            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
2229                *b = (i & 0xff) as u8;
2230            }
2231        }
2232        t.set_logical_shape(&[8, 8, 1]).expect("narrow");
2233        t.set_plane_offset(64);
2234
2235        t.set_format(PixelFormat::Grey).expect("set_format");
2236        assert_eq!(t.plane_offset(), None, "the wrapper field is cleared");
2237        let m = t.map().expect("map after set_format");
2238        assert_eq!(
2239            m.as_slice()[0],
2240            0,
2241            "map() must be back at the origin: a cleared wrapper field over a \
2242             live storage offset is a stale window"
2243        );
2244    }
2245
2246    /// Mock whose map and unmap both fail: with `PboDisconnected` when
2247    /// `disconnected`, otherwise with an unrelated error.
2248    struct FailingOps {
2249        disconnected: bool,
2250    }
2251
2252    impl FailingOps {
2253        fn error(&self) -> Error {
2254            if self.disconnected {
2255                Error::PboDisconnected
2256            } else {
2257                Error::InvalidOperation("GL call failed".into())
2258            }
2259        }
2260    }
2261
2262    // SAFETY: never produces a pointer at all.
2263    unsafe impl PboOps for FailingOps {
2264        fn map_buffer(&self, _buffer_id: u32, _size: usize) -> Result<PboMapping> {
2265            Err(self.error())
2266        }
2267
2268        fn unmap_buffer(&self, _buffer_id: u32) -> Result<()> {
2269            Err(self.error())
2270        }
2271
2272        fn delete_buffer(&self, _buffer_id: u32) {}
2273    }
2274
2275    /// `ops` as an importer on the far side of the vtable sees it.
2276    fn import_through_the_vtable(ops: Arc<dyn PboOps>) -> Arc<dyn PboOps> {
2277        let producer = PboTensor::<u8>::from_pbo(4, 16, &[16], None, ops).expect("from_pbo");
2278        let vtable_ptr = producer.pbo_vtable() as *const PboOpsVtable as *const c_void;
2279        // SAFETY: `vtable_ptr` came from `pbo_vtable()` on a live tensor, and
2280        // the importer retains the channel before the producer drops.
2281        unsafe {
2282            let parts = read_pbo_vtable_parts(vtable_ptr).expect("read_pbo_vtable_parts");
2283            client_state_pbo_ops(parts.state, parts.map_fn, parts.unmap_fn)
2284                .expect("client_state_pbo_ops")
2285        }
2286    }
2287
2288    /// A disconnected GL context crosses the vtable as itself, so an importer
2289    /// can tell "gone" from any other failure.
2290    #[test]
2291    fn a_disconnected_producer_is_reported_as_disconnected_across_the_vtable() {
2292        let imported = import_through_the_vtable(Arc::new(FailingOps { disconnected: true }));
2293        assert!(matches!(
2294            imported.map_buffer(4, 16),
2295            Err(Error::PboDisconnected)
2296        ));
2297        assert!(matches!(
2298            imported.unmap_buffer(4),
2299            Err(Error::PboDisconnected)
2300        ));
2301    }
2302
2303    /// Any other producer failure crosses the vtable as a generic failure.
2304    #[test]
2305    fn other_producer_failures_cross_the_vtable_as_a_generic_errno() {
2306        let imported = import_through_the_vtable(Arc::new(FailingOps {
2307            disconnected: false,
2308        }));
2309        for result in [
2310            imported.map_buffer(4, 16).map(|_| ()),
2311            imported.unmap_buffer(4),
2312        ] {
2313            match result {
2314                Err(Error::NotImplemented(msg)) => assert!(msg.ends_with("errno 1"), "{msg}"),
2315                other => panic!("expected NotImplemented(.. errno 1), got {other:?}"),
2316            }
2317        }
2318    }
2319
2320    #[test]
2321    fn debug_names_the_buffer() {
2322        let t = PboTensor::<u8>::from_pbo(77, 16, &[16], Some("dbg"), MockPboOps::new(16))
2323            .expect("from_pbo");
2324        let text = format!("{t:?}");
2325        assert!(text.contains("PboTensor"), "{text}");
2326        assert!(text.contains("buffer_id: 77"), "{text}");
2327        assert!(text.contains("size: 16"), "{text}");
2328        assert!(text.contains("\"dbg\""), "{text}");
2329    }
2330}