diff options
Diffstat (limited to 'src/host_io.zig')
| -rw-r--r-- | src/host_io.zig | 276 |
1 files changed, 271 insertions, 5 deletions
diff --git a/src/host_io.zig b/src/host_io.zig index f1a4eb98..a019e15d 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -760,8 +760,13 @@ pub const Shell = struct { return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } }; } - fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { + /// A shell by its path or its name in the usual directories, or null. + /// A path may be under a mount this editor serves: the turn goes out + /// with the lookups (pardes.Turn.yield). + pub fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { if (bin.len == 0 or bin.len + 1 > buf.len) return null; + pardes.turn.yield(); + defer pardes.turn.back(); if (std.mem.indexOfScalar(u8, bin, '/') != null) { @memcpy(buf[0..bin.len], bin); buf[bin.len] = 0; @@ -966,6 +971,9 @@ test "the child environment replaces the launcher's terminal identity exactly on extern "c" fn unsetenv(name: [*:0]const u8) c_int; +/// The longest line a command pane runs (exec.zig refuses a longer one). +pub const exec_command_max = pardes.exec.command_max; + pub fn forkShell( core: ?*pardes.Pardes, pane: usize, @@ -979,6 +987,7 @@ pub fn forkShell( const native_cwd = filesystem.localPath(cwd) orelse cwd; if (std.mem.indexOfScalar(u8, native_cwd, 0) != null) return error.InvalidPath; var cwd_buf: [4096]u8 = undefined; + const serial = if (core) |c| if (c.panes[pane]) |pn| pn.serial else 0 else 0; const cwd_z: ?[:0]const u8 = if (native_cwd.len == 0) null else dir: { const path = std.fmt.bufPrintSentinel(&cwd_buf, "{s}", .{native_cwd}, 0) catch return error.NameTooLong; // A shell's directory may be inside a mount this editor serves. @@ -990,9 +999,32 @@ pub fn forkShell( }; var master: c_int = -1; var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const spawn = Shell.resolve(bin, &path_buf, prompt_rcs); + // A terminal opened on a shell of its own (`Tty fish`) runs that one, + // copied: looking it up gives the turn up, and the pane may go. + var own_buf: [256]u8 = undefined; + var own: ?[]const u8 = null; + if (core) |c| if (c.panes[pane]) |pn| if (pn.shell) |name| { + const n = @min(name.len, own_buf.len); + @memcpy(own_buf[0..n], name[0..n]); + own = own_buf[0..n]; + }; + var spawn = Shell.resolve(own orelse bin, &path_buf, prompt_rcs); + // The turn was given up for the stat and the lookup: a 9P client may + // have closed the pane, and another taken its slot, whose command this + // is not. + if (core) |c| if ((if (c.panes[pane]) |pn| pn.serial else 0) != serial) return error.PaneGone; + // A command pane's child is the shell running its one line, which has + // no prompt to mark (exec.zig runCommand). + const one_line = if (core) |c| if (c.panes[pane]) |pn| pn.command else null else null; + var command_buf: [exec_command_max + 1]u8 = undefined; + if (one_line) |line| { + if (line.len > exec_command_max or std.mem.indexOfScalar(u8, line, 0) != null) return error.InvalidCommand; + @memcpy(command_buf[0..line.len], line); + command_buf[line.len] = 0; + spawn.argv = .{ spawn.path, "-c", command_buf[0..line.len :0].ptr, null }; + } var helper_buf: [4096]u8 = undefined; - const mounted = if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false; + const mounted = one_line == null and if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false; const helper: ?[:0]u8 = if (mounted) blk: { if (comptime builtin.os.tag != .linux) return error.LinuxRequired; if (fs == null) return error.SessionSocketRequired; @@ -1033,7 +1065,7 @@ pub fn forkShell( _exit(127); } ninep_io.setCloexec(master); - if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); + if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), one_line == null and spawn.argv[1] != null); return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; } @@ -1366,6 +1398,185 @@ pub fn retireShell(pid: libc.pid_t) void { thread.detach(); } +// ---- a command pane's child, watched to its exit ---- +// +// A command pane's command is done when its process exits, not when its pty +// reaches end of file: a job it put in the background can hold the pty open +// after it has gone, and a command can close its terminal and run on. A +// thread per command waits for the exit without reaping (WNOWAIT), so the +// pid is still the child's -- no Kill can reach another process that took +// it -- until the host, told through `takeExited`, reaps it in its own loop +// with `exitStatus` and tells the core. + +const waitid_p_pid: c_int = 1; +const waitid_wexited: c_int = 4; +const waitid_wnowait: c_int = if (builtin.os.tag == .linux) 0x01000000 else 0x20; +extern "c" fn waitid(idtype: c_int, id: c_uint, infop: *anyopaque, options: c_int) c_int; + +var exited: [pardes.MAX_PANES * 2]std.atomic.Value(libc.pid_t) = @splat(.init(0)); +var exit_lock: libc.pthread_mutex_t = .{}; +var exit_wake: ?struct { ctx: ?*anyopaque, f: *const fn (?*anyopaque) void } = null; + +/// Who wakes the host's loop when a watched child exits; null at shutdown, +/// after which a watcher still waiting tells nobody. +pub fn setExitWake(ctx: ?*anyopaque, f: ?*const fn (?*anyopaque) void) void { + _ = libc.pthread_mutex_lock(&exit_lock); + defer _ = libc.pthread_mutex_unlock(&exit_lock); + exit_wake = if (f) |wake| .{ .ctx = ctx, .f = wake } else null; +} + +/// Watches a command pane's child; false when no thread could be started, +/// and the host then reads its exit at end of file instead. +pub fn watchExit(pid: libc.pid_t) bool { + const thread = std.Thread.spawn(.{}, watchChild, .{pid}) catch return false; + thread.detach(); + return true; +} + +fn watchChild(pid: libc.pid_t) void { + var info: [128]u8 align(16) = undefined; + while (waitid(waitid_p_pid, @intCast(pid), &info, waitid_wexited | waitid_wnowait) < 0 and libc.errno(-1) == .INTR) {} + // A cell for it, waiting for one if the host is slow to take them. + place: while (true) { + for (&exited) |*cell| if (cell.cmpxchgStrong(0, pid, .acq_rel, .acquire) == null) break :place; + const ts: libc.timespec = .{ .sec = 0, .nsec = 5 * std.time.ns_per_ms }; + _ = libc.nanosleep(&ts, null); + } + wakeForExit(); +} + +fn wakeForExit() void { + _ = libc.pthread_mutex_lock(&exit_lock); + defer _ = libc.pthread_mutex_unlock(&exit_lock); + if (exit_wake) |wake| wake.f(wake.ctx); +} + +/// Where a host's command pane is between its child's exit and the end of +/// its output. The exit is told once the output before it is in: at the +/// pty's end of file; or, when something else still holds the pty (a job +/// the command left in the background), once nothing is waiting to be read +/// -- checked when the exit is seen and again after each chunk of output, +/// with no timer to miss. acme's waitthread tells an exit when it is +/// reaped (acme.c:587-700); a terminal also has output in flight. +pub const CommandWatch = struct { + watched: bool = false, + /// The master, to ask whether output is still waiting. + fd: c_int = -1, + eof: bool = false, + exited: bool = false, + told: bool = false, + status: ?u8 = null, + + /// Whether the exit is to be told now. + pub fn due(w: *const CommandWatch) bool { + if (!w.exited or w.told) return false; + if (w.eof) return true; + var pfd = [1]libc.pollfd{.{ .fd = w.fd, .events = libc.POLL.IN, .revents = 0 }}; + if (libc.poll(&pfd, 1, 0) < 0) return true; + // Every holder of its terminal gone: the end of file comes, after + // the rest of the output. + if (pfd[0].revents & libc.POLL.HUP != 0) return false; + return pfd[0].revents & libc.POLL.IN == 0; + } +}; + +/// A host's pty slot, whichever way it keeps it: an optional, or one whose +/// `fd` is -1 when empty. +fn SlotPty(comptime S: type) type { + return switch (@typeInfo(S)) { + .optional => |o| *o.child, + else => *S, + }; +} + +fn slotPty(slot: anytype) ?SlotPty(@TypeOf(slot.*)) { + return switch (@typeInfo(@TypeOf(slot.*))) { + .optional => if (slot.*) |*pt| pt else null, + else => if (slot.fd < 0) null else slot, + }; +} + +/// Each watched child that exited: reaped, and the core told once its +/// output is in, its pty closed (`close(ctx, id)`) if its end of file came +/// first. Every host runs this after an exit's wake and after each chunk +/// of a command's output; `ptys` is its slot array, each slot with a `pid` +/// and a `cmd`. +pub fn takeExits(core: *pardes.Pardes, ptys: anytype, ctx: anytype, comptime close: anytype) void { + while (takeExited()) |pid| for (ptys) |*slot| { + const pt = slotPty(slot) orelse continue; + if (!pt.cmd.watched or pt.pid != pid or pt.cmd.exited) continue; + pt.cmd.status = (reapExited(pid) orelse break).status; + pt.cmd.exited = true; + pt.pid = 0; // reaped: no signal or retire may reach whoever gets it next + break; + }; + for (ptys, 0..) |*slot, id| { + const pt = slotPty(slot) orelse continue; + if (!pt.cmd.watched or !pt.cmd.due()) continue; + pt.cmd.told = true; + const eof = pt.cmd.eof; + core.update(.{ .exited = .{ .pane = @intCast(id), .status = pt.cmd.status } }); + if (eof) close(ctx, id); + } +} + +/// A watched command's pty reached end of file: the core hears its output +/// is over; the pty is closed now if its exit was told, else when it is. +pub fn commandEof(core: *pardes.Pardes, ptys: anytype, id: usize, ctx: anytype, comptime close: anytype) void { + const pt = slotPty(&ptys[id]) orelse return; + pt.cmd.eof = true; + core.update(.{ .eof = .{ .pane = @intCast(id) } }); + if (pt.cmd.told) return close(ctx, id); + takeExits(core, ptys, ctx, close); +} + +/// Reaps `pid` if it has exited: its status (null: unknown, or not ours +/// to read), or null when it is still running -- a pid a watcher reported +/// may since have been reaped by another and taken by a new child. +pub fn reapExited(pid: libc.pid_t) ?struct { status: ?u8 } { + if (pid <= 0) return .{ .status = null }; + var status: c_int = 0; + const got = libc.waitpid(pid, &status, libc.W.NOHANG); + if (got == 0) return null; + if (got != pid) return .{ .status = null }; + const s: u32 = @bitCast(status); + if (libc.W.IFEXITED(s)) return .{ .status = @truncate(libc.W.EXITSTATUS(s)) }; + if (libc.W.IFSIGNALED(s)) return .{ .status = @truncate(128 + @as(u32, @intFromEnum(libc.W.TERMSIG(s)))) }; + return .{ .status = null }; +} + +/// A watched child that has exited, not yet reaped, or null. +pub fn takeExited() ?libc.pid_t { + for (&exited) |*cell| { + const pid = cell.swap(0, .acq_rel); + if (pid != 0) return pid; + } + return null; +} + +/// A pane's child at its pty's end of file: reaped, and how it exited +/// (its code, or 128 and the signal that ended it), if it is gone within +/// `wait_ms`; null if it is still running (a job it left holds nothing +/// open, but the child itself may be a moment behind its pty), which the +/// caller then retires as it would any shell. +pub fn exitStatus(pid: libc.pid_t, wait_ms: u32) ?u8 { + if (pid <= 0) return null; + var waited: u32 = 0; + while (true) : (waited += 2) { + var status: c_int = 0; + const got = libc.waitpid(pid, &status, libc.W.NOHANG); + if (got == pid) { + const s: u32 = @bitCast(status); + if (libc.W.IFEXITED(s)) return @truncate(libc.W.EXITSTATUS(s)); + if (libc.W.IFSIGNALED(s)) return @truncate(128 + @as(u32, @intFromEnum(libc.W.TERMSIG(s)))); + return null; + } + if (got < 0 or waited >= wait_ms) return null; + const ts: libc.timespec = .{ .sec = 0, .nsec = 2 * std.time.ns_per_ms }; + _ = libc.nanosleep(&ts, null); + } +} + fn reapShell(pid: libc.pid_t, grace_ms: u32) void { var waited: u32 = 0; while (waited < grace_ms) : (waited += 5) { @@ -1669,7 +1880,7 @@ fn haveFile(path: [*:0]const u8) bool { return true; } -fn nowMs() i64 { +pub fn nowMs() i64 { var ts: libc.timespec = undefined; _ = libc.clock_gettime(.MONOTONIC, &ts); return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); @@ -1728,6 +1939,61 @@ test "Kill stops the foreground job, never the shell, and finds none with job co try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG)); } +test "a command pane's child is its shell running the one line, and its exit status is read at the end" { + if (comptime !tty_probe_platform) return error.SkipZigTest; + const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer core.deinit(); + while (core.nextEffect()) |_| {} + const id = core.freeSlot().?; + _ = try core.newCommand(id, "", "printf 'said '; exit 7"); + while (core.nextEffect()) |_| {} + const rcs: Shell.PromptFiles = .{}; + const child = try forkShell(core, id, &rcs, "/bin/sh", "", 24, 80, null); + defer _ = libc.close(child.file.handle); + var got: [256]u8 = undefined; + var n: usize = 0; + while (n < got.len) { + const r = libc.read(child.file.handle, got[n..].ptr, got.len - n); + if (r <= 0) break; // EIO once the child is gone: the pty's end + n += @intCast(r); + } + try std.testing.expect(std.mem.indexOf(u8, got[0..n], "said") != null); + try std.testing.expectEqual(@as(?u8, 7), exitStatus(child.pid, 2000)); +} + +test "a command's exit is its process's, told by its watcher, whether or not its pty has ended" { + if (comptime !tty_probe_platform) return error.SkipZigTest; + const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer core.deinit(); + while (core.nextEffect()) |_| {} + const rcs: Shell.PromptFiles = .{}; + const Case = struct { line: []const u8, status: u8 }; + // A job left in the background holds the pty open past the command's + // exit; a command that lets go of its terminal runs on past its end of + // file. Either way the exit is the process's. + for ([_]Case{ + .{ .line = "sleep 3 & exit 4", .status = 4 }, + .{ .line = "exec </dev/null >/dev/null 2>&1; sleep 0.3; exit 5", .status = 5 }, + }) |c| { + const id = core.freeSlot().?; + _ = try core.newCommand(id, "", c.line); + while (core.nextEffect()) |_| {} + const child = try forkShell(core, id, &rcs, "/bin/sh", "", 24, 80, null); + defer _ = libc.close(child.file.handle); + try std.testing.expect(watchExit(child.pid)); + var got: ?libc.pid_t = null; + var waited: i64 = 0; + while (got == null and waited < 5000) : (waited += 10) { + got = takeExited(); + if (got == null) sleepMs(10); + } + try std.testing.expectEqual(@as(?libc.pid_t, child.pid), got); + // Told before it is reaped: the pid is still the child's. + try std.testing.expectEqual(@as(?u8, c.status), reapExited(child.pid).?.status); + try core.removePane(id, null); + } +} + test "a retired shell that ignores the hangup is killed and reaped, not left a zombie" { const pid = libc.fork(); if (pid == 0) { |
