From 0285c0d93ffc3a0fb166e32494ba13e14c5d6ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Wed, 18 Feb 2026 08:23:10 +0100 Subject: [PATCH] fix: defer arena allocator binding to avoid dangling pointer in ctx.allocator --- README.md | 7 +++++++ build.zig | 1 + src/core/program.zig | 18 ++++++++++++------ tests/program_tests.zig | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 tests/program_tests.zig diff --git a/README.md b/README.md index 6f9df45..22ee5a0 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,13 @@ By default (`null`/`auto`), ZigZag: - probes kitty text-sizing support, - applies terminal/multiplexer heuristics (e.g. tmux/screen/zellij favor legacy width). +### Allocator Lifetimes + +`ctx.allocator` is a frame allocator that is reset before each `tick()`. +Use it for temporary values (render strings, per-frame buffers). + +For model state that must live across frames, allocate with `ctx.persistent_allocator`. + ### Custom Event Loop For applications that need to do other work between frames (network polling, background processing, etc.), use `start()` + `tick()` instead of `run()`: diff --git a/build.zig b/build.zig index 07756c9..464a6cd 100644 --- a/build.zig +++ b/build.zig @@ -55,6 +55,7 @@ pub fn build(b: *std.Build) void { "tests/input_tests.zig", "tests/layout_tests.zig", "tests/unicode_tests.zig", + "tests/program_tests.zig", }; const test_step = b.step("test", "Run unit tests"); diff --git a/src/core/program.zig b/src/core/program.zig index 670d9d8..4f6a04a 100644 --- a/src/core/program.zig +++ b/src/core/program.zig @@ -66,14 +66,15 @@ pub fn Program(comptime Model: type) type { /// Initialize with custom options pub fn initWithOptions(allocator: std.mem.Allocator, options: Options) !Self { - var arena = std.heap.ArenaAllocator.init(allocator); - + const arena = std.heap.ArenaAllocator.init(allocator); const self = Self{ .allocator = allocator, .arena = arena, .model = undefined, .terminal = null, - .context = Context.init(arena.allocator(), allocator), + // `self` is returned by value, so don't capture an arena allocator here. + // It would point at this function's stack copy and dangle after return. + .context = Context.init(allocator, allocator), .options = options, .running = false, .start_time = std.time.nanoTimestamp(), @@ -172,6 +173,8 @@ pub fn Program(comptime Model: type) type { self.context.kitty_text_sizing = width_caps.kitty_text_sizing; unicode.setWidthStrategy(effective_width_strategy); + self.resetFrameAllocator(); + // Initialize the model const init_cmd = self.model.init(&self.context); try self.processCommand(init_cmd); @@ -206,9 +209,7 @@ pub fn Program(comptime Model: type) type { self.context.elapsed = @intCast(self.last_frame_time - self.start_time); self.context.frame += 1; - // Reset arena for this frame - _ = self.arena.reset(.retain_capacity); - self.context.allocator = self.arena.allocator(); + self.resetFrameAllocator(); // Check for resize if (self.terminal.?.checkResize()) { @@ -483,6 +484,11 @@ pub fn Program(comptime Model: type) type { } } + fn resetFrameAllocator(self: *Self) void { + _ = self.arena.reset(.retain_capacity); + self.context.allocator = self.arena.allocator(); + } + fn render(self: *Self) !void { const view_output = self.model.view(&self.context); diff --git a/tests/program_tests.zig b/tests/program_tests.zig new file mode 100644 index 0000000..7a9db91 --- /dev/null +++ b/tests/program_tests.zig @@ -0,0 +1,35 @@ +const std = @import("std"); +const testing = std.testing; +const zz = @import("zigzag"); + +const DummyModel = struct { + pub const Msg = union(enum) { + nop: void, + }; + + pub fn init(_: *DummyModel, _: *zz.Context) zz.Cmd(Msg) { + return .none; + } + + pub fn update(_: *DummyModel, _: Msg, _: *zz.Context) zz.Cmd(Msg) { + return .none; + } + + pub fn view(_: *const DummyModel, _: *const zz.Context) []const u8 { + return ""; + } +}; + +test "Program.init context allocator is stable before start and can be rebound to arena" { + var program = try zz.Program(DummyModel).init(testing.allocator); + defer program.deinit(); + + const backing_ptr = @intFromPtr(testing.allocator.ptr); + const init_context_allocator_ptr = @intFromPtr(program.context.allocator.ptr); + try testing.expectEqual(backing_ptr, init_context_allocator_ptr); + + program.context.allocator = program.arena.allocator(); + const arena_ptr = @intFromPtr(&program.arena); + const rebound_context_allocator_ptr = @intFromPtr(program.context.allocator.ptr); + try testing.expectEqual(arena_ptr, rebound_context_allocator_ptr); +}