From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: Refactor panes and filesystem; replace FUSE with 9P Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill. --- src/fs9_client.zig | 695 ----------------------------------------------------- 1 file changed, 695 deletions(-) delete mode 100644 src/fs9_client.zig (limited to 'src/fs9_client.zig') diff --git a/src/fs9_client.zig b/src/fs9_client.zig deleted file mode 100644 index ad46dee4..00000000 --- a/src/fs9_client.zig +++ /dev/null @@ -1,695 +0,0 @@ -//! `9p `: one pardes reading a file out of another pardes's tree. -//! -//! `src/fs9_service.zig`'s MIRROR, and the other half of `9P-2`: that file is a -//! listener with connections and hands each one a `ninep.Server`, this one -//! dials a single socket and drives a `ninep.Client` over it. They share the -//! socket NAMING and nothing else — `socketPath` is imported verbatim, so a -//! bare `9p work /1/body` resolves to exactly the path a `pardes --fs9 work` -//! bound, which is the whole point of having one spelling of it. -//! -//! WHAT IS HERE, and it is the same four things any non-blocking byte stream -//! needs: connect, read into `push`, `output` out through `send` and back -//! through `wrote`, and close. Everything above that is `src/9p.zig`, which is -//! freestanding and knows about neither sockets nor panes. -//! -//! IT BLOCKS, BRIEFLY AND BOUNDED, and that is a decision rather than an -//! oversight. `src/look.zig`'s `readFile` already blocks the frame on a disk -//! read — opening a file pane is a person waiting for a file — and a remote -//! read over a unix socket on the same machine is the same wait with a context -//! switch in it. What makes it safe to say that is the BUDGET: `budget_ms` is -//! the deadline for the whole transaction, `poll` is what waits, and the -//! descriptor is non-blocking, so a peer that stops answering costs one -//! `budget_ms` pause and a message on the message row rather than a wedged -//! editor. The alternative — a request queued into the frame loop, a state -//! machine per outstanding fetch, a pane that fills in later — is an async -//! runtime, and `docs/9p.typ` §12.5 is explicit that this design does not get -//! one. -//! -//! WHY THE HOST AND NOT THE CORE. A socket is `std.c`, and `src/9p.zig` must -//! keep compiling for `wasm32-freestanding` and the board's -//! `riscv32-freestanding`; the same `ninep.Client` runs over -//! `src/esp32p4/uart.zig` with no line of this file involved. So the split is -//! the one the server half already made: protocol in the freestanding file, -//! descriptor here. -//! -//! Linux and darwin, like every other unix socket in the tree. Anywhere else -//! `supported` is false and the word is not registered at all. -const std = @import("std"); -const libc = std.c; -const nested = @import("nested.zig"); -const ninep = @import("9p.zig"); -const fs9_service = @import("fs9_service.zig"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const output_pane = @import("output_pane.zig"); - -/// Unix sockets, which is all this needs — `fs9_service`'s own predicate, so a -/// build that can serve 9P can dial it and one that cannot has neither. -pub const supported = fs9_service.supported; - -/// `sun_path`, from the kernel's struct. See `nested.sun_path_len`. -const sun_path_len = nested.sun_path_len; - -/// The deadline for the WHOLE transaction: connect, handshake, attach, walk, -/// open, every read, clunk. -/// -/// TWO SECONDS, and the number is about the human rather than about the wire. -/// On a local socket the whole exchange is six round trips and some memcpys — -/// microseconds — so any wait long enough to notice means the far end is not -/// answering, and the useful thing to do about that is say so. Two seconds is -/// long enough that a busy editor on the other side finishing its frame is -/// never mistaken for a dead one, and short enough that a mistyped socket name -/// on a path that happens to exist does not feel like a hang. -pub const budget_ms: i64 = 2000; - -/// The most bytes one `9p` will carry into a pane. -/// -/// A MEGABYTE, which is a quarter of `look.zig`'s cap for a virtual file -/// (`read_stream_max_bytes`) and for a sharper reason: what is on the other end -/// is a synthetic tree of live editor state, where the biggest file is one -/// pane's `body`. A megabyte of it is a large source file; ten megabytes is -/// somebody pointing this word at a `/dev/zero` equivalent, and a read loop -/// with no cap would spend the whole budget filling the heap. -pub const max_bytes: u64 = 1 << 20; - -/// The deepest path this word will walk. -/// -/// `MAXWELEM` is sixteen elements per `Twalk` and a deeper path is legal — the -/// client splits it into chunks and `transact` does — so this is not a protocol -/// bound. It is a bound on the ARGUMENT: acme's tree is two deep (`/1/body`), -/// two chunks is thirty-two, and a path with more elements than that is a typo -/// or a loop rather than a file. Refused rather than truncated, because a -/// truncated path names a different file. -pub const max_depth: usize = 2 * ninep.max_welem; - -/// What the far end reports in `Rstat`'s three name fields (`uid`, `gid`, -/// `muid`) for everything we touch, because `ninep.Server` records the -/// attach's `uname` and quotes it back. -/// -/// A CONSTANT AND NOT `$USER`: the socket's permissions are the identity here -/// (0600, in a 0700 per-user directory — docs/9p.typ §10), so this string is -/// not a credential and cannot become one. What it is for is the operator -/// reading `ls -l` on the far side, and "pardes" tells them which program -/// walked their tree, which a login name they already share with it does not. -const uname = "pardes"; - -/// Everything this word can refuse, and each one is a different thing to do -/// about it. -pub const Error = error{ - MissingDial, - MissingPath, - /// More than `max_depth` elements. - PathTooDeep, - /// The dial names no address we can form: an empty name, a name with a - /// separator or a NUL in it, a path past `sun_path`, or no runtime - /// directory to resolve a bare name against. - BadDial, - /// `socket(2)` or `connect(2)` said no: nothing is listening on that - /// socket, or its permissions are not ours. The overwhelmingly common - /// case, and it means "that pardes is not running with `--fs9`". - Dial, - /// The peer closed mid-transaction. - Hangup, - /// `budget_ms` elapsed. See there. - Timeout, - /// The stream stopped being 9P: `ninep.Client` went dead, or a reply - /// arrived whose shape does not answer the request it was tagged for. - /// Nothing can be resynchronised from here. - Botch, - /// The far end answered `Rerror`. Its own string goes on the message row — - /// see `fetch` — so this value only says "reported already". - Remote, - /// The path names a directory. A directory READ is a run of `stat` - /// records rather than text, so opening it in a pane would show a person - /// the wire format; `9p` names files. - IsDirectory, - /// The walk stopped short: some element of the path is not there. Distinct - /// from `Remote` because a partial walk is a SUCCESSFUL `Rwalk` with fewer - /// qids and carries no message to report. - NotFound, - /// Past `max_bytes`. - FileTooLarge, - /// The pane that asked went away while this was in flight. - MissingPane, -}; - -/// The far end's own words, copied out of the client's input buffer before the -/// connection is torn down and the buffer with it. `ninep.errmax` is the buffer -/// a Plan 9 client has for an error string, so it is the right size for one. -const RemoteError = struct { - buf: [ninep.errmax]u8 = undefined, - len: usize = 0, - - /// Returns the error so that every call site is `return remote.set(e)`. - fn set(r: *RemoteError, msg: []const u8) error{Remote} { - r.len = @min(msg.len, r.buf.len); - @memcpy(r.buf[0..r.len], msg[0..r.len]); - return error.Remote; - } - - fn text(r: *const RemoteError) []const u8 { - return r.buf[0..r.len]; - } -}; - -/// `9p ` — walk to a remote file, read it, and open the bytes in a -/// pane. -/// -/// The pane is an ORDINARY OUTPUT BUFFER, which is what `src/board_memory.zig` -/// puts a hexdump in and what `Grep` puts its rows in: a file pane with an -/// `output` origin, so every motion, chord, search and Look works on it for -/// free. Deliberately NOT a real file pane, even though the bytes came from -/// `look.readFile`'s own kind of read: `file_pane.open` arms a file WATCH on -/// the path it was given, and there is no local path here for `inotify` to -/// watch — the bytes live in another process's memory. An output buffer is the -/// existing answer to "text with no file behind it". -/// -/// Identified by the WHOLE argument, so `9p work /1/body` and `9p work /2/body` -/// are two panes and running either again refills its own. -pub fn fetch(p: *Pardes, id: usize, argument: []const u8) !void { - const a = std.mem.trim(u8, argument, " \t\r\n"); - const args = try parse(a); - var names: [max_depth][]const u8 = undefined; - const n = try elements(args.path, &names); - - var sock_buf: [sun_path_len]u8 = undefined; - const sock = resolve(&sock_buf, args.dial) orelse return Error.BadDial; - - var remote: RemoteError = .{}; - const content = fetchBytes(p.gpa, sock, names[0..n], &remote) catch |err| { - if (err != Error.Remote) return err; - // The far end's own wording, which is the whole error ABI in base - // 9P2000 (docs/registry.typ `9P-4`) — reported verbatim rather than - // mapped to one of ours, because it is the only thing that says which - // of the eight operations the other side objected to and why. - var buf: [ninep.errmax + 8]u8 = undefined; - p.setMessage(id, std.fmt.bufPrint(&buf, "9p: {s}", .{remote.text()}) catch "9p: refused"); - return; - }; - const pane = p.panes[id] orelse { - p.gpa.free(content); - return Error.MissingPane; - }; - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - try output_pane.fillResults(p, id, dir, .{ .cmd = .@"9p" }, a, content, null); -} - -const Args = struct { dial: []const u8, path: []const u8 }; - -/// ` `, split at the FIRST run of whitespace and not tokenized. -/// -/// The path keeps its spaces, because a pane's name in acme's tree can have -/// them and a path is the last argument: `9p work /1/tag` and -/// `9p work /a name/body` both have exactly one reading. The dial cannot have -/// them, and does not need to — it is a socket name or a socket path. -fn parse(a: []const u8) Error!Args { - if (a.len == 0) return Error.MissingDial; - const cut = std.mem.indexOfAny(u8, a, " \t") orelse return Error.MissingPath; - const path = std.mem.trim(u8, a[cut..], " \t\r\n"); - if (path.len == 0) return Error.MissingPath; - return .{ .dial = a[0..cut], .path = path }; -} - -/// A path into `Twalk` elements. Separators are collapsed and a trailing one is -/// dropped, so `/1/body`, `1/body` and `//1/body/` are one file — the -/// normalisation every shell already does, done here because 9P has no -/// pathnames at all and a client that forwarded an empty element would be -/// asking for a file called "". -/// -/// ZERO ELEMENTS is the root, which is legal and is a directory; `transact` -/// refuses it there, where every other directory is refused too. -fn elements(path: []const u8, out: *[max_depth][]const u8) Error!usize { - var n: usize = 0; - var it = std.mem.tokenizeScalar(u8, path, '/'); - while (it.next()) |name| { - if (n == out.len) return Error.PathTooDeep; - out[n] = name; - n += 1; - } - return n; -} - -/// The dial, as an address. -/// -/// TWO SPELLINGS, told apart by a separator, and the distinction is the one a -/// person already makes: a NAME is what `pardes --fs9 work` was started with, -/// and it resolves through `fs9_service.socketPath` — the same function that -/// bound it, so the two can never drift. A PATH is taken as given, which is -/// what you need for a socket somewhere else entirely: a bind-mounted -/// container, a different user's runtime directory, an `ssh -L` forward. -fn resolve(buf: *[sun_path_len]u8, dial: []const u8) ?[:0]const u8 { - if (dial.len == 0) return null; - if (std.mem.indexOfScalar(u8, dial, '/') != null) { - if (std.mem.indexOfScalar(u8, dial, 0) != null) return null; - return std.fmt.bufPrintSentinel(buf, "{s}", .{dial}, 0) catch null; - } - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return null; - return fs9_service.socketPath(buf, dir, dial); -} - -/// One dialled connection: the descriptor, the deadline, the client and its -/// three buffers. -/// -/// HEAP-ALLOCATED by `fetchBytes`, for `fs9_service.Listener`'s reason and one -/// more: `cl.in` and `cl.out` are slices INTO this struct, so it must never be -/// moved once `cl` is initialised, and at three msizes it is 24 KiB, which does -/// not belong on the frame's stack. -/// -/// The msize is `fs9_service.msize`, the one number the serving side is already -/// sized from. A client on the same machine reading the same tree has no reason -/// to pick a different one, and picking the same one means the handshake never -/// clamps. -const Session = struct { - fd: c_int, - /// `nowMs()` past which every wait gives up. - deadline: i64, - cl: ninep.Client = undefined, - in: [fs9_service.msize]u8 = undefined, - out: [fs9_service.msize]u8 = undefined, - /// A frame-local staging buffer rather than a read straight into the - /// client's tail: advancing `in_len` is `push`'s business, and reaching - /// past it to do it here would make this file a second author of - /// `9p.zig`'s invariants for the sake of one memcpy per 8 KiB. Exactly - /// `fs9_service.fill`'s reasoning, from the other side. - stage: [fs9_service.msize]u8 = undefined, - - /// Wait for `events` on the descriptor, or give up. THE ONLY PLACE THIS - /// FILE BLOCKS, and the only place the budget is spent. - fn wait(s: *Session, events: i16) Error!void { - while (true) { - const left = s.deadline - nowMs(); - if (left <= 0) return Error.Timeout; - var fds = [1]libc.pollfd{.{ .fd = s.fd, .events = events, .revents = 0 }}; - const ready = libc.poll(&fds, 1, @intCast(@min(left, budget_ms))); - if (ready < 0) { - if (libc.errno(ready) == .INTR) continue; - return Error.Hangup; - } - if (ready == 0) return Error.Timeout; - // What we asked for wins over HUP: a peer that wrote a reply and - // then closed reports both at once, and those bytes are ours. - if (fds[0].revents & events != 0) return; - return Error.Hangup; - } - } - - /// Push everything the client owes the wire, and nothing else. Split out of - /// `settle` so that `dropNoWait` can send a message it will never collect a - /// reply for. Bounded by the same deadline `wait` enforces. - fn flush(s: *Session) Error!void { - while (s.cl.output().len != 0) { - try s.wait(poll_out); - const bytes = s.cl.output(); - const sent = libc.send(s.fd, bytes.ptr, bytes.len, nosignal); - if (sent < 0) switch (libc.errno(sent)) { - .INTR, .AGAIN => continue, - else => return Error.Hangup, - }; - // No progress and no error: looping on it is a spin, and a - // spin in here is the editor at 100% of a core. - if (sent == 0) return Error.Hangup; - s.cl.wrote(@intCast(sent)); - } - } - - /// Drive the client until the one outstanding request answers: flush what - /// we owe, collect if a reply is already buffered, otherwise wait and read. - /// - /// LOCK-STEP, deliberately, and it is worth saying why given that - /// `ninep.Client` allows sixteen requests in flight. A `9p` word is one - /// person waiting for one file, and its round trips are strictly ordered - /// anyway — you cannot read a fid you have not opened, or open one you have - /// not walked to. The one place pipelining would pay is the read loop, and - /// on a local socket at an 8 KiB msize a megabyte is 128 round trips of a - /// few microseconds each; buying that back would cost this file a request - /// window, an out-of-order reassembly buffer and a reason for both. The - /// CLIENT is where the sixteen tags live, so the board's runtime and any - /// future caller get them without this file having spent them. - fn settle(s: *Session) Error!ninep.Client.Done { - while (true) { - try s.flush(); - if (s.cl.take()) |done| return done; - if (s.cl.dead) return Error.Botch; - try s.wait(poll_in); - const room = s.cl.in.len - s.cl.in_len; - // Cannot happen: one reply is at most one msize and the buffer is - // exactly that, so a full buffer with nothing to take would mean - // the far end sent a frame it told us it would not. - if (room == 0) return Error.Botch; - const got = libc.read(s.fd, &s.stage, @min(room, s.stage.len)); - if (got == 0) return Error.Hangup; - if (got < 0) switch (libc.errno(got)) { - .INTR, .AGAIN => continue, - else => return Error.Hangup, - }; - const n = s.cl.push(s.stage[0..@intCast(got)]); - // The read was clamped to the room, so this cannot be short; it is - // asserted rather than ignored because silently dropping wire - // bytes desynchronises the stream, which is the one failure 9P - // cannot resynchronise from. - std.debug.assert(n == @as(usize, @intCast(got))); - } - } - - /// One request, one reply, and the two answers that are not the one asked - /// for folded into errors here so that `transact` reads as a script. - fn ask(s: *Session, req: ninep.Client.Request, remote: *RemoteError) Error!ninep.Client.Result { - _ = s.cl.submit(req) catch return Error.Botch; - const done = try s.settle(); - if (done.result == .fail) return remote.set(done.result.fail); - // The client already refuses a reply whose shape does not match the - // request's op (it kills the connection), so this can only be an - // `Rerror` we have just handled. Checked anyway: a `switch` here would - // be a second copy of that table. - if (std.mem.eql(u8, @tagName(done.result), @tagName(std.meta.activeTag(req)))) return done.result; - return Error.Botch; - } - - /// Clunk a fid and WAIT for the answer, because the caller is about to - /// reuse the number. `transact`'s walk alternates between fids 1 and 2, and - /// in-order processing is the only thing that makes that safe. - /// - /// Best effort otherwise: a refused clunk still frees the fid on both sides - /// (`clunk(5)`), so there is nothing here worth failing a fetch over. - fn drop(s: *Session, fid: u32) void { - _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; - _ = s.settle() catch {}; - } - - /// Clunk a fid and do NOT wait. For the last one, where the descriptor is - /// closed on the next line and closing it frees every fid the connection - /// held — so the `Rclunk` is not merely unwanted, it is unobservable. - /// - /// Waiting for it cost the whole budget against a peer that answers - /// everything else and ignores clunks: measured at 2.005 s to deliver a - /// file that was already in hand, and 2.003 s to report an error decided - /// 1.4 ms in. The bytes still go out — a well-behaved peer gets its clunk - /// and frees the fid immediately rather than at hangup — but nothing here - /// reads the reply. - fn dropNoWait(s: *Session, fid: u32) void { - _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; - s.flush() catch {}; - } -}; - -/// The whole transaction, and the only function here that knows 9P's order of -/// operations: version, attach, walk, open, read to the end, clunk. -fn transact( - s: *Session, - names: []const []const u8, - out: *std.Io.Writer.Allocating, - remote: *RemoteError, -) !void { - // The handshake. A server that answers "unknown" has no dialect in common - // with us and leaves `msize` at zero, which is a connection nothing can be - // submitted on — reported as a botch, because there is no fallback ladder - // here to climb down. - _ = try s.ask(.{ .version = .{} }, remote); - if (s.cl.msize == 0) return Error.Botch; - - // Fid 0 is the root for the life of the connection; 1 and 2 alternate as - // the walk descends, so a chunked walk never needs a third. - const root: u32 = 0; - var here = (try s.ask(.{ .attach = .{ .fid = root, .uname = uname } }, remote)).attach; - var cur: u32 = root; - var next: u32 = 1; - - var i: usize = 0; - while (i < names.len) { - // `MAXWELEM` elements at a time, which is what makes a path deeper than - // sixteen work at all: every implementation refuses a seventeenth - // element, so a deep path is several walks with an intermediate fid. - const n = @min(ninep.max_welem, names.len - i); - const w = (try s.ask(.{ .walk = .{ - .fid = cur, - .newfid = next, - .names = names[i..][0..n], - } }, remote)).walk; - // A PARTIAL WALK IS A SUCCESS with fewer qids, and only a failure on - // the first element is an `Rerror`. So this comparison is the whole of - // "did the path exist", and skipping it is how a client ends up - // reading the wrong file. - if (w.nwqid != n) return Error.NotFound; - here = w.wqid[n - 1]; - if (cur != root) s.drop(cur); - cur = next; - next = if (next == 1) 2 else 1; - i += n; - } - defer s.dropNoWait(cur); - - // The qid the walk landed on already says what this is, so the refusal - // costs no round trip — and it catches the bare `/` too, which is zero - // elements and the root. - if (here.type & ninep.qtdir != 0) return Error.IsDirectory; - - _ = try s.ask(.{ .open = .{ .fid = cur, .mode = ninep.oread } }, remote); - - // Read to the end. 9P has no EOF flag: a reply SHORTER than the count is - // ordinary and means nothing, and a reply of ZERO bytes is the end of the - // file (`read(5)`). The offset advances by what came back and never by what - // was asked, which is the same rule a POSIX read loop follows. - var off: u64 = 0; - while (true) { - if (off >= max_bytes) return Error.FileTooLarge; - const want: u32 = @intCast(@min(@as(u64, s.cl.maxRead()), max_bytes - off)); - const data = (try s.ask(.{ .read = .{ .fid = cur, .offset = off, .count = want } }, remote)).read; - if (data.len == 0) return; - try out.writer.writeAll(data); - off += data.len; - } -} - -/// Dial, transact, and hand back the bytes — gpa-owned, the way -/// `look.readFile`'s are, so the pane adopts them with no second copy. -fn fetchBytes( - gpa: std.mem.Allocator, - sock: [:0]const u8, - names: []const []const u8, - remote: *RemoteError, -) ![]u8 { - if (comptime !supported) return Error.Dial; - // The deadline starts BEFORE the dial, because the dial is part of the - // transaction and used not to be bounded by anything at all. See `connect`. - const deadline = nowMs() +| budget_ms; - const fd = try connect(sock, deadline); - const s = gpa.create(Session) catch return error.OutOfMemory; - defer { - _ = libc.close(fd); - gpa.destroy(s); - } - s.* = .{ .fd = fd, .deadline = deadline }; - s.cl = .init(.{ .in = &s.in, .out = &s.out }); - - var out: std.Io.Writer.Allocating = .init(gpa); - errdefer out.deinit(); - try transact(s, names, &out, remote); - return out.toOwnedSlice(); -} - -/// Connect to a unix socket, non-blocking from the first moment there is -/// anything to wait for — which is the connect itself. -/// -/// This used to leave the connect BLOCKING, on the argument that a unix socket -/// either completes at once or refuses at once. That is true only while the -/// listener's accept queue has room. When it is full, Linux's -/// `unix_stream_connect` waits in `unix_wait_for_peer` for `sk_sndtimeo`, which -/// defaults to MAX_SCHEDULE_TIMEOUT — forever. The core is single-threaded, so -/// that is the whole editor: no frame, no keystroke, no filesystem request -/// served. Measured at 177 seconds against a peer that had called `listen` and -/// never `accept`, and it ended only because the peer was killed. Nothing in -/// pardes would have ended it, and the trigger needs no hostility — a peer that -/// is itself wedged does it, and a bare path names sockets pardes does not own. -/// -/// So the descriptor is non-blocking before the connect and the wait is spent -/// against the caller's deadline. Both refusals have to be handled and they are -/// different: on AF_UNIX a full backlog is EAGAIN, NOT the EINPROGRESS a TCP -/// connect would give, so EAGAIN retries until the deadline and EINPROGRESS -/// waits for POLLOUT and then asks SO_ERROR what actually happened. -fn connect(sock: [:0]const u8, deadline: i64) Error!c_int { - if (sock.len + 1 > sun_path_len) return Error.BadDial; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return Error.Dial; - nested.setCloexec(fd); - setNonblock(fd); - errdefer _ = libc.close(fd); - while (true) { - if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0) break; - switch (libc._errno().*) { - // The backlog is full. Nobody is obliged to drain it, so this is a - // poll on the clock rather than on the descriptor: there is no - // event to wait for, only room that may or may not appear. - @intFromEnum(libc.E.AGAIN), @intFromEnum(libc.E.INTR) => { - if (nowMs() >= deadline) return Error.Dial; - nap(2); - }, - // Someone is listening and the connect is under way. This one IS a - // descriptor event, so wait for it and then ask what it was. - @intFromEnum(libc.E.INPROGRESS), @intFromEnum(libc.E.ALREADY) => { - const left = deadline - nowMs(); - if (left <= 0) return Error.Dial; - var pfd: [1]libc.pollfd = .{.{ .fd = fd, .events = poll_out, .revents = 0 }}; - if (libc.poll(&pfd, 1, @intCast(@min(left, 1000))) <= 0) continue; - var err: c_int = 0; - var len: libc.socklen_t = @sizeOf(c_int); - if (libc.getsockopt(fd, libc.SOL.SOCKET, libc.SO.ERROR, @ptrCast(&err), &len) != 0) - return Error.Dial; - if (err == 0) break; - return Error.Dial; - }, - // Already connected by a previous round of this loop. - @intFromEnum(libc.E.ISCONN) => break, - else => return Error.Dial, - } - } - if (comptime nested.darwin) { - // linux says MSG_NOSIGNAL per write, darwin once per socket. A peer - // that dies mid-transaction must not take the editor down with it. - const on: c_int = 1; - _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); - } - return fd; -} - -/// The descriptor is non-blocking and `poll` does the waiting, because that is -/// the only shape in which the budget above is enforceable: a blocking `read` -/// has no deadline to give it. `fs9_service`'s own `setNonblock` is not reused -/// for its stated reason — importing a daemon into a path the tty and GUI -/// shells take would make a frontend transport a dependency of a builtin. -fn setNonblock(fd: c_int) void { - const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); -} - -/// Milliseconds on the MONOTONIC clock, which is the only clock a deadline may -/// be measured against: the wall clock can be stepped, and an NTP correction -/// landing mid-fetch would turn a two-second budget into a hang or into an -/// instant timeout depending on which way it went. -/// -/// A clock that will not answer is reported as THE END OF TIME, so the budget -/// expires on the first wait rather than never — the saturating `+|` at the one -/// call site that adds to it is what makes that safe. -fn nowMs() i64 { - var ts: libc.timespec = undefined; - if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return std.math.maxInt(i64); - return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); -} - -const poll_in: i16 = @intCast(libc.POLL.IN); -const poll_out: i16 = @intCast(libc.POLL.OUT); - -/// Sleep a couple of milliseconds while a full accept backlog drains. There is -/// no descriptor to wait on for that — the room either appears or the deadline -/// arrives — so this is the one place here that waits on the clock. `poll` with -/// no descriptors is the portable spelling and needs no `nanosleep` import. -fn nap(ms: c_int) void { - _ = libc.poll(&[0]libc.pollfd{}, 0, ms); -} - -/// A dead peer must never kill the editor. linux says it per write, darwin once -/// per socket (see `connect`). -const nosignal: u32 = if (nested.darwin) 0 else libc.MSG.NOSIGNAL; - -const testing = std.testing; - -test "the argument is a dial and then the rest of the line" { - const a = try parse("work /1/body"); - try testing.expectEqualStrings("work", a.dial); - try testing.expectEqualStrings("/1/body", a.path); - - // A pane's name in acme's tree may contain spaces, and the path is the last - // argument, so it keeps them. - const spaced = try parse("work /a name/body"); - try testing.expectEqualStrings("work", spaced.dial); - try testing.expectEqualStrings("/a name/body", spaced.path); - - // A socket path as the dial, which is the other spelling. - const p = try parse("/run/user/1000/pardes-9p-work.sock /index"); - try testing.expectEqualStrings("/run/user/1000/pardes-9p-work.sock", p.dial); - try testing.expectEqualStrings("/index", p.path); - - try testing.expectError(Error.MissingDial, parse("")); - try testing.expectError(Error.MissingPath, parse("work")); - try testing.expectError(Error.MissingPath, parse("work ")); -} - -test "a path becomes walk elements, normalised the way a shell would" { - var out: [max_depth][]const u8 = undefined; - try testing.expectEqual(@as(usize, 2), try elements("/1/body", &out)); - try testing.expectEqualStrings("1", out[0]); - try testing.expectEqualStrings("body", out[1]); - - // Leading, trailing and doubled separators are one file, not four. - try testing.expectEqual(@as(usize, 2), try elements("1/body", &out)); - try testing.expectEqual(@as(usize, 2), try elements("//1//body//", &out)); - try testing.expectEqual(@as(usize, 1), try elements("/index", &out)); - - // Zero elements is the root, which is legal here and refused as a - // directory where every other directory is. - try testing.expectEqual(@as(usize, 0), try elements("/", &out)); - - // Deeper than two full walks is a typo, and truncating it would name a - // different file. - var deep: [8 * max_depth]u8 = @splat('/'); - for (0..max_depth + 1) |i| deep[i * 2 + 1] = 'a'; - try testing.expectError(Error.PathTooDeep, elements(deep[0 .. (max_depth + 1) * 2], &out)); -} - -test "a bare dial resolves to the socket --fs9 binds, and a path is taken as given" { - if (comptime !supported) return error.SkipZigTest; - var buf: [sun_path_len]u8 = undefined; - - // The one spelling both halves share: this must be the same name - // `fs9_service.socketPath` produces, or the friendly form dials nothing. - const named = resolve(&buf, "work").?; - try testing.expect(std.mem.endsWith(u8, named, "/pardes-9p-work.sock")); - var expect: [sun_path_len]u8 = undefined; - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf).?; - try testing.expectEqualStrings(fs9_service.socketPath(&expect, dir, "work").?, named); - - // A separator makes it a path, verbatim. - const path = resolve(&buf, "/tmp/somewhere.sock").?; - try testing.expectEqualStrings("/tmp/somewhere.sock", path); - - // And the refusals: nothing to dial, and a NUL that would truncate the - // address into something else entirely. - try testing.expect(resolve(&buf, "") == null); - try testing.expect(resolve(&buf, "/tmp/a\x00b") == null); -} - -test "a dial with nothing listening is one error and not a wait" { - if (comptime !supported) return error.SkipZigTest; - // The overwhelmingly common failure — "that pardes is not running with - // --fs9" — and it must be immediate: `connect` on a unix socket with no - // listener is refused by the kernel with no timeout in it, which is why - // `budget_ms` is never spent here. - var names: [max_depth][]const u8 = undefined; - const n = try elements("/1/body", &names); - var remote: RemoteError = .{}; - const before = nowMs(); - try testing.expectError( - Error.Dial, - fetchBytes(testing.allocator, "/tmp/pardes-9p-no-such-socket.sock", names[0..n], &remote), - ); - try testing.expect(nowMs() - before < budget_ms); -} - -test "one fetch costs three msize buffers and nothing that grows" { - // The number this word adds to a session WHILE IT RUNS, and nothing after: - // the `Session` is freed before `fetch` returns and only the content - // survives, adopted by the pane. Heap rather than stack for the reason - // `Session` states. - try testing.expectEqual(@as(usize, fs9_service.msize), @as(usize, (Session{ .fd = -1, .deadline = 0 }).in.len)); - try testing.expect(@sizeOf(Session) <= 3 * fs9_service.msize + 256); - // The client's own state is a rounding error beside its buffers, which is - // the whole point of borrowing payloads out of `in` instead of copying - // them per tag. - try testing.expect(@sizeOf(ninep.Client) <= 256); -} -- cgit v1.3