diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 17:24:37 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 0122e94fb37422085325f2dc78ca015051ce5f91 (patch) | |
| tree | 364af2eb96a192087971034b8ec1785cd6f90077 | |
| parent | fd50bd971d6dea7eaaa4ee5436ba16b95fa25b30 (diff) | |
| download | pardes-0122e94fb37422085325f2dc78ca015051ce5f91.tar.gz pardes-0122e94fb37422085325f2dc78ca015051ce5f91.zip | |
Advertise the editor in the posted-9P registry
pardes spoke 9P and could be mounted, but only by naming its socket:
$XDG_RUNTIME_DIR/pardes-9p-<name>.sock sits one directory above the
registry and nothing could find it. Now 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 —
the layout zmx posts its sessions under — so several editors group
rather than crowd the registry root.
The socket does not move: adopting the registry only advertises. Only
the runtime-directory socket posts, so an instance on the
~/.local/state fallback 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. The cloud9 pin moves to 9c4d668c for cloud9.post's
path helpers.
Serving is the whole of it. Consuming the registry is 9ns's job: it
mounts the lot at /mnt/9p and an interactive fish already self-wraps in
one, so a pardes started from a terminal reads /mnt/9p/harness/... with
the same code that reads any other path. Two drafts that taught
`resolve` to dial the registry itself were reverted — one duplicated
9ns for no gain, the other reinterpreted relative dials, which are a
feature. `resolve` is byte-identical to what it was, and no dial that
worked changes meaning.
What pardes still does not do, and why, is in docs/cloud9.md: it binds
its own socket rather than posting through cloud9.post, because post
claims flat names only — legalName rejects '/', and claimName derives
its lock directory by stripping "/9p" — 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, which is a change to
adversarially-hardened code rather than a rename.
docs/divergences.md records what this bookmark move leaves beside it:
the editor line rruwvuzm (~1300 lines, forked at 01104e7c, still on the
old cloud9 pin), the other bookmarks, and two failures that are not
this change — fs-test's syntax-highlighting assertion, which fails
identically on a clean main, and pardes not starting headless, which is
why this is covered by 9p-io-test rather than by running the editor.
Tests: 11/11 9p-io-test, including a listener that posts on start and
unposts on stop; 31/31 unit-test.
| -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; |
