summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 23:53:58 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit16717a555695ef666e9d2cd1bacc762a2ab15f4b (patch)
treebc1dcfa686610e87ed81b8b4f4a11be9475a5655
parente714bbfa8b7cbf9970053cfbabbb9b1f02a2290e (diff)
downloadpardes-16717a555695ef666e9d2cd1bacc762a2ab15f4b.tar.gz
pardes-16717a555695ef666e9d2cd1bacc762a2ab15f4b.zip
Fixes from three adversarial reviews, and a destructive one among them
The registry sweep could delete a live socket, anywhere on the filesystem. A reviewer reproduced it: a socket that is bound but has not reached listen(2) answers ECONNREFUSED exactly like a dead one -- that window is every server's startup -- and the sweep then followed the entry's symlink and unlinked whatever absolute path it named. It now follows a target only into the directory our own sockets live in and only to a `pardes-9p-*.sock` name, it re-probes immediately before deleting rather than trusting a probe that is by then several syscalls old, and a readlink that exactly filled its buffer is treated as the truncation it is. The test grew a case for an entry whose target is not ours: the entry goes, the file does not. Ctrl-V in raw tty mode was a black hole when the yank register was empty -- neither typed nor forwarded -- so vim's visual block, readline's quoted-insert and every other program's Ctrl-V simply vanished. With nothing to paste the chord belongs to the program again. The lone-ESC flush added earlier was dead code. vaxis already returns Escape for a one-byte 0x1b (`Parser.parseGround` asserts `input.len == 1`), so the carried byte it waited for can never exist; a reviewer showed a 3 ms gap and a 60 ms gap behaving identically. Removed rather than left to imply a guarantee it never provided. A shell whose editor is gone can start one again. Naming a live but unreachable session made `pardes <file>` exit 1, which let a stale environment variable lock someone out of their own editor; it falls through to an ordinary session, as it did before the variable existed. Also: the macOS ABI check for `pardes_topbar_pane_border_px` had been replaced by a duplicate of the line above it; `--startup` now fails on a leak the way every other measurement in that file does, and stops calling its maximum a p95 below twenty samples; the served README and the skill no longer tell you to write to `data` with `>`, which truncates the whole body before the write lands; `docs/v9fs.md` described the allocate-on-walk design that was rejected; and `test/fs.py` keys nesting off `PARDES_PID`, so its forwarding case stops passing only when the runner happens to be inside a live pardes. fs-test now reaches its one documented pre-existing failure instead of dying early. Suite 778/783 with the two known crashes. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
-rw-r--r--.agents/skills/pardes-9p/SKILL.md6
-rw-r--r--docs/fs.md2
-rw-r--r--docs/ui-review.md2
-rw-r--r--docs/v9fs.md16
-rw-r--r--features.txt11
-rw-r--r--src/9p_io.zig59
-rw-r--r--src/fs-help.txt2
-rw-r--r--src/macos.zig1
-rw-r--r--src/main.zig31
-rw-r--r--src/pardes.zig6
-rw-r--r--src/tty/tty.zig31
-rw-r--r--test/fs.py19
-rw-r--r--test/perf.zig9
13 files changed, 112 insertions, 83 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 91a8e864..aa856953 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -101,7 +101,7 @@ For a file or scratch pane, with `pane=$m/pane/<serial>`:
| Append text | `echo text >> $pane/body` | `client.write(pane + '/body', b'text\n')` |
| Replace all text | `echo text > $pane/body` | `client.write(pane + '/body', b'text\n', truncate=True)` |
| Address a byte range | `echo '#0,#2' > $pane/addr` | `client.write(pane + '/addr', b'#0,#2')` |
-| Replace that range | `echo 'pub fn' > $pane/data` | `client.write(pane + '/data', b'pub fn')` |
+| Replace that range | `printf 'pub fn' >> $pane/data` | `client.write(pane + '/data', b'pub fn')` |
| Read the selection | `cat $pane/dot` (offsets), `cat $pane/sel` (text) | the same two reads |
| Select the addressed range | `cp $pane/addr $pane/dot` | `client.write(pane + '/dot', client.read(pane + '/addr'))` |
| Reload from disk | `echo get > $pane/ctl` | `client.write(pane + '/ctl', b'get\n')` |
@@ -220,8 +220,8 @@ with tempfile.TemporaryDirectory(prefix='pardes-9p-skill-') as directory:
PY
```
-`new_pane` walks to `/pane/new` and reads the serial off the directory it
-lands on; `execute` writes one line to a pane's `exec`. Both are in
+`new_pane` opens `/pane/new` and reads the serial it answers; `execute` writes
+one line to a pane's `exec`. Both are in
`test/fs.py`. For terminal tests, use
`session(..., tty=True)` and read
[test/agent_session.py](../../../test/agent_session.py) for bounded interactive
diff --git a/docs/fs.md b/docs/fs.md
index 8607a26f..d1ed2b73 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -197,7 +197,7 @@ for Unix and TCP, a poll loop for QUIC, and the 9P client for mounts);
`zig build fs-test` drives real sessions using the independent Python client
in `test/ninep.py`; `zig build fs-discovery-test` checks that browsing
-creates nothing, that the walk to `/pane/new` and a remove work, and that
+creates nothing, that an open of `/pane/new` and a remove work, and that
`look`, `exec`, `name`, `sel` and `log` behave. `zig build
9p-test` checks the two engine configurations' budgets (the engine's own
tests are cloud9's `zig build test`); `zig build fs-bench` measures
diff --git a/docs/ui-review.md b/docs/ui-review.md
index 2438f2ef..58a42e61 100644
--- a/docs/ui-review.md
+++ b/docs/ui-review.md
@@ -198,7 +198,7 @@ Review artifacts from this pass:
## Regression checks
Both native binaries built in ReleaseFast. The full TTY and SDL unit suites
-passed with the inherited nesting variables `PARDES_FORWARD_LOOK`, `PARDES_9P`
+passed with the inherited nesting variables `PARDES_PID`, `PARDES_9P`
and `PARDES_PANE` removed from the test process. Those variables exposed an
existing environment-ownership issue in native subprocess tests: libc's
environment was changed while the Zig test I/O retained the old slice. This
diff --git a/docs/v9fs.md b/docs/v9fs.md
index 1b14c21f..cfbbd35b 100644
--- a/docs/v9fs.md
+++ b/docs/v9fs.md
@@ -13,16 +13,14 @@ cat "$PARDES_MOUNT/index"
cat "$PARDES_MOUNT/README"
cat "$PARDES_MOUNT/pane/$PARDES_PANE/body"
echo 'Msg hello' > "$PARDES_MOUNT/exec"
-awk '{print $1}' "$PARDES_MOUNT/pane/new/ctl"
+n=$(cat "$PARDES_MOUNT/pane/new")
```
-Walking to `pane/new` opens a pane, and the walk lands on that pane's own
-directory, so its `ctl` answers the serial to use afterwards. The kernel keeps
-the name it walked rather than the one the server answers back, so
-`pane/new` stays in the dentry cache as a name of its own; address the pane as
-`pane/<serial>` once you have it, and expect a fresh path resolution of
-`pane/new` to open another pane. It is not listed in `pane/`, so `ls -l` and
-`find` over the mount create nothing.
+**Opening** `pane/new` makes a pane, and reading that open file answers its
+serial; address the pane as `pane/<serial>` from then on. Each open makes
+another one, so read it once and keep the number. A *stat* makes nothing,
+which is why `new` can be listed at all: `ls`, `ls -l` and `find` over the
+whole mount create nothing, because none of them open it.
The mount belongs to that pane's subprocess tree. Other panes and the editor
core keep their original mount namespace. It works in native Linux TTY and SDL
@@ -112,5 +110,5 @@ Pardes accepts this truncation without storing caller-selected timestamps;
standalone timestamp, permission, and ownership changes remain unsupported.
The initial kernel probe passed on this host on 2026-09-14. The broader
-`fs-test` has an existing syntax-bold assertion failure at `test/fs.py:459`,
+`fs-test` has an existing syntax-bold assertion failure (now `test/fs.py:615`),
also reproduced on the cached editor binary preceding the truncation fix.
diff --git a/features.txt b/features.txt
index 5583e7f3..eeadba39 100644
--- a/features.txt
+++ b/features.txt
@@ -156,3 +156,14 @@ when it is false and nothing is animating. 9P round trip on a local socket: gui
the problem -- instrumentation proved every request woke the loop early (wakes=210, woke_early=212, timed_out=38 over 250 ticks) -- the reply simply could not be
produced until the loop had finished drawing a frame it did not need. Verified the gui still paints (screen shows the file, and a write through 9P redraws) and
the full suite is unchanged at 778/783 with the two known crashes.
+
+Found by adversarial review and NOT fixed, because they are upstream or macOS-only rather than from this session's work:
+- Surface.mark_hover is never assigned true anywhere in the tree, so the whole macOS hover ABI is inert: PARDES_CELL_HOVER is never emitted, encodeCellFlags always
+ contributes 0, and PardesView's liquid-glass affordance never draws. The tests pass only because they set the flag by hand. The comment in the look_hover_preview
+ paint block claims it carries those cells out to the hosts; it does not.
+- -Dworkspace-tag is read only in src/macos.zig, and core.settings.workspace_tag is assigned only there, so the option builds cleanly for gui/tty/web and is
+ silently ignored. gui.zig hard-codes `true` at six taglineBandOffset call sites and still uses raw TOPBAR_H at five more.
+- The detached wire encodes the pointer shape in the frame header variant (full_link/diff_link), so the newer `.target` shape collapses to `.arrow` for an attached
+ frontend. Fixing it means a new header variant, i.e. a protocol change.
+- A shell that outlives its editor keeps PARDES_PID; if that pid is reused, `pardes <file>` now exits 1 instead of falling back to starting an editor. Exiting 1
+ only when the socket itself refused would keep the old behaviour.
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 3d019e0d..7f0134ed 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -671,7 +671,7 @@ pub const Listener = struct {
log.warn("registry post skipped: cannot create {s}", .{svc});
return;
}
- sweepRegistry(l.io, svc);
+ sweepRegistry(l.io, svc, xdg);
var entry_buf: [sun_path_len:0]u8 = undefined;
const entry = std.fmt.bufPrintSentinel(&entry_buf, "{s}/{s}", .{ svc, name }, 0) catch {
log.warn("registry post skipped: name too long: {s}", .{name});
@@ -903,7 +903,21 @@ fn probe(path: [:0]const u8) Probe {
/// a definite refusal counts as gone. The socket a stale entry points
/// at goes too, but not before a stat agrees it is a socket of ours: a
/// plain file answers a connect with the same refusal.
-fn sweepRegistry(io: std.Io, svc: [:0]const u8) void {
+/// Is this the kind of path this editor is allowed to delete? A registry
+/// entry is a symlink we wrote, but its target is just bytes on disk that
+/// anyone could have pointed anywhere, so the sweep only ever follows one
+/// into the directory our own sockets live in, and only to a name of the
+/// shape we give them. Everything else gets its entry removed and its target
+/// left strictly alone.
+fn ourSocket(target: []const u8, sockets: []const u8) bool {
+ if (sockets.len == 0 or !std.mem.startsWith(u8, target, sockets)) return false;
+ if (target.len <= sockets.len or target[sockets.len] != '/') return false;
+ const base = target[sockets.len + 1 ..];
+ if (std.mem.indexOfScalar(u8, base, '/') != null) return false;
+ return std.mem.startsWith(u8, base, "pardes-9p-") and std.mem.endsWith(u8, base, ".sock");
+}
+
+fn sweepRegistry(io: std.Io, svc: [:0]const u8, sockets: []const u8) void {
if (comptime !supported) return;
// The names are staged before anything is unlinked, so the sweep
@@ -941,12 +955,22 @@ fn sweepRegistry(io: std.Io, svc: [:0]const u8) void {
if (libc.unlink(entry) != 0) continue;
reaped += 1;
// A relative target would resolve against this editor's working
- // directory, which says nothing about what the entry named.
+ // directory, which says nothing about what the entry named, and a
+ // readlink that exactly filled the buffer was truncated, so the path
+ // it produced is some other file's.
if (state != .stale or n <= 0 or link_buf[0] != '/') continue;
+ if (@as(usize, @intCast(n)) >= link_buf.len) continue;
var target_buf: [sun_path_len:0]u8 = undefined;
const target = std.fmt.bufPrintSentinel(&target_buf, "{s}", .{link_buf[0..@intCast(n)]}, 0) catch continue;
+ if (!ourSocket(target, sockets)) continue;
const t = statNoFollow(target) orelse continue;
- if (t.mode & 0o170000 == 0o140000 and t.uid == libc.getuid()) _ = libc.unlink(target);
+ if (t.mode & 0o170000 != 0o140000 or t.uid != libc.getuid()) continue;
+ // Ask again, immediately before deleting. The first probe was of the
+ // ENTRY and is by now several syscalls old; a socket that is bound but
+ // has not reached listen(2) yet answers ECONNREFUSED exactly like a
+ // dead one, and that window is every server's startup.
+ if (probe(target) != .stale) continue;
+ _ = libc.unlink(target);
}
if (reaped != 0) log.info("reaped {d} stale registry entries under {s}", .{ reaped, svc });
}
@@ -997,14 +1021,23 @@ test "the registry sweep takes the dead entries and leaves everything else" {
return error.SkipZigTest;
if (libc.mkdir(svc, 0o700) != 0) return error.SkipZigTest;
- var paths: [5][sun_path_len:0]u8 = undefined;
- const live_sock = try std.fmt.bufPrintSentinel(&paths[0], "{s}/live.sock", .{svc}, 0);
- const dead_sock = try std.fmt.bufPrintSentinel(&paths[1], "{s}/dead.sock", .{svc}, 0);
+ // The sockets live where the real ones do -- beside the registry, not in
+ // it, and named the way a session names them -- because the sweep only
+ // follows an entry to a target of exactly that shape and place.
+ var paths: [6][sun_path_len:0]u8 = undefined;
+ const pid: u32 = @intCast(libc.getpid());
+ const live_sock = try std.fmt.bufPrintSentinel(&paths[0], "{s}/pardes-9p-sweeplive-{d}.sock", .{ base, pid }, 0);
+ const dead_sock = try std.fmt.bufPrintSentinel(&paths[1], "{s}/pardes-9p-sweepdead-{d}.sock", .{ base, pid }, 0);
const live = try std.fmt.bufPrintSentinel(&paths[2], "{s}/live", .{svc}, 0);
const dead = try std.fmt.bufPrintSentinel(&paths[3], "{s}/dead", .{svc}, 0);
const stranger = try std.fmt.bufPrintSentinel(&paths[4], "{s}/stranger", .{svc}, 0);
+ // A dead entry pointing at something that is NOT one of our sockets: the
+ // entry goes, the file it named must not.
+ const outsider_sock = try std.fmt.bufPrintSentinel(&paths[5], "{s}/sweep-outsider-{d}.sock", .{ base, pid }, 0);
+ var outsider_buf: [sun_path_len:0]u8 = undefined;
+ const outsider = try std.fmt.bufPrintSentinel(&outsider_buf, "{s}/outsider", .{svc}, 0);
defer {
- for ([_][:0]const u8{ live_sock, dead_sock, live, dead, stranger }) |p| _ = libc.unlink(p);
+ for ([_][:0]const u8{ live_sock, dead_sock, live, dead, stranger, outsider_sock, outsider }) |p| _ = libc.unlink(p);
_ = libc.rmdir(svc);
}
@@ -1021,6 +1054,12 @@ test "the registry sweep takes the dead entries and leaves everything else" {
try testing.expectEqual(@as(c_int, 0), libc.symlink(live_sock, live));
try testing.expectEqual(@as(c_int, 0), libc.symlink(dead_sock, dead));
+ // Dead too, but its target is not one of our sockets by name, so the
+ // entry must go and the file it named must survive untouched.
+ const outside = bindSocket(outsider_sock);
+ try testing.expect(outside >= 0);
+ defer _ = libc.close(outside);
+ try testing.expectEqual(@as(c_int, 0), libc.symlink(outsider_sock, outsider));
// Not a symlink, so not this program's to reason about, even though
// connecting to it is refused exactly like the dead socket.
try std.Io.Dir.cwd().writeFile(testing.io, .{ .sub_path = stranger, .data = "" });
@@ -1058,7 +1097,7 @@ test "the registry sweep takes the dead entries and leaves everything else" {
try testing.expectEqual(Probe.live, probe(live));
try testing.expectEqual(Probe.stale, probe(dead));
- sweepRegistry(testing.io, svc);
+ sweepRegistry(testing.io, svc, base);
// Promptly, and not "eventually": a blocking probe never comes back
// at all, so any wall-clock bound at all is the assertion that
@@ -1071,6 +1110,8 @@ test "the registry sweep takes the dead entries and leaves everything else" {
try testing.expect(statNoFollow(live) != null);
try testing.expect(statNoFollow(live_sock) != null);
try testing.expect(statNoFollow(stranger) != null);
+ try testing.expect(statNoFollow(outsider) == null);
+ try testing.expect(statNoFollow(outsider_sock) != null);
try testing.expectEqual(Probe.live, probe(live));
}
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 3113590a..7f55b3af 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -23,7 +23,7 @@ Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too
cat $m/pane/$n/name; echo notes.txt > $m/pane/$n/name read, then rename
echo Save > $m/pane/$n/exec save it; rmdir $m/pane/$n closes it
echo 'Msg hello' > $m/exec show text in the editor
- echo '#0,#5' > $m/pane/$n/addr; echo NEW > $m/pane/$n/data replace bytes 0..5
+ echo '#0,#5' > $m/pane/$n/addr; printf NEW >> $m/pane/$n/data replace bytes 0..5
cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it
cat $m/pane/$n/dirty; echo 0 > $m/pane/$n/dirty is it modified? say it is not
cat $m/log block until a pane is made, renamed, saved or closed
diff --git a/src/macos.zig b/src/macos.zig
index 83b5eac9..d3b33c55 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -2594,6 +2594,7 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_gui_tagline_font_percent), @TypeOf(pardes_gui_tagline_font_percent));
try expectSameAbi(@TypeOf(c.pardes_tagline_band_offset), @TypeOf(pardes_tagline_band_offset));
try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_rgb), @TypeOf(pardes_topbar_pane_border_rgb));
+ try expectSameAbi(@TypeOf(c.pardes_topbar_pane_border_px), @TypeOf(pardes_topbar_pane_border_px));
try expectSameAbi(@TypeOf(c.pardes_tag_active_bg), @TypeOf(pardes_tag_active_bg));
try expectSameAbi(@TypeOf(c.pardes_tagline_origin_col), @TypeOf(pardes_tagline_origin_col));
try expectSameAbi(@TypeOf(c.pardes_grid_col_at), @TypeOf(pardes_grid_col_at));
diff --git a/src/main.zig b/src/main.zig
index 9abc04bf..050cee60 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -112,16 +112,6 @@ const nested_text =
\\
;
-/// $PARDES_PID named a live editor, so this shell IS inside one, but the Look
-/// never got there. Saying so beats quietly opening the second editor that
-/// $PARDES_PID exists to prevent.
-const unreachable_text =
- \\pardes: this shell is inside pardes, but that session did not take the
- \\file. Check that it is still running, or pass --nested to start a second
- \\editor in here anyway.
- \\
-;
-
// The browser runtime calls a C main (exported below); everything else keeps
// the std.process.Init entry.
pub const main = if (is_emscripten) webMain else nativeMain;
@@ -304,10 +294,13 @@ fn nativeMain(init: std.process.Init) !void {
if (serial == 0) break :reaching null;
break :reaching .{ .dial = dial, .serial = serial };
};
- const parent = found orelse {
- try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text);
- std.process.exit(1);
- };
+ // A pid we cannot reach is a session that is not answering: the shell
+ // says it is inside one, but there is nothing there to take the file.
+ // Starting an ordinary editor is what this did before the pid existed
+ // and is the more useful of the two answers -- refusing to start would
+ // leave a stale environment variable able to lock someone out of their
+ // own editor.
+ const parent = found orelse break :forwarding;
// The pane's `look` file: one line, and the line is the clicked text
// itself, which is what a right click in that pane would have been.
var look_buf: [64]u8 = undefined;
@@ -315,10 +308,7 @@ fn nativeMain(init: std.process.Init) !void {
const word = positional orelse {
var tag_buf: [64]u8 = undefined;
const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial});
- const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch {
- try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text);
- std.process.exit(1);
- };
+ const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch break :forwarding;
arena.free(contents);
try std.Io.File.stderr().writeStreamingAll(init.io, nested_text);
std.process.exit(1);
@@ -332,10 +322,7 @@ fn nativeMain(init: std.process.Init) !void {
const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path;
var command_buf: [8192]u8 = undefined;
const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
- ninep_io.Client.write(arena, parent.dial, look, command) catch {
- try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text);
- std.process.exit(1);
- };
+ ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
return;
}
if (positional) |a| {
diff --git a/src/pardes.zig b/src/pardes.zig
index e9908d65..5c6d2422 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -8368,7 +8368,11 @@ pub const Pardes = struct {
// A host that folds Shift into the letter says the same thing.
if (key.ctrl and !key.alt and (key.cp == 'v' or key.cp == 'V')) {
if (key.shift or key.cp == 'V') return p.clipRequest(p.active, .after);
- return p.typeToTty(p.active, pane, p.yank orelse return);
+ // With something in the register this is a paste. With nothing
+ // in it the chord is the program's -- vim's visual block,
+ // readline's quoted-insert -- and swallowing it would make
+ // Ctrl-V a black hole in every full-screen application.
+ if (p.yank) |text| return p.typeToTty(p.active, pane, text);
}
return panes.Terminal.forwardKey(p, p.active, key);
}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index a1917ba9..23b7c09b 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -71,21 +71,6 @@ fn inputReader(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) void {
loop.postEvent(.quit) catch {};
}
-/// How long a lone ESC waits for the rest of a sequence before it counts as
-/// the Escape key. Long enough for the remainder of a real sequence to arrive
-/// even over a slow link, short enough that nobody sees the delay.
-const escape_hold_ms = 25;
-
-/// Is there more input right behind what we have already read? Only a real
-/// terminal has an fd to ask; the test readers hand their parts over whole, so
-/// for them the answer is always no.
-fn morePending(tty: anytype) bool {
- const Reader = @typeInfo(@TypeOf(tty)).pointer.child;
- if (!@hasField(Reader, "fd") or @FieldType(Reader, "fd") != std.Io.File) return false;
- var fds = [_]std.posix.pollfd{.{ .fd = tty.fd.handle, .events = std.posix.POLL.IN, .revents = 0 }};
- return (std.posix.poll(&fds, escape_hold_ms) catch return false) > 0;
-}
-
fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
try loop.postEvent(.{ .winsize = try tty.getWinsize() });
var parser: vaxis.Parser = .{};
@@ -116,22 +101,6 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
}
carried = end - consumed;
std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]);
- // A lone ESC opens most sequences and is also the Escape key, so the
- // parser holds it for a remainder that a keypress never sends: the
- // press would only land when the NEXT key arrived. Wait a beat, and
- // when nothing follows it was the key. A terminal speaking the kitty
- // protocol never reaches here — it spells Escape out in full.
- if (carried == 1 and buf[0] == 0x1b and !morePending(tty)) {
- carried = 0;
- try vaxis.loop.handleEventGeneric(
- loop,
- loop.vaxis,
- cache,
- @TypeOf(Command.value),
- @as(vaxis.Event, .{ .key_press = .{ .codepoint = vaxis.Key.escape } }),
- loop.vaxis.opts.system_clipboard_allocator,
- );
- }
}
}
diff --git a/test/fs.py b/test/fs.py
index b20dc8ee..c8dea2d9 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -470,7 +470,13 @@ def test(binary, quic=False):
spaced = child_dir / 'space name.txt'
spaced.write_bytes(b'first line\nsecond line\n')
env = os.environ.copy()
- env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_FORWARD_LOOK='1')
+ # PARDES_PID is what says "you are inside a pardes"; the socket
+ # and the pane only say how to reach it. Without the pid the child
+ # starts its own session, so this test used to pass only when the
+ # runner itself happened to be running inside one.
+ status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines())
+ env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_PID=status['pid'])
+ env.pop('PARDES_FORWARD_LOOK', None)
for word, expected, selected, reuse in [('space name.txt:2:4', spaced.read_bytes(), [14, 14], False),
('/n/self/pane/1/body', b'initial\n', None, False),
('/virtual/pane/1/body:1:2-4', b'initial\n', [1, 4], True)]:
@@ -518,7 +524,7 @@ def test(binary, quic=False):
assert time.monotonic() < deadline, 'forwarding fixture Dump did not finish'
time.sleep(.005)
before = client.read('/index')
- inherited = {key: env[key] for key in ['PARDES_9P', 'PARDES_PANE', 'PARDES_FORWARD_LOOK']}
+ inherited = {key: env[key] for key in ['PARDES_9P', 'PARDES_PANE', 'PARDES_PID']}
cases = [
('mount', ['--mount=peer=' + str(address), '/n/peer/pane/1/body'], None),
('name', ['--9p=forwarded-name'], 'forwarded-name'),
@@ -543,7 +549,10 @@ def test(binary, quic=False):
assert local.read('/index') == before
assert local.read('/pane/1/body') == b'initial\n'
else:
- assert len(index) == (3 if name == 'shells' else 1), index
+ # A bare tty launch is a shell plus the empty text pane
+ # the boot puts under it; --shells=3 is the classic
+ # three-shell layout and has no scratch.
+ assert len(index) == (3 if name == 'shells' else 2), index
if name in ['tcp', 'quic']:
assert (name + '!127.0.0.1!').encode() in local.read('/listeners')
assert client.read('/index') == before
@@ -554,7 +563,9 @@ def test(binary, quic=False):
('stale-missing', dict(inherited, PARDES_9P=str(root / 'absent.sock')), 'missing-child-file.txt'),
('stale-noarg', dict(inherited, PARDES_9P=str(root / 'absent.sock')), None),
('stale-pane', dict(inherited, PARDES_PANE='4294967295'), str(spaced)),
- ('disabled', dict(inherited, PARDES_FORWARD_LOOK='0'), str(spaced)),
+ # No pid means "not inside a pardes at all", which is what
+ # --nested withholds and what a plain shell has.
+ ('not-nested', {k: v for k, v in inherited.items() if k != 'PARDES_PID'}, str(spaced)),
]:
with session(binary, root, name, tty=True, inherited=identity,
launch=['--tty', *([word] if word else [])]) as (local, local_address):
diff --git a/test/perf.zig b/test/perf.zig
index 8f463204..5ac38ccf 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -200,6 +200,10 @@ fn measureStartup(io: std.Io, reps: usize, json: bool) !void {
bytes += counter.bytes;
peak = @max(peak, counter.peak);
}
+ // Every other measurement in this file checks the session gave
+ // everything back; a boot that leaks is exactly what a startup
+ // benchmark should be the first to notice.
+ if (counter.live != 0) return error.LeakedStartupMemory;
}
std.mem.sort(u64, init_samples, {}, std.sort.asc(u64));
std.mem.sort(u64, frame_samples, {}, std.sort.asc(u64));
@@ -212,7 +216,10 @@ fn measureStartup(io: std.Io, reps: usize, json: bool) !void {
.reps = reps,
.cold_ns = cold_ns,
.init_median_ns = init_samples[reps / 2],
- .init_p95_ns = init_samples[@min(reps - 1, reps * 95 / 100)],
+ // A p95 needs at least twenty samples to be one; below that this
+ // is the maximum and says so rather than dressing it up.
+ .init_p95_ns = if (reps >= 20) init_samples[reps * 95 / 100] else init_samples[reps - 1],
+ .init_max_ns = init_samples[reps - 1],
.frame_median_ns = frame_samples[reps / 2],
.allocations = calls / reps,
.allocated_bytes = bytes / reps,