diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-22 11:15:46 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 31cb659ded4cf50af5903fc107f8c868ee3c7311 (patch) | |
| tree | 8ceaf0ae085cfee121a1b4bdcb923b2c03b01a60 /src/pardes.zig | |
| parent | a1d5ee19a648abc65b557dffa14a3b2f70577286 (diff) | |
| download | pardes-31cb659ded4cf50af5903fc107f8c868ee3c7311.tar.gz pardes-31cb659ded4cf50af5903fc107f8c868ee3c7311.zip | |
Answer 9P on the connection's task, so a session can open its own tree
The editor's loop was the only thing that could answer a 9P request, which
made the editor's own syscalls through a mount of its own tree -- a Look at
/mnt/9p/pardes/<me>/anything under a `9ns --mntgen` view, a Save into it --
requests only the blocked loop could serve. The name-based refusal that
followed (ownMountSuffix) and the in-process routing of a mount of oneself
(Client.sameSession) were patches over that, and both are gone, with the
mailbox that shipped every request to the editor's thread.
One rule replaces them, `pardes.turn`: the core is single-threaded, the
editor's thread has the turn by default and gives it up in two kinds of gap
-- while it waits for input and while a step of it is out in a host syscall
-- and a cloud9 connection task takes it in those gaps to answer. `out`
counts the steps that are out, from any thread: while one is, the core reads
consistently but that step still holds pointers into it, so a request that
would change a pane (a write, a truncation, an rmdir) is parked in the
engine and retried when the turn is next given up with nothing out, and the
editor's own wake waits for the count to reach zero. It is never a write of
its own that a step waits on out there -- writes come from a shell
performing a save between steps -- so a parked request is never the
syscall's own, and making a pane or rendering a screen need not park:
every yield sits before its step's mutation, so the layout and the surface
are whole under it. A changing request that queued effects is answered
once the editor has performed them (`echo Save > exec` returns with the
file written, as acme's `put` does), and it settles the way a step does,
because without that a /log reader waited for the user's next keystroke.
Every host syscall on a user path has to give the turn up, not fs.zig's
alone: the first end-to-end run hung in `inotify_add_watch` performing the
new pane's watch effect. PDFs and images are read whole at open, so no
draw goes out into the host. The core's allocator takes its fixed buffer
through the lock-free interface, since a connection task allocates while
the editor's thread is out in a syscall that allocates too. A Restore puts
the replacement in first and releases every task waiting on the old core.
cloud9 (pinned at eb1a104) parks an open, a truncating wstat, a clunk and a
remove on `again`, not only reads and writes, and answers a parked job
whose fid was clunked without asking the backend.
Verified: test/selfmount.py runs the editor under `9ns --mntgen` and
Looks at, reads and Saves its own tree through the mount; a unit test pins
that a change parks while the editor is out mid-step and lands when it
rests, while a read is answered in the window. 9P over the Unix socket
against a tty session, same machine, Debug builds: a read of /index 278us
-> 61us, a truncating body write 1184us -> 609us, exec Save 718us -> 583us;
the gesture benchmark is unchanged (geometric mean 0.997 over 53 cells).
Also from the reviews: a notice chip over an image or PDF pane was painted
out by the picture drawn after the cells, so pictures give up the rows; in
the GUI a tree-sitter context band painted over the chip, so body layers
are emitted first; a message is one row of printable text, its 256-byte
cut never leaves half a glyph, and one wider than its pane keeps its tail
(the file name, the reason) rather than its head.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 282 |
1 files changed, 226 insertions, 56 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 0ac16162..cc57127f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,148 @@ pub const font_picker = platform == .gui or platform == .macos; pub const hosted = platform == .tty or platform == .gui or platform == .macos; +/// Whose turn it is with the core. The core is single-threaded: the editor's +/// thread has it, and lets go of it in two kinds of gap -- while it waits for +/// input (`rest`, `wake`), and while it is out in the host filesystem in the +/// middle of a step (`yield`, `back`). A 9P connection task takes the turn +/// in those gaps to answer a request (`take`, `give`), which is what lets the +/// editor read its own tree through a mount: the syscall goes out, another +/// thread answers it, the syscall comes back. A step of a connection task's +/// own -- a Look written to `look` -- goes out the same way. +/// +/// `out` counts the steps that are out. While one is, the core reads +/// consistently but that step still holds pointers into it, so nothing may +/// change it: a request that would is parked in the engine (src/9p_io.zig) +/// and retried when the turn is next given up with nothing out, which +/// `parked` remembers to arrange; and the editor's own wake waits for the +/// count to reach zero, since a step of the editor's may change anything. +/// It is never a write of its own that a step waits on out there -- writes +/// come from a shell performing a save between steps -- so a parked request +/// is never the syscall's own. +/// +/// One per process, like the editor it belongs to. A process that never +/// starts it -- a test, the board, the browser -- has no second thread, and +/// every yield is a no-op. +pub var turn: Turn = .{}; + +pub const Turn = struct { + mutex: std.Io.Mutex = .init, + /// Set by `start`; a process that never starts the turn has none. + io: ?std.Io = null, + /// Steps out in a host syscall, from any thread. + out: u32 = 0, + /// A request that changes a pane was parked for want of quiet. + parked: bool = false, + wake_parked: ?*const fn (?*anyopaque) void = null, + wake_ctx: ?*anyopaque = null, + /// Bumped each time the editor has performed what the core asked of it, + /// so a request that asked for something -- a save, a shell -- can be + /// answered once it is done rather than merely queued, the way writing + /// `put` to acme's ctl returns after the file is written. + epoch: u64 = 0, + /// Bumped each time a shell has attempted the Restore the core asked for + /// (`takeRestore`), which lives after `pump` because it swaps the core + /// out from under it; a request that asked for one waits for this. + restores: u64 = 0, + /// Both of the above, and `out` reaching zero. + settled: std.Io.Condition = .init, + + /// The editor's thread takes the turn for good; it has it until `stop`. + pub fn start(t: *Turn, io: std.Io) void { + t.io = io; + t.mutex.lockUncancelable(io); + } + + pub fn stop(t: *Turn) void { + const io = t.io orelse return; + t.io = null; + t.parked = false; + t.mutex.unlock(io); + } + + /// The editor is about to wait for input: anyone may have the core. + pub fn rest(t: *Turn) void { + if (t.io == null) return; + t.release(); + } + + /// The editor takes the turn back -- once every step that is out in a + /// syscall has returned, since a step of the editor's may change what + /// those still point into. + pub fn wake(t: *Turn) void { + const io = t.io orelse return; + t.mutex.lockUncancelable(io); + while (t.out != 0) t.settled.waitUncancelable(io, &t.mutex); + } + + /// Whoever has the turn is about to block in the host in the middle of + /// a step. Nests: a directory listing that resolves each entry yields + /// inside a yield, and only the outermost one lets go. + pub fn yield(t: *Turn) void { + const io = t.io orelse return; + yield_depth += 1; + if (yield_depth != 1) return; + t.out += 1; + t.mutex.unlock(io); + } + + pub fn back(t: *Turn) void { + const io = t.io orelse return; + yield_depth -= 1; + if (yield_depth != 0) return; + t.mutex.lockUncancelable(io); + t.out -= 1; + if (t.out == 0) t.settled.broadcast(io); + } + + /// How many yields this thread is inside of. + threadlocal var yield_depth: u32 = 0; + + /// A connection task takes the turn; answers whether nothing is out + /// mid-step, which is what a request that changes a pane needs. + pub fn take(t: *Turn) bool { + t.mutex.lockUncancelable(t.io.?); + return t.out == 0; + } + + pub fn give(t: *Turn) void { + t.release(); + } + + /// The editor performed the core's effects. + pub fn settle(t: *Turn) void { + const io = t.io orelse return; + t.epoch +%= 1; + t.settled.broadcast(io); + } + + /// With the turn: waits, turn given up meanwhile, until the editor has + /// performed past `epoch`; stopping ends the wait early. + pub fn awaitSettled(t: *Turn, epoch: u64) void { + while (t.epoch == epoch) t.settled.wait(t.io.?, &t.mutex) catch return; + } + + /// The shell attempted the Restore the core asked for, whichever way it + /// went; called at the top of each loop step, when the previous step's + /// attempt is over. + pub fn restoreSettled(t: *Turn) void { + const io = t.io orelse return; + t.restores +%= 1; + t.settled.broadcast(io); + } + + pub fn awaitRestored(t: *Turn, restores: u64) void { + while (t.restores == restores) t.settled.wait(t.io.?, &t.mutex) catch return; + } + + fn release(t: *Turn) void { + const woken = t.out == 0 and t.parked; + if (woken) t.parked = false; + t.mutex.unlock(t.io.?); + if (woken) if (t.wake_parked) |f| f(t.wake_ctx); + } +}; + pub const can_attach = platform == .tty or platform == .gui; pub const terminal_panes = platform != .esp32p4; @@ -4754,7 +4896,6 @@ pub const Event = union(enum) { /// this must not clamp onto and preview the final grid cell. pointer_leave, tick, - fs_req: ctlfs.Req, }; pub const attach_name_max = 256; @@ -4792,7 +4933,6 @@ pub const Effect = union(enum) { /// Write the build-time theme ring below the per-user config directory. /// The pane receives the completion/error message from the native host. dump_themes: struct { pane: u8 }, - fs_reply: ctlfs.Reply, attach: struct { pane: u8, name: Buf(attach_name_max) }, detach: struct { pane: u8 }, quit, @@ -5910,11 +6050,6 @@ pub const Options = struct { ninep_name: []const u8 = "", ninep_tcp: ?[]const u8 = null, ninep_quic: ?[]const u8 = null, - ninep_identity: struct { - socket_path: []const u8 = "", - tcp_address: ?std.Io.net.IpAddress = null, - quic_address: ?std.Io.net.IpAddress = null, - } = .{}, mounts: []const filesystem.Mount = &.{}, config_dir: ?[]const u8 = null, startup_config_path: ?[]const u8 = null, @@ -6182,11 +6317,7 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, - .fs = .{ - .socket_path = opts.ninep_identity.socket_path, - .tcp_address = opts.ninep_identity.tcp_address, - .quic_address = opts.ninep_identity.quic_address, - }, + .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), @@ -6197,7 +6328,6 @@ pub const Pardes = struct { p.fs.started = ctlfs.events.now(); for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; - p.opts.ninep_identity = .{}; p.boot = Boot.of(opts); switch (p.boot) { .document => { @@ -6310,13 +6440,31 @@ pub const Pardes = struct { return @intCast(std.math.clamp(panes.File.displayWidth(text) + 2, 1, @as(usize, limit))); } + const Printed = struct { left: u16, dropped: usize }; + /// Prints `text` flush with the right edge of the band, and answers the - /// column it started at so a cursor can follow it. - fn printRight(s: *Surface, x: u16, row: u16, w: u16, text: []const u8, style: CellStyle) u16 { - const shown: u16 = @intCast(@min(@as(usize, w), panes.File.displayWidth(text))); - const left = x + w - shown; - _ = s.print(left, row, shown, text, style); - return left; + /// column it started at so a cursor can follow it. Text wider than the + /// band loses its HEAD: the end is the part that says something -- a + /// path's file name, an error's reason -- and `dropped` is how many + /// columns of it went. + fn printRight(s: *Surface, x: u16, row: u16, w: u16, text: []const u8, style: CellStyle) Printed { + const width = panes.File.displayWidth(text); + if (width <= w) { + const left: u16 = @intCast(x + w - width); + _ = s.print(left, row, @intCast(width), text, style); + return .{ .left = left, .dropped = 0 }; + } + // Whole glyphs only: a cut inside a wide one would leave the rest a + // column too wide and clip its LAST glyph instead of the first. + var dropped = width - w; + var start = panes.File.rawAtDisplay(text, dropped); + if (panes.File.rawDisplayCol(text, start) < dropped) { + dropped += 1; + start = panes.File.rawAtDisplay(text, dropped); + } + const left: u16 = @intCast(x + w - (width - dropped)); + _ = s.print(left, row, @intCast(width - dropped), text[start..], style); + return .{ .left = left, .dropped = dropped }; } /// The layout every single-pane boot starts from. @@ -6904,8 +7052,14 @@ pub const Pardes = struct { fn showMessage(p: *Pardes, id: usize, text: []const u8) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; - pane.msg_len = @intCast(@min(text.len, pane.msg.len)); - @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); + // One row of printable text: a language server's multi-line report + // reads as one line, and the cut never leaves half a glyph behind. + var n: usize = @min(text.len, pane.msg.len); + if (n < text.len) { + while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1; + } + for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c; + pane.msg_len = @intCast(n); } fn logMessage(p: *Pardes, id: usize, text: []const u8) void { @@ -7040,7 +7194,7 @@ pub const Pardes = struct { pub fn postEvent(p: *Pardes, ev: Event) void { switch (ev) { .key => |k| std.debug.assert(k.text.len == 0), - .output, .paste, .lsp_resp, .pipe_resp, .file_changed, .command, .fs_req => unreachable, + .output, .paste, .lsp_resp, .pipe_resp, .file_changed, .command => unreachable, else => {}, } if (p.in_len == p.in_q.len) return; @@ -7173,7 +7327,6 @@ pub const Pardes = struct { }, .theme_file => |t| if (v.watch_theme) |f| f(p.host.ctx, t.generation, t.on), .dump_themes => |d| if (v.dump_themes) |f| f(p.host.ctx, d.pane), - .fs_reply => {}, .attach => |a| { const name = a.name.slice(); @memcpy(p.attach_buf[0..name.len], name); @@ -7194,6 +7347,7 @@ pub const Pardes = struct { if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); + turn.settle(); // A quitting frame has already freed what it would draw. if (p.quit) return; if (v.poll_frame) |f| f(h.ctx); @@ -7208,24 +7362,35 @@ pub const Pardes = struct { p.needs_frame = false; } + /// One request of the control filesystem, from whichever thread has the + /// turn (`pardes.turn`). Not an event: it neither sweeps nor resets + /// anything the editor's own step may be in the middle of using, so it + /// can be answered while that step is out in a syscall. Only a request + /// that changes a pane costs a frame, which is why a round trip on a + /// local socket costs microseconds and not a vsync. + pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply { + if (!ctlfs.changesPane(req)) return ctlfs.handle(p, req); + p.needs_frame = true; + p.raw_hover_intent = false; + p.cancelLookHover(); + const reply = ctlfs.handle(p, req); + // The request was a whole step of its own, so it settles the way a + // step does: the cursor and scroll reconciled, the scripted panes + // told, and the panes it made announced to /log now rather than at + // the end of whatever the editor does next. + p.sync(); + p.fsReport(); + ctlfs.events.announce(p); + return reply; + } + pub fn update(p: *Pardes, ev: Event) void { - // Only two events cannot change the screen: a tick with nothing - // animating, and a filesystem request that only reads. The second is - // what 9P traffic overwhelmingly is, and answering it used to drag a - // whole vsync-blocked frame behind it -- which is why a round trip on - // a local socket cost a frame instead of a few microseconds. - p.needs_frame = p.needs_frame or switch (ev) { - .tick => false, - .fs_req => |req| ctlfs.changesPane(req), - else => true, - }; + // A tick with nothing animating is the one event that cannot change + // the screen. + p.needs_frame = p.needs_frame or ev != .tick; p.shell_rows.sweep(p.gpa); switch (ev) { .tick => {}, - .fs_req => |req| if (ctlfs.changesPane(req)) { - p.raw_hover_intent = false; - p.cancelLookHover(); - }, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { p.raw_hover_intent = false; p.cancelLookHover(); @@ -7324,7 +7489,6 @@ pub const Pardes = struct { }, .paste => |bytes| p.applyPaste(bytes), .command => |line| _ = p.executeBuiltinLine(p.active, line), - .fs_req => |r| p.emit(.{ .fs_reply = ctlfs.handle(p, r) }), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .pointer_leave => { @@ -12915,11 +13079,7 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, - .fs = .{ - .socket_path = opts.ninep_identity.socket_path, - .tcp_address = opts.ninep_identity.tcp_address, - .quic_address = opts.ninep_identity.quic_address, - }, + .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), @@ -12934,13 +13094,9 @@ pub const Pardes = struct { p.cell_pixels = old.cell_pixels; p.row_metrics = old.row_metrics; p.native_images = old.native_images; - p.fs.socket_path = old.fs.socket_path; - p.fs.tcp_address = old.fs.tcp_address; - p.fs.quic_address = old.fs.quic_address; } for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; - p.opts.ninep_identity = .{}; var parsed = try dump.readZon(gpa, zon_bytes, "load"); defer parsed.deinit(); const st = parsed.value; @@ -13521,16 +13677,19 @@ pub const Pardes = struct { // right would put it past the pane, off the grid, and past what // the detached wire will encode -- which drops every frame for // as long as the prompt is up. - const left = printRight(s, cx, row, chip -| 1, text, msg_style); + const printed = printRight(s, cx, row, chip -| 1, text, msg_style); if (kind != .prompt or id != p.active) continue; const at = pane.promptAt() orelse continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; const col = @as(usize, pane.tag_col); if (col < prompt0) continue; - // The cursor follows the text to wherever it landed. + // The cursor follows the text to wherever it landed; a caret + // in the part a narrow band dropped has nowhere to be. const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); - if (left + prompt_col < tx + tw) - s.cursor = .{ .x = left + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; + if (prompt_col < printed.dropped) continue; + const caret = printed.left + (prompt_col - printed.dropped); + if (caret < tx + tw) + s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.mode == .insert }; } } @@ -14018,12 +14177,19 @@ pub const Pardes = struct { const chip = r.x + r.w - cx; // Right aligned inside the chip, a blank cell short of its // edge: the same one the grid pass leaves for a prompt caret. + // Wider than the chip, the text loses its head, as on the grid. const room = p.tagCapacity(chip) -| 1; const shown = panes.File.displayWidth(text); - const pad = room -| shown; - const line = try arena.alloc(u8, pad + text.len); + var kept = text; + if (shown > room) { + var start = panes.File.rawAtDisplay(text, shown - room); + if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); + kept = text[start..]; + } + const pad = room -| panes.File.displayWidth(kept); + const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); - @memcpy(line[pad..], text); + @memcpy(line[pad..], kept); try p.renderHeaderLayer(arena, NOTICE_LAYER_BASE + id * Pane.Notices.max + i, .notice, @intCast(id), .{ .x = cx, .y = first + @as(u16, @intCast(i)), @@ -14221,8 +14387,12 @@ pub const Pardes = struct { if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; if (pane.image) |*iv| { - const image_h = r.h -| BOX_H; - panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, image_h); + // Below the notice chips: a picture is drawn after the cells (the + // GUI's image pass, kitty's z-order), so a chip over it would be + // painted out. Text gets the overlay; a picture gives up the rows. + const shown = pane.notices.len; + const image_h = (r.h -| BOX_H) -| shown; + panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it, and like that one it runs // past the notice bands so the gutter has no notch in it |
