std.Build: don't force all children to inherit color option

The build runner was previously forcing child processes to have their
stderr colorization match the build runner by setting `CLICOLOR_FORCE`
or `NO_COLOR`. This is a nice idea in some cases---for instance a simple
`Run` step which we just expect to exit with code 0 and whose stderr is
not being programmatically inspected---but is a bad idea in others, for
instance if there is a check on stderr or if stderr is captured, in
which case forcing color on the child could cause checks to fail.

Instead, this commit adds a field to `std.Build.Step.Run` which
specifies a behavior for the build runner to employ in terms of
assigning the `CLICOLOR_FORCE` and `NO_COLOR` environment variables. The
default behavior is to set `CLICOLOR_FORCE` if the build runner's output
is colorized and the step's stderr is not captured, and to set
`NO_COLOR` otherwise. Alternatively, colors can be always enabled,
always disabled, always match the build runner, or the environment
variables can be left untouched so they can be manually controlled
through `env_map`.

Notably, this fixes a failure when running `zig build test-cli` in a
TTY (or with colors explicitly enabled). GitHub CI hadn't caught this
because it does not request color, but Codeberg CI now does, and we were
seeing a failure in the `zig init` test because the actual output had
color escape codes in it due to 6d280dc.
This commit is contained in:
Matthew Lugg 2025-11-13 09:46:57 +00:00 committed by Alex Rønne Petersen
parent b38fb4bff3
commit c6b5945356
6 changed files with 76 additions and 19 deletions

View File

@ -443,11 +443,6 @@ pub fn main() !void {
} }
const ttyconf = color.detectTtyConf(); const ttyconf = color.detectTtyConf();
switch (ttyconf) {
.no_color => try graph.env_map.put("NO_COLOR", "1"),
.escape_codes => try graph.env_map.put("CLICOLOR_FORCE", "1"),
.windows_api => {},
}
const main_progress_node = std.Progress.start(.{ const main_progress_node = std.Progress.start(.{
.disable_printing = (color == .off), .disable_printing = (color == .off),
@ -1389,6 +1384,7 @@ fn workerMakeOneStep(
.thread_pool = thread_pool, .thread_pool = thread_pool,
.watch = run.watch, .watch = run.watch,
.web_server = if (run.web_server) |*ws| ws else null, .web_server = if (run.web_server) |*ws| ws else null,
.ttyconf = run.ttyconf,
.unit_test_timeout_ns = run.unit_test_timeout_ns, .unit_test_timeout_ns = run.unit_test_timeout_ns,
.gpa = run.gpa, .gpa = run.gpa,
}); });

View File

@ -118,6 +118,7 @@ pub const MakeOptions = struct {
// it currently breaks because `std.net.Address` doesn't work there. Work around for now. // it currently breaks because `std.net.Address` doesn't work there. Work around for now.
.wasm32 => void, .wasm32 => void,
}, },
ttyconf: std.Io.tty.Config,
/// If set, this is a timeout to enforce on all individual unit tests, in nanoseconds. /// If set, this is a timeout to enforce on all individual unit tests, in nanoseconds.
unit_test_timeout_ns: ?u64, unit_test_timeout_ns: ?u64,
/// Not to be confused with `Build.allocator`, which is an alias of `Build.graph.arena`. /// Not to be confused with `Build.allocator`, which is an alias of `Build.graph.arena`.

View File

