diff options
Diffstat (limited to 'src/selection_pipe.zig')
| -rw-r--r-- | src/selection_pipe.zig | 159 |
1 files changed, 133 insertions, 26 deletions
diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index c1104010..8e721751 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -65,19 +65,60 @@ pub const Job = struct { } }; -/// One worker answer. `outputs` owns each slice; a failure owns an empty list. +/// WHY a filter produced nothing, carried home so somebody can be told. +/// +/// Until this existed the runner read the command's stderr into memory and +/// then FREED IT UNREAD — the one artifact that explains a failure, discarded +/// two lines after it arrived — and every caller answered a failed filter with +/// a bare `return`. `| trr a-z A-Z` (a typo), `| grep nomatch` (exit 1), +/// `| jq .` on bad JSON: all of them did nothing, said nothing, and left the +/// text alone with no way to find out why. +pub const Failure = struct { + pub const Kind = enum { + /// The command never started: no `/bin/sh`, a cwd that is gone, a NUL + /// in the command, a fork that failed. + spawn, + /// Still running at `command_timeout_seconds`. + timeout, + /// Past `max_stdout_bytes` / `max_stderr_bytes` / the job total. + too_large, + /// Ran, and exited nonzero. `code` says which. + exit, + /// Killed by a signal. + signal, + /// A read, a write or an allocation failed under us. + io, + }; + + kind: Kind = .io, + /// Which selection this was, so a report over several cursors can say. + index: u32 = 0, + /// The exit status, when `kind` is `.exit`. + code: u8 = 0, + /// stderr exactly as the command wrote it, OWNED by the response. Empty + /// when the command said nothing, which is why `kind` and `code` exist. + stderr: []u8 = &.{}, +}; + +/// One worker answer. `outputs` owns each slice; a failure owns an empty list +/// and, usually, the command's own account of itself in `failure`. pub const Response = struct { id: u32, success: bool, outputs: [][]u8, + failure: ?Failure = null, pub fn deinit(response: *Response, gpa: std.mem.Allocator) void { for (response.outputs) |output| gpa.free(output); if (response.outputs.len > 0) gpa.free(response.outputs); + if (response.failure) |f| if (f.stderr.len > 0) gpa.free(f.stderr); response.* = undefined; } }; +/// What one invocation came to: the bytes, or the reason there are none. +pub const Outcome = union(enum) { ok: []u8, failed: Failure }; + /// The in-flight set a host keeps while pipes run off its loop. /// /// tty.zig and gui.zig each had this verbatim — same `finish` walk, same @@ -151,9 +192,14 @@ pub fn runOne( command: []const u8, cwd: []const u8, input: []const u8, -) ?[]u8 { +) Outcome { + const fail = struct { + fn k(kind: Failure.Kind) Outcome { + return .{ .failed = .{ .kind = kind } }; + } + }; if (std.mem.indexOfScalar(u8, command, 0) != null or - std.mem.indexOfScalar(u8, cwd, 0) != null) return null; + std.mem.indexOfScalar(u8, cwd, 0) != null) return fail.k(.spawn); var child = std.process.spawn(io, .{ .argv = &.{ "/bin/sh", "-c", command }, @@ -161,7 +207,7 @@ pub fn runOne( .stdin = .pipe, .stdout = .pipe, .stderr = .pipe, - }) catch return null; + }) catch return fail.k(.spawn); var writer_context: WriterContext = .{ .io = io, @@ -172,7 +218,7 @@ pub fn runOne( var writer: ?std.Thread = std.Thread.spawn(.{}, writeInput, .{&writer_context}) catch { writer_context.file.close(io); child.kill(io); - return null; + return fail.k(.spawn); }; // On every early return kill first, unblocking a command which never read // stdin, then join the short-lived writer before its borrowed input dies. @@ -192,33 +238,49 @@ pub fn runOne( } }).toDeadline(io); while (multi_reader.fill(64, deadline)) |_| { if (stdout_reader.buffered().len > max_stdout_bytes or - stderr_reader.buffered().len > max_stderr_bytes) return null; + stderr_reader.buffered().len > max_stderr_bytes) return fail.k(.too_large); } else |err| switch (err) { error.EndOfStream => {}, - else => return null, + // The deadline is the only one of these a human is likely to cause, + // and it is the one they are least able to guess at: ten seconds of + // nothing used to be followed by nothing. + error.Timeout => return fail.k(.timeout), + else => return fail.k(.io), } if (stdout_reader.buffered().len > max_stdout_bytes or - stderr_reader.buffered().len > max_stderr_bytes) return null; - multi_reader.checkAnyError() catch return null; - const term = child.wait(io) catch return null; + stderr_reader.buffered().len > max_stderr_bytes) return fail.k(.too_large); + multi_reader.checkAnyError() catch return fail.k(.io); + const term = child.wait(io) catch return fail.k(.io); writer.?.join(); writer = null; - const stdout = multi_reader.toOwnedSlice(0) catch return null; + const stdout = multi_reader.toOwnedSlice(0) catch return fail.k(.io); const stderr = multi_reader.toOwnedSlice(1) catch { gpa.free(stdout); - return null; - }; - defer gpa.free(stderr); - const exited_zero = switch (term) { - .exited => |code| code == 0, - else => false, + return fail.k(.io); }; - if (!writer_context.ok or !exited_zero) { - gpa.free(stdout); - return null; + // stderr is NOT freed here any more. It is the command's own account of + // what went wrong, and it goes home with the failure. + errdefer gpa.free(stderr); + + // A STDIN WRITE THAT ENDED EARLY IS NOT A FAILURE. `writer_context.ok` was + // part of this condition, so `| head -1` over a selection bigger than the + // pipe buffer failed — the command closed stdin after the line it wanted, + // the write got EPIPE, and a filter that had done exactly its job reported + // nothing. helix joins its input task and ignores the result for this + // reason; the exit status is the whole verdict. + switch (term) { + .exited => |code| if (code != 0) { + gpa.free(stdout); + return .{ .failed = .{ .kind = .exit, .code = code, .stderr = stderr } }; + }, + else => { + gpa.free(stdout); + return .{ .failed = .{ .kind = .signal, .stderr = stderr } }; + }, } - return stdout; + gpa.free(stderr); + return .{ .ok = stdout }; } /// Invoke the command independently for every selection. The response is all @@ -230,7 +292,17 @@ pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response { var made: usize = 0; var total: usize = 0; for (job.inputs, 0..) |input, i| { - const output = runOne(gpa, io, job.command, job.cwd, input) orelse break; + const output = switch (runOne(gpa, io, job.command, job.cwd, input)) { + .ok => |bytes| bytes, + .failed => |f| { + // WHICH selection, because with several cursors "it failed" is + // not enough to go looking with. + var owned = f; + owned.index = @intCast(i); + response.failure = owned; + break; + }, + }; if (std.math.add(usize, total, output.len) catch null) |next_total| { if (next_total <= max_total_stdout_bytes) { outputs[i] = output; @@ -240,6 +312,7 @@ pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response { } } gpa.free(output); + response.failure = .{ .kind = .too_large, .index = @intCast(i) }; break; } if (made != job.inputs.len) { @@ -252,12 +325,46 @@ pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response { return response; } -test "native pipe runner preserves stdin/stdout bytes and rejects failure" { +test "native pipe runner preserves stdin/stdout bytes and reports how it failed" { const gpa = std.testing.allocator; const io = std.testing.io; - const output = runOne(gpa, io, "printf 'prefix:'; cat; printf '\\n'", "/tmp", "a\x00b\n") orelse - return error.PipeCommandFailed; + const output = switch (runOne(gpa, io, "printf 'prefix:'; cat; printf '\\n'", "/tmp", "a\x00b\n")) { + .ok => |bytes| bytes, + .failed => return error.PipeCommandFailed, + }; defer gpa.free(output); try std.testing.expectEqualSlices(u8, "prefix:a\x00b\n\n", output); - try std.testing.expect(runOne(gpa, io, "printf ignored; exit 7", "/tmp", "") == null); + + // A NONZERO EXIT COMES HOME WITH ITS REASON. The status and the command's + // own stderr are the whole of what a human needs to fix a typo'd filter, + // and both used to be freed on the floor. + switch (runOne(gpa, io, "echo trouble >&2; exit 7", "/tmp", "")) { + .ok => |bytes| { + gpa.free(bytes); + return error.PipeShouldHaveFailed; + }, + .failed => |f| { + defer gpa.free(f.stderr); + try std.testing.expectEqual(Failure.Kind.exit, f.kind); + try std.testing.expectEqual(@as(u8, 7), f.code); + try std.testing.expectEqualSlices(u8, "trouble\n", f.stderr); + }, + } + + // ...and a command that stops reading its stdin SUCCEEDS. `| head -1` over + // a selection bigger than the pipe buffer takes the line it wanted and + // closes the pipe; the write gets EPIPE, and that used to fail the filter + // even though it had done exactly its job. + const big = try gpa.alloc(u8, 512 * 1024); + defer gpa.free(big); + @memset(big, 'x'); + big[0] = 'a'; + big[1] = '\n'; + switch (runOne(gpa, io, "head -1", "/tmp", big)) { + .ok => |bytes| { + defer gpa.free(bytes); + try std.testing.expectEqualSlices(u8, "a\n", bytes); + }, + .failed => return error.EarlyStdinCloseShouldNotFail, + } } |
