mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 12:22:22 +00:00
PSD admission: a little free memory no longer refuses every file
With 1 GiB or less of free RAM, or 512 MiB or less of commit headroom, the hard limit was exactly 0, so even a small file was refused with a message that didn't say memory was low. The reserve now shrinks with the measurement: up to a quarter of free RAM opens automatically, the rest goes to approval, and an approved import always leaves at least 256 MiB free. Limits are unchanged for automatic opening above 4/3 GiB free and for approval from 4 GiB free. A refusal set by measured memory now gives the free amount and says to close other apps and try again, and the approval prompt warns when little memory would be left. An unreadable cgroup still measures Some(0) and stays refused.
This commit is contained in:
1 parent
8f7d916ba5
commit
1ce1bced95
19 files changed
+523
-94
No files matched your search
@@ -661,7 +661,7 @@ mod waited_error_tests {
|
||||
fn waited_job_keeps_typed_failure() {
|
||||
let mut headless = Headless::trusted_local();
|
||||
let id = JobId(123);
|
||||
let error = photocraft_engine::EngineError::PsdHardBudget { name: "large.psd".into(), required: 9000, limit: 4000 };
|
||||
let error = photocraft_engine::EngineError::PsdHardBudget { name: "large.psd".into(), required: 9000, limit: 4000, free_memory: None };
|
||||
headless.waited.insert(
|
||||
id,
|
||||
Some(JobEvent {
|
||||
|
||||
@@ -79,12 +79,16 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
|
||||
}
|
||||
}
|
||||
}
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => {
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit, free_memory })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, free_memory, .. }) => {
|
||||
if let Some(m) = obj.as_object_mut() {
|
||||
m.insert("required".into(), json!(required));
|
||||
m.insert("hard".into(), json!(limit));
|
||||
m.insert("hardLimit".into(), json!(limit));
|
||||
if let Some(free) = free_memory {
|
||||
m.insert("available".into(), json!(free));
|
||||
m.insert("availableRam".into(), json!(free));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1092,7 +1092,12 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() {
|
||||
// fields, and only the fields an approval request has: no soft limit and no capacity, since
|
||||
// no approval can raise the ceiling.
|
||||
let mut hard_obj = json!({"id": 2, "ok": false, "error": "test"});
|
||||
let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 8000 });
|
||||
let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
|
||||
name: "big.psd".into(),
|
||||
required: 9000,
|
||||
limit: 8000,
|
||||
free_memory: None,
|
||||
});
|
||||
super::attach_memory_error_details(&mut hard_obj, &hard);
|
||||
assert_eq!(hard_obj["required"], 9000);
|
||||
assert_eq!(hard_obj["hard"], 8000);
|
||||
@@ -1100,6 +1105,20 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() {
|
||||
for absent in ["soft", "softLimit", "available", "availableRam"] {
|
||||
assert!(hard_obj.get(absent).is_none(), "a hard refusal has no {absent}: {hard_obj}");
|
||||
}
|
||||
|
||||
// When measured free memory set the limit, the refusal also reports it.
|
||||
let mut low_obj = json!({"id": 3, "ok": false, "error": "test"});
|
||||
let low = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
|
||||
name: "big.psd".into(),
|
||||
required: 9000,
|
||||
limit: 8000,
|
||||
free_memory: Some(10_000),
|
||||
});
|
||||
super::attach_memory_error_details(&mut low_obj, &low);
|
||||
assert_eq!(low_obj["hardLimit"], 8000);
|
||||
assert_eq!(low_obj["available"], 10_000);
|
||||
assert_eq!(low_obj["availableRam"], 10_000);
|
||||
assert!(low_obj.get("soft").is_none(), "a hard refusal has no soft limit: {low_obj}");
|
||||
}
|
||||
|
||||
/// #10897: `doc.place` forwards the strict one-shot high-memory approval on both routes: the
|
||||
|
||||
@@ -514,8 +514,9 @@ fn png_result(png: &[u8], note: String) -> CallToolResult {
|
||||
}
|
||||
|
||||
/// Adds the typed byte counts of a memory refusal to an error object: an approval request keeps
|
||||
/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps only its required
|
||||
/// and hard amounts, because no approval can raise that ceiling.
|
||||
/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps its required and
|
||||
/// hard amounts, because no approval can raise that ceiling, and adds the free memory when
|
||||
/// measured memory set the limit.
|
||||
fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
|
||||
let Some(obj) = obj.as_object_mut() else { return };
|
||||
match err {
|
||||
@@ -531,11 +532,15 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) {
|
||||
obj.insert("availableRam".into(), json!(avail));
|
||||
}
|
||||
}
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => {
|
||||
AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit, free_memory })
|
||||
| AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, free_memory, .. }) => {
|
||||
obj.insert("required".into(), json!(required));
|
||||
obj.insert("hard".into(), json!(limit));
|
||||
obj.insert("hardLimit".into(), json!(limit));
|
||||
if let Some(free) = free_memory {
|
||||
obj.insert("available".into(), json!(free));
|
||||
obj.insert("availableRam".into(), json!(free));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1614,7 +1619,12 @@ mod tests {
|
||||
assert!(serde_json::from_value::<PlaceParams>(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err(), "{invalid}");
|
||||
}
|
||||
|
||||
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "huge.psd".into(), required: 9_000, limit: 8_000 });
|
||||
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
|
||||
name: "huge.psd".into(),
|
||||
required: 9_000,
|
||||
limit: 8_000,
|
||||
free_memory: None,
|
||||
});
|
||||
let res = to_result(Err(err)).unwrap();
|
||||
assert_eq!(res.is_error, Some(true));
|
||||
let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text;
|
||||
@@ -1625,6 +1635,21 @@ mod tests {
|
||||
for absent in ["soft", "softLimit", "available", "availableRam"] {
|
||||
assert!(v.get(absent).is_none(), "a hard refusal has no {absent}: {v}");
|
||||
}
|
||||
|
||||
// With measured free memory behind the limit, the refusal reports it too.
|
||||
let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget {
|
||||
name: "huge.psd".into(),
|
||||
required: 9_000,
|
||||
limit: 8_000,
|
||||
free_memory: Some(10_000),
|
||||
});
|
||||
let res = to_result(Err(err)).unwrap();
|
||||
let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text;
|
||||
let v: Value = serde_json::from_str(text).unwrap();
|
||||
assert_eq!(v["hardLimit"], 8_000);
|
||||
assert_eq!(v["available"], 10_000);
|
||||
assert_eq!(v["availableRam"], 10_000);
|
||||
assert!(v.get("soft").is_none(), "a hard refusal has no soft limit: {v}");
|
||||
}
|
||||
|
||||
/// The MCP server applies finished jobs before every tool call too (#503).
|
||||
|
||||
@@ -287,7 +287,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,
|
||||
|
||||
@@ -4075,7 +4075,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));
|
||||
}
|
||||
@@ -4104,7 +4104,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);
|
||||
}
|
||||
|
||||
@@ -202,9 +202,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));
|
||||
}
|
||||
@@ -233,7 +233,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}");
|
||||
@@ -284,7 +284,7 @@ fn video_frames_to_layers_uses_the_footage_limit_for_a_single_page_layered_tiff(
|
||||
let _limit = super::limits::Scoped::psd_max_pixel_bytes(1);
|
||||
let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err();
|
||||
match err {
|
||||
EngineError::PsdHardBudget { name, required, limit } => {
|
||||
EngineError::PsdHardBudget { name, required, limit, .. } => {
|
||||
assert_eq!(name, path);
|
||||
assert_eq!(limit, 1);
|
||||
assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}");
|
||||
|
||||
@@ -23,8 +23,13 @@ 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} bytes cannot be admitted within the {limit} byte hard limit on this machine")]
|
||||
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} bytes cannot be admitted within the {limit} byte hard limit on this machine{advice}",
|
||||
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")]
|
||||
@@ -59,7 +64,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}")),
|
||||
}
|
||||
@@ -70,3 +75,36 @@ impl From<photocraft_raster::AllocationError> for EngineError {
|
||||
Self::Other(format!("not enough memory for this operation ({error}); the document was not changed"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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.ends_with("on this machine"), "{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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1350,14 +1350,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");
|
||||
|
||||
@@ -2498,14 +2498,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");
|
||||
@@ -2532,7 +2532,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(|_| ())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,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(
|
||||
@@ -1406,7 +1410,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(|_| ())),
|
||||
|
||||
+310
-42
@@ -31,9 +31,23 @@ pub const MAX_SOFT_BUDGET: u64 = 8 << 30;
|
||||
/// platform's actual allocation limits, but the process may run out of memory.
|
||||
pub const UNKNOWN_MEMORY_WARNING_THRESHOLD: u64 = 8 << 30;
|
||||
|
||||
/// Reserve subtracted from commit headroom when known.
|
||||
/// Largest reserve subtracted from commit headroom when known. The reserve is a quarter of the
|
||||
/// headroom, at least [`LOW_MEMORY_RESERVE`] and at most this, so a machine with little commit
|
||||
/// headroom still has room for a small file.
|
||||
pub const COMMIT_RESERVE: u64 = 512 << 20;
|
||||
|
||||
/// The smallest amount an approved import leaves free: for the app, its display and the system.
|
||||
///
|
||||
/// Free RAM or commit headroom at or below it leaves no room at all, so every file is refused.
|
||||
pub const LOW_MEMORY_RESERVE: u64 = 256 << 20;
|
||||
|
||||
/// What automatic opening leaves free once that is less than the quarter-of-free-RAM rule.
|
||||
const AUTOMATIC_RAM_RESERVE: u64 = 1 << 30;
|
||||
|
||||
/// When opening a file would leave less than this free, the approval prompt warns that
|
||||
/// PhotoCraft may run out of memory.
|
||||
pub const LOW_MEMORY_WARNING_MARGIN: u64 = 1 << 30;
|
||||
|
||||
/// Bytes Rust's layout rules allow for one contiguous allocation on this target
|
||||
/// (`isize::MAX`), not a bound on an import's aggregate peak.
|
||||
///
|
||||
@@ -171,6 +185,11 @@ pub struct MemoryBudget {
|
||||
/// Set when available memory is unknown: estimates above this threshold still need explicit
|
||||
/// approval but also carry the uncertain-capacity warning. `None` when capacity is known.
|
||||
pub warning_threshold: Option<u64>,
|
||||
/// The free memory measured when measured memory is what set the hard limit: the smaller of
|
||||
/// the known available RAM and commit headroom. `None` when neither is known, when the
|
||||
/// platform cap or the caller's limit is what bound the hard limit, and on the web. A refusal
|
||||
/// reports it so the user can be told to free memory (see [`low_memory_advice`]).
|
||||
pub measured_free: Option<u64>,
|
||||
}
|
||||
|
||||
/// Admission decision for a given required memory estimate.
|
||||
@@ -213,6 +232,33 @@ 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)),
|
||||
}
|
||||
}
|
||||
|
||||
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 +298,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 +346,71 @@ 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 is what set the hard 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 +487,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 +526,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 +845,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 +855,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 +894,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 +1074,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 +1160,193 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_credit_adds_to_the_low_memory_limits() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
return;
|
||||
}
|
||||
let snap = |ram, commit| MemorySnapshot { available_ram: ram, swap_free: None, commit_headroom: commit };
|
||||
with_mock_scratch(Some(40 * GIB), || {
|
||||
let one_gib = snap(Some(GIB), None);
|
||||
let out = evaluate_psd_admission_for(Platform::Native, 2 * GIB + 100 * MIB, 2 * GIB, 0, false, None, one_gib).unwrap();
|
||||
assert_eq!(out.authorized_total, 256 * MIB + 2 * GIB);
|
||||
|
||||
let err = evaluate_psd_admission_for(Platform::Native, 2 * GIB + 500 * MIB, 2 * GIB, 0, false, None, one_gib).unwrap_err();
|
||||
assert!(matches!(err, IoError::PsdApprovalRequired { soft_limit, hard_limit, .. }
|
||||
if soft_limit == 2 * GIB + 256 * MIB && hard_limit == 2 * GIB + 768 * MIB));
|
||||
|
||||
let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 800 * MIB, 2 * GIB, 0, true, None, one_gib));
|
||||
assert_eq!(refused, (2 * GIB + 800 * MIB, 2 * GIB + 768 * MIB, Some(GIB)));
|
||||
|
||||
// Fail closed: nothing measured, so only the credited tile share passes.
|
||||
let zero = snap(Some(0), None);
|
||||
assert!(evaluate_psd_admission_for(Platform::Native, 2 * GIB, 2 * GIB, 0, false, None, zero).is_ok());
|
||||
let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 1, 2 * GIB, 0, true, None, zero));
|
||||
assert_eq!(refused, (2 * GIB + 1, 2 * GIB, Some(0)));
|
||||
|
||||
// Low commit headroom with plenty of RAM: H = 256 MiB + credit.
|
||||
let low_commit = snap(Some(64 * GIB), Some(512 * MIB));
|
||||
let refused = hard_refusal(evaluate_psd_admission_for(Platform::Native, 2 * GIB + 300 * MIB, 2 * GIB, 0, true, None, low_commit));
|
||||
assert_eq!(refused, (2 * GIB + 300 * MIB, 2 * GIB + 256 * MIB, Some(512 * MIB)));
|
||||
let b = resolve_budget_credited(Platform::Native, low_commit, None, 2 * GIB);
|
||||
assert_eq!((b.soft_limit, b.hard_limit), (2 * GIB + 128 * MIB, 2 * GIB + 256 * MIB));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_memory_advice_names_the_free_memory() {
|
||||
assert_eq!(low_memory_advice(&None), "");
|
||||
assert_eq!(
|
||||
low_memory_advice(&Some(0)),
|
||||
". PhotoCraft measured no free memory, or could not read this system's memory limit. Close other apps and try again"
|
||||
);
|
||||
assert_eq!(low_memory_advice(&Some(900 << 20)), ". About 900 MiB of memory is free. Close other apps and try again");
|
||||
assert_eq!(low_memory_advice(&Some(3 << 30)), ". About 3.0 GiB of memory is free. Close other apps and try again");
|
||||
assert_eq!(low_memory_advice(&Some(512)), ". About 512 B of memory is free. Close other apps and try again");
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ pub fn estimate(file: &PsdFile) -> Result<PsdMemoryEstimate, IoError> {
|
||||
pub fn check(file: &PsdFile, limit: u64) -> Result<PsdMemoryEstimate, IoError> {
|
||||
let est = estimate(file)?;
|
||||
if est.total_bytes > limit {
|
||||
return Err(IoError::PsdImportBudget { required: est.total_bytes, limit });
|
||||
return Err(IoError::PsdImportBudget { required: est.total_bytes, limit, free_memory: None });
|
||||
}
|
||||
Ok(est)
|
||||
}
|
||||
|
||||
@@ -284,6 +284,29 @@ fn zero_available_ram_refuses_admission() {
|
||||
});
|
||||
}
|
||||
|
||||
/// A small file opens with 900 MiB free (it used to be refused outright below 1 GiB); with no
|
||||
/// free memory at all it is refused and the refusal says why.
|
||||
#[test]
|
||||
fn small_file_opens_with_little_free_memory_and_a_refusal_names_it() {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return; // the web policy ignores a host snapshot; it has its own tests
|
||||
}
|
||||
let file = testgen::small(Version::Psd, Compression::Zip);
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
|
||||
let low = crate::memory::MemorySnapshot { available_ram: Some(900 << 20), swap_free: None, commit_headroom: None };
|
||||
crate::memory::with_mock_memory(low, || {
|
||||
let res = crate::import_with_options("small.psd", &bytes, &crate::ImportOptions::default());
|
||||
assert!(res.is_ok(), "{:?}", res.map(|_| ()));
|
||||
});
|
||||
|
||||
let none = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None };
|
||||
crate::memory::with_mock_memory(none, || {
|
||||
let res = crate::import_with_options("small.psd", &bytes, &crate::ImportOptions::default());
|
||||
assert!(matches!(res, Err(IoError::PsdImportBudget { limit: 0, free_memory: Some(0), .. })));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_rejection_and_approval_files_release_gate() {
|
||||
if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) {
|
||||
@@ -337,7 +360,7 @@ fn retry_estimate_cap_rejects_altered_or_enlarged_file() {
|
||||
|
||||
// 3. Exceeds hard limit: refused completely regardless of approval
|
||||
let res = crate::memory::evaluate_psd_admission(10 * gb, 0, 0, true, Some(10 * gb), snap);
|
||||
assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit }) if required == 10 * gb && limit == 9 * gb));
|
||||
assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit, .. }) if required == 10 * gb && limit == 9 * gb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,7 +436,7 @@ fn nested_imports_consume_from_shared_allowance_and_do_not_reset_parent() {
|
||||
|
||||
// Second nested consume exceeds remaining 1 GB
|
||||
let err = crate::psd_gate::consume_attempt_allowance(2 * gb).unwrap_err();
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 2 * gb && limit == gb));
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit, .. } if required == 2 * gb && limit == gb));
|
||||
|
||||
// Allowance is still 1 GB (failed attempt does not reset or corrupt parent)
|
||||
assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok());
|
||||
@@ -472,7 +495,7 @@ fn real_import_propagates_high_memory_approval_and_its_estimate_bound() {
|
||||
let lowered = crate::ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() };
|
||||
assert!(matches!(
|
||||
crate::import_with_options("approval.psd", &bytes, &lowered),
|
||||
Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1
|
||||
Err(IoError::PsdImportBudget { required: r, limit: l, .. }) if r == required && l == required - 1
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -527,7 +550,7 @@ fn memory_drop_while_waiting_for_the_gate_refuses_before_decoding() {
|
||||
|
||||
let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding");
|
||||
match result {
|
||||
Err(IoError::PsdImportBudget { required, limit: 0 }) => assert!(required > 0),
|
||||
Err(IoError::PsdImportBudget { required, limit: 0, .. }) => assert!(required > 0),
|
||||
other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"),
|
||||
}
|
||||
handle.join().expect("worker thread");
|
||||
|
||||
@@ -79,14 +79,14 @@ pub(crate) fn consume_attempt_allowance(bytes: u64) -> Result<(), IoError> {
|
||||
ATTEMPT_ALLOWANCE.with(|a| match a.get() {
|
||||
Some(remaining) => {
|
||||
if bytes == u64::MAX || bytes > remaining {
|
||||
return Err(IoError::PsdImportBudget { required: bytes, limit: remaining });
|
||||
return Err(IoError::PsdImportBudget { required: bytes, limit: remaining, free_memory: None });
|
||||
}
|
||||
a.set(Some(remaining - bytes));
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
// Unknown allowance during reentrant gate must fail closed.
|
||||
Err(IoError::PsdImportBudget { required: bytes, limit: 0 })
|
||||
Err(IoError::PsdImportBudget { required: bytes, limit: 0, free_memory: None })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -193,7 +193,7 @@ mod tests {
|
||||
set_active_attempt_budget(u64::MAX);
|
||||
|
||||
let err = consume_attempt_allowance(u64::MAX).expect_err("the overflow sentinel is never a charge");
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == u64::MAX && limit == u64::MAX));
|
||||
assert!(matches!(err, IoError::PsdImportBudget { required, limit, .. } if required == u64::MAX && limit == u64::MAX));
|
||||
assert_eq!(active_attempt_remaining(), Some(u64::MAX), "a refused sentinel charge leaves the parent's allowance untouched");
|
||||
|
||||
// One byte below the sentinel is an ordinary, enormous charge.
|
||||
|
||||
@@ -1319,7 +1319,7 @@ mod tests {
|
||||
let lowered = ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() };
|
||||
assert!(matches!(
|
||||
crate::import_with_options("l.tif", &bytes, &lowered),
|
||||
Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1
|
||||
Err(IoError::PsdImportBudget { required: r, limit: l, .. }) if r == required && l == required - 1
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -1351,7 +1351,7 @@ mod tests {
|
||||
// remaining allowance is untouched.
|
||||
crate::psd_gate::set_active_attempt_budget(charged - 1);
|
||||
match crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl) {
|
||||
Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (charged, charged - 1)),
|
||||
Err(IoError::PsdImportBudget { required, limit, .. }) => assert_eq!((required, limit), (charged, charged - 1)),
|
||||
other => panic!("expected an import-budget refusal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(charged - 1), "a failed nested import leaves the allowance as it was");
|
||||
@@ -1462,7 +1462,7 @@ mod tests {
|
||||
crate::memory::with_mock_memory(zero, || {
|
||||
crate::flat::with_before_composite_decode(hook(&decoded), || {
|
||||
match crate::import_with_options("bogus.tif", &bytes, &ImportOptions::default()) {
|
||||
Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0),
|
||||
Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0),
|
||||
other => panic!("expected an import-budget refusal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(decoded.get(), 0, "a refused file must not decode its composite");
|
||||
@@ -1504,7 +1504,7 @@ mod tests {
|
||||
crate::memory::with_mock_memory(zero, || {
|
||||
crate::flat::with_before_composite_decode(hook(&decoded), || {
|
||||
match crate::import_with_options("zero.tif", &bytes, &ImportOptions::default()) {
|
||||
Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0),
|
||||
Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0),
|
||||
other => panic!("expected an import-budget refusal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(decoded.get(), 0, "a refused file must not decode its composite");
|
||||
@@ -1589,7 +1589,7 @@ mod tests {
|
||||
|
||||
let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding");
|
||||
match result {
|
||||
Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0),
|
||||
Err(IoError::PsdImportBudget { limit: 0, required, .. }) => assert!(required > 0),
|
||||
other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"),
|
||||
}
|
||||
assert!(!decoded.load(Ordering::SeqCst), "the composite must not be decoded after the post-gate refusal");
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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.
|
||||
|
||||
+34
-12
@@ -744,8 +744,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}`.
|
||||
@@ -756,8 +768,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.
|
||||
@@ -804,7 +816,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
|
||||
@@ -821,8 +835,8 @@ 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.
|
||||
refusal above the hard ceiling is the typed `PsdHardBudget` error (name, required, limit,
|
||||
`free_memory`), not a string, and it releases the queued source.
|
||||
- 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
|
||||
@@ -847,15 +861,23 @@ 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). A hard-budget refusal reports
|
||||
`required` and `hardLimit`, plus `available` and `availableRam` when measured free memory set the
|
||||
limit (the typed `free_memory` field); 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.
|
||||
|
||||
Reference in new issue
Block a user