summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/events.zig10
-rw-r--r--src/ninep/pane.zig8
-rw-r--r--src/ninep/pty.zig210
-rw-r--r--src/ninep/tree.zig9
4 files changed, 230 insertions, 7 deletions
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index ebd1cd2c..fa0a4975 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -128,6 +128,7 @@ pub fn noteInstall(p: *Pardes, id: usize) void {
/// A pane leaving; one never announced leaves silently.
pub fn noteRetire(p: *Pardes, id: usize, pane: *Pane) void {
if (id >= MAX_PANES) return;
+ tree.pty.shellGone(p, id);
if (p.fs.panes[id].unannounced) {
p.fs.panes[id].unannounced = false;
return;
@@ -168,10 +169,11 @@ fn pushLog(p: *Pardes, record: []u8) void {
c.* = ' ';
};
// One record larger than the ring would push every other out and then
- // not fit itself; cut it to what fits instead.
- const fit = @min(record.len, p.fs.log.cap - 4);
- record[fit - 1] = '\n';
- p.fs.log.push(p.gpa, record[0..fit]);
+ // not fit itself; cut it to what fits instead, on a character boundary.
+ var end = @min(record.len, p.fs.log.cap - 4) - 1;
+ while (end > 0 and record[end] & 0xC0 == 0x80) end -= 1;
+ record[end] = '\n';
+ p.fs.log.push(p.gpa, record[0 .. end + 1]);
}
/// An open freezes the ring's text, so `cat log` answers what happened lately
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 40d292c0..61073a55 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -41,6 +41,10 @@ pub const State = struct {
tag_snap: std.ArrayList(u8) = .empty,
pty_readers: u16 = 0,
pty_out: events.Queue = .{},
+ /// The pty/run open waiting on this shell's current command.
+ run: ?u8 = null,
+ /// The host started a shell it could not teach to mark its prompts.
+ unmarked: bool = false,
/// Installed during this update; /log hears about it once the update ends
/// and the pane has its file, name and place.
unannounced: bool = false,
@@ -225,7 +229,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
.pty_status => pty.status_len,
.pty_ctl => pty.ctlLen(pane),
.pty_data => events.pending(&pf.pty_out),
- .dir, .errors, .pty => 0,
+ .dir, .errors, .pty, .pty_run => 0,
};
}
@@ -293,6 +297,7 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
.pty_status => pty.readStatus(p, req, id, pane),
.pty_data => pty.readData(p, req, pf),
.pty_ctl => pty.readCtl(p, req, pane),
+ .pty_run => pty.readRun(p, req),
.dir, .errors, .pty => Reply.fail(req.tag, E.PERM),
};
}
@@ -386,6 +391,7 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
Reply.fail(req.tag, E.IO),
.pty_ctl => pty.writeCtl(p, req, id),
.pty_data => pty.writeData(p, req, id),
+ .pty_run => pty.writeRun(p, req, id, pane),
.dir, .pty, .pty_status => Reply.fail(req.tag, E.PERM),
};
}
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index b34bd59d..c7a32fbf 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -15,6 +15,7 @@ pub fn fileNamed(name: []const u8) ?PaneFile {
if (std.mem.eql(u8, name, "ctl")) return .pty_ctl;
if (std.mem.eql(u8, name, "status")) return .pty_status;
if (std.mem.eql(u8, name, "data")) return .pty_data;
+ if (std.mem.eql(u8, name, "run")) return .pty_run;
return null;
}
@@ -101,6 +102,147 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
return tree.stagedReply(p, req);
}
+// ---- run: one command line in, how it ended out ----
+//
+// factotum's rpc shape (factotum(4)): write a request on an open, read the
+// answer on that same open, so the open is what ties a result to its
+// command. The shell's own OSC 133 marks say when the command started (C)
+// and ended (D, with its status); pardes injects them into bash and fish.
+
+pub const run_slots = 8;
+
+pub const Run = struct {
+ /// 0 is a free slot.
+ node: u64 = 0,
+ phase: enum { idle, sent, done } = .idle,
+ /// The shell's command count its C must reach for the end to be ours.
+ want: u32 = 0,
+ /// The shell's prompt count when the line went: a newer prompt with no
+ /// C for it means the shell never ran the line.
+ prompts: u32 = 0,
+ /// The refused line was taken back off the prompt.
+ cleared: bool = false,
+ answer: [48]u8 = undefined,
+ len: u8 = 0,
+};
+
+fn runOf(p: *Pardes, req: Req) ?*Run {
+ if (req.handle == 0 or req.handle > run_slots) return null;
+ const slot = &p.fs.runs[req.handle - 1];
+ return if (slot.node == req.node) slot else null;
+}
+
+pub fn openRun(p: *Pardes, req: Req, serial: u32) Reply {
+ _ = serial;
+ for (&p.fs.runs, 0..) |*slot, i| {
+ if (slot.node != 0) continue;
+ slot.* = .{ .node = req.node };
+ return .{ .tag = req.tag, .handle = @intCast(i + 1) };
+ }
+ return Reply.fail(req.tag, E.NFILE);
+}
+
+fn answer(slot: *Run, comptime fmt: []const u8, args: anytype) void {
+ slot.len = @intCast((std.fmt.bufPrint(&slot.answer, fmt ++ "\n", args) catch unreachable).len);
+ slot.phase = .done;
+ pardes.turn.parked = true; // wake the read waiting on it
+}
+
+pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ const slot = runOf(p, req) orelse return Reply.fail(req.tag, E.INVAL);
+ if (slot.phase == .sent) return tree.failText(req.tag, 16, tree.e_in_use);
+ const line = std.mem.trimEnd(u8, req.data, "\r\n");
+ if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line);
+ for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line);
+ const pf = &p.fs.panes[id];
+ const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line);
+ const marks = &state.stream.handler;
+ if (pf.unmarked) {
+ answer(slot, "error no prompt marks", .{});
+ } else if (pf.run != null or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) {
+ // Something is running, someone has typed at the prompt, or the
+ // shell has not drawn its first one yet: sending now would type into
+ // the middle of it.
+ answer(slot, "busy", .{});
+ } else {
+ slot.want = marks.started +% 1;
+ slot.prompts = marks.prompts;
+ p.emitWrite(id, line);
+ p.emitWrite(id, "\r");
+ slot.phase = .sent;
+ pf.run = @intCast(slot - &p.fs.runs[0]);
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+pub fn readRun(p: *Pardes, req: Req) Reply {
+ const slot = runOf(p, req) orelse return Reply.fail(req.tag, E.INVAL);
+ switch (slot.phase) {
+ .idle => return .{ .tag = req.tag, .payload = .{ .staged = 0 } },
+ .sent => return .{ .tag = req.tag, .status = .again },
+ .done => {},
+ }
+ const text = slot.answer[0..slot.len];
+ const off = @min(req.off, text.len);
+ const n = @min(text.len - off, req.size);
+ p.fs.stage(p.gpa).appendSlice(p.gpa, text[off..][0..n]) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } };
+}
+
+pub fn releaseRun(p: *Pardes, req: Req) Reply {
+ const slot = runOf(p, req) orelse return .{ .tag = req.tag };
+ const idx: u8 = @intCast(slot - &p.fs.runs[0]);
+ // The command runs on in the shell; nobody is waiting for it any more.
+ for (&p.fs.panes) |*pf| if (pf.run == idx) {
+ pf.run = null;
+ };
+ slot.* = .{};
+ return .{ .tag = req.tag };
+}
+
+/// After each chunk of a terminal's output: once the command the run sent
+/// has ended and the shell is back at a prompt, the run answers how. Waiting
+/// for the prompt means whoever reads the answer can send the next command
+/// at once instead of finding the shell still drawing it.
+pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
+ if (comptime !pardes.panes.Terminal.enabled) return;
+ const state = pane.terminal orelse return;
+ const pf = &p.fs.panes[id];
+ const slot = &p.fs.runs[pf.run orelse return];
+ const marks = &state.stream.handler;
+ if (marks.phase != .input) return;
+ // ponytail: a prompt redrawn before the command starts (a resize in that
+ // instant) reads as not run; a per-command id in the marks would tell.
+ if (marks.started != slot.want and marks.prompts != slot.prompts) {
+ // fish keeps a refused line on the prompt for editing; it is the one
+ // this run typed into an empty prompt, so take it back (Ctrl-U), and
+ // answer once it is gone so the next line finds the prompt clear.
+ if (!pardes.panes.Terminal.promptInputEmpty(pane)) {
+ if (!slot.cleared) p.emitWrite(id, "\x15");
+ slot.cleared = true;
+ return;
+ }
+ answer(slot, "error not run", .{});
+ pf.run = null;
+ return;
+ }
+ const done = marks.finished orelse return;
+ if (done.seq != slot.want) return;
+ if (done.status) |status| answer(slot, "exit {d}", .{status}) else answer(slot, "exit", .{});
+ pf.run = null;
+}
+
+/// The pane closed or its shell was replaced: the command's end will never
+/// be reported, so the run says so instead of waiting forever.
+pub fn shellGone(p: *Pardes, id: usize) void {
+ const pf = &p.fs.panes[id];
+ const idx = pf.run orelse return;
+ answer(&p.fs.runs[idx], "error shell gone", .{});
+ pf.run = null;
+}
+
+const e_bad_line = "bad command line";
+
pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply {
if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again };
const out = p.fs.stage(p.gpa);
@@ -186,10 +328,11 @@ test "a terminal pane's pty/ holds exactly ctl, status and data" {
try testing.expect(th.nameAt(files, "pty").?.dir);
const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf);
- try testing.expectEqual(@as(usize, 3), inside.len);
+ try testing.expectEqual(@as(usize, 4), inside.len);
try testing.expectEqualStrings("ctl", inside[0].name);
try testing.expectEqualStrings("status", inside[1].name);
try testing.expectEqualStrings("data", inside[2].name);
+ try testing.expectEqualStrings("run", inside[3].name);
for (inside) |d| try testing.expect(!d.dir);
try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node);
@@ -386,6 +529,71 @@ test "pty/data writes at the shell and reads the raw stream" {
try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes);
}
+test "run answers how its command ended once the shell is back at a prompt" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .pty_run);
+ const out = struct {
+ fn put(pp: *Pardes, bytes: []const u8) void {
+ pp.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ while (pp.nextEffect()) |_| {}
+ }
+ };
+ const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07";
+
+ // Before its first prompt the shell cannot take a line: busy, at once.
+ const early = call(p, .{ .tag = 1, .op = .open, .node = node });
+ _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" });
+ try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = early.reply.handle });
+
+ out.put(p, prompt);
+ const r = call(p, .{ .tag = 5, .op = .open, .node = node });
+ const h = r.reply.handle;
+ const sent = call(p, .{ .tag = 6, .op = .write, .node = node, .handle = h, .data = "make\n" });
+ try testing.expectEqualStrings("make\r", sent.pty());
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 7, .op = .read, .node = node, .handle = h, .size = 64 }).reply.status);
+ // A second line on the same open while the first runs is refused.
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = node, .handle = h, .data = "ls\n" }).reply.status);
+
+ // Untagged marks (fish's own, a nested shell's, a file being printed)
+ // and bash's C from its PROMPT_COMMAND trap change nothing.
+ out.put(p, "\x1b]133;D;9\x07\x1b]133;C;aid=pardes\x07building\r\n");
+ out.put(p, "\x1b]133;C;aid=pardes\x07\x1b]133;D;2;aid=pardes\x07");
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 9, .op = .read, .node = node, .handle = h, .size = 64 }).reply.status);
+ out.put(p, prompt);
+ try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .size = 64 }).bytes);
+ // The answer stays for a re-read until the open closes.
+ try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 11, .op = .read, .node = node, .handle = h, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 12, .op = .release, .node = node, .handle = h });
+
+ // Typed text on the prompt line: busy, rather than typing into it.
+ out.put(p, "ls -");
+ const typed = call(p, .{ .tag = 13, .op = .open, .node = node });
+ _ = call(p, .{ .tag = 14, .op = .write, .node = node, .handle = typed.reply.handle, .data = "true\n" });
+ try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 15, .op = .read, .node = node, .handle = typed.reply.handle, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 16, .op = .release, .node = node, .handle = typed.reply.handle });
+ out.put(p, "\x08\x08\x08\x08\x1b[K");
+
+ // A line the shell refuses without running it (fish on a syntax error):
+ // a fresh prompt and no C. Waiting for a D would wait forever.
+ const refused = call(p, .{ .tag = 21, .op = .open, .node = node });
+ _ = call(p, .{ .tag = 22, .op = .write, .node = node, .handle = refused.reply.handle, .data = "(exit 7)\n" });
+ out.put(p, "\r\nfish: command substitutions not allowed\r\n" ++ prompt);
+ try testing.expectEqualStrings("error not run\n", call(p, .{ .tag = 23, .op = .read, .node = node, .handle = refused.reply.handle, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 24, .op = .release, .node = node, .handle = refused.reply.handle });
+
+ // The pane going away while a command runs ends the wait.
+ out.put(p, "\r\n" ++ prompt);
+ const gone = call(p, .{ .tag = 17, .op = .open, .node = node });
+ _ = call(p, .{ .tag = 18, .op = .write, .node = node, .handle = gone.reply.handle, .data = "sleep 9\n" });
+ try p.removePane(0, null);
+ try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 19, .op = .read, .node = node, .handle = gone.reply.handle, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 20, .op = .release, .node = node, .handle = gone.reply.handle });
+ for (p.fs.runs) |slot| try testing.expect(slot.node == 0);
+}
+
test "the pty queue drops the oldest at its cap" {
const gpa = testing.allocator;
const p = try withTerm(gpa);
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index a85a1873..0d328594 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -167,6 +167,7 @@ pub const PaneFile = enum(u5) {
pty_ctl,
pty_status,
pty_data,
+ pty_run,
pub fn fileName(f: PaneFile) []const u8 {
return switch (f) {
@@ -174,6 +175,7 @@ pub const PaneFile = enum(u5) {
.pty_ctl => "ctl",
.pty_status => "status",
.pty_data => "data",
+ .pty_run => "run",
else => @tagName(f),
};
}
@@ -193,7 +195,7 @@ pub const PaneFile = enum(u5) {
pub fn inPty(f: PaneFile) bool {
return switch (f) {
- .pty, .pty_ctl, .pty_status, .pty_data => true,
+ .pty, .pty_ctl, .pty_status, .pty_data, .pty_run => true,
else => false,
};
}
@@ -531,6 +533,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
pf.event_reader = pf.event_reader or reader;
if (reader) return .{ .tag = req.tag, .handle = reader_handle };
},
+ .pty_run => return pty.openRun(p, req, t.serial),
.pty_data => if (reads(req.omode)) {
if (pf.pty_readers > 0) return failText(req.tag, e_busy, e_in_use);
pf.pty_readers = 1;
@@ -569,6 +572,8 @@ fn releaseHandle(p: *Pardes, req: Req) Reply {
switch (target) {
.top => {},
.pane => |t| {
+ // A run's open outlives its pane: it still holds the answer.
+ if (t.file == .pty_run) return pty.releaseRun(p, req);
if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag };
const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag };
const pf = &p.fs.panes[id];
@@ -633,6 +638,8 @@ fn read(p: *Pardes, req: Req, target: Target) Reply {
.root, .pane => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
+ // A run's answer outlives the pane it ran in.
+ if (t.file == .pty_run and req.handle != 0) return pty.readRun(p, req);
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
const pn = p.panes[id].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);