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