2d2b5035db17

streamed build stderr now newline-preserved and drain-safe on StreamTooLong (NINA-001/002)

time
2026-07-02 13:13 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
fable audit fix — exec.zig
parent
9a677da5a863
1 file src/exec.zig+81 −24
src/exec.zig+81 −24
@@ -397,28 +397,13 @@397397     return nixosRebuildRunStreamed(io, machine, gpa, "switch", flake, panel, extra_flags);398398 }399399 400-fn nixosRebuildRunStreamed(401-    io: std.Io,402-    machine: *const types.Machine,403-    gpa: std.mem.Allocator,404-    verb: []const u8,405-    flake: bool,406-    panel: *output.BuildPanel,407-    extra_flags: []const []const u8,408-) !u8 {409-    var argv_list: std.ArrayList([]const u8) = .empty;410-    defer argv_list.deinit(gpa);411-    try argv_list.appendSlice(gpa, &.{ "sudo", "nixos-rebuild", verb });412-    if (flake) try argv_list.appendSlice(gpa, &.{ "--flake", machine.config_path });413-    try argv_list.appendSlice(gpa, extra_flags);414-    const argv: []const []const u8 = argv_list.items;415-416-    output.flush();417-    const built_argv = try buildArgv(gpa, machine, argv);418-    defer if (!machine.local) gpa.free(built_argv);419-400+// Spawn argv with stderr piped, feed each line to the build panel for live progress,401+// and accumulate the full stderr (newlines preserved) for errors.translateError.402+// Factored out of nixosRebuildRunStreamed so the read loop is testable without a403+// real nixos-rebuild binary.404+fn runStreamedCapture(io: std.Io, gpa: std.mem.Allocator, argv: []const []const u8, panel: *output.BuildPanel) !u8 {420405     var child = std.process.spawn(io, .{421-        .argv = built_argv,406+        .argv = argv,422407         .stdin = .ignore,423408         .stdout = .ignore,424409         .stderr = .pipe,@@ -434,10 +419,23 @@434419         var reader_buf: [8192]u8 = undefined;435420         var fr = std.Io.File.Reader.initStreaming(stderr_file, io, &reader_buf);436421         while (true) {437-            const maybe = fr.interface.takeDelimiter('\n') catch break;438-            const raw = maybe orelse break;422+            const raw = fr.interface.takeDelimiter('\n') catch |err| switch (err) {423+                // A single line exceeded the reader's buffer — routine for nix eval424+                // traces/derivation lists. Discard the oversized remainder instead of425+                // abandoning the pipe: breaking here would leave stderr undrained, and426+                // once the kernel pipe buffer filled the child would block on write()427+                // forever, hanging child.wait() below.428+                error.StreamTooLong => {429+                    _ = fr.interface.discardDelimiterInclusive('\n') catch break;430+                    continue;431+                },432+                error.ReadFailed => break,433+            } orelse break;434+            // takeDelimiter excludes the delimiter, so re-add it or every downstream435+            // line-oriented consumer in errors.zig sees one giant concatenated line.439436             accumulated.appendSlice(gpa, raw) catch {};440-            const line = std.mem.trimEnd(u8, raw, "\r\n");437+            accumulated.append(gpa, '\n') catch {};438+            const line = std.mem.trimEnd(u8, raw, "\r");441439             panel.update(line);442440             panel.render();443441         }@@ -453,6 +451,65 @@453451         .exited => |code| code,454452         else => 1,455453     };454+}455+456+fn nixosRebuildRunStreamed(457+    io: std.Io,458+    machine: *const types.Machine,459+    gpa: std.mem.Allocator,460+    verb: []const u8,461+    flake: bool,462+    panel: *output.BuildPanel,463+    extra_flags: []const []const u8,464+) !u8 {465+    var argv_list: std.ArrayList([]const u8) = .empty;466+    defer argv_list.deinit(gpa);467+    try argv_list.appendSlice(gpa, &.{ "sudo", "nixos-rebuild", verb });468+    if (flake) try argv_list.appendSlice(gpa, &.{ "--flake", machine.config_path });469+    try argv_list.appendSlice(gpa, extra_flags);470+471+    output.flush();472+    const built_argv = try buildArgv(gpa, machine, argv_list.items);473+    defer if (!machine.local) gpa.free(built_argv);474+475+    return runStreamedCapture(io, gpa, built_argv, panel);476+}477+478+test "runStreamedCapture preserves newlines and survives an oversized stderr line (NINA-001/002)" {479+    const a = std.testing.allocator;480+    const io = std.testing.io;481+482+    var panel: output.BuildPanel = .{};483+484+    // A single stderr line over 8192 bytes (the reader's fixed buffer size) used to485+    // trip error.StreamTooLong and abandon the pipe (NINA-002); it must now be486+    // discarded and draining must continue for the lines that follow.487+    var oversized: [9000]u8 = undefined;488+    @memset(&oversized, 'x');489+490+    const script = try std.fmt.allocPrint(a,491+        \\printf 'these 3 paths will be fetched (12.3 MiB download, 45.6 MiB unpacked)\n'492+        \\printf '%s\n' "{s}"493+        \\printf 'error: nina test canary xyz-explanation\n'494+        \\printf 'For full logs, run some-command\n'495+        \\exit 1496+    , .{oversized});497+    defer a.free(script);498+499+    const exit_code = try runStreamedCapture(io, a, &.{ "sh", "-c", script }, &panel);500+    try std.testing.expectEqual(@as(u8, 1), exit_code);501+502+    const stderr = errors.getBuildStderr();503+    // Newlines must survive accumulation (NINA-001) — without them, every504+    // line-oriented consumer downstream (innermostError, extractLocation,505+    // extractWarnings) sees one giant concatenated line instead of real lines.506+    try std.testing.expect(std.mem.indexOf(u8, stderr, "error: nina test canary xyz-explanation\n") != null);507+508+    const translated = errors.translateError(a, stderr);509+    // Must reach Layer 2's structured extraction (innermostError needs a trimmed510+    // line starting with "error:") rather than falling through to the generic511+    // Layer-3 fallback, which is only reachable when line structure is lost.512+    try std.testing.expect(!std.mem.eql(u8, translated.body, "something went wrong that nina couldn't translate."));456513 }457514 458515 pub fn nixosRebuildDryActivate(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, extra_flags: []const []const u8) !u8 {

view file