Skip to content

fix: stop batch commands from dangling on return - #134

Merged
meszmate merged 1 commit into
mainfrom
fix/batch-slice-lifetime
Aug 14, 2026
Merged

meszmate merged 1 commit into
mainfrom
fix/batch-slice-lifetime

Conversation

@meszmate

Copy link
Copy Markdown
Owner

Found while writing the model test harness (#133): a batch built the obvious way came back as a completely different command.

The bug

Cmd.batch holds a []const Cmd(Msg) that the runtime reads after update has returned. A &.{ ... } literal only lands in static memory when every element is comptime-known. One runtime value in there and the array becomes a stack temporary, gone by the time the runtime walks it.

Minimal reproduction:

fn allComptime() C {
    return .{ .batch = &.{ .{ .set_title = "T" }, .{ .every = 100 } } };
}

fn withRuntimeValue(n: u64) C {
    return .{ .batch = &.{ .{ .set_title = "T" }, .{ .every = n } } };
}
comptime batch ptr=1010236a0     <- static, reads back fine
runtime  batch ptr=16ef69d48     <- stack
thread panic: invalid enum value

There is no diagnostic — the slice just points at reused stack memory. Depending on what lands there, a batch silently executes the wrong commands or aborts on an invalid union tag.

examples/hello_world.zig hit this exactly: the 'c' key path built a batch containing layout.size_cells, layout.row and layout.col, all runtime values.

The fix

The example now keeps its two commands in a field on the model, which outlives the call:

pending_batch: [2]zz.Cmd(Msg) = undefined,
// ...
self.pending_batch = .{ cache_cmd, place_cmd };
return .{ .batch = &self.pending_batch };

For cases where model-owned storage does not fit, Cmd.batchAlloc / Cmd.sequenceAlloc copy into an allocator:

return try zz.Cmd(Msg).batchAlloc(ctx.allocator, &.{ ... });

The frame allocator is the right one: commands are processed during the tick that produced them, and it is reset on the next.

The lifetime rule is now documented on the batch and sequence union fields and in the reference, since the type signature cannot express it.

Tests

tests/command_tests.zig builds each shape inside a function, returns it, deliberately overwrites 8 KB of stack, and then reads the batch back. The batchAlloc, sequenceAlloc, model-owned and all-comptime forms survive; the raw runtime literal is what does not, and is the case being steered away from.

zig build test and zig build clean on 0.16.0.

`Cmd.batch` holds a slice that the runtime reads after `update` has
returned. `&.{ ... }` only lands in static memory when every element is
comptime-known; one runtime value and the array is a stack temporary
that is gone by the time the runtime walks it. Reading it back gives an
invalid union tag and aborts.

`examples/hello_world.zig` did exactly this: the 'c' key path built a
batch containing `layout.size_cells` and friends. It now keeps the two
commands in a field on the model, which outlives the call.

Adds `Cmd.batchAlloc`/`Cmd.sequenceAlloc`, which copy into an allocator
-- the frame allocator being the right one, since commands are processed
during the tick that produced them. The lifetime rule is documented on
the union fields and in the reference.

Found while writing the model test harness: a batch built the obvious
way came back as the wrong command entirely.
@meszmate
meszmate merged commit 9d8611c into main Aug 14, 2026
9 checks passed
@meszmate
meszmate deleted the fix/batch-slice-lifetime branch August 28, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant