From 4f65855f372186406f4e48708ec3d5eb9aceb6d3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 22:51:17 -0300 Subject: log stats 0, and pardes re-pins cloud9 whose 9ns opens such a file direct and nonseekable: fs.md's follow recipe with cat works through a mount The log stated the length an open would freeze, a length a mount's kernel could take as its end, and a splicing cat read it from the kernel's offset. log now stats 0, as the other streams and generated views do, and the re-pinned cloud9's 9ns opens any file stating 0 FOPEN_DIRECT_IO | FOPEN_NONSEEKABLE. A look at such a file (the mounted index) reads a regular file that will not seek as a stream rather than refusing it as a pipe. selfmount runs the recipe, exec 3<>log; echo follow >&3; cat <&3, through the mount and hears a record made after it started. Co-Authored-By: Claude Opus 5.5 --- build.zig.zon | 4 ++-- src/fs.zig | 8 +++++++- src/ninep/events.zig | 12 ------------ src/ninep/tree.zig | 12 +++++++++++- test/selfmount.py | 10 ++++++++++ 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 4df012bc..106ef085 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,8 +9,8 @@ // read-only HTTPS URL is what a manifest can carry. Re-pin with // `zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#`. .cloud9 = .{ - .url = "git+https://git.sr.ht/~gbrls/cloud9#705be665742fa0332585a4321640b5e2245610cb", - .hash = "cloud9-0.1.0-yt86qt8NHgDuS2WNp0TgW03h-eVIgWmkznn0t0nMQKbr", + .url = "git+https://git.sr.ht/~gbrls/cloud9#a03d1649108510cda5951e918e561d7cae43d6b0", + .hash = "cloud9-0.1.0-yt86qiISHgBgvO5D4UOz0pZnWrLmL337v38Z8ROAeLG0", }, // Pinned to the exact libvaxis commit ghostty is built against, so it // is compatible with ghostty's uucode configuration (the one we share diff --git a/src/fs.zig b/src/fs.zig index d13e36bd..91edd429 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -1360,7 +1360,13 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { defer _ = libc.close(fd); const end = libc.lseek(fd, 0, libc.SEEK.END); - if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; + // Not seekable: a pipe or a FIFO is no file; a regular file that is a + // stream (a 9ns mount's log or screen, opened nonseekable) is read to + // its end, as one with no length is. + if (end < 0 and libc.errno(end) == .SPIPE) { + const facts = ninep_io.statNoFollow(path_z) orelse return error.NotAFile; + if (facts.mode & 0o170000 != 0o100000) return error.NotAFile; + } const size: usize = if (end < 0) 0 else @intCast(end); if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; if (size == 0) { diff --git a/src/ninep/events.zig b/src/ninep/events.zig index bacf3ad6..9752e287 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -494,18 +494,6 @@ pub fn writeLog(p: *Pardes, req: Req) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -/// The length a stat reports: the text an open would freeze now. -pub fn logLen(q: *const Queue) u64 { - var n: u64 = 0; - var at = q.head; - while (at + 4 <= q.buf.items.len) { - const len = std.mem.readInt(u32, q.buf.items[at..][0..4], .little); - n += len; - at += 4 + len; - } - return n; -} - // ---- per-pane event records ---- pub const max_record_text = 256; diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index be03e8f0..8c536c51 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -646,7 +646,9 @@ fn topSize(p: *Pardes, f: TopFile) u64 { .README => fs.help.len, .status => ctl.statusLen(p), .look, .exec, .tagexec => ctl.resultsLen(p), - .log => events.logLen(&p.fs.log), + // A stream: a length would read as its end to a kernel that caches + // one (a 9ns mount's splice), and a follower's reads go past it. + .log => 0, .ctl => ctl.rootLen(p), .commands => ctl.commandsLen(), .focus => focus: { @@ -1335,6 +1337,14 @@ test "filesystem inspection preserves pending and displayed Look hover" { try testing.expect(!p.raw_hover_intent); } +test "log stats 0 however much it holds, a stream as acme's generated files are" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + _ = th.wr(p, Node.of(th.serialOf(p), .exec), "Msg a record for the log\n"); + try testing.expect(th.logHas(p, "a record for the log")); + try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.log) }).reply.attr.size); +} + test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ // Opening `new` is what makes a pane now; walking to it makes none, diff --git a/test/selfmount.py b/test/selfmount.py index 86c32fa8..dd59083c 100644 --- a/test/selfmount.py +++ b/test/selfmount.py @@ -176,6 +176,16 @@ def run(binary): held = run(f"for i in $(seq 40); do cat {mount}/pane/{first}/event > /dev/null & done; " f"sleep 1; timeout 10 cat {mount}/layout > /dev/null; st=$?; kill $(jobs -p); exit $st\n".encode()) check(held == '0', f'cat layout answers beside 40 held event reads ({held!r})') + # fs.md's follow recipe, as written, with plain cat, its output + # a pipe (where coreutils cat splices): it hears a record made + # after it started. + recipe = Path(f'/tmp/{name}.follow') + ran = run(f"exec 3<>{mount}/log; echo follow >&3; " + f"(sleep 1; echo 'Msg recipe-heard' > {mount}/exec) & " + f"timeout 5 cat <&3 | cat > {recipe}; exit 0\n".encode()) + followed = recipe.read_text() if recipe.exists() else '' + recipe.unlink(missing_ok=True) + check(ran == '0' and 'recipe-heard' in followed, f'the log follow recipe with cat hears a new record ({followed[-120:]!r})') client.write('/exec', b'Msg still here\n') text = ''.join(cell[0] for cell in client.screen()['cells']) check('still here' in text, 'the editor answers after all of that') -- cgit v1.3