edgefirst_tensor/tensor_dyn/derived.rs
1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Methods expressible purely over the primitive `TensorDyn` API, written
5//! once and compiled into both the `static` and `dynamic` backends.
6//!
7//! [`TensorDyn::import_descriptor`] is the first resident: the consumer
8//! half of the cross-package tensor protocol. It was `static`-only until
9//! `edgefirst-python-common` -- the first consumer to call `TensorDyn` at
10//! the Rust level under `dynamic` -- needed it, and almost all of it is
11//! backend-independent. Only the per-kind *construction* switch names
12//! constructors and therefore differs; that stays in each backend as
13//! `import_storage`, and the validation before it and the metadata restore
14//! after it live here, once. Writing the whole function twice was the
15//! alternative and it is the shape this branch exists to delete: the
16//! stride-restore reasoning below is forty lines of load-bearing
17//! commentary that a second copy would eventually contradict.
18
19use crate::{DType, Result, TensorDesc, TensorDyn};
20
21impl TensorDyn {
22 /// Rebuild a tensor addressing the memory an [`crate::TensorDesc`]
23 /// describes — the consumer half of the cross-package tensor protocol
24 /// (see [`crate::protocol`]'s module docs).
25 ///
26 /// **Borrow semantics.** The result *aliases* the producer's memory; it
27 /// is valid only while the producer keeps that memory alive. For the
28 /// capsule protocol, that is the capsule's own lifetime: the capsule's
29 /// keepalive holds the producing tensor (and, for a host-memory descriptor,
30 /// its pin) for as long as the capsule itself is alive. A `DMABUF`
31 /// import is the one exception with its own, narrower guarantee — this
32 /// function `dup`s the fd, so the imported tensor's fd stays valid even
33 /// after the producer's tensor drops (closing its own copy); the
34 /// underlying dma-buf allocation itself is still only alive as long as
35 /// *some* fd or GPU import references it. `D3D11_TEXTURE` behaves the
36 /// same way for the same reason: the import duplicates the texture and
37 /// fence NT handles it keeps, so the result outlives the producer's
38 /// tensor while the texture itself lives as long as some handle or view
39 /// references it. Both of the descriptor's handle values must be valid
40 /// **in this process** — the protocol duplicates, it does not reach into
41 /// another process's handle table (the blob transport does; see
42 /// [`crate::blob::import`]). A consumer needing to survive past the
43 /// capsule for any other kind (`HOST`, `IOSURFACE`) must take its own
44 /// reference explicitly — this function does not.
45 ///
46 /// # Errors
47 ///
48 /// * [`Error::NotImplemented`](crate::Error::NotImplemented) — an ABI
49 /// major this build does not understand ([`desc.version`](crate::TensorDesc::version)),
50 /// a dtype/kind code this build does not recognise, or a kind this
51 /// platform cannot import (e.g. dma-buf off Linux).
52 /// * [`Error::InvalidArgument`](crate::Error::InvalidArgument) — a
53 /// malformed descriptor: a missing fd/surface id/host address for the
54 /// kind it claims to be, or (macOS/iOS) a surface id that no longer
55 /// resolves to a live `IOSurface`.
56 pub fn import_descriptor(desc: &TensorDesc) -> Result<Self> {
57 let (dtype, shape) = validate_descriptor(desc)?;
58 let mut t = Self::import_storage(desc, &shape, dtype)?;
59 restore_descriptor_metadata(&mut t, desc, &shape)?;
60 Ok(t)
61 }
62}
63
64/// Refuse a descriptor this build cannot honour, and decode the two facts
65/// every `import_storage` arm needs from one that it can.
66///
67/// Separate from the construction switch so the refusals apply identically
68/// on both backends -- a descriptor is untrusted cross-package input, and
69/// "which producers do we refuse" is exactly the decision that must not
70/// have two implementations.
71fn validate_descriptor(desc: &TensorDesc) -> Result<(DType, Vec<usize>)> {
72 if desc.version != crate::ABI_VERSION {
73 return Err(crate::Error::NotImplemented(format!(
74 "tensor interop ABI v{} (this build understands v{})",
75 desc.version,
76 crate::ABI_VERSION
77 )));
78 }
79
80 if desc.ndim as usize > crate::TensorDesc::MAX_NDIM {
81 return Err(crate::Error::NotImplemented(format!(
82 "descriptor declares rank {}, but this build's descriptor \
83 carries only {} shape slots; importing would address a \
84 prefix of a larger allocation and silently read wrong data",
85 desc.ndim,
86 crate::TensorDesc::MAX_NDIM
87 )));
88 }
89 // A producer advertising a fence we cannot wait on is a correctness
90 // problem, not a compatibility nicety: importing anyway would alias
91 // memory whose contents are still being written by the producer's
92 // device, and the corruption would be timing-dependent.
93 //
94 // `D3D11_TEXTURE` is the one kind with a wait path: its descriptor names
95 // an `ID3D11Fence` plus a value, and `from_d3d11_shared_handle` issues
96 // the GPU-side wait before the import is usable. Every other kind still
97 // has nowhere to wait, so a producer advertising a fence is refused.
98 if desc.flags & crate::protocol::flags::SYNC_PRESENT != 0
99 && desc.kind != crate::protocol::kind::D3D11_TEXTURE
100 {
101 return Err(crate::Error::NotImplemented(
102 "descriptor advertises SYNC_PRESENT, but waiting on a \
103 producer fence is not implemented in this build; importing \
104 would alias memory with device work still in flight"
105 .to_owned(),
106 ));
107 }
108 // The same refusal for a `D3D11_TEXTURE` descriptor that advertises a
109 // fence value with no fence to read it on: `ptr` is where that kind
110 // carries the fence handle, so a null one leaves a completion nobody can
111 // wait on, which is the in-flight-write hazard again rather than a
112 // missing feature.
113 if desc.flags & crate::protocol::flags::SYNC_PRESENT != 0
114 && desc.kind == crate::protocol::kind::D3D11_TEXTURE
115 && desc.ptr.is_null()
116 {
117 return Err(crate::Error::InvalidArgument(
118 "D3D11_TEXTURE descriptor advertises SYNC_PRESENT but carries no \
119 fence handle in `ptr`; the completion it names cannot be waited on"
120 .to_owned(),
121 ));
122 }
123 let dtype = crate::protocol::dtype_to_dtype(desc.dtype).ok_or_else(|| {
124 crate::Error::NotImplemented(format!("tensor interop dtype code {}", desc.dtype))
125 })?;
126 let shape: Vec<usize> = desc.shape().iter().map(|d| *d as usize).collect();
127 Ok((dtype, shape))
128}
129
130/// Restore the producer's format, row pitch and colorimetry onto a
131/// freshly-imported tensor.
132///
133/// Runs after `import_storage` on both backends, over the public setters
134/// (`set_format`, `set_row_stride`, `set_colorimetry`) both already have --
135/// which is what makes it shareable at all.
136fn restore_descriptor_metadata(
137 t: &mut TensorDyn,
138 desc: &TensorDesc,
139 shape: &[usize],
140) -> Result<()> {
141 // `format` is the HAL-aware format code; it can represent every
142 // `PixelFormat` (including Planar, which has no FourCC). Fall back
143 // to `fourcc` only when the producer left `format` at NONE, for
144 // third-party/DRM-only producers that never set it.
145 let format = if desc.format != crate::protocol::format::NONE {
146 crate::protocol::format_from_code(desc.format)
147 } else if desc.fourcc != 0 {
148 crate::PixelFormat::from_fourcc(desc.fourcc)
149 } else {
150 None
151 };
152 if let Some(fmt) = format {
153 t.set_format(fmt)?;
154 restore_imported_row_stride(t, desc, shape, fmt);
155 }
156
157 // Colorimetry does not participate in `set_format`'s validation, so
158 // this can be unconditional: `0` unpacks to all-`None`, matching a
159 // freshly-imported tensor's default and costing nothing extra.
160 t.set_colorimetry(if desc.colorimetry == 0 {
161 None
162 } else {
163 Some(crate::Colorimetry::unpack(desc.colorimetry))
164 });
165
166 restore_d3d11_logical_shape(t, desc, shape)?;
167
168 // An alias may not allow more CPU access than its producer declared.
169 if let Some(access) = crate::protocol::flags::cpu_access(desc.flags) {
170 t.restrict_cpu_access(access);
171 }
172 // The descriptor cannot carry a `ViewOrigin`, so a view's import is marked instead.
173 if desc.flags & crate::protocol::flags::VIEW != 0 {
174 t.mark_view();
175 }
176
177 Ok(())
178}
179
180/// Give a `kind::D3D11_TEXTURE` import the shape its producer had.
181///
182/// The import opens the texture at the format's *allocation* shape, because
183/// that is what `from_d3d11_shared_handle` builds from width and height. A
184/// producer that called `set_logical_shape` was carrying the *addressing*
185/// shape instead (`[h, w]` for a semi-planar image rather than
186/// `[combined_h, w]`), and the descriptor faithfully reported it; without
187/// this the consumer would see a shape its producer did not have. The same
188/// narrowing rebuilds a `view()`: its descriptor carries the window's shape,
189/// the import opens the whole texture, and this cuts it down to the window
190/// (`Tensor::set_logical_shape` keeps the texture's pitch as the row stride
191/// while it does, or `view()`'s tight stride for a single-row window, so
192/// the window maps in the texture's last row). Where the window sits is the
193/// plane offset, which the
194/// descriptor cannot carry and the capsule restores afterwards. The import
195/// arm has already checked `shape` is one of those three spellings of the
196/// geometry the texture itself reports, so nothing untrusted reaches
197/// `set_logical_shape` here.
198///
199/// After the format restore, not before: `set_format` validates the shape it
200/// finds, and an addressing shape is not one it accepts for a semi-planar
201/// format (`[481, 640]` is an unreachable combined-plane height).
202///
203/// No other kind needs this. Every one of them is imported at exactly the
204/// shape the descriptor carries.
205fn restore_d3d11_logical_shape(
206 t: &mut TensorDyn,
207 desc: &TensorDesc,
208 shape: &[usize],
209) -> Result<()> {
210 if desc.kind != crate::protocol::kind::D3D11_TEXTURE || t.shape() == shape {
211 return Ok(());
212 }
213 t.set_logical_shape(shape)
214}
215
216/// Restore the producer's physical row pitch (the row dimension
217/// convention documented on `protocol::from_parts`) so a decode
218/// that reconfigures this import within its allocation --
219/// `configure_image`'s pool-reuse path -- keeps writing at the
220/// producer's real stride instead of recomputing a tighter one
221/// that only fits today's logical shape. Without this, `capacity`
222/// alone is not enough: `configure_image` only *prefers* a prior
223/// stride when one is recorded, so a fresh import with no stride
224/// still picks a freshly-computed (and possibly narrower) pitch.
225///
226/// `MEM`, `SHM` and `DMABUF`. Historical note: restoring a stride for an
227/// imported `DMABUF` used to be actively harmful -- `Tensor::map`'s
228/// strided path rejected any imported (non-self-allocated) DMA-BUF
229/// outright, so recording one here would have made a foreign
230/// DMA-BUF's CPU map fail with `InvalidOperation`. That was fixed
231/// at the map site instead (a strided imported DMA-BUF is now
232/// accepted there, bounds-checked against `buf_size` exactly like
233/// a self-allocated one is -- `fstat`-derived kernel truth when
234/// the kernel reports a usable size, the declared logical size in
235/// the rare fallback, and either way an out-of-range mmap is still
236/// rejected by the kernel), which is what makes restoring it here
237/// safe: the same argument that justified loosening `Tensor::map`
238/// applies verbatim to the value being restored here -- it is
239/// bounds-checked again by `set_row_stride`'s caller
240/// (`configure_image`, later) and by `Tensor::map` itself at write
241/// time, so an oversized or malformed producer-reported stride
242/// cannot cause an OOB write even though it is trusted without
243/// independent verification.
244/// Confirmed load-bearing, not just anticipated: a pool-sized
245/// (oversized) DMA-BUF-backed destination decoding a smaller
246/// image lost its real pitch without this, recomputing a
247/// tighter one instead -- silent misalignment for any GPU
248/// consumer reading at the true (wider) physical pitch.
249///
250/// `IOSURFACE` is included for the same reason `DMABUF` is, and with the
251/// same bound. An IOSurface's `bytesPerRow` is a property of the surface
252/// itself -- the same in every process that maps it -- so the producer's
253/// stride is the consumer's, and `IoSurfaceTensor::capacity_bytes` is the
254/// whole surface, so the `stride * rows <= capacity` check below bounds
255/// it the same way. Without it a view of a pitched surface, reopened at
256/// the window's shape, was read at the window's tight row over rows the
257/// surface stores a pitch apart (issue #161; pinned by
258/// `descriptor_import_restores_the_iosurface_pitch_onto_a_view`).
259///
260/// `D3D11_TEXTURE` is deliberately excluded: a texture import has a pitch
261/// of its own -- `from_d3d11_shared_handle` records the one this device's
262/// staging copy reports -- and the producer's is a fact about the
263/// producer's driver, not about the texture as this process sees it.
264/// `PBO` is included for the same reason `DMABUF` and `IOSURFACE` are, and
265/// with the same bound. A GL buffer has no pitch of its own -- it is bytes
266/// -- so the producer's stride is the only description of how its rows are
267/// spaced, and `PboTensor::capacity_bytes` is the whole GL allocation, so
268/// the `stride * rows <= capacity` check below bounds it the same way.
269/// Without it a `view()` of a PBO, whose recorded stride is the PARENT's
270/// pitch, was rebuilt at the window's own tight row and every row after the
271/// first read the wrong columns -- the shear half of issue #162, which the
272/// storage-kind half (a view demoting to a host placeholder) hid until
273/// Stage B fixed it. Excluded before Stage B for a reason that was true
274/// then: `import_descriptor` had no `kind::PBO` arm at all, so no PBO
275/// descriptor ever reached this function.
276///
277/// `CUDA` is excluded because that import does not reconstruct a
278/// strided image today.
279fn restore_imported_row_stride(
280 t: &mut TensorDyn,
281 desc: &TensorDesc,
282 shape: &[usize],
283 fmt: crate::PixelFormat,
284) {
285 if !matches!(
286 desc.kind,
287 crate::protocol::kind::MEM
288 | crate::protocol::kind::SHM
289 | crate::protocol::kind::DMABUF
290 | crate::protocol::kind::IOSURFACE
291 | crate::protocol::kind::PBO
292 ) {
293 return;
294 }
295 let row_dim = match fmt.layout() {
296 crate::PixelLayout::Planar if desc.ndim as usize >= 3 => 1,
297 _ => 0,
298 };
299 if (desc.ndim as usize) < 2 {
300 return;
301 }
302 // `strides` is signed to leave room for a future flip
303 // (negative stride); nothing produces one today, and
304 // `set_row_stride` has no way to express it, so a
305 // negative value here is simply skipped rather than
306 // reinterpreted as a huge unsigned pitch.
307 let Some(&stride_bytes) = desc.strides().get(row_dim) else {
308 return;
309 };
310 let Ok(stride_bytes) = usize::try_from(stride_bytes) else {
311 return;
312 };
313 // `set_row_stride` enforces only a minimum
314 // (`stride >= min_stride`); it explicitly does no
315 // size validation, by design (it is pure layout
316 // metadata, reused by `strides_follow_row_stride
317 // _not_shape`-style tests that never touch a real
318 // allocation). Import is not that case: `desc` is
319 // an untrusted cross-package payload, and an
320 // oversized stride here would otherwise be
321 // caught only downstream, by whichever consumer
322 // happens to multiply it out first
323 // (`Tensor::map`'s strided path, or
324 // `configure_image`'s own capacity check). The
325 // multiply below is checked -- the descriptor's
326 // stride is untrusted input, so it must not wrap
327 // into a small-looking value that passes a
328 // capacity comparison it should fail.
329 //
330 // `desc.strides()` is in bytes, so there is no
331 // element->byte conversion here (and no second
332 // multiply that could overflow) any more.
333 let rows: usize = match fmt.layout() {
334 crate::PixelLayout::Planar if shape.len() >= 2 => shape[0].saturating_mul(shape[1]),
335 _ => shape.first().copied().unwrap_or(0),
336 };
337 apply_imported_row_stride(t, fmt, stride_bytes, rows);
338}
339
340fn apply_imported_row_stride(
341 t: &mut TensorDyn,
342 fmt: crate::PixelFormat,
343 stride_bytes: usize,
344 rows: usize,
345) {
346 let capacity = t.capacity_bytes();
347 match stride_bytes.checked_mul(rows) {
348 Some(needed) if needed <= capacity => {
349 if let Err(e) = t.set_row_stride(stride_bytes) {
350 log::warn!(
351 "import_descriptor: producer row_stride \
352 {stride_bytes} bytes rejected for {fmt:?} \
353 ({e}); keeping the freshly-imported tight \
354 pitch instead"
355 );
356 }
357 }
358 Some(needed) => {
359 log::warn!(
360 "import_descriptor: producer row_stride \
361 {stride_bytes} bytes × {rows} rows needs {needed} \
362 bytes, exceeding this import's capacity \
363 {capacity}; keeping the freshly-imported tight \
364 pitch instead of trusting an oversized descriptor"
365 );
366 }
367 None => {
368 log::warn!(
369 "import_descriptor: producer row_stride \
370 {stride_bytes} bytes × {rows} rows overflows \
371 usize computing bytes needed; keeping the \
372 freshly-imported tight pitch instead of trusting \
373 a malformed descriptor"
374 );
375 }
376 }
377}