summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 14:36:46 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit0f18cc317190c2aaaf0efec4cd6da656cb1f3402 (patch)
treefb2abbddacae3be30ea5ddcbeeca03e40cccf26c
parent010b123a88aa5278c499f83098c00bdbdb0093ce (diff)
downloadpardes-0f18cc317190c2aaaf0efec4cd6da656cb1f3402.tar.gz
pardes-0f18cc317190c2aaaf0efec4cd6da656cb1f3402.zip
A spawn names the pane it was made for, so a pane closed and its slot retaken spawns nothing
The spawn effect carried only a slot, and the host read the command and shell off whatever pane held the slot as it forked: a command pane closed and its slot taken by the next one before the effects ran forked the new pane's command twice. The effect now carries the pane's serial and is dropped when the slot holds another; forkShell checks it again after giving up the turn for its directory's stat, the one moment a 9P client can change the panes under it. Every front end performs spawns with the turn, from its step. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/dump.zig2
-rw-r--r--src/exec.zig2
-rw-r--r--src/host_io.zig4
-rw-r--r--src/ninep/pty.zig2
-rw-r--r--src/pardes.zig43
5 files changed, 46 insertions, 7 deletions
diff --git a/src/dump.zig b/src/dump.zig
index 3d0f0f0e..954a3270 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -748,7 +748,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
p.installPane(i, restored);
try restored.setOwnedCwd(t.cwd);
if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty;
- if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .cwd = .from(t.cwd) } });
+ if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .serial = restored.serial, .cwd = .from(t.cwd) } });
break :terminal restored;
},
.file => try pardes.panes.File.restore(p, i, src),
diff --git a/src/exec.zig b/src/exec.zig
index e2b9f880..86adfeeb 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -430,7 +430,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
var buf: [command_max + 8]u8 = undefined;
const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else "";
panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&buf, "{s}% {s}\r\n", .{ lead, line }) catch "%\r\n");
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } });
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } });
noteRun(p, pane, "run", line);
return id;
}
diff --git a/src/host_io.zig b/src/host_io.zig
index 63b907ac..b167ef8a 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -965,6 +965,7 @@ pub fn forkShell(
const native_cwd = filesystem.localPath(cwd) orelse cwd;
if (std.mem.indexOfScalar(u8, native_cwd, 0) != null) return error.InvalidPath;
var cwd_buf: [4096]u8 = undefined;
+ const serial = if (core) |c| if (c.panes[pane]) |pn| pn.serial else 0 else 0;
const cwd_z: ?[:0]const u8 = if (native_cwd.len == 0) null else dir: {
const path = std.fmt.bufPrintSentinel(&cwd_buf, "{s}", .{native_cwd}, 0) catch return error.NameTooLong;
// A shell's directory may be inside a mount this editor serves.
@@ -974,6 +975,9 @@ pub fn forkShell(
if (stat.kind != .directory) return error.NotDir;
break :dir path;
};
+ // The turn was given up for the stat: a 9P client may have closed the
+ // pane, and another taken its slot, whose command this is not.
+ if (core) |c| if ((if (c.panes[pane]) |pn| pn.serial else 0) != serial) return error.PaneGone;
var master: c_int = -1;
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
// A terminal opened on a shell of its own (`Tty fish`) runs that one.
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index dcf9a8a7..6065dab2 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -71,7 +71,7 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
if (words.next() != null) return false;
if (pane.cwdSlice().len > pardes.effect_path_cap) return false;
if (!apply) return true;
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } });
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } });
},
}
return true;
diff --git a/src/pardes.zig b/src/pardes.zig
index e92fb3f7..7863e272 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1361,6 +1361,36 @@ test "Exec in a terminal whose tty is taken runs as a command pane instead of ty
try std.testing.expect(spawned);
}
+test "a spawn is for the pane it was made for, not whatever took the slot since" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const Count = struct {
+ var spawns: usize = 0;
+ var command: [32]u8 = undefined;
+ var core: *Pardes = undefined;
+ const vtable: Host.VTable = .{ .spawn = spawn };
+ fn spawn(_: ?*anyopaque, pane: u8, _: []const u8) void {
+ spawns += 1;
+ const line = core.panes[pane].?.command.?;
+ @memcpy(command[0..line.len], line);
+ }
+ };
+ Count.core = p;
+ p.host = .{ .vtable = &Count.vtable };
+ // A command pane closed and its slot taken, in one step, before the
+ // effects run: only the second's spawn is performed.
+ const slot = p.freeSlot().?;
+ _ = try p.newCommand(slot, "", "first");
+ try p.removePane(slot, null);
+ try std.testing.expectEqual(slot, p.freeSlot().?);
+ _ = try p.newCommand(slot, "", "second");
+ while (p.nextEffect()) |effect| p.perform(effect);
+ try std.testing.expectEqual(@as(usize, 1), Count.spawns);
+ try std.testing.expectEqualStrings("second", Count.command[0.."second".len]);
+}
+
test "Tty+fish, one word a tag can hold, opens a terminal on that shell" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
@@ -3136,7 +3166,10 @@ pub const WatchMode = enum { reconcile, baseline_disk };
pub const effect_path_cap = 256;
pub const Effect = union(enum) {
- spawn: struct { pane: u8, cwd: Buf(effect_path_cap) },
+ /// A child for the pane in slot `pane` -- the one whose serial is
+ /// `serial`: a pane closed and its slot taken before the effect is
+ /// performed is not the one it was for (perform).
+ spawn: struct { pane: u8, serial: u32, cwd: Buf(effect_path_cap) },
write: struct { pane: u8, bytes: Buf(64) },
resize_pty: struct { pane: u8, cols: u16, rows: u16 },
signal_pty: struct { pane: u8, sig: PtySignal },
@@ -4243,7 +4276,7 @@ pub const Pardes = struct {
const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
panes.Terminal.armShellSpawn(pane);
p.installPane(id, pane);
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } });
return pane;
}
@@ -4262,7 +4295,7 @@ pub const Pardes = struct {
// directory's commands reuse it; the child's own cd does not move it.
pane.setOwnedCwd(cwd) catch {};
p.installPane(id, pane);
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } });
return pane;
}
@@ -4608,7 +4641,9 @@ pub const Pardes = struct {
pub fn perform(p: *Pardes, e: Effect) void {
const v = p.host.vtable;
switch (e) {
- .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else {
+ // The host reads the pane (its command, its shell) as it forks,
+ // so the slot must still hold the pane the spawn was for.
+ .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else {
p.fallback.spawned[s.pane] = true;
},
// A pane with no child is silent: nothing invents output on its