summaryrefslogtreecommitdiff
path: root/src/fs9_client.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-27 16:42:15 -0300
committerGabriel Schneider <[email protected]>2026-08-27 22:07:32 -0300
commit147ebd4a36ec7199074ba05bcfb79d4a656c0b74 (patch)
tree400441fc7b103152cd741aec7ee42b82b943aed8 /src/fs9_client.zig
parentdef843b2f59b867ee9b1d501f559f59fb335d4cc (diff)
downloadpardes-147ebd4a36ec7199074ba05bcfb79d4a656c0b74.tar.gz
pardes-147ebd4a36ec7199074ba05bcfb79d4a656c0b74.zip
9p: the client half, and a board that serves its own tree over the UART
Step 5 of the 9P chain (docs/9p.typ 12.5, docs/registry.typ 9P-22, 9P-11, BOARD-1). THE CLIENT. `Client` in src/9p.zig is the mirror of `Server` and the same shape: sans-io, no allocator, no threads, no descriptor, caller-owned buffers, and it builds freestanding. 152 bytes of struct against the server's 9,488, because a client owns neither a fid table nor a park table -- the far end does. The API is submit / push+output+wrote / take. Completion is a PULL: a callback would fire inside push, inside the transport's read, inside the host's poll dispatch, which is exactly where fs9_service says filesystem work must not happen. `take()` returns the next completed operation or null, which is `Server.next()`'s loop-until-null contract read from the other side. Tags are a fixed 16-entry table indexed BY the tag, so an out-of-order reply -- which 9P allows and both reference clients rely on -- costs one bounds check. The reply's TYPE is checked against the request's op, because a tag is only as good as the table behind it. A `Done` borrows the input buffer and is valid until the next call; `take()` releases the previous frame on entry, so the rule is mechanical rather than remembered, and read data and error strings are zero-copy. And one real caller, so this is not a library with no user: the `9p` word takes a dial and a path, walks another instance's tree, and opens the bytes in a pane like any other `Look`. THE BOARD. A SECOND image, not a second role: the console runtime keeps UART0 bidirectionally and is behaviourally untouched. On the new one the UART carries 9P AND NOTHING ELSE -- no ANSI, no vaxis, no allocator, no heap module. The loop is uart.read -> push / retry+next -> handle -> reply / output -> writeSome -> wrote. `writeSome` is new and additive: `write`'s bounded spin DROPS bytes on a stalled transmitter, which on a protocol stream truncates a reply mid-message and desynchronises for good, where a short count cannot. BOARD-1's one divider write raises the line to 921600. 88,000 B text, 49,424 B bss, an 88,080-byte image -- 5.7% of the 1,536,000 B partition, against the console image's 809,536 B. THE COMPTIME BRIDGE, which is the part worth reading. `board9p.caps` is the ONLY place the GPIO tree is described; node ids, parents, names, permissions, handlers, buffer size and the per-pin directories are all derived from it, and `fan.dirs` makes `gpio/<n>/value` one table entry serving eleven pins. Modes are derived from which handlers a file has rather than declared. A second capability is a table entry, not new tree code. JP1 became a real table in the new leaf `src/board_pins.zig`, with the ASCII drawing RENDERED from it at comptime and the pin list COLLECTED from it -- the 9P image links no core and so cannot import board_memory.zig, and copying the table was not acceptable. A golden test pins the drawing byte for byte, the console's own shape test still passes, and the identical bytes are present in all three artifacts. PROVED. Two daemons: B read A's `/1/body` through the `9p` word into a pane, byte-identical to plan9port's `9p read` of the same path. Both board images build. No hardware was attached, so nothing about the board is claimed beyond what builds and what the host tests cover. zig build unit-test 585/585. fs-bench unchanged and still zero allocations on every read row. --- REVIEW FIXES FOLDED IN. Steps 3, 4 and 5 were verified on the happy path and then adversarially reviewed by three agents; eight defects, six fixed here, five of them reproduced with measurements before and after. Full writeup in docs/registry.typ `9P-27`. In brief: * a remote crash of the WHOLE daemon: one `size[4]` of zero plus one byte hit `unreachable` in `fs9_service.fill`. Also 99.7% of a core when the stuck buffer made `room == 0` return without reading. Now `srv.dead` is a hangup, checked before the room guard. * the editor froze 177 s on a dial: `connect(2)` ran on a still-BLOCKING socket before the deadline existed, and a full accept backlog waits forever. Now non-blocking with the wait spent against the budget. After: 2.03 s. * a 64 KiB pty read is exactly `queue_cap` and wiped every unread byte AND dropped itself. `notePtyOutput` splits at half the cap. Deterministic. * four silent sockets denied `--fs9` forever; connections now expire on the same five-second rule the frontend transport already had. * EMFILE spun a core; the listener pauses and leaves the poll set, as the frontend listener does. * `max_fids = 32` made `find` over `9pfuse` fail with 57 consecutive `Rerror`s -- refuting this step's own acceptance clause. 256 for a host, `board_fids` 32 for the microcontroller. Found clean and worth recording: `sig` reaches the foreground process group; the two-namespace pty lookup is right over both transports; `PaneFile`'s u4 wall is guarded; reader counts release on every abrupt-death path; `fs_origin` routing and the reply arithmetic hold under probing.
Diffstat (limited to 'src/fs9_client.zig')
-rw-r--r--src/fs9_client.zig695
1 files changed, 695 insertions, 0 deletions
diff --git a/src/fs9_client.zig b/src/fs9_client.zig
new file mode 100644
index 00000000..ad46dee4
--- /dev/null
+++ b/src/fs9_client.zig
@@ -0,0 +1,695 @@
+//! `9p <dial> <path>`: 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 <dial> <path>` — 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 };
+
+/// `<dial> <path>`, 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);
+}