Problem
The eight UI crates carry 248 copy-pasted definitions of str_from_header with 766 call sites (ui-macos 44 defs/118 calls, ui-ios 41/94, ui-visionos 37/76, ui-windows 36/105, ui-gtk4 32/98, ui-tvos 27/55, ui-android 21/113, ui-watchos 6/37; plus 1 in perry-audio-miniaudio). Every copy open-codes the string layout and returns a &'static str manufactured over a movable GC payload:
// e.g. crates/perry-ui-macos/src/app.rs:63-73 — repeated ~248 times across the tree
fn str_from_header(ptr: *const u8) -> &'static str {
if ptr.is_null() { return ""; }
unsafe {
let header = ptr as *const crate::string_header::StringHeader;
let len = (*header).byte_len as usize;
let data = ptr.add(std::mem::size_of::<crate::string_header::StringHeader>());
std::str::from_utf8_unchecked(std::slice::from_raw_parts(data, len))
}
}
Problems: (1) the 'static lifetime erases the GC hazard — Perry strings are relocated by the copying minor, and a borrow held across any allocation through the runtime dangles (the "borrowed heap slice" class, crates/perry-runtime/src/object/field_get_set.rs:11-27); (2) 248 copies of the layout means any StringHeader change is a 248-site flag day; (3) ui-macos keeps its own struct mirror (crates/perry-ui-macos/src/string_header.rs:5) "to avoid a dep cycle", and perry-audio-miniaudio another (crates/perry-audio-miniaudio/src/lib.rs:32).
Suggested fix
- One shared helper. Options, in preference order: (a)
perry-ffi (it already mirrors StringHeader at types.rs:34 and exposes read_string; check whether the UI crates can depend on it without a cycle — it is a leaf crate, so they should); (b) a tiny new perry-string-abi crate holding the #[repr(C)] struct + reader if perry-ffi drags unwanted deps.
- Return an owned
String, not a borrow. UI FFI entry points are not hot paths; String::from_utf8_lossy(...).into_owned() (or unchecked + .to_string() where WTF-8 is impossible) kills the entire 'static hazard class in the same stroke. Most call sites immediately convert to NSString/owned data anyway.
- Migrate mechanically, crate by crate (this can land as one PR per UI crate — say so in each PR title). Delete the local struct mirrors and point at the shared one, keeping a
const size assert (== 20) at the shared definition.
Validation
- Cross-host UI crates don't build on every host — validate what the host can: on macOS,
cargo check -p perry-ui-macos -p perry-ui-ios -p perry-ui-tvos ... (check-only works for cross targets where full builds don't; if a crate can't even check on the host, note it in the PR and let CI's matrix cover it).
- At least one behavioral smoke on the host platform (a
perry/ui example compiles and shows a window with a non-ASCII title, exercising the reader).
- Grep-count
str_from_header definitions after: expect ~0 in migrated crates. If the byte-access ratchet script (filed separately) has landed, its baseline update is the proof of progress.
Workflow
PR = code + tests + changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge). Note the 2000-line file cap (scripts/check_file_size.sh) when touching large UI files.
Problem
The eight UI crates carry 248 copy-pasted definitions of
str_from_headerwith 766 call sites (ui-macos 44 defs/118 calls, ui-ios 41/94, ui-visionos 37/76, ui-windows 36/105, ui-gtk4 32/98, ui-tvos 27/55, ui-android 21/113, ui-watchos 6/37; plus 1 in perry-audio-miniaudio). Every copy open-codes the string layout and returns a&'static strmanufactured over a movable GC payload:Problems: (1) the
'staticlifetime erases the GC hazard — Perry strings are relocated by the copying minor, and a borrow held across any allocation through the runtime dangles (the "borrowed heap slice" class,crates/perry-runtime/src/object/field_get_set.rs:11-27); (2) 248 copies of the layout means anyStringHeaderchange is a 248-site flag day; (3) ui-macos keeps its own struct mirror (crates/perry-ui-macos/src/string_header.rs:5) "to avoid a dep cycle", and perry-audio-miniaudio another (crates/perry-audio-miniaudio/src/lib.rs:32).Suggested fix
perry-ffi(it already mirrorsStringHeaderattypes.rs:34and exposesread_string; check whether the UI crates can depend on it without a cycle — it is a leaf crate, so they should); (b) a tiny newperry-string-abicrate holding the#[repr(C)]struct + reader if perry-ffi drags unwanted deps.String, not a borrow. UI FFI entry points are not hot paths;String::from_utf8_lossy(...).into_owned()(or unchecked +.to_string()where WTF-8 is impossible) kills the entire'statichazard class in the same stroke. Most call sites immediately convert toNSString/owned data anyway.constsize assert (== 20) at the shared definition.Validation
cargo check -p perry-ui-macos -p perry-ui-ios -p perry-ui-tvos ...(check-only works for cross targets where full builds don't; if a crate can't even check on the host, note it in the PR and let CI's matrix cover it).perry/uiexample compiles and shows a window with a non-ASCII title, exercising the reader).str_from_headerdefinitions after: expect ~0 in migrated crates. If the byte-access ratchet script (filed separately) has landed, its baseline update is the proof of progress.Workflow
PR = code + tests +
changelog.d/<PR>-<slug>.md; no version bump (maintainer bumps at merge). Note the 2000-line file cap (scripts/check_file_size.sh) when touching large UI files.