summaryrefslogtreecommitdiff
path: root/src/look.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-14 13:40:29 -0300
committerGabriel Schneider <[email protected]>2026-08-15 11:57:13 -0300
commitf67fec978a9296c651ec06bd2f43686d34ff86ee (patch)
treecbf5568883e5888398e0887093fc5afc524fd54d /src/look.zig
parent9280c597b000eed661fd98793e182fdcb640f6cd (diff)
downloadpardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.tar.gz
pardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.zip
look: richer path/range parsing, pdf rendering, corner-drag and stepgrain snapshots
Diffstat (limited to 'src/look.zig')
-rw-r--r--src/look.zig818
1 files changed, 806 insertions, 12 deletions
diff --git a/src/look.zig b/src/look.zig
index 36390ec2..49be1646 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -93,7 +93,13 @@ fn num(tok: []const u8, i: usize) struct { v: usize, end: usize } {
/// that keeps the dash safe: `a-b`, `build-2:3` and `x:1-y` are all paths (the
/// last one goes back to hunting for a later ':' and finds none), because a
/// range needs a number on both sides of its dash.
-pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot } {
+///
+/// `end` is how far into `tok` the form actually REACHED. The read is lenient
+/// by design — `main.zig:100:7x` is the file at line 100 and the mangled `:7x`
+/// is simply dropped — so `end == tok.len` is the separate question "is the
+/// whole token this target and nothing else", which is what a row-grained step
+/// must ask before it selects a run of a line (lookableLineSpan).
+pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: usize } {
var sep: usize = 0;
while (sep < tok.len) : (sep += 1) {
if (tok[sep] != config.line_col_sep) continue;
@@ -106,37 +112,41 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot } {
const e = num(tok, i + 1);
if (e.end == i + 1) continue; // a dash with no number is not a range
if (e.end < tok.len and tok[e.end] != config.line_col_sep) continue; // junk after it
- return .{ .path = path, .at = .{ .line = l.v, .end_line = e.v } };
+ return .{ .path = path, .at = .{ .line = l.v, .end_line = e.v }, .end = e.end };
}
if (i < tok.len and tok[i] != config.line_col_sep) continue; // junk after the number
var at: Spot = .{ .line = l.v };
- if (i == tok.len) return .{ .path = path, .at = at };
+ if (i == tok.len) return .{ .path = path, .at = at, .end = i };
// `:COL`. A column that does not parse is dropped and the LINE still
- // stands, which is how this has always read a half-mangled suffix.
+ // stands, which is how this has always read a half-mangled suffix — and
+ // `end` stops at the last character that DID read, so the caller that
+ // cares can tell the two apart.
const c = num(tok, i + 1);
- if (c.end == i + 1) return .{ .path = path, .at = at };
+ if (c.end == i + 1) return .{ .path = path, .at = at, .end = i };
if (c.end < tok.len and tok[c.end] != config.line_col_sep and tok[c.end] != config.range_sep)
- return .{ .path = path, .at = at };
+ return .{ .path = path, .at = at, .end = i };
at.col = c.v;
i = c.end;
- if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at };
+ if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at, .end = i };
// `-ENDCOL` on this same line, unless a `:ENDCOL` follows — then that
// first number was the end LINE all along. One lookahead, and it is
// what lets the two-number and four-number forms share a spelling.
const e = num(tok, i + 1);
- if (e.end == i + 1) return .{ .path = path, .at = at };
+ if (e.end == i + 1) return .{ .path = path, .at = at, .end = i };
at.end_line = at.line;
at.end_col = e.v;
+ var end = e.end;
if (e.end < tok.len and tok[e.end] == config.line_col_sep) {
const e2 = num(tok, e.end + 1);
if (e2.end > e.end + 1) {
at.end_line = at.end_col;
at.end_col = e2.v;
+ end = e2.end;
}
}
- return .{ .path = path, .at = at };
+ return .{ .path = path, .at = at, .end = end };
}
- return .{ .path = tok, .at = .{} };
+ return .{ .path = tok, .at = .{}, .end = tok.len };
}
test "parsePathLine: spots, ranges, and the paths that merely look like them" {
@@ -165,6 +175,34 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" {
}
}
+test "parsePathLine: `end` separates a whole-token target from a lenient read" {
+ // the whole token IS the target: every spelling the doc above lists
+ for ([_][]const u8{
+ "main.zig", "main.zig:100", "main.zig:100:7",
+ "main.zig:100-104", "main.zig:100:7-21", "main.zig:100:7-104:3",
+ "@p3:10:5", "x:1-y",
+ }) |tok| try std.testing.expectEqual(tok.len, parsePathLine(tok).end);
+ // ...and the reads that DROP a tail: a result row with its matched text
+ // still attached, which is exactly what a row-grained step must not select
+ // whole (lookableLineSpan). Note where each one STOPS — a spot is only
+ // taken once its whole form has read, so the `:7` of a `:100:7 text` row
+ // is dropped along with the text and `end` says so.
+ const partial = [_]struct { tok: []const u8, end: usize }{
+ .{ .tok = "main.zig:100:", .end = "main.zig:100".len }, // trailing ':' is peeled, not parsed
+ .{ .tok = "main.zig:100:7x", .end = "main.zig:100".len },
+ .{ .tok = "main.zig:100:7 fn main() void {", .end = "main.zig:100".len },
+ .{ .tok = "main.zig:100:7-21 const x = 1;", .end = "main.zig:100:7-21".len },
+ .{ .tok = "@p3:10:5 /home/goblin", .end = "@p3:10".len },
+ };
+ for (partial) |c| try std.testing.expectEqual(c.end, parsePathLine(c.tok).end);
+ // A form that breaks off mid-range is not a lenient read at all: the scan
+ // goes back for a later ':', finds none, and the token is a plain PATH
+ // whole — which resolves or does not on its own merits.
+ const whole = "main.zig:100-104 whole lines";
+ try std.testing.expectEqual(whole.len, parsePathLine(whole).end);
+ try std.testing.expectEqualStrings(whole, parsePathLine(whole).path);
+}
+
/// A file-like Look target has a rendering kind only in MuPDF builds. The
/// feature-off enum has no `pdf` tag at all, so `.pdf` is indistinguishable
/// from any other ordinary file before it reaches the core.
@@ -236,8 +274,11 @@ const lead_trim = "([{<\"'`*";
const trail_trim = ")]}>\"'`*,;:.!?";
/// The largest look-able span inside one whitespace-delimited `word`, or null
-/// when nothing in it resolves. This is the whole heuristic behind n/N: split
-/// on whitespace, and take the biggest piece of each run that Look can act on.
+/// when nothing in it resolves. This is the WORD grain of n/N — split a row on
+/// whitespace and take the biggest piece of each run Look can act on — which
+/// is what a terminal, a file and a PDF step, because their lines are free
+/// text and a line may hold several places (an `ls` row hops file to file).
+/// A results buffer steps ROWS instead: lookableLineSpan.
///
/// TWO resolve attempts at most, which is what keeps a motion across a
/// screenful of prose from being a hundred realpaths: the run with every
@@ -301,6 +342,119 @@ test "lookableSpan peels prose punctuation off a path, largest first" {
try std.testing.expectEqual(@as(?Span, null), lookableSpan("((()))", ".", &realbuf));
}
+/// The largest look-able span ANCHORED at the start of `line`'s text, or null
+/// when the row names no place at all. This is the ROW grain of n/N, and what
+/// a results buffer steps: a row there IS one location — `path:LINE:COL text`
+/// — and the words after the location are the MATCH, not a second place to
+/// step to. One stop per row, always its head.
+///
+/// LARGEST, so the candidates are the run from the first non-blank cell out to
+/// each whitespace boundary, tried LONGEST first: a path with a blank in it
+/// (`old notes/plan.txt`) beats the word hiding inside it, which is the case
+/// the word grain cannot express at all.
+///
+/// A candidate only counts when it is the target EXACTLY — parsePathLine
+/// consuming every byte of it, after the same wrapper peel lookableSpan does.
+/// That gate is what keeps longest-first from swallowing the whole row:
+/// `resolve` is lenient by design and answers `src/x.zig:12:5 const y` with
+/// the FILE, so without it every result row would select out to its right
+/// margin and throw the `:5` away along with the text. A url is lenient the
+/// same way in the other direction — it is recognised by its PREFIX, so a
+/// longer run is not a longer link — and only the filesystem can vouch for a
+/// span with a blank inside it, so only the filesystem is allowed to.
+///
+/// Cost is the word grain's: the exactness gate is pure parsing, so a row
+/// spends at most one resolve per whitespace boundary and the ordinary result
+/// row — whose head is its whole location — spends two.
+pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span {
+ var lo: usize = 0;
+ while (lo < line.len and (line[lo] == ' ' or line[lo] == '\t')) lo += 1;
+ var hi = std.mem.trimEnd(u8, line, " \t\r").len;
+ while (hi > lo) {
+ var a = lo;
+ var b = hi;
+ while (a < b and std.mem.indexOfScalar(u8, lead_trim, line[a]) != null) a += 1;
+ while (b > a and std.mem.indexOfScalar(u8, trail_trim, line[b - 1]) != null) b -= 1;
+ const cand = line[a..b];
+ if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(cand, cwd, realbuf)) {
+ .dir, .file, .image => return .{ .start = a, .end = b },
+ .url, .pane => if (std.mem.indexOfAny(u8, cand, " \t") == null)
+ return .{ .start = a, .end = b },
+ .none => {},
+ };
+ // ...else the same run one word shorter
+ while (hi > lo and line[hi - 1] != ' ' and line[hi - 1] != '\t') hi -= 1;
+ while (hi > lo and (line[hi - 1] == ' ' or line[hi - 1] == '\t')) hi -= 1;
+ }
+ return null;
+}
+
+test "lookableLineSpan takes the row's location and stops before its text" {
+ if (!platform_has_fs) return;
+ var realbuf: [4096]u8 = undefined;
+ // a grep row: the location, and NOT the matched code after it — which
+ // `resolve` would happily answer for, minus the column
+ try std.testing.expectEqualDeep(
+ @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5-9".len }),
+ lookableLineSpan("src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf),
+ );
+ // an lsp/jumplist row, whose column is followed by a blank rather than a
+ // ':' — the form a lenient read drops on the floor
+ try std.testing.expectEqualDeep(
+ @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5".len }),
+ lookableLineSpan("src/look.zig:12:5 pub fn resolve", ".", &realbuf),
+ );
+ try std.testing.expectEqualDeep(
+ @as(?Span, .{ .start = 0, .end = "@p3:10:5".len }),
+ lookableLineSpan("@p3:10:5 /home/goblin", ".", &realbuf),
+ );
+ // a bare path row, wrappers peeled and blank indent skipped like anywhere
+ // else — the anchor is the row's first non-blank cell, not column zero
+ try std.testing.expectEqualDeep(
+ @as(?Span, .{ .start = 3, .end = 3 + "src/look.zig".len }),
+ lookableLineSpan(" (src/look.zig)", ".", &realbuf),
+ );
+ // a link row keeps its link and leaves the title alone: a longer run is
+ // not a longer url
+ try std.testing.expectEqualDeep(
+ @as(?Span, .{ .start = 0, .end = "https://pardes.dev/a".len }),
+ lookableLineSpan("https://pardes.dev/a Chapter One", ".", &realbuf),
+ );
+ // ANCHORED: a place mentioned mid-row is not a stop, and a row with no
+ // place at its head is no stop at all
+ try std.testing.expectEqual(
+ @as(?Span, null),
+ lookableLineSpan("see also src/look.zig", ".", &realbuf),
+ );
+ try std.testing.expectEqual(@as(?Span, null), lookableLineSpan(" ", ".", &realbuf));
+ try std.testing.expectEqual(@as(?Span, null), lookableLineSpan("", ".", &realbuf));
+}
+
+test "lookableLineSpan prefers the longest run, so a blank inside a path is one span" {
+ if (!platform_has_fs) return;
+ var realbuf: [4096]u8 = undefined;
+ // A real path with a blank in it, under a directory whose own name is the
+ // first word of the row: the word grain can only ever see `tmp`, and the
+ // row grain sees the file, because it asks about the longest run first.
+ const io = std.Io.Threaded.global_single_threaded.io();
+ var tmp = try std.Io.Dir.cwd().openDir(io, "/tmp", .{});
+ defer tmp.close(io);
+ const name = "pardes look span.txt";
+ try tmp.writeFile(io, .{ .sub_path = name, .data = "" });
+ defer tmp.deleteFile(io, name) catch {};
+ try std.testing.expectEqualDeep(
+ @as(?Span, .{ .start = 0, .end = ("tmp/" ++ name).len }),
+ lookableLineSpan("tmp/" ++ name, "/", &realbuf),
+ );
+ // ...and the shrink still finds the shorter run when the long one is
+ // prose. Candidates END at a blank, so the runs tried are whole words:
+ // there is no hunt for a path hiding inside one (lookableSpan's rule).
+ try std.testing.expectEqualDeep(
+ @as(?Span, .{ .start = 0, .end = "tmp".len }),
+ lookableLineSpan("tmp holds pardes look span.txt", "/", &realbuf),
+ );
+}
+
/// Resolve a looked-at word against the pane's directory. `realbuf` must
/// outlive the returned Target (native paths point into it; web paths are
/// process-lifetime slices in the embedded source archive).
@@ -758,3 +912,643 @@ pub fn shellCwd(pid: libc.pid_t, buf: *[1024]u8) ?[]const u8 {
else => return null,
}
}
+
+// ---- tty occupancy: is a pane's terminal still the prompt pardes forked? ----
+
+// Linux answers TIOCGPGRP asked of the pty MASTER with the SLAVE side's
+// foreground process group — the number the kernel would deliver ^C to. Not in
+// std.c, and the master is the only end pardes holds.
+extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t;
+
+/// How far the descendant walk goes before it stops trusting itself. A shell
+/// sitting at its prompt has no descendants at all and a foreground job is one
+/// hop, so these are not a budget, they are a fuse: the walk is driven by
+/// numbers read out of the kernel and must not be able to spin on a surprising
+/// one (the same reason nested.outer() caps its hops). Hitting either bound
+/// answers OCCUPIED — a tree we did not finish reading may hide the foreground
+/// job, and typing a command line into vim is worse than declining to type it
+/// into a shell that really was idle under 32 background jobs.
+const occ_max_depth: u8 = 8;
+const occ_max_visited: usize = 32;
+
+/// Scratch for one `ttyTaken` answer: the walk's helpers share it rather than
+/// each declaring its own copy of a path buffer. Lives in the probe's own
+/// frame — there is no polling loop to hoist it out of any more, because the
+/// core asks this question only where it is about to type a command line.
+const TtyProbe = struct {
+ /// the forked shell's own executable, read once per probe
+ self_exe: [std.fs.max_path_bytes]u8 = undefined,
+ /// ...and one descendant's, to compare against it
+ exe: [std.fs.max_path_bytes]u8 = undefined,
+ /// one small /proc text at a time: a children list, a stat line, a status
+ /// blob. Each is consumed (parsed to numbers) before the next read.
+ blob: [4096]u8 = undefined,
+ /// the DFS worklist, bounded by the same fuse as the visit count
+ pending: [occ_max_visited]Node = undefined,
+
+ const Node = struct { pid: libc.pid_t, depth: u8 };
+};
+
+/// Is something OTHER than the shell prompt pardes forked sitting on this
+/// pane's tty — vim, less, an agent, a build? An Exec must never type a command
+/// line into such a program (it would land as vim keystrokes), so a taken
+/// terminal is treated exactly like no terminal at all: the core routes the
+/// command to another shell.
+///
+/// The predicate, and the false answer each clause exists to prevent:
+///
+/// fg = tcgetpgrp(master) the tty's foreground pgrp, from the kernel
+/// fg < 0 -> free no answer at all (not a tty, a host that
+/// does not allow the ioctl): behave as before
+/// self = exe(shell_pid) the binary of the terminal we spawned,
+/// straight out of /proc, so no spawn path has
+/// to be plumbed through three frontends' Pty
+/// structs and kept in step with shell_bin
+/// self == null -> free the shell is gone; the pane's EOF is about
+/// to remove it anyway
+/// walk descendants of shell_pid:
+/// exe unreadable -> occupied, unless the child is a zombie (or has
+/// already vanished), which is provably not on
+/// the tty. Unreadable-but-alive is a setuid
+/// program — `sudo` waiting for a password is
+/// the case that must NOT be typed into.
+/// exe != self -> occupied iff its pgrp is fg. The pgrp filter is
+/// what keeps `sleep 30 &` from looking
+/// occupied: a background job is a child of an
+/// idle prompt, and its pgrp is not the tty's.
+/// exe == self -> recurse. A nested shell prompt is still a usable
+/// prompt, so `bash` inside `bash` stays
+/// Exec-able; and the leaf is the answer, which
+/// is what catches `bash -c 'sleep 30'` — there
+/// the foreground pgrp LEADER's exe is our own
+/// shell binary while the tty really belongs to
+/// `sleep`.
+/// no visited process in pgrp fg, and fg != shell_pid
+/// -> occupied the tty belongs to a group we could not
+/// attribute to anything we forked (a
+/// foreground leader that died or re-parented);
+/// never type into it.
+/// otherwise -> free
+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;
+
+ // The shell's own pgrp is normally the tty's when it is at its
+ // prompt (forkpty made it the session and group leader), so the
+ // idle answer is reached without reading its stat at all — the
+ // whole fast path is tcgetpgrp, one readlink, and an empty
+ // children file.
+ var saw_fg = fg == shell_pid;
+ var pending: usize = 0;
+ var visited: usize = 0;
+ switch (pushChildren(&probe, &pending, shell_pid, 1)) {
+ .pushed => {},
+ // No children file: a kernel without CONFIG_PROC_CHILDREN
+ // cannot answer this question at all, so answer free and leave
+ // behaviour exactly as it was before this probe existed.
+ .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;
+
+ // One stat read carries the group; note it before anything can
+ // return, because the final clause is about every process we
+ // looked at, not only the ones that decided the answer.
+ 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 => {},
+ // This one exited while we walked (or the kernel stopped
+ // answering for it); its own pgrp was already counted and
+ // there is nothing below it to learn.
+ .unreadable => {},
+ .full => return true,
+ }
+ }
+ return !saw_fg;
+ },
+ // A darwin implementation is tcgetpgrp (which xnu also allows on the
+ // master) plus a descendant walk built from proc_listchildpids, with
+ // proc_pidpath for the exe and proc_bsdinfo's pbi_pgid for the group —
+ // there is no /proc to read. Until then macOS behaves as it did before
+ // this probe existed: every terminal is a prompt.
+ else => return false,
+ }
+}
+
+/// Read a small /proc text in one go. These files are generated on read and
+/// answer completely in a single call at these sizes; a short read would only
+/// truncate a field, which every parser below treats as "no answer".
+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)];
+}
+
+/// The binary behind a pid, as the kernel spells it. Fails for a zombie (no mm
+/// to point at) and for a process we may not inspect — the two cases `ttyTaken`
+/// has to tell apart.
+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)];
+}
+
+/// A pid's process group.
+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);
+}
+
+/// Field 5 of /proc/<pid>/stat, found by scanning back from the LAST ')'
+/// rather than counting fields from the start: field 2 is `comm` in
+/// parentheses, and a comm may contain spaces AND parentheses, so a process
+/// named `sh (a b)` shifts everything after it and a positional parse silently
+/// reads some other number as the group. Same trap nested.parsePPid documents;
+/// the kernel puts comm's closing paren last precisely so this scan works.
+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;
+}
+
+/// Is this pid provably NOT holding the tty even though its exe is unreadable:
+/// a zombie (dead, waiting to be reaped) or already gone. Everything else that
+/// hides its exe — a setuid program — is alive and on the terminal.
+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;
+ // No status at all: the pid died between the children read and here. A
+ // process that no longer exists cannot be typed into.
+ const status = readProc(path, buf) orelse return true;
+ return parseZombie(status);
+}
+
+/// The `State:` field of a /proc/<pid>/status blob, and only Z. Line-anchored,
+/// so a comm that spells `State: Z` inside the `Name:` line cannot answer.
+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 };
+
+/// Put a pid's direct children on the worklist. The children file is the whole
+/// reason this walk is cheap: an idle shell's is empty, so the fast path reads
+/// one empty file instead of scanning /proc.
+///
+/// Spelled out rather than routed through `readProc` precisely because of that
+/// empty file: readProc treats a zero-byte answer as no answer, which is right
+/// for a stat line and exactly wrong here — "this process has no children" is
+/// the most informative reply the walk ever gets, and calling it unreadable
+/// would make the whole probe give up on every idle shell.
+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;
+}
+
+/// The pids in a /proc/<pid>/task/<tid>/children blob: space separated, with a
+/// trailing space, and empty for the overwhelmingly common idle shell. Returns
+/// how many valid pids the blob HAS, having written the first `out.len` of them
+/// — a total past `out.len` is the caller's overflow signal. A token that is
+/// not strictly digits is skipped rather than answered wrong: this drives who
+/// gets walked, and parseInt alone would take `-1` and `+7`.
+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;
+ // the idle shell, which is the case the whole fast path is shaped around
+ try std.testing.expectEqual(@as(usize, 0), parseChildren("", &out));
+ try std.testing.expectEqual(@as(usize, 0), parseChildren(" ", &out));
+ // one child — the kernel writes a TRAILING space and no newline
+ try std.testing.expectEqual(@as(usize, 1), parseChildren("991 ", &out));
+ try std.testing.expectEqual(@as(libc.pid_t, 991), out[0]);
+ // several, with and without the trailing separator
+ 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));
+ // garbage: this list decides whose /proc entries get read, and parseInt
+ // alone would take every one of these. The pids AROUND the junk still
+ // answer — dropping the tree because one token was odd would silently turn
+ // a busy terminal into a free one.
+ 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]);
+ // overflow is REPORTED, not silently truncated: the total is what the blob
+ // HAS, so the caller can answer "occupied" instead of walking a tree it
+ // only partly read
+ 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" {
+ // the comm here contains a space AND parentheses — the exact shape that
+ // breaks `field 5 of /proc/<pid>/stat` (see nested.parsePPid). Counting
+ // from the left answers `b))` for the state and `S` for the group.
+ 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).?);
+ // ...and the ordinary shape still reads the same field
+ try std.testing.expectEqual(@as(libc.pid_t, 7), parsePgrp("42 (bash) S 1 7 7 34816 7 4194304").?);
+ // a group of its own, which is what a background job has
+ try std.testing.expectEqual(@as(libc.pid_t, 42), parsePgrp("42 (sleep) S 7 42 7 0 -1").?);
+ // a truncated read must not answer from a half line, and a blob that is
+ // not a stat line at all must not answer at all
+ 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" {
+ // a reaped-but-not-yet-collected child: no exe to read, and provably not
+ // holding the tty, so the walk must skip it instead of answering occupied
+ 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"));
+ // every other state is a live process, and an unreadable exe then means
+ // setuid (sudo asking for a password) — the one thing never to type into
+ 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"));
+ // a comm that spells the field cannot answer for it: the scan is anchored
+ // to the start of a line, and `Name:` is where a comm lives
+ try std.testing.expect(!parseZombie("Name:\tsh (State: Z)\nState:\tS (sleeping)\n"));
+ // a truncated read is not a zombie (and so stays conservative)
+ try std.testing.expect(!parseZombie("Name:\tsh\nSta"));
+ try std.testing.expect(!parseZombie("State:\t"));
+}
+
+// ---- tests: the predicate against real processes on a real pty ----
+//
+// The parsers above cannot see any of what follows: whether Linux answers
+// TIOCGPGRP on the MASTER at all, whether bash really puts a background job in
+// its own group, and whether `bash -c` leaves our own binary as the foreground
+// leader are all facts about the system, and every one of them decides an
+// answer. So these fork a real bash on a real pty — the way
+// test/e2e_harness.zig forks the whole app — and drive it.
+extern "c" fn forkpty(
+ amaster: *c_int,
+ name: ?[*:0]u8,
+ termp: ?*const anyopaque,
+ winp: ?*const std.posix.winsize,
+) c_int;
+
+const test_shell = "/bin/bash";
+const test_prompt = "PZX> ";
+
+/// A real interactive bash on a pty of our own, plus the polling the cases need.
+/// Nothing here sleeps for a fixed time waiting for the shell: every step polls
+/// to a deadline, and every poll DRAINS the master — a shell whose output is
+/// never read blocks on a full pty buffer and then nothing else happens either.
+const TestShell = struct {
+ master: c_int,
+ pid: libc.pid_t,
+ /// a rolling window of what the shell has written, so a case can wait for
+ /// the prompt (or a job-control notice) instead of guessing a duration
+ 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) {
+ // --norc: the developer's own bashrc must not decide what these
+ // tests see. -i: job control, which is what puts a background job
+ // in a group of its own and is half of what is under test.
+ const argv: [3:null]?[*:0]const u8 = .{ test_shell, "--norc", "-i" };
+ _ = execv(test_shell, &argv);
+ _exit(127);
+ }
+ var sh: TestShell = .{ .master = master, .pid = pid };
+ // A prompt of our own — EXPORTED, so a nested bash shows the same one —
+ // spelled with a '' seam, so the echo of the command that sets it
+ // cannot be mistaken for the prompt it produces.
+ 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;
+ }
+
+ /// Read everything the shell has produced so far, without blocking.
+ 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);
+ }
+
+ /// Poll until the verdict is `want` — the answer changes when the SHELL
+ /// gets around to forking or reaping, not when we sent the line.
+ 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);
+ }
+ }
+
+ /// ...and the other direction: the verdict STAYS `want` for a window. What
+ /// a false positive looks like is a probe that flickers to occupied while
+ /// the shell sits at its prompt with a background job, and a single sample
+ /// can miss it.
+ 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;
+ }
+
+ /// Kill the shell AND everything under it, then reap and close. The tree
+ /// has to be collected BEFORE the shell dies: a foreground job lives in its
+ /// own process group, so killing bash alone leaves `sleep 30` running,
+ /// re-parented to init — a stray that outlives the test binary.
+ 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);
+ // ...and its group, for a program that forked helpers of its own
+ _ = 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();
+
+ // The whole point of the default: a shell sitting at its prompt is usable,
+ // and stays usable across samples.
+ try std.testing.expect(sh.holdsTaken(false, 200));
+
+ // A foreground job IS the terminal now — this is the answer an Exec needs,
+ // and typing a command line here would be typing it at `sleep`.
+ sh.send("sleep 30\n");
+ try std.testing.expect(sh.waitTaken(true, 10_000));
+
+ // ^C, and the tty is the prompt's again. Nothing is cached: the next poll
+ // simply finds no children, which is why recovery needs no event.
+ 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();
+
+ // The false positive the pgrp filter exists for. Waiting for the job
+ // notice first matters: the verdict has to be taken while the child is
+ // genuinely alive, or this test would pass with no probe at all.
+ sh.send("sleep 30 &\n");
+ try std.testing.expect(sh.waitText("[1]", 10_000));
+ try std.testing.expect(sh.holdsTaken(false, 300));
+
+ // ...and it is still free once the job is gone, which also means the
+ // zombie between `kill` and bash's reap is not read as an occupant.
+ 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();
+
+ // `bash` inside `bash`: the leaf matches the binary we spawned, so it is a
+ // prompt like any other and Exec must keep working. This is the case the
+ // recursion is FOR, and the reason "any child at all" would be wrong.
+ 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));
+
+ // ...and one level deeper still
+ 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));
+
+ // a job inside the INNER shell is still the tty's owner
+ 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));
+
+ // The same shape where bash provably CANNOT exec the command in place (two
+ // commands, so the wrapper has to stay around and fork): the foreground
+ // group's leader is then our own shell binary while the tty really belongs
+ // to `sleep`. A predicate that stopped at the leader would call this free.
+ sh.send("bash --norc -c 'sleep 30; :'\n");
+ try std.testing.expect(sh.waitTaken(true, 10_000));
+
+ // ...and that is the shape asserted, not assumed: the shell's only child
+ // runs the same binary the shell does.
+ 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;
+ // The two shapes a human actually loses a terminal to: an editor that takes
+ // the alternate screen, and a pager that does not. Both are skipped rather
+ // than failed where they are not installed.
+ const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{
+ // -u NONE -i NONE: no vimrc, no viminfo — this must not touch the
+ // developer's own files, and an rc that starts a plugin would change
+ // the process tree under test.
+ .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" },
+ // LESS= so a developer's own -F (quit if one screen) cannot make the
+ // pager exit before it is asked to
+ .{ .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;
+}