summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 10:45:57 -0300
committerGabriel Schneider <[email protected]>2026-10-01 11:09:59 -0300
commitc5e5db988e59689b44feb42c59090c07687bcd42 (patch)
treecfbe54c55de94676c160d75600393e11d8523bf7 /src
parent25844b54fd08dd9cadff2275f2161ee5dd316395 (diff)
downloadpardes-c5e5db988e59689b44feb42c59090c07687bcd42.tar.gz
pardes-c5e5db988e59689b44feb42c59090c07687bcd42.zip
`> exec` through a mount no longer hangs the editor, and a close holding a last line is answered at once: the line runs at the editor's next step
A shell's truncating open of exec (or look, ctl, log, pager) sends a setattr that changes nothing; it waited for the editor to be quiet, and through a 9ns mount that quiet could never come: 9ns answers nothing else on the mount while the setattr is out, and the editor's step may be out reading a pane's file through it. This was the "truncating open of exec hangs, only in selfmount" mystery. Only a truncate of a pane's file or a tag waits now. A close holding a last line with no newline hands it to the editor's step (tree.runClosedLines, beside fillClosedPagers), as a /pager close does, and is answered at once; a refusal is the log's err. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig5
-rw-r--r--src/fs.zig7
-rw-r--r--src/ninep/pane.zig1
-rw-r--r--src/ninep/tree.zig76
-rw-r--r--src/pardes.zig2
5 files changed, 80 insertions, 11 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 57df0c5f..cb345b00 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -266,7 +266,7 @@ pub const Listener = struct {
const restores = pardes.turn.restores;
// Asked first: a release that runs a held line has none after.
const changes = pardes.ctlfs.changesPane(core, req);
- const fills = pardes.ctlfs.holdsPage(core, req);
+ const fills = pardes.ctlfs.handsOff(core, req);
const reply = core.serveFs(req);
if (req.op == .release and quiet) l.collectOs();
// A read with nothing yet stays parked in the engine, and the core
@@ -301,7 +301,8 @@ pub const Listener = struct {
// whole after the wait.
l.kick();
// A read that put a /pager open's text in (ctl.fillPager), or a
- // close that handed it to the editor's step, waits on nothing the
+ // close that handed its text or last line to the editor's step
+ // (ctl.fillClosedPagers, tree.runClosedLines), waits on nothing the
// editor performs: its answer goes now, while the turn still keeps
// a read's payload whole. Waiting would hang a close through a
// mount: 9ns answers nothing else on it while a clunk is out, and
diff --git a/src/fs.zig b/src/fs.zig
index 4a243b10..7a56d387 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1636,6 +1636,11 @@ pub const Namespace = struct {
/// a close through a mount may not wait for the editor to be quiet,
/// as 9ns answers nothing else on that mount while a clunk is out.
pages: std.ArrayList(struct { serial: u32, bytes: std.ArrayList(u8) }) = .empty,
+ /// The last line each closed open wrote with no newline after it, with
+ /// the node it was written to, for the editor's step to run
+ /// (tree.runClosedLines): a close is answered at once, for the same
+ /// reason as `pages`.
+ closed_lines: std.ArrayList(struct { node: u64, bytes: std.ArrayList(u8) }) = .empty,
batch: struct {
serial: u32 = 0,
node: u64 = 0,
@@ -1685,6 +1690,8 @@ pub const Namespace = struct {
for (&st.opens) |*o| o.deinit(gpa);
for (st.pages.items) |*page| page.bytes.deinit(gpa);
st.pages.deinit(gpa);
+ for (st.closed_lines.items) |*line| line.bytes.deinit(gpa);
+ st.closed_lines.deinit(gpa);
st.log.deinit(gpa);
st.out.deinit(gpa);
}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index e2ec31d1..c496571f 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -1571,6 +1571,7 @@ test "a name cut across writes is one name, applied once at its newline or its c
_ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" });
try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?));
_ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true });
+ tree.runClosedLines(p);
try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?));
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 5499a337..b0ffdd68 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -87,18 +87,34 @@ pub const features: cloud9.fs.Features = .{ .remove = true, .create = true };
/// so what parks here is never the syscall's own request.
pub fn needsQuiet(p: *Pardes, req: Req) bool {
return switch (req.op) {
- .write, .setattr => true,
- .release => req.remove or holdsLines(p, req),
+ .write => true,
+ .setattr => truncates(req),
+ .release => req.remove,
.read => holdsPage(p, req),
else => false,
};
}
+/// A setattr that changes something: a truncate of a pane's file or a
+/// tag. `> exec` (a shell's truncating open) changes nothing, so it waits
+/// on nothing: through a mount it hung, as 9ns answers nothing else on the
+/// mount while it is out, and the editor's step may be out reading a pane's
+/// file through that mount, the quiet it waited for never coming.
+fn truncates(req: Req) bool {
+ if (!req.truncate) return false;
+ return switch (Node.target(req.node) orelse return false) {
+ .pane => true,
+ .col => |c| c.file == .tag,
+ .top => |f| f == .tag,
+ };
+}
+
/// `needsQuiet`, plus the open that makes a pane: what costs a frame and a
/// wake of the editor.
pub fn changesPane(p: *Pardes, req: Req) bool {
return switch (req.op) {
- .write, .setattr => true,
+ .write => true,
+ .setattr => truncates(req),
.open => req.node == @intFromEnum(TopFile.new),
.release => req.remove or holdsLines(p, req) or holdsPage(p, req),
.read => holdsPage(p, req),
@@ -973,13 +989,11 @@ fn releaseHandle(p: *Pardes, req: Req) void {
p.fs.pages.append(p.gpa, .{ .serial = o.what.pager, .bytes = o.pending }) catch o.pending.deinit(p.gpa);
o.pending = .empty;
}
- // The last line an open wrote with no newline after it runs now.
+ // The last line an open wrote with no newline after it runs at the
+ // editor's next step (runClosedLines), as a /pager open's text does.
if (o.pending.items.len > 0) {
- const rest = p.scratch.allocator().dupe(u8, o.pending.items) catch "";
- o.pending.clearRetainingCapacity();
- const w: Req = .{ .tag = req.tag, .op = .write, .node = req.node, .handle = req.handle, .data = rest };
- const reply = writeNow(p, w, t);
- if (reply.status == .err) events.noteError(p, w, reply);
+ p.fs.closed_lines.append(p.gpa, .{ .node = req.node, .bytes = o.pending }) catch o.pending.deinit(p.gpa);
+ o.pending = .empty;
}
const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null;
if (of) |pn| switch (o.what) {
@@ -1169,6 +1183,25 @@ fn linesOpen(target: Target, req: Req) bool {
return req.omode & 3 != 0 or resultsFile(target);
}
+/// The editor's step: the last lines of closed opens run, each as a write
+/// of its own; a refusal is the log's err, as no open is left to answer.
+pub fn runClosedLines(p: *Pardes) void {
+ for (p.fs.closed_lines.items) |*line| {
+ defer line.bytes.deinit(p.gpa);
+ const t = Node.target(line.node) orelse continue;
+ const w: Req = .{ .tag = 0, .op = .write, .node = line.node, .data = line.bytes.items };
+ const reply = writeNow(p, w, t);
+ if (reply.status == .err) events.noteError(p, w, reply);
+ }
+ p.fs.closed_lines.clearRetainingCapacity();
+}
+
+/// A close that hands its open's held text, a last line or a /pager's
+/// page, to the editor's step: answered at once, never waiting on it.
+pub fn handsOff(p: *Pardes, req: Req) bool {
+ return (req.op == .release and holdsLines(p, req)) or holdsPage(p, req);
+}
+
/// A release that will run a line its open held back.
fn holdsLines(p: *Pardes, req: Req) bool {
const o = openOf(p, req) orelse return false;
@@ -1806,6 +1839,24 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e
try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null);
}
+test "nothing a mount's close or a no-op truncate sends waits on the editor: a held line runs at its next step" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec = @intFromEnum(TopFile.exec);
+ // `> exec` truncates nothing; `> body` does.
+ try testing.expect(!needsQuiet(p, .{ .tag = 1, .op = .setattr, .node = exec, .truncate = true }));
+ try testing.expect(!changesPane(p, .{ .tag = 1, .op = .setattr, .node = exec, .truncate = true }));
+ try testing.expect(needsQuiet(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serialOf(p), .body), .truncate = true }));
+ const h = call(p, .{ .tag = 2, .op = .open, .node = exec, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = exec, .handle = h, .data = "Msg at-step" });
+ const close: Req = .{ .tag = 4, .op = .release, .node = exec, .handle = h, .opened = true };
+ try testing.expect(!needsQuiet(p, close) and handsOff(p, close));
+ _ = call(p, close);
+ try testing.expect(!th.logHas(p, " at-step\n"));
+ runClosedLines(p);
+ try testing.expect(th.logHas(p, " at-step\n"));
+}
+
test "a command line cut across writes runs once whole, and the last runs at release; an Edit block waits for its end" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -1821,6 +1872,7 @@ test "a command line cut across writes runs once whole, and the last runs at rel
try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n"));
try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n"));
_ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true });
+ runClosedLines(p);
try testing.expect(th.logHas(p, " tail\n"));
// An Edit block's text lines come in later writes on the same open.
const serial = serialOf(p);
@@ -1829,6 +1881,7 @@ test "a command line cut across writes runs once whole, and the last runs at rel
for ([_][]const u8{ "Edit ,a\n", "one\ntw", "o\n", ".\n" }) |piece|
try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = piece }).reply.status);
_ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ runClosedLines(p);
try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content);
}
@@ -1878,6 +1931,7 @@ test "a write with no newline waits for its newline or the close, whatever its s
try testing.expectEqual(Status.ok, call(p, .{ .tag = 6, .op = .write, .node = ctl_node, .handle = c, .data = "bogus" }).reply.status);
try testing.expect(!th.logHas(p, "bogus"));
_ = call(p, .{ .tag = 7, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ runClosedLines(p);
try testing.expect(th.logHas(p, "unknown control message \"bogus\""));
// A name: a NUL fails its write, not the close after it.
const name = Node.of(serial, .name);
@@ -1886,6 +1940,7 @@ test "a write with no newline waits for its newline or the close, whatever its s
try testing.expectEqual(Status.err, bad.reply.status);
try testing.expect(std.mem.indexOf(u8, bad.reply.ename, "a NUL") != null);
try testing.expectEqual(Status.ok, call(p, .{ .tag = 12, .op = .release, .node = name, .handle = n, .opened = true }).reply.status);
+ runClosedLines(p);
}
test "look and exec read back what their own open's write touched; a fresh open reads the session's last" {
@@ -1973,6 +2028,7 @@ test "an Edit block still open when its open closes fails there with an err and
const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
_ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,x/b/{\ni/[/\n" });
_ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ runClosedLines(p);
try testing.expect(th.logHas(p, "unmatched"));
try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
}
@@ -1984,6 +2040,7 @@ test "an a, c or i text still open when its open closes fails there too and chan
const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
_ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a\nhalf of a block\n" });
_ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ runClosedLines(p);
try testing.expect(th.logHas(p, "not ended by a . line"));
try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
}
@@ -1995,6 +2052,7 @@ test "an a, c or i text cut short through exec is an err in the log, not a msg"
const c = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
_ = call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = c, .data = "Edit $a\nhalf\n" });
_ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = c, .opened = true });
+ runClosedLines(p);
var want: [64]u8 = undefined;
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: Edit: a, c or i text not ended", .{serialOf(p)})));
try testing.expect(!th.logHas(p, "msg 1 Edit: a, c or i"));
diff --git a/src/pardes.zig b/src/pardes.zig
index 5d9f8606..612a09aa 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5756,6 +5756,8 @@ pub const Pardes = struct {
ctlfs.pane.flushPausedBatch(p);
// ...and what a /pager open was written before its close.
ctlfs.ctl.fillClosedPagers(p);
+ // ...and the last line of an open closed with no newline after it.
+ ctlfs.runClosedLines(p);
while (p.nextQueued()) |ev| p.update(ev);
while (p.nextEffect()) |e| p.perform(e);
turn.settle();