summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-22 01:24:56 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit760448928186382da4ef9d942af1a2b7546ef54c (patch)
tree46b4dd010bc51668a2e874f7b165121c5849f8df /src
parent16717a555695ef666e9d2cd1bacc762a2ab15f4b (diff)
downloadpardes-760448928186382da4ef9d942af1a2b7546ef54c.tar.gz
pardes-760448928186382da4ef9d942af1a2b7546ef54c.zip
Refuse a session's own mount, and paint notices as a tagline band
Opening a path inside this editor's own 9P tree hung the session outright, and it is easy to do by accident: a file manager whose $EDITOR is pardes, or a Look at anything under /mnt/9p/pardes/<me>/. The realpath, the stat and the read all leave through the mount and come back as 9P requests only this editor's loop can answer, while that loop is blocked making them. The filesystem then stops answering anybody, which is what made it look frozen rather than slow. The answer has to come from the NAME, before any syscall, because the syscall is the thing that never returns: the listener notes the name it is posted under and `resolveOs` refuses a path containing `/pardes/<that name>/`, with `readLimit` refusing it too for anything that gets past resolution. Another session's mount stays perfectly usable, and `/n/self/...` is the way to reach your own tree -- the editor serves it from memory without leaving the process. Reproduced before and after: the 9P write that never returned now returns, the editor stays responsive, and it spends four CPU ticks doing it. The transient lines also now look like what they were modelled on. They carried the context band's bookkeeping -- one list, rows reserved the way sticky headers reserve them -- but still painted as ordinary body text, so a message read as a stray line at the bottom of the pane rather than as part of its chrome. They take the tagline font and the tagline's own colours now, verified on a running editor: the announcement lands with font_role=tagline. Two tests the features never had: a builtin announcing itself, reaching the notice list, being overridden by Msg's own text and silenced by Verbose; and the own-mount guard, including that a session with no listener refuses nothing. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig3
-rw-r--r--src/fs.zig49
-rw-r--r--src/pardes.zig42
3 files changed, 90 insertions, 4 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 7f0134ed..08deae7a 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -1358,6 +1358,9 @@ pub fn start(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes) ?*Listene
return null;
};
core.fs.socket_path = listener.path();
+ // So the editor can recognise its own tree by name and refuse to walk into
+ // it through the mount, which would deadlock the loop that serves it.
+ pardes.filesystem.noteOwnSocket(listener.path());
core.fs.tcp_address = listener.tcp_address;
core.fs.quic_address = listener.quic_address;
return listener;
diff --git a/src/fs.zig b/src/fs.zig
index 7cb08980..5c75eb72 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -681,8 +681,56 @@ pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[409
return null;
}
+/// The name this session is posted under, taken from its socket path.
+var own_name_buf: [64]u8 = undefined;
+var own_name_len: usize = 0;
+
+pub fn noteOwnSocket(socket_path: []const u8) void {
+ own_name_len = 0;
+ const base = std.fs.path.basename(socket_path);
+ const head = "pardes-9p-";
+ const tail = ".sock";
+ if (!std.mem.startsWith(u8, base, head) or !std.mem.endsWith(u8, base, tail)) return;
+ const name = base[head.len .. base.len - tail.len];
+ if (name.len == 0 or name.len > own_name_buf.len) return;
+ @memcpy(own_name_buf[0..name.len], name);
+ own_name_len = name.len;
+}
+
+/// Is this path inside this editor's OWN 9P tree, as a mount presents it?
+///
+/// Touching one from inside the editor deadlocks the session outright: the
+/// realpath, the stat and the read all leave through the mount and come back
+/// as 9P requests that only the editor's loop can answer, while that loop is
+/// blocked making them. The filesystem then stops answering anybody. So the
+/// answer has to come from the NAME, before any syscall -- the syscall is the
+/// thing that never returns. Look at `/n/self/...` instead, which the editor
+/// serves from memory without leaving the process.
+pub fn isOwnMount(path: []const u8) bool {
+ if (own_name_len == 0) return false;
+ var buf: [own_name_buf.len + 16]u8 = undefined;
+ const needle = std.fmt.bufPrint(&buf, "/pardes/{s}/", .{own_name_buf[0..own_name_len]}) catch return false;
+ return std.mem.indexOf(u8, path, needle) != null;
+}
+
+test "a path inside this session's own mount is refused before any syscall" {
+ noteOwnSocket("/run/user/1000/pardes-9p-demo.sock");
+ defer own_name_len = 0;
+ try std.testing.expect(isOwnMount("/mnt/9p/pardes/demo/index"));
+ try std.testing.expect(isOwnMount("/mnt/9p/pardes/demo/pane/new"));
+ try std.testing.expect(!isOwnMount("/mnt/9p/pardes/other/index"));
+ try std.testing.expect(!isOwnMount("/home/goblin/src/pardes/demo.zig"));
+ var out: [4096]u8 = undefined;
+ try std.testing.expect(resolveOs("/mnt/9p/pardes/demo/index", &out) == null);
+
+ // A session with no listener has no name, so nothing is refused.
+ noteOwnSocket("/tmp/not-a-pardes-socket");
+ try std.testing.expect(!isOwnMount("/mnt/9p/pardes/demo/index"));
+}
+
pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved {
if (comptime !platform_has_fs) return null;
+ if (isOwnMount(path)) return null;
var z: [4096]u8 = undefined;
const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return null;
const resolved = realpath(path_z, out) orelse return null;
@@ -706,6 +754,7 @@ pub fn read(p: *pardes.Pardes, path: []const u8) ![]u8 {
}
pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 {
+ if (isOwnMount(path)) return error.OwnMount;
const limit = @min(max_bytes, limits.max_file_bytes);
if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) {
var out: std.Io.Writer.Allocating = .init(p.gpa);
diff --git a/src/pardes.zig b/src/pardes.zig
index 5c6d2422..ec743bc6 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -13387,7 +13387,7 @@ pub const Pardes = struct {
s.previous_tag_layers = &.{};
s.cell_diffs = &.{};
const chrome = p.chromeTheme();
- const th = p.theme(); // the message row paints in the editor's colours
+ const th = p.theme();
// Separators have their own role; a scrollbar need not frame the page.
s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } });
@@ -13407,9 +13407,14 @@ pub const Pardes = struct {
const r = p.rects[id];
const tx = r.x + config.GUTTER;
const tw = r.w - config.GUTTER;
+ // Painted the way the sticky context headers above them are: the
+ // tagline font and the tagline's own colours, so a notice reads as
+ // a band belonging to the pane rather than as a line of body text
+ // that happens to sit at the bottom of it.
const msg_style: CellStyle = .{
- .fg = if (th.fg) |c| .{ .rgb = c } else .default,
- .bg = if (th.bg) |c| .{ .rgb = c } else .default,
+ .fg = .{ .rgb = chrome.tag_fg },
+ .bg = .{ .rgb = chrome.tag_bg },
+ .font_role = .tagline,
};
// The stack sits directly above the tagline, one line to a row, in
// the order collectNotices chose. A single notice therefore lands
@@ -13417,7 +13422,7 @@ pub const Pardes = struct {
const bottom = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1;
for (pane.notices.slice(), 0..) |kind, i| {
const row = bottom - @as(u16, @intCast(pane.notices.len - 1 - i));
- s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg });
+ s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg, .font_role = .tagline });
switch (kind) {
.message => _ = s.print(tx, row, tw, pane.msg[0..pane.msg_len], msg_style),
.leader => {
@@ -14634,6 +14639,35 @@ test "Esc back into a tty leaves its view at the prompt" {
try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset);
}
+test "a builtin announces itself on the message row, and Verbose silences it" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[p.active].?;
+
+ p.sync();
+ try std.testing.expect(p.settings.verbose);
+ // Wrap, not Collapse: a builtin that folds its own pane away leaves no row
+ // for its announcement, which is correct and not what this is testing.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
+ try std.testing.expectEqualStrings("Wrap", pane.msg[0..pane.msg_len]);
+
+ // The notice list is what the paint pass reads, so the announcement has to
+ // reach it, not just the buffer.
+ p.collectNotices(pane, p.rects[p.active], p.active);
+ try std.testing.expect(pane.notices.len > 0);
+ try std.testing.expectEqual(Pane.Notices.Kind.message, pane.notices.kinds[0]);
+
+ // Msg owns the row itself, so it does not announce over its own text.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
+ try std.testing.expectEqualStrings("hello", pane.msg[0..pane.msg_len]);
+
+ pane.msg_len = 0;
+ p.settings.verbose = false;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
+ try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
+}
+
test "closing the last pane in a column leaves an empty one in its place" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 });