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}