Files
6326117c53 Web build: big operations that won't fit fail with an error instead of trapping, and a trap says so (#10129)
* Raster: ask the allocator whether a big operation fits; PSD save reserves its whole output

New photocraft_raster::headroom: ensure(need) tries to reserve the need plus a margin and frees it
at once (web only; native always passes), below(bytes) tells whether less than that can be
allocated, and simulate_available fakes the answer for tests and diagnostics.

PsdFile::to_bytes reserved only the pixel data, so embedded smart-object data and other tagged
blocks grew the output by doubling, which on the web is one unrecoverable huge realloc. It now
reserves the exact encoded length.

* Big operations that won't fit in memory fail with an error instead of trapping

Place, Image Size, a smart-object re-render and Save now ask the allocator whether their estimated
need can be allocated before they start (photocraft_engine::memory_gate, estimates per operation,
photocraft_io::estimate_export_bytes for saves). A refusal changes nothing and says what the
operation needed and what to do (save, Edit > Purge > All, close documents).

A standing notice warns when less than 512 MiB can be allocated, with hysteresis, so the user can
save before anything traps. Notices can now be sticky, which keeps them past the auto-hide delay.

* Web build: a trap says so instead of freezing the page, and ?free-mb simulates low memory

index.html holds a hidden, static 'PhotoCraft stopped' message with a Reload link. web.rs reveals
it when the module traps (the window error event, or an unhandled rejection from a spawned
future), tells running out of memory from an internal error by the panic message a chained panic
hook kept, and skips the Leave site prompt once stopped. ?free-mb=N makes the memory gates and the
low-memory warning see only N MiB, for checking them in a browser.

* Docs: web build memory handling in the gaps list and the parity assessment

* Engine: use is_multiple_of in the megabyte formatter

* Cargo.lock: the web shell depends on photocraft-raster

* Web boot page: carry the stopped message too, as the test that keeps the two pages equal requires

* Save check counts one copy of the document, and runs once per save

The encoded file's buffer is reserved fallibly, so a save it doesn't fit
in fails with an error instead of trapping; counting it as well refused
saves that would have fitted. On the background-save path the check ran
in write_document and again in start_save, which other callers reach
directly; it now runs only in start_save there.

* Review fixes: warning check allocates nothing, PSD merged image counted, automation saves checked

The low-memory warning's periodic check reads the size of linear memory
instead of reserving a block: wasm memory never shrinks, so a trial every
few seconds raised the high-water mark by up to 768 MiB for nothing. The
operation checks keep their trial reservation.

PSD and PSB saves always build a canvas-sized merged image (a placeholder
without maximum compatibility) and hold it raw and encoded; the save
estimate now counts it. The synchronous automation save checks memory
before encoding, as File > Save does, and its test asserts the memory
error.

The trap listeners go in before the app is built, so a trap while
building it shows the stopped message too, and a memory access out of
bounds reads as unexpected rather than as running out of memory.

* Cover layered PSD preflight limitation

* Fix layered PSD test iteration

* Docs: layered PSD preflight limitation in the gaps list

* Web OOM gate: gate direct exports, truthful advice for unbounded PSD sources (#10024)

* Web OOM gate: estimate layered PSDs per layer instead of refusing every real PSD

The PSD check admitted a file only when its color-mode data, image
resources and layer section were all empty. Every Photoshop file has
image resources (at least the resolution), so on the web nearly every
real PSD was refused for Place, PSD smart objects could not render,
and Image Size was refused for documents holding one, whatever the
free memory (u64::MAX never fits).

The color-mode data and image resources hold no pixels, so they are
now only skipped. A layered file reads its layer count from the layer
info (signed, absolute value) and adds a canvas-sized surface and mask
per layer to the flat estimate. Files that are not 8-bit RGB or
grayscale, keep their layers in a Layr tagged block, or whose sections
can't be read stay unbounded and refused, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Web stopped page: match trap messages ignoring case

Safari reports the allocation-failure abort as "Unreachable code
should not be executed" and Firefox says "out of memory", which the
case-sensitive matching missed: the trap was not recognised, or was
shown as an unexpected stop instead of running out of memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Export estimate: count the merged image OpenRaster writes

The ORA writer keeps the layers but also flattens the document for
mergedimage.png, a 16 B/px composite of the canvas, which the
estimate left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:04:59 +01:00

262 lines
12 KiB
Rust

//! On the web build a failed allocation aborts the module and freezes the page. The big
//! operations therefore ask the allocator first (`photocraft_raster::headroom`) and refuse with
//! an error when their estimated need won't fit (#10024). The simulation switch is process-wide,
//! so every test here holds `LOCK` and resets it when it ends.
use std::sync::{Mutex, MutexGuard, PoisonError};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_engine::Session;
use photocraft_raster::headroom;
use serde_json::json;
static LOCK: Mutex<()> = Mutex::new(());
/// Holds the lock and the simulated room; resets the simulation when dropped.
struct Room(#[allow(dead_code)] MutexGuard<'static, ()>);
impl Room {
fn new(bytes: Option<u64>) -> Room {
let guard = Room(LOCK.lock().unwrap_or_else(PoisonError::into_inner));
headroom::simulate_available(bytes);
guard
}
fn set(&self, bytes: Option<u64>) {
headroom::simulate_available(bytes);
}
}
impl Drop for Room {
fn drop(&mut self) {
headroom::simulate_available(None);
}
}
const MIB: u64 = 1 << 20;
/// Enough for the reserve and a small operation, far too little for a big one.
const SMALL: u64 = headroom::RESERVE + 4 * MIB;
fn png(dir: &std::path::Path, name: &str, w: u32, h: u32, color: &str) -> String {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "background": color})).unwrap();
let st = s.active().unwrap();
let bytes = photocraft_io::export(&st.doc, "png", &Default::default()).unwrap().bytes;
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
/// What an operation must not change when the gate refuses it.
fn state(s: &Session) -> (u64, usize, usize, (u32, u32)) {
let st = s.active().unwrap();
(st.revision, st.history.past_len(), st.doc.layers.len(), (st.doc.size.width, st.doc.size.height))
}
fn new_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 300})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap();
s
}
#[test]
fn place_that_does_not_fit_fails_and_leaves_the_document_alone() {
let room = Room::new(None);
let dir = tempfile::tempdir().unwrap();
// 2400 x 1800 px: about 4 MP, about 190 MB by the estimate.
let path = png(dir.path(), "big \u{e9}.png", 2400, 1800, "#ff0000");
let mut s = new_doc();
let before = state(&s);
room.set(Some(SMALL));
for cmd in ["file.placeEmbedded", "file.placeLinked"] {
let err = s.execute(cmd, json!({"path": path})).unwrap_err().to_string();
assert!(err.contains("Not enough memory to place \u{201c}big \u{e9}.png\u{201d}"), "{err}");
assert!(err.contains("MB"), "names the need: {err}");
assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "says what to do: {err}");
assert_eq!(state(&s), before, "{cmd}");
}
room.set(None);
s.execute("file.placeEmbedded", json!({"path": path})).unwrap();
assert_eq!(state(&s).2, before.2 + 1);
}
#[test]
fn image_size_that_does_not_fit_fails_and_leaves_the_document_alone() {
let room = Room::new(None);
let mut s = new_doc();
let before = state(&s);
room.set(Some(SMALL));
let err = s.execute("image.imageSize", json!({"width": 12000, "height": 9000})).unwrap_err().to_string();
assert!(err.contains("Not enough memory to resize the image to 12000 x 9000 px"), "{err}");
assert_eq!(state(&s), before);
// Only changing the resolution allocates nothing, so it still goes through.
s.execute("image.imageSize", json!({"resolution": 150, "resample": "none"})).unwrap();
room.set(None);
s.execute("image.imageSize", json!({"width": 800, "height": 600})).unwrap();
assert_eq!(state(&s).3, (800, 600));
}
/// A smart-object re-render that doesn't fit is refused by name (its own error); the commands
/// that re-render after an edit keep the layer's last appearance instead of failing.
#[test]
fn smart_object_render_that_does_not_fit_is_refused_by_name() {
let room = Room::new(None);
let dir = tempfile::tempdir().unwrap();
let path = png(dir.path(), "src.png", 1500, 1500, "#12ab34");
let bytes = std::fs::read(&path).unwrap();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
room.set(Some(SMALL));
let err = photocraft_engine::smart_cmds::source_image("src.png", &bytes, fmt).unwrap_err().to_string();
assert!(err.contains("Not enough memory to render the smart object \u{201c}src.png\u{201d}"), "{err}");
room.set(None);
assert!(photocraft_engine::smart_cmds::source_image("src.png", &bytes, fmt).is_ok());
}
#[test]
fn layered_psd_is_refused_before_decode_without_changing_the_document() {
use photocraft_psd::{Compression, LayerSpec, PixelData, PsdBuilder};
let room = Room::new(None);
const SIDE: u32 = 256;
const LAYERS: usize = 24;
let pixels = u64::from(SIDE) * u64::from(SIDE);
let mut builder = PsdBuilder::new(SIDE, SIDE).compression(Compression::Rle);
for layer in 0..LAYERS {
// Nonuniform, opaque pixels populate each full-canvas surface while remaining compressible.
let rgba: Vec<u8> = (0..SIDE * SIDE).flat_map(|i| [((i / SIDE + layer as u32) % 256) as u8, 80, 160, 255]).collect();
builder.push_layer(LayerSpec::new(format!("layer {layer}"), 0, 0, SIDE, SIDE, PixelData::Rgba8(rgba)));
}
let bytes = builder.to_bytes().unwrap();
let budget = pixels * photocraft_engine::memory_gate::SMART_RENDER_BYTES_PER_PX + bytes.len() as u64;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("layers.psd");
std::fs::write(&path, &bytes).unwrap();
let mut s = new_doc();
let before = state(&s);
room.set(Some(headroom::RESERVE));
for cmd in ["file.placeEmbedded", "file.placeLinked"] {
assert!(s.execute(cmd, json!({"path": path})).unwrap_err().to_string().contains("Not enough memory"));
assert_eq!(state(&s), before);
}
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
assert!(photocraft_engine::smart_cmds::source_image("layers.psd", &bytes, fmt).is_err());
// The layers make the need bigger but bounded, so the error names it.
let err = s.execute("file.placeEmbedded", json!({"path": path})).unwrap_err().to_string();
assert!(err.contains("MB"), "{err}");
// This budget passed the old canvas-only estimate even though the layers retain more.
room.set(Some(headroom::RESERVE + budget));
for cmd in ["file.placeEmbedded", "file.placeLinked"] {
assert!(s.execute(cmd, json!({"path": path})).unwrap_err().to_string().contains("Not enough memory"));
assert_eq!(state(&s), before);
}
assert!(photocraft_engine::smart_cmds::source_image("layers.psd", &bytes, fmt).is_err());
}
#[test]
fn small_flat_psd_still_places_and_renders() {
let room = Room::new(Some(SMALL));
// Two-by-two RGB PSD: empty color, resource and layer sections, raw planar pixels.
let mut bytes = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x08\0\x03".to_vec();
bytes.extend_from_slice(&[0; 12]);
bytes.extend_from_slice(&[0, 0]);
bytes.extend_from_slice(&[128; 12]);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("flat.psd");
std::fs::write(&path, &bytes).unwrap();
let mut s = new_doc();
let before = state(&s);
s.execute("file.placeEmbedded", json!({"path": path})).unwrap();
assert_eq!(state(&s).2, before.2 + 1);
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
assert!(photocraft_engine::smart_cmds::source_image("flat.psd", &bytes, fmt).is_ok());
drop(room);
}
/// Every Photoshop file has image resources (resolution, ICC profile, thumbnail); they hold no
/// pixels, so a PSD that has them, flat or with a few layers, still places and renders when the
/// memory is there.
#[test]
fn a_psd_with_image_resources_places_and_renders_when_it_fits() {
use photocraft_psd::{LayerSpec, PixelData, PsdBuilder};
let room = Room::new(Some(headroom::RESERVE + 256 * MIB));
const SIDE: u32 = 64;
let rgba = |r: u8| -> Vec<u8> { (0..SIDE * SIDE).flat_map(|_| [r, 80, 160, 255]).collect() };
let mut flat = PsdBuilder::new(SIDE, SIDE).resolution(72.0);
flat.composite(PixelData::Rgba8(rgba(10)));
let mut layered = PsdBuilder::new(SIDE, SIDE).resolution(150.0);
for (i, r) in [20u8, 30].into_iter().enumerate() {
layered.push_layer(LayerSpec::new(format!("layer {i}"), 0, 0, SIDE, SIDE, PixelData::Rgba8(rgba(r))));
}
let dir = tempfile::tempdir().unwrap();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let mut s = new_doc();
for (name, builder) in [("resources.psd", flat), ("resources-layers.psd", layered)] {
let bytes = builder.to_bytes().unwrap();
// No color-mode data for RGB, then a non-empty image resources section.
assert_ne!(&bytes[30..34], &[0u8; 4][..], "{name} has image resources");
assert!(photocraft_engine::smart_cmds::source_image(name, &bytes, fmt).is_ok(), "{name}");
let path = dir.path().join(name);
std::fs::write(&path, &bytes).unwrap();
let before = state(&s);
s.execute("file.placeEmbedded", json!({"path": path})).unwrap();
assert_eq!(state(&s).2, before.2 + 1, "{name}");
}
drop(room);
}
/// A PSD with no safe bound (here 16-bit) is refused whatever the free memory, so the error
/// doesn't suggest freeing some.
#[test]
fn a_psd_without_a_bound_says_to_flatten_it() {
let room = Room::new(Some(headroom::RESERVE + 1024 * MIB));
let mut bytes = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x02\0\x10\0\x03".to_vec();
bytes.extend_from_slice(&[0; 14]);
bytes.extend_from_slice(&[128; 24]);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deep.psd");
std::fs::write(&path, &bytes).unwrap();
let mut s = new_doc();
let before = state(&s);
let err = s.execute("file.placeEmbedded", json!({"path": path})).unwrap_err().to_string();
assert!(!err.contains("Purge"), "freeing memory cannot help an unbounded source: {err}");
assert!(err.contains("flatten"), "{err}");
assert_eq!(state(&s), before);
drop(room);
}
#[test]
fn a_smart_object_edit_under_low_memory_keeps_its_appearance() {
let room = Room::new(None);
let dir = tempfile::tempdir().unwrap();
let path = png(dir.path(), "edit.png", 1500, 1500, "#a1b2c3");
let mut s = new_doc();
s.execute("file.placeEmbedded", json!({"path": path})).unwrap();
let before = state(&s);
room.set(Some(SMALL));
s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50})).unwrap();
assert_eq!(state(&s).2, before.2, "the layer is still there");
}
#[test]
fn everything_passes_without_a_simulation() {
let _room = Room::new(None);
assert!(photocraft_engine::memory_gate::ensure("do anything", 64 << 30).is_ok());
assert!(photocraft_engine::memory_gate::ensure_for_save("save", 64 << 30).is_ok());
}
#[test]
fn the_save_wording_leads_with_freeing_memory_and_keeps_the_document() {
let room = Room::new(Some(SMALL));
let err = photocraft_engine::memory_gate::ensure_for_save("save \u{201c}a.psd\u{201d}", 900 * MIB).unwrap_err().to_string();
assert!(err.starts_with("Not enough memory to save \u{201c}a.psd\u{201d} (it needs about 900 MB)."), "{err}");
assert!(err.contains("Edit \u{203a} Purge \u{203a} All"), "{err}");
assert!(err.contains("still open"), "{err}");
drop(room);
}