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:
RickiNano committed 2026-11-06 19:53:07 +01:00
commit 265fedc687
46 files changed
+1934 -213

No files matched your search

+1 -1
View File
@@ -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 }
+2 -2
View File
@@ -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
View File
@@ -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"
+8
View File
@@ -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();
+38 -1
View File
@@ -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")]
+1 -1
View File
@@ -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 {
+6 -2
View File
@@ -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));
}
}
}
_ => {}
+20 -1
View File
@@ -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
+30 -5
View File
@@ -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).
+2 -1
View File
@@ -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,
+2 -2
View File
@@ -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);
}
+4 -4
View File
@@ -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}");
+56 -9
View File
@@ -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]
+2 -2
View File
@@ -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");
+9
View File
@@ -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,
+14
View File
@@ -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();
+3 -2
View File
@@ -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,
+3 -3
View File
@@ -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(|_| ())),
}
}
+7 -3
View File
@@ -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
View File
@@ -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");
}
}
+1 -1
View File
@@ -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)
}
+27 -4
View File
@@ -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");
+3 -3
View File
@@ -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.
+5 -5
View File
@@ -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");
+26 -2
View File
@@ -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
View File
@@ -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, &not_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, &not_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()));
+26 -2
View File
@@ -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.
+208
View File
@@ -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);
}
+10 -1
View File
@@ -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"] }
+5
View File
@@ -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;
+3
View File
@@ -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
+334
View File
@@ -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:?}");
}
}
+4
View File
@@ -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!(
+1 -1
View File
@@ -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;
+68 -6
View File
@@ -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,
}
}
+21
View File
@@ -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.
+6
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+67
View File
@@ -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())
+44
View File
@@ -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()