Migrating cmdtest from Zig 0.15 to 0.16
I recently ran zig build test on cmdtest and got hit with a wall of
compilation errors.
[*] mise x -- zig build test --summary all
test
└─ run test integration
└─ install
└─ install cmdtest
└─ compile exe cmdtest Debug native 1 errors
src/test_exe.zig:5:29: error: root source file struct 'process' has no member named 'argsWithAllocator'
var it = try std.process.argsWithAllocator(allocator);
~~~~~~~~~~~^~~~~~~~~~~~~~~~~~
.mise/installs/zig/0.16.0/lib/std/process.zig:1:1: note: struct declared here
const builtin = @import("builtin");
^~~~~
referenced by:
callMain [inlined]: .mise/installs/zig/0.16.0/lib/std/start.zig:698:59
callMainWithArgs [inlined]: .mise/installs/zig/0.16.0/lib/std/start.zig:638:20
posixCallMainAndExit: .mise/installs/zig/0.16.0/lib/std/start.zig:590:38
2 reference(s) hidden; use '-freference-trace=5' to see all references
error: 1 compilation errors
failed command: .../cmdtest/.mise/installs/zig/0.16.0/zig build-exe -Mroot=.../cmdtest/src/test_exe.zig --cache-dir .zig-cache --global-cache-dir /home/pyk/.cache/zig --name cmdtest --zig-lib-dir .mise/installs/zig/0.16.0/lib/ --listen=-
test
└─ run test integration
└─ compile test integration Debug native 4 errors
src/root.zig:51:33: error: root source file struct 'process' has no member named 'EnvMap'
env_map: ?*const std.process.EnvMap = null,
~~~~~~~~~~~^~~~~~~
.mise/installs/zig/0.16.0/lib/std/process.zig:1:1: note: struct declared here
const builtin = @import("builtin");
^~~~~
referenced by:
run: src/root.zig:87:12
test.run: echo: src/test.zig:8:29
src/root.zig:164:33: error: root source file struct 'process' has no member named 'EnvMap'
env_map: ?*const std.process.EnvMap = null,
~~~~~~~~~~~^~~~~~~
...
error: the following build command failed with exit code 1:
.zig-cache/o/055203a97cbf37c65a834016518e7acc/build .../cmdtest/.mise/installs/zig/0.16.0/zig .mise/installs/zig/0.16.0/lib .../cmdtest .zig-cache /home/pyk/.cache/zig --seed 0xf8768ad1 -Z4e794eb486e463a4 test --summary all
~/github/pyk/cmdtest main
[*] make test
test
└─ run test integration
└─ install
└─ install cmdtest
└─ compile exe cmdtest Debug native 1 errors
src/test_exe.zig:5:29: error: root source file struct 'process' has no member named 'argsWithAllocator'
var it = try std.process.argsWithAllocator(allocator);
~~~~~~~~~~~^~~~~~~~~~~~~~~~~~
.mise/installs/zig/0.16.0/lib/std/process.zig:1:1: note: struct declared here
const builtin = @import("builtin");
^~~~~
referenced by:
callMain [inlined]: .mise/installs/zig/0.16.0/lib/std/start.zig:698:59
callMainWithArgs [inlined]: .mise/installs/zig/0.16.0/lib/std/start.zig:638:20
posixCallMainAndExit: .mise/installs/zig/0.16.0/lib/std/start.zig:590:38
2 reference(s) hidden; use '-freference-trace=5' to see all references
error: 1 compilation errors
failed command: .../cmdtest/.mise/installs/zig/0.16.0/zig build-exe -Mroot=.../cmdtest/src/test_exe.zig --cache-dir .zig-cache --global-cache-dir /home/pyk/.cache/zig --name cmdtest --zig-lib-dir .mise/installs/zig/0.16.0/lib/ --listen=-
test
└─ run test integration
└─ compile test integration Debug native 4 errors
src/root.zig:51:33: error: root source file struct 'process' has no member named 'EnvMap'
env_map: ?*const std.process.EnvMap = null,
~~~~~~~~~~~^~~~~~~
.mise/installs/zig/0.16.0/lib/std/process.zig:1:1: note: struct declared here
const builtin = @import("builtin");
^~~~~
referenced by:
run: src/root.zig:87:12
test.run: echo: src/test.zig:8:29
src/root.zig:164:33: error: root source file struct 'process' has no member named 'EnvMap'
env_map: ?*const std.process.EnvMap = null,
~~~~~~~~~~~^~~~~~~
.mise/installs/zig/0.16.0/lib/std/process.zig:1:1: note: struct declared here
const builtin = @import("builtin");
^~~~~
src/test.zig:135:33: error: no field or member function named 'realpathAlloc' in 'Io.Dir'
const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, ".");
~~~~~~~^~~~~~~~~~~~~~
.mise/installs/zig/0.16.0/lib/std/Io/Dir.zig:1:1: note: struct declared here
const Dir = @This();
^~~~~
...Zig 0.16 had shipped, and nearly every function my library used had moved or changed signature.
cmdtest is a small library, about 300 lines across three source files. But it
touches a lot of I/O surface area: spawning child processes, reading stdout and
stderr, writing to stdin, and accessing the environment. The 0.15 to 0.16
transition turned those 300 lines into a deep dive into the new I/O
architecture.
Here is what changed and how I approached the migration.
I/O Gets Explicit
The single largest change in 0.16 is that I/O operations now require an Io
parameter. Every file.close(), file.writer(), child.wait(), and
process.spawn() call needs an explicit io instance.
In 0.15, you could do this:
var child = Child.init(argv, allocator);
try child.spawn();
const term = try child.wait();In 0.16, you pass io to everything:
const io = testing.io;
var child = try process.spawn(io, .{ .argv = argv });
defer child.kill(io);
const term = try child.wait(io);This felt verbose at first, but it makes sense. Zig’s Io abstracts over
threaded and evented backends. By threading io through the
call chain, your code works with any backend without changes.
cmdtest now follows the same convention. Both run and spawn take io as their
first parameter, matching the standard library’s design.
No More Init + Spawn in Child
The old API paired Child.init with a separate spawn call:
var child = Child.init(argv, allocator);
child.cwd = my_cwd;
child.env_map = &env;
child.stdin_behavior = .Pipe;
child.stdout_behavior = .Pipe;
try child.spawn();The new API folds everything into a single process.spawn call:
var child = try process.spawn(io, .{
.argv = argv,
.cwd = .{ .path = my_cwd },
.environ_map = &env,
.stdin = .pipe,
.stdout = .pipe,
.stderr = .pipe,
});The field names changed too. cwd is now a tagged union (Child.Cwd) —
either .inherit, .{ .path = "..." }, or .{ .dir = some_dir }. The
env_map field was renamed to environ_map, reflecting the new
std.process.Environ module.
No More collectOutput
The child.collectOutput helper is gone in 0.16. Instead, the standard library
provides Io.File.MultiReader, which reads from multiple pipes concurrently
using an internal batch system.
Here is how process.run does it internally, and the pattern I followed:
var multi_reader_buffer: Io.File.MultiReader.Buffer(2) = undefined;
var multi_reader: Io.File.MultiReader = undefined;
multi_reader.init(allocator, io, multi_reader_buffer.toStreams(), &.{
child.stdout.?,
child.stderr.?,
});
defer multi_reader.deinit();
const stdout_reader = multi_reader.reader(0);
const stderr_reader = multi_reader.reader(1);
while (multi_reader.fill(1, .none)) |_| {
// Check limits here
} else |err| switch (err) {
error.EndOfStream => {},
else => |e| return e,
}
try multi_reader.checkAnyError();
const term = try child.wait(io);
const stdout_slice = try multi_reader.toOwnedSlice(0);
const stderr_slice = try multi_reader.toOwnedSlice(1);The MultiReader reads from both pipes in one batch operation, removes the
need for manual select/poll-style code, and gives you owned slices at the
end. It also handles output limits cleanly.
Reader and Writer Now Take Io and Buffer
In 0.15, creating a writer looked like this:
var buf: [1024]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
var io = &writer.interface;In 0.16, the order changed and io comes first:
var buf: [1024]u8 = undefined;
var writer = std.Io.File.stdout().writer(io, &buf);
var w = &writer.interface;The same applies to readers:
var reader_buf: [1024]u8 = undefined;
var reader = child.stdout.?.reader(io, &reader_buf);
var r = &reader.interface;One thing that tripped me up: std.fs.File was renamed to std.Io.File.
std.fs is still around for path utilities, but the file handle type now lives
under Io.
Environment and Args
The process environment map changed from std.process.EnvMap to
std.process.Environ.Map:
// Old
var env = std.process.EnvMap.init(allocator);
try env.put("KEY", "value");
// New
var env = std.process.Environ.Map.init(allocator);
try env.put("KEY", "value");For command line arguments, std.process.argsWithAllocator is gone. In 0.16,
the main function receives an Init struct that contains everything:
// Old
pub fn main() !void {
var it = try std.process.argsWithAllocator(allocator);
defer it.deinit();
while (it.next()) |arg| { ... }
}
// New
pub fn main(init: std.process.Init) !void {
var it = try init.minimal.args.iterateAllocator(init.gpa);
defer it.deinit();
while (it.next()) |arg| { ... }
}The Init struct gives you access to args (minimal.args), environment
(environ_map), an allocator (gpa), and the io instance (io). This is
much cleaner than the old global approach.
Smaller Changes
-
Child.Termfields are now lowercase:.Exitedbecame.exited,.Signalbecame.signal. This matches the Zig convention of using lowercase for enum fields. -
std.mem.trimRightis nowstd.mem.trimEnd: A rename to clarify the function’s behavior. -
std.process.getCwdAllocis nowstd.process.currentPathAllocand requires anioparameter. -
Dir.realpathAllocis nowDir.realPathFileAllocand also needsio.
Passing testing.io Explicitly
For test code, testing.io provides a pre-initialized Io instance backed by
the threaded I/O implementation. You pass it directly to run and spawn:
test "echo: hello" {
const argv = &[_][]const u8{"echo", "hello"};
var result = try cmdtest.run(testing.io, .{ .argv = argv });
defer result.deinit();
try testing.expectEqualStrings("hello\n", result.stdout);
}Adding testing.io feels repetitive at first, but it makes the dependency
explicit. The same pattern also lets you use cmdtest with a custom Io
backend outside tests.
Thanks to DeepSeek V4 Flash
The migration took a few minutes of reading the standard library source to understand the new patterns, thanks to DeepSeek V4 Flash I can learn it much much faster now. My job is now reviewing the changes, make sure everything is correct.
| Tags | zig , cmdtest , migration |
|---|