Zig Notes

zig caveats
Login

zig caveats

  1. defer can't modify returned values
  2. zig std syscalls wrappers might have undefined behavior on certain returns
  3. zig build's default test runner chokes if a test writes to stdout
  4. zig fetch is not enough, to use a zig dependency
  5. zig compiles C code much more strictly than normal
  6. The left hand side of an assignment is evaluated first

defer can't modify returned values

const Lock = struct {
    locked: bool,
    pub fn lock(self: *Lock) void {
        self.locked = true;
    }
    pub fn unlock(self: *Lock) void {
        self.locked = false;
    }
};
fn g() Lock {
    var it = Lock{ .locked = false };
    it.lock();
    defer it.unlock();
    return it;
}
test {
    try std.testing.expectEqual(false, g().locked); // fails!
}

What happens is that returned value is written to the result location, then the defer runs and - in this case - performs a useless modification of a dead stack location. That location isn't reused so the modification isn't corrupting memory, but the modified value is no longer participating in the logic of the program in any way, it's dead, and the intended modification never happens.

If the returned value isn't pointing to the stack, or if multiple defers use it, then changes can still be meaningful.

Deferred modifications to the stack will only affect deferred code. You'll most likely run afoul of this when calling method-like calls on non-const stack variables. Such calls don't necessarily modify the stack, but since you would have to inspect remote code to confirm that, and since that code could change later, the simple rule is to either

zig std syscalls wrappers might have undefined behavior on certain returns

Consider std.posix.renameat:

    switch (errno(system.renameat(old_dir_fd, old_path, new_dir_fd, new_path))) {
        .SUCCESS => return,
        .ACCES => return error.AccessDenied,
        .PERM => return error.AccessDenied,
        .BUSY => return error.FileBusy,
        .DQUOT => return error.DiskQuota,
        .FAULT => unreachable,
        .INVAL => unreachable,
        .ISDIR => return error.IsDir,
        .LOOP => return error.SymLinkLoop,

You can get EINVAL as easily as trying to rename a file on a filesystem that doesn't support one of the flags that you provided. C will return the error code and you can deal with that. Zig, with this code, will crash.

Rationale:

  1. Errors that represent programmer error should be treated as programmer errors - by crashing.
  2. std.posix is the high level interface. If you want raw syscalls you can find those as well, at std.os.linux and friends.

zig build's default test runner chokes if a test writes to stdout

Issue 15091

Consider a minimal Zig project with this build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const testexe = b.addTest(.{
        .name = "unit_tests",
        .root_source_file = b.path("hello.zig"),
        .target = b.host,
    });
    const testrun = b.addRunArtifact(testexe);
    const teststep = b.step("test", "Run tests");
    teststep.dependOn(&testrun.step);
}

and this hello.zig:

const std = @import("std");
const stdout = std.io.getStdOut().writer();

test "hello?" {
    for (0..5) |_| {
        try stdout.print("Hello, world!\n", .{});
    }
}

Although this test will run fine from zig test,

$ zig test hello.zig 
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
All 1 tests passed.

it gets wedged, leaving this on the screen, when run with zig build test:

$ zig build test
[1/3] steps
└─ [0/1] run unit_tests
   └─ hello.test.hello?

zig fetch is not enough, to use a zig dependency

If you're familiar with other build systems - cargo, dub, nimble - you probably expect that a command like the following,

$ zig fetch --save "https://github.com/mnemnion/ztap/archive/refs/tags/v0.8.1.tar.gz"

that modifies your build.zig.zon, is all you need to do to start @import("ztap")'ing in your code. This is incorrect:

$ zig build test
test
└─ run test
   └─ zig test Debug native 1 errors
.../src/ztap-runner.zig:3:22: error: no module named 'ztap' available within module root
const ztap = @import("ztap");
                     ^~~~~~

You also need to modify your build.zig to specify exactly how and where and for what steps to use the import. The build.zig.zon changes only tell Zig how to find and verify that dependency when build.zig needs it.

To continue the ztap example, you'd need something like this:

    const exe_unit_tests = b.addTest(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .test_runner = b.path("src/ztap-runner.zig"),
    });

    const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);

    if (b.lazyDependency("ztap", .{
        .target = target,
        .optimize = optimize,
    })) |ztap_dep| {
        exe_unit_tests.root_module.addImport("ztap", ztap_dep.module("ztap"));
    }

zig compiles C code much more strictly than normal

This could be over at zig gems instead, but you'll most likely encounter this as a surprising error with some C code. This blog post describes the issue, and links to other encounters with it.

In short, if your C code is performing some standard-undefined behavior, and you don't want to fix that, you can disable it per file in this manner:

exe.addCSourceFile("badfile.c", &.{"-fno-sanitize=undefined"});

The left hand side of an assignment is evaluated first

This test fails with a segfault on the assignment to a dead location. With a different allocator the assignment could silently corrupt memory.

const std = @import("std");

fn force_realloc(a: *std.ArrayList(u32)) !u32 {
    for (1..1e6) |i| try a.append(@intCast(i));
    return 1234;
}

test {
    var a = std.ArrayList(u32).init(std.testing.allocator);
    try a.append(4321);
    a.items[0] = try force_realloc(&a);
    try std.testing.expectEqual(1234, a.items[0]);
}

This is a very common issue, not restricted to Zig and also not caused by any distinctive Zig features.