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.