Hi,
I use zig to build a c++ JUCE project on a Mac M2 Silicon running Tahoe 26.6.2 and recently updated to Zig 0.17.
Today I had the following problem: I build the project but the executable didn’t get updated. After some research, Claude claimed that the problem lies in Zig.
I am not so proficient in Zig to judge for myself if the problem was indeed in Zig and I cannot send a bug report due to the no AI bug report policy, which is fine.
Because I benefit greatly from using Zig daily, I still wanted to provide some feedback, so I chose this forum. I asked for a minimal project that can recreate the problem, it is here. The problem was real, I am unsure about the cause.
Kind regards.
Zig 0.17.0: a header change is missed after a link-only rebuild
The problem
Zig keeps two kinds of cache notes for a C program:
- A note for each source file. It lists the source and every header the compiler read while compiling it, for example
main.candvalue.h. This note is correct. - A note for the whole build step (the executable or library). At the start of a build, zig checks this note first. If nothing listed in it has changed, zig reports the step as
cachedand does no work. It never opens the per-file notes.
The bug is in the second note. Sometimes zig must redo a step without compiling any of its sources, for example when a library it links has changed, so it only re-links. Zig then rewrites the step’s note with the source files only, and no headers. The header list comes from compiling, and nothing was compiled.
After that, the step’s note no longer mentions value.h. When value.h changes, zig checks the note, sees no listed file changed, reports cached, and keeps the old binary. The binary stays stale on every later build until .zig-cache is deleted.
The minimal project
| File | What it does |
|---|---|
value.h |
Defines VALUE |
main.c |
Includes value.h and prints VALUE |
other.c |
A static library with one function that returns a number. main.c prints its result too. |
build.zig |
Builds the library and an executable that links it |
The script
It creates the project in /tmp/zig-header-bug and runs two cases. It touches nothing outside /tmp.
(
d=/tmp/zig-header-bug
rm -rf "$d"
mkdir -p "$d"
cd "$d" || exit 1
cat > build.zig <<'EOF'
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const lib_mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true });
lib_mod.addCSourceFile(.{ .file = b.path("other.c") });
const lib = b.addLibrary(.{ .name = "other", .root_module = lib_mod, .linkage = .static });
const exe_mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true });
exe_mod.addCSourceFile(.{ .file = b.path("main.c") });
exe_mod.linkLibrary(lib);
const exe = b.addExecutable(.{ .name = "repro", .root_module = exe_mod });
b.installArtifact(exe);
}
EOF
cat > main.c <<'EOF'
#include <stdio.h>
#include "value.h"
int other(void);
int main(void)
{
printf("header=%d library=%d\n", VALUE, other());
return 0;
}
EOF
setHeader() { printf '#define VALUE %s\n' "$1" > value.h; }
setLib() { printf 'int other(void) { return %s; }\n' "$1" > other.c; }
run() {
sleep 1
zig build --summary all 2>&1 | grep -E 'compile exe repro|error'
printf ' output: '
./zig-out/bin/repro
}
echo "zig $(zig version)"
echo '=== CONTROL: header change only ==='
rm -rf .zig-cache zig-out
setHeader 1; setLib 1
echo '-- first build (expect header=1 library=1)'; run
setHeader 2
echo '-- header 1 -> 2 (expect header=2 library=1)'; run
echo '=== BUG: library change, then header change ==='
rm -rf .zig-cache zig-out
setHeader 1; setLib 1
echo '-- first build (expect header=1 library=1)'; run
setLib 2
echo '-- library 1 -> 2, program only re-linked (expect header=1 library=2)'; run
setHeader 2
echo '-- header 1 -> 2 (expect header=2 library=2)'; run
echo '-- the same build again, nothing changed (expect header=2 library=2)'; run
) 2>&1 | tee /tmp/zig-header-bug.txt
How to reproduce
- Open a terminal.
- Paste the whole script above, from the first
(to the last line, and press Return. - Read the output. It is also saved in
/tmp/zig-header-bug.txt.
The sleep 1 before each build keeps file times clearly apart, so the result cannot be a timing effect.
The same steps by hand, in the project folder:
- Run
zig build, then./zig-out/bin/repro. It printsheader=1 library=1. - Change
other.cto return 2, and build again. Zig re-links only, and the program printsheader=1 library=2. - Change
value.hto#define VALUE 2, and build again. Zig reportscached, and the program still printsheader=1.
Expected and actual
| Step | Expected | Actual |
|---|---|---|
| Control: change the header only | Recompile, header=2 |
Recompile, header=2 (correct) |
| Bug: change the library | Re-link, header=1 library=2 |
Re-link, header=1 library=2 (correct) |
| Bug: then change the header | Recompile, header=2 library=2 |
cached, header=1 library=2 |
| Bug: build again, no change | header=2 library=2 |
cached, header=1 library=2 |
Observed output (zig 0.17.0, aarch64-macos)
zig 0.17.0
=== CONTROL: header change only ===
-- first build (expect header=1 library=1)
+- compile exe repro debug native success 125ms MaxRSS:54M
output: header=1 library=1
-- header 1 -> 2 (expect header=2 library=1)
+- compile exe repro debug native success 133ms MaxRSS:54M
output: header=2 library=1
=== BUG: library change, then header change ===
-- first build (expect header=1 library=1)
+- compile exe repro debug native success 118ms MaxRSS:54M
output: header=1 library=1
-- library 1 -> 2, program only re-linked (expect header=1 library=2)
+- compile exe repro debug native success 81ms MaxRSS:49M
output: header=1 library=2
-- header 1 -> 2 (expect header=2 library=2)
+- compile exe repro debug native cached 62ms MaxRSS:30M
output: header=1 library=2
-- the same build again, nothing changed (expect header=2 library=2)
+- compile exe repro debug native cached 62ms MaxRSS:30M
output: header=1 library=2
Why it matters
The build reports success and the output looks fine, but it is built from old headers. In a real project, the common trigger is a change in a linked library while the program’s own sources stay the same. A later header change then ships silently as old code.
Workarounds
- Clean build: delete
.zig-cachebefore a build that must be correct. - Header stamp: a build step hashes every project header into a small generated source file (comments only) that each affected step compiles. Zig hashes sources by content, so a header change becomes a source change that zig cannot miss. Unchanged headers give the same file, so nothing is recompiled; the cost is a re-link of about half a second.