summaryrefslogtreecommitdiff
path: root/src/nested.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 02:34:49 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit0a15af8d98771180e32402e68ea844f9f377cefb (patch)
tree8f919ba30dd304c4654e4abf127c238f05458000 /src/nested.zig
parent09c35b22793153d201fa202b52671a6442e5e2e1 (diff)
downloadpardes-0a15af8d98771180e32402e68ea844f9f377cefb.tar.gz
pardes-0a15af8d98771180e32402e68ea844f9f377cefb.zip
a pardes launched inside pardes hands its file to the outer one
Diffstat (limited to 'src/nested.zig')
-rw-r--r--src/nested.zig361
1 files changed, 361 insertions, 0 deletions
diff --git a/src/nested.zig b/src/nested.zig
new file mode 100644
index 00000000..6e70bdc9
--- /dev/null
+++ b/src/nested.zig
@@ -0,0 +1,361 @@
+//! A pardes launched inside a pardes hands its file to the outer one.
+//!
+//! Every top-level instance listens on `<dir>/pardes-<pid>.sock`, where `<dir>`
+//! is `$XDG_RUNTIME_DIR` or, when the session has none, `~/.local/state/pardes`
+//! created 0700. NOT /tmp: this socket takes a command line and runs it, and a
+//! world-writable directory means both that somebody else can plant a listener
+//! at a pid we are about to guess and that a file they planted under the sticky
+//! bit cannot be unlinked, so bind fails and the feature goes quietly off.
+//!
+//! An instance that finds an ancestor process running the same executable
+//! resolves its positional argument, writes ONE line — `Look /abs/path` — to
+//! that ancestor's socket and exits silently; the outer pardes runs the line
+//! through executeBuiltinLine and opens a pane for it. The wire format is a
+//! builtin command line because that is a language pardes already speaks: no
+//! serialization, nothing to version. The receive side still filters it down
+//! to `Look `, because executeBuiltinLine dispatches ANY builtin and this
+//! socket sits at a path anyone can derive from a pid — `Exec …` arriving here
+//! is not something this protocol is allowed to say.
+//!
+//! Linux only. ponytail: darwin has no /proc, so the walk there is
+//! proc_pidinfo(PROC_PIDTBSDINFO) for `pbi_ppid` plus a `pbi_comm` compare —
+//! a 16-byte truncated name, which is a weaker identity than an exe path —
+//! and neither SOCK_CLOEXEC nor accept4 exists, so the socket half needs two
+//! extra fcntl(FD_CLOEXEC) calls. Its `sockaddr.un.path` is 104 bytes, not
+//! 108: the `[108]u8` buffers and the unguarded memcpys below are sized for
+//! linux and a port has to re-derive them from `@FieldType`. None of it is
+//! testable from here, so detection is simply off: a pardes inside a pardes on
+//! macOS opens a second session the way it always did.
+const std = @import("std");
+const builtin = @import("builtin");
+const libc = std.c;
+const linux = std.os.linux; // statx; referenced only on linux
+
+// std.c has getenv but neither setter; the tests below need both
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+
+/// The longest command line this protocol carries or accepts. `Look ` plus a
+/// PATH_MAX path fits with room over; anything longer cannot have come from
+/// the client and is dropped rather than truncated into a different command.
+pub const max_line = 4200;
+
+/// Where the sockets live. `$XDG_RUNTIME_DIR` first — a per-user 0700 tmpfs
+/// the login session already cleans up — else `~/.local/state/pardes`, which
+/// is per-user for the same reason a home directory is. Asked by the client
+/// (to derive the path), by the listener (to create and vet it) and by the
+/// sweeper (to scan it), so it is written once.
+fn socketDir(buf: *[108:0]u8) ?[:0]const u8 {
+ if (libc.getenv("XDG_RUNTIME_DIR")) |x|
+ return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(x)}, 0) catch null;
+ const home = libc.getenv("HOME") orelse return null;
+ return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{std.mem.span(home)}, 0) catch null;
+}
+
+/// `<dir>/pardes-<pid>.sock`. `<pid>` is the LISTENING instance's own pid, so
+/// two pardes never collide and a nested child derives the exact path from the
+/// ancestor pid its tree walk found. The buffer is sun_path-sized: a longer
+/// path is not a socket address at all.
+pub fn socketPath(buf: *[108]u8, pid: libc.pid_t) ?[:0]const u8 {
+ var dir_buf: [108:0]u8 = undefined;
+ const dir = socketDir(&dir_buf) orelse return null;
+ // unsigned: {d} prints a leading '+' for a positive SIGNED int
+ return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d}.sock", .{ dir, @as(u32, @intCast(pid)) }, 0) catch null;
+}
+
+/// A `/proc/<pid>/exe` readlink with the kernel's `" (deleted)"` suffix taken
+/// off. `zig build` replaces the binary under a running pardes — that is the
+/// daily loop in this repo — and from that moment the OUTER instance's exe
+/// link reads `/path/to/pardes (deleted)` while the freshly built child's
+/// reads `/path/to/pardes`. Comparing them raw made every nested launch after
+/// a rebuild open a second full-screen UI inside the pane.
+pub fn stripDeleted(link: []const u8) []const u8 {
+ const suffix = " (deleted)";
+ return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link;
+}
+
+/// The `PPid:` field of a /proc/<pid>/status blob. Deliberately NOT field 4 of
+/// /proc/<pid>/stat: that field is positional after `comm`, and a comm may
+/// contain spaces and parentheses — a process named `sh (a b)` shifts every
+/// field after it and the parse silently reads the wrong number.
+pub fn parsePPid(status: []const u8) ?libc.pid_t {
+ var lines = std.mem.splitScalar(u8, status, '\n');
+ while (lines.next()) |line| {
+ if (!std.mem.startsWith(u8, line, "PPid:")) continue;
+ return std.fmt.parseInt(libc.pid_t, std.mem.trim(u8, line["PPid:".len..], " \t\r"), 10) catch null;
+ }
+ return null;
+}
+
+/// The pid in a `pardes-<pid>.sock` filename, for the startup sweep. Strictly
+/// digits: parseInt alone would take `pardes-+7.sock` and `pardes--7.sock`,
+/// and the sweep unlinks what this answers about.
+pub fn sweepPid(name: []const u8) ?libc.pid_t {
+ if (!std.mem.startsWith(u8, name, "pardes-") or !std.mem.endsWith(u8, name, ".sock")) return null;
+ const digits = name["pardes-".len .. name.len - ".sock".len];
+ if (digits.len == 0) return null;
+ for (digits) |ch| if (!std.ascii.isDigit(ch)) return null;
+ return std.fmt.parseInt(libc.pid_t, digits, 10) catch null;
+}
+
+/// The pid of the nearest ancestor running THIS executable, or null. Identity
+/// is `readlink("/proc/<pid>/exe")` against our own, not a name: a name match
+/// would call every `vim pardes.zig` an outer pardes. The hop cap is not for
+/// /proc, which cannot loop, but because the walk is driven by numbers read
+/// out of files and should not be able to spin on a surprising one.
+pub fn outer() ?libc.pid_t {
+ if (comptime builtin.os.tag != .linux) return null;
+ var self_buf: [4096]u8 = undefined;
+ const self_n = libc.readlink("/proc/self/exe", &self_buf, self_buf.len);
+ if (self_n <= 0) return null;
+ const self_exe = stripDeleted(self_buf[0..@intCast(self_n)]);
+ var pid = libc.getppid();
+ var hops: usize = 0;
+ while (pid > 1 and hops < 64) : (hops += 1) {
+ var name: [64:0]u8 = undefined;
+ var buf: [4096]u8 = undefined;
+ const exe = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null;
+ const n = libc.readlink(exe, &buf, buf.len);
+ if (n > 0 and std.mem.eql(u8, stripDeleted(buf[0..@intCast(n)]), self_exe)) return pid;
+ const status = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return null;
+ const fd = libc.open(status, .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return null;
+ const got = libc.read(fd, &buf, buf.len);
+ _ = libc.close(fd);
+ if (got <= 0) return null;
+ pid = parsePPid(buf[0..@intCast(got)]) orelse return null;
+ }
+ return null;
+}
+
+/// Hand `Look <path>[:<line>]` to the pardes listening as `pid` and say
+/// whether it landed. False for every failure — no socket file, nobody
+/// accepting, a path that does not fit — because an outer instance that
+/// cannot be reached (an older build, a stale path) must never cost the
+/// caller its own launch. Writes and returns: the answer is a pane appearing
+/// on someone else's screen, and there is nothing to wait for.
+pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool {
+ if (comptime builtin.os.tag != .linux) return false;
+ // The protocol is one line, so a path with a line break IN it says
+ // something else entirely: `we\nird.txt` arrived as `Look .../we` and the
+ // outer instance opened a different file that happened to exist. \r goes
+ // too — the receive side trims a trailing one. Unsendable, not escaped:
+ // the caller falls through and opens the file in its own session.
+ if (std.mem.indexOfAny(u8, path, "\r\n") != null) return false;
+ var cmd_buf: [max_line]u8 = undefined;
+ const cmd = (if (line > 0)
+ std.fmt.bufPrint(&cmd_buf, "Look {s}:{d}\n", .{ path, line })
+ else
+ std.fmt.bufPrint(&cmd_buf, "Look {s}\n", .{path})) catch return false;
+
+ var path_buf: [108]u8 = undefined;
+ const sock = socketPath(&path_buf, pid) orelse return false;
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM | libc.SOCK.CLOEXEC, 0);
+ if (fd < 0) return false;
+ defer _ = libc.close(fd);
+ if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false;
+ var off: usize = 0;
+ while (off < cmd.len) {
+ const n = libc.write(fd, cmd.ptr + off, cmd.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return false;
+ }
+ if (n == 0) return false;
+ off += @intCast(n);
+ }
+ return true;
+}
+
+/// Create the socket directory if it is missing and refuse it unless it is a
+/// directory WE own with nothing granted to group or other. A planted path is
+/// the whole attack on a socket that runs commands, and $XDG_RUNTIME_DIR
+/// passes this untouched (the login session already makes it 0700).
+fn ensureSocketDir(dir: [:0]const u8) bool {
+ // mkdir -p, because the HOME branch is three levels deep and a machine
+ // without ~/.local/state would otherwise switch the feature off in
+ // silence. Under $XDG_RUNTIME_DIR every prefix already exists and simply
+ // EEXISTs, which is the ordinary case for the leaf too.
+ var partial: [108:0]u8 = undefined;
+ @memcpy(partial[0 .. dir.len + 1], dir[0 .. dir.len + 1]);
+ for (1..dir.len) |i| {
+ if (dir[i] != '/') continue;
+ partial[i] = 0;
+ _ = libc.mkdir(partial[0..i :0], 0o700);
+ partial[i] = '/';
+ }
+ _ = libc.mkdir(dir, 0o700);
+ var stx: linux.Statx = undefined;
+ const want: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true };
+ // NOFOLLOW: a symlink where the directory should be is exactly the plant
+ if (libc.statx(linux.AT.FDCWD, dir, linux.AT.SYMLINK_NOFOLLOW, want, &stx) != 0) return false;
+ if (!linux.S.ISDIR(stx.mode)) return false;
+ if (stx.uid != libc.getuid()) return false;
+ return stx.mode & 0o077 == 0;
+}
+
+/// Unlink the socket files of pardes processes that are gone. A pardes killed
+/// rather than quit runs no defer, so its file outlives it; harmless by
+/// construction (bind unlinks first, a client's connect is refused) but it is
+/// our own litter and the snapshot suite alone leaves ~90 behind per run.
+/// Bounded: one readdir of a directory only we write to, one kill(0) each.
+fn sweep(dir: [:0]const u8) void {
+ const d = libc.opendir(dir) orelse return;
+ defer _ = libc.closedir(d);
+ const me = libc.getpid();
+ while (libc.readdir(d)) |ent| {
+ const pid = sweepPid(std.mem.sliceTo(&ent.name, 0)) orelse continue;
+ if (pid == me) continue;
+ // 0 = alive; EPERM = alive and someone else's. Only ESRCH is a corpse.
+ const rc = libc.kill(pid, @enumFromInt(0));
+ if (rc == 0 or libc.errno(rc) != .SRCH) continue;
+ var pbuf: [108]u8 = undefined;
+ _ = libc.unlink(socketPath(&pbuf, pid) orelse continue);
+ }
+}
+
+/// Bind and listen so nested instances can find us; -1 if anything fails, and
+/// a pardes without a socket is simply one whose children open their own UI.
+///
+/// CLOEXEC matters more here than on any other fd in the program: pane shells
+/// are forked with forkpty and inherit everything open, and an orphaned bash
+/// holding this one would keep the socket bound long after we exit — the same
+/// shape as the inherited lock fd that once held a flock forever.
+pub fn listenAt(path: [:0]const u8) c_int {
+ if (comptime builtin.os.tag != .linux) return -1;
+ var dir_buf: [108:0]u8 = undefined;
+ const dir = socketDir(&dir_buf) orelse return -1;
+ if (!ensureSocketDir(dir)) return -1;
+ sweep(dir);
+ var addr: libc.sockaddr.un = .{ .path = @splat(0) };
+ if (path.len + 1 > addr.path.len) return -1;
+ @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
+ const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM | libc.SOCK.CLOEXEC, 0);
+ if (fd < 0) return -1;
+ _ = libc.unlink(path); // pid reuse: a dead pardes' file would EADDRINUSE forever
+ if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) {
+ _ = libc.close(fd);
+ return -1;
+ }
+ // Owner-only, and BEFORE listen(2), which is the moment anyone could
+ // connect: the directory is already private, this is the second wall.
+ _ = libc.chmod(path, 0o600);
+ if (libc.listen(fd, 8) != 0) {
+ _ = libc.close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+/// Block until a nested instance sends a `Look` line, and return it inside
+/// `buf`. Null only when the listening fd itself is gone — teardown closed it,
+/// or it was never a socket — because anything else (EMFILE, ECONNABORTED)
+/// would otherwise kill the listener thread for the life of the process while
+/// the socket stayed bound, and every later launch would exit 0 having done
+/// nothing. Every accepted connection is CLOEXEC for the reason the listener
+/// is.
+pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 {
+ if (comptime builtin.os.tag != .linux) return null;
+ while (true) {
+ const conn = libc.accept4(fd, null, null, libc.SOCK.CLOEXEC);
+ if (conn < 0) {
+ switch (libc.errno(conn)) {
+ .INTR => continue,
+ // the fd went away or never was one: nothing will ever arrive
+ .BADF, .INVAL, .NOTSOCK => return null,
+ // transient. Sleep first: EMFILE persists until some other fd
+ // is freed, and a bare `continue` would spin a core on it.
+ else => {
+ var ts: libc.timespec = .{ .sec = 0, .nsec = 100 * std.time.ns_per_ms };
+ _ = libc.nanosleep(&ts, null);
+ continue;
+ },
+ }
+ }
+ defer _ = libc.close(conn);
+ // A peer that connects and says nothing must not hold the listener:
+ // this is a serial accept loop, and one silent connection used to
+ // block every later launch until it let go. The client writes its one
+ // short line immediately, so a second is already generous.
+ const tv: libc.timeval = .{ .sec = 1, .usec = 0 };
+ _ = libc.setsockopt(conn, libc.SOL.SOCKET, libc.SO.RCVTIMEO, &tv, @sizeOf(libc.timeval));
+ var len: usize = 0;
+ // ...and a cap on the reads themselves, because the timeout is PER
+ // read and a peer dribbling one byte under it would otherwise stretch
+ // to buf.len seconds. One line is one or two reads.
+ var reads: usize = 0;
+ while (len < buf.len and reads < 64) : (reads += 1) {
+ const n = libc.read(conn, buf.ptr + len, buf.len - len);
+ if (n < 0 and libc.errno(n) == .INTR) continue;
+ if (n <= 0) break; // EOF, or the receive timeout expired
+ len += @intCast(n);
+ if (std.mem.indexOfScalar(u8, buf[0..len], '\n') != null) break;
+ }
+ const end = std.mem.indexOfScalar(u8, buf[0..len], '\n') orelse len;
+ // a full buffer with no newline is an overlong line: drop it whole
+ // rather than run its truncation as some other command
+ if (end == buf.len) continue;
+ const line = std.mem.trimEnd(u8, buf[0..end], "\r");
+ // one verb (see the file header): this socket may open things, and
+ // that is all it may do
+ if (!std.mem.startsWith(u8, line, "Look ")) continue;
+ return line;
+ }
+}
+
+test "socket path: XDG first, then a private dir under HOME, never /tmp" {
+ var buf: [108]u8 = undefined;
+ _ = setenv("XDG_RUNTIME_DIR", "/run/user/1000", 1);
+ try std.testing.expectEqualStrings("/run/user/1000/pardes-4242.sock", socketPath(&buf, 4242).?);
+ _ = unsetenv("XDG_RUNTIME_DIR");
+ _ = setenv("HOME", "/home/who", 1);
+ try std.testing.expectEqualStrings("/home/who/.local/state/pardes/pardes-4242.sock", socketPath(&buf, 4242).?);
+ // sun_path is 108 bytes including the NUL, so a directory that long has no
+ // socket address at all — say so instead of binding a truncated one
+ _ = setenv("XDG_RUNTIME_DIR", "/" ++ ("x" ** 100), 1);
+ try std.testing.expect(socketPath(&buf, 4242) == null);
+ _ = unsetenv("XDG_RUNTIME_DIR");
+ _ = unsetenv("HOME");
+ try std.testing.expect(socketPath(&buf, 4242) == null);
+}
+
+test "a rebuilt binary still matches its own running instance" {
+ // `zig build` under a live pardes: the outer's exe link gains the suffix,
+ // the new process's does not, and before this the two stopped comparing
+ // equal — every nested launch opened a second UI.
+ try std.testing.expectEqualStrings("/usr/bin/pardes", stripDeleted("/usr/bin/pardes (deleted)"));
+ try std.testing.expectEqualStrings("/usr/bin/pardes", stripDeleted("/usr/bin/pardes"));
+ try std.testing.expectEqualStrings("", stripDeleted(" (deleted)"));
+ // only a SUFFIX, and only the whole one
+ try std.testing.expectEqualStrings("/x (deleted) y", stripDeleted("/x (deleted) y"));
+ try std.testing.expectEqualStrings("/x (delete)", stripDeleted("/x (delete)"));
+}
+
+test "the sweep only recognises its own socket names" {
+ try std.testing.expectEqual(@as(libc.pid_t, 7), sweepPid("pardes-7.sock").?);
+ try std.testing.expectEqual(@as(libc.pid_t, 4194304), sweepPid("pardes-4194304.sock").?);
+ try std.testing.expect(sweepPid("pardes-.sock") == null);
+ try std.testing.expect(sweepPid("pardes-7.sockx") == null);
+ try std.testing.expect(sweepPid("pardes-7") == null);
+ try std.testing.expect(sweepPid("bus") == null);
+ try std.testing.expect(sweepPid("pardes-osc133.bash") == null);
+ // parseInt alone would take these, and the sweep UNLINKS what it answers
+ try std.testing.expect(sweepPid("pardes-+7.sock") == null);
+ try std.testing.expect(sweepPid("pardes--7.sock") == null);
+ try std.testing.expect(sweepPid("pardes- 7.sock") == null);
+}
+
+test "PPid comes off the status field, not a comm-shifted stat line" {
+ // the comm here contains a space AND parentheses — the exact shape that
+ // breaks `field 4 of /proc/<pid>/stat`
+ const status = "Name:\tsh (a b)\nUmask:\t0022\nState:\tS (sleeping)\n" ++
+ "Tgid:\t1234\nNgid:\t0\nPid:\t1234\nPPid:\t991\nTracerPid:\t0\n";
+ try std.testing.expectEqual(@as(libc.pid_t, 991), parsePPid(status).?);
+ try std.testing.expectEqual(@as(libc.pid_t, 0), parsePPid("PPid:\t0\n").?);
+ try std.testing.expect(parsePPid("Name:\tinit\nTracerPid:\t0\n") == null);
+ try std.testing.expect(parsePPid("PPid:\tnotanumber\n") == null);
+ // a truncated read must not answer from a half line
+ try std.testing.expect(parsePPid("Name:\tsh\nPPi") == null);
+}