summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 10:38:30 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commitb03338789f5c9f3619a1ace7fcda842bc073c4dc (patch)
tree30864a1003615a2b2b5f499aaf393acc8676c3b2 /src
parentd8382d6ea6692cf07ec6df5a7f2b86ad1f01a9df (diff)
downloadpardes-b03338789f5c9f3619a1ace7fcda842bc073c4dc.tar.gz
pardes-b03338789f5c9f3619a1ace7fcda842bc073c4dc.zip
pty/run on a fresh terminal waits for the shell's first prompt instead of answering busy
A new terminal's shell takes tens of milliseconds to draw its first prompt, and a run written meanwhile answered busy, so a script's first command was lost, while pty/status said busy 0. A line written before the first prompt now waits for it and is sent then (a respawn in between keeps it waiting for the new shell), and pty/status's third field says what a run would be told: busy while a command runs or text is typed at the prompt, not before the first prompt. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/ninep/events.zig2
-rw-r--r--src/ninep/pty.zig101
-rw-r--r--src/ninep/tree.zig5
-rw-r--r--src/pardes.zig2
4 files changed, 78 insertions, 32 deletions
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index a501d1e2..34355e5c 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -113,7 +113,7 @@ pub const LogKind = enum { new, del, rename, save };
/// A pane leaving, before its state goes with it; one never announced
/// leaves silently.
pub fn noteRetire(p: *Pardes, pane: *Pane) void {
- tree.pty.shellGone(p, pane);
+ tree.pty.shellGone(p, pane, false);
p.fs.news = true; // a read held on its event or pty/data hears it went
p.fs.listeners -|= pane.fs.readers;
// A write parked on its lock goes again, and finds the pane gone.
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index f788b4f8..0a55df94 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -84,16 +84,19 @@ pub fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply {
return tree.stagedReply(p, req);
}
-/// Three right-aligned fields: cols, rows and whether the host holds the tty.
+/// Three right-aligned fields: cols, rows and busy: whether a run written now
+/// would be told `busy` (a command runs, or text is typed at the prompt).
pub const status_len: u64 = 36;
pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
+ // Busy is what a run written now would be told: a command is running
+ // or a line is typed at the prompt. Before the shell's first prompt a
+ // run waits for it rather than answer busy, so that is not busy.
+ const marks = if (pane.terminal) |state| &state.stream.handler else null;
+ const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or
+ (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false)));
const out = p.fs.stage(p.gpa);
- out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{
- pane.cols,
- pane.rows,
- @intFromBool(p.hostTtyTaken(id)),
- }) catch {};
+ out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, @intFromBool(busy) }) catch {};
return tree.stagedReply(p, req);
}
@@ -115,7 +118,11 @@ pub const output_rows = output_cap / 8;
/// What an open of run holds (tree.zig's open records).
pub const Run = struct {
- phase: enum { idle, sent, done } = .idle,
+ /// `waiting`: the line is kept until the shell's first prompt (a new
+ /// terminal's shell takes a moment to draw it), then sent.
+ phase: enum { idle, waiting, sent, done } = .idle,
+ /// The line, gpa-owned, while it waits to be sent.
+ line: []u8 = &.{},
/// 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
@@ -146,9 +153,10 @@ fn answer(p: *Pardes, slot: *Run, comptime fmt: []const u8, args: anytype) void
pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.run;
- if (slot.phase == .sent) return tree.failText(req.tag, E.BUSY, tree.e_in_use);
+ if (slot.phase == .sent or slot.phase == .waiting) return tree.failText(req.tag, E.BUSY, tree.e_in_use);
// A new line on this open: a new answer.
p.gpa.free(slot.output);
+ p.gpa.free(slot.line);
slot.* = .{};
const line = std.mem.trimEnd(u8, req.data, "\r\n");
if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line);
@@ -159,23 +167,22 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const marks = &state.stream.handler;
if (pf.unmarked) {
answer(p, slot, "error no prompt marks", .{});
- } else if (waitingRun(p, pane) != null or marks.phase != .input or
- !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id))
+ } else if (waitingRun(p, pane) != null or (marks.prompts > 0 and (marks.phase != .input 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. The phase is pardes's own marks: a nested
- // shell's prompt (ssh, a shell with its own integration) looks like
- // an empty prompt to ghostty but is not the shell this run knows.
+ // Something is running or someone has typed at the prompt: sending
+ // now would type into the middle of it. The phase is pardes's own
+ // marks: a nested shell's prompt (ssh, a shell with its own
+ // integration) looks like an empty prompt to ghostty but is not the
+ // shell this run knows.
answer(p, slot, "busy", .{});
} else {
- slot.want = marks.started +% 1;
- slot.prompts = marks.prompts;
- slot.declined = marks.declined;
- p.emitWrite(id, line);
- p.emitWrite(id, "\r");
- slot.phase = .sent;
+ // Sent now if the shell is at its prompt, or at its first prompt
+ // when it has not drawn one yet (noteMarks).
+ slot.line = p.gpa.dupe(u8, line) catch return Reply.fail(req.tag, E.NOMEM);
+ slot.phase = .waiting;
pf.run = req.handle;
+ noteMarks(p, id, pane);
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -184,7 +191,7 @@ pub fn readRun(p: *Pardes, req: Req) Reply {
const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.run;
switch (slot.phase) {
.idle => return .{ .tag = req.tag, .payload = .{ .staged = 0 } },
- .sent => return .{ .tag = req.tag, .status = .again },
+ .waiting, .sent => return .{ .tag = req.tag, .status = .again },
.done => {},
}
// The answer line, then the output: one stream, read from `read` on.
@@ -214,6 +221,21 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
const pf = &pane.fs;
const slot = waitingRun(p, pane) orelse return;
const marks = &state.stream.handler;
+ if (slot.phase == .waiting) {
+ if (pf.unmarked) {
+ answer(p, slot, "error no prompt marks", .{});
+ pf.run = null;
+ return;
+ }
+ if (marks.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) return;
+ slot.want = marks.started +% 1;
+ slot.prompts = marks.prompts;
+ slot.declined = marks.declined;
+ p.emitWrite(id, slot.line);
+ p.emitWrite(id, "\r");
+ slot.phase = .sent;
+ return;
+ }
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.
@@ -290,9 +312,12 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
}
/// 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, pane: *Pane) void {
- answer(p, waitingRun(p, pane) orelse return, "error shell gone", .{});
+/// be reported, so the run says so instead of waiting forever. A line not
+/// sent yet waits on for the replacement's first prompt.
+pub fn shellGone(p: *Pardes, pane: *Pane, respawn: bool) void {
+ const slot = waitingRun(p, pane) orelse return;
+ if (respawn and slot.phase == .waiting) return;
+ answer(p, slot, "error shell gone", .{});
pane.fs.run = null;
}
@@ -609,13 +634,31 @@ test "run answers how its command ended once the shell is back at a prompt" {
};
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.
+ // Before its first prompt the shell cannot take a line yet: the line
+ // waits for it, and pty/status does not call the shell busy meanwhile.
+ const status = Node.of(serialOf(p), .pty_status);
+ try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0 "));
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);
+ const waiting = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" });
+ try testing.expectEqualStrings("", waiting.pty());
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status);
+ try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1 "));
+ // A respawn before that prompt keeps it waiting for the new shell's.
+ p.acknowledgeShell(0, "/bin/sh", true);
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status);
+ p.update(.{ .output = .{ .pane = 0, .bytes = prompt } });
+ var sent_late: [16]u8 = undefined;
+ var sent_len: usize = 0;
+ while (p.nextEffect()) |e| if (e == .write) {
+ const b = e.write.bytes.slice();
+ @memcpy(sent_late[sent_len..][0..b.len], b);
+ sent_len += b.len;
+ };
+ try testing.expectEqualStrings("true\r", sent_late[0..sent_len]);
+ out.put(p, "\x1b]133;C;aid=pardes\x07\x1b]133;D;0;aid=pardes\x07" ++ prompt);
+ try testing.expectEqualStrings("exit 0\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" });
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 3ce7e758..a83b0193 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -139,7 +139,10 @@ pub const Open = struct {
switch (o.what) {
.snapshot => |bytes| if (bytes) |b| gpa.free(b),
.log => |log| gpa.free(log.bytes),
- .run => |run| gpa.free(run.output),
+ .run => |run| {
+ gpa.free(run.output);
+ gpa.free(run.line);
+ },
.event, .pty_data, .ctl => {},
}
o.* = .{};
diff --git a/src/pardes.zig b/src/pardes.zig
index 442a3a30..405e7839 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4307,7 +4307,7 @@ pub const Pardes = struct {
pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void {
if (id < MAX_PANES) if (p.panes[id]) |pane| {
- ctlfs.pty.shellGone(p, pane); // a respawn replaced whatever ran
+ ctlfs.pty.shellGone(p, pane, true); // a respawn replaced whatever ran
pane.fs.unmarked = !prompt_marks;
};
if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks);