Files
FredApps 44f13e5498 Scope scratch read failures to the document that holds the tile
One unreadable tile used to block editing, saving and exporting every open document
(spill::check_integrity was process-global). Add Tile/Surface/Document::scratch_health
(free while no tile has failed) and Document::for_each_surface (ops' history walk now
delegates to it). edit_with checks the document before and after, jobs the document they
captured, import the result, save/export the document written; check_since is now
generation-only so a failure or recovery during an operation refuses it once and a retry
works. Caches check their own sources and purge once per new failure instead of every
frame. Fault-injection tests use a duplicated handle (the Unix file has no name).
2026-11-02 22:23:42 +01:00

120 lines
5.7 KiB
Rust

//! Disk-backed current pixels and history: lossless undo, and failed reads never commit.
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Layer};
use photocraft_engine::Session;
use photocraft_geom::{Rect, Size, TileCoord};
use photocraft_raster::spill;
use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::Arc;
#[test]
fn scratch_pixels_history_and_failed_save_are_transactional() {
let dir = std::env::temp_dir().join(format!("photocraft-engine-scratch-{}", std::process::id()));
let mut session = Session::new();
session.edit_prefs(|p| {
p.scratch_disks.disks = vec![photocraft_engine::prefs::ScratchDisk { path: dir.to_string_lossy().into(), enabled: true }];
p.performance.memory_usage_mb = 1;
});
assert!(spill::enabled());
assert_eq!(session.prefs().performance.history_cap_bytes(), 0);
let mut doc = Document::new("Scratch fixture", Size::new(2048, 256), ColorMode::Rgb, SampleType::U8);
let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8);
let id = layer.id;
for i in 0..8 {
let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut();
for (j, b) in bytes.iter_mut().enumerate() {
*b = (j as u32).wrapping_mul(17).wrapping_add(i as u32 * 43) as u8;
}
}
doc.layers.push(layer);
session.add_document(doc, None);
session.select_layer(id).unwrap();
let before = session.active().unwrap().doc.clone();
session
.edit("Paint", |d, _| {
d.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
spill::settle();
assert!(session.undo());
assert_eq!(*session.active().unwrap().doc, *before);
assert!(session.redo());
assert_eq!(session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]);
spill::settle();
// Force all tiles to disk, including the tile the failed edit will touch.
spill::configure(Some(&dir), 1).unwrap();
spill::settle();
assert_eq!(spill::stats().resident_bytes, 0);
// Damage only this test's scratch file; retain its exact contents to restore the backing.
let mut file = spill::scratch_handle_for_tests().unwrap();
file.seek(SeekFrom::Start(0)).unwrap();
let mut good = Vec::new();
file.read_to_end(&mut good).unwrap();
file.seek(SeekFrom::Start(0)).unwrap();
file.write_all(&vec![0; good.len()]).unwrap();
file.sync_all().unwrap();
let retained = session.active().unwrap().doc.clone();
let revision = session.active().unwrap().revision;
let failed = session.edit("Failed paint", |d, active| {
let discarded = Layer::raster("Discarded", PixelFormat::RGBA8);
*active = Some(discarded.id);
d.layers.push(discarded);
d.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(1800, 0, 4, 4), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
});
assert!(failed.is_err());
assert!(Arc::ptr_eq(&session.active().unwrap().doc, &retained));
assert_eq!(session.active().unwrap().revision, revision);
assert_eq!(session.active().unwrap().active_layer, Some(id));
let mut writer = photocraft_format::PcraftWriter::new();
assert!(writer.save_zip(&retained, &Default::default()).is_err());
assert!(photocraft_io::export(&retained, "png", &Default::default()).is_err());
// No failure or recovery happens during a retry, so only the document's own health
// refuses it.
let generation = spill::read_error_generation();
assert!(session.edit("Retry paint", |_, _| Ok(())).is_err());
assert_eq!(spill::read_error_generation(), generation, "an already-failed tile does not change the generation");
assert!(Arc::ptr_eq(&session.active().unwrap().doc, &retained));
// Another document is unaffected: it edits, saves and exports while this one is unreadable.
assert!(spill::check_integrity().is_err(), "the process-wide status still reports the failure");
let mut other = Document::new("Healthy", Size::new(512, 256), ColorMode::Rgb, SampleType::U8);
let mut pixels = Layer::raster("Other", PixelFormat::RGBA8);
let other_id = pixels.id;
pixels.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 512, 256), &[0.0, 0.0, 1.0, 1.0]);
other.layers.push(pixels);
session.add_document(other, None);
session.select_layer(other_id).unwrap();
session
.edit("Paint other", |d, _| {
d.layer_mut(other_id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 8, 8), &[1.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
spill::settle();
let healthy = session.active().unwrap().doc.clone();
assert!(healthy.scratch_health().is_ok());
let (saved, _) = photocraft_format::PcraftWriter::new().save_zip(&healthy, &Default::default()).unwrap();
assert_eq!(photocraft_format::load_from_bytes(&saved).unwrap().layers, healthy.layers);
assert!(photocraft_io::export(&healthy, "png", &Default::default()).is_ok());
assert!(retained.scratch_health().is_err());
file.seek(SeekFrom::Start(0)).unwrap();
file.write_all(&good).unwrap();
file.sync_all().unwrap();
for d in [&before, &retained] {
for (_, tile) in d.layer(id).unwrap().surface().unwrap().tiles() {
tile.try_bytes().unwrap();
}
}
assert!(spill::check_integrity().is_ok());
let (bytes, _) = writer.save_zip(&retained, &Default::default()).unwrap();
let loaded = photocraft_format::load_from_bytes(&bytes).unwrap();
assert_eq!(loaded.layers, retained.layers);
drop((session, before, retained, healthy, loaded, writer, file));
spill::configure(None, 0).unwrap();
let _ = std::fs::remove_dir_all(dir);
}