summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig2
-rw-r--r--build.zig.zon4
-rw-r--r--docs/cloud9.md53
-rw-r--r--docs/divergences.md70
-rw-r--r--src/9p_io.zig144
5 files changed, 269 insertions, 4 deletions
diff --git a/build.zig b/build.zig
index 6734d9b0..a9bf4c0f 100644
--- a/build.zig
+++ b/build.zig
@@ -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(&reg_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(&reg_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;