mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
Merge remote-tracking branch 'origin/main' into fix/windows-pen-eraser-end-2285
# Conflicts: # apps/photocraft/src/tablet.rs # crates/tablet/Cargo.toml # crates/tablet/src/lib.rs # crates/tablet/src/windows.rs # crates/ui-egui/src/lib.rs # crates/ui-egui/src/services.rs # docs/hardware-parity.md
This commit is contained in:
commit
265fedc687
46 files changed
+1934
-213
No files matched your search
@@ -11,7 +11,7 @@ nextest-version = "0.9.150"
|
||||
#
|
||||
# GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer
|
||||
# serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so
|
||||
# this group caps them at 4.
|
||||
# this group caps them at 4. scripts/ci-gpu-lavapipe.py reads this group's filters directly.
|
||||
[test-groups]
|
||||
gpu = { max-threads = 4 }
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
# egui/winit/rfd.
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
libgtk-3-dev mesa-vulkan-drivers libvulkan1
|
||||
# The tests build with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run. Same commit as release.yml.
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
timeout-minutes: 100
|
||||
# fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
|
||||
# `--locked --features heif`, then the missing-translations report (xtask/src/main.rs).
|
||||
command: cargo xtask ci
|
||||
command: python3 scripts/test-ci-gpu-lavapipe.py && cargo xtask ci && python3 scripts/ci-gpu-lavapipe.py
|
||||
|
||||
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job
|
||||
# they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration
|
||||
|
||||
Generated
+22
@@ -5531,10 +5531,13 @@ name = "photocraft-tablet"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
"windows",
|
||||
"wintab",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -9208,6 +9211,25 @@ version = "0.0.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
|
||||
|
||||
[[package]]
|
||||
name = "wintab"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e05efdae68aa69ef1f4bc0148a5c6cf7f2bc234963a723252fd75f2f9e70d86"
|
||||
dependencies = [
|
||||
"windows",
|
||||
"wintab-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wintab-sys"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d3d0743ba07fe30ca1ccc1471ee50adf32bc70932b86a00c8f1051177950de2"
|
||||
dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
|
||||
@@ -670,6 +670,14 @@ fn main() -> eframe::Result {
|
||||
if let Some(monitor) = tablet::install_windows() {
|
||||
let _ = tablet_monitor_win.set(monitor);
|
||||
}
|
||||
// With Windows Ink disabled for the tablet, WinTab supplies the pen pressure, tilt,
|
||||
// rotation and eraser end; the vendor driver still moves the cursor.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(poll) =
|
||||
tablet::install_wintab(cc, &app.stylus.feed, app.session.prefs().tools.tablet_input == photocraft_engine::prefs::TabletInput::WinTab)
|
||||
{
|
||||
app.services.tablet_poll = Some(poll);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, display);
|
||||
app.services.right_button_down = tablet::right_button_service();
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
|
||||
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`) and the eraser end (`PEN_FLAG_INVERTED`);
|
||||
//! `photocraft-tablet` recovers them through thread-local message hooks beside winit.
|
||||
//! `photocraft-tablet` recovers them through thread-local message hooks beside winit. With Windows
|
||||
//! Ink disabled for the tablet, the optional WinTab mode reads pressure, tilt, rotation and the
|
||||
//! eraser end from a WinTab 1.4 system context instead ([`install_wintab`]).
|
||||
//!
|
||||
//! Wayland compositors give a pen only to clients that bind the tablet protocol
|
||||
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
|
||||
@@ -43,6 +45,41 @@ pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
|
||||
Some(monitor)
|
||||
}
|
||||
|
||||
/// Installs the WinTab backend on the real PhotoCraft window and returns its per-frame poll.
|
||||
///
|
||||
/// `enabled` is Preferences > Tools > Pen Tablet > Tablet input set to WinTab. With Windows Ink disabled there, the vendor
|
||||
/// driver still moves the cursor; this backend only supplies pressure/tilt/rotation/eraser
|
||||
/// through the normal stylus feed. On failure it logs why and leaves Windows Ink untouched.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn install_wintab(cc: &eframe::CreationContext<'_>, feed: &StylusFeed, enabled: bool) -> Option<photocraft_ui_egui::TabletPollFn> {
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
let hwnd = match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Win32(handle) => handle.hwnd.get(),
|
||||
_ => return None,
|
||||
};
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
if let Err(error) = photocraft_tablet::wintab::install(hwnd, Box::new(move || ctx.request_repaint())) {
|
||||
log::warn!("WinTab backend: {error}");
|
||||
return None;
|
||||
}
|
||||
let sink = sink(feed.clone());
|
||||
log::info!("WinTab backend active (system context)");
|
||||
let logged = std::cell::Cell::new(false);
|
||||
Some(Box::new(move |focused| {
|
||||
photocraft_tablet::wintab::poll(focused, &|sample| {
|
||||
if let Some(sample) = sample
|
||||
&& !logged.replace(true)
|
||||
{
|
||||
log::info!("WinTab sample: pressure={:.3} tilt=({:.1},{:.1}) eraser={}", sample.pressure, sample.tilt_x, sample.tilt_y, sample.eraser);
|
||||
}
|
||||
sink(sample);
|
||||
});
|
||||
}))
|
||||
}
|
||||
|
||||
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
|
||||
/// reports the logical secondary button (swapped buttons included) for the app's own window only.
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -661,7 +661,7 @@ mod waited_error_tests {
|
||||
fn waited_job_keeps_typed_failure() {
|
||||
let mut headless = Headless::trusted_local();
|
||||
let id = JobId(123);
|
||||
let error = photocraft_engine::EngineError::PsdHardBudget { name: "large.psd".into(), required: 9000, limit: 4000 };
|
||||
let error = photocraft_engine::EngineError::PsdHardBudget { name: "large.psd".into(), required: 9000, limit: 4000, free_memory: None };
|
||||
headless.waited.insert(
|
||||
id,
|
||||
Some(JobEvent {
|
||||
|
||||
@@ -79,12 +79,16 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
|
||||
}
|
||||
}
|
||||
}
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => {
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit, free_memory })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, free_memory, .. }) => {
|
||||
if let Some(m) = obj.as_object_mut() {
|
||||
m.insert("required".into(), json!(required));
|
||||
m.insert("hard".into(), json!(limit));
|
||||
m.insert("hardLimit".into(), json!(limit));
|
||||
if let Some(free) = free_memory {
|
||||
m.insert("available".into(), json!(free));
|
||||
m.insert("availableRam".into(), json!(free));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1092,7 +1092,12 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() {
|
||||
// fields, and only the fields an approval request has: no soft limit and no capacity, since
|
||||
// no approval can raise the ceiling.
|
||||
let mut hard_obj = json!({"id": 2, "ok": false, "error": "test"});
|
||||
let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 8000 });
|
||||
let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
|
||||
name: "big.psd".into(),
|
||||
required: 9000,
|
||||
limit: 8000,
|
||||
free_memory: None,
|
||||
});
|
||||
super::attach_memory_error_details(&mut hard_obj, &hard);
|
||||
assert_eq!(hard_obj["required"], 9000);
|
||||
assert_eq!(hard_obj["hard"], 8000);
|
||||
@@ -1100,6 +1105,20 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() {
|
||||
for absent in ["soft", "softLimit", "available", "availableRam"] {
|
||||
assert!(hard_obj.get(absent).is_none(), "a hard refusal has no {absent}: {hard_obj}");
|
||||
}
|
||||
|
||||
// When measured free memory set the limit, the refusal also reports it.
|
||||
let mut low_obj = json!({"id": 3, "ok": false, "error": "test"});
|
||||
let low = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
|
||||
name: "big.psd".into(),
|
||||
required: 9000,
|
||||
limit: 8000,
|
||||
free_memory: Some(10_000),
|
||||
});
|
||||
super::attach_memory_error_details(&mut low_obj, &low);
|
||||
assert_eq!(low_obj["hardLimit"], 8000);
|
||||
assert_eq!(low_obj["available"], 10_000);
|
||||
assert_eq!(low_obj["availableRam"], 10_000);
|
||||
assert!(low_obj.get("soft").is_none(), "a hard refusal has no soft limit: {low_obj}");
|
||||
}
|
||||
|
||||
/// #10897: `doc.place` forwards the strict one-shot high-memory approval on both routes: the
|
||||
|
||||
@@ -514,8 +514,9 @@ fn png_result(png: &[u8], note: String) -> CallToolResult {
|
||||
}
|
||||
|
||||
/// Adds the typed byte counts of a memory refusal to an error object: an approval request keeps
|
||||
/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps only its required
|
||||
/// and hard amounts, because no approval can raise that ceiling.
|
||||
/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps its required and
|
||||
/// hard amounts, because no approval can raise that ceiling, and adds the free memory when
|
||||
/// measured memory set the limit.
|
||||
fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
|
||||
let Some(obj) = obj.as_object_mut() else { return };
|
||||
match err {
|
||||
@@ -531,11 +532,15 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
|
||||
obj.insert("availableRam".into(), json!(avail));
|
||||
}
|
||||
}
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => {
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit, free_memory })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, free_memory, .. }) => {
|
||||
obj.insert("required".into(), json!(required));
|
||||
obj.insert("hard".into(), json!(limit));
|
||||
obj.insert("hardLimit".into(), json!(limit));
|
||||
if let Some(free) = free_memory {
|
||||
obj.insert("available".into(), json!(free));
|
||||
obj.insert("availableRam".into(), json!(free));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1614,7 +1619,12 @@ mod tests {
|
||||
assert!(serde_json::from_value::<PlaceParams>(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err(), "{invalid}");
|
||||
}
|
||||
|
||||
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "huge.psd".into(), required: 9_000, limit: 8_000 });
|
||||
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
|
||||
name: "huge.psd".into(),
|
||||
required: 9_000,
|
||||
limit: 8_000,
|
||||
free_memory: None,
|
||||
});
|
||||
let res = to_result(Err(err)).unwrap();
|
||||
assert_eq!(res.is_error, Some(true));
|
||||
let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text;
|
||||
@@ -1625,6 +1635,21 @@ mod tests {
|
||||
for absent in ["soft", "softLimit", "available", "availableRam"] {
|
||||
assert!(v.get(absent).is_none(), "a hard refusal has no {absent}: {v}");
|
||||
}
|
||||
|
||||
// With measured free memory behind the limit, the refusal reports it too.
|
||||
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
|
||||
name: "huge.psd".into(),
|
||||
required: 9_000,
|
||||
limit: 8_000,
|
||||
free_memory: Some(10_000),
|
||||
});
|
||||
let res = to_result(Err(err)).unwrap();
|
||||
let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text;
|
||||
let v: Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(v["hardLimit"], 8_000);
|
||||
assert_eq!(v["available"], 10_000);
|
||||
assert_eq!(v["availableRam"], 10_000);
|
||||
assert!(v.get("soft").is_none(), "a hard refusal has no soft limit: {v}");
|
||||
}
|
||||
|
||||
/// The MCP server applies finished jobs before every tool call too (#503).
|
||||
|
||||
@@ -283,7 +283,8 @@ pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// does not keep the tile share), and the import re-checks the exact split before decoding.
|
||||
let budget = photocraft_io::memory::resolve_budget_with_scratch(photocraft_io::memory::MemorySnapshot::current(), None, u64::MAX);
|
||||
if preflight.required > budget.hard_limit {
|
||||
return Err(EngineError::PsdHardBudget { name: preflight.name, required: preflight.required, limit: budget.hard_limit });
|
||||
let free_memory = budget.refusal_free_memory(preflight.required);
|
||||
return Err(EngineError::PsdHardBudget { name: preflight.name, required: preflight.required, limit: budget.hard_limit, free_memory });
|
||||
}
|
||||
let started = s.start_open_approved_with_background(
|
||||
&preflight.name,
|
||||
|
||||
@@ -4091,7 +4091,7 @@ fn approve_memory_releases_the_preflight_when_the_hard_limit_refuses_it() {
|
||||
photocraft_io::memory::with_mock_memory(snap_low, || {
|
||||
let err = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})).unwrap_err();
|
||||
match &err {
|
||||
EngineError::PsdHardBudget { name, required, limit } => {
|
||||
EngineError::PsdHardBudget { name, required, limit, .. } => {
|
||||
assert_eq!(name, "ram_drop.psd");
|
||||
assert_eq!((*required, *limit), (8 * gb, expected_limit));
|
||||
}
|
||||
@@ -4120,7 +4120,7 @@ fn open_as_and_place_keep_the_typed_hard_budget_fields() {
|
||||
let mut s = Session::new();
|
||||
let err = s.execute("file.openAs", json!({"path": path.clone(), "approveHighMemory": true})).unwrap_err();
|
||||
match &err {
|
||||
EngineError::PsdHardBudget { name, required, limit } => {
|
||||
EngineError::PsdHardBudget { name, required, limit, .. } => {
|
||||
assert_eq!(name, &path);
|
||||
assert!(*required > hard && *limit == hard);
|
||||
}
|
||||
|
||||
@@ -213,9 +213,9 @@ fn tiff_page_errors_keep_memory_refusals_typed_and_other_context() {
|
||||
other => panic!("page 2 approval refusal lost its fields: {other:?}"),
|
||||
}
|
||||
|
||||
let hard = photocraft_io::IoError::PsdImportBudget { required: 9_000, limit: 8_000 };
|
||||
let hard = photocraft_io::IoError::PsdImportBudget { required: 9_000, limit: 8_000, free_memory: None };
|
||||
match tiff_page_error(display, name, 2, hard) {
|
||||
EngineError::PsdHardBudget { name: named, required, limit } => {
|
||||
EngineError::PsdHardBudget { name: named, required, limit, .. } => {
|
||||
assert_eq!(named, name);
|
||||
assert_eq!((required, limit), (9_000, 8_000));
|
||||
}
|
||||
@@ -244,7 +244,7 @@ fn video_frames_to_layers_keeps_a_layered_tiff_page_refusal_typed() {
|
||||
let _limit = super::limits::Scoped::psd_max_pixel_bytes(1);
|
||||
let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err();
|
||||
match err {
|
||||
EngineError::PsdHardBudget { name, required, limit } => {
|
||||
EngineError::PsdHardBudget { name, required, limit, .. } => {
|
||||
assert_eq!(name, path);
|
||||
assert_eq!(limit, 1);
|
||||
assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}");
|
||||
@@ -295,7 +295,7 @@ fn video_frames_to_layers_uses_the_footage_limit_for_a_single_page_layered_tiff(
|
||||
let _limit = super::limits::Scoped::psd_max_pixel_bytes(1);
|
||||
let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err();
|
||||
match err {
|
||||
EngineError::PsdHardBudget { name, required, limit } => {
|
||||
EngineError::PsdHardBudget { name, required, limit, .. } => {
|
||||
assert_eq!(name, path);
|
||||
assert_eq!(limit, 1);
|
||||
assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}");
|
||||
|
||||
@@ -23,8 +23,14 @@ pub enum EngineError {
|
||||
PsdApprovalRequired { name: String, required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option<u64> },
|
||||
/// The file's estimated memory cannot be admitted within the hard ceiling, which no approval
|
||||
/// can raise. The requirement may equal the ceiling when the estimate saturated.
|
||||
#[error("cannot open “{name}”: its estimated memory requirement of {required_text} ({required} bytes) cannot be admitted within the {limit_text} ({limit} byte) hard limit{scope}{scratch}", required_text = bytes_text(.required), limit_text = bytes_text(.limit), scope = build_limit_scope(build_memory_limit()), scratch = unmeasured_scratch_hint(scratch_room_unmeasured()))]
|
||||
PsdHardBudget { name: String, required: u64, limit: u64 },
|
||||
///
|
||||
/// `free_memory` is the free memory measured when it set the limit, if it did.
|
||||
#[error(
|
||||
"cannot open “{name}”: its estimated memory requirement of {required_text} ({required} bytes) cannot be admitted within the {limit_text} ({limit} byte) hard limit{scope}{scratch}{advice}",
|
||||
required_text = bytes_text(.required), limit_text = bytes_text(.limit), scope = build_limit_scope(build_memory_limit()),
|
||||
scratch = unmeasured_scratch_hint(scratch_room_unmeasured()), advice = photocraft_io::memory::low_memory_advice(.free_memory)
|
||||
)]
|
||||
PsdHardBudget { name: String, required: u64, limit: u64, free_memory: Option<u64> },
|
||||
/// The file behind an opened handle is not the one whose memory estimate was approved.
|
||||
/// The document is discarded and is not opened.
|
||||
#[error("“{name}” changed since it was checked; open it again to approve the new file")]
|
||||
@@ -76,16 +82,17 @@ fn build_limit_scope(limit: BuildMemoryLimit) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exactly when spilling is on but gives an import no room: see `spill::scratch_capacity`.
|
||||
/// The current scratch configuration, not the admission-time cause of a refusal.
|
||||
fn scratch_room_unmeasured() -> bool {
|
||||
let stats = photocraft_raster::spill::stats();
|
||||
stats.free_space_unmeasured && stats.size_cap_bytes.is_none()
|
||||
}
|
||||
|
||||
/// The hint that follows the scope when the scratch disk's free space can't be measured.
|
||||
/// Configuration guidance only: the refusal can instead be bound by a caller or build limit,
|
||||
/// or by memory that cannot spill. Current scratch stats cannot identify that cause.
|
||||
fn unmeasured_scratch_hint(unmeasured: bool) -> &'static str {
|
||||
if unmeasured {
|
||||
". The scratch disk's free space can't be measured, so it adds no room for this file: set a Scratch Size Limit in Preferences > Scratch Disks, or use a local disk"
|
||||
". Current scratch configuration: free space can't be measured and no Scratch Size Limit is set. To configure scratch capacity, set a Scratch Size Limit in Preferences > Scratch Disks, or use a disk whose free space can be measured. Scratch capacity cannot override caller or build limits, or replace memory that cannot spill to disk"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
@@ -127,7 +134,7 @@ pub(crate) fn io_open_error(name: &str, error: photocraft_io::IoError) -> Engine
|
||||
IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } => {
|
||||
EngineError::PsdApprovalRequired { name: name.to_string(), required, soft_limit, hard_limit, available_ram }
|
||||
}
|
||||
IoError::PsdImportBudget { required, limit } => EngineError::PsdHardBudget { name: name.to_string(), required, limit },
|
||||
IoError::PsdImportBudget { required, limit, free_memory } => EngineError::PsdHardBudget { name: name.to_string(), required, limit, free_memory },
|
||||
IoError::SourceChanged => EngineError::SourceChanged { name: name.to_string() },
|
||||
error => EngineError::Other(format!("{name}: {error}")),
|
||||
}
|
||||
@@ -143,6 +150,35 @@ impl From<photocraft_raster::AllocationError> for EngineError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hard(free_memory: Option<u64>) -> EngineError {
|
||||
EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 4000, free_memory }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_budget_message_tells_the_user_to_free_memory_when_it_was_measured() {
|
||||
let message = hard(Some(900 << 20)).to_string();
|
||||
assert!(message.ends_with("Close other apps and try again"), "{message}");
|
||||
assert!(message.contains("900 MiB"), "{message}");
|
||||
|
||||
let message = hard(Some(0)).to_string();
|
||||
assert!(message.contains("measured no free memory"), "{message}");
|
||||
|
||||
let message = hard(None).to_string();
|
||||
assert!(message.contains(&build_limit_scope(build_memory_limit())), "{message}");
|
||||
assert!(!message.contains("Close other apps"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_open_error_carries_the_free_memory_through() {
|
||||
for free_memory in [None, Some(0), Some(3 << 30)] {
|
||||
let error = photocraft_io::IoError::PsdImportBudget { required: 9000, limit: 4000, free_memory };
|
||||
match io_open_error("big.psd", error) {
|
||||
EngineError::PsdHardBudget { required: 9000, limit: 4000, free_memory: carried, .. } => assert_eq!(carried, free_memory),
|
||||
other => panic!("expected the typed hard refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_budget_scope_text_per_build() {
|
||||
assert_eq!(build_limit_scope(BuildMemoryLimit::Measured), " on this machine");
|
||||
@@ -163,16 +199,27 @@ mod tests {
|
||||
fn unmeasured_scratch_hint_only_when_unmeasured() {
|
||||
assert_eq!(unmeasured_scratch_hint(false), "");
|
||||
let hint = unmeasured_scratch_hint(true);
|
||||
assert!(hint.starts_with(". ") && hint.contains("Scratch Size Limit") && hint.contains("local disk"), "{hint}");
|
||||
assert!(hint.starts_with(". Current scratch configuration:"), "{hint}");
|
||||
assert!(hint.contains("Scratch Size Limit in Preferences > Scratch Disks") && hint.contains("disk whose free space can be measured"), "{hint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_configuration_hint_does_not_promise_to_raise_a_binding_limit() {
|
||||
let hint = unmeasured_scratch_hint(true);
|
||||
// A caller-bound refusal can carry the same live scratch state as a capacity refusal.
|
||||
// The note must describe that state without attributing this file's refusal to it.
|
||||
assert!(hint.contains("cannot override caller or build limits"), "{hint}");
|
||||
assert!(hint.contains("memory that cannot spill to disk"), "{hint}");
|
||||
assert!(!hint.contains("for this file") && !hint.contains("so it adds no room"), "{hint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_budget_message_names_file_numbers_and_scope() {
|
||||
let text = EngineError::PsdHardBudget { name: "big.psd".into(), required: 23_049_071_616, limit: 2_147_483_648 }.to_string();
|
||||
let text = EngineError::PsdHardBudget { name: "big.psd".into(), required: 23_049_071_616, limit: 2_147_483_648, free_memory: None }.to_string();
|
||||
for part in ["big.psd", "21.5 GiB", "23049071616 bytes", "2.0 GiB", "2147483648 byte", "hard limit"] {
|
||||
assert!(text.contains(part), "{text}");
|
||||
}
|
||||
assert!(text.ends_with(&build_limit_scope(build_memory_limit())), "{text}");
|
||||
assert!(text.contains(&build_limit_scope(build_memory_limit())), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1569,14 +1569,14 @@ fn a_failed_job_keeps_its_typed_error_for_wait_and_events() {
|
||||
json!({}),
|
||||
"Test hard budget",
|
||||
false,
|
||||
|_ctx| -> Result<()> { Err(EngineError::PsdHardBudget { name: "too-big.psd".into(), required: 9000, limit: 4000 }) },
|
||||
|_ctx| -> Result<()> { Err(EngineError::PsdHardBudget { name: "too-big.psd".into(), required: 9000, limit: 4000, free_memory: None }) },
|
||||
|_, _| Ok(Value::Null),
|
||||
)
|
||||
.unwrap();
|
||||
let id = job(started);
|
||||
let err = s.wait_job(id).expect_err("the job fails");
|
||||
assert!(
|
||||
matches!(&err, EngineError::PsdHardBudget { name, required, limit } if name == "too-big.psd" && *required == 9000 && *limit == 4000),
|
||||
matches!(&err, EngineError::PsdHardBudget { name, required, limit, .. } if name == "too-big.psd" && *required == 9000 && *limit == 4000),
|
||||
"wait_job must not flatten the typed refusal: {err:?}"
|
||||
);
|
||||
let event = s.poll_jobs().into_iter().find(|e| e.id == id).expect("the job event");
|
||||
|
||||
@@ -100,6 +100,11 @@ choice!(
|
||||
/// pointer (Photoshop), or erase with the current brush while dragging (Krita, Paint).
|
||||
RightClickPaint { BrushPicker = "brushPicker", Erase = "erase" } default BrushPicker
|
||||
);
|
||||
choice!(
|
||||
/// Windows: which pen input supplies pressure. Windows Ink is the default; WinTab needs
|
||||
/// Windows Ink turned off in the tablet driver's utility and a restart.
|
||||
TabletInput { WindowsInk = "windowsInk", WinTab = "winTab" } default WindowsInk
|
||||
);
|
||||
choice!(OtherCursor { Standard = "standard", Precise = "precise" } default Standard);
|
||||
choice!(CheckerSize { None = "none", Small = "small", Medium = "medium", Large = "large" } default Medium);
|
||||
choice!(CheckerColors { Light = "light", Medium = "medium", Dark = "dark", Red = "red", Orange = "orange", Green = "green", Blue = "blue", Purple = "purple", Custom = "custom" } default Light);
|
||||
@@ -392,6 +397,9 @@ pub struct Tools {
|
||||
pub right_click_with_painting_tools: RightClickPaint,
|
||||
/// Pen tablets: pressure, tilt and rotation reach the brush (off: a pen paints like a mouse).
|
||||
pub use_tablet_pressure: bool,
|
||||
/// Windows: which pen input supplies pressure. Windows Ink is the default; WinTab needs
|
||||
/// Windows Ink turned off in the tablet driver's utility and a restart.
|
||||
pub tablet_input: TabletInput,
|
||||
/// Pen pressure response: `[input, output]` control points in 0..1 that the pen's pressure
|
||||
/// passes through before it reaches any brush ([`PressureCurve`]). Linear by default.
|
||||
pub pressure_curve: Vec<[f32; 2]>,
|
||||
@@ -419,6 +427,7 @@ impl Default for Tools {
|
||||
double_click_layer_mask_launches_select_and_mask: true,
|
||||
right_click_with_painting_tools: RightClickPaint::BrushPicker,
|
||||
use_tablet_pressure: true,
|
||||
tablet_input: TabletInput::WindowsInk,
|
||||
pressure_curve: vec![[0.0, 0.0], [1.0, 1.0]],
|
||||
bundle_nudges: true,
|
||||
nudge_bundle_pause_ms: 1000,
|
||||
|
||||
@@ -512,6 +512,20 @@ fn svg_group_rasterization_threshold_is_bounded_by_document_depth() {
|
||||
assert_eq!(s.prefs().file_handling.rasterize_svg_groups_deeper_than, 0, "out-of-range preferences leave the valid value unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tablet_input_pref_defaults_to_windows_ink() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.prefs().tools.tablet_input, TabletInput::WindowsInk);
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "tools.tabletInput"})).unwrap(), json!("windowsInk"));
|
||||
assert_eq!(choices("tools.tabletInput"), Some(TabletInput::NAMES));
|
||||
s.execute("prefs.set", json!({"path": "tools.tabletInput", "value": "winTab"})).unwrap();
|
||||
assert_eq!(s.prefs().tools.tablet_input, TabletInput::WinTab);
|
||||
for bad in [json!("ink"), json!(1), json!(true), json!(null)] {
|
||||
assert!(s.execute("prefs.set", json!({"path": "tools.tabletInput", "value": bad})).is_err());
|
||||
}
|
||||
assert_eq!(s.prefs().tools.tablet_input, TabletInput::WinTab, "rejected values change nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_with_painting_tools_pref() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::shortcuts::normalize_shortcut;
|
||||
use super::{
|
||||
AppearanceMode, Ask, CanvasBorder, CanvasColor, CheckerColors, CheckerSize, ColorPicker, DarkTheme, ExportLocation, ExportMetadata, FontPreview,
|
||||
GpuBackend, Interpolation, LightTheme, LineStyle, LinuxDisplayServer, LogDestination, LogDetail, OtherCursor, PaintingCursor, PlaybackPerformance,
|
||||
PointSize, PressureCurve, QuickExportFormat, RawColorSpace, RawDemosaic, RawDepth, RawSharpen, RenderingMode, RightClickPaint, TextEngine, Theme, TypeUnit,
|
||||
UiFontSize, UiScale, Unit, Value,
|
||||
PointSize, PressureCurve, QuickExportFormat, RawColorSpace, RawDemosaic, RawDepth, RawSharpen, RenderingMode, RightClickPaint, TabletInput, TextEngine,
|
||||
Theme, TypeUnit, UiFontSize, UiScale, Unit, Value,
|
||||
};
|
||||
|
||||
/// Preferences dialog sections in Photoshop's order: (id, title).
|
||||
@@ -88,6 +88,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
"cursors.painting" => PaintingCursor::NAMES,
|
||||
"cursors.other" => OtherCursor::NAMES,
|
||||
"tools.rightClickWithPaintingTools" => RightClickPaint::NAMES,
|
||||
"tools.tabletInput" => TabletInput::NAMES,
|
||||
"transparencyAndGamut.gridSize" => CheckerSize::NAMES,
|
||||
"transparencyAndGamut.gridColors" => CheckerColors::NAMES,
|
||||
"unitsAndRulers.rulers" | "guidesGridAndSlices.gridUnit" => Unit::NAMES,
|
||||
|
||||
@@ -2709,14 +2709,14 @@ mod streamed_png_tests {
|
||||
|
||||
let lowered = crate::ImportOptions { psd_max_pixel_bytes: 99, ..Default::default() };
|
||||
match tiff_admission(100, 0, &lowered, &ctl) {
|
||||
Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)),
|
||||
Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (100, 99)),
|
||||
other => panic!("expected the caller's limit to refuse, got {:?}", other.map(|_| ())),
|
||||
}
|
||||
assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched");
|
||||
|
||||
let stale = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(99), ..Default::default() };
|
||||
match tiff_admission(100, 0, &stale, &ctl) {
|
||||
Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)),
|
||||
Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (100, 99)),
|
||||
other => panic!("expected the approved estimate bound to refuse, got {:?}", other.map(|_| ())),
|
||||
}
|
||||
assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched");
|
||||
@@ -2743,7 +2743,7 @@ mod streamed_png_tests {
|
||||
crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(u64::MAX), ..Default::default() },
|
||||
] {
|
||||
match tiff_admission(u64::MAX, 0, &opts, &Interrupt::NONE) {
|
||||
Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (u64::MAX, u64::MAX)),
|
||||
Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (u64::MAX, u64::MAX)),
|
||||
other => panic!("expected a hard refusal for {opts:?}, got {:?}", other.map(|_| ())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +126,12 @@ pub enum IoError {
|
||||
#[error("PSD: {0}")]
|
||||
Psd(#[from] PsdError),
|
||||
/// The conservative PSD pixel-storage and workspace estimate exceeded its allowance.
|
||||
#[error("PSD import needs an estimated {required} bytes of pixel memory; limit is {limit} bytes")]
|
||||
PsdImportBudget { required: u64, limit: u64 },
|
||||
///
|
||||
/// `free_memory` is the free memory measured when it set the limit (see
|
||||
/// [`memory::MemoryBudget::measured_free`]); `None` when a caller or attempt allowance, the
|
||||
/// platform cap or an unknown capacity set it.
|
||||
#[error("PSD import needs an estimated {required} bytes of pixel memory; limit is {limit} bytes{advice}", advice = crate::memory::low_memory_advice(.free_memory))]
|
||||
PsdImportBudget { required: u64, limit: u64, free_memory: Option<u64> },
|
||||
/// PSD pixel-storage estimate exceeds the automatic soft limit, but is within the hard limit.
|
||||
/// Explicit approval is required to proceed.
|
||||
#[error(
|
||||
@@ -1446,7 +1450,7 @@ mod tests {
|
||||
for (opts, what) in cases {
|
||||
super::psd_gate::set_active_attempt_budget(ample);
|
||||
match super::import_with_options_and_interrupt("nested.psd", &bytes, &opts, &ctl) {
|
||||
Err(super::IoError::PsdImportBudget { required: r, limit }) => {
|
||||
Err(super::IoError::PsdImportBudget { required: r, limit, .. }) => {
|
||||
assert_eq!((r, limit), (required, required - 1), "{what} must refuse with its exact fields")
|
||||
}
|
||||
other => panic!("expected {what} to refuse, got {:?}", other.map(|_| ())),
|
||||
|
||||
+324
-42
@@ -31,9 +31,23 @@ pub const MAX_SOFT_BUDGET: u64 = 8 << 30;
|
||||
/// platform's actual allocation limits, but the process may run out of memory.
|
||||
pub const UNKNOWN_MEMORY_WARNING_THRESHOLD: u64 = 8 << 30;
|
||||
|
||||
/// Reserve subtracted from commit headroom when known.
|
||||
/// Largest reserve subtracted from commit headroom when known. The reserve is a quarter of the
|
||||
/// headroom, at least [`LOW_MEMORY_RESERVE`] and at most this, so a machine with little commit
|
||||
/// headroom still has room for a small file.
|
||||
pub const COMMIT_RESERVE: u64 = 512 << 20;
|
||||
|
||||
/// The smallest amount an approved import leaves free: for the app, its display and the system.
|
||||
///
|
||||
/// Free RAM or commit headroom at or below it leaves no room at all, so every file is refused.
|
||||
pub const LOW_MEMORY_RESERVE: u64 = 256 << 20;
|
||||
|
||||
/// What automatic opening leaves free once that is less than the quarter-of-free-RAM rule.
|
||||
const AUTOMATIC_RAM_RESERVE: u64 = 1 << 30;
|
||||
|
||||
/// When opening a file would leave less than this free, the approval prompt warns that
|
||||
/// PhotoCraft may run out of memory.
|
||||
pub const LOW_MEMORY_WARNING_MARGIN: u64 = 1 << 30;
|
||||
|
||||
/// Bytes Rust's layout rules allow for one contiguous allocation on this target
|
||||
/// (`isize::MAX`), not a bound on an import's aggregate peak.
|
||||
///
|
||||
@@ -171,6 +185,11 @@ pub struct MemoryBudget {
|
||||
/// Set when available memory is unknown: estimates above this threshold still need explicit
|
||||
/// approval but also carry the uncertain-capacity warning. `None` when capacity is known.
|
||||
pub warning_threshold: Option<u64>,
|
||||
/// The free memory measured when measured memory is what set the hard limit: the smaller of
|
||||
/// the known available RAM and commit headroom. `None` when neither is known, when the
|
||||
/// platform cap or the caller's limit is what bound the hard limit, and on the web. A refusal
|
||||
/// reports it so the user can be told to free memory (see [`low_memory_advice`]).
|
||||
pub measured_free: Option<u64>,
|
||||
}
|
||||
|
||||
/// Admission decision for a given required memory estimate.
|
||||
@@ -213,6 +232,34 @@ impl MemoryBudget {
|
||||
pub fn requires_uncertainty_warning(&self, required: u64) -> bool {
|
||||
self.warning_threshold.is_some_and(|threshold| required > threshold)
|
||||
}
|
||||
|
||||
/// The free memory to report when `required` is refused, or `None` when closing other apps
|
||||
/// cannot help: the estimate overflowed `u64` (`u64::MAX`), or the limit that refused it was
|
||||
/// not set by measured memory.
|
||||
pub fn refusal_free_memory(&self, required: u64) -> Option<u64> {
|
||||
if required == u64::MAX { None } else { self.measured_free }
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentence appended to a hard-memory refusal when measured free memory set the limit; empty
|
||||
/// for `None`. `Some(0)` also covers the fail-closed reading of an unreadable memory limit.
|
||||
pub fn low_memory_advice(free_memory: &Option<u64>) -> String {
|
||||
match free_memory {
|
||||
None => String::new(),
|
||||
Some(0) => ". PhotoCraft measured no free memory, or could not read this system's memory limit. Close other apps and try again".to_string(),
|
||||
Some(n) => format!(". About {} of memory is free. Close other apps and try again", size_text(*n)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A size for people: "3.0 GiB", "900 MiB", "512 B".
|
||||
fn size_text(bytes: u64) -> String {
|
||||
if bytes >= 1 << 30 {
|
||||
format!("{:.1} GiB", bytes as f64 / (1u64 << 30) as f64)
|
||||
} else if bytes >= 1 << 20 {
|
||||
format!("{} MiB", bytes / (1 << 20))
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an approval-required estimate carries the unknown-capacity warning, given the
|
||||
@@ -252,16 +299,22 @@ pub fn native_platform_cap() -> u64 {
|
||||
/// The limits are for an import's aggregate peak estimate, not for one allocation: a peak may sum
|
||||
/// many buffers, each of which is separately bounded where the decoder allocates it.
|
||||
///
|
||||
/// Policy:
|
||||
/// - Known RAM: H = min(floor(3*A/4), A - 1 GiB, C - reserve if known, [`native_platform_cap`]);
|
||||
/// S = min(A/2, 8 GiB, H).
|
||||
/// - Unknown native RAM: S = min(2 GiB, H) where H is [`native_platform_cap`], reduced by known
|
||||
/// commit headroom; approval above S is possible up to H. 8 GiB is a warning threshold, not H.
|
||||
/// - Web (wasm32): S = 1 GiB; H = min([`WASM_MAX_PEAK_BUDGET`], caller limit).
|
||||
/// Policy (A = available RAM, C = commit headroom, each when known):
|
||||
/// - Known RAM: H = min(floor(3*A/4), A - [`LOW_MEMORY_RESERVE`], C - reserve if known,
|
||||
/// [`native_platform_cap`]), where the commit reserve is a quarter of C, at least
|
||||
/// [`LOW_MEMORY_RESERVE`] and at most [`COMMIT_RESERVE`]. S = min(A/2, 8 GiB, max(A - 1 GiB,
|
||||
/// A/4), max(C - [`COMMIT_RESERVE`], C/4) if known, H). Above 4/3 GiB free this is the usual
|
||||
/// "leave 1 GiB free"; below it up to a quarter of free RAM opens automatically and the rest
|
||||
/// goes to approval. At or below [`LOW_MEMORY_RESERVE`] of either there is no room.
|
||||
/// - Unknown native RAM: S = min(2 GiB, commit soft room if known, H) where H is
|
||||
/// [`native_platform_cap`], reduced by known commit headroom as above; approval above S is
|
||||
/// possible up to H. 8 GiB is a warning threshold, not H.
|
||||
/// - <= S: automatic; S < E <= H: explicit approval; > H: refuse, no unlimited override.
|
||||
/// - The saturated `u64::MAX` overflow sentinel is always refused, even when H is `u64::MAX`
|
||||
/// (see [`MemoryBudget::decide`]).
|
||||
/// - Known zero available RAM stays zero (it never becomes unknown/free).
|
||||
/// - Known zero available RAM stays zero (it never becomes unknown/free). The fail-closed reading
|
||||
/// of an unreadable memory limit is `Some(0)`, so it stays refused; only a credited tile share
|
||||
/// (see [`resolve_budget_with_scratch`]) can pass, as before.
|
||||
/// - swap is never part of the adaptive budget.
|
||||
/// - Explicit caller limits may lower admission, never bypass the hard boundary.
|
||||
pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option<u64>) -> MemoryBudget {
|
||||
@@ -294,47 +347,72 @@ fn resolve_budget_credited(platform: Platform, snapshot: MemorySnapshot, caller_
|
||||
if let Some(caller) = caller_limit.filter(|&c| c > 0) {
|
||||
hard = hard.min(caller);
|
||||
}
|
||||
return MemoryBudget { soft_limit: WASM_BUDGET.min(hard), hard_limit: hard, available_ram: None, warning_threshold: None };
|
||||
return MemoryBudget { soft_limit: WASM_BUDGET.min(hard), hard_limit: hard, available_ram: None, warning_threshold: None, measured_free: None };
|
||||
}
|
||||
|
||||
let platform_cap = native_platform_cap();
|
||||
let (mut hard, mut soft, warning_threshold) = match snapshot.available_ram {
|
||||
Some(a) => {
|
||||
let h_pct = ((a as u128 * 3) / 4) as u64;
|
||||
let h_reserve = a.saturating_sub(1 << 30);
|
||||
let mut h = h_pct.min(h_reserve);
|
||||
let ram = snapshot.available_ram;
|
||||
let commit = snapshot.commit_headroom;
|
||||
|
||||
if let Some(c) = snapshot.commit_headroom {
|
||||
let c_room = c.saturating_sub(COMMIT_RESERVE);
|
||||
h = h.min(c_room);
|
||||
}
|
||||
|
||||
h = h.min(platform_cap);
|
||||
|
||||
let s_pct = a / 2;
|
||||
let s = s_pct.min(MAX_SOFT_BUDGET).min(h);
|
||||
(h, s, None)
|
||||
}
|
||||
None => {
|
||||
let mut h = platform_cap;
|
||||
if let Some(c) = snapshot.commit_headroom {
|
||||
let c_room = c.saturating_sub(COMMIT_RESERVE);
|
||||
h = h.min(c_room);
|
||||
}
|
||||
let s = NATIVE_FALLBACK_BUDGET.min(h);
|
||||
(h, s, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD))
|
||||
}
|
||||
// The room measured memory leaves; with neither measurement known only the platform cap bounds.
|
||||
let measured_room = match (ram.map(ram_hard_room), commit.map(commit_hard_room)) {
|
||||
(Some(r), Some(c)) => Some(r.min(c)),
|
||||
(r, c) => r.or(c),
|
||||
};
|
||||
let mut hard = measured_room.map_or(platform_cap, |room| room.min(platform_cap));
|
||||
let commit_soft = commit.map(commit_soft_room);
|
||||
let (mut soft, warning_threshold) = match ram {
|
||||
Some(a) => (ram_soft_room(a).min(commit_soft.unwrap_or(u64::MAX)).min(hard), None),
|
||||
None => (NATIVE_FALLBACK_BUDGET.min(commit_soft.unwrap_or(u64::MAX)).min(hard), Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)),
|
||||
};
|
||||
// Tile pixels the scratch disk takes need no RAM (see [`resolve_budget_with_scratch`]).
|
||||
hard = hard.saturating_add(credit);
|
||||
soft = soft.saturating_add(credit);
|
||||
let machine_limit = hard;
|
||||
|
||||
if let Some(caller) = caller_limit.filter(|&c| c > 0) {
|
||||
hard = hard.min(caller);
|
||||
soft = soft.min(hard);
|
||||
}
|
||||
|
||||
MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: snapshot.available_ram, warning_threshold }
|
||||
// Report the measured free memory only when it alone set the hard limit: where the platform
|
||||
// cap equals the measured room, more free memory could not raise the limit.
|
||||
let measured_free = match (ram, commit) {
|
||||
(None, None) => None,
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(c)) => Some(c),
|
||||
(Some(a), Some(c)) => Some(a.min(c)),
|
||||
}
|
||||
.filter(|_| measured_room.is_some_and(|room| room < platform_cap) && hard >= machine_limit);
|
||||
|
||||
MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: ram, warning_threshold, measured_free }
|
||||
}
|
||||
|
||||
/// Hard room (what an approved import may use) when `a` bytes of RAM are free: three quarters of
|
||||
/// it, leaving at least [`LOW_MEMORY_RESERVE`].
|
||||
fn ram_hard_room(a: u64) -> u64 {
|
||||
let quarter_rule = ((a as u128 * 3) / 4) as u64;
|
||||
quarter_rule.min(a.saturating_sub(LOW_MEMORY_RESERVE))
|
||||
}
|
||||
|
||||
/// Automatic room when `a` bytes of RAM are free: half of it, up to [`MAX_SOFT_BUDGET`], leaving
|
||||
/// 1 GiB free or, when that is less, a quarter of it.
|
||||
fn ram_soft_room(a: u64) -> u64 {
|
||||
(a / 2).min(MAX_SOFT_BUDGET).min(a.saturating_sub(AUTOMATIC_RAM_RESERVE).max(a / 4))
|
||||
}
|
||||
|
||||
/// Hard room when `c` bytes of commit headroom remain: all but a reserve of a quarter, at least
|
||||
/// [`LOW_MEMORY_RESERVE`] and at most [`COMMIT_RESERVE`].
|
||||
fn commit_hard_room(c: u64) -> u64 {
|
||||
// `clamp` panics only if its bounds are out of order, and they are constants.
|
||||
const { assert!(LOW_MEMORY_RESERVE <= COMMIT_RESERVE) };
|
||||
let reserve = (c / 4).clamp(LOW_MEMORY_RESERVE, COMMIT_RESERVE);
|
||||
c.saturating_sub(reserve)
|
||||
}
|
||||
|
||||
/// Automatic room when `c` bytes of commit headroom remain.
|
||||
fn commit_soft_room(c: u64) -> u64 {
|
||||
c.saturating_sub(COMMIT_RESERVE).max(c / 4)
|
||||
}
|
||||
|
||||
/// Result of evaluating an import admission request.
|
||||
@@ -411,7 +489,9 @@ fn evaluate_psd_admission_for(
|
||||
}
|
||||
cap
|
||||
}
|
||||
BudgetDecision::Refused => return Err(IoError::PsdImportBudget { required, limit: budget.hard_limit }),
|
||||
BudgetDecision::Refused => {
|
||||
return Err(IoError::PsdImportBudget { required, limit: budget.hard_limit, free_memory: budget.refusal_free_memory(required) });
|
||||
}
|
||||
};
|
||||
|
||||
if required > authorized_total {
|
||||
@@ -448,7 +528,7 @@ pub(crate) fn nested_import_local_bound(required: u64, caller_limit: u64, approv
|
||||
(caller, approved) => caller.or(approved),
|
||||
};
|
||||
match limit {
|
||||
Some(limit) if required > limit => Err(IoError::PsdImportBudget { required, limit }),
|
||||
Some(limit) if required > limit => Err(IoError::PsdImportBudget { required, limit, free_memory: None }),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -767,7 +847,7 @@ mod tests {
|
||||
|
||||
for (approved, estimate) in [(false, None), (true, None), (true, Some(u64::MAX))] {
|
||||
match evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, 0, approved, estimate, MemorySnapshot::UNKNOWN) {
|
||||
Err(IoError::PsdImportBudget { required, limit }) => {
|
||||
Err(IoError::PsdImportBudget { required, limit, .. }) => {
|
||||
assert_eq!((required, limit), (u64::MAX, u64::MAX), "the typed refusal keeps the estimate and limit unchanged")
|
||||
}
|
||||
other => panic!("approved={approved}, estimate={estimate:?}: expected a hard refusal, got {other:?}"),
|
||||
@@ -777,7 +857,7 @@ mod tests {
|
||||
// A caller limit equal to the sentinel, or an approved estimate of it, is no exception.
|
||||
assert!(matches!(
|
||||
evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, u64::MAX, true, Some(u64::MAX), MemorySnapshot::UNKNOWN),
|
||||
Err(IoError::PsdImportBudget { required: u64::MAX, limit: u64::MAX })
|
||||
Err(IoError::PsdImportBudget { required: u64::MAX, limit: u64::MAX, .. })
|
||||
));
|
||||
}
|
||||
|
||||
@@ -816,8 +896,9 @@ mod tests {
|
||||
|
||||
let snap_tight = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(gb) };
|
||||
let b_tight = resolve_budget(snap_tight, None);
|
||||
assert_eq!(b_tight.hard_limit, gb - COMMIT_RESERVE);
|
||||
assert_eq!(b_tight.soft_limit, gb - COMMIT_RESERVE);
|
||||
// 1 GiB of headroom keeps a quarter free (256 MiB), not the full 512 MiB.
|
||||
assert_eq!(b_tight.hard_limit, 768 << 20);
|
||||
assert_eq!(b_tight.soft_limit, 512 << 20);
|
||||
|
||||
let snap_room = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(4 * gb) };
|
||||
let b_room = resolve_budget(snap_room, None);
|
||||
@@ -995,7 +1076,7 @@ mod tests {
|
||||
assert!(evaluate_psd_admission(26 * gb, 18 * gb, 0, true, Some(26 * gb - 1), snap).is_err());
|
||||
// 10 GiB non-spillable is over H: refused even when approved.
|
||||
let err = evaluate_psd_admission(28 * gb, 18 * gb, 0, true, Some(28 * gb), snap).unwrap_err();
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 28 * gb && limit == 27 * gb));
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit, .. } if required == 28 * gb && limit == 27 * gb));
|
||||
// A spillable share claimed beyond the total is clamped to the total.
|
||||
assert!(evaluate_psd_admission(5 * gb, u64::MAX, 0, false, None, snap).is_ok());
|
||||
// The caller's explicit limit still bounds the whole import.
|
||||
@@ -1081,4 +1162,205 @@ mod tests {
|
||||
let without_swap = MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None };
|
||||
assert_eq!(resolve_budget_for(Platform::Native, with_swap, None), resolve_budget_for(Platform::Native, without_swap, None));
|
||||
}
|
||||
|
||||
const MIB: u64 = 1 << 20;
|
||||
const GIB: u64 = 1 << 30;
|
||||
|
||||
fn native_budget(ram: Option<u64>, commit: Option<u64>) -> MemoryBudget {
|
||||
resolve_budget_for(Platform::Native, MemorySnapshot { available_ram: ram, swap_free: None, commit_headroom: commit }, None)
|
||||
}
|
||||
|
||||
fn hard_refusal(result: Result<AdmissionOutcome, IoError>) -> (u64, u64, Option<u64>) {
|
||||
match result {
|
||||
Err(IoError::PsdImportBudget { required, limit, free_memory }) => (required, limit, free_memory),
|
||||
other => panic!("expected a hard refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_ram_boundaries_keep_room_for_small_files() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
return;
|
||||
}
|
||||
// (free RAM, soft limit, hard limit)
|
||||
let table = [
|
||||
(0, 0, 0),
|
||||
(128 * MIB, 0, 0),
|
||||
(256 * MIB, 0, 0),
|
||||
(256 * MIB + 1, 64 * MIB, 1),
|
||||
(512 * MIB, 128 * MIB, 256 * MIB),
|
||||
(GIB, 256 * MIB, 768 * MIB),
|
||||
(GIB + 1, 256 * MIB, 768 * MIB),
|
||||
(1366 * MIB, 342 * MIB, 2049 * MIB / 2),
|
||||
(3 * GIB / 2, 512 * MIB, 1152 * MIB),
|
||||
(2 * GIB, GIB, 3 * GIB / 2),
|
||||
(3 * GIB, 3 * GIB / 2, 9 * GIB / 4),
|
||||
(4 * GIB, 2 * GIB, 3 * GIB),
|
||||
(8 * GIB, 4 * GIB, 6 * GIB),
|
||||
];
|
||||
for (ram, soft, hard) in table {
|
||||
let b = native_budget(Some(ram), None);
|
||||
// The soft limit is also bounded by the hard limit: 256 MiB + 1 leaves 1 byte.
|
||||
let soft = soft.min(hard);
|
||||
assert_eq!((b.soft_limit, b.hard_limit), (soft, hard), "free RAM {ram}");
|
||||
assert_eq!(b.warning_threshold, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_commit_boundaries_keep_room_for_small_files() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
return;
|
||||
}
|
||||
// (commit headroom, soft limit, hard limit) with plenty of RAM, then with RAM unknown.
|
||||
let table = [
|
||||
(0, 0, 0),
|
||||
(128 * MIB, 0, 0),
|
||||
(256 * MIB, 0, 0),
|
||||
(512 * MIB, 128 * MIB, 256 * MIB),
|
||||
(512 * MIB + 1, 128 * MIB, 256 * MIB + 1),
|
||||
(GIB, 512 * MIB, 768 * MIB),
|
||||
(3 * GIB / 2, GIB, 1152 * MIB),
|
||||
(2 * GIB, 3 * GIB / 2, 3 * GIB / 2),
|
||||
(5 * GIB, 9 * GIB / 2, 9 * GIB / 2),
|
||||
];
|
||||
for (commit, soft, hard) in table {
|
||||
let b = native_budget(Some(64 * GIB), Some(commit));
|
||||
assert_eq!((b.soft_limit, b.hard_limit), (soft, hard), "commit {commit}, RAM known");
|
||||
let unknown = native_budget(None, Some(commit));
|
||||
assert_eq!(unknown.hard_limit, hard, "commit {commit}, RAM unknown");
|
||||
assert_eq!(unknown.soft_limit, soft.min(2 * GIB), "commit {commit}, RAM unknown");
|
||||
assert_eq!(unknown.warning_threshold, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgets_grow_with_free_memory_and_leave_the_reserve() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
return;
|
||||
}
|
||||
for by_commit in [false, true] {
|
||||
let (mut last_soft, mut last_hard) = (0, 0);
|
||||
let mut measured = 0;
|
||||
while measured <= 9 * GIB {
|
||||
let b = if by_commit { native_budget(Some(64 * GIB), Some(measured)) } else { native_budget(Some(measured), None) };
|
||||
assert!(b.soft_limit <= b.hard_limit, "commit={by_commit} {measured}");
|
||||
assert!(b.hard_limit <= measured, "commit={by_commit} {measured}");
|
||||
assert!(b.soft_limit >= last_soft && b.hard_limit >= last_hard, "commit={by_commit} {measured}: not non-decreasing");
|
||||
if b.hard_limit > 0 {
|
||||
assert!(measured - b.hard_limit >= LOW_MEMORY_RESERVE, "commit={by_commit} {measured}: reserve not kept");
|
||||
}
|
||||
(last_soft, last_hard) = (b.soft_limit, b.hard_limit);
|
||||
measured += 16 * MIB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_file_opens_on_a_machine_with_little_free_memory() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
return;
|
||||
}
|
||||
let b = native_budget(Some(900 * MIB), None);
|
||||
assert_eq!(b.decide(100 * MIB), BudgetDecision::Automatic);
|
||||
assert_eq!(b.decide(300 * MIB), BudgetDecision::RequiresApproval);
|
||||
assert_eq!(b.decide(700 * MIB), BudgetDecision::Refused);
|
||||
|
||||
// The reported case: about 190 MB beyond 1 GiB free.
|
||||
let snap = MemorySnapshot { available_ram: Some(190_693_376 + GIB), swap_free: None, commit_headroom: None };
|
||||
let (required, limit, free) = hard_refusal(evaluate_psd_admission_for(Platform::Native, 1_136_504_592, 0, 0, false, None, snap));
|
||||
assert_eq!((required, limit), (1_136_504_592, (190_693_376 + GIB) * 3 / 4));
|
||||
assert_eq!(free, Some(190_693_376 + GIB));
|
||||
assert!(matches!(evaluate_psd_admission_for(Platform::Native, 500_000_000, 0, 0, false, None, snap), Err(IoError::PsdApprovalRequired { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals_report_free_memory_only_when_measured_memory_set_the_limit() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
return;
|
||||
}
|
||||
let snap = |ram, commit| MemorySnapshot { available_ram: ram, swap_free: None, commit_headroom: commit };
|
||||
let refuse = |required, caller, snapshot| hard_refusal(evaluate_psd_admission_for(Platform::Native, required, 0, caller, false, None, snapshot));
|
||||
|
||||
// RAM binds.
|
||||
assert_eq!(refuse(2 * GIB, 0, snap(Some(GIB), None)), (2 * GIB, 768 * MIB, Some(GIB)));
|
||||
// Commit headroom below RAM binds.
|
||||
assert_eq!(refuse(2 * GIB, 0, snap(Some(64 * GIB), Some(GIB))), (2 * GIB, 768 * MIB, Some(GIB)));
|
||||
// Commit headroom alone (RAM unknown).
|
||||
assert_eq!(refuse(2 * GIB, 0, snap(None, Some(GIB))), (2 * GIB, 768 * MIB, Some(GIB)));
|
||||
// Zero measured RAM (also the fail-closed unreadable limit) refuses everything.
|
||||
assert_eq!(refuse(1, 0, snap(Some(0), None)), (1, 0, Some(0)));
|
||||
// A binding caller limit is not the machine's.
|
||||
assert_eq!(refuse(2 * GIB, GIB, snap(Some(8 * GIB), None)), (2 * GIB, GIB, None));
|
||||
assert_eq!(refuse(2 * GIB, GIB, MemorySnapshot::UNKNOWN), (2 * GIB, GIB, None));
|
||||
// The overflow sentinel is not helped by closing apps.
|
||||
assert_eq!(refuse(u64::MAX, 0, snap(Some(8 * GIB), None)), (u64::MAX, 6 * GIB, None));
|
||||
assert_eq!(refuse(u64::MAX, 0, MemorySnapshot::UNKNOWN), (u64::MAX, u64::MAX, None));
|
||||
|
||||
// On the budget itself.
|
||||
assert_eq!(native_budget(Some(8 * GIB), None).measured_free, Some(8 * GIB));
|
||||
assert_eq!(native_budget(Some(8 * GIB), None).refusal_free_memory(u64::MAX), None);
|
||||
assert_eq!(native_budget(None, None).measured_free, None);
|
||||
assert_eq!(resolve_budget_for(Platform::Web, snap(Some(GIB), None), None).measured_free, None);
|
||||
// A caller limit equal to the machine's limit does not hide the measurement.
|
||||
assert_eq!(resolve_budget_for(Platform::Native, snap(Some(8 * GIB), None), Some(6 * GIB)).measured_free, Some(8 * GIB));
|
||||
assert_eq!(resolve_budget_for(Platform::Native, snap(Some(8 * GIB), None), Some(6 * GIB - 1)).measured_free, None);
|
||||
}
|
||||
|
||||
/// Only 32-bit native has a finite cap: when it equals the measured room, freeing memory
|
||||
/// cannot raise the limit, so no free-memory figure is reported.
|
||||
#[test]
|
||||
fn a_platform_cap_equal_to_the_measured_room_reports_no_free_memory() {
|
||||
let cap = native_platform_cap();
|
||||
if cap == u64::MAX {
|
||||
return;
|
||||
}
|
||||
assert_eq!(native_budget(None, Some(cap + COMMIT_RESERVE)).measured_free, None);
|
||||
assert_eq!(native_budget(None, Some(cap - 1 + COMMIT_RESERVE)).measured_free, Some(cap - 1 + COMMIT_RESERVE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_credit_adds_to_the_low_memory_limits() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
return;
|
||||
}
|
||||
let snap = |ram, commit| MemorySnapshot { available_ram: ram, swap_free: None, commit_headroom: commit };
|
||||
with_mock_scratch(Some(40 * GIB), || {
|
||||
let one_gib = snap(Some(GIB), None);
|
||||
let out = evaluate_psd_admission_for(Platform::Native, 2 * GIB + 100 * MIB, 2 * GIB, 0, false, None, one_gib).unwrap();
|
||||
assert_eq!(out.authorized_total, 256 * MIB + 2 * GIB);
|
||||
|
||||
let err = evaluate_psd_admission_for(Platform::Native, 2 * GIB + 500 * MIB, 2 * GIB, 0, false, None, one_gib).unwrap_err();
|
||||
assert!(matches!(err, IoError::PsdApprovalRequired { soft_limit, hard_limit, .. }
|
||||
if soft_limit == 2 * GIB + 256 * MIB && hard_limit == 2 * GIB + 768 * MIB));
|
||||
|
||||
let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 800 * MIB, 2 * GIB, 0, true, None, one_gib));
|
||||
assert_eq!(refused, (2 * GIB + 800 * MIB, 2 * GIB + 768 * MIB, Some(GIB)));
|
||||
|
||||
// Fail closed: nothing measured, so only the credited tile share passes.
|
||||
let zero = snap(Some(0), None);
|
||||
assert!(evaluate_psd_admission_for(Platform::Native, 2 * GIB, 2 * GIB, 0, false, None, zero).is_ok());
|
||||
let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 1, 2 * GIB, 0, true, None, zero));
|
||||
assert_eq!(refused, (2 * GIB + 1, 2 * GIB, Some(0)));
|
||||
|
||||
// Low commit headroom with plenty of RAM: H = 256 MiB + credit.
|
||||
let low_commit = snap(Some(64 * GIB), Some(512 * MIB));
|
||||
let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 300 * MIB, 2 * GIB, 0, true, None, low_commit));
|
||||
assert_eq!(refused, (2 * GIB + 300 * MIB, 2 * GIB + 256 * MIB, Some(512 * MIB)));
|
||||
let b = resolve_budget_credited(Platform::Native, low_commit, None, 2 * GIB);
|
||||
assert_eq!((b.soft_limit, b.hard_limit), (2 * GIB + 128 * MIB, 2 * GIB + 256 * MIB));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_memory_advice_names_the_free_memory() {
|
||||
assert_eq!(low_memory_advice(&None), "");
|
||||
assert_eq!(
|
||||
low_memory_advice(&Some(0)),
|
||||
". PhotoCraft measured no free memory, or could not read this system's memory limit. Close other apps and try again"
|
||||
);
|
||||
assert_eq!(low_memory_advice(&Some(900 << 20)), ". About 900 MiB of memory is free. Close other apps and try again");
|
||||
assert_eq!(low_memory_advice(&Some(3 << 30)), ". About 3.0 GiB of memory is free. Close other apps and try again");
|
||||
assert_eq!(low_memory_advice(&Some(512)), ". About 512 B of memory is free. Close other apps and try again");
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ pub fn estimate(file: &PsdFile) -> Result<PsdMemoryEstimate, IoError> {
|
||||
pub fn check(file: &PsdFile, limit: u64) -> Result<PsdMemoryEstimate, IoError> {
|
||||
let est = estimate(file)?;
|
||||
if est.total_bytes > limit {
|
||||
return Err(IoError::PsdImportBudget { required: est.total_bytes, limit });
|
||||
return Err(IoError::PsdImportBudget { required: est.total_bytes, limit, free_memory: None });
|
||||
}
|
||||
Ok(est)
|
||||
}
|
||||
|
||||
@@ -284,6 +284,29 @@ fn zero_available_ram_refuses_admission() {
|
||||
});
|
||||
}
|
||||
|
||||
/// A small file opens with 900 MiB free (it used to be refused outright below 1 GiB); with no
|
||||
/// free memory at all it is refused and the refusal says why.
|
||||
#[test]
|
||||
fn small_file_opens_with_little_free_memory_and_a_refusal_names_it() {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return; // the web policy ignores a host snapshot; it has its own tests
|
||||
}
|
||||
let file = testgen::small(Version::Psd, Compression::Zip);
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
|
||||
let low = crate::memory::MemorySnapshot { available_ram: Some(900 << 20), swap_free: None, commit_headroom: None };
|
||||
crate::memory::with_mock_memory(low, || {
|
||||
let res = crate::import_with_options("small.psd", &bytes, &crate::ImportOptions::default());
|
||||
assert!(res.is_ok(), "{:?}", res.map(|_| ()));
|
||||
});
|
||||
|
||||
let none = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None };
|
||||
crate::memory::with_mock_memory(none, || {
|
||||
let res = crate::import_with_options("small.psd", &bytes, &crate::ImportOptions::default());
|
||||
assert!(matches!(res, Err(IoError::PsdImportBudget { limit: 0, free_memory: Some(0), .. })));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_rejection_and_approval_files_release_gate() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
@@ -337,7 +360,7 @@ fn retry_estimate_cap_rejects_altered_or_enlarged_file() {
|
||||
|
||||
// 3. Exceeds hard limit: refused completely regardless of approval
|
||||
let res = crate::memory::evaluate_psd_admission(10 * gb, 0, 0, true, Some(10 * gb), snap);
|
||||
assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit }) if required == 10 * gb && limit == 9 * gb));
|
||||
assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit, .. }) if required == 10 * gb && limit == 9 * gb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,7 +436,7 @@ fn nested_imports_consume_from_shared_allowance_and_do_not_reset_parent() {
|
||||
|
||||
// Second nested consume exceeds remaining 1 GB
|
||||
let err = crate::psd_gate::consume_attempt_allowance(2 * gb).unwrap_err();
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 2 * gb && limit == gb));
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit, .. } if required == 2 * gb && limit == gb));
|
||||
|
||||
// Allowance is still 1 GB (failed attempt does not reset or corrupt parent)
|
||||
assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok());
|
||||
@@ -472,7 +495,7 @@ fn real_import_propagates_high_memory_approval_and_its_estimate_bound() {
|
||||
let lowered = crate::ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() };
|
||||
assert!(matches!(
|
||||
crate::import_with_options("approval.psd", &bytes, &lowered),
|
||||
Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1
|
||||
Err(IoError::PsdImportBudget { required: r, limit: l, .. }) if r == required && l == required - 1
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -527,7 +550,7 @@ fn memory_drop_while_waiting_for_the_gate_refuses_before_decoding() {
|
||||
|
||||
let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding");
|
||||
match result {
|
||||
Err(IoError::PsdImportBudget { required, limit: 0 }) => assert!(required > 0),
|
||||
Err(IoError::PsdImportBudget { required, limit: 0, .. }) => assert!(required > 0),
|
||||
other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"),
|
||||
}
|
||||
handle.join().expect("worker thread");
|
||||
|
||||
@@ -79,14 +79,14 @@ pub(crate) fn consume_attempt_allowance(bytes: u64) -> Result<(), IoError> {
|
||||
ATTEMPT_ALLOWANCE.with(|a| match a.get() {
|
||||
Some(remaining) => {
|
||||
if bytes == u64::MAX || bytes > remaining {
|
||||
return Err(IoError::PsdImportBudget { required: bytes, limit: remaining });
|
||||
return Err(IoError::PsdImportBudget { required: bytes, limit: remaining, free_memory: None });
|
||||
}
|
||||
a.set(Some(remaining - bytes));
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
// Unknown allowance during reentrant gate must fail closed.
|
||||
Err(IoError::PsdImportBudget { required: bytes, limit: 0 })
|
||||
Err(IoError::PsdImportBudget { required: bytes, limit: 0, free_memory: None })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -193,7 +193,7 @@ mod tests {
|
||||
set_active_attempt_budget(u64::MAX);
|
||||
|
||||
let err = consume_attempt_allowance(u64::MAX).expect_err("the overflow sentinel is never a charge");
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == u64::MAX && limit == u64::MAX));
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit, .. } if required == u64::MAX && limit == u64::MAX));
|
||||
assert_eq!(active_attempt_remaining(), Some(u64::MAX), "a refused sentinel charge leaves the parent's allowance untouched");
|
||||
|
||||
// One byte below the sentinel is an ordinary, enormous charge.
|
||||
|
||||
@@ -1319,7 +1319,7 @@ mod tests {
|
||||
let lowered = ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() };
|
||||
assert!(matches!(
|
||||
crate::import_with_options("l.tif", &bytes, &lowered),
|
||||
Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1
|
||||
Err(IoError::PsdImportBudget { required: r, limit: l, .. }) if r == required && l == required - 1
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -1351,7 +1351,7 @@ mod tests {
|
||||
// remaining allowance is untouched.
|
||||
crate::psd_gate::set_active_attempt_budget(charged - 1);
|
||||
match crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl) {
|
||||
Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (charged, charged - 1)),
|
||||
Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (charged, charged - 1)),
|
||||
other => panic!("expected an import-budget refusal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(charged - 1), "a failed nested import leaves the allowance as it was");
|
||||
@@ -1462,7 +1462,7 @@ mod tests {
|
||||
crate::memory::with_mock_memory(zero, || {
|
||||
crate::flat::with_before_composite_decode(hook(&decoded), || {
|
||||
match crate::import_with_options("bogus.tif", &bytes, &ImportOptions::default()) {
|
||||
Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0),
|
||||
Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0),
|
||||
other => panic!("expected an import-budget refusal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(decoded.get(), 0, "a refused file must not decode its composite");
|
||||
@@ -1504,7 +1504,7 @@ mod tests {
|
||||
crate::memory::with_mock_memory(zero, || {
|
||||
crate::flat::with_before_composite_decode(hook(&decoded), || {
|
||||
match crate::import_with_options("zero.tif", &bytes, &ImportOptions::default()) {
|
||||
Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0),
|
||||
Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0),
|
||||
other => panic!("expected an import-budget refusal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(decoded.get(), 0, "a refused file must not decode its composite");
|
||||
@@ -1589,7 +1589,7 @@ mod tests {
|
||||
|
||||
let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding");
|
||||
match result {
|
||||
Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0),
|
||||
Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0),
|
||||
other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"),
|
||||
}
|
||||
assert!(!decoded.load(Ordering::SeqCst), "the composite must not be decoded after the post-gate refusal");
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
const ALIGN: u64 = 4096;
|
||||
@@ -48,6 +48,7 @@ pub(crate) struct Allocator {
|
||||
|
||||
impl Allocator {
|
||||
/// Bytes currently allocated (end of file minus free space).
|
||||
#[cfg(test)]
|
||||
pub fn used(&self) -> u64 {
|
||||
self.end - self.by_offset.values().sum::<u64>()
|
||||
}
|
||||
@@ -108,6 +109,9 @@ pub(crate) struct ScratchFile {
|
||||
#[cfg(not(windows))]
|
||||
linked: bool,
|
||||
allocator: Mutex<Allocator>,
|
||||
/// What [`ScratchFile::used`] reads. No lock: `free` truncates the file under the allocator lock,
|
||||
/// and a dead folder would block that, so a status read must not wait for it.
|
||||
used: AtomicU64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ScratchFile {
|
||||
@@ -129,6 +133,7 @@ impl ScratchFile {
|
||||
#[cfg(not(windows))]
|
||||
linked,
|
||||
allocator: Mutex::new(Allocator::default()),
|
||||
used: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +150,7 @@ impl ScratchFile {
|
||||
pub fn write(&self, data: &[u8]) -> std::io::Result<Extent> {
|
||||
let len = u32::try_from(data.len()).map_err(|_| std::io::Error::other("block larger than 4 GiB"))?;
|
||||
let mut extent = self.lock().alloc(len);
|
||||
self.used.fetch_add(extent.alloc, Ordering::Relaxed);
|
||||
if let Err(e) = write_all_at(&self.main, data, extent.offset) {
|
||||
self.free(extent);
|
||||
return Err(e);
|
||||
@@ -164,6 +170,7 @@ impl ScratchFile {
|
||||
}
|
||||
|
||||
pub fn free(&self, extent: Extent) {
|
||||
self.used.fetch_sub(extent.alloc, Ordering::Relaxed);
|
||||
let mut allocator = self.lock();
|
||||
allocator.free(extent);
|
||||
if allocator.end == 0 {
|
||||
@@ -175,7 +182,7 @@ impl ScratchFile {
|
||||
}
|
||||
|
||||
pub fn used(&self) -> u64 {
|
||||
self.lock().used()
|
||||
self.used.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Allocator> {
|
||||
@@ -483,6 +490,23 @@ mod tests {
|
||||
assert_eq!(a.alloc(10).offset, 4096, "remainder of the split hole is used next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn used_follows_writes_and_frees() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-scratch-used-{}", std::process::id()));
|
||||
let scratch = ScratchFile::create(&dir).unwrap();
|
||||
assert_eq!(scratch.used(), 0);
|
||||
let a = scratch.write(b"one").unwrap();
|
||||
assert_eq!(scratch.used(), a.alloc);
|
||||
let b = scratch.write(&[1u8; 9000]).unwrap();
|
||||
assert_eq!(scratch.used(), a.alloc + b.alloc);
|
||||
scratch.free(a);
|
||||
assert_eq!(scratch.used(), b.alloc);
|
||||
scratch.free(b);
|
||||
assert_eq!(scratch.used(), 0);
|
||||
drop(scratch);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_roundtrip_and_crc() {
|
||||
let dir = std::env::temp_dir().join("photocraft-scratch-test");
|
||||
|
||||
+438
-80
@@ -22,10 +22,16 @@
|
||||
//! poisons the tile: edits and saves check the health of the document they publish, plus
|
||||
//! [`check_since`] for failures that came and went during the operation. The
|
||||
//! infallible display API uses a temporary placeholder, never replacement pixel storage.
|
||||
//!
|
||||
//! No lock is held across a call into the scratch folder. Opening and measuring it run on a
|
||||
//! short-lived query thread, at most one per folder and question; callers wait a bounded time
|
||||
//! for the answer, and a late answer still applies if that folder is still the configured one.
|
||||
//! Status reads the UI makes every frame stay fast whatever the folder does.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
|
||||
use std::sync::{Arc, Condvar, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::scratch_file::{Extent, ScratchFile};
|
||||
@@ -261,6 +267,8 @@ struct Config {
|
||||
backoff: Duration,
|
||||
/// Explicit scratch size limit ([`set_size_cap`]); the free-space reserve always applies.
|
||||
size_cap: Option<u64>,
|
||||
/// Scratch bytes the volume allows, as of the last answer; `None` = unmeasured.
|
||||
room: Option<u64>,
|
||||
}
|
||||
|
||||
struct Pause {
|
||||
@@ -274,6 +282,51 @@ const RETRY_MAX: Duration = Duration::from_secs(60);
|
||||
const SPACE_CHECK_EVERY: u64 = 64 << 20;
|
||||
/// Leave at least max(1 GiB, 5 % of the volume) free for the system and other programs.
|
||||
const RESERVE_MIN: u64 = 1 << 30;
|
||||
/// How long a caller waits for the scratch folder to answer. A healthy disk answers in far less;
|
||||
/// the query goes on after this and its answer still counts when it comes.
|
||||
const QUERY_WAIT: Duration = Duration::from_secs(2);
|
||||
/// Most folder queries in flight at once. A system call on a dead hard mount can't be cancelled,
|
||||
/// so each stuck one keeps its thread, and every other folder tried on that mount adds one.
|
||||
const MAX_QUERIES: usize = 16;
|
||||
|
||||
/// A question in flight about a scratch folder (open it and/or measure it).
|
||||
struct Query {
|
||||
dir: PathBuf,
|
||||
prepare: bool,
|
||||
deadline: Instant,
|
||||
done: Arc<QueryDone>,
|
||||
}
|
||||
|
||||
/// Set once a query's answer has been applied (or discarded).
|
||||
#[derive(Default)]
|
||||
struct QueryDone {
|
||||
finished: Mutex<bool>,
|
||||
wake: Condvar,
|
||||
}
|
||||
|
||||
impl QueryDone {
|
||||
fn finish(&self) {
|
||||
*self.finished.lock().unwrap_or_else(PoisonError::into_inner) = true;
|
||||
self.wake.notify_all();
|
||||
}
|
||||
|
||||
fn finished(&self) -> bool {
|
||||
*self.finished.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Wait until the query finishes or `deadline` passes; a deadline already past returns at once.
|
||||
fn wait_until(&self, deadline: Instant) {
|
||||
let Some(left) = deadline.checked_duration_since(Instant::now()) else { return };
|
||||
let done = self.finished.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
drop(self.wake.wait_timeout_while(done, left, |done| !*done).unwrap_or_else(PoisonError::into_inner));
|
||||
}
|
||||
}
|
||||
|
||||
static QUERIES: Mutex<Vec<Query>> = Mutex::new(Vec::new());
|
||||
/// Query threads alive, counted until they finish, including closing any file they opened;
|
||||
/// `QUERIES` only dedupes questions.
|
||||
static QUERY_THREADS: AtomicUsize = AtomicUsize::new(0);
|
||||
static QUERY_HOOK: RwLock<Option<fn(&Path)>> = RwLock::new(None);
|
||||
|
||||
static M: Manager = Manager {
|
||||
failed: AtomicUsize::new(0),
|
||||
@@ -288,7 +341,7 @@ static M: Manager = Manager {
|
||||
recovering: AtomicBool::new(false),
|
||||
space_unmeasured: AtomicBool::new(false),
|
||||
registry: Mutex::new(Registry { cells: Vec::new(), hand: 0 }),
|
||||
config: Mutex::new(Config { dir: None, file: None, last_error: None, budget: 0, paused: None, backoff: RETRY_FIRST, size_cap: None }),
|
||||
config: Mutex::new(Config { dir: None, file: None, last_error: None, budget: 0, paused: None, backoff: RETRY_FIRST, size_cap: None, room: None }),
|
||||
evictions: AtomicU64::new(0),
|
||||
reloads: AtomicU64::new(0),
|
||||
written: AtomicU64::new(0),
|
||||
@@ -348,27 +401,73 @@ impl Manager {
|
||||
self.set_active(c.budget);
|
||||
}
|
||||
|
||||
/// Open the scratch file and check its volume's room; pause (and say why) if either fails.
|
||||
/// The caller holds the config lock. Returns whether spilling can go ahead.
|
||||
fn prepare(&self, c: &mut Config) -> bool {
|
||||
let Some(dir) = c.dir.clone() else { return false };
|
||||
if c.file.is_none() {
|
||||
match std::fs::create_dir_all(&dir).and_then(|()| ScratchFile::create(&dir)) {
|
||||
Ok(f) => c.file = Some(Arc::new(f)),
|
||||
Err(e) => {
|
||||
self.pause(c, &format!("could not create a scratch file in {}: {e}", dir.display()));
|
||||
return false;
|
||||
}
|
||||
/// Apply a finished folder query. `file` is the scratch file a `prepare` query opened (or the
|
||||
/// error), `sampled_used` the scratch bytes in use just before the volume was asked, `space`
|
||||
/// what the volume reported. Discarded if the folder changed meanwhile.
|
||||
fn settle(&self, dir: &Path, prepare: bool, file: Option<std::io::Result<ScratchFile>>, sampled_used: u64, space: Option<(u64, u64)>, done: &QueryDone) {
|
||||
// Declared before the guard so a rejected file closes after the lock is released.
|
||||
let mut _spare: Option<ScratchFile> = None;
|
||||
let mut c = self.config();
|
||||
// Under the config lock, so a caller holding the lock can tell answered from not.
|
||||
done.finish();
|
||||
if c.dir.as_deref() != Some(dir) {
|
||||
return;
|
||||
}
|
||||
match file {
|
||||
Some(Ok(f)) if c.file.is_none() => c.file = Some(Arc::new(f)),
|
||||
Some(Ok(f)) => _spare = Some(f),
|
||||
Some(Err(e)) if c.file.is_none() => {
|
||||
self.pause(&mut c, &format!("could not create a scratch file in {}: {e}", dir.display()));
|
||||
return;
|
||||
}
|
||||
Some(Err(_)) | None => {}
|
||||
}
|
||||
let used = c.file.as_ref().map_or(0, |f| f.used());
|
||||
refresh_space(c, &dir, used);
|
||||
if used >= self.space_limit.load(Ordering::Relaxed) {
|
||||
let msg = no_room(&dir, c.size_cap);
|
||||
self.pause(c, &msg);
|
||||
return false;
|
||||
apply_space(&mut c, sampled_used, space);
|
||||
if !prepare {
|
||||
return;
|
||||
}
|
||||
true
|
||||
if c.file.is_none() {
|
||||
// The folder changed away and back while this ran.
|
||||
self.pause(&mut c, ¬_responding(dir));
|
||||
return;
|
||||
}
|
||||
if used >= self.space_limit.load(Ordering::Relaxed) {
|
||||
let msg = no_room(dir, c.size_cap);
|
||||
self.pause(&mut c, &msg);
|
||||
return;
|
||||
}
|
||||
if c.paused.is_some() {
|
||||
self.resume(&mut c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the scratch file and check its volume's room, waiting up to [`QUERY_WAIT`]; an answer
|
||||
/// that comes later is applied by [`Manager::settle`]. Pauses (and says why) on failure or
|
||||
/// silence. The caller holds no lock.
|
||||
fn prepare(&self, dir: &Path) {
|
||||
let done = ask(dir, true);
|
||||
let mut c = self.config();
|
||||
if c.dir.as_deref() != Some(dir) || done.as_ref().is_some_and(|d| d.finished()) {
|
||||
return;
|
||||
}
|
||||
// No answer in time: it still counts when it comes. Until then:
|
||||
let used = c.file.as_ref().map_or(0, |f| f.used());
|
||||
apply_space(&mut c, used, None);
|
||||
if c.file.is_none() || c.paused.is_some() {
|
||||
self.pause(&mut c, ¬_responding(dir));
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the volume's free space, waiting up to [`QUERY_WAIT`]. The caller holds no lock.
|
||||
fn measure(&self, dir: &Path) {
|
||||
let done = ask(dir, false);
|
||||
let mut c = self.config();
|
||||
if c.dir.as_deref() != Some(dir) || done.as_ref().is_some_and(|d| d.finished()) {
|
||||
return;
|
||||
}
|
||||
let used = c.file.as_ref().map_or(0, |f| f.used());
|
||||
apply_space(&mut c, used, None);
|
||||
}
|
||||
|
||||
/// Retry a paused scratch disk once its backoff has elapsed. Cheap when not paused.
|
||||
@@ -376,40 +475,44 @@ impl Manager {
|
||||
if !self.paused.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let mut c = match self.config.try_lock() {
|
||||
Ok(c) => c,
|
||||
Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
|
||||
// Someone else is configuring or retrying.
|
||||
Err(std::sync::TryLockError::WouldBlock) => return,
|
||||
};
|
||||
let c = self.config();
|
||||
let due = c.paused.as_ref().is_some_and(|p| Instant::now() >= p.retry_at);
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
if c.dir.is_none() || c.budget == 0 {
|
||||
c.paused = None;
|
||||
self.paused.store(false, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
if self.prepare(&mut c) {
|
||||
self.resume(&mut c);
|
||||
self.retry_now(c);
|
||||
}
|
||||
|
||||
/// Retry a paused scratch disk now (the guard is released before the folder is asked).
|
||||
fn retry_now(&self, mut c: std::sync::MutexGuard<'_, Config>) {
|
||||
let dir = match c.dir.clone() {
|
||||
Some(dir) if c.budget > 0 => dir,
|
||||
_ => {
|
||||
c.paused = None;
|
||||
self.paused.store(false, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Push the next retry out so other threads don't all wait on this one.
|
||||
let wait = c.backoff;
|
||||
if let Some(p) = &mut c.paused {
|
||||
p.retry_at = Instant::now().checked_add(wait).unwrap_or_else(Instant::now);
|
||||
}
|
||||
drop(c);
|
||||
self.prepare(&dir);
|
||||
}
|
||||
|
||||
/// The scratch file, if spilling is on and the volume still has room.
|
||||
fn file(&self) -> Option<Arc<ScratchFile>> {
|
||||
let mut c = self.config();
|
||||
if self.budget.load(Ordering::Relaxed) == 0 {
|
||||
return None;
|
||||
}
|
||||
if c.file.is_none() && !self.prepare(&mut c) {
|
||||
return None;
|
||||
}
|
||||
c.file.clone()
|
||||
self.config().file.clone()
|
||||
}
|
||||
|
||||
/// Whether the scratch file may grow further; re-reads free space every
|
||||
/// [`SPACE_CHECK_EVERY`] bytes and pauses spilling when the volume runs low.
|
||||
/// Whether the scratch file may grow further; asks for free space every
|
||||
/// [`SPACE_CHECK_EVERY`] bytes (in the background, never waiting) and pauses spilling when the
|
||||
/// volume runs low.
|
||||
fn has_room(&self, file: &ScratchFile) -> bool {
|
||||
let used = file.used();
|
||||
if used < self.next_space_check.load(Ordering::Relaxed) && used < self.space_limit.load(Ordering::Relaxed) {
|
||||
@@ -417,8 +520,20 @@ impl Manager {
|
||||
}
|
||||
let mut c = self.config();
|
||||
let Some(dir) = c.dir.clone() else { return false };
|
||||
refresh_space(&c, &dir, used);
|
||||
if used < self.space_limit.load(Ordering::Relaxed) {
|
||||
// Ask in the background: this can be any thread, the UI's too. The answer moves the limit.
|
||||
drop(c);
|
||||
let query = start_query(&dir, false);
|
||||
let c = self.config();
|
||||
// A rejected request must remain due. Don't overwrite a new folder's state or an
|
||||
// answer that arrived before this lock was reacquired.
|
||||
if c.dir.as_deref() == Some(dir.as_path()) && !query.as_ref().is_some_and(|(done, _)| done.finished()) {
|
||||
if query.is_some() {
|
||||
self.next_space_check.store(used.saturating_add(SPACE_CHECK_EVERY), Ordering::Relaxed);
|
||||
} else {
|
||||
self.space_unmeasured.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if c.paused.is_none() {
|
||||
@@ -491,18 +606,128 @@ impl Manager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Free space on the volume holding `dir`, minus the reserve, sets how far the file may grow.
|
||||
/// The caller holds the config lock, so this is the current directory; it also records whether the
|
||||
/// volume answered, for [`free_space_unmeasured`].
|
||||
fn refresh_space(c: &Config, dir: &Path, used: u64) {
|
||||
let space = free_space(dir);
|
||||
/// Record a volume's answer (`None` = it didn't say) and derive the growth limit from it.
|
||||
fn apply_space(c: &mut Config, used: u64, space: Option<(u64, u64)>) {
|
||||
M.space_unmeasured.store(space.is_none(), Ordering::Relaxed);
|
||||
let room = space.map_or(u64::MAX, |(available, total)| used.saturating_add(available.saturating_sub(RESERVE_MIN.max(total / 20))));
|
||||
let limit = c.size_cap.map_or(room, |cap| cap.min(room));
|
||||
M.space_limit.store(limit, Ordering::Relaxed);
|
||||
c.room = space.map(|(available, total)| used.saturating_add(available.saturating_sub(RESERVE_MIN.max(total / 20))));
|
||||
store_limit(c);
|
||||
M.next_space_check.store(used.saturating_add(SPACE_CHECK_EVERY), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Publish the scratch size the volume and the size limit allow.
|
||||
fn store_limit(c: &Config) {
|
||||
let room = c.room.unwrap_or(u64::MAX);
|
||||
M.space_limit.store(c.size_cap.map_or(room, |cap| cap.min(room)), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn not_responding(dir: &Path) -> String {
|
||||
format!("scratch disk {} is not responding", dir.display())
|
||||
}
|
||||
|
||||
/// Start (or join) a query about `dir`: the same question is never asked by a second thread, and
|
||||
/// at most [`MAX_QUERIES`] run at once. `None` if no thread could be started.
|
||||
fn start_query(dir: &Path, prepare: bool) -> Option<(Arc<QueryDone>, Instant)> {
|
||||
let mut queries = QUERIES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(q) = queries.iter().find(|q| q.prepare == prepare && q.dir.as_path() == dir) {
|
||||
return Some((q.done.clone(), q.deadline));
|
||||
}
|
||||
if QUERY_THREADS.load(Ordering::Acquire) >= MAX_QUERIES {
|
||||
return None;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let deadline = now.checked_add(QUERY_WAIT).unwrap_or(now);
|
||||
let done = Arc::new(QueryDone::default());
|
||||
queries.push(Query { dir: dir.to_path_buf(), prepare, deadline, done: done.clone() });
|
||||
let (job_dir, job_done) = (dir.to_path_buf(), done.clone());
|
||||
QUERY_THREADS.fetch_add(1, Ordering::AcqRel);
|
||||
let spawned = std::thread::Builder::new().name("pc-scratch-query".into()).spawn(move || run_query(job_dir, prepare, job_done));
|
||||
if spawned.is_err() {
|
||||
QUERY_THREADS.fetch_sub(1, Ordering::AcqRel);
|
||||
queries.pop();
|
||||
return None;
|
||||
}
|
||||
Some((done, deadline))
|
||||
}
|
||||
|
||||
/// Ask about `dir` and wait up to [`QUERY_WAIT`] for the answer. The caller holds no lock.
|
||||
fn ask(dir: &Path, prepare: bool) -> Option<Arc<QueryDone>> {
|
||||
let (done, deadline) = start_query(dir, prepare)?;
|
||||
done.wait_until(deadline);
|
||||
Some(done)
|
||||
}
|
||||
|
||||
/// The query thread: touches the folder, then hands the answer to [`Manager::settle`].
|
||||
fn run_query(dir: PathBuf, prepare: bool, done: Arc<QueryDone>) {
|
||||
let hook = *QUERY_HOOK.read().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(hook) = hook {
|
||||
hook(&dir);
|
||||
}
|
||||
// Before measuring: writes made during the query come off the free space and must not be
|
||||
// counted again on top of it. Another folder's file says nothing about this one.
|
||||
let (open, used) = {
|
||||
let c = M.config();
|
||||
let current = c.file.as_ref().filter(|_| c.dir.as_deref() == Some(dir.as_path()));
|
||||
(prepare && current.is_none(), current.map_or(0, |f| f.used()))
|
||||
};
|
||||
let file = open.then(|| std::fs::create_dir_all(&dir).and_then(|()| ScratchFile::create(&dir)));
|
||||
let space = free_space(&dir);
|
||||
// Forget it first: a caller that joined it can rely on the answer, and a later caller asks anew.
|
||||
QUERIES.lock().unwrap_or_else(PoisonError::into_inner).retain(|q| !Arc::ptr_eq(&q.done, &done));
|
||||
M.settle(&dir, prepare, file, used, space, &done);
|
||||
QUERY_THREADS.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// Whether a query about `dir` is past its wait without an answer.
|
||||
fn query_overdue(dir: Option<&Path>) -> bool {
|
||||
let Some(dir) = dir else { return false };
|
||||
let now = Instant::now();
|
||||
let queries = QUERIES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
queries.iter().any(|q| q.dir.as_path() == dir && now >= q.deadline)
|
||||
}
|
||||
|
||||
/// Scratch files on their way out, oldest first.
|
||||
struct Retired {
|
||||
files: VecDeque<(Arc<ScratchFile>, Arc<QueryDone>)>,
|
||||
/// Closer threads alive.
|
||||
closers: usize,
|
||||
}
|
||||
|
||||
/// Most closer threads at once: enough that one close stuck on a dead mount doesn't hold up the
|
||||
/// files behind it, few enough that stuck closes can't pile up threads.
|
||||
const MAX_CLOSERS: usize = 4;
|
||||
|
||||
static RETIRED: Mutex<Retired> = Mutex::new(Retired { files: VecDeque::new(), closers: 0 });
|
||||
|
||||
/// Let go of a scratch file off this thread: the last reference closes it, which can flush to its
|
||||
/// folder, and a dead mount blocks that, while leaving a dead folder is how a user recovers. The
|
||||
/// returned handle finishes when this reference is gone.
|
||||
fn retire(file: Arc<ScratchFile>) -> Arc<QueryDone> {
|
||||
let done = Arc::new(QueryDone::default());
|
||||
let mut retired = RETIRED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
retired.files.push_back((file, done.clone()));
|
||||
// If no thread can start, the file waits in the queue for the next one rather than closing here.
|
||||
if retired.closers < MAX_CLOSERS && std::thread::Builder::new().name("pc-scratch-close".into()).spawn(close_retired).is_ok() {
|
||||
retired.closers += 1;
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
fn close_retired() {
|
||||
loop {
|
||||
let next = {
|
||||
let mut retired = RETIRED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let next = retired.files.pop_front();
|
||||
if next.is_none() {
|
||||
retired.closers = retired.closers.saturating_sub(1);
|
||||
}
|
||||
next
|
||||
};
|
||||
let Some((file, done)) = next else { return };
|
||||
drop(file);
|
||||
done.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn no_room(dir: &Path, cap: Option<u64>) -> String {
|
||||
match cap {
|
||||
Some(cap) => format!("scratch disk {} reached its size limit ({} MB) or is low on free space", dir.display(), cap >> 20),
|
||||
@@ -560,22 +785,41 @@ fn disk_free_space(dir: &str) -> Option<(u64, u64)> {
|
||||
/// use its available figure. A listed disk counts as this volume when the mount point's own
|
||||
/// `statvfs` gives the same numbers (APFS volumes in one container share them, and firmlinks give
|
||||
/// `/Users` and `/` different device ids, so ids can't be compared); a change between the two
|
||||
/// readings just keeps the `statvfs` figure. Never fails.
|
||||
/// readings just keeps the `statvfs` figure. sysinfo asks every mounted volume for its properties,
|
||||
/// so an unrelated dead mount must not hold up this measurement: the lookup runs on a helper thread
|
||||
/// that is waited for at most [`PURGEABLE_WAIT`]. Never fails.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn with_purgeable_space(dir: &Path, available: u64, total: u64) -> u64 {
|
||||
sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage())
|
||||
.ok()
|
||||
.and_then(|disks| {
|
||||
static LISTING: AtomicBool = AtomicBool::new(false);
|
||||
// A listing already running may be stuck on some mount: don't start another.
|
||||
if LISTING.swap(true, Ordering::AcqRel) {
|
||||
return available;
|
||||
}
|
||||
let dir = dir.to_path_buf();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("pc-scratch-disks".into()).spawn(move || {
|
||||
let found: Option<u64> = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()).ok().and_then(|disks| {
|
||||
disks
|
||||
.iter()
|
||||
.filter(|d| dir.starts_with(d.mount_point()))
|
||||
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||
.filter(|d| volume_space(d.mount_point()) == Some((available, total)))
|
||||
.map(|d| d.available_space())
|
||||
})
|
||||
.unwrap_or(available)
|
||||
});
|
||||
LISTING.store(false, Ordering::Release);
|
||||
let _ = tx.send(found);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
LISTING.store(false, Ordering::Release);
|
||||
return available;
|
||||
}
|
||||
rx.recv_timeout(PURGEABLE_WAIT).ok().flatten().unwrap_or(available)
|
||||
}
|
||||
|
||||
/// How long a measurement waits for the mounted-volume listing before using the `statvfs` figure.
|
||||
#[cfg(target_os = "macos")]
|
||||
const PURGEABLE_WAIT: Duration = Duration::from_millis(500);
|
||||
|
||||
#[cfg(all(any(unix, windows), not(target_os = "macos")))]
|
||||
fn with_purgeable_space(_dir: &Path, available: u64, _total: u64) -> u64 {
|
||||
available
|
||||
@@ -590,7 +834,11 @@ fn free_space(_dir: &Path) -> Option<(u64, u64)> {
|
||||
fn maybe_evict() {
|
||||
let budget = M.budget.load(Ordering::Relaxed);
|
||||
if budget == 0 {
|
||||
M.retry();
|
||||
// The supervisor retries when there is one; this can be any thread, the UI's too, and a
|
||||
// retry waits for the folder.
|
||||
if MAINTENANCE.load(Ordering::Acquire) == 0 {
|
||||
M.retry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let resident = M.resident.load(Ordering::Relaxed);
|
||||
@@ -660,25 +908,28 @@ pub fn set_size_cap(cap: Option<u64>) {
|
||||
return;
|
||||
}
|
||||
c.size_cap = cap;
|
||||
store_limit(&c);
|
||||
M.next_space_check.store(0, Ordering::Relaxed);
|
||||
if let Some(p) = &mut c.paused {
|
||||
p.retry_at = Instant::now();
|
||||
if c.paused.is_some() {
|
||||
c.backoff = RETRY_FIRST;
|
||||
M.retry_now(c);
|
||||
}
|
||||
drop(c);
|
||||
M.retry();
|
||||
}
|
||||
|
||||
/// Turn the scratch disk on (`dir` and a non-zero `budget_bytes`) or off. Tiles already on disk
|
||||
/// stay readable either way. Changing the directory starts a new scratch file there; the old one
|
||||
/// is deleted once its last tile is freed or changed. A directory that can't be used is kept and
|
||||
/// retried with backoff (spilling stays paused meanwhile); the error is returned and reported.
|
||||
/// Waits up to 2 s for the folder; a folder that doesn't answer counts as unusable until it does.
|
||||
pub fn configure(dir: Option<&Path>, budget_bytes: usize) -> Result<(), String> {
|
||||
let mut c = M.config();
|
||||
let dir = dir.filter(|_| budget_bytes > 0).map(Path::to_path_buf);
|
||||
if dir != c.dir {
|
||||
c.file = None;
|
||||
}
|
||||
let mut c = M.config();
|
||||
let old = if dir != c.dir {
|
||||
c.room = None;
|
||||
c.file.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
c.dir = dir.clone();
|
||||
c.budget = if dir.is_some() { budget_bytes } else { 0 };
|
||||
c.backoff = RETRY_FIRST;
|
||||
@@ -686,7 +937,22 @@ pub fn configure(dir: Option<&Path>, budget_bytes: usize) -> Result<(), String>
|
||||
c.last_error = None;
|
||||
M.paused.store(false, Ordering::Release);
|
||||
M.next_space_check.store(0, Ordering::Relaxed);
|
||||
if dir.is_some() && !M.prepare(&mut c) {
|
||||
drop(c);
|
||||
let start = Instant::now();
|
||||
let closing = old.map(retire);
|
||||
if let Some(dir) = &dir {
|
||||
M.prepare(dir);
|
||||
}
|
||||
// One wait for the whole change: closing the old file gets what the new folder left over.
|
||||
if let Some(closing) = closing {
|
||||
closing.wait_until(start.checked_add(QUERY_WAIT).unwrap_or(start));
|
||||
}
|
||||
let c = M.config();
|
||||
if c.dir != dir {
|
||||
// A newer configure owns the state now.
|
||||
return Ok(());
|
||||
}
|
||||
if c.paused.is_some() {
|
||||
return Err(c.last_error.clone().unwrap_or_default());
|
||||
}
|
||||
M.set_active(c.budget);
|
||||
@@ -753,7 +1019,7 @@ pub fn stats() -> SpillStats {
|
||||
last_error: c.last_error.clone(),
|
||||
paused: c.paused.is_some(),
|
||||
size_cap_bytes: c.size_cap,
|
||||
free_space_unmeasured: free_space_unmeasured(),
|
||||
free_space_unmeasured: enabled() && (M.space_unmeasured.load(Ordering::Relaxed) || query_overdue(c.dir.as_deref())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,25 +1031,24 @@ pub fn last_error() -> Option<String> {
|
||||
/// Uncompressed tile bytes the scratch disk can still take before it reaches its size limit
|
||||
/// ([`set_size_cap`]) or the volume's free-space reserve, so an import can admit tile pixels
|
||||
/// beyond physical RAM. `None` while spilling is off or paused, and when neither the free space
|
||||
/// nor a size limit is known (nothing to promise). Tiles are written compressed; this counts
|
||||
/// them uncompressed, less 1/64 for the allocator's block rounding, so it never over-promises.
|
||||
/// nor a size limit is known (nothing to promise), which includes a folder that doesn't answer
|
||||
/// within 2 s. Tiles are written compressed; this counts them uncompressed, less 1/64 for the allocator's block rounding, so it never over-promises.
|
||||
pub fn scratch_capacity() -> Option<u64> {
|
||||
if !enabled() || paused() {
|
||||
return None;
|
||||
}
|
||||
let (dir, used, cap) = {
|
||||
let c = M.config();
|
||||
(c.dir.clone()?, c.file.as_ref().map_or(0, |f| f.used()), c.size_cap)
|
||||
};
|
||||
let space = free_space(&dir);
|
||||
// A slow query must not overwrite the status of a directory configured meanwhile.
|
||||
// The guard is a named local: as a temporary in the `if` condition it would drop before the store.
|
||||
let dir = M.config().dir.clone()?;
|
||||
M.measure(&dir);
|
||||
let c = M.config();
|
||||
if c.dir.as_deref() == Some(dir.as_path()) {
|
||||
M.space_unmeasured.store(space.is_none(), Ordering::Relaxed);
|
||||
// A slow query must not describe a directory configured meanwhile, and a sweep can also have
|
||||
// paused spilling meanwhile.
|
||||
if c.dir.as_deref() != Some(dir.as_path()) || c.paused.is_some() {
|
||||
return None;
|
||||
}
|
||||
let used = c.file.as_ref().map_or(0, |f| f.used());
|
||||
let cap = c.size_cap;
|
||||
let room = c.room.map(|room| room.saturating_sub(used));
|
||||
drop(c);
|
||||
let room = space.map(|(available, total)| available.saturating_sub(RESERVE_MIN.max(total / 20)));
|
||||
let room = match (room, cap) {
|
||||
(Some(room), Some(cap)) => room.min(cap.saturating_sub(used)),
|
||||
(Some(room), None) => room,
|
||||
@@ -794,10 +1059,18 @@ pub fn scratch_capacity() -> Option<u64> {
|
||||
}
|
||||
|
||||
/// Whether spilling is on but the scratch directory's volume won't report its free space, so the
|
||||
/// reserve doesn't apply and imports earn scratch room only up to a size limit. A plain atomic
|
||||
/// read, safe to call every frame.
|
||||
/// reserve doesn't apply and imports earn scratch room only up to a size limit. Two short locks,
|
||||
/// neither ever held across a query, so it is safe to call every frame. A query past its wait
|
||||
/// counts as unmeasured even with nobody waiting on it.
|
||||
pub fn free_space_unmeasured() -> bool {
|
||||
enabled() && M.space_unmeasured.load(Ordering::Relaxed)
|
||||
if !enabled() {
|
||||
return false;
|
||||
}
|
||||
if M.space_unmeasured.load(Ordering::Relaxed) {
|
||||
return true;
|
||||
}
|
||||
let c = M.config();
|
||||
query_overdue(c.dir.as_deref())
|
||||
}
|
||||
|
||||
/// Whether spilling is on (tiles beyond the budget go to disk).
|
||||
@@ -849,6 +1122,18 @@ pub fn scratch_handle_for_tests() -> Option<std::fs::File> {
|
||||
M.config().file.as_ref().and_then(|f| f.duplicate_handle().ok())
|
||||
}
|
||||
|
||||
/// Runs at the start of every folder query, off every lock: tests stall it to simulate a dead mount.
|
||||
#[doc(hidden)]
|
||||
pub fn set_query_hook_for_tests(hook: Option<fn(&Path)>) {
|
||||
*QUERY_HOOK.write().unwrap_or_else(PoisonError::into_inner) = hook;
|
||||
}
|
||||
|
||||
/// Query threads still alive, so a test can wait until a stalled query has fully settled.
|
||||
#[doc(hidden)]
|
||||
pub fn query_threads_for_tests() -> usize {
|
||||
QUERY_THREADS.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Evict down to the budget now, on this thread (tests, and before a measurement).
|
||||
pub fn settle() {
|
||||
let budget = M.budget.load(Ordering::Relaxed);
|
||||
@@ -1002,6 +1287,79 @@ mod tests {
|
||||
assert_eq!(known_space(5, 10), Some((5, 10)));
|
||||
}
|
||||
|
||||
static CAPPED_GATE: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
|
||||
|
||||
fn hold_capped(dir: &Path) {
|
||||
if dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("photocraft-capped-")) {
|
||||
let mut open = CAPPED_GATE.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
while !*open {
|
||||
open = CAPPED_GATE.1.wait(open).unwrap_or_else(PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_threads_stay_capped_and_single_flight() {
|
||||
set_query_hook_for_tests(Some(hold_capped));
|
||||
let base = std::env::temp_dir().join(format!("photocraft-capped-{}", std::process::id()));
|
||||
let first = start_query(&base, false).unwrap();
|
||||
let again = start_query(&base, false).unwrap();
|
||||
assert!(Arc::ptr_eq(&first.0, &again.0) && first.1 == again.1, "the same question must not start a second thread");
|
||||
let mut started = 1;
|
||||
for i in 0..2 * MAX_QUERIES {
|
||||
if start_query(&base.join(format!("photocraft-capped-{i}")), false).is_some() {
|
||||
started += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(started, MAX_QUERIES);
|
||||
// Exercise the real growth path while every query slot is occupied. A separate manager
|
||||
// keeps this check independent of other tests' process-wide scratch configuration.
|
||||
let file = ScratchFile::create(&base).unwrap();
|
||||
let manager = Manager {
|
||||
failed: AtomicUsize::new(0),
|
||||
read_errors: AtomicU64::new(0),
|
||||
resident: AtomicUsize::new(0),
|
||||
live: AtomicUsize::new(0),
|
||||
budget: AtomicUsize::new(1),
|
||||
paused: AtomicBool::new(false),
|
||||
epoch: AtomicU64::new(0),
|
||||
space_limit: AtomicU64::new(u64::MAX),
|
||||
next_space_check: AtomicU64::new(0),
|
||||
recovering: AtomicBool::new(false),
|
||||
space_unmeasured: AtomicBool::new(false),
|
||||
registry: Mutex::new(Registry { cells: Vec::new(), hand: 0 }),
|
||||
config: Mutex::new(Config {
|
||||
dir: Some(base.join("growth")),
|
||||
file: None,
|
||||
last_error: None,
|
||||
budget: 1,
|
||||
paused: None,
|
||||
backoff: RETRY_FIRST,
|
||||
size_cap: None,
|
||||
room: None,
|
||||
}),
|
||||
evictions: AtomicU64::new(0),
|
||||
reloads: AtomicU64::new(0),
|
||||
written: AtomicU64::new(0),
|
||||
};
|
||||
let allowed = manager.has_room(&file);
|
||||
let next_check = manager.next_space_check.load(Ordering::Relaxed);
|
||||
let unmeasured = manager.space_unmeasured.load(Ordering::Relaxed);
|
||||
*CAPPED_GATE.0.lock().unwrap_or_else(PoisonError::into_inner) = true;
|
||||
CAPPED_GATE.1.notify_all();
|
||||
let start = Instant::now();
|
||||
while query_threads_for_tests() != 0 {
|
||||
assert!(start.elapsed() < Duration::from_secs(30), "queries never finished");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
set_query_hook_for_tests(None);
|
||||
assert!(allowed, "the last measured growth limit still applies");
|
||||
assert_eq!(next_check, 0, "a rejected growth query must stay due");
|
||||
assert!(unmeasured, "a rejected growth query must not report the old measurement as current");
|
||||
drop(file);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_read_retains_backing_and_blocks_commit_until_repaired() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-read-failure-{}", std::process::id()));
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
|
||||
use photocraft_raster::spill;
|
||||
|
||||
/// The room, once no query is outstanding. A query that outlives its 2 s wait on a slow machine
|
||||
/// counts as no answer, and its late answer would change the status between two reads, so ask again
|
||||
/// until the room and the status describe the same answer.
|
||||
fn answered_capacity() -> Option<u64> {
|
||||
let mut room = None;
|
||||
let started = std::time::Instant::now();
|
||||
for _ in 0..10 {
|
||||
room = spill::scratch_capacity();
|
||||
while spill::query_threads_for_tests() != 0 {
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(30), "scratch capacity query did not finish within 30 seconds: {:?}", spill::stats());
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
if room.is_none() == spill::free_space_unmeasured() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
room
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_capacity_follows_spilling_the_size_limit_and_pauses() {
|
||||
let root = std::env::temp_dir().join(format!("photocraft-scratch-capacity-{}", std::process::id()));
|
||||
@@ -16,13 +35,15 @@ fn scratch_capacity_follows_spilling_the_size_limit_and_pauses() {
|
||||
spill::configure(Some(&dir), 1 << 20).unwrap();
|
||||
assert!(spill::enabled() && !spill::paused());
|
||||
// With no size limit, the flag says exactly when the capacity is unknown.
|
||||
let unknown = spill::scratch_capacity().is_none();
|
||||
let unknown = answered_capacity().is_none();
|
||||
assert_eq!(spill::free_space_unmeasured(), unknown);
|
||||
// The free-space reserve applies; an unknown volume without a size limit promises nothing.
|
||||
if let Some(room) = spill::scratch_capacity() {
|
||||
assert!(room < u64::MAX / 2, "the reserve and margin are applied: {room}");
|
||||
}
|
||||
|
||||
let measurable = answered_capacity().is_some();
|
||||
|
||||
// A folder that stops answering is flagged and clears once it answers again. Windows keeps the
|
||||
// open scratch file's folder, so this part needs a folder that can be removed.
|
||||
if std::fs::remove_dir_all(&dir).is_ok() {
|
||||
@@ -30,9 +51,12 @@ fn scratch_capacity_follows_spilling_the_size_limit_and_pauses() {
|
||||
assert!(spill::free_space_unmeasured());
|
||||
assert!(spill::stats().free_space_unmeasured);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let unknown = spill::scratch_capacity().is_none();
|
||||
let unknown = answered_capacity().is_none();
|
||||
assert_eq!(spill::free_space_unmeasured(), unknown);
|
||||
assert_eq!(spill::stats().free_space_unmeasured, unknown);
|
||||
if measurable {
|
||||
assert!(!unknown, "the folder answers again: its room must be known again");
|
||||
}
|
||||
}
|
||||
|
||||
// The Scratch Size Limit bounds it, less the 1/64 margin, whatever the volume has free.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! A scratch folder that stops answering (a dead hard-mounted network share) must not freeze
|
||||
//! anything: status reads stay prompt, opening and measuring wait a bounded time, retries do not
|
||||
//! pile up threads, leaving the folder works, and a late answer applies only to the folder it
|
||||
//! was about.
|
||||
//!
|
||||
//! One test function: the spill manager is process-wide, so its steps must not interleave.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::{Rect, TILE_SIZE};
|
||||
use photocraft_raster::{Surface, spill};
|
||||
|
||||
const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE) as usize * 4;
|
||||
|
||||
/// Folders whose name starts with "hung" stall their queries while this is on.
|
||||
static STALL: AtomicBool = AtomicBool::new(false);
|
||||
/// Queries that reached the stall since the last reset.
|
||||
static ENTERED: AtomicUsize = AtomicUsize::new(0);
|
||||
/// `true` once the stalled queries may go on.
|
||||
static GATE: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
|
||||
|
||||
fn stall(dir: &Path) {
|
||||
let hung = dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("hung"));
|
||||
if !STALL.load(Ordering::SeqCst) || !hung {
|
||||
return;
|
||||
}
|
||||
ENTERED.fetch_add(1, Ordering::SeqCst);
|
||||
let mut open = GATE.0.lock().unwrap();
|
||||
while !*open {
|
||||
open = GATE.1.wait(open).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn arm() {
|
||||
ENTERED.store(0, Ordering::SeqCst);
|
||||
*GATE.0.lock().unwrap() = false;
|
||||
STALL.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn release() {
|
||||
STALL.store(false, Ordering::SeqCst);
|
||||
*GATE.0.lock().unwrap() = true;
|
||||
GATE.1.notify_all();
|
||||
}
|
||||
|
||||
fn poll_until(what: &str, cond: impl Fn() -> bool) {
|
||||
let start = Instant::now();
|
||||
while !cond() {
|
||||
assert!(start.elapsed() < Duration::from_secs(30), "timed out waiting for {what}: {:?}", spill::stats());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` and require that it returns well before a query's wait.
|
||||
fn prompt<T>(what: &str, f: impl FnOnce() -> T) -> T {
|
||||
let start = Instant::now();
|
||||
let out = f();
|
||||
assert!(start.elapsed() < Duration::from_millis(1500), "{what} took {:?}", start.elapsed());
|
||||
out
|
||||
}
|
||||
|
||||
/// A query's 2 s wait plus a scheduling allowance.
|
||||
const QUERY_DEADLINE: Duration = Duration::from_secs(6);
|
||||
|
||||
/// Run `f` and require that it returns within [`QUERY_DEADLINE`].
|
||||
fn bounded<T>(what: &str, f: impl FnOnce() -> T) -> T {
|
||||
let start = Instant::now();
|
||||
let out = f();
|
||||
assert!(start.elapsed() < QUERY_DEADLINE, "{what} took {:?}", start.elapsed());
|
||||
out
|
||||
}
|
||||
|
||||
fn rgba8() -> PixelFormat {
|
||||
PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true }
|
||||
}
|
||||
|
||||
/// Incompressible bytes, different per seed.
|
||||
fn noise(seed: u32, len: usize) -> Vec<u8> {
|
||||
let mut x = seed.wrapping_mul(2_654_435_761).max(1);
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x as u8
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn surface(seed: u32, n: i32) -> Surface {
|
||||
let mut s = Surface::new(rgba8());
|
||||
for i in 0..n {
|
||||
let r = Rect::from_xywh(i * TILE_SIZE, 0, TILE_SIZE as u32, TILE_SIZE as u32);
|
||||
s.write_interleaved(r, &noise(seed + i as u32, TILE_BYTES));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_stops_answering_never_freezes_the_app() {
|
||||
let root = std::env::temp_dir().join(format!("photocraft-scratch-slow-{}", std::process::id()));
|
||||
let good = root.join("good");
|
||||
let good2 = root.join("good2");
|
||||
let hung1 = root.join("hung1");
|
||||
let hung2 = root.join("hung2");
|
||||
let hung3 = root.join("hung3");
|
||||
spill::set_query_hook_for_tests(Some(stall));
|
||||
|
||||
// A. A healthy folder.
|
||||
spill::configure(Some(&good), 1 << 20).unwrap();
|
||||
assert!(spill::enabled() && !spill::paused());
|
||||
let measurable = spill::scratch_capacity().is_some();
|
||||
|
||||
// B. Configuring a folder that never answers: status reads stay prompt meanwhile (they
|
||||
// blocked on the config lock before), and configure gives up after its wait.
|
||||
arm();
|
||||
let began = Instant::now();
|
||||
let configuring = {
|
||||
let hung1 = hung1.clone();
|
||||
std::thread::spawn(move || spill::configure(Some(&hung1), 1 << 20))
|
||||
};
|
||||
poll_until("the first query to start", || ENTERED.load(Ordering::SeqCst) == 1);
|
||||
prompt("last_error", spill::last_error);
|
||||
prompt("check_integrity", spill::check_integrity).ok();
|
||||
prompt("stats", spill::stats);
|
||||
prompt("free_space_unmeasured", spill::free_space_unmeasured);
|
||||
prompt("paused", spill::paused);
|
||||
let result = configuring.join().unwrap();
|
||||
assert!(began.elapsed() < QUERY_DEADLINE, "configure of a hung folder took {:?}", began.elapsed());
|
||||
assert!(result.as_ref().err().is_some_and(|e| e.contains("not responding")), "{result:?}");
|
||||
assert!(spill::paused() && !spill::enabled());
|
||||
assert_eq!(spill::stats().dir.as_deref(), Some(hung1.as_path()));
|
||||
|
||||
// C. Retries neither wait nor start more threads: the question is already being asked.
|
||||
for _ in 0..5 {
|
||||
spill::retry_now_for_tests();
|
||||
prompt("maintain_budget", spill::maintain_budget);
|
||||
}
|
||||
assert_eq!(ENTERED.load(Ordering::SeqCst), 1, "a retry started a second query");
|
||||
assert!(spill::paused());
|
||||
|
||||
// D. Leaving the dead folder works while its query is stuck.
|
||||
prompt("configure away from a hung folder", || spill::configure(Some(&good2), 1 << 20)).unwrap();
|
||||
assert!(spill::enabled() && !spill::paused());
|
||||
|
||||
// E. The stuck query's late answer is about a folder that is no longer current: discarded.
|
||||
release();
|
||||
poll_until("the stuck query to settle", || spill::query_threads_for_tests() == 0);
|
||||
let st = spill::stats();
|
||||
assert_eq!(st.dir.as_deref(), Some(good2.as_path()), "{st:?}");
|
||||
assert!(spill::enabled() && !spill::paused() && st.last_error.is_none(), "{st:?}");
|
||||
|
||||
// F. A late answer for the current folder applies: spilling resumes by itself.
|
||||
arm();
|
||||
let result = bounded("configure of a second hung folder", || spill::configure(Some(&hung2), 1 << 20));
|
||||
assert!(result.is_err());
|
||||
assert!(spill::paused());
|
||||
release();
|
||||
poll_until("the late answer to resume spilling", || spill::enabled() && !spill::paused() && spill::last_error().is_none());
|
||||
assert_eq!(spill::stats().dir.as_deref(), Some(hung2.as_path()));
|
||||
|
||||
// G. The open folder stops answering when measured: no answer counts as unknown, spilling
|
||||
// goes on, and asking again costs nothing.
|
||||
spill::configure(Some(&hung3), 1 << 20).unwrap();
|
||||
arm();
|
||||
assert_eq!(bounded("scratch_capacity", spill::scratch_capacity), None);
|
||||
assert!(spill::free_space_unmeasured());
|
||||
assert!(spill::stats().free_space_unmeasured);
|
||||
assert!(spill::enabled() && !spill::paused());
|
||||
for _ in 0..5 {
|
||||
assert_eq!(prompt("scratch_capacity", spill::scratch_capacity), None);
|
||||
}
|
||||
assert_eq!(ENTERED.load(Ordering::SeqCst), 1, "asking again started a second query");
|
||||
release();
|
||||
if measurable {
|
||||
poll_until("the late answer to measure the volume", || !spill::free_space_unmeasured());
|
||||
}
|
||||
|
||||
poll_until("the previous query to finish", || spill::query_threads_for_tests() == 0);
|
||||
|
||||
// H. The growth check never stalls eviction: tiles still spill while it is unanswered.
|
||||
spill::set_budget(8 * TILE_BYTES);
|
||||
arm();
|
||||
spill::set_size_cap(Some(1 << 50));
|
||||
let trigger_tiles = surface(1, 32);
|
||||
poll_until("the growth query to reach the stall", || ENTERED.load(Ordering::SeqCst) >= 1);
|
||||
let evictions = spill::stats().evictions;
|
||||
let tiles = surface(33, 32);
|
||||
prompt("settle", spill::settle);
|
||||
assert!(spill::stats().evictions > evictions, "{:?}", spill::stats());
|
||||
poll_until("the unanswered growth check to count as unknown", spill::free_space_unmeasured);
|
||||
release();
|
||||
if measurable {
|
||||
poll_until("the growth check to be answered", || !spill::free_space_unmeasured());
|
||||
}
|
||||
spill::set_size_cap(None);
|
||||
|
||||
// Cleanup.
|
||||
spill::set_query_hook_for_tests(None);
|
||||
drop(tiles);
|
||||
drop(trigger_tiles);
|
||||
spill::configure(None, 0).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name = "photocraft-tablet"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit) and Linux X11 (XInput2), and barrel button and eraser end on Windows"
|
||||
description = "Pen tablet pressure, tilt, rotation and eraser end on macOS (AppKit), Linux X11 (XInput2) and Windows (WinTab), and barrel button and eraser end on Windows (Windows Ink)"
|
||||
|
||||
# The one isolated `unsafe` helper crate (see AGENTS.md, Never crash): the AppKit event
|
||||
# monitor on macOS and the WH_GETMESSAGE and WH_CALLWNDPROC pointer hooks on Windows need
|
||||
@@ -31,6 +31,15 @@ objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["xinput"] }
|
||||
|
||||
# WinTab 1.4: a system context on PhotoCraft's real window supplies pressure, tilt, rotation and
|
||||
# the eraser end while Windows Ink is disabled. `wintab` safely wraps the vendor's wintab32.dll,
|
||||
# loaded at run time; only the Windows target builds it. `windows` is pinned to the version
|
||||
# `wintab` uses.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
log = { workspace = true }
|
||||
wintab = "=0.1.1"
|
||||
windows = { version = "=0.62.2", features = ["Win32_Foundation"] }
|
||||
|
||||
# Tests build real tablet NSEvents (from CGEvents) and read them through the monitor's path.
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "block2", "NSEvent", "NSGraphicsContext", "objc2-core-graphics", "NSApplication", "NSResponder"] }
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//! - **Windows** ([`windows::Monitor`]): thread-local message hooks inspect `WM_POINTER*`
|
||||
//! messages on the UI thread to read `PEN_FLAG_BARREL` and the eraser end (`PEN_FLAG_INVERTED`)
|
||||
//! from `GetPointerPenInfo`, which winit 0.30 drops by mapping pen inputs to touch events.
|
||||
//! - **Windows WinTab** ([`wintab`]): with Windows Ink disabled for the tablet, a WinTab 1.4
|
||||
//! system context on PhotoCraft's window supplies pressure, tilt, rotation and the eraser end.
|
||||
//! The vendor driver still moves the cursor; [`wintab::poll`] is called once per frame.
|
||||
//! AppKit's monitor API and Windows' thread hooks need OS interop, so `macos.rs` and `windows.rs`
|
||||
//! allow `unsafe` (an isolated, tested helper crate with `SAFETY:`
|
||||
//! comments on every block).
|
||||
@@ -37,6 +40,8 @@ pub mod xi;
|
||||
pub mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod wintab;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod x11;
|
||||
|
||||
|
||||
@@ -217,6 +217,9 @@ unsafe extern "system" fn get_msg_proc(code: i32, w_param: usize, l_param: isize
|
||||
// SAFETY: Win32 guarantees that for code >= 0, l_param points to a valid MSG struct
|
||||
// dispatched to this thread's message queue.
|
||||
let msg = unsafe { &*(l_param as *const MSG) };
|
||||
// The WinTab backend shares this thread hook: it decodes the driver's proximity
|
||||
// messages (and ignores everything else) to clear a pen that left the tablet.
|
||||
crate::wintab::on_message(msg.message, msg.w_param, msg.l_param);
|
||||
handle_pointer_message(msg.message, msg.w_param, read_pen_flags);
|
||||
}
|
||||
// SAFETY: CallNextHookEx passes the parameters down the hook chain. MSDN notes the first
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! WinTab 1.4 backend for Windows.
|
||||
//!
|
||||
//! The app opens a WinTab context on its real top-level window and polls queued packets once per
|
||||
//! frame. This module only translates Wintab packets into [`Sample`]s; it does not synthesise
|
||||
//! pointer events. With Windows Ink disabled for the tablet, the vendor driver provides the
|
||||
//! mouse cursor while this module provides pressure, tilt, rotation and the eraser flag.
|
||||
//!
|
||||
//! Devices without orientation axes (pressure-only tablets) are opened with a packet that asks
|
||||
//! only for the fields the driver reports; their tilt is neutral rather than a made-up value.
|
||||
//!
|
||||
//! The context is thread-local because Wintab contexts belong to the thread and window that
|
||||
//! opened them. [`install`] and [`poll`] must therefore run on the UI thread.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::c_void;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
|
||||
|
||||
use crate::{Error, Sample};
|
||||
|
||||
wintab::packet! {
|
||||
struct PenPacket { status, buttons, x, y, normal_pressure, orientation }
|
||||
}
|
||||
|
||||
wintab::packet! {
|
||||
struct PressurePacket { status, buttons, x, y, normal_pressure }
|
||||
}
|
||||
|
||||
/// The open context. A device that reports orientation gets tilt and rotation; a pressure-only
|
||||
/// device gets a packet without the orientation field, so a zeroed field never becomes a tilt.
|
||||
enum Backend {
|
||||
Oriented { context: wintab::Context<PenPacket>, axes: [wintab::Axis; 3] },
|
||||
PressureOnly { context: wintab::Context<PressurePacket> },
|
||||
}
|
||||
|
||||
struct State {
|
||||
backend: Backend,
|
||||
pressure_axis: wintab::Axis,
|
||||
last_packet: Instant,
|
||||
emitted: bool,
|
||||
focused: bool,
|
||||
/// The pen is inside the tablet's sensing range (`WT_PROXIMITY`, hardware transitions).
|
||||
proximity_in: bool,
|
||||
/// Requests a frame when a WinTab message arrives, so pressure-only strokes update without
|
||||
/// cursor movement.
|
||||
wake: Box<dyn Fn()>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static STATE: RefCell<Option<State>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Opens a WinTab system context on `hwnd`.
|
||||
///
|
||||
/// Call on the UI thread. A previous context on this thread is replaced, so the caller keeps the
|
||||
/// app's normal single-window lifetime. The context is polled with [`poll`]. `wake` is called from
|
||||
/// the thread's message hook for every WinTab message: a frame is then requested even when the
|
||||
/// pen only changes pressure (no cursor movement, no other input event).
|
||||
pub fn install(hwnd: isize, wake: Box<dyn Fn()>) -> Result<(), Error> {
|
||||
if hwnd == 0 {
|
||||
return Err(Error::Unsupported("no window handle".into()));
|
||||
}
|
||||
let wintab = wintab::Wintab::load().map_err(|e| Error::Platform(e.to_string()))?;
|
||||
let device = wintab.device(0).ok_or_else(|| Error::Unsupported("no Wintab digitizer".into()))?;
|
||||
let pressure_axis = device.pressure.ok_or_else(|| Error::Unsupported("Wintab device has no pressure axis".into()))?;
|
||||
let hwnd = HWND(hwnd as *mut c_void);
|
||||
let options = wintab::ContextOptions::new().system(true).name("PhotoCraft WinTab");
|
||||
// Ask only for the fields the device reports: a pressure-only tablet never fills
|
||||
// orientation, and reading a zeroed orientation would invent a maximum tilt.
|
||||
let backend = match device.orientation {
|
||||
Some(axes) => {
|
||||
let context = wintab.open::<PenPacket>(hwnd, &options).map_err(|e| Error::Platform(e.to_string()))?;
|
||||
if let Err(error) = context.overlap(true) {
|
||||
log::warn!("WinTab: could not take overlap priority: {error}");
|
||||
}
|
||||
Backend::Oriented { context, axes }
|
||||
}
|
||||
None => {
|
||||
let context = wintab.open::<PressurePacket>(hwnd, &options).map_err(|e| Error::Platform(e.to_string()))?;
|
||||
if let Err(error) = context.overlap(true) {
|
||||
log::warn!("WinTab: could not take overlap priority: {error}");
|
||||
}
|
||||
Backend::PressureOnly { context }
|
||||
}
|
||||
};
|
||||
STATE.with_borrow_mut(|slot| {
|
||||
*slot = Some(State { backend, pressure_axis, last_packet: Instant::now(), emitted: false, focused: false, proximity_in: true, wake });
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drains queued packets and reports the newest sample.
|
||||
///
|
||||
/// Reports `Some` while a pen is in use, `None` once the pen has left the tablet. Leaving is the
|
||||
/// packet's proximity status, not a quiet period: a pen held still may send no packets for a
|
||||
/// while, and treating that as left painted a held airbrush build-up at mouse pressure. A long
|
||||
/// timeout remains only as a fallback for drivers that never report the leave.
|
||||
///
|
||||
/// Coordinates in the packet are not used: with Windows Ink off, the vendor driver moves the
|
||||
/// normal cursor, and PhotoCraft's existing pointer path supplies the position.
|
||||
///
|
||||
/// `focused` is whether the app's window has focus. Another tablet application can take the top
|
||||
/// WinTab overlap priority; when PhotoCraft is activated again it has to ask for it back. The
|
||||
/// window procedure belongs to winit, so focus from the app's input is the activation signal.
|
||||
pub fn poll(focused: bool, callback: &dyn Fn(Option<Sample>)) {
|
||||
STATE.with_borrow_mut(|slot| {
|
||||
let Some(state) = slot.as_mut() else { return };
|
||||
if focused && !state.focused {
|
||||
let result = match &state.backend {
|
||||
Backend::Oriented { context, .. } => context.overlap(true),
|
||||
Backend::PressureOnly { context } => context.overlap(true),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
log::warn!("WinTab: could not take overlap priority: {error}");
|
||||
}
|
||||
} else if !focused && state.focused {
|
||||
let result = match &state.backend {
|
||||
Backend::Oriented { context, .. } => context.overlap(false),
|
||||
Backend::PressureOnly { context } => context.overlap(false),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
log::warn!("WinTab: could not release overlap priority: {error}");
|
||||
}
|
||||
}
|
||||
state.focused = focused;
|
||||
let State { backend, pressure_axis, last_packet, emitted, proximity_in, .. } = state;
|
||||
match backend {
|
||||
Backend::Oriented { context, axes } => {
|
||||
let mut packets = Vec::new();
|
||||
context.drain(&mut packets);
|
||||
for packet in packets {
|
||||
if packet.status.out_of_context() {
|
||||
if *emitted {
|
||||
*emitted = false;
|
||||
callback(None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let sample = sample(&packet, pressure_axis, axes);
|
||||
*last_packet = Instant::now();
|
||||
*emitted = true;
|
||||
callback(Some(sample));
|
||||
}
|
||||
}
|
||||
Backend::PressureOnly { context } => {
|
||||
let mut packets = Vec::new();
|
||||
context.drain(&mut packets);
|
||||
for packet in packets {
|
||||
if packet.status.out_of_context() {
|
||||
if *emitted {
|
||||
*emitted = false;
|
||||
callback(None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let sample = sample_pressure(&packet, pressure_axis);
|
||||
*last_packet = Instant::now();
|
||||
*emitted = true;
|
||||
callback(Some(sample));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !*proximity_in && *emitted {
|
||||
*emitted = false;
|
||||
callback(None);
|
||||
}
|
||||
if *emitted && last_packet.elapsed() >= Duration::from_secs(60) {
|
||||
*emitted = false;
|
||||
callback(None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles a WinTab window message from the thread's message hook (`windows.rs`).
|
||||
///
|
||||
/// A hardware proximity leave is a `WT_PROXIMITY` message, not a packet status: the context's
|
||||
/// `TPS_PROXIMITY` status only says the cursor left the context. Clearing the feed here covers
|
||||
/// drivers that send the hardware leave without a following out-of-context packet.
|
||||
pub(crate) fn on_message(message: u32, wparam: usize, lparam: isize) {
|
||||
// This runs inside the Win32 hook, where a panic aborts the app: a driver call under `poll`
|
||||
// that pumps this thread's messages would find `STATE` borrowed. Such a message is skipped.
|
||||
let _ = STATE.try_with(|cell| {
|
||||
let Ok(mut slot) = cell.try_borrow_mut() else { return };
|
||||
let Some(state) = slot.as_mut() else { return };
|
||||
let decoded = match &state.backend {
|
||||
Backend::Oriented { context, .. } => context.message(message, WPARAM(wparam), LPARAM(lparam)),
|
||||
Backend::PressureOnly { context } => context.message(message, WPARAM(wparam), LPARAM(lparam)),
|
||||
};
|
||||
let win_message = decoded.is_some();
|
||||
if let Some(wintab::Message::Proximity { entering, hardware }) = decoded
|
||||
&& hardware
|
||||
{
|
||||
state.proximity_in = entering;
|
||||
}
|
||||
if win_message {
|
||||
// Packets can arrive with no cursor movement; the frame then polls them.
|
||||
(state.wake)();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Closes the context on this thread.
|
||||
pub fn uninstall() {
|
||||
STATE.with_borrow_mut(|slot| *slot = None);
|
||||
}
|
||||
|
||||
/// One packet from a device with orientation axes as the UI's pen sample.
|
||||
///
|
||||
/// Angles come from the device's own axes (`TU_CIRCLE`), falling back to Wintab's usual tenths
|
||||
/// of a degree when an axis does not measure angles.
|
||||
fn sample(packet: &PenPacket, pressure: &wintab::Axis, orientation: &[wintab::Axis; 3]) -> Sample {
|
||||
let raw = packet.orientation;
|
||||
let degrees = |index: usize, value: i32| orientation[index].degrees(value).map_or(value as f32 / 10.0, |degrees| degrees as f32);
|
||||
let azimuth = degrees(0, raw.azimuth).to_radians();
|
||||
// Wintab altitude is the angle from the tablet plane (0 = flat, 90 = upright); W3C tilt is
|
||||
// the angle from the perpendicular, per axis: atan2 of the in-plane projection over height.
|
||||
// Wintab azimuth 0 points at the tablet's top and grows clockwise, while W3C +x is right and
|
||||
// +y towards the user, so the in-plane components are (sin(azimuth), -cos(azimuth)).
|
||||
let elevation = degrees(1, raw.altitude).abs().to_radians();
|
||||
let in_plane_x = elevation.cos() * azimuth.sin();
|
||||
let in_plane_y = -(elevation.cos() * azimuth.cos());
|
||||
let height = elevation.sin();
|
||||
Sample {
|
||||
pressure: pressure.normalize(packet.normal_pressure as i32),
|
||||
tilt_x: in_plane_x.atan2(height).to_degrees(),
|
||||
tilt_y: in_plane_y.atan2(height).to_degrees(),
|
||||
rotation: degrees(2, raw.twist),
|
||||
eraser: packet.status.inverted(),
|
||||
}
|
||||
.sanitized()
|
||||
}
|
||||
|
||||
/// One packet from a device without orientation axes: pressure and the eraser end, neutral tilt.
|
||||
fn sample_pressure(packet: &PressurePacket, pressure: &wintab::Axis) -> Sample {
|
||||
Sample { pressure: pressure.normalize(packet.normal_pressure as i32), tilt_x: 0.0, tilt_y: 0.0, rotation: 0.0, eraser: packet.status.inverted() }
|
||||
.sanitized()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wintab::Units;
|
||||
|
||||
fn pressure_axis() -> wintab::Axis {
|
||||
wintab::Axis { min: 0, max: 32767, units: Units::None, resolution: 0.0 }
|
||||
}
|
||||
|
||||
fn angle_axis() -> wintab::Axis {
|
||||
wintab::Axis { min: 0, max: 3599, units: Units::Circle, resolution: 3600.0 }
|
||||
}
|
||||
|
||||
fn axes() -> [wintab::Axis; 3] {
|
||||
[angle_axis(), angle_axis(), angle_axis()]
|
||||
}
|
||||
|
||||
fn packet(normal_pressure: u32) -> PenPacket {
|
||||
PenPacket { status: wintab::Status(0), buttons: wintab::Buttons(0), x: 0, y: 0, normal_pressure, orientation: wintab::Orientation::default() }
|
||||
}
|
||||
|
||||
fn pressure_packet(normal_pressure: u32) -> PressurePacket {
|
||||
PressurePacket { status: wintab::Status(0), buttons: wintab::Buttons(0), x: 0, y: 0, normal_pressure }
|
||||
}
|
||||
|
||||
/// Pressure is normalized through the device's own axis.
|
||||
#[test]
|
||||
fn pressure_maps_through_the_device_axis() {
|
||||
let axes = axes();
|
||||
let s = sample(&packet(16384), &pressure_axis(), &axes);
|
||||
assert!((s.pressure - 0.5).abs() < 0.01, "{}", s.pressure);
|
||||
let s = sample(&packet(0), &pressure_axis(), &axes);
|
||||
assert_eq!(s.pressure, 0.0);
|
||||
}
|
||||
|
||||
/// The inverted status bit is the eraser end.
|
||||
#[test]
|
||||
fn eraser_comes_from_the_inverted_status() {
|
||||
let axes = axes();
|
||||
let inverted = PenPacket { status: wintab::Status(wintab::sys::TPS_INVERT), ..packet(0) };
|
||||
assert!(sample(&inverted, &pressure_axis(), &axes).eraser);
|
||||
assert!(!sample(&packet(0), &pressure_axis(), &axes).eraser);
|
||||
}
|
||||
|
||||
/// Orientation axes are converted to degrees for the W3C tilt projection.
|
||||
#[test]
|
||||
fn orientation_axes_report_degrees() {
|
||||
let axes = axes();
|
||||
let tilted = PenPacket { orientation: wintab::Orientation { azimuth: 450, altitude: 300, twist: 900 }, ..packet(0) };
|
||||
let sample = sample(&tilted, &pressure_axis(), &axes);
|
||||
assert!((sample.tilt_x - 50.8).abs() < 0.1, "{}", sample.tilt_x);
|
||||
assert!((sample.tilt_y + 50.8).abs() < 0.1, "{}", sample.tilt_y);
|
||||
assert!((sample.rotation - 90.0).abs() < 0.1, "{}", sample.rotation);
|
||||
}
|
||||
|
||||
/// A pressure-only device keeps pressure and the eraser end, with neutral tilt.
|
||||
#[test]
|
||||
fn pressure_only_devices_get_pressure_and_neutral_tilt() {
|
||||
let s = sample_pressure(&pressure_packet(16384), &pressure_axis());
|
||||
assert!((s.pressure - 0.5).abs() < 0.01, "{}", s.pressure);
|
||||
assert_eq!((s.tilt_x, s.tilt_y, s.rotation), (0.0, 0.0, 0.0));
|
||||
let inverted = PressurePacket { status: wintab::Status(wintab::sys::TPS_INVERT), ..pressure_packet(0) };
|
||||
assert!(sample_pressure(&inverted, &pressure_axis()).eraser);
|
||||
}
|
||||
|
||||
/// Wintab azimuth 0 is the tablet top; W3C axes are right and toward the user.
|
||||
#[test]
|
||||
fn cardinal_azimuths_match_w3c_tilt() {
|
||||
let axes = axes();
|
||||
let tilt = |azimuth: i32| {
|
||||
let p = PenPacket { orientation: wintab::Orientation { azimuth, altitude: 450, twist: 0 }, ..packet(0) };
|
||||
sample(&p, &pressure_axis(), &axes)
|
||||
};
|
||||
let top = tilt(0);
|
||||
assert!(top.tilt_x.abs() < 0.1 && (top.tilt_y + 45.0).abs() < 0.1, "{top:?}");
|
||||
let right = tilt(900);
|
||||
assert!((right.tilt_x - 45.0).abs() < 0.1 && right.tilt_y.abs() < 0.1, "{right:?}");
|
||||
let bottom = tilt(1800);
|
||||
assert!(bottom.tilt_x.abs() < 0.1 && (bottom.tilt_y - 45.0).abs() < 0.1, "{bottom:?}");
|
||||
let left = tilt(2700);
|
||||
assert!((left.tilt_x + 45.0).abs() < 0.1 && left.tilt_y.abs() < 0.1, "{left:?}");
|
||||
}
|
||||
|
||||
/// An upright pen has no tilt; a flat one has the most.
|
||||
#[test]
|
||||
fn an_upright_pen_has_no_tilt_and_a_flat_one_has_the_most() {
|
||||
let axes = axes();
|
||||
let upright = PenPacket { orientation: wintab::Orientation { azimuth: 0, altitude: 900, twist: 0 }, ..packet(0) };
|
||||
let s = sample(&upright, &pressure_axis(), &axes);
|
||||
assert!(s.tilt_x.abs() < 0.1 && s.tilt_y.abs() < 0.1, "{s:?}");
|
||||
let flat = PenPacket { orientation: wintab::Orientation { azimuth: 0, altitude: 0, twist: 0 }, ..packet(0) };
|
||||
let s = sample(&flat, &pressure_axis(), &axes);
|
||||
assert!(s.tilt_x.abs() < 0.1 && (s.tilt_y + 90.0).abs() < 0.1, "{s:?}");
|
||||
}
|
||||
}
|
||||
@@ -245,8 +245,12 @@ impl eframe::App for PhotocraftApp {
|
||||
self.finish_type_reset_logic(ctx);
|
||||
}
|
||||
|
||||
/// Drain the platform's optional WinTab queue before the frame's input is processed.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
let frame_start = gpu_canvas::now_ms();
|
||||
if let Some(tablet_poll) = self.services.tablet_poll.as_mut() {
|
||||
tablet_poll(raw_input.focused);
|
||||
}
|
||||
// An undecorated window gets its caption back when the toolkit changes its flags
|
||||
// (maximize, restore, …): take it off again before the frame is laid out (#2246).
|
||||
if let Some(window_frame) = self.services.window_frame.as_mut() {
|
||||
|
||||
@@ -407,6 +407,10 @@ fn show_memory(app: &mut PhotocraftApp, prompt: &MemoryPrompt) {
|
||||
};
|
||||
message.push_str("\n\n");
|
||||
message.push_str(tl!("Opening this document uses more memory and may slow down your computer or other running applications."));
|
||||
if available.is_some_and(|a| a.saturating_sub(prompt.estimated) < photocraft_io::memory::LOW_MEMORY_WARNING_MARGIN) {
|
||||
message.push_str("\n\n");
|
||||
message.push_str(tl!("Little memory would be left after opening this file. Close other apps first, or PhotoCraft may run out of memory."));
|
||||
}
|
||||
if prompt.uncertain_capacity_warning {
|
||||
message.push_str("\n\n");
|
||||
message.push_str(tl!(
|
||||
|
||||
@@ -261,7 +261,7 @@ pub use services::{
|
||||
CaptionStyleFn, ClipboardGetFn, ClipboardGetSvgFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn,
|
||||
ExportSettings, ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox,
|
||||
LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PenEndFn, PhotoshopShortcutsFn, PlaceInbox, QuitFn, RecoverFn, Recoverable, RightButtonDownFn,
|
||||
SameFileFn, SaveFileFn, SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn,
|
||||
SameFileFn, SaveFileFn, SaveTextFn, Services, SystemThemeFn, TabletPollFn, WindowFrameFn, WriteFn,
|
||||
};
|
||||
pub use text::bidi_text;
|
||||
pub use text::cjk_fonts;
|
||||
|
||||
@@ -101,8 +101,11 @@ pub(super) fn humanize(key: &str) -> String {
|
||||
.replace("Lut ", "LUT ")
|
||||
}
|
||||
|
||||
/// The visible label of a stored choice value (`windowsInk` -> "Windows Ink (default)").
|
||||
pub(super) fn choice_label(v: &str) -> String {
|
||||
match v {
|
||||
"windowsInk" => "Windows Ink (default)".into(),
|
||||
"winTab" => "WinTab".into(),
|
||||
"exr" => "EXR".into(),
|
||||
"cm" => "Centimeters".into(),
|
||||
"mm" => "Millimeters".into(),
|
||||
@@ -148,7 +151,11 @@ pub(super) fn color_of(s: &str) -> Color32 {
|
||||
/// Search the same visible, editable preference labels used in the dialog.
|
||||
pub(super) fn preference_matches(section: &str, key: &str, query: &str) -> bool {
|
||||
let label = humanize(key);
|
||||
format!("{section}.{key}").to_lowercase().contains(query) || label.to_lowercase().contains(query) || tl!(&label).to_lowercase().contains(query)
|
||||
format!("{section}.{key}").to_lowercase().contains(query)
|
||||
|| label.to_lowercase().contains(query)
|
||||
|| tl!(&label).to_lowercase().contains(query)
|
||||
// The Tablet input row and its Pen Tablet note are the visible terms, not just the key.
|
||||
|| (cfg!(target_os = "windows") && section == "tools" && key == "tabletInput" && "windows ink pen tablet wintab tablet input".contains(query))
|
||||
}
|
||||
|
||||
pub(super) fn preference_sections(values: &Value, search: &str) -> Vec<(&'static str, &'static str)> {
|
||||
@@ -298,8 +305,13 @@ pub(super) fn export_field_visible(obj: &Map<String, Value>, key: &str) -> bool
|
||||
}
|
||||
|
||||
/// The pause that bundles arrow-key nudges means nothing with Bundle Arrow-Key Nudges off (#2259).
|
||||
/// WinTab is a Windows-only opt-in.
|
||||
pub(super) fn tools_field_visible(obj: &Map<String, Value>, key: &str) -> bool {
|
||||
key != "nudgeBundlePauseMs" || obj.get("bundleNudges").and_then(Value::as_bool) != Some(false)
|
||||
match key {
|
||||
"tabletInput" => cfg!(target_os = "windows"),
|
||||
"nudgeBundlePauseMs" => obj.get("bundleNudges").and_then(Value::as_bool) != Some(false),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled, outlined group of settings (Photoshop's Interface page has Appearance,
|
||||
@@ -374,7 +386,7 @@ pub(super) fn field_visible(section: &str, obj: &Map<String, Value>, k: &str, qu
|
||||
// The browser presents frames itself: Low-Latency Drawing does nothing there.
|
||||
|| (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing")
|
||||
|| (section == "export" && !export_field_visible(obj, k))
|
||||
|| (section == "tools" && !tools_field_visible(obj, k));
|
||||
|| (section == "tools" && (!tools_field_visible(obj, k) || k == "tabletInput"));
|
||||
(query.is_empty() || preference_matches(section, k, query))
|
||||
&& !drawn_elsewhere
|
||||
&& !prefs::is_hidden(&path)
|
||||
@@ -405,6 +417,29 @@ pub(super) fn section_fields(
|
||||
if section == "performance" && (query.is_empty() || preference_matches(section, "memoryUsageMb", &query)) {
|
||||
memory_usage_row(ui, obj);
|
||||
}
|
||||
// WinTab is an explicit Windows choice; Windows Ink stays the default everywhere else.
|
||||
if section == "tools"
|
||||
&& cfg!(target_os = "windows")
|
||||
&& (query.is_empty() || preference_matches(section, "tabletInput", &query))
|
||||
&& obj.contains_key("tabletInput")
|
||||
{
|
||||
let t = Tokens::get(ui.ctx());
|
||||
pref_group(ui, "Pen Tablet", |ui| {
|
||||
egui::Grid::new("prefs-grid-pen-tablet").num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| {
|
||||
field_rows(ui, section, obj, vec!["tabletInput".to_string()], lang);
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(tl!(
|
||||
"WinTab reads pressure, tilt, rotation and the eraser end from the tablet driver. Turn off Windows Ink in your tablet's software utility (for example Wacom Center) before using it."
|
||||
))
|
||||
.small()
|
||||
.color(t.text_dim),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
});
|
||||
}
|
||||
keys.retain(|k| field_visible(section, obj, k, &query));
|
||||
egui::Grid::new(("prefs-grid", section)).num_columns(2).spacing([14.0, 7.0]).show(ui, |ui| field_rows(ui, section, obj, keys, lang));
|
||||
}
|
||||
@@ -457,7 +492,12 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, ke
|
||||
let labels: Vec<String> = opts.iter().map(|o| choice_label(o)).collect();
|
||||
let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect();
|
||||
let mut cur = s.clone();
|
||||
crate::widgets::dropdown(ui, &format!("pref-{path}"), tl!(&label), &mut cur, &pairs, 220.0);
|
||||
ui.vertical(|ui| {
|
||||
crate::widgets::dropdown(ui, &format!("pref-{path}"), tl!(&label), &mut cur, &pairs, 220.0);
|
||||
if read_at_launch(&path) {
|
||||
ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
obj.insert(k, json!(cur));
|
||||
}
|
||||
Value::String(s) if prefs::is_color(&path) => {
|
||||
@@ -522,8 +562,14 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, ke
|
||||
if ui.small_button(tl!("Add disk")).clicked() {
|
||||
items.push(json!({"enabled": true, "path": ""}));
|
||||
}
|
||||
if photocraft_raster::spill::free_space_unmeasured() {
|
||||
ui.add(egui::Label::new(RichText::new(tl!("This scratch disk doesn't report its free space, so PhotoCraft can't stop before it fills up, and large files get no extra room from it without a Scratch Size Limit. Set a limit or use a local disk.")).color(t.warning)).wrap());
|
||||
let scratch = photocraft_raster::spill::stats();
|
||||
if scratch.free_space_unmeasured {
|
||||
let dir = scratch.dir.as_deref().map(|p| p.display().to_string()).unwrap_or_else(|| tl!("Unknown").into());
|
||||
let warning = crate::i18n::fmt(
|
||||
tl!("The current scratch directory ({path}) doesn't report its free space, so PhotoCraft can't stop before it fills up, and large files get no extra room from it without a Scratch Size Limit. This warning applies to the current configuration; changes above take effect on Apply or OK. Set a limit or use a local disk."),
|
||||
&[("path", &dir)],
|
||||
);
|
||||
ui.add(egui::Label::new(RichText::new(warning).color(t.warning)).wrap());
|
||||
}
|
||||
});
|
||||
obj.insert(k, Value::Array(items));
|
||||
@@ -540,3 +586,19 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, ke
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::preference_matches;
|
||||
|
||||
/// The Tablet input row matches its visible search terms.
|
||||
#[test]
|
||||
fn wintab_row_matches_its_visible_terms() {
|
||||
if cfg!(target_os = "windows") {
|
||||
for query in ["windows ink", "pen tablet", "wintab", "tablet input"] {
|
||||
assert!(preference_matches("tools", "tabletInput", query), "{query}");
|
||||
}
|
||||
}
|
||||
assert!(preference_matches("tools", "useTabletPressure", "tablet pressure"));
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,8 @@ pub(super) fn read_at_launch(path: &str) -> bool {
|
||||
"interface.systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))),
|
||||
// The desktop app decides on the control server before its window opens.
|
||||
"integrations.allowAgentControl" | "integrations.controlPort" => cfg!(not(target_arch = "wasm32")),
|
||||
// The WinTab context is opened once, when the window starts.
|
||||
"tools.tabletInput" => cfg!(target_os = "windows"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -758,6 +758,27 @@ fn memory_prompt_states_unknown_capacity_and_the_eight_gib_warning() {
|
||||
assert!(app.ui.dialogs.is_empty());
|
||||
}
|
||||
|
||||
/// The prompt warns when opening the file would leave less than 1 GiB of the measured free
|
||||
/// memory, and stays quiet when there is room to spare.
|
||||
#[test]
|
||||
fn memory_prompt_warns_when_little_memory_would_be_left() {
|
||||
let gb = 1u64 << 30;
|
||||
let mut app = app();
|
||||
let available = 3 * gb + gb / 2;
|
||||
let id = store_preflight(&mut app, "tight.psd", 3 * gb, Some(available));
|
||||
let tab = crate::jobs_ui::OpenTab { job: photocraft_engine::jobs::JobId(3), name: "tight.psd".into(), path: None, slot: None, order: None };
|
||||
crate::jobs_ui::finish_open_tab(
|
||||
&mut app,
|
||||
&tab,
|
||||
&json!({"pendingPreflight": id, "estimated": 3 * gb, "availableRam": available, "capacityUnknown": false, "uncertainCapacityWarning": false}),
|
||||
)
|
||||
.unwrap();
|
||||
let message = app.ui.dialogs[0].fields["message"].as_str().unwrap();
|
||||
assert!(message.contains("Little memory would be left") && message.contains("may run out of memory"), "{message}");
|
||||
let dialog = app.ui.dialogs[0].id;
|
||||
assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"], true);
|
||||
}
|
||||
|
||||
/// #10897: reopening an ordinary file whose high-memory prompt is already waiting shows that
|
||||
/// prompt instead of queueing a second one and a second open; a template still opens again as
|
||||
/// another Untitled, like one that is still loading.
|
||||
|
||||
@@ -403,6 +403,10 @@ pub type RightButtonDownFn = Box<dyn Fn() -> bool + Send + Sync>;
|
||||
/// Which end of the pen is in use, `Some(true)` = eraser, `None` = no pen ([`Services::pen_end`]).
|
||||
pub type PenEndFn = Box<dyn Fn() -> Option<bool> + Send + Sync>;
|
||||
|
||||
/// Polls the platform's optional WinTab packet queue into the app's stylus feed (Windows), with
|
||||
/// whether the app window is focused (to re-assert WinTab overlap priority on activation).
|
||||
pub type TabletPollFn = Box<dyn FnMut(bool)>;
|
||||
|
||||
/// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of
|
||||
/// I/O dependencies.
|
||||
#[derive(Default)]
|
||||
@@ -528,6 +532,8 @@ pub struct Services {
|
||||
/// Which end of the pen is in use, where the platform reports it outside the stylus feed
|
||||
/// (Windows: the feed carries no pen samples there). Read for the eraser-end tool switch.
|
||||
pub pen_end: Option<PenEndFn>,
|
||||
/// WinTab packet poll (Windows with Windows Ink disabled; see `photocraft_tablet::wintab`).
|
||||
pub tablet_poll: Option<TabletPollFn>,
|
||||
/// The persistent brush preset store, loading in the background (desktop; see
|
||||
/// `photocraft_engine::preset_store`). Attached to the session once it arrives; without
|
||||
/// one, brush presets are session-only unless the shell attached a store before startup.
|
||||
|
||||
@@ -48,7 +48,7 @@ tablets against Photoshop side by side.
|
||||
| Quick taps keep their pressure | partial | #1967 fixed one case; tap paints a full blob (#1798), tap sensitivity (#1933) |
|
||||
| Smooth pressure on Windows | partial | "jumpy" (#2046) |
|
||||
| Tilt and rotation drive angle/roundness | partial | macOS, X11, web; not Windows; rotation not applied (#1301) |
|
||||
| Eraser end of the pen switches to Eraser | partial | macOS, Windows with Windows Ink (#2285); X11 and web partial |
|
||||
| Eraser end of the pen switches to Eraser | partial | macOS, Windows (WinTab; Windows Ink not yet confirmed on hardware, #2285); X11 and web partial |
|
||||
|
||||
## Stroke quality and feel
|
||||
|
||||
|
||||
+44
-15
@@ -113,6 +113,8 @@ the opted-in test; an unset variable leaves the ordinary behavior tests availabl
|
||||
After review, remove only the two generated PNGs and their owned temporary directory. The same
|
||||
Rust test and variable work on Windows with the native shell's environment-variable syntax.
|
||||
|
||||
**GPU tests without a GPU.** Linux CI uses the shared `craft-common-actions` gate with Mesa's Vulkan driver installed. After the regular gate, `python3 scripts/ci-gpu-lavapipe.py` reruns the configured `gpu` group with `WGPU_BACKEND=vulkan` and the lavapipe ICD explicitly selected. It requires Vulkan parity evidence and rejects authored skips, except the documented software-adapter timing exceptions. It reads the filter from `.config/nextest.toml`, so no second filter needs maintaining. To run locally on Linux, install `mesa-vulkan-drivers libvulkan1` and run the same script. Windows uses WARP; this check does not certify Windows skip coverage.
|
||||
|
||||
## Fonts (craft-fonts)
|
||||
|
||||
Font assets shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo: don't commit font files here (Inter and JetBrains Mono in `assets/fonts/` are the only exceptions; new fonts go to craft-fonts).
|
||||
@@ -778,8 +780,20 @@ clipped.
|
||||
|
||||
Native desktop platforms use an adaptive memory policy evaluated against currently available
|
||||
physical RAM (excluding swap) and, where measurable, system commit headroom:
|
||||
- **Hard budget (H)** for known RAM: $\min(75\% \text{ RAM}, \text{RAM} - 1 \text{ GiB}, \text{commit headroom} - 512 \text{ MiB}, \text{aggregate address-space cap})$.
|
||||
- **Soft budget (S)**: $\min(50\% \text{ RAM}, 8 \text{ GiB}, H)$.
|
||||
- **Hard budget (H)** for known memory, with A the available RAM and C the commit headroom:
|
||||
$\min(\lfloor 3A/4 \rfloor, A - 256 \text{ MiB}, C - r(C), \text{aggregate address-space cap})$, where the
|
||||
commit reserve $r(C) = \min(\max(C/4, 256 \text{ MiB}), 512 \text{ MiB})$. An approved import therefore
|
||||
always leaves at least `LOW_MEMORY_RESERVE` (256 MiB) free.
|
||||
- **Soft budget (S)**: $\min(A/2, 8 \text{ GiB}, \max(A - 1 \text{ GiB}, A/4), \max(C - 512 \text{ MiB}, C/4), H)$
|
||||
(the commit term only where C is known).
|
||||
- The low-memory band: with more than 4/3 GiB free, automatic opening is unchanged (it leaves
|
||||
1 GiB free). Below that, up to a quarter of the free RAM opens automatically and the rest goes
|
||||
to approval, up to $\min(3A/4, A - 256 \text{ MiB})$. The commit reserve shrinks from a flat
|
||||
512 MiB to a quarter of the headroom with a 256 MiB floor. At or below 256 MiB of either
|
||||
measurement there is no room and every file is refused. For example 1 GiB free gives S = 256 MiB
|
||||
and H = 768 MiB; 512 MiB free gives S = 128 MiB and H = 256 MiB.
|
||||
- Scratch-disk credit (see `docs/scratch-disks.md`) is added on top of these limits before a
|
||||
caller's limit applies; non-spillable memory stays against RAM alone.
|
||||
- Files requiring $\le S$ are admitted automatically.
|
||||
- Files requiring $S < E \le H$ prompt for explicit one-shot user confirmation ("Open anyway") in the UI. Headless callers default to no interactive dialog and require explicit per-invocation opt-in:
|
||||
- Engine commands: `file.openAs` and `file.placeEmbedded` take `approveHighMemory: bool` (default `false`); `file.approveMemory` takes `{"id": u64, "approve": bool = true}`.
|
||||
@@ -790,8 +804,8 @@ physical RAM (excluding swap) and, where measurable, system commit headroom:
|
||||
- Files requiring $> H$ are refused and cannot be overridden.
|
||||
- When available RAM is unknown (an unsupported platform or a failed provider), native
|
||||
automatic admission is 2 GiB, not 8 GiB:
|
||||
$S = \min(2 \text{ GiB}, H)$, where $H$ is the aggregate address-space cap
|
||||
(`native_platform_cap`), reduced by known commit headroom. Estimates above $S$ need the same
|
||||
$S = \min(2 \text{ GiB}, \max(C - 512 \text{ MiB}, C/4), H)$, where $H$ is the aggregate
|
||||
address-space cap (`native_platform_cap`), reduced by known commit headroom as above. Estimates above $S$ need the same
|
||||
explicit, per-invocation approval as known-memory estimates above $S$, and approval is possible
|
||||
up to $H$. Estimates above 8 GiB remain approvable; `MemoryBudget::warning_threshold` and
|
||||
`MemoryBudget::requires_uncertainty_warning` let callers show the uncertain-capacity warning.
|
||||
@@ -838,7 +852,9 @@ physical RAM (excluding swap) and, where measurable, system commit headroom:
|
||||
is `memsw limit - memory limit - swap already used`, and those allowances are combined
|
||||
separately from RAM headroom. A memory cgroup whose mount, path, membership, limit or usage
|
||||
cannot be read, or whose membership file is malformed or empty, fails closed (available memory
|
||||
is treated as zero) rather than falling back to unrestricted host RAM.
|
||||
is treated as zero, `Some(0)`) rather than falling back to unrestricted host RAM. That reading
|
||||
gives H = 0 from the formula, so it stays refused (only a credited tile share can pass), and
|
||||
the refusal says PhotoCraft measured no free memory or could not read the system's memory limit.
|
||||
- Applications can lower the admission threshold with `ImportOptions::psd_max_pixel_bytes`; explicit caller limits may lower admission, never bypass the hard boundary.
|
||||
- Concurrent PSD decodes are serialized using a process-wide, cancellation-aware admission gate, with nested decodes sharing the enclosing allowance.
|
||||
- A pending high-memory approval is bound to one source and one estimate. The engine keeps at
|
||||
@@ -855,11 +871,13 @@ physical RAM (excluding swap) and, where measurable, system commit headroom:
|
||||
for approval. Windows has no stable file-index API in `std`, so its identity is the length and
|
||||
modification time (a different file with both preserved is not distinguished there). The
|
||||
`file.approveMemory` permission is one-shot and is not journaled or recorded in an action. A
|
||||
refusal above the hard ceiling is the typed `PsdHardBudget` error (name, required, limit), not
|
||||
a string, and it releases the queued source. Its message says where the limit comes from:
|
||||
the computer's memory on 64-bit desktop; the web build names its fixed cap; on 32-bit desktop the
|
||||
limit may be either, so the message mentions the 2 GiB cap and that the 64-bit build may open the file.
|
||||
Those two also link the releases page. Sizes appear in GiB or MiB next to the exact bytes.
|
||||
refusal above the hard ceiling is the typed `PsdHardBudget` error (name, required, limit,
|
||||
`free_memory`), not a string, and it releases the queued source. Its message says where the
|
||||
limit comes from: the computer's memory on 64-bit desktop; the web build names its fixed cap;
|
||||
on 32-bit desktop the limit may be either, so the message mentions the 2 GiB cap and that the
|
||||
64-bit build may open the file. Those two also link the releases page. Sizes appear in GiB
|
||||
or MiB next to the exact bytes. When measured memory set the limit, the message also gives
|
||||
the free-memory reading and advises closing other apps.
|
||||
- A layered TIFF (Photoshop layer data in tags 37724/34377) is admitted by the same policy
|
||||
before its composite is decoded. The page's tags are read into the PSD model first, without
|
||||
pixels; then that model's layer/workspace estimate, the decoded composite and its
|
||||
@@ -884,15 +902,26 @@ physical RAM (excluding swap) and, where measurable, system commit headroom:
|
||||
opened without Photoshop layers.
|
||||
|
||||
Headless RPC and MCP report approval-required memory errors with byte counts (`required`,
|
||||
`softLimit`, `hardLimit`, and `availableRam` when known). A hard-budget refusal reports only
|
||||
`required` and `hardLimit`: no approval can raise that ceiling, so there is no soft limit or
|
||||
capacity to act on. This holds for `doc.open` and `doc.place` and for the MCP `doc_open` and
|
||||
`softLimit`, `hardLimit`, and `availableRam` when known); here `availableRam` means available
|
||||
physical RAM. A hard-budget refusal reports
|
||||
`required` and `hardLimit`, plus `available` and `availableRam` when measured free memory set the
|
||||
limit (the typed `free_memory` field). Both fields then contain the smaller of available RAM and
|
||||
commit headroom (or the one measurement available), so `availableRam` can represent commit
|
||||
headroom rather than physical RAM. There is never a soft limit, because no approval can raise
|
||||
that ceiling. A limit set by the caller, the platform cap, or an unknown capacity carries no
|
||||
free-memory figure. This holds for `doc.open` and `doc.place` and for the MCP `doc_open` and
|
||||
`doc_place` tools, including a refusal from `file.openAs`/`file.placeEmbedded` reached through a
|
||||
command. GUI automation imports currently cross a service boundary that returns string errors.
|
||||
|
||||
When measured free memory set the limit, the refusal message gives the free amount and ends with
|
||||
"Close other apps and try again"; a reading of zero says PhotoCraft measured no free memory or
|
||||
could not read the system's memory limit. The approval prompt warns that PhotoCraft may run out of
|
||||
memory when opening the file would leave less than 1 GiB of the measured free RAM
|
||||
(`LOW_MEMORY_WARNING_MARGIN`).
|
||||
|
||||
An import exceeding the hard budget returns `IoError::PsdImportBudget` with the estimated
|
||||
and allowed byte counts; imports requiring confirmation return `IoError::PsdApprovalRequired`
|
||||
when unapproved. Damaged-file recovery never bypasses these memory guards. The legacy
|
||||
and allowed byte counts (and `free_memory` when measured memory set the limit); imports requiring
|
||||
confirmation return `IoError::PsdApprovalRequired` when unapproved. Damaged-file recovery never bypasses these memory guards. The legacy
|
||||
`psd_to_document` conversion APIs cannot return an error, so they return an empty document and
|
||||
an explicit warning on preflight refusal. Layer and mask pixel allocation failures also fail
|
||||
ordinary imports instead of opening partial content.
|
||||
|
||||
+15
-6
@@ -1,6 +1,6 @@
|
||||
# Hardware parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Windows: flipping the pen to its eraser end selects the Eraser with Windows Ink on, #2285; GPU compositor startup builds in the background, measurements pending; Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Windows: flipping the pen to its eraser end selects the Eraser with Windows Ink on, #2285, not yet confirmed on a pen with an eraser end; optional WinTab backend on Windows: pressure and the eraser end; tilt on devices with orientation axes; rotation only with a pen and driver that report twist, with Windows Ink off) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Each hardware feature Photoshop 2026 (27.11.0) uses, per platform, against PhotoCraft. Photoshop
|
||||
side: its bundle (Metal and OpenCL paths, "Precise Color Management for HDR Display" and EDR
|
||||
@@ -9,7 +9,9 @@ strings, Touch Bar resources, pen pressure/tilt strings) and Adobe's GPU FAQ. Ph
|
||||
`engine/src/display_color.rs`, `apps/photocraft/src/monitor_profile.rs`.
|
||||
|
||||
**Summary (estimated):** ~50% ready. GPU compositing and colour-managed display are solid; pen
|
||||
input is uneven by platform (Windows lacks tilt/rotation, Wayland is broken) and there is no HDR
|
||||
input is uneven by platform (the default Windows Ink path lacks tilt and rotation, Wayland is
|
||||
broken; the opt-in WinTab path on Windows supplies tilt only on devices that report orientation
|
||||
axes, and rotation only when the pen and driver report twist) and there is no HDR
|
||||
output. Remaining: 80–150 h (gap G12). Human: tablets (Wacom, XP-Pen, Huion) on each OS and an
|
||||
HDR display to verify.
|
||||
|
||||
@@ -31,12 +33,18 @@ HDR display to verify.
|
||||
| Feature | Photoshop | macOS | Windows | Linux X11 | Linux Wayland | Web |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Pressure | yes | yes (AppKit monitor) | yes (WM_POINTER), jumpy (#2046) | yes (XInput2) | via Xwayland only; GNOME 50 pen doesn't click (#2622), KDE (#2297) | yes |
|
||||
| Tilt | yes | yes | **no** | yes | no | yes |
|
||||
| Barrel rotation | yes | yes (not applied to tip angle, #1301) | no | partial | no | yes (twist) |
|
||||
| Eraser end | yes | yes | yes with Windows Ink (#2285) | partial | no | partial |
|
||||
| WinTab driver mode | yes | – | no | – | – | – |
|
||||
| Tilt | yes | yes | **no** (WinTab: with orientation axes, opt-in) | yes | no | yes |
|
||||
| Barrel rotation | yes | yes (not applied to tip angle, #1301) | no (WinTab: with a twist-reporting pen and driver, opt-in) | partial | no | yes (twist) |
|
||||
| Eraser end | yes | yes | yes with Windows Ink, not yet confirmed on hardware (#2285); WinTab: yes (opt-in) | partial | no | partial |
|
||||
| WinTab driver mode | yes | – | yes (opt-in; Windows Ink off) | – | – | – |
|
||||
| Pressure curve | Windows Ink / driver | global curve in Preferences (#2195) | same | same | same | same |
|
||||
|
||||
Windows: the opt-in WinTab backend (`tools.tabletInput = "winTab"`, with Windows Ink turned off
|
||||
in the tablet utility) supplies pressure and the eraser end, tilt only on devices that report
|
||||
orientation axes, and rotation only when the pen and driver report twist (pressure-only devices
|
||||
get neutral tilt and rotation). The default Windows Ink path still lacks tilt and
|
||||
rotation; its eraser end comes from the pointer hooks (#2285).
|
||||
|
||||
## Displays and colour
|
||||
|
||||
| Feature | Photoshop | PhotoCraft | Status |
|
||||
@@ -64,6 +72,7 @@ HDR display to verify.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | Windows eraser end: the pointer hooks read `PEN_FLAG_INVERTED` and the Eraser switch follows it (Windows Ink on; #2285) |
|
||||
| 2026-11-05 | minor | Optional WinTab driver mode on Windows: pressure and eraser; tilt with orientation axes; rotation with a twist-reporting pen, with Windows Ink off |
|
||||
| 2026-10-28 | minor | Build GPU compositor pipelines in the background; benchmark baseline pending (#9157) |
|
||||
| 2026-10-10 | major | First version |
|
||||
| 2026-10-17 | minor | GPU filters: Camera Raw's preview, per-pixel stages (#6916 step 1) |
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| [Performance](#performance) | 3 | 22 | 51 | 85 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 47 not run, 4 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 |
|
||||
| [Tools](#tools) | 36 | 9 | 0 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 191 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 192 (target 0) |
|
||||
| [Type](#type) | 5 | 5 | 0 | |
|
||||
| [Automation](#automation) | 5 | 3 | 3 | 660 of 660 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present |
|
||||
@@ -209,7 +209,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 10 of 191** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 10 of 192** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`.
|
||||
|
||||
|
||||
+15
-3
@@ -54,9 +54,21 @@ total is zero, as with some FUSE and WebDAV mounts) counts as unknown: the free-
|
||||
doesn't apply, imports earn a scratch allowance only up to the Scratch Size Limit, and
|
||||
Preferences → Scratch Disks and the hard-limit refusal say so, with the hint to set a limit or
|
||||
use a local disk; `spill::stats` exposes it as `freeSpaceUnmeasured`. With neither free space nor
|
||||
a size limit known, imports get no scratch allowance. The query runs on the thread that checks
|
||||
room, as creating the scratch file does, so a dead hard-mounted network folder can block it the
|
||||
same way its scratch I/O would.
|
||||
a size limit known, imports get no scratch allowance. Opening and measuring the folder happen
|
||||
off every lock, on a short-lived thread (at most one per folder and question, 16 overall), and
|
||||
callers wait up to 2 s for the answer. A query that doesn't answer in time counts as unknown
|
||||
(warned as above) and, when the scratch file couldn't be opened, pauses spilling with "not
|
||||
responding" and retries later. A system call can't be cancelled, so a stuck query keeps its
|
||||
thread until the mount answers and no second one starts; a late answer applies if that folder is
|
||||
still the current one and is discarded otherwise. The free-space check every 64 MB of growth never
|
||||
waits, and with an engine session tile allocation leaves retries to its supervisor. The wait is on
|
||||
the calling thread: changing Scratch Disks preferences or checking room for an import may wait up
|
||||
to those 2 s once for a newly hung folder. A whole folder change waits at most 2 s in total, the new
|
||||
folder first, with closing the old scratch file getting what is left; old files close on at most
|
||||
four background threads, so one stuck close doesn't hold up the others. On macOS the purgeable-space lookup asks every mounted volume, so it runs
|
||||
separately for at most 0.5 s, and an unrelated dead mount only loses the purgeable figure, never
|
||||
the folder's own numbers. Limit: reading or writing tile pixels already spilled
|
||||
to a hung folder still blocks the thread doing it; only opening and measuring are bounded.
|
||||
A failed read retains its backing extent and blocks editing, saving and exporting of the
|
||||
document that holds the tile until it is readable again; other open documents keep working.
|
||||
Each commit checks the health of its own document (before and after an edit, the document a
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the configured GPU group on lavapipe without silently losing comparisons."""
|
||||
import glob
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
|
||||
def gpu_filter(config):
|
||||
filters = [row['filter'] for row in config['profile']['default']['overrides']
|
||||
if row.get('test-group') == 'gpu']
|
||||
if not filters:
|
||||
raise ValueError('nextest GPU group has no filters')
|
||||
return ' | '.join(f'({value})' for value in filters)
|
||||
|
||||
|
||||
def validate_log(lines):
|
||||
vulkan = False
|
||||
rejected = []
|
||||
for line in lines:
|
||||
vulkan |= 'GPU parity backend: Vulkan' in line
|
||||
# Authored skips, not nextest's summary of ignored/filtered-out tests.
|
||||
if re.search(r'\bskipping\b|\bno (?:GPU )?adapter\b', line):
|
||||
rejected.append(line.rstrip())
|
||||
if 'SKIP ' in line and not (
|
||||
('SKIP gpu timing:' in line and 'is a software adapter' in line)
|
||||
or 'SKIP the comparison: this GPU finishes' in line
|
||||
or 'SKIP the sum: this refresh still fit one command buffer' in line
|
||||
):
|
||||
rejected.append(line.rstrip())
|
||||
if not vulkan:
|
||||
rejected.append('GPU parity tests did not report the Vulkan backend')
|
||||
return rejected
|
||||
|
||||
|
||||
def main():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
with (root / '.config/nextest.toml').open('rb') as source:
|
||||
selected = gpu_filter(tomllib.load(source))
|
||||
icds = sorted(glob.glob('/usr/share/vulkan/icd.d/lvp_icd*.json'))
|
||||
if not icds:
|
||||
raise RuntimeError('Install mesa-vulkan-drivers and libvulkan1 before this check')
|
||||
env = dict(os.environ, WGPU_BACKEND='vulkan', VK_ICD_FILENAMES=os.pathsep.join(icds))
|
||||
command = ['cargo', 'nextest', 'run', '--profile', 'ci', '--workspace', '--locked',
|
||||
'--features', 'heif', '--lib', '--bins', '--tests', '-E', selected,
|
||||
'--success-output', 'immediate', '--color', 'never']
|
||||
# Stream output and keep it on disk, rather than buffering every successful test in memory.
|
||||
with tempfile.TemporaryFile(mode='w+t') as log:
|
||||
with subprocess.Popen(command, cwd=root, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True) as process:
|
||||
for line in process.stdout:
|
||||
sys.stdout.write(line)
|
||||
log.write(line)
|
||||
result = process.wait()
|
||||
log.seek(0)
|
||||
errors = validate_log(log)
|
||||
for error in errors:
|
||||
print(f'::error::{error}', file=sys.stderr)
|
||||
return result if result else int(bool(errors))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,44 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('gpu_check', Path(__file__).with_name('ci-gpu-lavapipe.py'))
|
||||
check = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(check)
|
||||
|
||||
|
||||
class LogChecks(unittest.TestCase):
|
||||
def test_requires_vulkan_evidence(self):
|
||||
self.assertTrue(check.validate_log(['GPU parity backend: Gl']))
|
||||
self.assertTrue(check.validate_log([]))
|
||||
|
||||
def test_success_and_nextest_summary(self):
|
||||
self.assertFalse(check.validate_log(['GPU parity backend: Vulkan (llvmpipe, Cpu)',
|
||||
'Summary 100 tests run: 100 passed, 3 skipped']))
|
||||
|
||||
def test_rejects_missing_comparisons(self):
|
||||
for message in ['skipping GPU parity tests: no device', 'skipping: unsupported format',
|
||||
'no GPU adapter', 'SKIP gpu timing: no adapter',
|
||||
'SKIP gpu timing: no device with timestamp queries', 'SKIP unknown test']:
|
||||
with self.subTest(message=message):
|
||||
self.assertTrue(check.validate_log(['GPU parity backend: Vulkan', message]))
|
||||
|
||||
def test_allows_only_known_timing_exceptions(self):
|
||||
self.assertFalse(check.validate_log(['GPU parity backend: Vulkan',
|
||||
'SKIP gpu timing: llvmpipe is a software adapter, which cannot show a GPU/CPU split',
|
||||
'SKIP the comparison: this GPU finishes a 6 MP composite in 1 ms against 2 ms of encoding',
|
||||
'SKIP the sum: this refresh still fit one command buffer']))
|
||||
|
||||
def test_selects_all_gpu_filters_from_config(self):
|
||||
config = {'profile': {'default': {'overrides': [
|
||||
{'filter': 'package(a)', 'test-group': 'gpu'},
|
||||
{'filter': 'package(b)', 'test-group': 'cpu'},
|
||||
{'filter': 'test(c)', 'test-group': 'gpu'}]}}}
|
||||
self.assertEqual(check.gpu_filter(config), '(package(a)) | (test(c))')
|
||||
config['profile']['default']['overrides'] = []
|
||||
with self.assertRaises(ValueError):
|
||||
check.gpu_filter(config)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user