Weird behaviour of union(enum)

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.

3 Likes

Which version of Zig are you using? I can’t reproduce on 0.12.0-dev.2139+e025ad7b4 (aarch64 macOS).

If you’re using an older version of Zig, try updating, this bug might have been fixed already.

I’ve updated today to 0.12.0-dev.2334+aef1da163 (linux x86_64).

I looked into it, and this is definitely a miscompilation. It seems to store the wrong byte value in release:

const std = @import("std");

const State = union(enum) { idle, busy };

pub fn main() !void {
    var array = [_]State{ .idle, .idle, .busy };
    array[1] = .busy;
    std.log.err("Data {any}", .{
    	std.mem.sliceAsBytes(array[0..]),
    }); // Prints { 0, 255, 1 }
}
1 Like

I can reproduce and it seems to work correctly if you initialize with .{ .idle = {} } instead.

const std = @import("std");

const State = union(enum) { idle, busy };

pub fn main() !void {
    var array = [_]State{ .idle, .idle };

    for (0..2) |_| {
        foo(array[0..]);
        bar(array[0..]);
    }
}

fn foo(data: []State) void {
    for (data, 0..) |*s, i| {
        if (s.* == .idle) {
            std.debug.print("{}: {} -> .busy\n", .{ i, s.* });
            s.* = .{ .busy = {} };
        }
    }
}

fn bar(data: []State) void {
    for (data, 0..) |*s, i| {
        if (s.* == .busy) {
            std.debug.print("{}: {} -> .idle\n", .{ i, s.* });
            s.* = .{ .idle = {} };
        }
    }
}

Then I think this would be a great GitHub Issue. You have a simple reproduction and a behavior that is fairly clearly wrong from the compiler.

2 Likes

The tagged union members do not have payloads. As a workaround change it to enum.

const State = enum {idle, busy};

Hope it helps.