Closes #70.
src/log.zig was written complete — levels, JSON, a filter, a mutex, file output — and then never configured by anything. The issue asks to put a flag in front of it.
Doing only that would have produced an empty file. The module has two callers (devmode.zig, hotreload.zig); craft's actual diagnostics go through std.log and logging.zig, neither of which it has ever seen.
What lands in the file, and how
| source | mechanism | in the file? |
|---|---|---|
std.log.* (~200 sites, 50 files) | pub const std_options → craftLogFn in the exe root | yes — across module boundaries |
logging.zig scoped loggers | its existing .target = .callback hook | yes — zero call sites touched |
log.zig direct callers | already direct | yes |
std.debug.print | none — goes through std.Options.debug_io | no, and --help says so |
The log_level = .debug already declared in minimal.zig turns out to be load-bearing: std.log.debug is compiled out at comptime below the threshold, and most of craft's logging is debug level. Without it --log-level debug would be a flag that could not do anything.
Wiring it up is what made its defects reachable
Seven, all on the --log-file path, none previously reachable because nothing configured the module:
- Messages over 2048 bytes were dropped whole, not truncated —
bufPrint(...) catch return. The longest records are usually the ones worth reading, and nothing said anything was missing. - The file was written from offset zero every run.
CreateFileOptionshas no append flag in this Zig andwriteStreamingAllstarts at position 0, so each launch overwrote the previous one and left anything longer stranded past the end. Now seeded fromstat().sizeand advanced withwritePositionalAll. initkept the caller's slices foroutput_fileandfilter_pattern, andminimal.zigfrees the command-line strings during startup — a use-after-free that had simply never been reachable.- A reentrancy hang.
compat_mutexfalls back to a non-recursive spinlock that never yields (std.Thread.Mutexdoesn't exist in this Zig), andlogholds it across the write. Astd.logcall from insidelogwould spin against a lock the same thread holds, forever — no panic, no stack. Inert while nothing logged through it; live the moment 200 call sites do. - The timestamp had no date.
--log-fileexists to leave a trail read afterwards; a time alone can't say which day. Now ISO-8601 UTC — and the comment claiminglocaltime_r(which isn't instd.chere, and whose result the code discarded via a dead_ = t;) no longer claims it. - Bytes over 0x7f went into JSON verbatim, so one bad byte from a filename or truncated frame made the line unparseable.
std.c.clock_gettimedoesn't compile for Windows here. The file only ever built because nothing instantiatedlog().
That's the third time this session that wiring something up is what exposed it. It is the argument for the flag and the fixes landing together rather than the flag landing alone.
Tests
The module did have tests — test/log_test.zig — which reach it across a module boundary and therefore cannot touch the config storage or the formatter, the three places it was wrong. Twelve more now run against the source directly, covering truncation, append across runs, string ownership, level filtering, JSON under hostile input, invalid UTF-8, stderr suppression, re-init, the date, and the reentrancy guard.
Verified end to end, not just compiled
$ craft --html-file p.html --headless --log-file f.log
$ cat f.log
[2026-08-27T12:02:08Z] WARN refused to open a new window for a URL the page
cannot send to the browser: craft-probe://payloadThat record originates from a std.log.warn in macos.zig — a different module from the root — which is what proves the cross-module routing works. Also confirmed by running the binary:
| behaviour | result |
|---|---|
| two runs append | 2 records (the old code left 1) |
--log-json | parses as JSON |
--log-quiet | in file: 1, on stderr: 0 |
--log-level error | the warn is filtered out |
An earlier probe produced an empty file and I nearly reported success on "the flag works" — the file existed and the build was green. It was empty because the handler override hadn't actually landed. Forcing a known std.log.warn and finding it still absent is what caught it.
Verified
zig build · zig build test · zig build test-js · x86_64-windows cross-build · zig fmt --check · tsc --noEmit · 511 bun tests · pickier 38 warnings, unchanged from main.