Never crash: move the one unsafe FFI call into crates/sysmem

The macOS malloc_zone_pressure_relief call (returns freed allocator pages
after raw decodes) lived in identical copies inside the desktop app and the
CLI. It now lives in lightcraft-sysmem, an isolated L0 helper crate and the
workspace's only crate allowed unsafe: one #[allow(unsafe_code)] module, a
SAFETY comment, a safe API that returns bytes released (0 off macOS), and
tests (repeat calls, release after large frees, concurrent calls). Both apps'
alloc_release.rs now just install the safe hook, and their crate roots gain
#![forbid(unsafe_code)], so every production crate except sysmem forbids it.
AGENTS.md/CLAUDE.md name the exception, per craftrules standards/never-crash.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-05 02:06:08 -04:00
parent 5525ae667a
commit 1ef74a787d
13 changed files with 130 additions and 52 deletions

View File

@ -28,6 +28,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
fallback where possible, and a line here naming it.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.

View File

@ -28,6 +28,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
fallback (`unwrap_or…`) only where it can't silently corrupt a document. An unfinished feature returns an
"unsupported" error or is disabled. Sole exception: a provably infallible literal, as `#[allow(clippy::expect_used)]`
+ `.expect("why it can't fail")`.
- **`unsafe` lives only in `crates/sysmem`** (one FFI call, `malloc_zone_pressure_relief`, that returns freed
allocator pages to macOS after raw decodes). Every other production crate root has `#![forbid(unsafe_code)]`. A new
unsafe need goes in an isolated, well-tested helper crate like it: `// SAFETY:` on every block, a safe API, a safe
fallback where possible, and a line here naming it.
- **Input-derived numbers are hostile:** `get()` instead of `[i]`/`[a..b]` for offsets from files, users, agents or
arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative
casts to `usize`; cap allocations sized by input; slice strings only at char boundaries.

6
Cargo.lock generated
View File

@ -2080,6 +2080,7 @@ dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
"lightcraft-raster",
"lightcraft-sysmem",
"lightcraft-ui-egui",
"log",
"muda",
@ -2112,6 +2113,7 @@ dependencies = [
"lightcraft-mcp",
"lightcraft-merge",
"lightcraft-raster",
"lightcraft-sysmem",
"lightcraft-ui-egui",
"serde_json",
]
@ -2321,6 +2323,10 @@ dependencies = [
"serde",
]
[[package]]
name = "lightcraft-sysmem"
version = "0.1.1"
[[package]]
name = "lightcraft-tiff"
version = "0.1.1"

View File

@ -12,6 +12,7 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
# internal
lightcraft-geom = { path = "crates/geom" }
lightcraft-sysmem = { path = "crates/sysmem" }
lightcraft-color = { path = "crates/color" }
lightcraft-raster = { path = "crates/raster" }
lightcraft-tiff = { path = "crates/tiff" }

View File

@ -15,6 +15,7 @@ workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-sysmem = { workspace = true }
lightcraft-mcp = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }

View File

