diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 10:38:30 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | b03338789f5c9f3619a1ace7fcda842bc073c4dc (patch) | |
| tree | 30864a1003615a2b2b5f499aaf393acc8676c3b2 /src | |
| parent | d8382d6ea6692cf07ec6df5a7f2b86ad1f01a9df (diff) | |
| download | pardes-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.zig | 2 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 101 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 5 | ||||
| -rw-r--r-- | src/pardes.zig | 2 |
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); |
