diff options
| -rw-r--r-- | build.zig | 2 | ||||
| -rw-r--r-- | build.zig.zon | 4 | ||||
| -rw-r--r-- | docs/cloud9.md | 53 | ||||
| -rw-r--r-- | docs/divergences.md | 70 | ||||
| -rw-r--r-- | src/9p_io.zig | 144 |
5 files changed, 269 insertions, 4 deletions
@@ -1284,7 +1284,7 @@ pub fn build(b: *std.Build) void { } const ninep_io_tests = b.addTest(.{ .root_module = ninep_io_module, - .filters = &.{ "Unix TCP", "one fetch", "expired sessions" }, + .filters = &.{ "Unix TCP", "one fetch", "expired sessions", "posts itself" }, }); b.step("9p-io-test", "run native 9P transport and client integration tests") .dependOn(&b.addRunArtifact(ninep_io_tests).step); diff --git a/build.zig.zon b/build.zig.zon index cc53c227..3a172f3b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,8 +9,8 @@ // read-only HTTPS URL is what a manifest can carry. Re-pin with // `zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`. .cloud9 = .{ - .url = "git+https://git.sr.ht/~gbrls/cloud9#ba7ec40782ba7020d82a56896a5eb1b52578d6aa", - .hash = "cloud9-0.1.0-yt86qiEeEwBu1DUzSB0tFDCD2R4CS5gSc_Lm1IXmlBwH", + .url = "git+https://git.sr.ht/~gbrls/cloud9#9c4d668c926e2d9eae6ef87cd9b391d8185605f9", + .hash = "cloud9-0.1.0-yt86qjAoGgBEPMJl2t4CZn7iWPMZUw4sMHAL54tq7spP", }, // ZLS as a LIBRARY, not a language server: src/lsp_zls.zig imports the // `zls` module its build.zig publishes and calls the analyser in diff --git a/docs/cloud9.md b/docs/cloud9.md index d5acc37c..79fda717 100644 --- a/docs/cloud9.md +++ b/docs/cloud9.md @@ -34,3 +34,56 @@ known test-environment limits. Invalid framing now terminates a server connection. Cloud9 also checks reply counts and reserves tags until flush completion. Client metadata is slightly larger to track those reservations, and its bounds tests reflect that fixed cost. + +## The posted-9P registry + +`$XDG_RUNTIME_DIR/9p` is this machine's `/srv`: a server posts itself in it +under a name, and clients dial names rather than paths. cloud9 owns both +sides (`cloud9.post`), and `9ns --mntgen` mounts the whole registry at +`/mnt/9p` for programs that want it as a filesystem. pardes's only part in +it is to put itself there. + +**Serving.** A listening editor advertises itself at +`$XDG_RUNTIME_DIR/9p/pardes/<name>`, a symlink to the socket it already +binds. One directory for the program, one entry per editor, so several +editors group instead of crowding the registry root — the layout zmx posts +its sessions under. The socket itself does not move: adopting the registry +only advertises. Only the runtime-directory socket posts; an instance that +fell back to `~/.local/state/pardes` stays out of the user's registry, the +way a private `ZMX_DIR` does for zmx. Stopping unposts, and only while the +entry is still ours, so a name another editor has since claimed is never +unlinked. + +**Consuming.** Nothing. `9ns --mntgen` mounts the whole registry at +`/mnt/9p`, and an interactive fish already self-wraps in one, so a pardes +started from a terminal sees every posted service as ordinary files — +`/mnt/9p/harness/active/...` is read with the same code that reads any other +path. Teaching pardes to dial the registry itself would put discovery in a +second place for no gain: mounting is the client's job and 9ns is the +client. `--mount=<name>=<dial>` keeps meaning exactly what it always did, +and a dial keeps resolving exactly as it always did — a bare name is another +pardes session, and anything with a slash is a path, relative ones included. + +The one case that is not free: a pardes started outside a mntgen mount has +no `/mnt/9p`. That is 9ns's problem to solve — by being in the namespace — +not a reason for pardes to carry its own registry client. + +### What this diverges from, deliberately + +* **pardes binds its own socket; it does not post through `cloud9.post`.** + `post` would give us its hardened claim protocol (temp-bind plus atomic + rename under a lock) instead of the stale-socket retry in `listen`, but it + claims *flat* names only: `legalName` rejects `/`, and `claimName` derives + its lock directory by stripping `/9p` from the registry path, so a name + inside a subdirectory cannot go through it. zmx hand-rolls the same + symlink for the same reason. Unifying them means teaching `post` a group — + passing the lock directory in rather than deriving it — and that is a + change to adversarially-hardened code, not a rename. +* **The registry entry is a symlink, not the socket.** A reader that expects + every registry entry to be a socket must `stat` following symlinks. + `9ns --mntgen` and `cloud9.post.dial` both do. +* **Dialing is untouched.** An earlier draft taught `resolve` to fall back + to the registry for a bare name and to read `<group>/<name>` as a + subdirectory entry. Both were reverted: the second reinterpreted relative + dials, which are a feature, and the first duplicated what 9ns already + does. `src/9p_io.zig`'s `resolve` is byte-identical to what it was. diff --git a/docs/divergences.md b/docs/divergences.md new file mode 100644 index 00000000..fada830c --- /dev/null +++ b/docs/divergences.md @@ -0,0 +1,70 @@ +# Divergences + +What is not on `main`, and what on `main` is known to be wrong. Written so +that moving a bookmark does not quietly orphan work or hide a failure. + +## Two lines, forked at `01104e7c` + +`main` is not the only living line, and the other one is not behind it — +they are siblings: + +``` +◆ rruwvuzm 09-17 editor work: syntax, panes, modal, gui, fs, output +│ ◆ xqxpolmw 2b547e15 main 09-20 "Serve Unix and TCP 9P through cloud9.serve" +├─╯ +◆ lsnxpxtq 01104e7c 09-16 "Add macOS backdrop blur and preserve PDF ink opacity" +``` + +* **`main`** carries the 9P work: the `cloud9.serve` runner, and now the + posted-9P registry (`docs/cloud9.md`). +* **`rruwvuzm`** carries editor work — roughly 1300 lines across + `src/syntax.zig`, `src/panes.zig`, `src/modal.zig`, `src/gui/gui.zig`, + `src/fs.zig`, `src/pardes.zig`, `test/output.zig`, `docs/fs.md`. Reach it + with `jj edit rruwvuzm`. + +The fork matters for one concrete reason: **the cloud9 pin lives on `main` +only**. `rruwvuzm` still pins `ae310a20` (2026-09-14), `main` now pins +`9c4d668c`. Rebasing or merging the editor line will want the newer pin, or +`zig build` there fetches a cloud9 that predates `fs.Server`'s current shape. + +## Other bookmarks + +| bookmark | | | +|---|---|---| +| `reload-perf-wip` | 09-11 | unfinished: Reload presentation transport regression | +| `reload` | 09-10 | reload core code with shell-owned allocators | +| `reload-start` | 09-10 | names the Core/Shell seam, deferred Reload request | +| `ninep` | 08-27 | a 9P design note and a design registry to argue it in | +| `macos-fix` | 07-23 | | +| `full-prototype`, `term`, `tty-colors-mouse`, `vibes-ghostty`, `mouse` | 06-xx | older prototypes, also on the `vps` remote | + +None of these are published to the FreeBSD mirror: only `main` is pushed +there, deliberately, because that box serves a public site. + +## Known-failing on `main`, not caused by the 9P work + +* **`zig build fs-test`** fails on a syntax-highlighting assertion — the + word `fn` is expected bold and comes back unstyled: + + ``` + AssertionError: ('fn', [{'fg': {'rgb': [201, 176, 228]}, ..., 'bold': False}, ...]) + ``` + + Verified by restoring `main`'s own `src/9p_io.zig`, `build.zig` and + `build.zig.zon` and re-running: it fails identically. The editor line + (`rruwvuzm`) has substantial `src/syntax.zig` changes and may well be the + fix in progress. + +* **pardes does not start headless.** `pardes --9p=<name>` with no terminal + exits 1 from the argument-forwarding path; the installed build fails + earlier still, with `error.NoDevice` opening a terminal device. So the + registry posting above is covered by unit tests + (`zig build 9p-io-test`) rather than by running the editor. + +## Upstream + +`build.zig.zon` pins cloud9 `9c4d668c`. That commit exists because pinning +cloud9 `534c084f` here failed: cloud9's `build.zig` `@import`s each program's +build fragment, and `9harness` was missing from its `.paths`, so the +published package built from a checkout and not from a tarball. The pardes +build was the first consumer to notice. diff --git a/src/9p_io.zig b/src/9p_io.zig index 090eb838..4f7c2e97 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -241,6 +241,10 @@ pub const Listener = struct { paused_ms: i64 = 0, path_buf: [sun_path_len]u8 = undefined, path_len: usize = 0, + /// The registry entry this editor posted + /// (`$XDG_RUNTIME_DIR/9p/pardes/<name>`), zero when it did not. + posted_buf: [sun_path_len]u8 = undefined, + posted_len: usize = 0, conns: [quic_slots]Conn = @splat(.{}), control: [2]c_int = .{ -1, -1 }, watcher: ?std.Thread = null, @@ -635,8 +639,91 @@ pub const Listener = struct { } } + /// Advertises this editor in the posted-9P registry so a + /// `9ns --mntgen` mount lists it and dials it on a walk: + /// `$XDG_RUNTIME_DIR/9p/pardes/<name>` is a symlink to the socket + /// pardes already binds. One directory for the program, one entry + /// per running editor — the layout zmx uses for its sessions, so + /// several editors group instead of crowding the registry root. + /// + /// Only the runtime-directory socket posts: an instance that fell + /// back to `~/.local/state/pardes` stays out of the user's + /// registry, the way a private `ZMX_DIR` does for zmx. + /// + /// The socket itself is still bound by `listen` above, not by + /// `cloud9.post`: `post` claims flat names only, and its claim + /// protocol derives its lock directory from the registry path, so + /// a name inside a subdirectory cannot go through it yet. See + /// docs/cloud9.md. + fn postToRegistry(l: *Listener, name: []const u8) void { + if (comptime !supported) return; + if (name.len == 0 or std.mem.indexOfAny(u8, name, "/\x00") != null) return; + const xdg_c = libc.getenv("XDG_RUNTIME_DIR") orelse return; + const xdg = std.mem.span(xdg_c); + if (xdg.len == 0) return; + + var reg_buf: [sun_path_len:0]u8 = undefined; + const reg = std.fmt.bufPrintSentinel(®_buf, "{s}/9p", .{xdg}, 0) catch return; + _ = libc.mkdir(reg, 0o750); + var svc_buf: [sun_path_len:0]u8 = undefined; + const svc = std.fmt.bufPrintSentinel(&svc_buf, "{s}/9p/pardes", .{xdg}, 0) catch return; + if (libc.mkdir(svc, 0o750) != 0 and statNoFollow(svc) == null) { + log.warn("registry post skipped: cannot create {s}", .{svc}); + return; + } + var entry_buf: [sun_path_len:0]u8 = undefined; + const entry = std.fmt.bufPrintSentinel(&entry_buf, "{s}/{s}", .{ svc, name }, 0) catch { + log.warn("registry post skipped: name too long: {s}", .{name}); + return; + }; + const target = l.path_buf[0..l.path_len]; + + // Replace only what is provably not live: our own entry, or a + // dead predecessor's symlink. `isListening` treats uncertainty + // as live, so it is only asked about an entry that *is* a + // symlink; anything else is left strictly alone and the + // symlink below simply fails. + var link_buf: [sun_path_len]u8 = undefined; + const n = libc.readlink(entry, &link_buf, link_buf.len); + if (n >= 0) { + const had = link_buf[0..@intCast(n)]; + if (!std.mem.eql(u8, had, target) and alive(entry)) { + log.warn("registry entry pardes/{s} is live; not re-posted", .{name}); + return; + } + _ = libc.unlink(entry); + } + var target_z: [sun_path_len:0]u8 = undefined; + const target_zs = std.fmt.bufPrintSentinel(&target_z, "{s}", .{target}, 0) catch return; + if (libc.symlink(target_zs, entry) != 0) { + log.warn("registry post skipped: pardes/{s} is occupied", .{name}); + return; + } + @memcpy(l.posted_buf[0..entry.len], entry); + l.posted_len = entry.len; + log.info("posted pardes/{s} -> {s}", .{ name, target }); + } + + /// Removes the registry entry, but only while it is still ours: a + /// name another editor has since claimed is never unlinked. + fn unpostFromRegistry(l: *Listener) void { + if (comptime !supported) return; + if (l.posted_len == 0) return; + var z: [sun_path_len:0]u8 = undefined; + @memcpy(z[0..l.posted_len], l.posted_buf[0..l.posted_len]); + z[l.posted_len] = 0; + const entry = z[0..l.posted_len :0]; + var link_buf: [sun_path_len]u8 = undefined; + const n = libc.readlink(entry, &link_buf, link_buf.len); + if (n >= 0 and std.mem.eql(u8, link_buf[0..@intCast(n)], l.path_buf[0..l.path_len])) { + _ = libc.unlink(entry); + } + l.posted_len = 0; + } + pub fn deinit(l: *Listener, gpa: std.mem.Allocator) void { l.stopping.store(true, .release); + l.unpostFromRegistry(); if (l.watcher) |thread| { l.watch_stop.store(true, .release); _ = libc.write(l.control[1], "q", 1); @@ -680,7 +767,8 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, named: []const u8, fallback: [ .handler = .{ .ctx = l, .serve = Listener.onServe, .opened = Listener.onOpened, .closed = Listener.onClosed }, .greet_timeout_ms = Listener.greet_deadline_ms, }); - const p = socketPath(&l.path_buf, dir, if (named.len != 0) named else fallback) orelse { + const entry_name = if (named.len != 0) named else fallback; + const p = socketPath(&l.path_buf, dir, entry_name) orelse { gpa.destroy(l); return null; }; @@ -705,6 +793,7 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, named: []const u8, fallback: [ l.deinit(gpa); return null; } + l.postToRegistry(entry_name); if (tcp_dial) |dial| { if (!std.mem.startsWith(u8, dial, "tcp!")) { l.deinit(gpa); @@ -840,6 +929,59 @@ test "same-session TCP mounts compare canonical endpoints and local wildcard des extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; extern "c" fn rmdir(path: [*:0]const u8) c_int; +test "a listening editor posts itself into the 9P registry and unposts on stop" { + if (comptime !supported) return error.SkipZigTest; + const gpa = testing.allocator; + var directory: [64:0]u8 = undefined; + _ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-post-XXXXXX", .{}, 0); + if (mkdtemp(&directory) == null) return error.TempDirectoryFailed; + const dir = std.mem.span(@as([*:0]const u8, &directory)); + const old_runtime = if (libc.getenv("XDG_RUNTIME_DIR")) |v| try gpa.dupeZ(u8, std.mem.span(v)) else null; + defer { + if (old_runtime) |v| { + _ = setenv("XDG_RUNTIME_DIR", v, 1); + gpa.free(v); + } else _ = unsetenv("XDG_RUNTIME_DIR"); + } + try testing.expectEqual(@as(c_int, 0), setenv("XDG_RUNTIME_DIR", &directory, 1)); + + var entry_buf: [sun_path_len:0]u8 = undefined; + const entry = try std.fmt.bufPrintSentinel(&entry_buf, "{s}/9p/pardes/unit", .{dir}, 0); + var svc_buf: [sun_path_len:0]u8 = undefined; + const svc = try std.fmt.bufPrintSentinel(&svc_buf, "{s}/9p/pardes", .{dir}, 0); + var sock_buf: [sun_path_len:0]u8 = undefined; + const sock = try std.fmt.bufPrintSentinel(&sock_buf, "{s}/" ++ prefix ++ "unit.sock", .{dir}, 0); + defer { + _ = libc.unlink(entry); + _ = rmdir(svc); + var reg_buf: [sun_path_len:0]u8 = undefined; + if (std.fmt.bufPrintSentinel(®_buf, "{s}/9p", .{dir}, 0)) |reg| { + _ = rmdir(reg); + } else |_| {} + _ = libc.unlink(sock); + _ = rmdir(&directory); + } + + var link: [sun_path_len]u8 = undefined; + { + const l = listen(testing.io, gpa, "unit", "", null, null) orelse return error.ListenFailed; + defer l.deinit(gpa); + // The socket stays exactly where pardes has always bound it: + // adopting the registry moves nothing, it only advertises. + try testing.expect(statNoFollow(sock) != null); + // And the registry holds a symlink to it one directory down, so + // several editors group under /mnt/9p/pardes/ instead of + // crowding the registry root — the layout zmx posts its + // sessions in. + const n = libc.readlink(entry, &link, link.len); + try testing.expect(n > 0); + try testing.expectEqualStrings(sock, link[0..@intCast(n)]); + } + // Stopping takes the name back out, so the next editor of that name + // is not refused by its own corpse. + try testing.expect(libc.readlink(entry, &link, link.len) < 0); +} + test "Unix TCP and QUIC share one listener through reads writes reconnects and reset" { if (comptime !supported) return error.SkipZigTest; const gpa = testing.allocator; |
