Commit c6b5945356

Matthew Lugg <mlugg@mlugg.co.uk>
2025-11-13 10:46:57
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.
1 parent b38fb4b
Changed files (6)
lib
test
standalone
empty_env
lib/compiler/build_runner.zig
@@ -443,11 +443,6 @@ pub fn main() !void {
     }
 
     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(.{
         .disable_printing = (color == .off),
@@ -1389,6 +1384,7 @@ fn workerMakeOneStep(
         .thread_pool = thread_pool,
         .watch = run.watch,
         .web_server = if (run.web_server) |*ws| ws else null,
+        .ttyconf = run.ttyconf,
         .unit_test_timeout_ns = run.unit_test_timeout_ns,
         .gpa = run.gpa,
     });
lib/std/Build/Step/Run.zig
@@ -24,6 +24,21 @@ cwd: ?Build.LazyPath,
 /// Override this field to modify the environment, or use setEnvironmentVariable
 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
 /// to the child process, which otherwise would be used for the child to send
 /// progress updates to the parent.
@@ -525,7 +540,7 @@ pub fn setCwd(run: *Run, cwd: Build.LazyPath) void {
 pub fn clearEnvironment(run: *Run) void {
     const b = run.step.owner;
     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;
 }
 
@@ -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| {
         switch (arg) {
             .bytes => |bytes| {
@@ -1130,6 +1148,7 @@ pub fn rerunInFuzzMode(
         .thread_pool = undefined, // not used by `runCommand`
         .watch = undefined, // not used by `runCommand`
         .web_server = null, // only needed for time reports
+        .ttyconf = fuzz.ttyconf,
         .unit_test_timeout_ns = null, // don't time out fuzz tests for now
         .gpa = undefined, // not used by `runCommand`
     }, .{
@@ -1234,9 +1253,40 @@ fn runCommand(
     var interp_argv = std.array_list.Managed([]const u8).init(b.allocator);
     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();
+
+    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: {
+    const opt_generic_result = spawnChildAndCollect(run, argv, &env_map, has_side_effects, options, fuzz_context) catch |err| term: {
         // InvalidExe: cpu arch mismatch
         // FileNotFound: can happen with a wrong dynamic linker path
         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
                         // allow the user to override it (e.g. with `WINEDEBUG=err+all`) if desired.
                         if (env_map.get("WINEDEBUG") == null) {
-                            // We don't own `env_map` at this point, so create a copy in order to modify it.
-                            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;
+                            try env_map.put("WINEDEBUG", "-all");
                         }
                     } else {
                         return failForeign(run, "-fwine", argv[0], exe);
@@ -1377,7 +1422,7 @@ fn runCommand(
             step.result_failed_command = null;
             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 (e == error.MakeFailed) return error.MakeFailed; // error already reported
                 return step.fail("unable to spawn interpreter {s}: {s}", .{
lib/std/Build/Step.zig
@@ -118,6 +118,7 @@ pub const MakeOptions = struct {
         // it currently breaks because `std.net.Address` doesn't work there. Work around for now.
         .wasm32 => void,
     },
+    ttyconf: std.Io.tty.Config,
     /// If set, this is a timeout to enforce on all individual unit tests, in nanoseconds.
     unit_test_timeout_ns: ?u64,
     /// Not to be confused with `Build.allocator`, which is an alias of `Build.graph.arena`.
lib/std/Io/tty.zig
@@ -37,7 +37,7 @@ pub const Color = enum {
 pub const Config = union(enum) {
     no_color,
     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).
     /// 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);
             },
-            .windows_api => |ctx| if (native_os == .windows) {
+            .windows_api => |ctx| {
                 const attributes = switch (color) {
                     .black => 0,
                     .red => windows.FOREGROUND_RED,
@@ -130,8 +130,6 @@ pub const Config = union(enum) {
                 };
                 try w.flush();
                 try windows.SetConsoleTextAttribute(ctx.handle, attributes);
-            } else {
-                unreachable;
             },
         };
     }
lib/std/process.zig
@@ -206,6 +206,22 @@ pub const EnvMap = struct {
         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 {
         self.hash_map.allocator.free(value);
     }
test/standalone/empty_env/build.zig
@@ -31,6 +31,7 @@ pub fn build(b: *std.Build) void {
     const run = b.addRunArtifact(main);
     run.clearEnvironment();
     run.disable_zig_progress = true;
+    run.color = .manual;
 
     test_step.dependOn(&run.step);
 }