diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /src/host_io.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/host_io.zig')
| -rw-r--r-- | src/host_io.zig | 1566 |
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; +} |
