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