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