From 9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 9 Aug 2026 10:41:33 -0300 Subject: replace ArrayLists with bounded storage --- src/macos.zig | 179 ++++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 125 insertions(+), 54 deletions(-) (limited to 'src/macos.zig') diff --git a/src/macos.zig b/src/macos.zig index 2e628d54..d463b842 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -117,32 +117,29 @@ const Msg = union(enum) { } }; -/// 0.16 has no std.Thread.Mutex, and the gui shell's queue already settled -/// this: spin on the lock-free std.atomic.Mutex, and keep the critical section -/// to a pointer append. The reader allocates its chunk BEFORE taking the lock -/// for exactly that reason — a screenful of output must not make a keystroke -/// spin through a 64 KiB copy. -/// -/// ponytail: unbounded. `yes` in a pane can enqueue faster than the host -/// drains, and nothing throttles the reader — the tty shell gets that for free -/// from vaxis's 512-slot queue, whose post blocks when full, and the SDL shell -/// has the same hole this does. Bound it on queued bytes the day someone -/// watches RSS climb; the trap to avoid is a wait that teardown cannot cancel. +const inbox_capacity = 512; + +const MessageBatch = struct { + items: [inbox_capacity]Msg = undefined, + len: usize = 0, + + fn slice(batch: *MessageBatch) []Msg { + return batch.items[0..batch.len]; + } +}; + const Inbox = struct { mutex: std.atomic.Mutex = .unlocked, - items: std.ArrayList(Msg) = .empty, + items: [inbox_capacity]Msg = undefined, + head: usize = 0, + len: usize = 0, + closed: bool = false, /// Set when a wakeup has been delivered and not yet answered by a tick. - /// Without it a busy shell posts one wakeup per 64 KiB chunk, and each one - /// is a block on the host's main queue — a screenful of output becomes - /// thousands of scheduled pumps that all find the same drained inbox. wake_pending: std.atomic.Value(bool) = .init(false), fn lock(q: *Inbox) void { - // Bounded, unlike the gui shell's otherwise identical spin. AppKit's - // main thread runs at a higher QoS than these reader tasks and a raw - // CAS spin donates no priority, so a reader preempted inside the append - // (which can realloc) would have the highest-priority thread in the - // process spinning on it. Yielding hands the core back. + // AppKit's main thread runs at a higher QoS than reader tasks, so yield + // periodically rather than donating a full core to a preempted reader. var spins: u8 = 0; while (!q.mutex.tryLock()) { spins +%= 1; @@ -150,10 +147,70 @@ const Inbox = struct { } } + fn removeAt(q: *Inbox, offset: usize) Msg { + const removed = q.items[(q.head + offset) % q.items.len]; + var i = offset; + while (i + 1 < q.len) : (i += 1) + q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len]; + q.len -= 1; + return removed; + } + + /// Pty output is lossy under sustained backpressure. EOF is structural: + /// admit it by evicting queued output so dead readers are always reaped. fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void { q.lock(); defer q.mutex.unlock(); - q.items.append(gpa, m) catch m.free(gpa); + if (q.closed) { + m.free(gpa); + return; + } + if (q.len == q.items.len) { + const incoming_eof = switch (m) { + .eof => true, + .output => false, + }; + if (!incoming_eof) { + m.free(gpa); + return; + } + var offset: usize = 0; + while (offset < q.len) : (offset += 1) + if (switch (q.items[(q.head + offset) % q.items.len]) { + .output => true, + .eof => false, + }) break; + if (offset == q.len) return; + q.removeAt(offset).free(gpa); + } + q.items[(q.head + q.len) % q.items.len] = m; + q.len += 1; + } + + fn take(q: *Inbox) MessageBatch { + q.lock(); + defer q.mutex.unlock(); + var batch: MessageBatch = .{}; + while (q.len > 0) { + batch.items[batch.len] = q.items[q.head]; + batch.len += 1; + q.head = (q.head + 1) % q.items.len; + q.len -= 1; + } + q.head = 0; + return batch; + } + + fn close(q: *Inbox, gpa: std.mem.Allocator) void { + q.lock(); + defer q.mutex.unlock(); + q.closed = true; + while (q.len > 0) { + q.items[q.head].free(gpa); + q.head = (q.head + 1) % q.items.len; + q.len -= 1; + } + q.head = 0; } }; @@ -164,7 +221,8 @@ const State = struct { core: *pardes.Pardes, arena: std.heap.ArenaAllocator, runtime: Runtime, - cells: std.ArrayList(Cell) = .empty, + cells: []Cell = &.{}, + frame_len: usize = 0, /// The grid `cells` actually holds. Not read back off the core: a render /// can move screen_w/screen_h and then fail, and a host that sized its /// loops from those would walk off the buffer. @@ -180,9 +238,6 @@ const State = struct { /// moves a whole row at a time, so fractional trackpad travel accumulates /// here and is spent as wheel presses — see pardes_scroll. scroll_lag: f32 = 0, - /// Swapped with the inbox under the lock, so draining it costs one pointer - /// exchange and neither side reallocates once capacities have settled. - tick_msgs: std.ArrayList(Msg) = .empty, /// Owns the bytes of the user config, which Options only borrows. config_arena: std.heap.ArenaAllocator, }; @@ -209,6 +264,9 @@ export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { const gpa = std.heap.smp_allocator; + const allocs = pardes.allocators.init(gpa); + errdefer pardes.allocators.deinit(); + const threaded = try gpa.create(std.Io.Threaded); errdefer gpa.destroy(threaded); threaded.* = .init(gpa, .{}); @@ -218,7 +276,12 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { var config_arena: std.heap.ArenaAllocator = .init(gpa); errdefer config_arena.deinit(); - var opts: pardes.Options = .{ .tty_only = true }; + var opts: pardes.Options = .{ + .tty_only = true, + .image_allocator = allocs.image, + .pdf_allocator = allocs.pdf, + .tree_sitter_allocator = allocs.tree_sitter, + }; // Native shells opt into the user config, and every builtin in it must have // run before the host can render a frame — so it is read here, before // Pardes.init, exactly as src/main.zig does it. The env map is rebuilt from @@ -226,11 +289,14 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { if (captureEnv(config_arena.allocator())) |*env| opts.startup_config = user_config.load(io, config_arena.allocator(), env); - // stb_image's allocator shim, for image panes. - pardes.image.start(io, gpa); + pardes.image.start(io, allocs.image); errdefer pardes.image.stop(); + if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); + errdefer if (comptime pardes.pdf_enabled) pardes.pdf.stop(); + pardes.syntax.start(allocs.tree_sitter); + errdefer pardes.syntax.stop(); - const core = try pardes.Pardes.init(gpa, opts); + const core = try pardes.Pardes.init(allocs.pardes, opts); errdefer core.deinit(); // Shells emit OSC 133 prompt marks through these, which is what makes @@ -246,7 +312,7 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { .threaded = threaded, .io = io, .core = core, - .arena = .init(gpa), + .arena = .init(allocs.frame), .config_arena = config_arena, .runtime = if (runtime) |r| r.* else .{}, }; @@ -279,17 +345,17 @@ export fn pardes_deinit() void { for (0..pardes.MAX_PANES) |pane| reap(st, @intCast(pane)); // Only now is the inbox quiet. Anything still queued owns gpa bytes and // would show up as a leak rather than as the shutdown it actually is. - for (st.inbox.items.items) |msg| msg.free(st.gpa); - st.inbox.items.deinit(st.gpa); - for (st.tick_msgs.items) |msg| msg.free(st.gpa); - st.tick_msgs.deinit(st.gpa); - st.cells.deinit(st.gpa); - st.core.deinit(); + st.inbox.close(st.gpa); + if (st.cells.len > 0) st.gpa.free(st.cells); st.arena.deinit(); - st.config_arena.deinit(); + st.core.deinit(); pardes.image.stop(); + if (comptime pardes.pdf_enabled) pardes.pdf.stop(); + pardes.syntax.stop(); + st.config_arena.deinit(); st.threaded.deinit(); st.gpa.destroy(st.threaded); + pardes.allocators.deinit(); state = null; } @@ -308,15 +374,12 @@ export fn pardes_animating() bool { /// wakeup does not cost the host a repaint. export fn pardes_tick() bool { const st = &(state orelse return false); - // Cleared BEFORE the swap: a reader that pushes while this drain is running - // must be able to schedule the tick that will collect it. + // Cleared before the drain: a reader that pushes during this tick must be + // able to schedule the next one. st.inbox.wake_pending.store(false, .release); - st.inbox.lock(); - std.mem.swap(std.ArrayList(Msg), &st.inbox.items, &st.tick_msgs); - st.inbox.mutex.unlock(); - - var changed = st.tick_msgs.items.len > 0; - for (st.tick_msgs.items) |msg| { + var batch = st.inbox.take(); + var changed = batch.len > 0; + for (batch.slice()) |msg| { defer msg.free(st.gpa); switch (msg) { .output => |o| { @@ -327,16 +390,12 @@ export fn pardes_tick() bool { if (st.gens[e.pane] != e.gen) continue; // The shell is gone: join its reader (a completed future that // is never awaited leaks its allocation), close the master and - // free the slot. Without this the slot is only ever reaped by a - // later spawn INTO it — and the core emits spawn from newPane - // alone, so a pane that becomes a file pane instead would hold - // the dead fd for the life of the app. + // free the slot. reap(st, e.pane); st.core.update(.{ .eof = .{ .pane = e.pane } }); }, } } - st.tick_msgs.clearRetainingCapacity(); if (drainEffects(st, true)) changed = true; // A live theme transition repaints on its own clock; say so, or the host // stops ticking and the fade freezes half-applied. @@ -435,7 +494,7 @@ export fn pardes_frame() u32 { // The three accessors below must never describe a different frame than the // count this returns, so a failure empties all of them together rather than // leaving last frame's buffer behind a fresh cols/rows. - st.cells.clearRetainingCapacity(); + st.frame_len = 0; st.frame_cols = 0; st.frame_rows = 0; const surface = st.core.render(st.arena.allocator()) catch |err| { @@ -443,10 +502,22 @@ export fn pardes_frame() u32 { return 0; }; const count: usize = @as(usize, surface.cols) * surface.rows; - st.cells.resize(st.gpa, count) catch return 0; + if (count != st.cells.len) { + if (count == 0) { + if (st.cells.len > 0) st.gpa.free(st.cells); + st.cells = &.{}; + } else { + const resized = if (st.cells.len == 0) + st.gpa.alloc(Cell, count) + else + st.gpa.realloc(st.cells, count); + st.cells = resized catch return 0; + } + } + st.frame_len = count; st.frame_cols = surface.cols; st.frame_rows = surface.rows; - for (surface.cells, st.cells.items) |cell, *out| { + for (surface.cells, st.cells[0..count]) |cell, *out| { out.* = .{ .text = @splat(0), .fg = encodeColor(cell.style.fg), @@ -462,7 +533,7 @@ export fn pardes_frame() u32 { export fn pardes_frame_cells() ?[*]const Cell { const st = &(state orelse return null); - return if (st.cells.items.len == 0) null else st.cells.items.ptr; + return if (st.frame_len == 0) null else st.cells.ptr; } export fn pardes_frame_cols() u16 { -- cgit v1.3