Files
photocraft/crates/gpu/tests/it/bounded_readback.rs
Khawar Zamman WaniandFredApps 1089932ae9 Add a headless GPU backend for composition and flat export (#10842)
* Add headless GPU backend foundation for composition and flat export (issue #8062 PR 1)

* Fix headless GPU readback bounds, API compliance, and error preservation (#8062)

* Fix GPU readback bounds, context thread spawn error, and test parity (#8062)

* Fix BandRenderer Send bound, PNG reborrows, and clippy lints (#8062)

* Fix effect shadow test construction and remove dead code wrappers (#8062)

* Use short reborrows for Headless renderer without lifetime escaping (#8062)

* Fix clippy collapsible_if and ensure multi-band geometry in flat export tests (#8062)

* Fix automation Rect import, add renderer diagnostics, and harden flat export retries (#8062)

* Fix GPU feature test APIs and native target gates

* Verify unsupported GPU effects before testing band seams

* Use compositor buffer ownership API after upstream memory accounting change

* Update export renderer test helper for owned compositor buffers

* Fix headless GPU backend review findings (#10842)

- release a closed document's GPU caches through a BandRenderer hook
- bound the monitored readback wait and report a stalled GPU as device loss
- reject 32-bit float documents before band readback so exports fall back to CPU
- keep previews at 8-bit RGBA and report the palette/Duotone renderer bypass
- retain renderer diagnostics when encoding fails
- strengthen renderer, save-path and GPU diagnostics tests

* Add GPU close-hook and 8-bit preview regression tests (#10842)

* fix(io): correct renderer binding mutability

* Fix remaining headless GPU review findings (#10842)

- release a document's GPU caches when file.close, file.closeAll or
  file.closeOthers removes it through a command
- honor cancellation while band readback waits, polling in 100 ms slices
- fault the device after a readback timeout so later exports skip it
- report single-layer, CMYK ink-mixing, ORA and PDF CPU paths to a
  requested renderer
- keep fallback warnings in their export instead of leaking them into the
  next one
- requeue renderer diagnostics when a renderer-aware save's final write
  fails
- write MCP/serve diagnostics without panicking on a broken stderr
- document the actual colour-space contract and drop F32 from the GPU
  depth list

* Use question mark for cancellable readback errors

* Keep GPU export diagnostics with their attempt and stop bands on cancel

Encode native .pcraft bytes before draining renderer diagnostics, and
requeue export warnings when a workspace write fails, so callers still
report the adapter or fallback reason with the error.

Move a failed GPU attempt's queued diagnostics into its CPU retry result
instead of leaving them for a later successful export to report.

Stop GPU band readback at the next band after cancellation, and check the
interrupt before each CpuRenderer sink delivery.

Band readback uploads half-precision textures, so reject U16 alongside
F32: their samples cannot survive the upload. Tighten the 8-bit CPU/GPU
parity checks to 1/255.

* Keep headless export warnings when a workspace write fails (#10842)

A capability-scoped save encodes the export, then writes it; when the write
fails, the export warnings (folded together with renderer diagnostics) were
pushed back through `BandRenderer::push_diagnostic`, whose default is a no-op.
A custom renderer that overrides only `drain_diagnostics` therefore lost the
adapter or fallback reason that belonged with the error.

Keep them in `Headless` instead and return them from the next
`drain_diagnostics`, alongside the renderer's own queue. The regression uses a
renderer without a `push_diagnostic` override and checks the warnings survive
the failed write and drain exactly once.

Also name the actual source of the depth limit in the headless GPU matrix:
layer content is uploaded as half-precision textures, not band readback.

* Stop CPU bands and CPU retries on cancellation (#10912)

The CPU retry after a retryable GPU error composes through
`FlatSource::bands`, whose shared `emit` sink did not poll the export
interrupt: a cancel was only observed after the whole composite finished.
Check it once per band, as `CpuRenderer` does, for both RGB and CMYK ink
bands. The flat and streamed-PNG retry branches also check before starting
the full CPU composite again, so a cancelled export returns
`IoError::Cancelled` instead of re-rendering the document.

Tests: band-sink cancellation for RGB and CMYK inks, a renderer call counter
proving a second export reaches the renderer, and a pending `wait_slices`
poll observing cancellation set mid-wait.

* Merge origin/main into GPU foundation (#10842)

* Keep GPU-path diagnostics on every failure path, honour cancel in native copy

- save: export warnings of a failed local write stay in Headless pending diagnostics
- render: preview fallback warnings are returned, drained, and added to the doc.render reply
- io: renderer-neutral fallback wording; native single-layer copy checks cancellation per band
- gpu: gate wait_slices tests off wasm32 (#10842)

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 04:53:25 +01:00

316 lines
12 KiB
Rust

//! Tests for bounded, checked band readback across chunks, pages, and device failure modes.
use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
use photocraft_doc::{Document, Effect, Layer, LayerContent};
use photocraft_geom::{Rect, Size};
use photocraft_gpu::context::{NativeContext, NativeContextOptions};
use photocraft_gpu::readback::{DEFAULT_MAX_STAGING_BYTES, ReadbackError, render_bands, render_to_vec};
use photocraft_gpu::{Compositor, DeviceHealth};
use photocraft_raster::Surface;
use crate::GPU_LOCK;
fn test_gpu() -> Option<(NativeContext, Compositor, std::sync::MutexGuard<'static, ()>)> {
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let ctx = match NativeContext::request(&NativeContextOptions::default()) {
Ok(c) => c,
Err(e) => {
eprintln!("skipping bounded readback test: {e}");
return None;
}
};
let comp = match ctx.create_compositor() {
Ok(c) => c,
Err(e) => {
eprintln!("skipping bounded readback test: {e}");
return None;
}
};
Some((ctx, comp, lock))
}
fn sample_doc(w: u32, h: u32) -> Document {
let mut doc = Document::with_background("sample", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut s = Surface::new(doc.pixel_format());
s.fill_rect(Rect::new(10, 10, w as i32 - 10, h as i32 - 10), &[0.2, 0.6, 0.8, 0.7]);
let mut top = Layer::new("top", LayerContent::Raster(s));
top.opacity = 0.85;
top.blend = BlendMode::Multiply;
doc.layers.push(top);
doc
}
#[test]
fn bounded_readback_matches_independent_cpu_oracle() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
let doc = sample_doc(200, 300);
let rect = doc.bounds();
// Independent reference CPU compositor oracle
let mut cpu_pixels = Vec::new();
photocraft_compose::render_bands(&doc, rect, 0, |buf| {
cpu_pixels.extend_from_slice(&buf.px);
Ok::<(), std::convert::Infallible>(())
})
.expect("cpu compose");
// GPU bounded readback in bands of 16 rows
let mut gpu_banded = Vec::new();
let mut total_rows = 0;
render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, rect, 16, 32 * 1024, &photocraft_raster::Interrupt::NONE, |band_rect, px| {
total_rows += band_rect.height();
gpu_banded.extend_from_slice(&px);
Ok::<(), std::convert::Infallible>(())
})
.expect("banded readback");
assert_eq!(total_rows, rect.height());
assert_eq!(cpu_pixels.len(), gpu_banded.len());
for (i, (c, g)) in cpu_pixels.iter().zip(&gpu_banded).enumerate() {
for ch in 0..4 {
assert!((c[ch] - g[ch]).abs() <= 1.0 / 255.0, "pixel {i} ch {ch} differs from CPU oracle: cpu={}, gpu={}", c[ch], g[ch]);
}
}
}
#[test]
fn bounded_readback_strictly_respects_staging_budget() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
let doc = sample_doc(256, 256);
let rect = doc.bounds();
let budget = 16 * 1024; // 16 KiB strict cap
let bpp = if comp.acc_format() == wgpu::TextureFormat::Rgba32Float { 16u64 } else { 8u64 };
let chunk_size = comp.chunk_size() as i32;
let mut row_staging_bytes: u64 = 0;
let mut x = rect.x0.div_euclid(chunk_size) * chunk_size;
while x < rect.x1 {
let x_end = (x + chunk_size).min(rect.x1);
let chunk_w = (x_end - x.max(rect.x0)) as u64;
let chunk_row = (chunk_w * bpp).div_ceil(256) * 256;
row_staging_bytes += chunk_row;
x += chunk_size;
}
render_bands(
&mut comp,
ctx.device(),
ctx.queue(),
&doc,
rect,
0, // let provider calculate bounded rows
budget,
&photocraft_raster::Interrupt::NONE,
|band_rect, px| {
let actual_staging = band_rect.height() as u64 * row_staging_bytes;
assert!(actual_staging <= budget as u64, "band staging {} exceeds budget {}", actual_staging, budget);
assert_eq!(px.len(), (band_rect.width() * band_rect.height()) as usize);
Ok::<(), std::convert::Infallible>(())
},
)
.expect("bounded readback budget respected");
}
#[test]
fn hostile_dimensions_rejects_without_huge_allocation() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
// 1. Budget smaller than device 256-byte alignment
let doc = sample_doc(64, 64);
let res = render_bands(
&mut comp,
ctx.device(),
ctx.queue(),
&doc,
doc.bounds(),
0,
128, // less than 256
&photocraft_raster::Interrupt::NONE,
|_r, _px| Ok::<(), String>(()),
);
assert!(matches!(res, Err(ReadbackError::AllocationFailed(_))));
// 2. Wide document where 1 full row cannot fit in staging budget
let wide_doc = sample_doc(10_000, 10);
let small_budget = 4096; // 4 KiB cannot fit 1 row of 10,000 pixels (>= 80 KiB)
let res =
render_bands(&mut comp, ctx.device(), ctx.queue(), &wide_doc, wide_doc.bounds(), 0, small_budget, &photocraft_raster::Interrupt::NONE, |_r, _px| {
Ok::<(), String>(())
});
assert!(matches!(res, Err(ReadbackError::Unsupported(_))), "must safely fall back when 1 row cannot fit in budget: {res:?}");
// 3. Multi-chunk wide document spanning multiple chunk boundaries works via banding
let multi_chunk_doc = sample_doc(2500, 64);
let mut multi_chunk_px = Vec::new();
let res = render_bands(
&mut comp,
ctx.device(),
ctx.queue(),
&multi_chunk_doc,
multi_chunk_doc.bounds(),
16,
DEFAULT_MAX_STAGING_BYTES,
&photocraft_raster::Interrupt::NONE,
|_r, px| {
multi_chunk_px.extend_from_slice(&px);
Ok::<(), String>(())
},
);
assert!(res.is_ok(), "multi-chunk canvas must succeed via banding: {res:?}");
assert_eq!(multi_chunk_px.len(), 2500 * 64);
}
#[test]
fn sink_cancellation_and_error_preservation() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
let doc = sample_doc(128, 128);
let mut band_count = 0;
let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &photocraft_raster::Interrupt::NONE, |_r, _px| {
band_count += 1;
Err("abort_sink_now")
});
assert_eq!(band_count, 1, "sink failure must immediately stop further band rendering");
match res {
Err(ReadbackError::Sink(e)) => assert_eq!(e, "abort_sink_now"),
other => panic!("expected ReadbackError::Sink, got {other:?}"),
}
}
#[test]
fn readback_honors_cancellation_before_waiting() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
let doc = sample_doc(128, 128);
let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true);
let mut called = false;
let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &cancelled, |_rect, _px| {
called = true;
Ok::<(), std::convert::Infallible>(())
});
assert!(matches!(res, Err(ReadbackError::Cancelled)), "a cancelled readback must stop before the device wait: {res:?}");
assert!(!called, "no band may be delivered after cancellation");
}
#[test]
fn readback_honors_cancellation_between_bands() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
let doc = sample_doc(128, 128);
let cancel = std::sync::atomic::AtomicBool::new(false);
let is_cancelled = || cancel.load(std::sync::atomic::Ordering::SeqCst);
let cancelled = photocraft_raster::Interrupt::cancel_only(&is_cancelled);
let mut calls = 0usize;
let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &cancelled, |_rect, _px| {
calls += 1;
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
Ok::<(), std::convert::Infallible>(())
});
assert!(matches!(res, Err(ReadbackError::Cancelled)), "cancellation set after the first band must stop the readback: {res:?}");
assert_eq!(calls, 1, "only the first band may be delivered");
}
#[test]
fn bounded_readback_preserves_effect_halos_across_seams() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
let (w, h) = (256, 256);
let mut doc = Document::with_background("fx_doc", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE);
// Layer positioned right across row 128 (the middle of the canvas)
let mut s = Surface::new(doc.pixel_format());
s.fill_rect(Rect::new(50, 110, 200, 145), &[0.9, 0.1, 0.1, 1.0]);
let mut layer = Layer::new("shadow_layer", LayerContent::Raster(s));
// Drop shadow with blur radius 16 extending downward and upward
let Effect::DropShadow(mut shadow) = Effect::default_drop_shadow() else { unreachable!() };
shadow.distance = 8.0;
shadow.size = 16.0;
shadow.common.opacity = 0.8;
layer.effects.items.push(Effect::DropShadow(shadow));
doc.layers.push(layer);
let rect = doc.bounds();
if let Err(reason) = comp.supports(&doc) {
assert!(reason.0.contains("float effect-map render targets"), "unexpected unsupported feature: {reason:?}");
let result = render_bands(
&mut comp,
ctx.device(),
ctx.queue(),
&doc,
rect,
32,
32 * 1024,
&photocraft_raster::Interrupt::NONE,
|_, _| -> Result<(), std::convert::Infallible> {
panic!("unsupported layer effects must not emit pixels");
},
);
assert!(matches!(result, Err(ReadbackError::Unsupported(_))));
eprintln!("SKIPPED effect seam parity: {reason:?}; verified unsupported-feature rejection instead");
return;
}
let full = render_to_vec(&mut comp, ctx.device(), ctx.queue(), &doc, rect).expect("full readback");
// Render with bands of 32 rows so the shadow seamlessly crosses multiple band boundaries
let mut banded = Vec::new();
render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, rect, 32, 32 * 1024, &photocraft_raster::Interrupt::NONE, |_band_rect, px| {
banded.extend_from_slice(&px);
Ok::<(), std::convert::Infallible>(())
})
.expect("banded readback with effects");
assert_eq!(full.len(), banded.len());
for (i, (f, b)) in full.iter().zip(&banded).enumerate() {
for c in 0..4 {
assert!((f[c] - b[c]).abs() < 2.0 / 255.0, "halo pixel {i} channel {c} differs: full={}, banded={}", f[c], b[c]);
}
}
}
#[test]
fn high_depth_documents_are_rejected_before_readback() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
// Half-precision uploads cannot preserve 16-bit samples, so U16 joins F32 on the CPU path.
for depth in [SampleType::U16, SampleType::F32] {
let doc = Document::with_background("high_depth", Size::new(64, 64), ColorMode::Rgb, depth, Color::WHITE);
let mut called = false;
let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &photocraft_raster::Interrupt::NONE, |_rect, _px| {
called = true;
Ok::<(), std::convert::Infallible>(())
});
assert!(matches!(res, Err(ReadbackError::Unsupported(_))), "{depth:?} must be rejected before readback: {res:?}");
assert!(!called, "no band may be read for a {depth:?} document");
}
}
#[test]
fn bounded_readback_handles_empty_rect() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
let doc = sample_doc(100, 100);
let mut called = false;
let res =
render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, Rect::new(0, 0, 0, 0), 16, 64 * 1024, &photocraft_raster::Interrupt::NONE, |_rect, _px| {
called = true;
Ok::<(), std::convert::Infallible>(())
});
assert!(res.is_ok());
assert!(!called, "sink should not be called for empty rect");
}
#[test]
fn bounded_readback_notices_device_fault() {
let Some((ctx, mut comp, _lock)) = test_gpu() else { return };
let doc = sample_doc(100, 100);
// Simulate an explicit device fault
let health = DeviceHealth::new();
health.mark(photocraft_gpu::Fault::Lost("simulated driver crash".into()));
comp.set_health(health);
let res = render_bands(&mut comp, ctx.device(), ctx.queue(), &doc, doc.bounds(), 16, 64 * 1024, &photocraft_raster::Interrupt::NONE, |_rect, _px| {
Ok::<(), std::convert::Infallible>(())
});
assert!(matches!(res, Err(ReadbackError::DeviceLost(_))));
}