const builtin = @import("builtin"); const std = @import("std"); const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); const ninep_io = @import("9p_io.zig"); const filesystem = @import("fs.zig"); pub const Host = struct { ctx: ?*anyopaque = null, vtable: *const VTable = &.{}, pub const VTable = struct { wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null, present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, post_present: ?*const fn (ctx: ?*anyopaque) void = null, poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, detach: ?*const fn (ctx: ?*anyopaque) void = null, spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, pty_resize: ?*const fn (ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void = null, pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null, close_pty: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null, tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null, /// Answered at once, not queued as an effect: Kill asks while the /// job it saw running is still the one in the tty (host_io.killJob). kill_job: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null, gpio_toggle: ?*const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool = null, write_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void = null, write_dump: ?*const fn (ctx: ?*anyopaque, bytes: []const u8) void = null, watch_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool, mode: pardes.WatchMode) void = null, watch_theme: ?*const fn (ctx: ?*anyopaque, generation: u32, on: bool) void = null, dump_themes: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null, set_clipboard: ?*const fn (ctx: ?*anyopaque, text: []const u8) void = null, read_clipboard: ?*const fn (ctx: ?*anyopaque) void = null, open_link: ?*const fn (ctx: ?*anyopaque, url: []const u8) void = null, lsp: ?*const fn (ctx: ?*anyopaque, req: Lsp.Request) void = null, pipe: ?*const fn (ctx: ?*anyopaque, id: u32) void = null, }; }; pub const Fallback = struct { pub const dump_path = "pardes.dump.zon"; gpa: std.mem.Allocator, files: std.StringHashMapUnmanaged([]u8) = .empty, clipboard: std.ArrayListUnmanaged(u8) = .empty, link: std.ArrayListUnmanaged(u8) = .empty, spawned: [pardes.MAX_PANES]bool = @splat(false), watched: [pardes.MAX_PANES]bool = @splat(false), pub fn deinit(f: *Fallback) void { var it = f.files.iterator(); while (it.next()) |entry| { f.gpa.free(entry.key_ptr.*); f.gpa.free(entry.value_ptr.*); } f.files.deinit(f.gpa); f.clipboard.deinit(f.gpa); f.link.deinit(f.gpa); } pub fn writeFile(f: *Fallback, path: []const u8, bytes: []const u8) bool { const copy = f.gpa.dupe(u8, bytes) catch return false; if (f.files.getEntry(path)) |entry| { f.gpa.free(entry.value_ptr.*); entry.value_ptr.* = copy; return true; } const key = f.gpa.dupe(u8, path) catch { f.gpa.free(copy); return false; }; f.files.put(f.gpa, key, copy) catch { f.gpa.free(key); f.gpa.free(copy); return false; }; return true; } pub fn get(f: *const Fallback, path: []const u8) ?[]const u8 { if (f.files.get(path)) |written| return written; return filesystem.sourceBytes(path); } pub fn setClipboard(f: *Fallback, text: []const u8) void { f.clipboard.clearRetainingCapacity(); f.clipboard.appendSlice(f.gpa, text) catch {}; } pub fn setLink(f: *Fallback, url: []const u8) void { f.link.clearRetainingCapacity(); f.link.appendSlice(f.gpa, url) catch {}; } }; pub const Lsp = struct { pub const Request = struct { id: u32, kind: pardes.lsp.Kind, pane: u8, offset: u32, arg: []const u8, }; pub const Task = struct { id: u32, future: std.Io.Future(anyerror!void), }; /// One language query, owned by the worker that runs it. pub const Job = struct { id: u32, kind: pardes.lsp.Kind, offset: u32, path: []u8, source: [:0]u8, arg: []u8, root: []u8, pub fn free(job: *Job, gpa: std.mem.Allocator) void { gpa.free(job.path); gpa.free(job.source); gpa.free(job.arg); gpa.free(job.root); gpa.destroy(job); } }; // Copy before starting a worker; the editor may replace any source slice afterward. pub fn snapshot(gpa: std.mem.Allocator, core: *const pardes.Pardes, req: Request) !*Job { if (req.pane >= core.panes.len) return error.NoPane; const pane = core.panes[req.pane] orelse return error.NoPane; const file = pane.file; const job = try gpa.create(Job); errdefer gpa.destroy(job); const declared_path = if (file) |f| f.path else ""; const path = try gpa.dupe(u8, filesystem.localPath(declared_path) orelse declared_path); errdefer gpa.free(path); const source = try gpa.dupeZ(u8, if (file) |f| f.content else ""); errdefer gpa.free(source); const arg = try gpa.dupe(u8, req.arg); errdefer gpa.free(arg); const declared_root = if (file) |f| std.fs.path.dirname(f.path) orelse "/" else pane.cwdSlice(); const root = try gpa.dupe(u8, filesystem.localPath(declared_root) orelse declared_root); job.* = .{ .id = req.id, .kind = req.kind, .offset = req.offset, .path = path, .source = source, .arg = arg, .root = root, }; return job; } // Null is failure; the receiver frees non-null rows with the job's allocator. pub const Deliver = *const fn (ctx: ?*anyopaque, id: u32, rows: ?[]u8) void; pub fn work(gpa: std.mem.Allocator, job: *Job, ctx: ?*anyopaque, deliver: Deliver) void { defer job.free(gpa); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); pardes.lsp.query(gpa, arena.allocator(), .{ .kind = job.kind, .path = job.path, .source = job.source, .offset = job.offset, .arg = job.arg, .root = job.root, }, &out.writer) catch { deliver(ctx, job.id, null); return; }; const rows = out.toOwnedSlice() catch { deliver(ctx, job.id, null); return; }; deliver(ctx, job.id, rows); } test "a snapshot owns every byte the backend will read" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); var needle: [6]u8 = "needle".*; const job = try snapshot(gpa, core, .{ .id = 7, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = &needle, }); defer job.free(gpa); try std.testing.expectEqual(@as(u32, 7), job.id); try std.testing.expectEqual(pardes.lsp.Kind.status, job.kind); try std.testing.expectEqualStrings("needle", job.arg); try std.testing.expect(job.arg.ptr != &needle); try std.testing.expectEqualStrings("", job.path); try std.testing.expectEqual(@as(usize, 0), job.source.len); try std.testing.expectEqual(@as(u8, 0), job.source[0]); const pane = core.panes[core.active].?; try std.testing.expectEqualStrings(pane.cwdSlice(), job.root); if (job.root.len > 0) try std.testing.expect(job.root.ptr != pane.cwdSlice().ptr); } test "LSP snapshot frees every partially copied field on allocation failure" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); _ = try core.setTestFile("const copied = true;\n"); const Snapshot = struct { fn check(gpa: std.mem.Allocator, p: *const pardes.Pardes) !void { const job = try snapshot(gpa, p, .{ .id = 7, .kind = .hover, .pane = @intCast(p.active), .offset = 6, .arg = "query", }); defer job.free(gpa); try std.testing.expectEqualStrings("const copied = true;\n", job.source); } }; try std.testing.checkAllAllocationFailures(std.testing.allocator, Snapshot.check, .{core}); } test "LSP snapshots translate explicit OS paths once and retain virtual names" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("const value = true;\n"); for ([_]struct { path: []const u8, native: []const u8 }{ .{ .path = "/n/os/project/file.zig", .native = "/project/file.zig" }, .{ .path = "/n/os/n/os/project/file.zig", .native = "/n/os/project/file.zig" }, .{ .path = "/virtual/src/file.zig", .native = "/virtual/src/file.zig" }, }) |case| { const replacement = try gpa.dupe(u8, case.path); gpa.free(pane.file.?.path); pane.file.?.path = replacement; const job = try snapshot(gpa, core, .{ .id = 1, .kind = .hover, .pane = @intCast(core.active), .offset = 0, .arg = "" }); defer job.free(gpa); try std.testing.expectEqualStrings(case.native, job.path); try std.testing.expectEqualStrings(std.fs.path.dirname(case.native).?, job.root); try std.testing.expectEqualStrings(case.path, pane.file.?.path); } } test "a pane that is gone yields no job rather than a null deref" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); // The effect is drained after the core has moved on, so the pane it names // may already have been deleted. Every shell open-coded this check. const empty = for (core.panes, 0..) |slot, id| { if (slot == null) break @as(u8, @intCast(id)); } else return error.NoEmptyPane; try std.testing.expectError(error.NoPane, snapshot(gpa, core, .{ .id = 1, .kind = .definition, .pane = empty, .offset = 0, .arg = "", })); } test "work consumes the job and hands its rows to the sink" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const Sink = struct { var seen_id: u32 = 0; var seen_rows: ?[]u8 = null; fn take(_: ?*anyopaque, id: u32, rows: ?[]u8) void { seen_id = id; seen_rows = rows; } }; Sink.seen_id = 0; Sink.seen_rows = null; const job = try snapshot(gpa, core, .{ .id = 42, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "", }); work(gpa, job, null, Sink.take); // `status` is the one kind that answers with no file and no cursor, which // is what makes it assertable here without a language server on the box. try std.testing.expectEqual(@as(u32, 42), Sink.seen_id); const rows = Sink.seen_rows orelse return error.SinkNeverCalled; defer gpa.free(rows); } test "LSP edit query failure leaves text and undo untouched before a successful retry" { if (!pardes.lsp.supports.contains(.format)) return; const Sink = struct { core: *pardes.Pardes, gpa: std.mem.Allocator, calls: usize = 0, failed: bool = false, fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const self: *@This() = @ptrCast(@alignCast(ctx.?)); defer if (rows) |text| self.gpa.free(text); self.calls += 1; self.failed = rows == null; self.core.update(.{ .lsp_resp = .{ .id = id, .rows = rows } }); } }; const gpa = std.testing.allocator; for ([_]pardes.lsp.Kind{ .format, .rename }) |kind| { const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); const pane = try core.setTestFile("const value=1;\n"); const path = try gpa.dupe(u8, "/file.zig"); gpa.free(pane.file.?.path); pane.file.?.path = path; pane.body.cur_col = 6; const revision = pane.file.?.revision; const undo_len = pane.file.?.history.undo_len; var failing = std.testing.FailingAllocator.init(gpa, .{}); var sink: Sink = .{ .core = core, .gpa = failing.allocator() }; for ([_]bool{ true, false }) |fail| { failing.fail_index = std.math.maxInt(usize); core.lspRequest(core.active, kind, "renamed"); const job = try snapshot(failing.allocator(), core, .{ .id = core.lsp_wait.?.id, .kind = kind, .pane = @intCast(core.active), .offset = 6, .arg = "renamed", }); if (fail) failing.fail_index = failing.alloc_index; work(failing.allocator(), job, &sink, Sink.take); try std.testing.expectEqual(fail, sink.failed); try std.testing.expect(core.lsp_wait == null); if (fail) { try std.testing.expectEqualStrings("const value=1;\n", pane.file.?.content); try std.testing.expectEqual(revision, pane.file.?.revision); try std.testing.expectEqual(undo_len, pane.file.?.history.undo_len); } else { try std.testing.expectEqualStrings(if (kind == .format) "const value = 1;\n" else "const renamed=1;\n", pane.file.?.content); try std.testing.expectEqual(undo_len + 1, pane.file.?.history.undo_len); } } try std.testing.expectEqual(@as(usize, 2), sink.calls); } } test "LSP work delivers failure when transferring result ownership cannot allocate" { if (!pardes.lsp.supports.contains(.status)) return; const TransferAllocator = struct { failed: bool = false, fail_copy: bool = false, fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 { const self: *@This() = @ptrCast(@alignCast(ctx)); if (self.fail_copy) { self.failed = true; return null; } return std.testing.allocator.rawAlloc(len, alignment, ra); } fn resize(_: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, len: usize, ra: usize) bool { return std.testing.allocator.rawResize(bytes, alignment, len, ra); } fn remap(ctx: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, len: usize, ra: usize) ?[*]u8 { const self: *@This() = @ptrCast(@alignCast(ctx)); if (len < bytes.len) { self.fail_copy = true; return null; } return std.testing.allocator.rawRemap(bytes, alignment, len, ra); } fn free(_: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, ra: usize) void { std.testing.allocator.rawFree(bytes, alignment, ra); } }; const Sink = struct { calls: usize = 0, id: u32 = 0, rows: ?[]u8 = null, fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const self: *@This() = @ptrCast(@alignCast(ctx.?)); self.calls += 1; self.id = id; self.rows = rows; } }; var allocator: TransferAllocator = .{}; const gpa: std.mem.Allocator = .{ .ptr = &allocator, .vtable = &.{ .alloc = TransferAllocator.alloc, .resize = TransferAllocator.resize, .remap = TransferAllocator.remap, .free = TransferAllocator.free, } }; const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); const job = try snapshot(gpa, core, .{ .id = 37, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "" }); var sink: Sink = .{}; work(gpa, job, &sink, Sink.take); if (sink.rows) |rows| gpa.free(rows); try std.testing.expect(allocator.failed); try std.testing.expectEqual(@as(usize, 1), sink.calls); try std.testing.expectEqual(@as(u32, 37), sink.id); try std.testing.expect(sink.rows == null); } }; test { _ = Lsp; } pub const Shell = struct { const X_OK: c_int = 1; extern "c" fn mkstemp(template: [*:0]u8) c_int; extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; const path_capacity = 4096; const max_path_files = 64; // Repair only the system-only PATH inherited from a macOS GUI launch. fn adoptSystemPath() void { if (comptime builtin.os.tag != .macos) return; var buf: [path_capacity]u8 = undefined; var len: usize = 0; collectSystemPath("/etc/paths", "/etc/paths.d", &buf, &len); if (len == 0) return; const system = buf[0..len]; const current: []const u8 = if (libc.getenv("PATH")) |p| std.mem.span(p) else ""; if (!allEntriesWithin(current, system)) return; if (std.mem.eql(u8, current, system)) return; var out: [path_capacity:0]u8 = undefined; if (len >= out.len) return; @memcpy(out[0..len], system); out[len] = 0; _ = setenv("PATH", out[0..len :0].ptr, 1); } // Run in the parent before forking; children borrow the completed prompt files. pub fn prepare() PromptFiles { adoptSystemPath(); if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); return PromptFiles.init(); } fn collectSystemPath(paths_file: [:0]const u8, paths_dir: []const u8, buf: []u8, len: *usize) void { var file_buf: [path_capacity]u8 = undefined; if (readSmall(paths_file, &file_buf)) |body| appendLines(buf, len, body); const io = std.Io.Threaded.global_single_threaded.io(); var dir = std.Io.Dir.cwd().openDir(io, paths_dir, .{ .iterate = true }) catch return; defer dir.close(io); var names: [max_path_files][256]u8 = undefined; var name_lens: [max_path_files]usize = undefined; var count: usize = 0; var it = dir.iterate(); while (count < names.len) { const entry = (it.next(io) catch break) orelse break; if (entry.kind == .directory) continue; if (entry.name.len == 0 or entry.name.len > names[count].len) continue; @memcpy(names[count][0..entry.name.len], entry.name); name_lens[count] = entry.name.len; count += 1; } var order: [max_path_files]usize = undefined; for (0..count) |i| order[i] = i; std.mem.sort(usize, order[0..count], Names{ .names = &names, .lens = &name_lens }, Names.lessThan); var path_buf: [512]u8 = undefined; for (order[0..count]) |i| { const name = names[i][0..name_lens[i]]; const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ paths_dir, name }, 0) catch continue; if (readSmall(path, &file_buf)) |body| appendLines(buf, len, body); } } const Names = struct { names: *const [max_path_files][256]u8, lens: *const [max_path_files]usize, fn lessThan(self: Names, a: usize, b: usize) bool { return std.mem.order(u8, self.names[a][0..self.lens[a]], self.names[b][0..self.lens[b]]) == .lt; } }; fn appendLines(buf: []u8, len: *usize, body: []const u8) void { var lines = std.mem.splitScalar(u8, body, '\n'); while (lines.next()) |raw| appendEntry(buf, len, std.mem.trim(u8, raw, " \t\r")); } fn appendEntry(buf: []u8, len: *usize, entry: []const u8) void { if (entry.len == 0) return; if (hasEntry(buf[0..len.*], entry)) return; const separator: usize = if (len.* == 0) 0 else 1; if (len.* + separator + entry.len > buf.len) return; if (separator == 1) { buf[len.*] = ':'; len.* += 1; } @memcpy(buf[len.*..][0..entry.len], entry); len.* += entry.len; } fn hasEntry(list: []const u8, entry: []const u8) bool { var it = std.mem.tokenizeScalar(u8, list, ':'); while (it.next()) |have| if (std.mem.eql(u8, have, entry)) return true; return false; } fn allEntriesWithin(candidate: []const u8, list: []const u8) bool { var it = std.mem.tokenizeScalar(u8, candidate, ':'); while (it.next()) |entry| if (!hasEntry(list, entry)) return false; return true; } fn readSmall(path: [:0]const u8, buf: []u8) ?[]const u8 { const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); if (fd < 0) return null; defer _ = libc.close(fd); var off: usize = 0; while (off < buf.len) { const n = libc.read(fd, buf[off..].ptr, buf.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; return null; } if (n == 0) break; off += @intCast(n); } return buf[0..off]; } test "the launchd PATH is replaced and a configured one is left alone" { var buf: [256]u8 = undefined; var len: usize = 0; appendEntry(&buf, &len, "/usr/bin"); appendEntry(&buf, &len, "/bin"); appendEntry(&buf, &len, "/usr/bin"); // already there: dedup keeps the first appendEntry(&buf, &len, ""); try std.testing.expectEqualStrings("/usr/bin:/bin", buf[0..len]); try std.testing.expect(allEntriesWithin("/usr/bin:/bin", "/usr/bin:/bin:/sbin")); try std.testing.expect(allEntriesWithin("", "/usr/bin")); try std.testing.expect(!allEntriesWithin("/Users/x/.cargo/bin:/usr/bin", "/usr/bin:/bin")); try std.testing.expect(!allEntriesWithin("/opt/homebrew/bin", "/usr/bin:/bin")); } test "system path files are sorted and duplicate directories keep their first position" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths", .data = " /usr/bin \n/bin\n\n/usr/bin\n" }); try tmp.dir.createDirPath(std.testing.io, "paths.d"); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths.d/20-last", .data = "/opt/local/bin\n/usr/bin\n" }); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths.d/10-first", .data = "/opt/homebrew/bin\n/bin\n" }); var root_buf: [std.fs.max_path_bytes]u8 = undefined; const root = root_buf[0..try tmp.dir.realPath(std.testing.io, &root_buf)]; var file_buf: [std.fs.max_path_bytes]u8 = undefined; const paths_file = try std.fmt.bufPrintSentinel(&file_buf, "{s}/paths", .{root}, 0); var dir_buf: [std.fs.max_path_bytes]u8 = undefined; const paths_dir = try std.fmt.bufPrint(&dir_buf, "{s}/paths.d", .{root}); var buf: [path_capacity]u8 = undefined; var len: usize = 0; collectSystemPath(paths_file, paths_dir, &buf, &len); try std.testing.expectEqualStrings("/usr/bin:/bin:/opt/homebrew/bin:/opt/local/bin", buf[0..len]); } const Family = enum { bash, fish, none }; fn family(bin: []const u8) Family { const slash = std.mem.lastIndexOfScalar(u8, bin, '/'); const base = if (slash) |s| bin[s + 1 ..] else bin; if (std.mem.startsWith(u8, base, "bash")) return .bash; if (std.mem.startsWith(u8, base, "fish")) return .fish; return .none; } const bash_rc = \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" \\PS1='\[\e]133;A;cl=line;aid=pardes\a\]'"$PS1"'\[\e]133;B;aid=pardes\a\]' \\PS2='\[\e]133;A;k=c;aid=pardes\a\]'"$PS2"'\[\e]133;B;aid=pardes\a\]' \\PROMPT_COMMAND='printf "\e]133;D;%s;aid=pardes\a" "$?"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" \\if ((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 404)); then \\ PS0='\e]133;C;aid=pardes\a'"$PS0" \\else \\ trap 'printf "\e]133;C;aid=pardes\a"' DEBUG \\fi \\ ; // fish -C runs after config.fish; bash --rcfile must source .bashrc itself. const fish_rc = \\functions -c fish_prompt __pardes_user_prompt \\function fish_prompt \\ printf '\e]133;A;cl=line;aid=pardes\a' \\ __pardes_user_prompt \\ printf '\e]133;B;aid=pardes\a' \\end \\function __pardes_preexec --on-event fish_preexec \\ printf '\e]133;C;aid=pardes\a' \\end \\function __pardes_postexec --on-event fish_postexec \\ printf '\e]133;D;%s;aid=pardes\a' $status \\end \\ ; const rc_path_capacity = 64; // Private files live until host teardown. Lengths keep this value movable. pub const PromptFiles = struct { bash_path: [rc_path_capacity:0]u8 = @splat(0), bash_len: u8 = 0, fish_path: [rc_path_capacity:0]u8 = @splat(0), fish_len: u8 = 0, fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0), fish_command_len: u8 = 0, pub fn init() PromptFiles { var rcs: PromptFiles = .{}; rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc); rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc); if (rcs.fishPath()) |path| { const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch { _ = libc.unlink(path.ptr); rcs.fish_len = 0; return rcs; }; rcs.fish_command_len = @intCast(command.len); } return rcs; } pub fn deinit(rcs: *PromptFiles) void { if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr); if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr); rcs.bash_len = 0; rcs.fish_len = 0; rcs.fish_command_len = 0; } fn bashPath(rcs: *const PromptFiles) ?[:0]const u8 { if (rcs.bash_len == 0) return null; return rcs.bash_path[0..rcs.bash_len :0]; } fn fishPath(rcs: *const PromptFiles) ?[:0]const u8 { if (rcs.fish_len == 0) return null; return rcs.fish_path[0..rcs.fish_len :0]; } fn fishCommand(rcs: *const PromptFiles) ?[:0]const u8 { if (rcs.fish_command_len == 0) return null; return rcs.fish_command[0..rcs.fish_command_len :0]; } }; // Publish a path only after its private 0600 file is fully written and closed. fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 { const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0; const fd = mkstemp(path.ptr); if (fd < 0) return 0; var off: usize = 0; while (off < contents.len) { const n = libc.write(fd, contents[off..].ptr, contents.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; _ = libc.close(fd); _ = libc.unlink(path.ptr); return 0; } if (n == 0) { _ = libc.close(fd); _ = libc.unlink(path.ptr); return 0; } off += @intCast(n); } if (libc.close(fd) != 0) { _ = libc.unlink(path.ptr); return 0; } return @intCast(path.len); } test "shell family is the basename's prefix, and anything else runs unadorned" { try std.testing.expectEqual(Family.fish, family("fish")); try std.testing.expectEqual(Family.fish, family("/usr/bin/fish")); try std.testing.expectEqual(Family.fish, family("/opt/homebrew/bin/fish")); try std.testing.expectEqual(Family.bash, family("bash")); try std.testing.expectEqual(Family.bash, family("/bin/bash")); try std.testing.expectEqual(Family.bash, family("/usr/bin/bash-5.2")); try std.testing.expectEqual(Family.fish, family("/usr/local/bin/fish-3.7")); try std.testing.expectEqual(Family.none, family("/opt/fish/bin/nu")); try std.testing.expectEqual(Family.none, family("/usr/bin/zsh")); try std.testing.expectEqual(Family.none, family("/bin/sh")); try std.testing.expectEqual(Family.none, family("nu")); try std.testing.expectEqual(Family.none, family("")); } const bin_dirs = [_][]const u8{ "/usr/bin/", "/bin/", "/usr/local/bin/", "/opt/homebrew/bin/", "/opt/local/bin/", "/usr/sbin/", }; const fallbacks = [_][]const u8{ if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash", "/bin/sh", }; pub const Spawn = struct { path: [*:0]const u8, argv: [4:null]?[*:0]const u8, }; // Resolve in the parent. The path buffer and prompt files must survive through exec. pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8, prompt_rcs: *const PromptFiles) Spawn { const path = find(bin, buf) orelse fallback(buf); const marks: [2]?[*:0]const u8 = switch (family(std.mem.span(path))) { .bash => if (prompt_rcs.bashPath()) |rc| .{ "--rcfile", rc.ptr } else .{ null, null }, .fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null }, .none => .{ null, null }, }; 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 { if (bin.len == 0 or bin.len + 1 > buf.len) return null; if (std.mem.indexOfScalar(u8, bin, '/') != null) { @memcpy(buf[0..bin.len], bin); buf[bin.len] = 0; const p: [*:0]const u8 = @ptrCast(buf); return if (libc.access(p, X_OK) == 0) p else null; } for (bin_dirs) |dir| { if (dir.len + bin.len + 1 > buf.len) continue; @memcpy(buf[0..dir.len], dir); @memcpy(buf[dir.len..][0..bin.len], bin); buf[dir.len + bin.len] = 0; const p: [*:0]const u8 = @ptrCast(buf); if (libc.access(p, X_OK) == 0) return p; } return null; } fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 { for (fallbacks) |f| { @memcpy(buf[0..f.len], f); buf[f.len] = 0; const p: [*:0]const u8 = @ptrCast(buf); if (libc.access(p, X_OK) == 0) return p; } return @ptrCast(buf); } test "a path is taken at its word, a name is looked up, and both pick their own marks" { if (builtin.os.tag == .windows) return; var buf: [std.fs.max_path_bytes]u8 = undefined; var prompt_rcs = PromptFiles.init(); defer prompt_rcs.deinit(); const sh = resolve("/bin/sh", &buf, &prompt_rcs); try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path)); try std.testing.expect(sh.argv[1] == null); const bash = resolve("bash", &buf, &prompt_rcs); try std.testing.expect(family(std.mem.span(bash.path)) == .bash); try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?)); try std.testing.expectEqualStrings(prompt_rcs.bashPath().?, std.mem.span(bash.argv[2].?)); const missing = resolve("zznosuchshell", &buf, &prompt_rcs); try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path))); try std.testing.expect(libc.access(missing.path, X_OK) == 0); const gone = resolve("/zz/no/such/shell", &buf, &prompt_rcs); try std.testing.expect(libc.access(gone.path, X_OK) == 0); } test "prompt rc owners have private complete files and clean them up" { if (builtin.os.tag == .windows) return; var original = PromptFiles.init(); var a = original; original = .{}; original.deinit(); defer a.deinit(); var b = PromptFiles.init(); defer b.deinit(); const a_bash = a.bashPath() orelse return error.TempCreateFailed; const b_bash = b.bashPath() orelse return error.TempCreateFailed; const a_fish = a.fishPath() orelse return error.TempCreateFailed; try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash)); const fish_command = a.fishCommand() orelse return error.MissingFishCommand; try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]); try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]); for ([_][]const u8{ a_bash, b_bash, a_fish }) |path| { const stat = try std.Io.Dir.cwd().statFile(std.testing.io, path, .{}); try std.testing.expectEqual(std.Io.File.Kind.file, stat.kind); try std.testing.expectEqual(0, stat.permissions.toMode() & 0o077); } var fish_buf: [fish_rc.len]u8 = undefined; try std.testing.expectEqualStrings(fish_rc, readSmall(a_fish, &fish_buf) orelse return error.ReadFailed); var buf: [bash_rc.len]u8 = undefined; const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY }); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); var len: usize = 0; while (len < buf.len) { const n = libc.read(fd, buf[len..].ptr, buf.len - len); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } if (n == 0) break; len += @intCast(n); } try std.testing.expectEqualStrings(bash_rc, buf[0..len]); var removed: [rc_path_capacity:0]u8 = @splat(0); @memcpy(removed[0..a_bash.len], a_bash); removed[a_bash.len] = 0; a.deinit(); try std.testing.expect(libc.access(&removed, 0) < 0); try std.testing.expectEqualStrings(bash_rc, readSmall(b_bash, &buf) orelse return error.ReadFailed); } }; extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn execve(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) c_int; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; pub const Child = struct { file: std.Io.File, pid: posix.pid_t, }; /// The environment a pty child is handed: everything this process carries, /// with the terminal's own identity written over whatever the launcher left /// behind. The child is talking to the bundled VT, never to the terminal /// pardes itself was started from, and a macOS `.app` launch supplies no /// `TERM` at all — which is exactly why `clear` fails there, why nothing /// paints a colour, and why every cursor-addressing program falls back to a /// dumb line-at-a-time mode. pub const ChildEnv = struct { /// Entries pardes owns. The name of each is read back out of its own /// spelling, so there is one place to edit and no second list to drift. const own = [_][*:0]const u8{ "TERM=" ++ pardes.config.child_term, "COLORTERM=" ++ pardes.config.child_colorterm, "TERM_PROGRAM=" ++ pardes.config.child_term_program, }; /// Wide enough for any real environment; `build` answers null rather than /// truncate, because a child missing half its variables is a worse bug /// than one missing TERM, and the caller then execs with `environ` intact. pub const Slots = [1024]?[*:0]const u8; fn name(entry: [*:0]const u8) []const u8 { const line = std.mem.span(entry); return line[0 .. std.mem.indexOfScalar(u8, line, '=') orelse line.len]; } pub fn build(slots: *Slots) ?[*:null]const ?[*:0]const u8 { var n: usize = 0; var i: usize = 0; while (libc.environ[i]) |entry| : (i += 1) { const inherited = name(entry); const shadowed = for (own) |mine| { if (std.mem.eql(u8, inherited, name(mine))) break true; } else false; if (shadowed) continue; if (n + own.len + 1 > slots.len) return null; slots[n] = entry; n += 1; } for (own) |mine| { slots[n] = mine; n += 1; } slots[n] = null; return @ptrCast(slots); } }; test "the child environment replaces the launcher's terminal identity exactly once" { const saved: ?[]const u8 = if (libc.getenv("TERM")) |t| std.mem.span(t) else null; var saved_buf: [256]u8 = undefined; const restore: ?[:0]const u8 = if (saved) |t| blk: { if (t.len >= saved_buf.len) break :blk null; @memcpy(saved_buf[0..t.len], t); saved_buf[t.len] = 0; break :blk saved_buf[0..t.len :0]; } else null; defer if (restore) |t| { _ = Shell.setenv("TERM", t.ptr, 1); } else { _ = unsetenv("TERM"); }; // A launcher TERM must be shadowed rather than duplicated, and the rest of // the environment must arrive untouched. try std.testing.expectEqual(@as(c_int, 0), Shell.setenv("TERM", "dumb", 1)); try std.testing.expectEqual(@as(c_int, 0), Shell.setenv("PARDES_CHILD_ENV_PROBE", "1", 1)); defer _ = unsetenv("PARDES_CHILD_ENV_PROBE"); var slots: ChildEnv.Slots = undefined; const envp = ChildEnv.build(&slots) orelse return error.EnvironmentTooLarge; var terms: usize = 0; var colorterms: usize = 0; var probes: usize = 0; var dumb = false; var i: usize = 0; while (envp[i]) |entry| : (i += 1) { const line = std.mem.span(entry); if (std.mem.startsWith(u8, line, "TERM=")) terms += 1; if (std.mem.startsWith(u8, line, "COLORTERM=")) colorterms += 1; if (std.mem.eql(u8, line, "TERM=dumb")) dumb = true; if (std.mem.eql(u8, line, "PARDES_CHILD_ENV_PROBE=1")) probes += 1; } try std.testing.expectEqual(@as(usize, 1), terms); try std.testing.expectEqual(@as(usize, 1), colorterms); try std.testing.expectEqual(@as(usize, 1), probes); try std.testing.expect(!dumb); // TERM_PROGRAM shares the prefix "TERM"; the match is on the name, so it // is a separate entry and never a second TERM. try std.testing.expectEqualStrings("TERM=" ++ pardes.config.child_term, std.mem.span(envp[i - ChildEnv.own.len].?)); } 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, prompt_rcs: *const Shell.PromptFiles, bin: []const u8, cwd: []const u8, rows: u16, cols: u16, fs: ?*const ninep_io.Listener, ) !Child { 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. pardes.turn.yield(); defer pardes.turn.back(); const stat = try std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), path, .{}); if (stat.kind != .directory) return error.NotDir; break :dir path; }; // The turn was given up for the stat: 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; var master: c_int = -1; var path_buf: [std.fs.max_path_bytes]u8 = undefined; // A terminal opened on a shell of its own (`Tty fish`) runs that one. const own = if (core) |c| if (c.panes[pane]) |pn| pn.shell else null else null; var spawn = Shell.resolve(own orelse bin, &path_buf, prompt_rcs); // 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 = 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; break :blk try @import("linux/v9fs.zig").helperPath(&helper_buf); } else null; // The last argument is whether this session takes its children's Looks — // every session does but a `--nested` one, and a fork with no core at all // (the tests below) is nobody's parent. ninep_io.exportPaneEnv( fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0, if (core) |c| !c.opts.nested else false, ); if (helper) |path| { const c = core.?; const pn = c.panes[pane].?; const socket = libc.getenv("PARDES_9P") orelse return error.SessionSocketRequired; var command: std.Io.Writer.Allocating = .init(c.gpa); defer command.deinit(); try @import("linux/v9fs.zig").writeLaunchCommand(&command.writer, path, std.mem.span(socket), &spawn.argv); if (!try pardes.panes.Terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted; } // Built here and not after the fork: between fork and exec the child may // not call setenv, whose malloc can deadlock against a pty reader thread // that held the heap when the fork took its snapshot. var env_slots: ChildEnv.Slots = undefined; const envp = ChildEnv.build(&env_slots); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid < 0) return error.ForkFailed; if (pid == 0) { var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); if (cwd_z) |path| if (chdir(path.ptr) != 0) _exit(126); if (envp) |env| _ = execve(spawn.path, &spawn.argv, env); _ = execv(spawn.path, &spawn.argv); _exit(127); } ninep_io.setCloexec(master); 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 }; } test "shell spawn rejects invalid directories before creating a child" { const rcs: Shell.PromptFiles = .{}; try std.testing.expectError(error.InvalidPath, forkShell(null, 0, &rcs, "/bin/sh", "/tmp\x00/ignored", 24, 80, null)); const too_long = [_]u8{'x'} ** 4096; try std.testing.expectError(error.NameTooLong, forkShell(null, 0, &rcs, "/bin/sh", &too_long, 24, 80, null)); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "file", .data = "not a directory\n" }); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)]; var path_buf: [4096]u8 = undefined; const file = try std.fmt.bufPrint(&path_buf, "{s}/file", .{directory}); try std.testing.expectError(error.NotDir, forkShell(null, 0, &rcs, "/bin/sh", file, 24, 80, null)); const explicit_file = try std.fmt.bufPrint(&path_buf, "/n/os{s}/file", .{directory}); try std.testing.expectError(error.NotDir, forkShell(null, 0, &rcs, "/bin/sh", explicit_file, 24, 80, null)); const missing = try std.fmt.bufPrint(&path_buf, "{s}/missing/" ++ ("child/" ** 50), .{directory}); try std.testing.expect(missing.len > 256); try std.testing.expectError(error.FileNotFound, forkShell(null, 0, &rcs, "/bin/sh", missing, 24, 80, null)); } test "a forked shell is told which terminal it is talking to" { if (!haveFile("/bin/sh")) return error.SkipZigTest; // Whatever launched the test is the wrong answer, so make it a loud one. const saved = libc.getenv("TERM"); var saved_buf: [256]u8 = undefined; const restore: ?[:0]const u8 = if (saved) |t| blk: { const span = std.mem.span(t); if (span.len >= saved_buf.len) break :blk null; @memcpy(saved_buf[0..span.len], span); saved_buf[span.len] = 0; break :blk saved_buf[0..span.len :0]; } else null; defer if (restore) |t| { _ = Shell.setenv("TERM", t.ptr, 1); } else { _ = unsetenv("TERM"); }; _ = Shell.setenv("TERM", "dumb", 1); const rcs: Shell.PromptFiles = .{}; const child = try forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); defer { _ = libc.kill(child.pid, libc.SIG.KILL); _ = libc.waitpid(child.pid, null, 0); _ = libc.close(child.file.handle); } var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid }; // The echoed command carries the unexpanded `$TERM`, so only the shell's // own expansion can match this. try std.testing.expect(writeFd(child.file.handle, "printf '\nPARDES-TERM:%s:%s\n' \"$TERM\" \"$COLORTERM\"; exit\n")); try std.testing.expect(sh.waitText( "PARDES-TERM:" ++ pardes.config.child_term ++ ":" ++ pardes.config.child_colorterm, 5_000, )); } test "shell spawn uses an explicit OS directory longer than 256 bytes" { if (!haveFile("/bin/sh")) return error.SkipZigTest; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const sub_path = "nested-directory-with-more-than-forty-characters/" ** 7; try tmp.dir.createDirPath(std.testing.io, sub_path); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPathFile(std.testing.io, sub_path, &directory_buf)]; try std.testing.expect(directory.len > 256); var explicit_buf: [4096]u8 = undefined; const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{directory}); const rcs: Shell.PromptFiles = .{}; const child = try forkShell(null, 0, &rcs, "/bin/sh", explicit, 24, 80, null); defer { _ = libc.kill(child.pid, libc.SIG.KILL); _ = libc.waitpid(child.pid, null, 0); _ = libc.close(child.file.handle); } var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid }; try std.testing.expect(writeFd(child.file.handle, "printf '\\nPARDES-CWD:'; pwd; exit\n")); var expected_buf: [4096]u8 = undefined; const expected = try std.fmt.bufPrint(&expected_buf, "PARDES-CWD:{s}", .{directory}); try std.testing.expect(sh.waitText(expected, 5_000)); } pub fn writeFd(fd: c_int, data: []const u8) bool { var off: usize = 0; while (off < data.len) { const n = libc.write(fd, data[off..].ptr, data.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; return false; } if (n == 0) return false; off += @intCast(n); } return true; } const vnode_info_path = extern struct { vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid path: [1024]u8, // MAXPATHLEN }; const proc_vnodepathinfo = extern struct { cdir: vnode_info_path, rdir: vnode_info_path, }; const PROC_PIDVNODEPATHINFO: c_int = 9; extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; pub fn shellCwd(pid: libc.pid_t, buf: []u8) ?[]const u8 { switch (builtin.os.tag) { .linux => { var pbuf: [64]u8 = undefined; const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null; const n = libc.readlink(path, buf.ptr, buf.len); if (n <= 0 or n >= buf.len) return null; return buf[0..@intCast(n)]; }, .macos, .ios, .tvos, .watchos, .visionos => { var info: proc_vnodepathinfo = undefined; const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo)); if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null; const path = std.mem.sliceTo(&info.cdir.path, 0); if (path.len == 0 or path.len == info.cdir.path.len or path.len > buf.len) return null; @memcpy(buf[0..path.len], path); return buf[0..path.len]; }, else => return null, } } test "shell cwd rejects truncation and preserves an owned child path longer than 1024 bytes" { if (comptime builtin.os.tag != .linux) return error.SkipZigTest; if (!haveFile("/bin/sh")) return error.SkipZigTest; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const nested = "nested-directory-with-more-than-forty-characters/" ** 24; try tmp.dir.createDirPath(io, nested); var path_buf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; const directory = path_buf[0..try tmp.dir.realPathFile(io, nested, &path_buf)]; try std.testing.expect(directory.len > 1024); const rcs: Shell.PromptFiles = .{}; const child = try forkShell(null, 0, &rcs, "/bin/sh", directory, 24, 80, null); defer { _ = libc.kill(child.pid, libc.SIG.KILL); _ = libc.waitpid(child.pid, null, 0); _ = libc.close(child.file.handle); } var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid }; try std.testing.expect(writeFd(child.file.handle, "printf '\\160ardes-cwd-ready\\n'\n")); try std.testing.expect(sh.waitText("pardes-cwd-ready", 5_000)); var result: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; try std.testing.expect(shellCwd(child.pid, result[0..0]) == null); try std.testing.expect(shellCwd(child.pid, result[0..1024]) == null); try std.testing.expect(shellCwd(child.pid, result[0..directory.len]) == null); try std.testing.expectEqualStrings(directory, shellCwd(child.pid, result[0 .. directory.len + 1]) orelse return error.MissingCwd); try std.testing.expectEqualStrings(directory, shellCwd(child.pid, &result) orelse return error.MissingCwd); } extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t; const occ_max_depth: u8 = 8; const occ_max_visited: usize = 32; /// Where `ttyTaken` answers from evidence rather than from `false`. The two /// implementations are different walks over the same question, so they share /// one suite rather than each asserting half of it. const tty_probe_platform = switch (builtin.os.tag) { .linux, .macos => true, else => false, }; const TtyProbe = struct { self_exe: [std.fs.max_path_bytes]u8 = undefined, exe: [std.fs.max_path_bytes]u8 = undefined, blob: [4096]u8 = undefined, pending: [occ_max_visited]Node = undefined, const Node = struct { pid: libc.pid_t, depth: u8 }; }; pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { switch (builtin.os.tag) { .linux => { var probe: TtyProbe = undefined; const fg = tcgetpgrp(master_fd); if (fg < 0) return false; const self_exe = procExe(shell_pid, &probe.self_exe) orelse return false; var saw_fg = fg == shell_pid; var pending: usize = 0; var visited: usize = 0; switch (pushChildren(&probe, &pending, shell_pid, 1)) { .pushed => {}, .unreadable => return false, .full => return true, } while (pending > 0) { pending -= 1; const node = probe.pending[pending]; visited += 1; if (visited > occ_max_visited) return true; const pgrp = procPgrp(node.pid, &probe.blob); if (pgrp) |g| { if (g == fg) saw_fg = true; } const exe = procExe(node.pid, &probe.exe) orelse { if (offTty(node.pid, &probe.blob)) continue; return true; }; if (!std.mem.eql(u8, exe, self_exe)) { if (pgrp) |g| if (g == fg) return true; continue; } if (node.depth >= occ_max_depth) return true; switch (pushChildren(&probe, &pending, node.pid, node.depth + 1)) { .pushed => {}, .unreadable => {}, .full => return true, } } return !saw_fg; }, .macos => { const fg = tcgetpgrp(master_fd); if (fg <= 0) return false; if (fg == shell_pid) return false; return darwin.taken(shell_pid, fg); }, else => return false, } } /// The same question the Linux walk asks, answered the way macOS can answer /// it. There is no /proc to descend and no children list, but the tty's /// foreground process group can be enumerated in one call — and "is anything /// but the shell itself holding this terminal?" is exactly a statement about /// the executables in that group. The test is the shell's own executable and /// never its pid, which is what keeps a nested interactive shell a prompt, /// and what still catches `bash -c 'sleep 30'`: the group holds `sleep` too. /// /// Until this existed, `ttyTaken` answered `false` on darwin for every pane, /// so `exec.takesCommandLine` was permanently true and every bare Escape in /// a raw terminal ran the `Last` builtin instead of reaching the child. const darwin = struct { const proc_pgrp_only: u32 = 2; const proc_pidt_shortbsdinfo: c_int = 13; const zombie_status: u32 = 5; // SZOMB /// PROC_PIDPATHINFO_MAXSIZE. const path_max = 4 * 1024; /// A foreground group is a pipeline; anything larger is not a prompt. const group_max = 64; const ShortBsdInfo = extern struct { pid: u32, ppid: u32, pgid: u32, status: u32, comm: [16]u8, flags: u32, uid: u32, gid: u32, ruid: u32, rgid: u32, svuid: u32, svgid: u32, rfu: u32, }; extern "c" fn proc_listpids(kind: u32, typeinfo: u32, buffer: ?*anyopaque, buffersize: c_int) c_int; extern "c" fn proc_pidpath(pid: c_int, buffer: [*]u8, buffersize: u32) c_int; fn exe(pid: libc.pid_t, buf: *[path_max]u8) ?[]const u8 { const got = proc_pidpath(pid, buf, buf.len); if (got <= 0) return null; return buf[0..@intCast(got)]; } /// A pid with no readable path is either gone or not ours. Only a zombie /// is provably harmless — it holds no tty — so everything else is taken. fn zombie(pid: libc.pid_t) bool { var info: ShortBsdInfo = undefined; const got = proc_pidinfo(pid, proc_pidt_shortbsdinfo, 0, &info, @sizeOf(ShortBsdInfo)); if (got != @sizeOf(ShortBsdInfo)) return false; return info.status == zombie_status; } fn taken(shell_pid: libc.pid_t, fg: libc.pid_t) bool { var shell_buf: [path_max]u8 = undefined; const shell_exe = exe(shell_pid, &shell_buf) orelse return false; var group: [group_max]libc.pid_t = undefined; const bytes = proc_listpids(proc_pgrp_only, @intCast(fg), &group, @sizeOf(@TypeOf(group))); // An empty group is a group whose last member just exited: the shell // is about to have its terminal back, and calling that taken would // blink the prompt heuristic off for a frame every time a job ends. if (bytes <= 0) return false; const n = @as(usize, @intCast(bytes)) / @sizeOf(libc.pid_t); if (n >= group.len) return true; var exe_buf: [path_max]u8 = undefined; for (group[0..n]) |pid| { if (pid <= 0 or pid == shell_pid) continue; const path = exe(pid, &exe_buf) orelse { if (zombie(pid)) continue; return true; }; if (!std.mem.eql(u8, path, shell_exe)) return true; } return false; } }; /// A terminal pane's shell is done with (its pane gone, its pty closed or at /// end of file): say hangup, for a shell that ignores the tty's, and see it /// reaped, killed if it is still there 100 ms on. A thread does the waiting /// so that no host loop needs a timer for it (macOS's has none); a thread /// that cannot start kills and reaps it here. /// ponytail: a shell still inside its 100 ms when the editor exits is left /// to init; the gui and detached hosts keep their own retired lists, which /// this could replace. pub fn retireShell(pid: libc.pid_t) void { if (pid <= 0) return; _ = libc.kill(pid, libc.SIG.HUP); if (libc.waitpid(pid, null, libc.W.NOHANG) != 0) return; const thread = std.Thread.spawn(.{}, reapShell, .{ pid, 100 }) catch return reapShell(pid, 0); 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(); // Once more a little later: a job the command left holding its pty // may keep its end of file from ever coming, and the host tells the // exit then (`CommandWatch.due`). const ts: libc.timespec = .{ .sec = 0, .nsec = (CommandWatch.grace_ms + 10) * 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 its pty's /// end of file. The exit is told once the output before it has been read, /// at end of file -- the exit is often seen first, with output still in /// the pty -- or after `grace_ms` without one, a job the command left in /// the background holding the pty. pub const CommandWatch = struct { pub const grace_ms = 50; watched: bool = false, eof: bool = false, exited: bool = false, told: bool = false, status: ?u8 = null, at: i64 = 0, /// The child is reaped, with this status. pub fn exit(w: *CommandWatch, status: ?u8) void { w.exited = true; w.status = status; w.at = nowMs(); } /// Whether the exit is to be told now. pub fn due(w: *const CommandWatch) bool { return w.exited and !w.told and (w.eof or nowMs() - w.at >= grace_ms); } }; /// 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) { // Not 0 is reaped, or someone else reaped it: either way not ours. if (libc.waitpid(pid, null, libc.W.NOHANG) != 0) return; const ts: libc.timespec = .{ .sec = 0, .nsec = 5 * std.time.ns_per_ms }; _ = libc.nanosleep(&ts, null); } if (libc.waitpid(pid, null, libc.W.NOHANG) != 0) return; _ = libc.kill(pid, libc.SIG.KILL); while (libc.waitpid(pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} } pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void { const sig = switch (which) { .int => libc.SIG.INT, .term => libc.SIG.TERM, .hup => libc.SIG.HUP, .quit => libc.SIG.QUIT, .kill => libc.SIG.KILL, }; const fg = tcgetpgrp(master_fd); if (fg > 0) { _ = libc.kill(-fg, sig); return; } if (shell_pid > 0) _ = libc.kill(shell_pid, sig); } /// Kill: SIGTERM to the job in the tty's foreground, never to the shell. /// The shell leads its own group, and a job it runs has another; with job /// control off (`set +m`, a shell run as `sh -c`) the job shares the shell's /// group, and there is no job to signal apart from it: false. pub fn killJob(shell_pid: libc.pid_t, master_fd: c_int) bool { const fg = tcgetpgrp(master_fd); if (fg <= 0 or fg == shell_pid) return false; return libc.kill(-fg, libc.SIG.TERM) == 0; } fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 { const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); if (fd < 0) return null; defer _ = libc.close(fd); const got = libc.read(fd, buf.ptr, buf.len); if (got <= 0) return null; return buf[0..@intCast(got)]; } fn procExe(pid: libc.pid_t, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 { var name: [64:0]u8 = undefined; const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; const n = libc.readlink(link, buf, buf.len); if (n <= 0) return null; return buf[0..@intCast(n)]; } fn procPgrp(pid: libc.pid_t, buf: *[4096]u8) ?libc.pid_t { var name: [64:0]u8 = undefined; const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/stat", .{@as(u32, @intCast(pid))}, 0) catch return null; return parsePgrp(readProc(path, buf) orelse return null); } fn parsePgrp(stat: []const u8) ?libc.pid_t { const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null; var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n"); _ = fields.next() orelse return null; // 3: state _ = fields.next() orelse return null; // 4: ppid const pgrp = fields.next() orelse return null; // 5: pgrp return std.fmt.parseInt(libc.pid_t, pgrp, 10) catch null; } fn offTty(pid: libc.pid_t, buf: *[4096]u8) bool { var name: [64:0]u8 = undefined; const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return false; const status = readProc(path, buf) orelse return true; return parseZombie(status); } fn parseZombie(status: []const u8) bool { var lines = std.mem.splitScalar(u8, status, '\n'); while (lines.next()) |line| { if (!std.mem.startsWith(u8, line, "State:")) continue; const state = std.mem.trim(u8, line["State:".len..], " \t\r"); return state.len > 0 and state[0] == 'Z'; } return false; } const Pushed = enum { pushed, unreadable, full }; fn pushChildren(probe: *TtyProbe, pending: *usize, pid: libc.pid_t, depth: u8) Pushed { var name: [96:0]u8 = undefined; const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/task/{d}/children", .{ @as(u32, @intCast(pid)), @as(u32, @intCast(pid)), }, 0) catch return .unreadable; const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); if (fd < 0) return .unreadable; defer _ = libc.close(fd); const got = libc.read(fd, &probe.blob, probe.blob.len); if (got < 0) return .unreadable; var kids: [occ_max_visited]libc.pid_t = undefined; const total = parseChildren(probe.blob[0..@intCast(got)], &kids); if (total > kids.len or pending.* + total > probe.pending.len) return .full; for (kids[0..total]) |kid| { probe.pending[pending.*] = .{ .pid = kid, .depth = depth }; pending.* += 1; } return .pushed; } fn parseChildren(text: []const u8, out: []libc.pid_t) usize { var total: usize = 0; var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); while (it.next()) |tok| { if (std.mem.indexOfNone(u8, tok, "0123456789") != null) continue; const kid = std.fmt.parseInt(libc.pid_t, tok, 10) catch continue; if (total < out.len) out[total] = kid; total += 1; } return total; } test "the children blob parses to pids, and a garbage token never becomes one" { var out: [8]libc.pid_t = undefined; try std.testing.expectEqual(@as(usize, 0), parseChildren("", &out)); try std.testing.expectEqual(@as(usize, 0), parseChildren(" ", &out)); try std.testing.expectEqual(@as(usize, 1), parseChildren("991 ", &out)); try std.testing.expectEqual(@as(libc.pid_t, 991), out[0]); try std.testing.expectEqual(@as(usize, 3), parseChildren("7 8 9 ", &out)); try std.testing.expectEqualSlices(libc.pid_t, &.{ 7, 8, 9 }, out[0..3]); try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12", &out)); try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12\n", &out)); try std.testing.expectEqual(@as(usize, 2), parseChildren("5 -1 +7 0x3 abc 6 ", &out)); try std.testing.expectEqualSlices(libc.pid_t, &.{ 5, 6 }, out[0..2]); var two: [2]libc.pid_t = undefined; try std.testing.expectEqual(@as(usize, 4), parseChildren("1 2 3 4 ", &two)); try std.testing.expectEqualSlices(libc.pid_t, &.{ 1, 2 }, two[0..2]); } test "the process group comes off the last ')', not a comm-shifted stat field" { const shifted = "1234 (sh (a b)) S 991 992 993 34816 992 4194560 " ++ "1729 0 0 0 1 0 0 0 20 0 1 0 8244630 9887744 1131"; try std.testing.expectEqual(@as(libc.pid_t, 992), parsePgrp(shifted).?); try std.testing.expectEqual(@as(libc.pid_t, 7), parsePgrp("42 (bash) S 1 7 7 34816 7 4194304").?); try std.testing.expectEqual(@as(libc.pid_t, 42), parsePgrp("42 (sleep) S 7 42 7 0 -1").?); try std.testing.expect(parsePgrp("") == null); try std.testing.expect(parsePgrp("1234 (bash) S 991") == null); try std.testing.expect(parsePgrp("1234 (bash) S 991 notanumber") == null); try std.testing.expect(parsePgrp("no parens here at all") == null); } test "the zombie state comes off its own status line" { try std.testing.expect(parseZombie("Name:\tsh (a b)\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t1234\n")); try std.testing.expect(parseZombie("State:\tZ (zombie)\n")); try std.testing.expect(!parseZombie("Name:\tsh\nState:\tS (sleeping)\n")); try std.testing.expect(!parseZombie("Name:\tvim\nState:\tR (running)\n")); try std.testing.expect(!parseZombie("Name:\tvim\nState:\tT (stopped)\n")); try std.testing.expect(!parseZombie("Name:\tsh (State: Z)\nState:\tS (sleeping)\n")); try std.testing.expect(!parseZombie("Name:\tsh\nSta")); try std.testing.expect(!parseZombie("State:\t")); } const test_shell = "/bin/bash"; const test_prompt = "PZX> "; const TestShell = struct { master: c_int, pid: libc.pid_t, tail: [8192]u8 = undefined, tail_len: usize = 0, fn start() ?TestShell { if (!haveFile(test_shell)) return null; var master: c_int = undefined; const ws = std.posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid < 0) return null; if (pid == 0) { const argv: [3:null]?[*:0]const u8 = .{ test_shell, "--norc", "-i" }; _ = execv(test_shell, &argv); _exit(127); } var sh: TestShell = .{ .master = master, .pid = pid }; sh.send("export PS1='PZ''X> '\n"); if (!sh.waitText(test_prompt, 10_000)) { sh.stop(); return null; } sh.forget(); return sh; } fn send(sh: *TestShell, bytes: []const u8) void { _ = libc.write(sh.master, bytes.ptr, bytes.len); } fn forget(sh: *TestShell) void { sh.tail_len = 0; } fn drain(sh: *TestShell) void { while (true) { var fds = [1]libc.pollfd{.{ .fd = sh.master, .events = libc.POLL.IN, .revents = 0 }}; if (libc.poll(&fds, 1, 0) <= 0) return; if (fds[0].revents & libc.POLL.IN == 0) return; var chunk: [4096]u8 = undefined; const n = libc.read(sh.master, &chunk, chunk.len); if (n <= 0) return; sh.append(chunk[0..@intCast(n)]); } } fn append(sh: *TestShell, bytes: []const u8) void { if (bytes.len >= sh.tail.len) { @memcpy(&sh.tail, bytes[bytes.len - sh.tail.len ..]); sh.tail_len = sh.tail.len; return; } const room = sh.tail.len - sh.tail_len; if (bytes.len > room) { const drop = bytes.len - room; std.mem.copyForwards(u8, sh.tail[0 .. sh.tail_len - drop], sh.tail[drop..sh.tail_len]); sh.tail_len -= drop; } @memcpy(sh.tail[sh.tail_len..][0..bytes.len], bytes); sh.tail_len += bytes.len; } fn waitText(sh: *TestShell, needle: []const u8, ms: i64) bool { const deadline = nowMs() + ms; while (true) { sh.drain(); if (std.mem.indexOf(u8, sh.tail[0..sh.tail_len], needle) != null) return true; if (nowMs() >= deadline) return false; sleepMs(5); } } fn taken(sh: *TestShell) bool { sh.drain(); return ttyTaken(sh.pid, sh.master); } fn waitTaken(sh: *TestShell, want: bool, ms: i64) bool { const deadline = nowMs() + ms; while (true) { if (sh.taken() == want) return true; if (nowMs() >= deadline) return false; sleepMs(5); } } fn holdsTaken(sh: *TestShell, want: bool, ms: i64) bool { const deadline = nowMs() + ms; while (nowMs() < deadline) { if (sh.taken() != want) return false; sleepMs(5); } return true; } fn stop(sh: *TestShell) void { if (comptime builtin.os.tag == .linux) { var probe: TtyProbe = undefined; var pending: usize = 0; var doomed: [occ_max_visited]libc.pid_t = undefined; var n: usize = 0; _ = pushChildren(&probe, &pending, sh.pid, 1); while (pending > 0) { pending -= 1; const node = probe.pending[pending]; if (n == doomed.len) break; doomed[n] = node.pid; n += 1; if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1); } _ = libc.kill(sh.pid, libc.SIG.KILL); for (doomed[0..n]) |kid| { _ = libc.kill(kid, libc.SIG.KILL); _ = libc.kill(-kid, libc.SIG.KILL); } } else { // No children list to descend. forkpty made the shell a session // leader, so its own group plus whichever group currently holds // the tty covers the job the test left running. const fg = tcgetpgrp(sh.master); if (fg > 0 and fg != sh.pid) _ = libc.kill(-fg, libc.SIG.KILL); _ = libc.kill(-sh.pid, libc.SIG.KILL); _ = libc.kill(sh.pid, libc.SIG.KILL); } _ = libc.waitpid(sh.pid, null, 0); _ = libc.close(sh.master); } }; fn haveFile(path: [*:0]const u8) bool { const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); if (fd < 0) return false; _ = libc.close(fd); return true; } 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); } fn sleepMs(ms: i64) void { const ts = libc.timespec{ .sec = @intCast(@divFloor(ms, 1000)), .nsec = @intCast(@mod(ms, 1000) * 1_000_000), }; _ = libc.nanosleep(&ts, null); } test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands it back" { if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); try std.testing.expect(sh.holdsTaken(false, 200)); sh.send("sleep 30\n"); try std.testing.expect(sh.waitTaken(true, 10_000)); sh.forget(); sh.send("\x03"); try std.testing.expect(sh.waitTaken(false, 10_000)); try std.testing.expect(sh.waitText(test_prompt, 10_000)); } test "Kill stops the foreground job, never the shell, and finds none with job control off" { if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); // At the prompt the shell is the foreground: there is nothing to stop. try std.testing.expect(!killJob(sh.pid, sh.master)); sleepMs(100); try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG)); sh.send("sleep 30\n"); try std.testing.expect(sh.waitTaken(true, 10_000)); sh.forget(); try std.testing.expect(killJob(sh.pid, sh.master)); try std.testing.expect(sh.waitTaken(false, 10_000)); try std.testing.expect(sh.waitText(test_prompt, 10_000)); try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG)); // Job control off: the job runs in the shell's own group. sh.forget(); sh.send("set +m; sleep 30\n"); sleepMs(300); try std.testing.expect(!killJob(sh.pid, sh.master)); sh.forget(); sh.send("\x03"); try std.testing.expect(sh.waitText(test_prompt, 10_000)); 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 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) { const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "trap '' HUP; exec sleep 30" }; const envp = [_:null]?[*:0]const u8{}; _ = libc.execve("/bin/sh", &argv, &envp); libc._exit(127); } try std.testing.expect(pid > 0); sleepMs(100); // past the trap retireShell(pid); // A zombie still answers kill(pid, 0); only a reaped pid is gone. var waited: i64 = 0; while (libc.kill(pid, @enumFromInt(0)) == 0 and waited < 2000) : (waited += 10) sleepMs(10); try std.testing.expect(libc.kill(pid, @enumFromInt(0)) != 0); try std.testing.expect(waited >= 90); // it did ignore the hangup } test "a background job is not the tty's owner" { if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); sh.send("sleep 30 &\n"); try std.testing.expect(sh.waitText("[1]", 10_000)); try std.testing.expect(sh.holdsTaken(false, 300)); sh.send("kill %1\n"); try std.testing.expect(sh.holdsTaken(false, 300)); } test "a nested interactive shell is still a prompt" { if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); sh.forget(); sh.send("bash --norc -i\n"); try std.testing.expect(sh.waitText(test_prompt, 10_000)); try std.testing.expect(sh.holdsTaken(false, 300)); sh.forget(); sh.send("bash --norc -i\n"); try std.testing.expect(sh.waitText(test_prompt, 10_000)); try std.testing.expect(sh.holdsTaken(false, 300)); sh.send("sleep 30\n"); try std.testing.expect(sh.waitTaken(true, 10_000)); sh.send("\x03"); try std.testing.expect(sh.waitTaken(false, 10_000)); } test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" { if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); sh.send("bash --norc -c 'sleep 30'\n"); try std.testing.expect(sh.waitTaken(true, 10_000)); sh.send("\x03"); try std.testing.expect(sh.waitTaken(false, 10_000)); sh.send("bash --norc -c 'sleep 30; :'\n"); try std.testing.expect(sh.waitTaken(true, 10_000)); // Why the shallow answer is the wrong one, in each walk's own evidence: // the wrapper between the shell and `sleep` wears the shell's executable, // so a probe that stopped at it would report a prompt. if (comptime builtin.os.tag == .linux) { var probe: TtyProbe = undefined; var pending: usize = 0; try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1)); try std.testing.expectEqual(@as(usize, 1), pending); var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined; var shell_buf: [std.fs.max_path_bytes]u8 = undefined; try std.testing.expectEqualStrings( procExe(sh.pid, &shell_buf).?, procExe(probe.pending[0].pid, &wrapper_buf).?, ); } else { const fg = tcgetpgrp(sh.master); try std.testing.expect(fg > 0 and fg != sh.pid); var leader_buf: [darwin.path_max]u8 = undefined; var shell_buf: [darwin.path_max]u8 = undefined; // The group's leader IS that wrapper, and it is the shell's binary. try std.testing.expectEqualStrings( darwin.exe(sh.pid, &shell_buf).?, darwin.exe(fg, &leader_buf).?, ); // Taken all the same, because the rest of the group is not. try std.testing.expect(darwin.taken(sh.pid, fg)); } sh.send("\x03"); try std.testing.expect(sh.waitTaken(false, 10_000)); } test "a full-screen program takes the tty until it quits" { if (comptime !tty_probe_platform) return error.SkipZigTest; const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{ .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" }, .{ .bin = "/usr/bin/less", .run = "env LESS= less /etc/hosts\n", .quit = "q" }, }; var ran: usize = 0; for (cases) |c| { if (!haveFile(c.bin)) continue; var sh = TestShell.start() orelse return error.SkipZigTest; defer sh.stop(); sh.send(c.run); try std.testing.expect(sh.waitTaken(true, 10_000)); sh.forget(); sh.send(c.quit); try std.testing.expect(sh.waitTaken(false, 10_000)); try std.testing.expect(sh.waitText(test_prompt, 10_000)); ran += 1; } if (ran == 0) return error.SkipZigTest; }