summaryrefslogtreecommitdiff
path: root/src/host_io.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/host_io.zig')
-rw-r--r--src/host_io.zig1566
1 files changed, 1407 insertions, 159 deletions
diff --git a/src/host_io.zig b/src/host_io.zig
index 6ffc890e..000d1a88 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -1,200 +1,939 @@
-//! THE MACHINE-LOCAL HALF OF A HOST: fork a pane's shell, put bytes on a disk.
-//!
-//! `host.zig` is the seam — the struct of function pointers the core asks
-//! through. This file is the part of the answer that is the same on every host
-//! that has an operating system under it, and it is now the ONLY copy of it:
-//! tty.zig, detached/server.zig, gui/gui.zig and macos.zig all fork and write
-//! through here. They did not always. Each of the four grew its own `forkShell`
-//! and its own `writeFd`, and what those four copies were for is best said by
-//! what they had in common: ALL FOUR were missing FD_CLOEXEC on the pty master,
-//! so in every shell pardes has ever shipped a program in one pane could read
-//! and write another pane's terminal, and closing a master did not reliably hang
-//! its shell up. One line below fixes that for all four at once (see `forkShell`)
-//! — which is a better argument for this file existing than "it is shared" is.
-//!
-//! Why the daemon and not the frontend does this work: a unix socket means the
-//! core and its frontends are on the SAME machine, so there is no question of
-//! whose disk or whose process table is meant. Given that, the pane shells
-//! belong to the long-lived process, because the whole promise of a detached
-//! session is that it outlives the frontend attached to it — a shell forked by
-//! a frontend dies with that frontend, and then the session has a pane with no
-//! shell in it. The frontend keeps exactly what needs the human's screen: the
-//! grid, the keyboard, the clipboard and a link to open.
-//!
-//! So `forkShell` takes the core it is forking on behalf of and nothing about
-//! terminals: no vaxis, no `Loop`, no reader thread. Who drains the master fd
-//! is the caller's business, and the callers answer differently on purpose. The
-//! tty, gui and macOS shells hand it to a worker that posts into their event
-//! loop; the daemon adds it to the one `poll(2)` it already runs over its
-//! clients, and makes its own copy non-blocking in order to. That last is why
-//! `Child.file.flags` is left saying what it says: the flag describes the
-//! descriptor `forkpty` handed back, for the three callers that stream it, and
-//! the one that polls it keeps only the handle.
+const builtin = @import("builtin");
const std = @import("std");
const posix = std.posix;
const libc = std.c;
const pardes = @import("pardes.zig");
-const shell_bin = @import("shell_bin.zig");
-const fs_service = @import("fs_service.zig");
-const fuse = @import("fuse.zig");
+const ninep_io = @import("9p_io.zig");
+const filesystem = @import("fs.zig");
-/// `setCloexec` and nothing else. Imported rather than copied a fourth time —
-/// fuse.zig and nested.zig each grew a private two-line version of it — because
-/// the descriptor this file has to protect is the one every OTHER file in the
-/// tree already protects, and one predicate is how the reasoning stays in one
-/// place. nested.zig is a leaf (std, builtin, libc), so this costs no
-/// dependency worth the name.
-const nested = @import("nested.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,
+ tty_taken: ?*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.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\a\]'"$PS1"'\[\e]133;B\a\]'
+ \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
+ \\trap 'printf "\e]133;C\a"' DEBUG
+ \\
+ ;
+
+ // 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\a'
+ \\ __pardes_user_prompt
+ \\ printf '\e]133;B\a'
+ \\end
+ \\function __pardes_preexec --on-event fish_preexec
+ \\ printf '\e]133;C\a'
+ \\end
+ \\function __pardes_postexec --on-event fish_postexec
+ \\ printf '\e]133;D\a'
+ \\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 chdir(path: [*:0]const u8) c_int;
extern "c" fn _exit(status: c_int) noreturn;
-/// A forked pane shell: the pty master to read and write, and the pid to reap.
-/// Named rather than anonymous because four files now hold one of these.
pub const Child = struct {
file: std.Io.File,
pid: posix.pid_t,
};
-/// Fork a shell onto a fresh pty for `pane`, sized `rows`x`cols`.
-///
-/// `core` is optional because a host may fork before it has one, and a core
-/// that is absent simply does not name its shell.
pub fn forkShell(
core: ?*pardes.Pardes,
pane: usize,
- prompt_rcs: *const shell_bin.PromptRcs,
+ prompt_rcs: *const Shell.PromptFiles,
bin: []const u8,
- cwd: ?[*:0]const u8,
+ cwd: []const u8,
rows: u16,
cols: u16,
- fs: ?*const fuse.Fs,
-) Child {
- var master: c_int = undefined;
- // resolved BEFORE the fork, into this frame, which the child inherits:
- // nothing between fork and exec may allocate, and a PATH search would
+ 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 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;
+ 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;
+ };
+ var master: c_int = -1;
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
- // ...and so is the pane's own address on the control filesystem, for a
- // second reason on top of that one: acme puts `winid` in the child, which
- // is safe there only because rfork(RFENVG) has just given it a private
- // environment group. See fs_service.exportPaneEnv.
- fs_service.exportPaneEnv(fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0);
+ const spawn = Shell.resolve(bin, &path_buf, prompt_rcs);
+ 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,
+ );
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) {
- // the blocked-SIGWINCH mask survives fork AND exec — unblock it or
- // bash/vim in the pane would never see resizes (sigprocmask is
- // async-signal-safe)
var set = posix.sigemptyset();
posix.sigaddset(&set, posix.SIG.WINCH);
posix.sigprocmask(posix.SIG.UNBLOCK, &set, null);
- if (cwd) |c| _ = chdir(c);
+ if (cwd_z) |path| if (chdir(path.ptr) != 0) _exit(126);
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
- if (pid > 0) {
- // CLOEXEC ON THE MASTER, and it belongs here rather than at either
- // caller because `forkpty` is what opens it: /dev/ptmx is opened with no
- // O_CLOEXEC and there is no flag argument to ask for one. Without this,
- // every pane shell forked AFTER this one inherits this master and keeps
- // it across `execv`, which is two bugs at once.
- //
- // The loud one: a program running in pane 3 can read pane 0's output and
- // write bytes into pane 0's screen.
- //
- // The silent one, and the reason it compounds: closing a master is the
- // only thing that hangs its shell up, and a master a later shell still
- // holds open is not closed. detached/server.zig `closePty` and tty.zig
- // `spawn` both depend on that hangup, so a pane delete or a respawn left
- // an orphaned shell that never exits — never reaped, eventually blocked
- // writing into a pty nobody reads — and each orphan pinned every earlier
- // pane's master in turn. The startup drain forks pane 0 and then pane 1,
- // so the arrangement existed from boot, and it existed in all four
- // copies of this function before they became this one. nested.zig and
- // fuse.zig say the same thing about their own descriptors ("pane shells
- // are forked with forkpty and inherit everything open"); the master was
- // the one descriptor in the tree that nobody had said it to.
- //
- // THE WINDOW THIS LEAVES, stated rather than papered over: fcntl after
- // fork is not atomic, so a thread that forks and execs between these two
- // syscalls inherits the master anyway. In the detached daemon there is no
- // such thread — it is single-threaded by construction, which is what
- // putting the pty masters in its own `poll(2)` bought. The shells with
- // worker threads that can exec — tty.zig's pipe tasks above all — have a
- // window two syscalls wide, and closing it means replacing `forkpty` with
- // our own `posix_openpt(O_CLOEXEC)` / `grantpt` / `unlockpt` / fork /
- // `setsid`, which is a different change to a different file.
- nested.setCloexec(master);
- if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
- }
+ ninep_io.setCloexec(master);
+ if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
-/// Truncate-or-create `path` and put `bytes` there. False on any failure, and
-/// the caller reports it: a save that did not happen must not be announced as
-/// one.
-/// WHY it failed, and not merely that it did. A save is the one operation in
-/// this program whose failure a user must not be able to miss, and until this
-/// returned an error there was nothing for a host to put on the message row:
-/// the bool said "no" and every caller answered it with a bare `return`.
-/// `NoSpaceLeft` is the one that most needs saying — the file has already been
-/// truncated by the time it happens, so a save that reports nothing has
-/// destroyed the file it was asked to preserve.
-pub const WriteError = error{
- PathTooLong,
- PermissionDenied,
- IsDirectory,
- ReadOnlyFilesystem,
- NoSpaceLeft,
- OpenFailed,
- WriteFailed,
-};
+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));
-pub fn writeFileBytes(path: []const u8, bytes: []const u8) WriteError!void {
- var pathbuf: [4096:0]u8 = undefined;
- if (path.len >= pathbuf.len) return error.PathTooLong;
- @memcpy(pathbuf[0..path.len], path);
- pathbuf[path.len] = 0;
- const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return switch (libc.errno(fd)) {
- .ACCES, .PERM => error.PermissionDenied,
- .ISDIR => error.IsDirectory,
- .ROFS => error.ReadOnlyFilesystem,
- .NOSPC, .DQUOT => error.NoSpaceLeft,
- .NAMETOOLONG => error.PathTooLong,
- else => error.OpenFailed,
- };
- const wrote = writeFd(fd, bytes);
- // The close is part of the write. NFS and every write-back filesystem
- // report a deferred error here and nowhere else, so a close that fails on a
- // file we believe we wrote is a file we did not write.
- const closed = libc.close(fd) == 0;
- if (!wrote or !closed) return error.WriteFailed;
+ 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 "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));
}
-/// A whole-buffer write that finishes short writes, retries EINTR, and refuses
-/// to loop on no progress.
-///
-/// The zero guard is not bookkeeping: without it a `write(2)` that returns 0 for
-/// a nonzero count is an infinite SPIN, because 0 is neither an error nor
-/// progress and `off` never moves. macos.zig's copy carried the guard and its
-/// reason all along — "a zero-byte write makes no progress; looping on it would
-/// spin the main thread forever" — and the tty copy this file was extracted
-/// from did not, so the extraction briefly promoted the weakest of the three to
-/// being the shared one. All three are now this one: gui.zig and macos.zig were
-/// migrated onto it, so the guard is no longer missing anywhere.
-///
-/// A spin is strictly worse than the block it replaces, which is why this
-/// matters more now that detached/server.zig reaches this file from a
-/// single-threaded poll loop: a blocked `write` is one syscall a signal can
-/// interrupt, and a spin is 100% of a core with the whole session behind it.
-/// True when every byte went. The answer is new: this used to return `void`, so
-/// a full disk and a completed write were the same event to every caller — and
-/// the one caller that matters had already truncated the file. A pty write
-/// ignores it, which is what `_ =` at those call sites means.
pub fn writeFd(fd: c_int, data: []const u8) bool {
var off: usize = 0;
while (off < data.len) {
@@ -208,3 +947,512 @@ pub fn writeFd(fd: c_int, data: []const u8) bool {
}
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;
+
+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;
+ },
+ else => return false,
+ }
+}
+
+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);
+}
+
+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 {
+ 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);
+ }
+ _ = 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;
+}
+
+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 builtin.os.tag != .linux) 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 "a background job is not the tty's owner" {
+ if (comptime builtin.os.tag != .linux) 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 builtin.os.tag != .linux) 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 builtin.os.tag != .linux) 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));
+
+ 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).?,
+ );
+
+ 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 builtin.os.tag != .linux) 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;
+}