I am working on a video game in zig and today, I had a weird bug. I’ve found and fixed it, but while doing so, I’ve also found something weird. It seems like a case of unchecked undefined behaviour, or maybe even a compiler bug, but I am not sure.
I was able to get a small example working (with some difficulty) to showcase this:
const std = @import("std");
const State = union(enum) { idle, busy };
pub fn main() !void {
var array = [_]State{ .idle, .idle };
for (0..2) |_| {
foo(&array);
bar(&array);
}
}
fn foo(data: []State) void {
const log = std.log.scoped(.foo);
for (data, 0..) |*s, i| {
// log.info("pre {}", .{i});
if (s.* == .idle) {
log.info("{}: {} -> .busy", .{ i, s });
s.* = .busy;
}
}
}
fn bar(data: []State) void {
const log = std.log.scoped(.bar);
for (data, 0..) |*s, i| {
// log.info("pre {}", .{i});
if (s.* == .busy) {
log.info("{}: {} -> .idle", .{ i, s });
s.* = .idle;
}
}
}
basically, functions foo
and bar
switch all the .idle
elements to .busy
and back.
In debug mode, it does exactly what it should.
$ zig run -ODebug union_enum_bug.zig
info(foo): 0: union_enum_bug.State{ .idle = void } -> .busy
info(foo): 1: union_enum_bug.State{ .idle = void } -> .busy
info(bar): 0: union_enum_bug.State{ .busy = void } -> .idle
info(bar): 1: union_enum_bug.State{ .busy = void } -> .idle
info(foo): 0: union_enum_bug.State{ .idle = void } -> .busy
info(foo): 1: union_enum_bug.State{ .idle = void } -> .busy
info(bar): 0: union_enum_bug.State{ .busy = void } -> .idle
info(bar): 1: union_enum_bug.State{ .busy = void } -> .idle
trouble starts when compiling with -OReleaseSafe
:
$ zig run -OReleaseSafe union_enum_bug.zig
info(foo): 0: union_enum_bug.State{ .idle = void } -> .busy
info(foo): 1: union_enum_bug.State{ .idle = void } -> .busy
info(bar): 0: union_enum_bug.State{ .busy = void } -> .idle
info(foo): 0: union_enum_bug.State{ .idle = void } -> .busy
info(bar): 0: union_enum_bug.State{ .busy = void } -> .idle
that’s not the same output.
I wanted to check if the loop even ran for data[1]
as it isn’t shown in the output outside of the first call to foo
, so I put a log.info
before the condition in both functions.
Somehow, that causes both Debug and ReleaseSafe to give the same output (the correct one).
$ zig run -OReleaseSafe union_enum_bug.zig
info(foo): pre 0
info(foo): 0: union_enum_bug.State{ .idle = void } -> .busy
info(foo): pre 1
info(foo): 1: union_enum_bug.State{ .idle = void } -> .busy
info(bar): pre 0
info(bar): 0: union_enum_bug.State{ .busy = void } -> .idle
info(bar): pre 1
info(bar): 1: union_enum_bug.State{ .busy = void } -> .idle
info(foo): pre 0
info(foo): 0: union_enum_bug.State{ .idle = void } -> .busy
info(foo): pre 1
info(foo): 1: union_enum_bug.State{ .idle = void } -> .busy
info(bar): pre 0
info(bar): 0: union_enum_bug.State{ .busy = void } -> .idle
info(bar): pre 1
info(bar): 1: union_enum_bug.State{ .busy = void } -> .idle
This is why I think undefined behaviour might be at least involved (spooky action a distance).
The solution was to change the type of State
from union(enum)
to just enum
. That gives the expected result for all of the above. In my original program, I’ve used union(enum)
because I thought I will need it. Well, I didn’t and didn’t notice it until I trying to compile with ReleaseSafe and this showed up.