a73x

52e5cdc1

scenario: plug capture leaks + thread CellGeom + distinct errors

a73x   2026-04-19 09:42

Critical fixes:
  - Capture branch now OOM-safe: all allocations complete before
    committing to the captures map; errdefers clean up on failure.
  - Duplicate-label capture frees the prior image + reuses old key.
  - last_capture_label UAF closed (dupe before freeing old).

Important fixes:
  - AssertCellWithoutCapture is a distinct error from AssertFailed.
  - CellGeom threads through ScenarioState.init for Plan 3 use.

Minor fix:
  - assert-cell-at bounds-check diagnostic now says the right name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/src/scenario.zig b/src/scenario.zig
index 1f6007a..e0dbfad 100644
--- a/src/scenario.zig
+++ b/src/scenario.zig
@@ -434,7 +434,7 @@ pub fn parse(
                    return error.ParseFailed;
                };
                if (row >= rows or col >= cols) {
                    diag.* = .{ .line = line_num, .message = "assert-cell row/col out of bounds for scenario size" };
                    diag.* = .{ .line = line_num, .message = "assert-cell-at row/col out of bounds for scenario size" };
                    return error.ParseFailed;
                }
                try directives.append(alloc, .{ .assert_cell_at = .{
@@ -781,6 +781,7 @@ pub const TickError = error{
    ScenarioTimeout,
    SleepUntilFlipTimeout,
    AssertFailed,
    AssertCellWithoutCapture,
    PredicateOnMissingLabel,
    CallbackFailed,
} || std.mem.Allocator.Error;
@@ -799,12 +800,14 @@ pub const ScenarioState = struct {
    sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive
    captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG
    last_capture_label: ?[]const u8, // most recently captured label (owned by alloc)
    cell_geom: CellGeom, // renderer cell dimensions for assert predicates
    alloc: std.mem.Allocator,

    pub fn init(
        alloc: std.mem.Allocator,
        scenario: *const Scenario,
        origin_ns: i128,
        cell_geom: CellGeom,
    ) ScenarioState {
        return .{
            .scenario = scenario,
@@ -815,6 +818,7 @@ pub const ScenarioState = struct {
            .sleep_until_flip_started_ns = null,
            .captures = .{},
            .last_capture_label = null,
            .cell_geom = cell_geom,
            .alloc = alloc,
        };
    }
@@ -883,23 +887,39 @@ pub const ScenarioState = struct {
                        std.log.warn("scenario: capture callback failed: {s}", .{@errorName(err)});
                        return error.CallbackFailed;
                    };
                    // Dupe the label into ScenarioState.alloc so captures outlive the arena.
                    const label_copy = try self.alloc.dupe(u8, label);
                    errdefer self.alloc.free(label_copy);
                    try self.captures.put(self.alloc, label_copy, img);
                    // Update last_capture_label: free previous, dupe new from original label.
                    if (self.last_capture_label) |old| self.alloc.free(old);
                    self.last_capture_label = try self.alloc.dupe(u8, label);
                    errdefer self.alloc.free(img.pixels);

                    // Dupe label for both the map key and last_capture_label. Do both
                    // allocs BEFORE committing to the map so any OOM unwinds cleanly.
                    const key_dup = try self.alloc.dupe(u8, label);
                    errdefer self.alloc.free(key_dup);
                    const new_last = try self.alloc.dupe(u8, label);
                    errdefer self.alloc.free(new_last);

                    // Reserve the map slot. getOrPut may OOM; errdefers above handle it.
                    const gop = try self.captures.getOrPut(self.alloc, key_dup);

                    // Past here: no more OOM-able operations — commit everything atomically.
                    if (gop.found_existing) {
                        // Free old pixel buffer and our freshly-allocated key_dup;
                        // the map retains the existing key slot.
                        self.alloc.free(gop.value_ptr.pixels);
                        self.alloc.free(key_dup);
                    }
                    gop.value_ptr.* = img;

                    if (self.last_capture_label) |old_last| self.alloc.free(old_last);
                    self.last_capture_label = new_last;

                    self.cursor += 1;
                },
                .assert_cell => |ac| {
                    const lbl = self.last_capture_label orelse {
                        std.debug.print("scenario: assert-cell with no prior capture\n", .{});
                        return error.AssertFailed;
                        return error.AssertCellWithoutCapture;
                    };
                    const img = self.captures.get(lbl).?;
                    const geom = CellGeom{ .cell_w_px = 8, .cell_h_px = 16 };
                    const result = evalPredicate(img, ac.row, ac.col, geom, ac.pred, null);
                    const result = evalPredicate(img, ac.row, ac.col, self.cell_geom, ac.pred, null);
                    if (!result.pass) {
                        std.debug.print("scenario: assert-cell ({d},{d}) {s} failed: {s}\n", .{
                            ac.row, ac.col, @tagName(ac.pred), result.reason,
@@ -912,8 +932,7 @@ pub const ScenarioState = struct {
                    const img = self.captures.get(aca.label) orelse {
                        return error.PredicateOnMissingLabel;
                    };
                    const geom = CellGeom{ .cell_w_px = 8, .cell_h_px = 16 };
                    const result = evalPredicate(img, aca.row, aca.col, geom, aca.pred, null);
                    const result = evalPredicate(img, aca.row, aca.col, self.cell_geom, aca.pred, null);
                    if (!result.pass) {
                        std.debug.print("scenario: assert-cell-at {s} ({d},{d}) {s} failed: {s}\n", .{
                            aca.label, aca.row, aca.col, @tagName(aca.pred), result.reason,
@@ -986,7 +1005,7 @@ test "tick: empty scenario is immediately done" {
        \\timeout 1000ms
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    try std.testing.expect(state.isDone());
@@ -999,7 +1018,7 @@ test "tick: bytes directive fires write_bytes immediately" {
        \\bytes "abc"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
@@ -1019,7 +1038,7 @@ test "tick: sleep holds advancement until time passes" {
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
@@ -1048,7 +1067,7 @@ test "tick: capture calls io.capture and stores the result under the label" {
        \\capture snap1
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
@@ -1069,7 +1088,7 @@ test "tick: scenario timeout fires" {
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
@@ -1088,7 +1107,7 @@ test "tick: sleep-until-flip holds until flip callback returns true" {
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
@@ -1114,7 +1133,7 @@ test "tick: sleep-until-flip times out after 2x blink_period_ns of real wait" {
        \\sleep-until-flip
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
@@ -1152,7 +1171,7 @@ test "tick: write_bytes callback failure surfaces as CallbackFailed" {
        \\bytes "x"
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var ctx: u8 = 0;
@@ -1534,7 +1553,7 @@ test "tick+eval: assert-cell evaluates against last capture" {
        \\assert-cell 0 0 cursor-block-at
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
@@ -1553,7 +1572,7 @@ test "tick+eval: assert-cell-at on missing label errors" {
        \\assert-cell-at nope 0 0 cell-empty
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
@@ -1562,3 +1581,39 @@ test "tick+eval: assert-cell-at on missing label errors" {
    const r = state.tick(0, tio.io());
    try std.testing.expectError(error.PredicateOnMissingLabel, r);
}

test "tick: duplicate capture label frees the prior image" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture snap
        \\capture snap
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    _ = try state.tick(0, tio.io());
    try std.testing.expect(state.isDone());
    // Testing allocator will catch any leak here on deinit.
}

test "tick: assert-cell with no prior capture errors distinctly" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell 0 0 cell-empty
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0, .{ .cell_w_px = 8, .cell_h_px = 16 });
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    const r = state.tick(0, tio.io());
    try std.testing.expectError(error.AssertCellWithoutCapture, r);
}