fix: C allocator contract, shadertoy overloads/MSL stride, minidumps

Closes the remaining library rows of the final completeness audit
(docs/rewrite/notes/final-Z3.md):
- libghostty-vt: OSC parser storage goes through the caller-supplied C
  allocator via a ByteStorage abstraction; ghostty_osc_new reports
  GHOSTTY_OUT_OF_MEMORY and failed grows invalidate the OSC like Zig.
  Tested with a counting/failing/limiting C allocator.
- shadertoy: sampler parameter rewriting is keyed by full signature so
  overloads work; the Metal uniform block keeps std140 strides for
  iChannelTime[4] (MSL offsets checked against the upload layout).
- tripwire.zig ported (named trip points, hit counts, verification) and
  used to port the previously skipped allocation-failure tests that have
  a fallible Rust path; the rest are documented.
- crash: fatal signals capture siginfo + registers and a forked helper
  writes a minidump (minidump-writer) as an envelope attachment, like
  sentry-native/Breakpad; tests parse it with the minidump crate.
- kitty LoadingImage::debug_dump (debug builds) and the Darwin
  Instruments signpost around benchmark runs.

Test Plan:
- cargo test -p ghostty-vt-capi --test c_allocator
- cargo test -p ghostty-crash --test minidump
This commit is contained in:
ddidderr committed 2026-10-12 08:14:02 +02:00
1 parent 96314c1a8b
commit f6002d7519
22 files changed
+2079 -57

No files matched your search

+150 -2
View File
@@ -3,12 +3,22 @@
//! Wrapper objects handed out through the C API (parsers, events, encoders)
//! and the SGR parameter copy are allocated with the caller's allocator when
//! one is given and with the Rust global allocator otherwise (Zig: libc
//! `c_allocator` / `wasm_allocator`). Interior Rust collections (e.g. the OSC
//! parser's buffer) always use the global allocator.
//! `c_allocator` / `wasm_allocator`). Parser-owned byte storage (the OSC
//! payload buffer, [`CByteStorage`]) and the copies handed back to C (the OSC
//! window title, [`CBlock`]) are allocated through the same allocator, so the
//! caller's limits and failures govern them: a failed allocation surfaces as
//! `GHOSTTY_OUT_OF_MEMORY` where Zig can report it (object creation) and
//! invalidates the OSC being parsed where Zig's writer fails.
//!
//! Transient values built while a command is parsed (the payload copies the
//! per-command parsers make before `ghostty_osc_end` returns) use the global
//! allocator; nothing of them outlives that call.
#![allow(unsafe_code)]
use std::{alloc::Layout, ffi::c_void, ptr::NonNull};
use ghostty_vt::osc::{ByteStorage, StorageFull};
/// C: `GhosttyAllocatorVtable`
#[repr(C)]
pub struct AllocatorVtable {
@@ -133,3 +143,141 @@ impl Alloc {
}
}
}
/// A block of bytes allocated through an [`Alloc`] (Zig: an allocator-owned
/// slice). Freed with the same allocator on drop.
pub struct CBlock {
alloc: Alloc,
ptr: NonNull<u8>,
cap: usize,
}
impl CBlock {
/// Allocate `cap` bytes (alignment 1); `None` when the allocator fails.
pub fn new(alloc: Alloc, cap: usize) -> Option<CBlock> {
let layout = Layout::from_size_align(cap, 1).ok()?;
let ptr = NonNull::new(alloc.alloc_raw(layout))?;
if cap > 0 {
// SAFETY: freshly allocated `cap` bytes; zeroing makes the whole
// block initialized so slices over it are sound.
unsafe { ptr.as_ptr().write_bytes(0, cap) };
}
Some(CBlock { alloc, ptr, cap })
}
/// A copy of `bytes` followed by a NUL terminator.
pub fn from_bytes_z(alloc: Alloc, bytes: &[u8]) -> Option<CBlock> {
let mut b = CBlock::new(alloc, bytes.len() + 1)?;
b.as_mut_slice()[..bytes.len()].copy_from_slice(bytes);
b.as_mut_slice()[bytes.len()] = 0;
Some(b)
}
pub fn as_ptr(&self) -> *const u8 {
self.ptr.as_ptr()
}
pub fn capacity(&self) -> usize {
self.cap
}
pub fn as_slice(&self) -> &[u8] {
// SAFETY: `ptr` is a live, zero-initialized allocation of `cap` bytes.
unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.cap) }
}
pub fn as_mut_slice(&mut self) -> &mut [u8] {
// SAFETY: as above, with unique access through `&mut self`.
unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.cap) }
}
}
impl Drop for CBlock {
fn drop(&mut self) {
// SAFETY: allocated by `self.alloc` with this layout in `new`.
unsafe {
self.alloc.free_raw(
self.ptr.as_ptr(),
Layout::from_size_align_unchecked(self.cap, 1),
);
}
}
}
/// OSC payload storage backed by the C allocator. Starts with `base` bytes
/// (the parser's fixed buffer, allocated together with the parser so creation
/// can fail with out-of-memory) and grows by allocate-copy-free.
pub struct CByteStorage {
block: CBlock,
len: usize,
base: usize,
}
// SAFETY: the storage is uniquely owned by its parser. Moving a parser to
// another thread is allowed under the same contract as Zig's: the caller's
// allocator must be usable from the thread that uses the parser.
unsafe impl Send for CByteStorage {}
impl std::fmt::Debug for CByteStorage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CByteStorage")
.field("len", &self.len)
.field("cap", &self.block.capacity())
.field("base", &self.base)
.finish()
}
}
impl CByteStorage {
/// `None` if the initial `base` bytes cannot be allocated.
pub fn new(alloc: Alloc, base: usize) -> Option<CByteStorage> {
Some(CByteStorage {
block: CBlock::new(alloc, base)?,
len: 0,
base,
})
}
fn grow_to(&mut self, needed: usize) -> Result<(), StorageFull> {
if needed <= self.block.capacity() {
return Ok(());
}
let cap = needed.max(self.block.capacity().saturating_mul(2));
let mut new = CBlock::new(self.block.alloc, cap).ok_or(StorageFull)?;
new.as_mut_slice()[..self.len].copy_from_slice(&self.block.as_slice()[..self.len]);
self.block = new;
Ok(())
}
}
impl ByteStorage for CByteStorage {
fn bytes(&self) -> &[u8] {
&self.block.as_slice()[..self.len]
}
fn try_reserve(&mut self, additional: usize) -> Result<(), StorageFull> {
self.grow_to(self.len.checked_add(additional).ok_or(StorageFull)?)
}
fn try_extend(&mut self, bytes: &[u8]) -> Result<(), StorageFull> {
self.grow_to(self.len.checked_add(bytes.len()).ok_or(StorageFull)?)?;
self.block.as_mut_slice()[self.len..self.len + bytes.len()].copy_from_slice(bytes);
self.len += bytes.len();
Ok(())
}
fn reset(&mut self) {
self.len = 0;
// Give back memory acquired by growth (Zig deinits the allocating
// writer). If the small block cannot be allocated, keep the big one.
if self.block.capacity() > self.base
&& let Some(b) = CBlock::new(self.block.alloc, self.base)
{
self.block = b;
}
}
fn clone_box(&self) -> Box<dyn ByteStorage> {
Box::new(self.bytes().to_vec())
}
}