@ -24,6 +24,21 @@ cwd: ?Build.LazyPath,
/// Override this field to modify the environment, or use setEnvironmentVariable /// Override this field to modify the environment, or use setEnvironmentVariable
env_map: ?*EnvMap, env_map: ?*EnvMap,
/// Controls the `NO_COLOR` and `CLICOLOR_FORCE` environment variables.
color: enum {
/// `CLICOLOR_FORCE` is set, and `NO_COLOR` is unset.
enable,
/// `NO_COLOR` is set, and `CLICOLOR_FORCE` is unset.
disable,
/// If the build runner is using color, equivalent to `.enable`. Otherwise, equivalent to `.disable`.
inherit,
/// If stderr is captured or checked, equivalent to `.disable`. Otherwise, equivalent to `.inherit`.
auto,
/// The build runner does not modify the `CLICOLOR_FORCE` or `NO_COLOR` environment variables.
/// They are treated like normal variables, so can be controlled through `setEnvironmentVariable`.
manual,
} = .auto,
/// When `true` prevents `ZIG_PROGRESS` environment variable from being passed /// When `true` prevents `ZIG_PROGRESS` environment variable from being passed
/// to the child process, which otherwise would be used for the child to send /// to the child process, which otherwise would be used for the child to send
/// progress updates to the parent. /// progress updates to the parent.
@ -525,7 +540,7 @@ pub fn setCwd(run: *Run, cwd: Build.LazyPath) void {
pub fn clearEnvironment(run: *Run) void { pub fn clearEnvironment(run: *Run) void {
const b = run.step.owner; const b = run.step.owner;
const new_env_map = b.allocator.create(EnvMap) catch @panic("OOM"); const new_env_map = b.allocator.create(EnvMap) catch @panic("OOM");
new_env_map.* = EnvMap.init(b.allocator); new_env_map.* = .init(b.allocator);
run.env_map = new_env_map; run.env_map = new_env_map;
} }
@ -806,6 +821,9 @@ fn make(step: *Step, options: Step.MakeOptions) !void {
} }
} }
man.hash.add(run.color);
man.hash.add(run.disable_zig_progress);
for (run.argv.items) |arg| { for (run.argv.items) |arg| {
switch (arg) { switch (arg) {
.bytes => |bytes| { .bytes => |bytes| {
@ -1130,6 +1148,7 @@ pub fn rerunInFuzzMode(
.thread_pool = undefined, // not used by `runCommand` .thread_pool = undefined, // not used by `runCommand`
.watch = undefined, // not used by `runCommand` .watch = undefined, // not used by `runCommand`
.web_server = null, // only needed for time reports .web_server = null, // only needed for time reports
.ttyconf = fuzz.ttyconf,
.unit_test_timeout_ns = null, // don't time out fuzz tests for now .unit_test_timeout_ns = null, // don't time out fuzz tests for now
.gpa = undefined, // not used by `runCommand` .gpa = undefined, // not used by `runCommand`
}, .{ }, .{
@ -1234,9 +1253,40 @@ fn runCommand(
var interp_argv = std.array_list.Managed([]const u8).init(b.allocator); var interp_argv = std.array_list.Managed([]const u8).init(b.allocator);
defer interp_argv.deinit(); defer interp_argv.deinit();
var env_map = run.env_map orelse &b.graph.env_map; var env_map: EnvMap = env: {
const orig = run.env_map orelse &b.graph.env_map;
break :env try orig.clone(gpa);
};
defer env_map.deinit();
const opt_generic_result = spawnChildAndCollect(run, argv, env_map, has_side_effects, options, fuzz_context) catch |err| term: { color: switch (run.color) {
.manual => {},
.enable => {
try env_map.put("CLICOLOR_FORCE", "1");
env_map.remove("NO_COLOR");
},
.disable => {
try env_map.put("NO_COLOR", "1");
env_map.remove("CLICOLOR_FORCE");
},
.inherit => switch (options.ttyconf) {
.no_color, .windows_api => continue :color .disable,
.escape_codes => continue :color .enable,
},
.auto => {
const capture_stderr = run.captured_stderr != null or switch (run.stdio) {
.check => |checks| checksContainStderr(checks.items),
.infer_from_args, .inherit, .zig_test => false,
};
if (capture_stderr) {
continue :color .disable;
} else {
continue :color .inherit;
}
},
}
const opt_generic_result = spawnChildAndCollect(run, argv, &env_map, has_side_effects, options, fuzz_context) catch |err| term: {
// InvalidExe: cpu arch mismatch // InvalidExe: cpu arch mismatch
// FileNotFound: can happen with a wrong dynamic linker path // FileNotFound: can happen with a wrong dynamic linker path
if (err == error.InvalidExe or err == error.FileNotFound) interpret: { if (err == error.InvalidExe or err == error.FileNotFound) interpret: {
@ -1273,12 +1323,7 @@ fn runCommand(
// Wine's excessive stderr logging is only situationally helpful. Disable it by default, but // Wine's excessive stderr logging is only situationally helpful. Disable it by default, but
// allow the user to override it (e.g. with `WINEDEBUG=err+all`) if desired. // allow the user to override it (e.g. with `WINEDEBUG=err+all`) if desired.
if (env_map.get("WINEDEBUG") == null) { if (env_map.get("WINEDEBUG") == null) {
// We don't own `env_map` at this point, so create a copy in order to modify it. try env_map.put("WINEDEBUG", "-all");
const new_env_map = arena.create(EnvMap) catch @panic("OOM");
new_env_map.hash_map = try env_map.hash_map.cloneWithAllocator(arena);
try new_env_map.put("WINEDEBUG", "-all");
env_map = new_env_map;
} }
} else { } else {
return failForeign(run, "-fwine", argv[0], exe); return failForeign(run, "-fwine", argv[0], exe);
@ -1377,7 +1422,7 @@ fn runCommand(
step.result_failed_command = null; step.result_failed_command = null;
try Step.handleVerbose2(step.owner, cwd, run.env_map, interp_argv.items); try Step.handleVerbose2(step.owner, cwd, run.env_map, interp_argv.items);
break :term spawnChildAndCollect(run, interp_argv.items, env_map, has_side_effects, options, fuzz_context) catch |e| { break :term spawnChildAndCollect(run, interp_argv.items, &env_map, has_side_effects, options, fuzz_context) catch |e| {
if (!run.failing_to_execute_foreign_is_an_error) return error.MakeSkipped; if (!run.failing_to_execute_foreign_is_an_error) return error.MakeSkipped;
if (e == error.MakeFailed) return error.MakeFailed; // error already reported if (e == error.MakeFailed) return error.MakeFailed; // error already reported
return step.fail("unable to spawn interpreter {s}: {s}", .{ return step.fail("unable to spawn interpreter {s}: {s}", .{

View File

@ -37,7 +37,7 @@ pub const Color = enum {
pub const Config = union(enum) { pub const Config = union(enum) {
no_color, no_color,
escape_codes, escape_codes,
windows_api: if (native_os == .windows) WindowsContext else void, windows_api: if (native_os == .windows) WindowsContext else noreturn,
/// Detect suitable TTY configuration options for the given file (commonly stdout/stderr). /// Detect suitable TTY configuration options for the given file (commonly stdout/stderr).
/// This includes feature checks for ANSI escape codes and the Windows console API, as well as /// This includes feature checks for ANSI escape codes and the Windows console API, as well as
@ -105,7 +105,7 @@ pub const Config = union(enum) {
}; };
try w.writeAll(color_string); try w.writeAll(color_string);
}, },
.windows_api => |ctx| if (native_os == .windows) { .windows_api => |ctx| {
const attributes = switch (color) { const attributes = switch (color) {
.black => 0, .black => 0,
.red => windows.FOREGROUND_RED, .red => windows.FOREGROUND_RED,
@ -130,8 +130,6 @@ pub const Config = union(enum) {
}; };
try w.flush(); try w.flush();
try windows.SetConsoleTextAttribute(ctx.handle, attributes); try windows.SetConsoleTextAttribute(ctx.handle, attributes);
} else {
unreachable;
}, },
}; };
} }

View File

@ -206,6 +206,22 @@ pub const EnvMap = struct {
return self.hash_map.iterator(); return self.hash_map.iterator();
} }
/// Returns a full copy of `em` allocated with `gpa`, which is not necessarily
/// the same allocator used to allocate `em`.
pub fn clone(em: *const EnvMap, gpa: Allocator) Allocator.Error!EnvMap {
var new: EnvMap = .init(gpa);
errdefer new.deinit();
// Since we need to dupe the keys and values, the only way for error handling to not be a
// nightmare is to add keys to an empty map one-by-one. This could be avoided if this
// abstraction were a bit less... OOP-esque.
try new.hash_map.ensureUnusedCapacity(em.hash_map.count());
var it = em.hash_map.iterator();
while (it.next()) |entry| {
try new.put(entry.key_ptr.*, entry.value_ptr.*);
}
return new;
}
fn free(self: EnvMap, value: []const u8) void { fn free(self: EnvMap, value: []const u8) void {
self.hash_map.allocator.free(value); self.hash_map.allocator.free(value);
} }

View File

@ -31,6 +31,7 @@ pub fn build(b: *std.Build) void {
const run = b.addRunArtifact(main); const run = b.addRunArtifact(main);
run.clearEnvironment(); run.clearEnvironment();
run.disable_zig_progress = true; run.disable_zig_progress = true;
run.color = .manual;
test_step.dependOn(&run.step); test_step.dependOn(&run.step);
} }