diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-27 16:42:15 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 22:07:32 -0300 |
| commit | 147ebd4a36ec7199074ba05bcfb79d4a656c0b74 (patch) | |
| tree | 400441fc7b103152cd741aec7ee42b82b943aed8 /src/fs9_client.zig | |
| parent | def843b2f59b867ee9b1d501f559f59fb335d4cc (diff) | |
| download | pardes-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.zig | 695 |
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); +} |
