summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/gui/gui.zig46
-rw-r--r--src/main.zig85
-rw-r--r--src/nested.zig361
-rw-r--r--src/pardes.zig13
-rw-r--r--src/tty/tty.zig52
5 files changed, 537 insertions, 20 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 1f7812e4..0cc5f520 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -29,6 +29,7 @@ const selection_pipe = @import("../selection_pipe.zig");
const is_emscripten = builtin.os.tag == .emscripten;
const temp_file = if (is_emscripten) struct {} else @import("../temp_file.zig");
const shell_bin = if (is_emscripten) struct {} else @import("../shell_bin.zig");
+const nested = if (is_emscripten) struct {} else @import("../nested.zig");
pub const c = @cImport({
@cDefine("SDL_DISABLE_OLD_NAMES", "1");
@@ -623,6 +624,9 @@ const Msg = union(enum) {
pipe: selection_pipe.Response,
/// something happened in a watched directory (see watchThread)
files_changed,
+ /// a pardes launched inside this one sent us a builtin command line (see
+ /// lookThread); gpa-owned, like `output` bytes
+ command: []u8,
};
/// The files on open panes, watched through ONE inotify instance. Same shape
@@ -682,6 +686,7 @@ const Queue = struct {
var response = response_value;
response.deinit(q.gpa);
},
+ .command => |line| q.gpa.free(line),
.eof, .files_changed => {},
}
return;
@@ -695,6 +700,7 @@ const Queue = struct {
var response = response_value;
response.deinit(q.gpa);
},
+ .command => |line| q.gpa.free(line),
.eof, .files_changed => {},
}
return;
@@ -727,6 +733,7 @@ const Queue = struct {
var response = response_value;
response.deinit(q.gpa);
},
+ .command => |line| q.gpa.free(line),
.eof, .files_changed => {},
};
q.items.deinit(q.gpa);
@@ -807,6 +814,21 @@ fn watchThread(fd: c_int, q: *Queue) void {
}
}
+/// Block on the nested-instance socket and hand the loop each command line a
+/// pardes started inside this one sends. Detached like the pty readers and the
+/// watcher — but NOT ended the way they are: close(2) does not release a
+/// thread parked in accept4 on linux, so this one simply dies with the
+/// process. The window that leaves is one connection accepted between the last
+/// drain and process exit pushing into a queue nobody empties again; Queue
+/// frees a push made after close(), and the process is on its way out anyway.
+fn lookThread(gpa: std.mem.Allocator, fd: c_int, q: *Queue) void {
+ var buf: [nested.max_line]u8 = undefined;
+ while (nested.acceptLine(fd, &buf)) |line| {
+ const owned = gpa.dupe(u8, line) catch continue;
+ q.push(.{ .command = owned });
+ }
+}
+
/// Hand the core every watched pane whose bytes moved on disk. The wake says
/// only THAT something happened, so this re-reads the lot; the hash comparison
/// is what keeps our own Save — and any write that lands on identical content
@@ -1310,6 +1332,19 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
inotify_fd = -1;
};
var watches: [pardes.MAX_PANES]?Watch = @splat(null);
+ // The socket a pardes launched inside this one connects to (nested.zig).
+ // --nested opted out of the whole mechanism, including being an outer
+ // instance; so does any failure to bind, and then children simply open
+ // their own session.
+ var sock_buf: [108]u8 = undefined;
+ const sock_path: ?[:0]const u8 = if (opts.nested) null else nested.socketPath(&sock_buf, libc.getpid());
+ const sock_fd: c_int = if (sock_path) |sp| nested.listenAt(sp) else -1;
+ // only on the fd, so a bind that FAILED cannot unlink a path this process
+ // never created
+ defer if (sock_fd >= 0) {
+ _ = libc.close(sock_fd);
+ _ = libc.unlink(sock_path.?);
+ };
// initial spawns BEFORE any worker thread exists: forkpty from a
// multithreaded process can wedge the child before exec (see tty.zig).
@@ -1319,6 +1354,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// already exists and an unwatched inotify instance just parks in read(2) —
// one thread for the process, however many panes come and go.
if (inotify_fd >= 0) if (std.Thread.spawn(.{}, watchThread, .{ inotify_fd, &queue })) |th| th.detach() else |_| {};
+ // ...and the nested-instance listener, detached like every other blocking
+ // worker here
+ if (sock_fd >= 0) if (std.Thread.spawn(.{}, lookThread, .{ gpa, sock_fd, &queue })) |th| th.detach() else |_| {};
_ = c.SDL_StartTextInput(window);
@@ -1375,6 +1413,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
break;
};
},
+ .command => |line| {
+ core.update(.{ .command = line });
+ gpa.free(line);
+ },
// Coalesced on purpose: a burst of writes (a formatter, a build, a
// `git checkout`) collapses into ONE pass below, so it cannot queue
// a reload — or an undo entry — per write.
@@ -1862,7 +1904,9 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
break;
};
},
- .files_changed => {}, // unreachable: no watcher thread in this mode
+ // both unreachable here: this mode starts neither thread
+ .files_changed => {},
+ .command => |line| gpa.free(line),
};
msgs.deinit(gpa);
drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true);
diff --git a/src/main.zig b/src/main.zig
index 343742e3..472dda6e 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const builtin = @import("builtin");
const pardes = @import("pardes.zig");
+const nested = @import("nested.zig");
const is_emscripten = builtin.os.tag == .emscripten;
@@ -75,10 +76,21 @@ const help_text =
\\ A FILE argument boots just that file instead.
\\ --tty-toggle <key> use Ctrl-<key> to enter/leave tty mode
\\ -l <dump.zon> load a dump of another instance (see Dump)
+ \\ --nested run a full session even inside another pardes.
+ \\ Without it, a pardes started inside a pardes
+ \\ hands its FILE argument to the outer one. This
+ \\ session will not serve its own children either.
\\ -h, --help show this help and exit
\\
;
+const nested_text =
+ \\pardes: this shell is already inside pardes, and a pardes inside a pardes
+ \\is spicy. Name a file or a directory and the outer session opens it, or
+ \\pass --nested if you really want a second one in here.
+ \\
+;
+
// The browser runtime calls a C main (exported below); everything else keeps
// the std.process.Init entry.
pub const main = if (is_emscripten) webMain else nativeMain;
@@ -108,8 +120,15 @@ fn nativeMain(init: std.process.Init) !void {
var opts: pardes.Options = .{};
const arena = init.arena.allocator();
const args = try init.minimal.args.toSlice(arena);
- if (args.len == 1) opts.tty_only = true; // bare `pardes` boots straight into tty mode
- var positional = false;
+ // bare `pardes` boots straight into tty mode — and `pardes --nested` still
+ // counts as bare, because --nested says something about the session's
+ // relationship to its parent and nothing about its layout
+ if (args.len == 1 or (args.len == 2 and std.mem.eql(u8, args[1], "--nested")))
+ opts.tty_only = true;
+ // Kept RAW until every flag is parsed: classifying it means chdir'ing into
+ // a directory and recording nothing, and the nested client below still
+ // needs the word itself to resolve.
+ var positional: ?[:0]const u8 = null;
var i: usize = 1;
while (i < args.len) : (i += 1) {
const a = args[i];
@@ -129,30 +148,58 @@ fn nativeMain(init: std.process.Init) !void {
i += 1;
if (i >= args.len) return error.BadArgs;
opts.load_path = args[i];
+ } else if (std.mem.eql(u8, a, "--nested")) {
+ opts.nested = true;
} else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
try std.Io.File.stdout().writeStreamingAll(init.io, help_text);
return;
- } else if (a.len > 0 and a[0] != '-' and !positional) {
- // one positional: a directory becomes the cwd shells spawn in
- // (chdir succeeds only on dirs); anything else resolves as a file
- positional = true;
- if (std.c.chdir(a.ptr) != 0) {
- var cwdbuf: [4096]u8 = undefined;
- const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return error.BadArgs;
- var realbuf: [4096]u8 = undefined;
- switch (@import("look.zig").resolve(a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) {
- .file => |t| {
- opts.file = try arena.dupe(u8, t.path);
- opts.file_line = t.at.line;
- },
- .image => |t| opts.file = try arena.dupe(u8, t.path),
- else => return error.BadArgs,
- }
- }
+ } else if (a.len > 0 and a[0] != '-' and positional == null) {
+ positional = a;
} else {
return error.BadArgs;
}
}
+ // Started INSIDE another pardes: hand it the file and get out of the way
+ // rather than stacking a second full-screen UI inside one of its panes.
+ // The word is resolved here rather than sent raw because the outer
+ // instance resolves against ITS panes' directories, which are not ours.
+ // A word naming nothing on disk sends nothing and falls through to the
+ // classification below, which already refuses it — no second UI either way.
+ if (!opts.nested) if (nested.outer()) |outer_pid| {
+ const word = positional orelse {
+ try std.Io.File.stderr().writeStreamingAll(init.io, nested_text);
+ std.process.exit(1);
+ };
+ var cwdbuf: [4096]u8 = undefined;
+ const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return error.BadArgs;
+ var realbuf: [4096]u8 = undefined;
+ const sent = switch (@import("look.zig").resolve(word, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) {
+ .dir => |d| nested.sendLook(outer_pid, d, 0),
+ .file => |t| nested.sendLook(outer_pid, t.path, t.at.line),
+ .image => |t| nested.sendLook(outer_pid, t.path, 0),
+ // an unreachable outer instance (an older build, a stale socket
+ // path) is not worth failing a launch over: run normally instead
+ else => false,
+ };
+ if (sent) return;
+ };
+ if (positional) |a| {
+ // a directory becomes the cwd shells spawn in (chdir succeeds only on
+ // dirs); anything else resolves as a file
+ if (std.c.chdir(a.ptr) != 0) {
+ var cwdbuf: [4096]u8 = undefined;
+ const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return error.BadArgs;
+ var realbuf: [4096]u8 = undefined;
+ switch (@import("look.zig").resolve(a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) {
+ .file => |t| {
+ opts.file = try arena.dupe(u8, t.path);
+ opts.file_line = t.at.line;
+ },
+ .image => |t| opts.file = try arena.dupe(u8, t.path),
+ else => return error.BadArgs,
+ }
+ }
+ }
// Native shells opt into the user config; the sans-IO core and web keep
// Options' null default. Read it before entering either frontend so every
// builtin has run before that frontend can render its first frame.
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);
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index ba80ee22..2ea52e15 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -3022,6 +3022,13 @@ pub const Event = union(enum) {
/// nothing in the core waits for it.
file_changed: struct { pane: u8, bytes: []const u8 },
paste: []const u8,
+ /// One builtin command line, handed to the shell by a pardes launched
+ /// INSIDE this one (see nested.zig) — `Look /abs/path` and nothing else
+ /// today. Borrowed for this call exactly like `output` bytes. It comes in
+ /// as an EVENT rather than a direct executeBuiltinLine call so it gets the
+ /// trailing sync and the ordinary effect drain: `Look` on a directory
+ /// emits a `.spawn` the shell has to perform.
+ command: []const u8,
/// Native shells may preserve sub-cell wheel distance in physical pixels.
/// TTY button events still enter through the ordinary mouse path.
pdf_scroll: struct { pane: u8, delta_pixels: f32 },
@@ -4016,6 +4023,11 @@ pub const Options = struct {
tty_toggle: u21 = config.tty_toggle_default,
/// load a dump of another instance instead of spawning shells (acme -l)
load_path: ?[]const u8 = null,
+ /// `--nested`: run a full session even inside another pardes. The core
+ /// never reads it; it rides here because it is the shells that would
+ /// otherwise open the nested.zig socket, and this is the way argv already
+ /// reaches them.
+ nested: bool = false,
/// Native main fills this with the contents of the per-user config file.
/// Keeping discovery out of the core makes constructors and web builds
/// deterministic; when present, each line is dispatched as a builtin
@@ -4601,6 +4613,7 @@ pub const Pardes = struct {
p.setYank(bytes);
if (p.panes[p.active]) |pane| p.normalPaste(pane, false);
},
+ .command => |line| _ = p.executeBuiltinLine(p.active, line),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
.tick => p.chrome_animation.advance(),
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 22449719..91213d9f 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -15,6 +15,7 @@ const temp_file = @import("../temp_file.zig");
const shell_bin = @import("../shell_bin.zig");
const message = @import("../message.zig");
const selection_pipe = @import("../selection_pipe.zig");
+const nested = @import("../nested.zig");
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;
@@ -42,6 +43,9 @@ pub const Command = struct {
pipe_done: selection_pipe.Response,
/// something happened in a watched directory (see watchFiles)
files_changed,
+ /// a pardes launched inside this one sent us a builtin command line
+ /// (see lookServer); gpa-owned, like pty_read
+ command: []u8,
} = .nop;
};
const Loop = vaxis.Loop(@TypeOf(Command.value));
@@ -436,6 +440,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
}
while (loop.tryEvent() catch null) |ev| switch (ev) {
.pty_read => |pr| gpa.free(pr.bytes),
+ .command => |line| gpa.free(line),
.paste => |b| gpa.free(@constCast(b)),
.lsp_done => |d| gpa.free(d.rows),
.pipe_done => |response_value| {
@@ -446,6 +451,22 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
};
}
+ // The socket a pardes launched inside this one connects to (nested.zig).
+ // Declared AFTER the drain above so its teardown runs BEFORE it — the
+ // listener thread must be out of the way before the queue is emptied.
+ // --nested opted out of the whole mechanism, including being an outer
+ // instance; so does any failure to bind, and then children simply open
+ // their own session.
+ var sock_buf: [108]u8 = undefined;
+ const sock_path: ?[:0]const u8 = if (opts.nested) null else nested.socketPath(&sock_buf, libc.getpid());
+ const sock_fd: c_int = if (sock_path) |sp| nested.listenAt(sp) else -1;
+ // only on the fd, so a bind that FAILED cannot unlink a path this process
+ // never created
+ defer if (sock_fd >= 0) {
+ _ = libc.close(sock_fd);
+ _ = libc.unlink(sock_path.?);
+ };
+
// Perform the initial spawns BEFORE any worker thread exists: forkpty from
// a multithreaded process can wedge the child before exec.
drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
@@ -455,6 +476,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// resize watcher: plain detached thread (not io.concurrent — teardown
// joins those, and sigwait never returns); dies with the process
(try std.Thread.spawn(.{}, winchWatch, .{ &loop, &vx, &tty })).detach();
+ // ...and the nested-instance listener, detached for the same reason: a
+ // blocking accept(2) never returns either, so an io.concurrent task would
+ // hang the teardown that joins it.
+ if (sock_fd >= 0) (try std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, &loop })).detach();
// Capability handshake — SEND the probes, do not wait on them. This was
// queryTerminal(2ms), which blocks on a futex until DA1 comes back. The
// number has to beat one terminal round trip: a local terminal answers in
@@ -593,6 +618,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
core.update(.{ .paste = bytes });
gpa.free(@constCast(bytes));
},
+ .command => |line| {
+ core.update(.{ .command = line });
+ gpa.free(line);
+ },
// Coalesced on purpose: a burst of writes (a formatter, a
// build, a `git checkout`) collapses into ONE pass below, so
// it cannot queue a reload — or an undo entry — per write.
@@ -1089,6 +1118,29 @@ fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void {
}
}
+/// Block on the nested-instance socket and hand the loop each command line a
+/// pardes started inside this one sends. Same shape as winchWatch: a plain
+/// detached thread around a call that never returns, posting into the vaxis
+/// loop from ordinary thread context.
+///
+/// Nothing here is woken by teardown — close(2) does NOT release a thread
+/// parked in accept4 on linux — so this dies with the process, exactly as
+/// winchWatch dies inside sigwait. The window that leaves is one connection
+/// accepted between the last drain and process exit posting into a queue whose
+/// owner has returned; same shape and same bound as every other detached
+/// worker here, and a self-pipe to close it would be more machinery than the
+/// window is worth.
+fn lookServer(gpa: std.mem.Allocator, fd: c_int, loop: *Loop) void {
+ var buf: [nested.max_line]u8 = undefined;
+ while (nested.acceptLine(fd, &buf)) |line| {
+ const owned = gpa.dupe(u8, line) catch continue;
+ loop.postEvent(.{ .command = owned }) catch {
+ gpa.free(owned);
+ break;
+ };
+ }
+}
+
/// Consume SIGWINCH synchronously (it is blocked in every thread) and post
/// the new size as a winsize event from normal thread context — the one place
/// vaxis's Io-backed queue is safe to touch on a resize.