@ -1,32 +1,12 @@
//! Give memory the system allocator keeps after frees back to the system
//! ([`lightcraft_engine::memory::set_release_hook`]).
//!
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
//! process's memory footprint but stay in its resident size until the system runs short. After a
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
/// Install the hook for this platform.
/// Install the hook.
pub fn install() {
#[cfg(target_os = "macos")]
lightcraft_engine::memory::set_release_hook(macos::release);
lightcraft_engine::memory::set_release_hook(release);
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
unsafe extern "C" {
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
}
pub fn release() {
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
// it only returns free pages to the system and touches no memory we own.
unsafe {
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
}
}
fn release() {
let _ = lightcraft_sysmem::release_free_memory();
}

View File

@ -10,6 +10,7 @@
//! lightcraft-cli commands [--json]
//! lightcraft-cli controls [--json]
//! ```
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;

View File

@ -11,6 +11,7 @@ workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-sysmem = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raster = { workspace = true }

View File

@ -1,32 +1,12 @@
//! Give memory the system allocator keeps after frees back to the system
//! ([`lightcraft_engine::memory::set_release_hook`]).
//!
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
//! process's memory footprint but stay in its resident size until the system runs short. After a
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
//! ([`lightcraft_engine::memory::set_release_hook`]). The platform call lives in
//! `lightcraft-sysmem`, the workspace's only crate allowed `unsafe`.
/// Install the hook for this platform.
/// Install the hook.
pub fn install() {
#[cfg(target_os = "macos")]
lightcraft_engine::memory::set_release_hook(macos::release);
lightcraft_engine::memory::set_release_hook(release);
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
unsafe extern "C" {
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
}
pub fn release() {
// SAFETY: a documented libSystem function taking a null zone (= every zone) and a size;
// it only returns free pages to the system and touches no memory we own.
unsafe {
malloc_zone_pressure_relief(std::ptr::null_mut(), 0);
}
}
fn release() {
let _ = lightcraft_sysmem::release_free_memory();
}

View File

@ -17,6 +17,7 @@
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;

18
crates/sysmem/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "lightcraft-sysmem"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft sysmem (L0): returns freed allocator memory to the OS. The workspace's only crate allowed `unsafe` (one FFI call)."
# Opts out of the workspace lints only to keep `unsafe_code = "deny"` overridable for the single FFI
# module below; every other crate root has `#![forbid(unsafe_code)]`. See AGENTS.md and craftrules
# standards/never-crash.md ("isolated helper library crate").
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
[dependencies]

80
crates/sysmem/src/lib.rs Normal file
View File

@ -0,0 +1,80 @@
//! Give memory the system allocator keeps after frees back to the operating system.
//!
//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the
//! process's memory footprint but stay in its resident size until the system runs short. After a
//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of
//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now.
//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do.
//!
//! This is LightCraft's only crate allowed `unsafe` (one FFI call, in `macos`). Its API is safe,
//! and on failure or on other platforms it simply releases nothing.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// Return free allocator pages to the system. Returns the number of bytes released (always 0 on
/// platforms where freed large blocks are already unmapped). Safe to call at any time, from any
/// thread.
pub fn release_free_memory() -> usize {
#[cfg(target_os = "macos")]
{
macos::release()
}
#[cfg(not(target_os = "macos"))]
{
0
}
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
unsafe extern "C" {
/// libSystem malloc: release free memory of `zone` (all zones when null) back to the
/// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released.
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
}
pub fn release() -> usize {
// SAFETY: `malloc_zone_pressure_relief` is a documented, thread-safe libSystem function
// (malloc/malloc.h). A null zone means "every zone" and a goal of 0 means "as much as
// possible". It only returns already-free pages to the system: it reads and writes no
// memory we own, takes no pointers we must keep alive, and has no failure mode beyond
// releasing 0 bytes.
unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) }
}
}
#[cfg(test)]
mod tests {
use super::release_free_memory;
#[test]
fn releasing_with_nothing_freed_is_harmless() {
// Twice in a row: the second call usually has nothing left to release.
let _ = release_free_memory();
let _ = release_free_memory();
}
#[test]
fn freed_large_blocks_can_be_released_and_memory_stays_usable() {
// Allocate and free well over the allocator's large-block threshold, then release.
let blocks: Vec<Vec<u8>> = (0..8).map(|i| vec![i as u8; 16 << 20]).collect();
let sum: u64 = blocks.iter().map(|b| u64::from(b[b.len() - 1])).sum();
assert_eq!(sum, (0..8u64).sum());
drop(blocks);
let released = release_free_memory();
if cfg!(not(target_os = "macos")) {
assert_eq!(released, 0);
}
// The allocator must still work normally afterwards.
let again = vec![7u8; 16 << 20];
assert_eq!(again[again.len() - 1], 7);
}
#[test]
fn concurrent_calls_are_safe() {
let handles: Vec<_> = (0..4).map(|_| std::thread::spawn(release_free_memory)).collect();
for h in handles {
assert!(h.join().is_ok());
}
}
}

View File

@ -37,6 +37,7 @@ pub const TABLE: &[(&str, Class)] = &[
("color", Class::Layer(0)),
("raster", Class::Layer(0)),
("tiff", Class::Layer(0)),
("sysmem", Class::Layer(0)),
("raw", Class::Layer(1)),
("codecs", Class::Layer(1)),
("meta", Class::Layer(1)),