Commit a388a8e5a7

mlugg <mlugg@mlugg.co.uk>
2025-08-26 17:28:42
std.Build: separate errors from failed commands
Recording the command in a separate field will give the build runner more freedom to choose how and when the command should be printed.
1 parent e4456d0
Changed files (5)
lib/compiler/build_runner.zig
@@ -1443,6 +1443,14 @@ pub fn printErrorMessages(
         }
         try stderr.writeAll("\n");
     }
+
+    if (failing_step.result_failed_command) |cmd_str| {
+        try ttyconf.setColor(stderr, .red);
+        try stderr.writeAll("failed command: ");
+        try ttyconf.setColor(stderr, .reset);
+        try stderr.writeAll(cmd_str);
+        try stderr.writeByte('\n');
+    }
 }
 
 fn printSteps(builder: *std.Build, w: *Writer) !void {
lib/std/Build/Step/Fmt.zig
@@ -67,7 +67,7 @@ fn make(step: *Step, options: Step.MakeOptions) !void {
         argv.appendAssumeCapacity(b.pathFromRoot(p));
     }
 
-    const run_result = try step.captureChildProcess(prog_node, argv.items);
+    const run_result = try step.captureChildProcess(options.gpa, prog_node, argv.items);
     if (fmt.check) switch (run_result.term) {
         .Exited => |code| if (code != 0 and run_result.stdout.len != 0) {
             var it = std.mem.tokenizeScalar(u8, run_result.stdout, '\n');
@@ -77,5 +77,5 @@ fn make(step: *Step, options: Step.MakeOptions) !void {
         },
         else => {},
     };
-    try step.handleChildProcessTerm(run_result.term, null, argv.items);
+    try step.handleChildProcessTerm(run_result.term);
 }
lib/std/Build/Step/Run.zig
@@ -1212,10 +1212,11 @@ fn runCommand(
     const step = &run.step;
     const b = step.owner;
     const arena = b.allocator;
+    const gpa = options.gpa;
 
     const cwd: ?[]const u8 = if (run.cwd) |lazy_cwd| lazy_cwd.getPath2(b, step) else null;
 
-    try step.handleChildProcUnsupported(cwd, argv);
+    try step.handleChildProcUnsupported();
     try Step.handleVerbose2(step.owner, cwd, run.env_map, argv);
 
     const allow_skip = switch (run.stdio) {
@@ -1365,6 +1366,8 @@ fn runCommand(
                 run.addPathForDynLibs(exe);
             }
 
+            gpa.free(step.result_failed_command.?);
+            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| {
@@ -1380,18 +1383,11 @@ fn runCommand(
         return step.fail("failed to spawn and capture stdio from {s}: {s}", .{ argv[0], @errorName(err) });
     };
 
-    const final_argv = if (interp_argv.items.len == 0) argv else interp_argv.items;
-
     const generic_result = opt_generic_result orelse {
         assert(run.stdio == .zig_test);
-        // Specific errors have already been reported. All we need to do is detect those and
-        // report the general "test failed" error, which includes the command argv.
-        if (!step.test_results.isSuccess() or step.result_error_msgs.items.len > 0) {
-            return step.fail(
-                "the following test command failed:\n{s}",
-                .{try Step.allocPrintCmd(arena, cwd, final_argv)},
-            );
-        }
+        // Specific errors have already been reported, and test results are populated. All we need
+        // to do is report step failure if any test failed.
+        if (!step.test_results.isSuccess()) return error.MakeFailed;
         return;
     };
 
@@ -1445,93 +1441,77 @@ fn runCommand(
             .expect_stderr_exact => |expected_bytes| {
                 if (!mem.eql(u8, expected_bytes, generic_result.stderr.?)) {
                     return step.fail(
-                        \\
                         \\========= expected this stderr: =========
                         \\{s}
                         \\========= but found: ====================
                         \\{s}
-                        \\========= from the following command: ===
-                        \\{s}
                     , .{
                         expected_bytes,
                         generic_result.stderr.?,
-                        try Step.allocPrintCmd(arena, cwd, final_argv),
                     });
                 }
             },
             .expect_stderr_match => |match| {
                 if (mem.indexOf(u8, generic_result.stderr.?, match) == null) {
                     return step.fail(
-                        \\
                         \\========= expected to find in stderr: =========
                         \\{s}
                         \\========= but stderr does not contain it: =====
                         \\{s}
-                        \\========= from the following command: =========
-                        \\{s}
                     , .{
                         match,
                         generic_result.stderr.?,
-                        try Step.allocPrintCmd(arena, cwd, final_argv),
                     });
                 }
             },
             .expect_stdout_exact => |expected_bytes| {
                 if (!mem.eql(u8, expected_bytes, generic_result.stdout.?)) {
                     return step.fail(
-                        \\
                         \\========= expected this stdout: =========
                         \\{s}
                         \\========= but found: ====================
                         \\{s}
-                        \\========= from the following command: ===
-                        \\{s}
                     , .{
                         expected_bytes,
                         generic_result.stdout.?,
-                        try Step.allocPrintCmd(arena, cwd, final_argv),
                     });
                 }
             },
             .expect_stdout_match => |match| {
                 if (mem.indexOf(u8, generic_result.stdout.?, match) == null) {
                     return step.fail(
-                        \\
                         \\========= expected to find in stdout: =========
                         \\{s}
                         \\========= but stdout does not contain it: =====
                         \\{s}
-                        \\========= from the following command: =========
-                        \\{s}
                     , .{
                         match,
                         generic_result.stdout.?,
-                        try Step.allocPrintCmd(arena, cwd, final_argv),
                     });
                 }
             },
             .expect_term => |expected_term| {
                 if (!termMatches(expected_term, generic_result.term)) {
-                    return step.fail("the following command {f} (expected {f}):\n{s}", .{
+                    return step.fail("process {f} (expected {f})", .{
                         fmtTerm(generic_result.term),
                         fmtTerm(expected_term),
-                        try Step.allocPrintCmd(arena, cwd, final_argv),
                     });
                 }
             },
         },
         else => {
-            // On failure, print stderr if captured.
+            // On failure, report captured stderr like normal standard error output.
             const bad_exit = switch (generic_result.term) {
                 .Exited => |code| code != 0,
                 .Signal, .Stopped, .Unknown => true,
             };
+            if (bad_exit) {
+                if (generic_result.stderr) |bytes| {
+                    run.step.result_stderr = bytes;
+                }
+            }
 
-            if (bad_exit) if (generic_result.stderr) |err| {
-                try step.addError("stderr:\n{s}", .{err});
-            };
-
-            try step.handleChildProcessTerm(generic_result.term, cwd, final_argv);
+            try step.handleChildProcessTerm(generic_result.term);
         },
     }
 }
@@ -1594,6 +1574,10 @@ fn spawnChildAndCollect(
         child.stdin_behavior = .Pipe;
     }
 
+    // If an error occurs, it's caused by this command:
+    assert(run.step.result_failed_command == null);
+    run.step.result_failed_command = try Step.allocPrintCmd(options.gpa, child.cwd, argv);
+
     if (run.stdio == .zig_test) {
         var timer = try std.time.Timer.start();
         const res = try evalZigTest(run, &child, options, fuzz_context);
lib/std/Build/Step.zig
@@ -56,6 +56,9 @@ result_cached: bool,
 result_duration_ns: ?u64,
 /// 0 means unavailable or not reported.
 result_peak_rss: usize,
+/// If the step is failed and this field is populated, this is the command which failed.
+/// This field may be populated even if the step succeeded.
+result_failed_command: ?[]const u8,
 test_results: TestResults,
 
 /// The return address associated with creation of this step that can be useful
@@ -257,6 +260,7 @@ pub fn init(options: StepOptions) Step {
         .result_cached = false,
         .result_duration_ns = null,
         .result_peak_rss = 0,
+        .result_failed_command = null,
         .test_results = .{},
     };
 }
@@ -336,20 +340,20 @@ pub fn dump(step: *Step, w: *std.Io.Writer, tty_config: std.Io.tty.Config) void
     }
 }
 
-pub fn evalChildProcess(s: *Step, argv: []const []const u8) ![]u8 {
-    const run_result = try captureChildProcess(s, std.Progress.Node.none, argv);
-    try handleChildProcessTerm(s, run_result.term, null, argv);
-    return run_result.stdout;
-}
-
+/// Populates `s.result_failed_command`.
 pub fn captureChildProcess(
     s: *Step,
+    gpa: Allocator,
     progress_node: std.Progress.Node,
     argv: []const []const u8,
 ) !std.process.Child.RunResult {
     const arena = s.owner.allocator;
 
-    try handleChildProcUnsupported(s, null, argv);
+    // If an error occurs, it's happened in this command:
+    assert(s.result_failed_command == null);
+    s.result_failed_command = try allocPrintCmd(gpa, null, argv);
+
+    try handleChildProcUnsupported(s);
     try handleVerbose(s.owner, null, argv);
 
     const result = std.process.Child.run(.{
@@ -386,6 +390,7 @@ pub const ZigProcess = struct {
 
 /// Assumes that argv contains `--listen=-` and that the process being spawned
 /// is the zig compiler - the same version that compiled the build runner.
+/// Populates `s.result_failed_command`.
 pub fn evalZigProcess(
     s: *Step,
     argv: []const []const u8,
@@ -394,6 +399,10 @@ pub fn evalZigProcess(
     web_server: ?*Build.WebServer,
     gpa: Allocator,
 ) !?Path {
+    // If an error occurs, it's happened in this command:
+    assert(s.result_failed_command == null);
+    s.result_failed_command = try allocPrintCmd(gpa, null, argv);
+
     if (s.getZigProcess()) |zp| update: {
         assert(watch);
         if (std.Progress.have_ipc) if (zp.progress_ipc_fd) |fd| prog_node.setIpcFd(fd);
@@ -410,8 +419,9 @@ pub fn evalZigProcess(
             else => |e| return e,
         };
 
-        if (s.result_error_bundle.errorMessageCount() > 0)
+        if (s.result_error_bundle.errorMessageCount() > 0) {
             return s.fail("{d} compilation errors", .{s.result_error_bundle.errorMessageCount()});
+        }
 
         if (s.result_error_msgs.items.len > 0 and result == null) {
             // Crash detected.
@@ -420,7 +430,7 @@ pub fn evalZigProcess(
             };
             s.result_peak_rss = zp.child.resource_usage_statistics.getMaxRss() orelse 0;
             s.clearZigProcess(gpa);
-            try handleChildProcessTerm(s, term, null, argv);
+            try handleChildProcessTerm(s, term);
             return error.MakeFailed;
         }
 
@@ -430,7 +440,7 @@ pub fn evalZigProcess(
     const b = s.owner;
     const arena = b.allocator;
 
-    try handleChildProcUnsupported(s, null, argv);
+    try handleChildProcUnsupported(s);
     try handleVerbose(s.owner, null, argv);
 
     var child = std.process.Child.init(argv, arena);
@@ -484,16 +494,11 @@ pub fn evalZigProcess(
             else => {},
         };
 
-        try handleChildProcessTerm(s, term, null, argv);
+        try handleChildProcessTerm(s, term);
     }
 
-    // This is intentionally printed for failure on the first build but not for
-    // subsequent rebuilds.
     if (s.result_error_bundle.errorMessageCount() > 0) {
-        return s.fail("the following command failed with {d} compilation errors:\n{s}", .{
-            s.result_error_bundle.errorMessageCount(),
-            try allocPrintCmd(arena, null, argv),
-        });
+        return s.fail("{d} compilation errors", .{s.result_error_bundle.errorMessageCount()});
     }
 
     return result;
@@ -696,54 +701,38 @@ pub fn handleVerbose2(
     }
 }
 
-pub inline fn handleChildProcUnsupported(
-    s: *Step,
-    opt_cwd: ?[]const u8,
-    argv: []const []const u8,
-) error{ OutOfMemory, MakeFailed }!void {
+/// Asserts that the caller has already populated `s.result_failed_command`.
+pub inline fn handleChildProcUnsupported(s: *Step) error{ OutOfMemory, MakeFailed }!void {
     if (!std.process.can_spawn) {
-        return s.fail(
-            "unable to execute the following command: host cannot spawn child processes\n{s}",
-            .{try allocPrintCmd(s.owner.allocator, opt_cwd, argv)},
-        );
+        return s.fail("unable to spawn process: host cannot spawn child processes", .{});
     }
 }
 
-pub fn handleChildProcessTerm(
-    s: *Step,
-    term: std.process.Child.Term,
-    opt_cwd: ?[]const u8,
-    argv: []const []const u8,
-) error{ MakeFailed, OutOfMemory }!void {
-    const arena = s.owner.allocator;
+/// Asserts that the caller has already populated `s.result_failed_command`.
+pub fn handleChildProcessTerm(s: *Step, term: std.process.Child.Term) error{ MakeFailed, OutOfMemory }!void {
+    assert(s.result_failed_command != null);
     switch (term) {
         .Exited => |code| {
             if (code != 0) {
-                return s.fail(
-                    "the following command exited with error code {d}:\n{s}",
-                    .{ code, try allocPrintCmd(arena, opt_cwd, argv) },
-                );
+                return s.fail("process exited with error code {d}", .{code});
             }
         },
         .Signal, .Stopped, .Unknown => {
-            return s.fail(
-                "the following command terminated unexpectedly:\n{s}",
-                .{try allocPrintCmd(arena, opt_cwd, argv)},
-            );
+            return s.fail("process terminated unexpectedly", .{});
         },
     }
 }
 
 pub fn allocPrintCmd(
-    arena: Allocator,
+    gpa: Allocator,
     opt_cwd: ?[]const u8,
     argv: []const []const u8,
 ) Allocator.Error![]u8 {
-    return allocPrintCmd2(arena, opt_cwd, null, argv);
+    return allocPrintCmd2(gpa, opt_cwd, null, argv);
 }
 
 pub fn allocPrintCmd2(
-    arena: Allocator,
+    gpa: Allocator,
     opt_cwd: ?[]const u8,
     opt_env: ?*const std.process.EnvMap,
     argv: []const []const u8,
@@ -783,11 +772,13 @@ pub fn allocPrintCmd2(
         }
     };
 
-    var aw: std.Io.Writer.Allocating = .init(arena);
+    var aw: std.Io.Writer.Allocating = .init(gpa);
+    defer aw.deinit();
     const writer = &aw.writer;
     if (opt_cwd) |cwd| writer.print("cd {s} && ", .{cwd}) catch return error.OutOfMemory;
     if (opt_env) |env| {
-        const process_env_map = std.process.getEnvMap(arena) catch std.process.EnvMap.init(arena);
+        var process_env_map = std.process.getEnvMap(gpa) catch std.process.EnvMap.init(gpa);
+        defer process_env_map.deinit();
         var it = env.iterator();
         while (it.next()) |entry| {
             const key = entry.key_ptr.*;
@@ -973,11 +964,14 @@ fn addWatchInputFromPath(step: *Step, path: Build.Cache.Path, basename: []const
 pub fn reset(step: *Step, gpa: Allocator) void {
     assert(step.state == .precheck_done);
 
+    if (step.result_failed_command) |cmd| gpa.free(cmd);
+
     step.result_error_msgs.clearRetainingCapacity();
     step.result_stderr = "";
     step.result_cached = false;
     step.result_duration_ns = null;
     step.result_peak_rss = 0;
+    step.result_failed_command = null;
     step.test_results = .{};
 
     step.result_error_bundle.deinit(gpa);
lib/std/Build.zig
@@ -1594,20 +1594,6 @@ pub fn validateUserInputDidItFail(b: *Build) bool {
     return b.invalid_user_input;
 }
 
-fn allocPrintCmd(gpa: Allocator, opt_cwd: ?[]const u8, argv: []const []const u8) error{OutOfMemory}![]u8 {
-    var buf: ArrayList(u8) = .empty;
-    if (opt_cwd) |cwd| try buf.print(gpa, "cd {s} && ", .{cwd});
-    for (argv) |arg| {
-        try buf.print(gpa, "{s} ", .{arg});
-    }
-    return buf.toOwnedSlice(gpa);
-}
-
-fn printCmd(ally: Allocator, cwd: ?[]const u8, argv: []const []const u8) void {
-    const text = allocPrintCmd(ally, cwd, argv) catch @panic("OOM");
-    std.debug.print("{s}\n", .{text});
-}
-
 /// This creates the install step and adds it to the dependencies of the
 /// top-level install step, using all the default options.
 /// See `addInstallArtifact` for a more flexible function.
@@ -1857,14 +1843,14 @@ pub fn runAllowFail(
 pub fn run(b: *Build, argv: []const []const u8) []u8 {
     if (!process.can_spawn) {
         std.debug.print("unable to spawn the following command: cannot spawn child process\n{s}\n", .{
-            try allocPrintCmd(b.allocator, null, argv),
+            try Step.allocPrintCmd(b.allocator, null, argv),
         });
         process.exit(1);
     }
 
     var code: u8 = undefined;
     return b.runAllowFail(argv, &code, .Inherit) catch |err| {
-        const printed_cmd = allocPrintCmd(b.allocator, null, argv) catch @panic("OOM");
+        const printed_cmd = Step.allocPrintCmd(b.allocator, null, argv) catch @panic("OOM");
         std.debug.print("unable to spawn the following command: {s}\n{s}\n", .{
             @errorName(err), printed_cmd,
         });