Files
photocraft/apps/photocraft-cli/tests/it/gpu.rs
T
Khawar Zamman WaniandFredApps 13b481f892 Expose optional GPU composition and export in photocraft-cli (#10912)
* 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)

* Expose optional GPU rendering in photocraft-cli (#8062)

* Fix GPU feature test APIs and native target gates

* Fix CLI locked metadata, bounded benchmark params, and protocol diagnostics (#8062)

* Verify unsupported GPU effects before testing band seams

* Fix CLI renderer borrows and GPU-enabled test compilation

* Use compositor buffer ownership API after upstream memory accounting change

* Fix GPU CLI lint failures and validate run environment policy

* Update export renderer test helper for owned compositor buffers

* Fix needless benchmark layer name borrow

* 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

* Use valid batch arguments and inspect method in GPU CLI tests

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

* Fix GPU CLI review findings (#10912)

- guard the benchmark's modulo divisors for one-pixel dimensions
- count a cold export as GPU only when the renderer context was used
- report every batch file's export warnings, deduplicating only GPU diagnostics
- drain renderer diagnostics before propagating convert and run save errors
- cover renderer use in the GPU precedence, bridge and serve tests

* fix(io): correct renderer binding mutability

* test(cli): verify narrow benchmark inputs and GPU diagnostics

* 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

* 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

* Fix remaining GPU CLI review findings (#10912)

- reject non-UTF-8 PHOTOCRAFT_GPU values instead of silently using the CPU
- release a batch item's document caches before taking the renderer to the
  next item, and report fallbacks once
- count warm benchmark runs that fell back to CPU in the summary
- write serve diagnostics without panicking on a broken stderr
- assert that the batch test's --gpu policy reaches the renderer

* Use question mark for cancellable readback errors

* 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 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.

* Report partial warm fallbacks and CPU fallback on stderr

The averaged GPU Warm row now says how many warm runs fell back to the
CPU and were excluded from the average, through the existing helper.

The single-layer convert diagnostic test checks that the CPU fallback is
reported on stderr, in GPU-enabled and CPU-only builds alike.

* 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)

* Address CodeRabbit review: keep fallback reasons on failed retries, report native-copy and quick-export CPU use, guard diagnostics drain (#10912)

* Accept render diagnostics from the doc.render reply in the serve GPU test (#10912)

---------

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

336 lines
16 KiB
Rust

//! Tests for photocraft-cli GPU rendering flags, precedence, and fallback behavior.
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
fn bin() -> Command {
let mut cmd = Command::new(crate::binary());
cmd.env_remove("PHOTOCRAFT_CONTROL_PORT");
cmd.env_remove("PHOTOCRAFT_CONTROL_TOKEN");
cmd.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE");
cmd.env_remove("PHOTOCRAFT_GPU");
cmd
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pc-cli-gpu-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn write_png(path: &Path, w: u32, h: u32) {
let data: Vec<u8> = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(7)).collect();
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap();
std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
}
#[test]
fn gpu_and_no_gpu_conflict() {
let d = tmp("conflict");
let in_png = d.join("in.png");
let out_png = d.join("out.png");
write_png(&in_png, 16, 16);
let o = bin().args(["convert", "--gpu", "--no-gpu", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.contains("cannot be used with") || stderr.contains("conflicts with"), "{stderr}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn gpu_env_var_validation() {
let d = tmp("env_val");
let in_png = d.join("in.png");
let out_png = d.join("out.png");
write_png(&in_png, 16, 16);
// Invalid non-empty value rejected with usage error naming the variable
for bad in ["2", "true", "yes", "false", "on", "off"] {
let o = bin().env("PHOTOCRAFT_GPU", bad).args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
assert_eq!(o.status.code(), Some(2), "expected exit 2 for PHOTOCRAFT_GPU={bad}");
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.contains("PHOTOCRAFT_GPU"), "expected stderr to name variable: {stderr}");
assert!(stderr.contains("expected '1' or '0'"), "expected usage note: {stderr}");
}
// Empty or blank value is accepted as unset (CPU default)
for empty in ["", " "] {
let out_sub = d.join(format!("out_{}.png", empty.trim()));
let o = bin().env("PHOTOCRAFT_GPU", empty).args(["convert", in_png.to_str().unwrap(), out_sub.to_str().unwrap()]).output().unwrap();
assert!(o.status.success(), "expected success for PHOTOCRAFT_GPU={empty:?}");
assert!(out_sub.is_file());
}
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn gpu_env_var_rejects_a_non_utf8_value() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let d = tmp("env_non_utf8");
let in_png = d.join("in.png");
let out_png = d.join("out.png");
write_png(&in_png, 16, 16);
let bad = OsString::from_vec(vec![0xff, 0xfe]);
let o = bin().env("PHOTOCRAFT_GPU", bad).args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
assert_eq!(o.status.code(), Some(2), "a non-UTF-8 PHOTOCRAFT_GPU must be a usage error");
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.contains("PHOTOCRAFT_GPU"), "the usage error must name the variable: {stderr}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn explicit_flags_override_environment() {
let d = tmp("override");
let in_png = d.join("in.png");
let in_layers = d.join("in.pcraft");
let out1 = d.join("out1.png");
let out2 = d.join("out2.png");
write_png(&in_png, 16, 16);
// A single-layer PNG takes the native export path and never calls the renderer; a
// multi-layer document composites, so it observes whether `--gpu` reached the backend.
let o = bin().args(["run", "--new", r#"{"width":16,"height":16}"#, "--cmd", "layer.new.layer", "--out", in_layers.to_str().unwrap()]).output().unwrap();
assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
// --no-gpu overrides PHOTOCRAFT_GPU=1
let o = bin().env("PHOTOCRAFT_GPU", "1").args(["convert", "--no-gpu", in_png.to_str().unwrap(), out1.to_str().unwrap()]).output().unwrap();
assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
assert!(out1.is_file());
// --gpu overrides PHOTOCRAFT_GPU=0
let o = bin().env("PHOTOCRAFT_GPU", "0").args(["convert", "--gpu", in_layers.to_str().unwrap(), out2.to_str().unwrap()]).output().unwrap();
assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
assert!(out2.is_file());
let stderr = String::from_utf8_lossy(&o.stderr);
// The renderer reports either its adapter, an unavailable adapter, or (in a build without
// GPU support) that GPU rendering was requested; the CPU default emits none of these.
let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU"];
assert!(markers.iter().any(|marker| stderr.contains(marker)), "explicit --gpu must reach the renderer when the environment disables it: {stderr}");
// Explicit flag overrides even bad environment value
let out3 = d.join("out3.png");
let o = bin().env("PHOTOCRAFT_GPU", "invalid").args(["convert", "--no-gpu", in_png.to_str().unwrap(), out3.to_str().unwrap()]).output().unwrap();
assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
assert!(out3.is_file());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn mcp_bridge_rejects_explicit_gpu_flags_and_ignores_env() {
// Explicit --gpu with --bridge rejected
let o = bin().args(["mcp", "--bridge", "127.0.0.1:50000", "--gpu"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.contains("configure rendering on the desktop app"), "{stderr}");
// Explicit --no-gpu with --bridge rejected
let o = bin().args(["mcp", "--bridge", "127.0.0.1:50000", "--no-gpu"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.contains("configure rendering on the desktop app"), "{stderr}");
// An invalid PHOTOCRAFT_GPU value is ignored in bridge mode: the desktop process controls
// rendering, so the bridge must not validate the variable or reject the request for it.
let o = bin().env("PHOTOCRAFT_GPU", "invalid").args(["mcp", "--bridge", "127.0.0.1:50000"]).stdin(std::process::Stdio::null()).output().unwrap();
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(!stderr.contains("PHOTOCRAFT_GPU"), "bridge mode must ignore the GPU environment: {stderr}");
}
#[test]
fn convert_and_run_honor_gpu_flag_and_preserve_stdout_protocol() {
let d = tmp("protocol");
let out_png = d.join("out.png");
// run --gpu output on stdout is clean JSON
let o =
bin().args(["run", "--gpu", "--new", r#"{"width":32,"height":32}"#, "--cmd", "layer.new.layer", "--out", out_png.to_str().unwrap()]).output().unwrap();
assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
assert!(out_png.is_file());
let stdout = String::from_utf8_lossy(&o.stdout);
for line in stdout.lines() {
let v: Value = serde_json::from_str(line).expect("each stdout line must be valid JSON");
assert!(v.get("command").is_some());
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn batch_recovers_renderer_after_failed_item_and_preserves_gpu_policy() {
let d = tmp("batch_regress");
let in_dir = d.join("in");
let out_dir = d.join("out");
std::fs::create_dir_all(&in_dir).unwrap();
std::fs::create_dir_all(&out_dir).unwrap();
// First item is corrupt and fails to open
let corrupt_png = in_dir.join("01_corrupt.png");
std::fs::write(&corrupt_png, b"invalid png bytes").unwrap();
// Second item is valid
let valid_png = in_dir.join("02_valid.png");
write_png(&valid_png, 32, 32);
let actions = d.join("actions.json");
std::fs::write(&actions, serde_json::json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
let o = bin()
.args(["batch", "--gpu", "--actions"])
.arg(&actions)
.args(["--in", in_dir.to_str().unwrap(), "--out", out_dir.to_str().unwrap()])
.output()
.unwrap();
// Exit code 1 because 1 file failed
assert_eq!(o.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stdout.contains("1 succeeded, 1 failed"), "stdout: {stdout}");
assert!(stderr.contains("FAIL"), "stderr must report failing item: {stderr}");
let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU"];
assert!(markers.iter().any(|marker| stderr.contains(marker)), "--gpu must reach the batch renderer: {stderr}");
assert!(out_dir.join("02_valid.png").is_file(), "second item must have been saved successfully");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn diagnostics_preserve_clean_stdout_and_emit_to_stderr() {
let d = tmp("diag_clean");
let in_png = d.join("in.png");
let out_png = d.join("out.png");
write_png(&in_png, 32, 32);
let o = bin().args(["convert", "--gpu", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
assert!(out_png.is_file());
// Convert emits no stdout; diagnostics must go to stderr
assert!(o.stdout.is_empty(), "convert stdout must remain completely empty, got: {:?}", String::from_utf8_lossy(&o.stdout));
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.contains("falling back to CPU"), "single-layer convert must report its CPU fallback on stderr: {stderr}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn serve_gpu_preserves_clean_protocol_stdout_and_drains_diagnostics_to_stderr() {
use std::io::{BufRead, BufReader, Write};
use std::process::Stdio;
let mut child = bin().args(["serve", "--gpu"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
// 1. Initial valid command
writeln!(stdin, "{}", serde_json::json!({"id": 1, "method": "doc.new", "params": {"width": 16, "height": 16}})).unwrap();
stdin.flush().unwrap();
let mut line1 = String::new();
assert!(stdout.read_line(&mut line1).unwrap() > 0, "server must respond");
let v1: Value = serde_json::from_str(&line1).expect("line 1 on stdout must be valid JSON");
assert_eq!(v1["id"], 1);
assert_eq!(v1["ok"], true, "{v1}");
// 2. A second layer so the render below composites instead of taking the native path.
writeln!(stdin, "{}", serde_json::json!({"id": 2, "method": "engine.execute", "params": {"command": "layer.new.layer", "params": {"name": "Top"}}}))
.unwrap();
stdin.flush().unwrap();
let mut line2 = String::new();
assert!(stdout.read_line(&mut line2).unwrap() > 0, "server must respond to the layer command");
let v2: Value = serde_json::from_str(&line2).expect("line 2 on stdout must be valid JSON");
assert_eq!(v2["id"], 2);
assert_eq!(v2["ok"], true, "{v2}");
// 3. Failure recovery: invalid request receives error reply without crashing server
writeln!(stdin, "{}", serde_json::json!({"id": 3, "method": "invalid.nonexistent.method", "params": {}})).unwrap();
stdin.flush().unwrap();
let mut line3 = String::new();
assert!(stdout.read_line(&mut line3).unwrap() > 0, "server must respond to invalid request");
let v3: Value = serde_json::from_str(&line3).expect("line 3 on stdout must be valid JSON");
assert_eq!(v3["id"], 3);
assert_eq!(v3["ok"], false, "{v3}");
// 4. Subsequent valid command still succeeds (proves session & renderer recovery)
writeln!(stdin, "{}", serde_json::json!({"id": 4, "method": "doc.inspect"})).unwrap();
stdin.flush().unwrap();
let mut line4 = String::new();
assert!(stdout.read_line(&mut line4).unwrap() > 0, "server must respond to the inspect request");
let v4: Value = serde_json::from_str(&line4).expect("line 4 on stdout must be valid JSON");
assert_eq!(v4["id"], 4);
assert_eq!(v4["ok"], true, "{v4}");
// 5. Render the composite: one JSON line on stdout, diagnostics only on stderr.
writeln!(stdin, "{}", serde_json::json!({"id": 5, "method": "doc.render", "params": {"maxSide": 16}})).unwrap();
stdin.flush().unwrap();
let mut line5 = String::new();
assert!(stdout.read_line(&mut line5).unwrap() > 0, "server must answer the render request");
let v5: Value = serde_json::from_str(&line5).expect("line 5 on stdout must be valid JSON");
assert_eq!(v5["id"], 5);
assert_eq!(v5["ok"], true, "{v5}");
assert!(v5["result"]["base64"].as_str().is_some_and(|b| !b.is_empty()), "render must return PNG bytes: {v5}");
// Close stdin and verify graceful exit
drop(stdin);
let output = child.wait_with_output().unwrap();
assert!(output.status.success());
// Every render that reaches the renderer reports its adapter or why it fell back, whichever
// build and adapter this test runs on.
// `doc.render` returns the diagnostics of its own render in the reply's `warnings`; any left
// in the queue go to stderr. Either way they stay off the protocol lines' framing.
let stderr = String::from_utf8_lossy(&output.stderr);
let reported = format!("{stderr}\n{}", v5["result"]["warnings"]);
let markers = ["GPU adapter:", "no GPU adapter available", "could not build GPU compositor", "falling back to CPU", "GPU acceleration requested"];
assert!(markers.iter().any(|m| reported.contains(m)), "render must report its GPU backend or fallback in its reply or on stderr: {reported}");
}
#[test]
fn cpu_default_does_not_emit_gpu_warnings() {
let d = tmp("cpu_default");
let in_png = d.join("in.png");
let out_png = d.join("out.png");
write_png(&in_png, 16, 16);
let o = bin().args(["convert", in_png.to_str().unwrap(), out_png.to_str().unwrap()]).output().unwrap();
assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
assert!(out_png.is_file());
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(!stderr.contains("GPU"), "default CPU convert should not emit GPU warnings, got: {stderr}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn run_gpu_quick_export_reports_that_it_runs_on_the_cpu() {
let d = tmp("quick_export");
let out_png = d.join("quick.png");
let params = format!(r#"{{"path":{}}}"#, serde_json::to_string(&out_png.to_string_lossy()).unwrap());
let args = ["run", "--new", r#"{"width":16,"height":16,"background":"white"}"#, "--cmd", "file.export.quickExport", "--params", params.as_str()];
let o = bin().args(args).arg("--gpu").output().unwrap();
assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));
assert!(out_png.is_file());
let stderr = String::from_utf8_lossy(&o.stderr);
assert_eq!(stderr.matches("quickExport runs on the CPU compositor").count(), 1, "one CPU note expected: {stderr}");
let o = bin().args(args).output().unwrap();
assert!(o.status.success());
assert!(!String::from_utf8_lossy(&o.stderr).contains("runs on the CPU compositor"), "no note without --gpu");
let _ = std::fs::remove_dir_all(&d);
}