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
↓ nina-2d2b5035.tar.gzatlas at this save
files changed1 file
src/exec.zig+81 −24src/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 {