From b5af181a4d4967173bbc7015abbc24f475f648f2 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 2 Nov 2026 10:47:08 -0500 Subject: [PATCH] Integrate with herdr cli. --- README.md | 18 + build.zig | 12 + src/Layouts.zig | 14 + src/Pty.zig | 10 +- src/Session.zig | 463 +++++++++++++++++++++-- src/Settings.zig | 16 + src/SettingsDialog.zig | 105 ++++-- src/View.zig | 24 +- src/Window.zig | 102 +++-- src/hc.zig | 827 +++++++++++++++++++++++++++++++++++++++++ src/hc/frame.zig | 143 +++++++ 11 files changed, 1641 insertions(+), 93 deletions(-) create mode 100644 src/hc.zig create mode 100644 src/hc/frame.zig diff --git a/README.md b/README.md index acf6f97..ae7124a 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,24 @@ deliberate one — there is nothing left worth offering to put back, and a banne at the next launch offering the session before that would be answering a question nobody asked. +### Shells that outlive the window + +Settings → Quitting → *Keep shells running after the window closes* changes the +last paragraph's answer for terminals. With it on, a new shell does not run in a +pty of playpen's own but in [herdr-core](https://github.com/herdrdev/herdr-core) +— the runtime behind `hc` — and the pane attaches to it. Quitting, a crash, or +systemd killing the app's scope leaves every shell and whatever is running in +it alone, and the next launch restores the session **without asking**, in place +of the startup tabs: each pane reattaches to its own shell, scrollback and all. +Closing a pane or a tab still ends what is in it; only the app going away does +not. It needs `hc` on `PATH` or in `~/.local/bin`, and falls back to a local +shell whenever the core cannot be reached. + +The snapshot records which core terminal each pane was showing (`"hc": "t7"` on +a terminal leaf). A terminal that has since exited reopens as a fresh shell in +the recorded directory, as it would without herdr-core. Shells in the core can +also be reached from anywhere else with `hc ls` and `hc attach`. + The file is `session.json`, and it lives in the **state** directory rather than beside `layouts.json` and `settings.json`: diff --git a/build.zig b/build.zig index 3cf8898..b97cceb 100644 --- a/build.zig +++ b/build.zig @@ -180,6 +180,18 @@ pub fn build(b: *std.Build) void { palette_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); test_step.dependOn(&b.addRunArtifact(palette_tests).step); + // herdr-core's frame format is its own root: the bytes of the wire + // protocol, testable with no core to talk to. The connection on top of it + // wants a socket and a main loop, and is checked by running it. + const hc_frame_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/hc/frame.zig"), + .target = target, + .optimize = optimize, + }), + }); + test_step.dependOn(&b.addRunArtifact(hc_frame_tests).step); + // And a root for the settings file itself. `Settings.zig` reaches GLib for // where the file lives, but reading and writing its *contents* is pure // enough to test — which is what these cover, since the palette overrides diff --git a/src/Layouts.zig b/src/Layouts.zig index 35f90c6..fcd0416 100644 --- a/src/Layouts.zig +++ b/src/Layouts.zig @@ -94,6 +94,14 @@ pub const Pane = struct { /// Page to load, for web panes. url: []const u8 = "", + + /// The herdr-core terminal a terminal pane was showing (`t7`). + /// + /// Only ever written into the session snapshot, never into a saved layout: + /// a layout is a recipe for new shells, and a snapshot is a photograph of + /// running ones — this is what lets restoring it reattach to them rather + /// than start over. See `Session.Options.hc`. + hc: []const u8 = "", }; pub const Node = union(enum) { @@ -304,6 +312,7 @@ pub fn parseNode(alloc: std.mem.Allocator, raw: std.json.Value) ParseError!*Node .cwd = try optionalString(alloc, obj.get("cwd")), .command = try optionalString(alloc, obj.get("command")), .url = try optionalString(alloc, obj.get("url")), + .hc = try optionalString(alloc, obj.get("hc")), } }; return node; } @@ -469,6 +478,10 @@ pub fn writeNode(json: *std.json.Stringify, node: *const Node) SaveError!void { try json.objectField("url"); try json.write(p.url); } + if (p.hc.len > 0) { + try json.objectField("hc"); + try json.write(p.hc); + } }, } try json.endObject(); @@ -498,6 +511,7 @@ pub const Builder = struct { .cwd = try self.dupe(spec.cwd), .command = try self.dupe(spec.command), .url = try self.dupe(spec.url), + .hc = try self.dupe(spec.hc), } }); } diff --git a/src/Pty.zig b/src/Pty.zig index 60024b4..4234611 100644 --- a/src/Pty.zig +++ b/src/Pty.zig @@ -241,7 +241,7 @@ fn buildEnv( /// Whether an inherited `NAME=value` entry names a variable the caller is /// about to define. -fn shadowedBy(entry: []const u8, extra: []const []const u8) bool { +pub fn shadowedBy(entry: []const u8, extra: []const []const u8) bool { const eq = std.mem.indexOfScalar(u8, entry, '=') orelse return false; for (extra) |candidate| { const candidate_eq = std.mem.indexOfScalar(u8, candidate, '=') orelse continue; @@ -263,8 +263,14 @@ fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void { /// are rather than where you began. Read straight out of procfs: there is no /// portable API for it, and this app is Linux-only regardless. pub fn cwd(self: Pty, buf: []u8) ?[]const u8 { + return cwdOf(self.pid, buf); +} + +/// `cwd` for any process, not only one this module started — a herdr-core +/// terminal's program, which runs on this machine but is nobody's child here. +pub fn cwdOf(pid: pid_t, buf: []u8) ?[]const u8 { var link_buf: [64]u8 = undefined; - const link = std.mem.printSentinel(&link_buf, "/proc/{d}/cwd", .{self.pid}, 0) catch return null; + const link = std.mem.printSentinel(&link_buf, "/proc/{d}/cwd", .{pid}, 0) catch return null; const n = c.readlink(link.ptr, buf.ptr, buf.len); if (n <= 0) return null; diff --git a/src/Session.zig b/src/Session.zig index ab69652..9aa9298 100644 --- a/src/Session.zig +++ b/src/Session.zig @@ -12,6 +12,8 @@ const glibunix = @import("glibunix"); const vt = @import("ghostty-vt"); const Pty = @import("Pty.zig"); +const Settings = @import("Settings.zig"); +const hc = @import("hc.zig"); const scope = @import("scope.zig"); const theme = @import("theme.zig"); @@ -31,11 +33,17 @@ term: vt.Terminal, /// read boundaries, so this must outlive individual reads. stream: vt.TerminalStream, -pty: Pty, +/// Where the program runs: a pty of our own, or a terminal in herdr-core. +backend: Backend, /// GLib source ID for the PTY read watch, so we can cancel it on teardown. +/// Local backend only; herdr-core output arrives through `hc`. watch: c_uint = 0, +/// Fallback for typing the startup command into a herdr-core terminal — see +/// `onHcData`. +flush_timer: c_uint = 0, + /// True once the child process has exited and the PTY hung up. exited: bool = false, @@ -91,6 +99,57 @@ pub const Status = enum { failed, }; +/// Where a session's program runs. +/// +/// Local is the original arrangement: a pty this process opened, and a child +/// that dies with it. Herdr-core moves the program into a terminal the core +/// holds, so that it outlives the app — see `hc.zig`. Everything above the +/// byte stream (the emulator, the renderer, input encoding) is the same for +/// both; only where bytes go and come from differs. +const Backend = union(enum) { + local: Pty, + hc: Remote, +}; + +/// A terminal in herdr-core, as seen from the pane showing it. +const Remote = struct { + /// Its address in the core, `t7`. Owned. + address: []u8, + + /// The stream it is attached on, or null while it is not — after the + /// connection dropped and before a reattach has succeeded. + channel: ?u32 = null, + + /// Its program's pid, for reading the working directory out of procfs. The + /// core runs on this machine, so the pid means the same thing here. + pid: i32 = 0, + + /// Output frames to let pass before the shell counts as started. A fresh + /// terminal's stream opens with the core's paint of its screen, which comes + /// at once and says nothing about whether the shell is ready to read. + paints_to_skip: u8 = 0, + + /// Pending check on the terminal after its stream ended. + timer: c_uint = 0, + + /// Reattach attempts since the stream was last up, for the backoff. + attempts: u32 = 0, + + /// The size the program was last told, and the pending telling of a new + /// one — see `scheduleRemoteResize`. + sent_cols: u16 = 0, + sent_rows: u16 = 0, + resize_timer: c_uint = 0, +}; + +/// How long a pane's size has to hold before a herdr-core program hears of it. +const remote_resize_settle_ms = 60; + +/// How long to wait before typing the startup command into a herdr-core shell +/// that has said nothing since the first paint — one whose prompt was already +/// on screen when it was attached. +const flush_fallback_ms = 1500; + pub const Callbacks = struct { on_damage: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, @@ -117,6 +176,13 @@ pub const Options = struct { /// no chance of picking up another tab's review. Empty leaves the variable /// unset, which is what an agent sees when the server never started. review_url: []const u8 = "", + + /// A herdr-core terminal to attach to instead of starting a shell — one + /// this pane was showing when the session snapshot was taken. Attached to + /// whether or not the setting is on, since it is already running: what the + /// setting decides is where *new* shells start. If it has gone, the pane + /// starts a new shell in `cwd` as it would have without it. + hc: []const u8 = "", }; pub fn create( @@ -138,7 +204,7 @@ pub fn create( .max_scrollback_bytes = 8 * 1024 * 1024, }), .stream = undefined, - .pty = undefined, + .backend = undefined, .on_damage = cbs.on_damage, .on_title = cbs.on_title, .on_exit = cbs.on_exit, @@ -149,7 +215,7 @@ pub fn create( // Before the child is spawned, so the first thing it prints is already in // the right palette. - self.refreshPalette(); + self.applyPalette(); self.stream = self.term.vtStream(); errdefer self.stream.deinit(); @@ -203,34 +269,48 @@ pub fn create( env_extra = &env_buf; } - self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, env_extra, .{ - .ws_row = rows, - .ws_col = cols, - }); - errdefer self.pty.deinit(); + if (self.startRemote(opts, cols, rows, shell, cwd_z, env_extra)) |remote| { + self.backend = .{ .hc = remote }; + } else |err| { + if (err != error.Disabled) { + std.log.warn("herdr-core: starting a local shell instead ({s})", .{@errorName(err)}); + } + // The grid's size rather than `cols` and `rows`: a reattach that got as + // far as taking its terminal's size before failing has already moved it. + self.backend = .{ .local = try .create(alloc, shell, &.{argv0}, cwd_z, env_extra, .{ + .ws_row = self.term.rows, + .ws_col = self.term.cols, + }) }; - // Out of the app's own cgroup, so that the kernel killing something this - // shell runs is not also systemd's cue to kill the app. See `scope`. - scope.adopt(self.pty.pid); + // Out of the app's own cgroup, so that the kernel killing something this + // shell runs is not also systemd's cue to kill the app. See `scope`. + // A herdr-core terminal needs none of this: the core already runs its + // programs in services of their own, outside the app's scope. + scope.adopt(self.backend.local.pid); + + self.watch = glibunix.fdAdd( + self.backend.local.master, + .{ .in = true, .hup = true, .err = true }, + &onReadable, + self, + ); + } + errdefer self.stopBackend(); if (opts.command.len > 0) { self.startup_command = try alloc.dupe(u8, opts.command); + if (self.backend == .hc) { + self.flush_timer = glib.timeoutAddOnce(flush_fallback_ms, &onFlushTimer, self); + } } - self.watch = glibunix.fdAdd( - self.pty.master, - .{ .in = true, .hup = true, .err = true }, - &onReadable, - self, - ); - return self; } pub fn destroy(self: *Session) void { if (self.startup_command) |cmd| self.alloc.free(cmd); - if (self.watch != 0) _ = glib.Source.remove(self.watch); - self.pty.deinit(); + if (self.flush_timer != 0) _ = glib.Source.remove(self.flush_timer); + self.stopBackend(); self.stream.deinit(); self.term.deinit(self.alloc); self.alloc.destroy(self); @@ -268,7 +348,7 @@ fn onReadable( if (condition.in) { var buf: [read_buf_size]u8 = undefined; - if (self.pty.read(&buf)) |n| { + if (self.backend.local.read(&buf)) |n| { self.stream.nextSlice(buf[0..n]); self.flushStartupCommand(); self.on_damage(self.ctx); @@ -300,14 +380,14 @@ fn flushStartupCommand(self: *Session) void { self.startup_command = null; defer self.alloc.free(command); - self.pty.writeAll(command); - self.pty.writeAll("\n"); + self.write(command); + self.write("\n"); } /// Effect callback: the terminal wants to send bytes back to the child. fn effectWritePty(handler: *vt.TerminalStream.Handler, data: []const u8) void { const self = fromHandler(handler); - self.pty.writeAll(data); + self.write(data); } /// Effect callback: OSC 0/2 changed the window title. @@ -399,6 +479,11 @@ fn fromHandler(handler: *vt.TerminalStream.Handler) *Session { /// terminal owns. If that copy can't be made, fall back to the built-in /// palette, which never allocates, rather than keep the previous scheme's. pub fn refreshPalette(self: *Session) void { + self.applyPalette(); + self.sendFacts(); +} + +fn applyPalette(self: *Session) void { const palette = &self.term.colors.palette; palette.changeDefault(self.alloc, theme.ansiPalette()) catch |err| { std.log.err("could not change the palette: {s}", .{@errorName(err)}); @@ -408,14 +493,36 @@ pub fn refreshPalette(self: *Session) void { /// Send user input to the child. pub fn write(self: *Session, bytes: []const u8) void { - self.pty.writeAll(bytes); + switch (self.backend) { + .local => |pty| pty.writeAll(bytes), + .hc => |r| if (r.channel) |ch| hc.input(ch, bytes), + } +} + +/// The program's current working directory, copied into `buf`, or null if it +/// can't be read. See `Pty.cwd`. +pub fn cwd(self: *Session, buf: []u8) ?[]const u8 { + return switch (self.backend) { + .local => |pty| pty.cwd(buf), + .hc => |r| if (r.pid > 0) Pty.cwdOf(r.pid, buf) else null, + }; +} + +/// The herdr-core terminal this session shows, or null for a local one. +pub fn hcAddress(self: *const Session) ?[]const u8 { + return switch (self.backend) { + .local => null, + .hc => |r| r.address, + }; } /// Resize the terminal grid and tell the child about it. pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !void { // Recorded before the early return: the grid can keep its dimensions // across a font change that moves every pixel measurement. + const cell_changed = self.cell_px.w != cell_w or self.cell_px.h != cell_h; self.cell_px = .{ .w = cell_w, .h = cell_h }; + if (cell_changed) self.sendFacts(); if (cols == self.term.cols and rows == self.term.rows) return; @@ -425,10 +532,304 @@ pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !v .cell_size_px = .{ .width = cell_w, .height = cell_h }, }); - self.pty.setSize(.{ - .ws_row = rows, - .ws_col = cols, - .ws_xpixel = @intCast(cell_w * cols), - .ws_ypixel = @intCast(cell_h * rows), - }); + switch (self.backend) { + .local => |pty| pty.setSize(.{ + .ws_row = rows, + .ws_col = cols, + .ws_xpixel = @intCast(cell_w * cols), + .ws_ypixel = @intCast(cell_h * rows), + }), + .hc => self.scheduleRemoteResize(), + } +} + +/// Tell a herdr-core program its new size once the size has settled. +/// +/// A local shell is told every step, and that has never mattered: a pane goes +/// through a few sizes on its way to its place in a layout — full width, then +/// its share of a split — but a new shell has printed nothing by then. A +/// restored one has a screen full of output, and a shell answers each resize +/// by redrawing its prompt in place; a prompt that wraps at one width and not +/// at the next is redrawn over the lines above it. One resize, to where the +/// pane actually ends up, leaves them alone. Dragging a divider gets the same +/// treatment, which spares the program a redraw per pixel. +fn scheduleRemoteResize(self: *Session) void { + const r = &self.backend.hc; + if (r.resize_timer != 0) _ = glib.Source.remove(r.resize_timer); + r.resize_timer = glib.timeoutAddOnce(remote_resize_settle_ms, &onRemoteResize, self); +} + +fn onRemoteResize(data: ?*anyopaque) callconv(.c) void { + const self: *Session = @ptrCast(@alignCast(data.?)); + const r = &self.backend.hc; + r.resize_timer = 0; + const ch = r.channel orelse return; + if (r.sent_cols == self.term.cols and r.sent_rows == self.term.rows) return; + hc.resize(ch, self.term.cols, self.term.rows); + r.sent_cols = self.term.cols; + r.sent_rows = self.term.rows; +} + +// ------------------------------------------------------------------------- +// herdr-core + +/// Attach to the terminal `opts` names, or start a new one, or say why not. +/// `error.Disabled` means the setting is off and there was nothing to attach +/// to: the ordinary way a local shell gets made, not a failure. +fn startRemote( + self: *Session, + opts: Options, + cols: u16, + rows: u16, + shell: [:0]const u8, + cwd_z: ?[:0]const u8, + env_extra: []const []const u8, +) !Remote { + if (opts.hc.len > 0) { + if (self.reattach(opts.hc)) |remote| { + return remote; + } else |err| switch (err) { + // Gone while the app was away: start afresh, as the pane would + // have without the snapshot remembering it. + error.NotFound, error.Finished => {}, + else => std.log.warn("herdr-core: could not reattach {s}: {s}", .{ opts.hc, @errorName(err) }), + } + } + + if (!Settings.get().herdr_core) return error.Disabled; + + var env: std.ArrayListUnmanaged([]const u8) = .empty; + defer env.deinit(self.alloc); + try remoteEnvironment(self.alloc, &env, env_extra); + + // `-l` rather than the local pty's `-zsh` argv[0]: the core takes a + // program to find on PATH, not a path and a separate argv[0], and every + // shell worth starting reads its profile for `-l` all the same. + const argv = [_][]const u8{ shell, "-l" }; + + // The app's own directory stands in for none, as it does for a local + // shell, which simply inherits it. Left out, the core would use its own. + var here_buf: [std.fs.max_path_bytes]u8 = undefined; + const here: ?[]const u8 = if (std.c.getcwd(&here_buf, here_buf.len)) |p| + std.mem.span(@as([*:0]u8, @ptrCast(p))) + else + null; + + const started = hc.run(self.alloc, .{ + .argv = &argv, + .cwd = if (cwd_z) |z| z else here, + .env = env.items, + }) catch |err| switch (err) { + // A directory that no longer exists. The local pty starts in the app's + // own directory in that case; so does this. + error.SpawnFailed => if (cwd_z != null) try hc.run(self.alloc, .{ + .argv = &argv, + .cwd = here, + .env = env.items, + }) else return err, + else => return err, + }; + errdefer { + hc.kill(started.address); + self.alloc.free(started.address); + } + + const att = try hc.attach(started.address, cols, rows, 0, self.display(), self.hcStream()); + return .{ + .address = started.address, + .channel = att.channel, + .pid = if (att.pid > 0) att.pid else started.pid, + .paints_to_skip = 1, + .sent_cols = cols, + .sent_rows = rows, + }; +} + +/// Attach to a terminal that was running before this pane existed. +/// +/// At the terminal's own size rather than the placeholder this session was +/// made at: the scrollback arrives laid out for whatever size the attach asks +/// for, so asking for 80 columns would hard-wrap every longer line in it. At +/// its own size, the lines arrive as they were drawn, and when the widget then +/// gives the grid its real size they reflow like any other resize. +fn reattach(self: *Session, address: []const u8) !Remote { + const facts = try hc.get(address); + switch (facts.state) { + .live => {}, + .finished => return error.Finished, + .gone => return error.NotFound, + } + if (facts.cols > 0 and facts.rows > 0) { + try self.stream.handler.resize(.{ + .cols = facts.cols, + .rows = facts.rows, + .cell_size_px = .{ .width = self.cell_px.w, .height = self.cell_px.h }, + }); + } + + const att = try hc.attach(address, self.term.cols, self.term.rows, hc.restore_history, self.display(), self.hcStream()); + return .{ + .address = try self.alloc.dupe(u8, address), + .channel = att.channel, + .pid = att.pid, + .sent_cols = self.term.cols, + .sent_rows = self.term.rows, + }; +} + +/// The environment a herdr-core shell is started with: this process's, as the +/// local pty would hand on, on top of the core's own. +/// +/// Passed whole rather than left to the core, whose environment is whatever +/// the session that first started it had — not necessarily the one this app +/// is running in. `HC_*` is dropped, since those describe a terminal of the +/// core's and would be wrong if this app was itself started from one. +fn remoteEnvironment( + alloc: std.mem.Allocator, + out: *std.ArrayListUnmanaged([]const u8), + extra: []const []const u8, +) !void { + var i: usize = 0; + while (std.c.environ[i]) |entry| : (i += 1) { + const span = std.mem.span(entry); + if (std.mem.startsWith(u8, span, "TERM=")) continue; + if (std.mem.startsWith(u8, span, "COLORTERM=")) continue; + if (std.mem.startsWith(u8, span, "HC_")) continue; + if (Pty.shadowedBy(span, extra)) continue; + try out.append(alloc, span); + } + try out.append(alloc, "TERM=xterm-256color"); + try out.append(alloc, "COLORTERM=truecolor"); + try out.appendSlice(alloc, extra); +} + +fn stopBackend(self: *Session) void { + switch (self.backend) { + .local => |*pty| { + if (self.watch != 0) _ = glib.Source.remove(self.watch); + self.watch = 0; + pty.deinit(); + }, + .hc => |*r| { + if (r.timer != 0) _ = glib.Source.remove(r.timer); + r.timer = 0; + if (r.resize_timer != 0) _ = glib.Source.remove(r.resize_timer); + r.resize_timer = 0; + if (r.channel) |ch| hc.detach(ch); + r.channel = null; + + // The one decision that makes this backend worth having. Closing a + // pane is asking for its program to go; the app quitting, or + // crashing, is not — see `hc.detaching`. + if (!hc.detaching and !self.exited) hc.kill(r.address); + self.alloc.free(r.address); + }, + } +} + +fn hcStream(self: *Session) hc.Stream { + return .{ .on_data = &onHcData, .on_end = &onHcEnd, .ctx = self }; +} + +/// What the core answers the program's display queries with: what this pane +/// actually looks like. +fn display(self: *const Session) hc.Display { + const rgb = struct { + fn of(c: theme.Rgb) hc.Rgb { + return .{ .r = c.r, .g = c.g, .b = c.b }; + } + }.of; + return .{ + .fg = rgb(theme.fg()), + .bg = rgb(theme.bg()), + .cursor = rgb(theme.cursor()), + .cell = .{ self.cell_px.w, self.cell_px.h }, + }; +} + +fn sendFacts(self: *Session) void { + switch (self.backend) { + .local => {}, + .hc => |r| if (r.channel) |ch| hc.facts(ch, self.display()), + } +} + +fn onHcData(ctx: *anyopaque, bytes: []const u8) void { + const self: *Session = @ptrCast(@alignCast(ctx)); + self.stream.nextSlice(bytes); + + const r = &self.backend.hc; + if (r.paints_to_skip > 0) { + r.paints_to_skip -= 1; + } else { + self.flushStartupCommand(); + } + self.on_damage(self.ctx); +} + +fn onFlushTimer(data: ?*anyopaque) callconv(.c) void { + const self: *Session = @ptrCast(@alignCast(data.?)); + self.flush_timer = 0; + self.flushStartupCommand(); +} + +/// The stream ended. Whether that is the program exiting is a question for the +/// core, asked from a timer: this runs inside the connection's delivery loop, +/// which must not make calls of its own. +fn onHcEnd(ctx: *anyopaque, why: hc.End) void { + const self: *Session = @ptrCast(@alignCast(ctx)); + const r = &self.backend.hc; + r.channel = null; + + if (why == .lost and r.attempts == 0) { + // Said once, in the pane, so a terminal that has stopped answering + // does not look like a hung program. The reattach repaints over it. + self.stream.nextSlice("\r\n\x1b[2m[playpen: lost herdr-core, reconnecting\xe2\x80\xa6]\x1b[0m\r\n"); + self.on_damage(self.ctx); + } + self.scheduleRecheck(); +} + +fn scheduleRecheck(self: *Session) void { + const r = &self.backend.hc; + if (r.timer != 0) return; + // Quick the first time, which is the common case of a program exiting, + // then backing off to every few seconds for a core that is down. + const shift: u5 = @intCast(@min(r.attempts, 5)); + const delay: c_uint = @min(@as(c_uint, 50) << shift, 5000); + r.timer = glib.timeoutAddOnce(delay, &onRecheck, self); +} + +fn onRecheck(data: ?*anyopaque) callconv(.c) void { + const self: *Session = @ptrCast(@alignCast(data.?)); + const r = &self.backend.hc; + r.timer = 0; + + const facts = hc.get(r.address) catch { + r.attempts += 1; + self.scheduleRecheck(); + return; + }; + + switch (facts.state) { + // Still running: the stream ended for some other reason — the + // connection dropped, or a kept terminal's shell was replaced. The + // emulator already holds the scrollback, so only the screen is asked for. + .live => { + const att = hc.attach(r.address, self.term.cols, self.term.rows, 0, self.display(), self.hcStream()) catch { + r.attempts += 1; + self.scheduleRecheck(); + return; + }; + r.channel = att.channel; + if (att.pid > 0) r.pid = att.pid; + r.attempts = 0; + r.sent_cols = self.term.cols; + r.sent_rows = self.term.rows; + }, + .finished, .gone => { + self.exited = true; + self.on_exit(self.ctx); + }, + } } diff --git a/src/Settings.zig b/src/Settings.zig index 8b0fdd7..22cc1ff 100644 --- a/src/Settings.zig +++ b/src/Settings.zig @@ -137,6 +137,16 @@ theme: Theme = .system, /// a mistyped chord and every shell in every tab exiting. confirm_quit: bool = true, +/// Whether new terminals run in herdr-core (`hc`) rather than in a pty of the +/// app's own. +/// +/// Off by default: it needs `hc` installed, and it changes what quitting +/// means. With it on, a shell's program belongs to the core, so closing the +/// window — or the app crashing — leaves every shell and agent running, and +/// the next launch restores the session without asking, attaching to them +/// again. Closing a pane or a tab still ends what is in it. See `hc.zig`. +herdr_core: bool = false, + /// Whether a pane finishing posts a desktop notification. /// /// On by default. The whole shape of this app is "set something going in a tab @@ -268,6 +278,10 @@ fn parse(self: *Settings, text: []const u8) ParseError!void { if (value == .bool) self.confirm_quit = value.bool; } + if (root.get("herdr_core")) |value| { + if (value == .bool) self.herdr_core = value.bool; + } + if (root.get("notifications")) |value| { if (value == .bool) self.notifications = value.bool; } @@ -623,6 +637,8 @@ fn serialize(self: *Settings) SaveError![]u8 { try json.write(@tagName(self.theme)); try json.objectField("confirm_quit"); try json.write(self.confirm_quit); + try json.objectField("herdr_core"); + try json.write(self.herdr_core); try json.objectField("notifications"); try json.write(self.notifications); diff --git a/src/SettingsDialog.zig b/src/SettingsDialog.zig index aede9f2..0eb9168 100644 --- a/src/SettingsDialog.zig +++ b/src/SettingsDialog.zig @@ -36,6 +36,7 @@ const PaletteEditor = @import("PaletteEditor.zig"); const PathEntry = @import("PathEntry.zig"); const Settings = @import("Settings.zig"); const appearance = @import("appearance.zig"); +const hc = @import("hc.zig"); const SettingsDialog = @This(); @@ -112,6 +113,9 @@ confirm_quit: *gtk.Switch, /// The notifications toggle, held for the same reason. notifications: *gtk.Switch, +/// The run-terminals-in-herdr-core toggle, held for the same reason. +herdr_core: *gtk.Switch, + /// Container the startup rows live in, so rows can be added and removed after /// the page is already on screen. startup_box: *gtk.Box, @@ -149,6 +153,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo .colors = undefined, .confirm_quit = gtk.Switch.new(), .notifications = gtk.Switch.new(), + .herdr_core = gtk.Switch.new(), .startup_box = gtk.Box.new(.vertical, 6), .error_label = gtk.Label.new(null), }; @@ -407,7 +412,8 @@ fn onNotificationsChanged( self.showError(null); } -/// The Quitting section: whether closing the window asks first. +/// The Quitting section: whether closing the window asks first, and whether +/// closing it ends the shells at all. /// /// Above the startup list rather than below it. That list grows with the number /// of tabs someone opens at launch, and a single-row group underneath it would @@ -421,30 +427,7 @@ fn buildQuitting(self: *SettingsDialog) *gtk.Widget { title.as(gtk.Widget).addCssClass("playpen-settings-title"); group.append(title.as(gtk.Widget)); - const row = gtk.Box.new(.horizontal, 12); - - const labels = gtk.Box.new(.vertical, 2); - labels.as(gtk.Widget).setHexpand(1); - labels.as(gtk.Widget).setValign(.center); - - const name = gtk.Label.new("Confirm before closing the window"); - name.setXalign(0); - name.as(gtk.Widget).addCssClass("playpen-dialog-label"); - labels.append(name.as(gtk.Widget)); - - const hint_label = gtk.Label.new( - "Closing the window exits every shell in every tab. Off, the window " ++ - "goes the moment the window manager says so.", - ); - hint_label.setXalign(0); - hint_label.setWrap(1); - hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); - labels.append(hint_label.as(gtk.Widget)); - - row.append(labels.as(gtk.Widget)); - self.confirm_quit.setActive(@intFromBool(Settings.get().confirm_quit)); - self.confirm_quit.as(gtk.Widget).setValign(.center); _ = gobject.Object.signals.notify.connect( self.confirm_quit, *SettingsDialog, @@ -452,13 +435,83 @@ fn buildQuitting(self: *SettingsDialog) *gtk.Widget { self, .{ .detail = "active" }, ); - row.append(self.confirm_quit.as(gtk.Widget)); + group.append(switchRow( + "Confirm before closing the window", + "Closing the window exits every shell in every tab. Off, the window " ++ + "goes the moment the window manager says so.", + self.confirm_quit, + )); - group.append(row.as(gtk.Widget)); + // Said up front when `hc` is missing, rather than letting the switch turn + // on and every new shell quietly come up local anyway. + const installed = hc.installed(); + self.herdr_core.setActive(@intFromBool(Settings.get().herdr_core)); + _ = gobject.Object.signals.notify.connect( + self.herdr_core, + *SettingsDialog, + &onHerdrCoreChanged, + self, + .{ .detail = "active" }, + ); + group.append(switchRow( + "Keep shells running after the window closes", + if (installed) + "New shells run in herdr-core, so quitting or a crash leaves them " ++ + "running, and the next launch reattaches to them in place of the " ++ + "startup tabs. Closing a pane or tab still ends it." + else + "Needs herdr-core: hc was not found on PATH or in ~/.local/bin. " ++ + "Until it is, new shells run here as usual.", + self.herdr_core, + )); return group.as(gtk.Widget); } +/// A setting that is one switch: its name and a line of explanation on the +/// left, the switch on the right. +fn switchRow(name_text: [:0]const u8, hint_text: [:0]const u8, toggle: *gtk.Switch) *gtk.Widget { + const row = gtk.Box.new(.horizontal, 12); + + const labels = gtk.Box.new(.vertical, 2); + labels.as(gtk.Widget).setHexpand(1); + labels.as(gtk.Widget).setValign(.center); + + const name = gtk.Label.new(name_text); + name.setXalign(0); + name.as(gtk.Widget).addCssClass("playpen-dialog-label"); + labels.append(name.as(gtk.Widget)); + + const hint_label = gtk.Label.new(hint_text); + hint_label.setXalign(0); + hint_label.setWrap(1); + hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); + labels.append(hint_label.as(gtk.Widget)); + + row.append(labels.as(gtk.Widget)); + + toggle.as(gtk.Widget).setValign(.center); + row.append(toggle.as(gtk.Widget)); + + return row.as(gtk.Widget); +} + +/// Saved straight away like the others. Only shells opened from now on are +/// affected: the ones already open stay where they are running. +fn onHerdrCoreChanged( + toggle: *gtk.Switch, + _: *gobject.ParamSpec, + self: *SettingsDialog, +) callconv(.c) void { + Settings.get().herdr_core = toggle.getActive() != 0; + + Settings.get().save() catch { + self.showError("Could not write the settings file."); + return; + }; + self.showError(null); +} + /// Straight to the file, as the theme picker does, rather than through /// `persist`: this is one flag, and `commit` would re-read every startup row to /// save it. diff --git a/src/View.zig b/src/View.zig index d22b7b7..15e2eae 100644 --- a/src/View.zig +++ b/src/View.zig @@ -562,7 +562,11 @@ fn paneSpec( const cwd = try Layouts.expandPath(self.alloc, p.cwd, bindings); errdefer self.alloc.free(cwd); const command = try Layouts.expand(self.alloc, p.command, bindings); - break :blk .{ .terminal = .{ .cwd = cwd, .command = command } }; + errdefer self.alloc.free(command); + // Taken literally: an address is not something a parameter can + // stand in for, and only a snapshot ever has one. + const hc = try self.alloc.dupe(u8, p.hc); + break :blk .{ .terminal = .{ .cwd = cwd, .command = command, .hc = hc } }; }, .web => .{ .web = .{ .url = try Layouts.expand(self.alloc, p.url, bindings), @@ -580,6 +584,7 @@ fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void { .terminal => |o| { alloc.free(o.cwd); alloc.free(o.command); + alloc.free(o.hc); }, .web => |o| alloc.free(o.url), // Borrowed from the window, which owns the strings for as long as the @@ -594,19 +599,24 @@ fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void { /// they are on screen. What each pane should *run* can't be known from a live /// pane, so terminals come back with their current directory and an empty /// command for the user to fill in; web panes bring their current URL. -pub fn capture(self: *View, builder: Layouts.Builder) !?*Layouts.Node { +/// +/// `running` is for the session snapshot, which also records the herdr-core +/// terminal each terminal pane is attached to so a restore can reattach to it. +/// A saved layout leaves that out: it is a recipe for new shells. +pub fn capture(self: *View, builder: Layouts.Builder, running: bool) !?*Layouts.Node { const root = self.layout.root orelse return null; - return try self.captureNode(root, builder); + return try self.captureNode(root, builder, running); } -fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layouts.Node { +fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder, running: bool) !*Layouts.Node { return switch (node.kind) { .leaf => |pane| switch (pane.content) { .terminal => |t| blk: { var buf: [std.fs.max_path_bytes]u8 = undefined; break :blk try builder.pane(.{ .kind = .terminal, - .cwd = t.session.pty.cwd(&buf) orelse "", + .cwd = t.session.cwd(&buf) orelse "", + .hc = if (running) t.session.hcAddress() orelse "" else "", }); }, .web => |b| try builder.pane(.{ @@ -628,8 +638,8 @@ fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layo else => .horizontal, }, s.ratio, - try self.captureNode(s.a, builder), - try self.captureNode(s.b, builder), + try self.captureNode(s.a, builder, running), + try self.captureNode(s.b, builder, running), ), }; } diff --git a/src/Window.zig b/src/Window.zig index b56cc7a..2321f68 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -29,6 +29,7 @@ const Terminal = @import("Terminal.zig"); const View = @import("View.zig"); const appearance = @import("appearance.zig"); const emoji = @import("emoji.zig"); +const hc = @import("hc.zig"); const key = @import("key.zig"); const notify = @import("notify.zig"); const review = @import("review.zig"); @@ -499,12 +500,18 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { appearance.onChanged(&onAppearanceChanged, self); - try self.openStartupTabs(); + // With shells in herdr-core, the last session is not a photograph to be + // offered but the shells themselves, still running — so it simply comes + // back, in place of the startup tabs rather than after them. With nothing + // to restore, or nothing that would build, it is an ordinary launch. + const herdr = Settings.get().herdr_core; + if (!(herdr and self.restoreAtLaunch())) try self.openStartupTabs(); // After the startup tabs, not before: the offer is withdrawn by anything // that changes the tab set, and the window opening its own tabs would - // otherwise withdraw it before it was ever made. - self.offerRestore(); + // otherwise withdraw it before it was ever made. Never with herdr-core on: + // whatever there was to restore has already been restored. + if (!herdr) self.offerRestore(); self.autosave = glib.timeoutAddSeconds(autosave_interval_s, &onAutosave, self); for (terminate_signals, &self.signal_sources) |signum, *source| { @@ -1133,7 +1140,7 @@ fn onSaveLayoutClicked(_: *gtk.Button, self: *Window) callconv(.c) void { fn saveCurrentTabAsLayout(self: *Window) void { const tab = self.activeTab() orelse return; - const root = tab.view.capture(self.layouts.builder()) catch |err| { + const root = tab.view.capture(self.layouts.builder(), false) catch |err| { std.log.err("failed to capture layout: {s}", .{@errorName(err)}); return; } orelse return; @@ -1549,6 +1556,34 @@ fn restoreSession(self: *Window) void { }; defer self.alloc.free(previous); + // Nothing was built, so nothing is closed and the window is exactly as it + // was — only the banner has gone. + const restored = self.buildSnapshotTabs() orelse return; + + self.select(restored); + for (previous) |tab| self.discardTab(tab); +} + +/// The launch-time restore herdr-core gets instead of the banner: the snapshot +/// read and built straight into an empty window. False when there was nothing +/// to restore or none of it would build, leaving the window empty for the +/// startup tabs. +/// +/// Built into an empty window rather than over the startup tabs the way the +/// banner's restore is, since there is no question to wait on. Opening the +/// startup tabs first would start a herdr-core shell for each of them only to +/// kill it a moment later. +fn restoreAtLaunch(self: *Window) bool { + self.snapshot.load(); + if (!self.snapshot.any()) return false; + + const restored = self.buildSnapshotTabs() orelse return false; + self.select(restored); + return true; +} + +/// Build every tab in the snapshot, returning the first, or null if none built. +fn buildSnapshotTabs(self: *Window) ?*Tab { var first: ?*Tab = null; for (self.snapshot.tabs.items) |entry| { const tab = self.buildSnapshotTab(entry) catch |err| { @@ -1563,16 +1598,10 @@ fn restoreSession(self: *Window) void { if (first == null) first = tab; } - const restored = first orelse { - // Nothing was built, so nothing is closed and the window is exactly as - // it was — only the banner has gone. Reaching here takes an allocation - // failure per tab, at which point there is nothing better to offer. - std.log.err("nothing in the session snapshot could be reopened", .{}); - return; - }; - - self.select(restored); - for (previous) |tab| self.discardTab(tab); + // Reaching here with nothing takes an allocation failure per tab, at which + // point there is nothing better to offer. + if (first == null) std.log.err("nothing in the session snapshot could be reopened", .{}); + return first; } /// One tab out of the snapshot. @@ -1632,7 +1661,7 @@ fn saveSnapshot(self: *Window) void { defer values.deinit(self.alloc); for (self.tabs.items) |tab| { - const root = tab.view.capture(snapshot.builder()) catch |err| { + const root = tab.view.capture(snapshot.builder(), true) catch |err| { std.log.warn("could not capture a tab for the snapshot: {s}", .{@errorName(err)}); continue; } orelse continue; @@ -2351,17 +2380,31 @@ fn askBeforeQuitting(self: *Window) void { // it is also the quickest way to notice you are about to close the wrong // window. The buffer is sized so the format cannot fail, but the fallback // says the same thing without the number rather than nothing at all. + // + // With shells in herdr-core, the second half would be untrue — and untrue + // in the direction that makes quitting look costlier than it is. var buf: [160]u8 = undefined; - const body: [:0]const u8 = std.mem.printSentinel( - &buf, - "{d} tab{s} will close, and every shell in {s} will exit.", - .{ - self.tabs.items.len, - if (self.tabs.items.len == 1) "" else "s", - if (self.tabs.items.len == 1) "it" else "them", - }, - 0, - ) catch "Every tab will close, and every shell in them will exit."; + const body: [:0]const u8 = if (Settings.get().herdr_core) + std.mem.printSentinel( + &buf, + "{d} tab{s} will close. Shells in herdr-core keep running and come back on the next launch.", + .{ + self.tabs.items.len, + if (self.tabs.items.len == 1) "" else "s", + }, + 0, + ) catch "Every tab will close. Shells in herdr-core keep running." + else + std.mem.printSentinel( + &buf, + "{d} tab{s} will close, and every shell in {s} will exit.", + .{ + self.tabs.items.len, + if (self.tabs.items.len == 1) "" else "s", + if (self.tabs.items.len == 1) "it" else "them", + }, + 0, + ) catch "Every tab will close, and every shell in them will exit."; dialog.setBody(body.ptr); dialog.addResponse("cancel", "Keep Working"); @@ -2415,6 +2458,11 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { if (source != 0) _ = glib.Source.remove(source); } + // From here on the app is going away, not closing panes: herdr-core + // terminals are detached from, not killed, so they are still there for the + // snapshot below to be restored into. See `hc.detaching`. + hc.detaching = true; + // First, while every tab is still whole and every shell still running: the // directories in the snapshot are read out of live processes. Written even // if the last autosave took the same picture, since the exit is the one @@ -2640,12 +2688,12 @@ fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 { if (tab.view.focusedPane()) |focused| { if (focused.terminal()) |terminal| { - if (terminal.session.pty.cwd(buf)) |dir| return dir; + if (terminal.session.cwd(buf)) |dir| return dir; } } for (tab.view.panes.items) |pane| { const terminal = pane.terminal() orelse continue; - if (terminal.session.pty.cwd(buf)) |dir| return dir; + if (terminal.session.cwd(buf)) |dir| return dir; } // Playpen's own directory, which at least gives the server something to diff --git a/src/hc.zig b/src/hc.zig new file mode 100644 index 0000000..77c7a4d --- /dev/null +++ b/src/hc.zig @@ -0,0 +1,827 @@ +//! A connection to herdr-core, the terminal runtime behind `hc`, so that a +//! terminal pane's program can live in the core instead of in this process. +//! +//! What that buys is the one thing a pty owned by the app cannot give: the +//! programs outlive the window. The core holds each terminal (its process, its +//! screen, its scrollback) in a service of its own, and a client attaches to +//! one to see it and type into it. Quit playpen, or have it crash, or have +//! systemd kill its scope, and every shell and agent keeps going. The session +//! snapshot remembers which terminal each pane was attached to, and restoring +//! it attaches again. +//! +//! The connection is `hc connect`: the core's JSON-RPC API over the child's +//! stdin and stdout, switched to framed mode by `hello` (see `hc/frame.zig`). +//! Channel 0 carries the RPC; each attached terminal gets a channel of its own +//! for its raw byte stream. One connection serves every pane in the app. +//! +//! Everything runs on the GLib main loop, the same as the local pty, so the +//! terminal state stays single-threaded. RPCs are synchronous — a pane being +//! created needs its terminal's address before it can do anything — and wait +//! on the socket with a deadline rather than spinning the main loop. Frames +//! for other channels that arrive during the wait are left in the buffer and +//! delivered from an idle callback afterwards, never from inside the call, +//! so no stream callback can run while some other code is halfway through +//! creating a pane. +//! +//! The child's stdin and stdout are one end of a socketpair rather than two +//! pipes, so writes can pass `MSG_NOSIGNAL`. A pipe whose reader has died +//! raises SIGPIPE, which would kill the app; ignoring the signal process-wide +//! instead would leak into every shell the local pty starts, since an ignored +//! signal stays ignored across exec. + +const std = @import("std"); +const glib = @import("glib"); +const glibunix = @import("glibunix"); + +const frame = @import("hc/frame.zig"); + +/// Everything here lives as long as the connection, which is not tied to any +/// one pane, so it takes libc's allocator rather than borrowing a caller's. +const gpa = std.heap.c_allocator; + +const c = struct { + extern "c" fn socketpair(domain: c_int, kind: c_int, protocol: c_int, fds: *[2]c_int) c_int; + extern "c" fn send(fd: c_int, buf: [*]const u8, len: usize, flags: c_int) isize; + extern "c" fn recv(fd: c_int, buf: [*]u8, len: usize, flags: c_int) isize; + extern "c" fn close(fd: c_int) c_int; + extern "c" fn poll(fds: [*]PollFd, n: c_ulong, timeout: c_int) c_int; + extern "c" fn access(path: [*:0]const u8, mode: c_int) c_int; + extern "c" fn __errno_location() *c_int; +}; + +const PollFd = extern struct { fd: c_int, events: c_short, revents: c_short = 0 }; + +const AF_UNIX = 1; +const SOCK_STREAM = 1; +const SOCK_CLOEXEC = 0o2000000; +const MSG_DONTWAIT = 0x40; +const MSG_NOSIGNAL = 0x4000; +const POLLIN = 0x1; +const POLLOUT = 0x4; +const EINTR = 4; +const EAGAIN = 11; +const X_OK = 1; + +/// The protocol generation this client speaks. +const generation = 1; + +/// How long an RPC may take before the app stops waiting for it. Long enough +/// for a core that is starting up; short enough that a wedged one freezes the +/// window for a moment rather than for good. +const call_timeout_ms = 5000; + +/// How long to wait before trying to connect again after failing to. Without +/// it, a broken core would cost every new pane the whole of `call_timeout_ms`. +const retry_after_failure_us = 5 * std.time.us_per_s; + +/// Rows of scrollback asked for when attaching to a terminal this app has not +/// seen before — one being restored from the snapshot. Matches herdr's own app. +pub const restore_history = 2000; + +/// Set by the window as it tears down. While it is set, a terminal pane being +/// destroyed detaches from its program rather than killing it: the app is +/// going away, and the programs are meant to outlive it. At any other time, +/// destroying a pane is closing it, and its program goes with it. +pub var detaching = false; + +/// What an attached terminal's owner hears back. +pub const Stream = struct { + /// Output from the program, to be fed to the emulator. + on_data: *const fn (ctx: *anyopaque, bytes: []const u8) void, + + /// The stream is over. The owner decides what that means by asking the + /// core — see `End`. + on_end: *const fn (ctx: *anyopaque, why: End) void, + + ctx: *anyopaque, +}; + +pub const End = enum { + /// The core ended this stream. Usually the program exited, but a kept + /// terminal whose program was lost restarts with a fresh shell and ends + /// its streams too, so this is a cue to ask, not an answer. + closed, + + /// The connection to the core dropped. The program is most likely still + /// running — a core restart keeps them — so the owner should reattach. + lost, +}; + +pub const Error = error{ + /// No core: `hc` is not installed, or the core would not answer. + Unavailable, + /// The connection dropped mid-call. + Lost, + Timeout, + /// The terminal does not exist (any more). + NotFound, + /// The terminal exists but its program has ended. + Finished, + /// The core could not start the program. + SpawnFailed, + /// Any other refusal, or a reply we could not read. + Refused, + OutOfMemory, +}; + +pub const Rgb = struct { r: u8, g: u8, b: u8 }; + +/// What the viewer looks like. The core answers the program's display +/// queries (colors, light or dark, cell size) from this rather than passing +/// them through, so it has to be told. +pub const Display = struct { + fg: Rgb, + bg: Rgb, + cursor: Rgb, + cell: [2]u32, +}; + +/// A terminal just started in the core. +pub const Started = struct { + /// Its address, `t7`. Owned by the caller's allocator. + address: []u8, + pid: i32, +}; + +/// A terminal just attached to. +pub const Attached = struct { + channel: u32, + pid: i32, +}; + +pub const RunOptions = struct { + argv: []const []const u8, + cwd: ?[]const u8 = null, + /// `NAME=value`, set on top of the core's own environment. + env: []const []const u8 = &.{}, +}; + +// ------------------------------------------------------------------------- +// The connection + +const Core = struct { + /// Our end of the socketpair the `hc connect` child talks on. + fd: c_int, + + watch: c_uint = 0, + + /// Pending delivery of frames read during a synchronous call. + idle: c_uint = 0, + + /// Bytes read and not yet consumed, from `head` on. + buf: std.ArrayListUnmanaged(u8) = .empty, + head: usize = 0, + + next_id: u64 = 1, + + streams: std.AutoHashMapUnmanaged(u32, Stream) = .empty, + + /// Set while `dispatch` runs, which must not nest. + dispatching: bool = false, +}; + +var core: ?*Core = null; + +/// When a failed connect may next be retried, in GLib monotonic microseconds. +var retry_at: i64 = 0; + +/// Scratch for one frame's payload while it is delivered. A stream callback +/// can write, and a write can read more into `buf` and move it, so the payload +/// cannot be handed out as a slice of `buf`. +var scratch: std.ArrayListUnmanaged(u8) = .empty; + +/// The connection, made if there is none. Null when there is no core to be +/// had, which callers take as "use a local pty". +fn connection() ?*Core { + if (core) |k| return k; + const now = glib.getMonotonicTime(); + if (now < retry_at) return null; + core = connect() catch |err| { + retry_at = now + retry_after_failure_us; + std.log.warn("herdr-core: could not connect ({s}); terminals run locally", .{@errorName(err)}); + return null; + }; + return core; +} + +/// Whether `hc` is installed where this app would look for it. Says nothing +/// about whether its core will answer. +pub fn installed() bool { + var buf: [std.fs.max_path_bytes]u8 = undefined; + return findBinary(&buf) != null; +} + +/// Find `hc`. On PATH first, then where its installer puts it — a desktop +/// launcher's PATH often has no `~/.local/bin` on it. +fn findBinary(buf: []u8) ?[:0]const u8 { + for ([_][*:0]const u8{ "hc", "herdr-core" }) |name| { + const found = glib.findProgramInPath(name) orelse continue; + defer glib.free(found); + return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(found)}, 0) catch null; + } + const home = std.mem.span(glib.getHomeDir()); + for ([_][]const u8{ "hc", "herdr-core" }) |name| { + const path = std.fmt.bufPrintSentinel(buf, "{s}/.local/bin/{s}", .{ home, name }, 0) catch continue; + if (c.access(path.ptr, X_OK) == 0) return path; + } + return null; +} + +fn connect() !*Core { + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const bin = findBinary(&path_buf) orelse return error.NotInstalled; + + var fds: [2]c_int = undefined; + if (c.socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, &fds) != 0) return error.SocketFailed; + errdefer _ = c.close(fds[0]); + + const arg0 = try gpa.dupeSentinel(u8, bin, 0); + defer gpa.free(arg0); + var arg1 = "connect".*; + var argv = [_:null]?[*:0]u8{ arg0.ptr, &arg1 }; + + // The child's end becomes its stdin and stdout. Its stderr is ours, so + // anything the core has to say lands in the app's log. Reaped by GLib, + // since nothing here needs its exit status: the socket closing is how a + // dead child makes itself known. + var spawn_err: ?*glib.Error = null; + const spawned = glib.spawnAsyncWithFds(null, &argv, null, .{}, null, null, null, fds[1], fds[1], -1, &spawn_err); + _ = c.close(fds[1]); + if (spawned == 0) { + if (spawn_err) |e| { + std.log.warn("herdr-core: {s}", .{e.f_message orelse "spawn failed"}); + e.free(); + } + return error.SpawnFailed; + } + + const self = try gpa.create(Core); + self.* = .{ .fd = fds[0] }; + errdefer { + self.buf.deinit(gpa); + gpa.destroy(self); + } + + try hello(self); + + self.watch = glibunix.fdAdd(self.fd, .{ .in = true, .hup = true, .err = true }, &onReadable, self); + if (self.buf.items.len > self.head) scheduleDispatch(self); + return self; +} + +/// The handshake, in line mode: one JSON line out, one back. Everything after +/// the reply's newline is already framed. +fn hello(self: *Core) !void { + try sendAll(self, "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"hello\",\"params\":{\"framed\":true,\"generation\":1}}\n"); + + const deadline = glib.getMonotonicTime() + call_timeout_ms * std.time.us_per_ms; + const line_end = while (true) { + if (std.mem.indexOfScalar(u8, self.buf.items, '\n')) |i| break i; + if (!try fill(self, deadline)) return error.Timeout; + }; + + const parsed = std.json.parseFromSlice(std.json.Value, gpa, self.buf.items[0..line_end], .{}) catch + return error.BadHello; + defer parsed.deinit(); + const obj = switch (parsed.value) { + .object => |o| o, + else => return error.BadHello, + }; + if (obj.get("error")) |e| { + _ = e; + return error.HelloRefused; + } + const res = obj.get("result") orelse return error.BadHello; + if (res != .object) return error.BadHello; + if (res.object.get("framed")) |f| { + if (f != .bool or !f.bool) return error.NotFramed; + } + if (res.object.get("generation")) |g| { + if (g != .integer or g.integer != generation) return error.WrongGeneration; + } + + self.head = line_end + 1; +} + +/// Read whatever is available into `buf`, waiting until `deadline` for +/// something to arrive. False on timeout; error.Lost when the core has gone. +fn fill(self: *Core, deadline: i64) Error!bool { + while (true) { + const remaining_us = deadline - glib.getMonotonicTime(); + if (remaining_us <= 0) return false; + var pfd = [_]PollFd{.{ .fd = self.fd, .events = POLLIN }}; + const ms: c_int = @intCast(@max(1, @divTrunc(remaining_us, std.time.us_per_ms))); + const ready = c.poll(&pfd, 1, ms); + if (ready < 0) { + if (c.__errno_location().* == EINTR) continue; + return error.Lost; + } + if (ready == 0) return false; + return switch (try readSome(self)) { + .some, .would_block => true, + }; + } +} + +const ReadResult = enum { some, would_block }; + +/// One non-blocking read into `buf`. +fn readSome(self: *Core) Error!ReadResult { + try self.buf.ensureUnusedCapacity(gpa, 64 * 1024); + const spare = self.buf.unusedCapacitySlice(); + const n = c.recv(self.fd, spare.ptr, spare.len, MSG_DONTWAIT); + if (n > 0) { + self.buf.items.len += @intCast(n); + return .some; + } + if (n < 0) { + const errno = c.__errno_location().*; + if (errno == EAGAIN or errno == EINTR) return .would_block; + } + return error.Lost; +} + +/// Write all of `bytes`. When the socket is full, whatever the core has sent +/// meanwhile is read into `buf` while we wait: a core blocked writing to us +/// while we are blocked writing to it would otherwise be a deadlock. +fn sendAll(self: *Core, bytes: []const u8) Error!void { + var off: usize = 0; + while (off < bytes.len) { + const n = c.send(self.fd, bytes.ptr + off, bytes.len - off, MSG_NOSIGNAL | MSG_DONTWAIT); + if (n > 0) { + off += @intCast(n); + continue; + } + const errno = c.__errno_location().*; + if (n < 0 and errno == EINTR) continue; + if (n < 0 and errno != EAGAIN) return error.Lost; + + var pfd = [_]PollFd{.{ .fd = self.fd, .events = POLLIN | POLLOUT }}; + if (c.poll(&pfd, 1, call_timeout_ms) <= 0) return error.Lost; + if (pfd[0].revents & POLLIN != 0) { + _ = try readSome(self); + scheduleDispatch(self); + } + } +} + +fn sendFrame(self: *Core, channel: u32, kind: frame.Kind, payload: []const u8) Error!void { + const head = frame.header(channel, kind, payload.len); + try sendAll(self, &head); + try sendAll(self, payload); +} + +fn scheduleDispatch(self: *Core) void { + if (self.idle == 0) self.idle = glib.idleAddOnce(&onIdle, self); +} + +fn onIdle(data: ?*anyopaque) callconv(.c) void { + const self: *Core = @ptrCast(@alignCast(data.?)); + self.idle = 0; + dispatch(self); +} + +fn onReadable(_: c_int, condition: glib.IOCondition, data: ?*anyopaque) callconv(.c) c_int { + const self: *Core = @ptrCast(@alignCast(data.?)); + + var gone = !condition.in; + if (condition.in) { + while (true) { + const r = readSome(self) catch { + gone = true; + break; + }; + if (r == .would_block) break; + } + } + + // Deliver what did arrive before giving up on the rest: the last thing a + // dying core sends can be the close of a stream. + dispatch(self); + if (core != self) return 0; + + if (gone) { + self.watch = 0; + lose(self); + return 0; + } + return 1; +} + +/// Deliver every complete frame in `buf` to its stream. +fn dispatch(self: *Core) void { + if (self.dispatching) return; + self.dispatching = true; + + while (true) { + const f = (frame.parse(self.buf.items[self.head..]) catch { + std.log.err("herdr-core: a frame too large to be real; dropping the connection", .{}); + self.dispatching = false; + lose(self); + return; + }) orelse break; + self.head += f.len(); + + // Replies to calls nobody is waiting for: kills, mostly. + if (f.channel == 0) continue; + + const stream = self.streams.get(f.channel) orelse continue; + switch (f.kind) { + .data => { + scratch.clearRetainingCapacity(); + scratch.appendSlice(gpa, f.payload) catch continue; + // Granted back before it is handled, so a slow frame does not + // also stall the next one in the core. + sendFrame(self, f.channel, .credit, &frame.creditPayload(scratch.items.len)) catch {}; + stream.on_data(stream.ctx, scratch.items); + }, + .close => { + _ = self.streams.remove(f.channel); + stream.on_end(stream.ctx, .closed); + }, + else => {}, + } + + // A callback can lose the connection, which frees it. + if (core != self) return; + } + + self.dispatching = false; + compact(self); +} + +/// Drop consumed bytes from the front of `buf`. +fn compact(self: *Core) void { + if (self.head == 0) return; + const rest = self.buf.items.len - self.head; + std.mem.copyForwards(u8, self.buf.items[0..rest], self.buf.items[self.head..]); + self.buf.items.len = rest; + self.head = 0; +} + +/// The connection is gone: tell every stream, and forget it. The next call +/// connects afresh. +fn lose(self: *Core) void { + if (core == self) core = null; + if (self.watch != 0) _ = glib.Source.remove(self.watch); + if (self.idle != 0) _ = glib.Source.remove(self.idle); + _ = c.close(self.fd); + + var streams = self.streams; + self.buf.deinit(gpa); + gpa.destroy(self); + + std.log.warn("herdr-core: lost the connection", .{}); + var it = streams.valueIterator(); + while (it.next()) |stream| stream.on_end(stream.ctx, .lost); + streams.deinit(gpa); +} + +// ------------------------------------------------------------------------- +// Calls + +/// A reply, owned by the caller. +const Reply = std.json.Parsed(std.json.Value); + +/// Make a call and wait for its reply. Returns the reply's `result`, inside +/// `Reply`, or the error the core gave. +fn call(self: *Core, method: []const u8, params: []const u8) Error!Reply { + const id = self.next_id; + self.next_id += 1; + + const request = std.fmt.allocPrint(gpa, "{{\"jsonrpc\":\"2.0\",\"id\":{d},\"method\":\"{s}\",\"params\":{s}}}", .{ id, method, params }) catch + return error.OutOfMemory; + defer gpa.free(request); + sendFrame(self, 0, .data, request) catch |err| { + lose(self); + return err; + }; + + const deadline = glib.getMonotonicTime() + call_timeout_ms * std.time.us_per_ms; + var scan = self.head; + while (true) { + while (frame.parse(self.buf.items[scan..]) catch { + lose(self); + return error.Lost; + }) |f| { + if (f.channel == 0 and f.kind == .data) { + if (try matchReply(f.payload, id)) |reply| { + // Taken out of the buffer; everything around it stays for + // `dispatch`, in order. + self.buf.replaceRangeAssumeCapacity(scan, f.len(), &.{}); + if (self.buf.items.len > self.head) scheduleDispatch(self); + return reply; + } + } + scan += f.len(); + } + + const got = fill(self, deadline) catch |err| { + lose(self); + return err; + }; + if (!got) { + if (self.buf.items.len > self.head) scheduleDispatch(self); + std.log.warn("herdr-core: {s} timed out", .{method}); + return error.Timeout; + } + } +} + +/// The reply in `payload` if it answers call `id`, with an error reply turned +/// into the matching error. +fn matchReply(payload: []const u8, id: u64) Error!?Reply { + const reply = std.json.parseFromSlice(std.json.Value, gpa, payload, .{}) catch return null; + const obj = switch (reply.value) { + .object => |o| o, + else => { + reply.deinit(); + return null; + }, + }; + const got = obj.get("id") orelse { + reply.deinit(); + return null; + }; + if (got != .integer or got.integer != id) { + reply.deinit(); + return null; + } + + if (obj.get("error")) |e| { + defer reply.deinit(); + const kind = if (e == .object) blk: { + const d = e.object.get("data") orelse break :blk ""; + if (d != .object) break :blk ""; + const k = d.object.get("kind") orelse break :blk ""; + break :blk if (k == .string) k.string else ""; + } else ""; + if (std.mem.eql(u8, kind, "not_found")) return error.NotFound; + if (std.mem.eql(u8, kind, "finished")) return error.Finished; + if (std.mem.eql(u8, kind, "spawn_failed")) return error.SpawnFailed; + std.log.warn("herdr-core refused a call: {s}", .{if (kind.len > 0) kind else "unknown"}); + return error.Refused; + } + if (obj.get("result") == null) { + reply.deinit(); + return error.Refused; + } + return reply; +} + +/// Send a call and don't wait for its reply, which `dispatch` will drop. +fn cast(self: *Core, method: []const u8, params: []const u8) void { + const id = self.next_id; + self.next_id += 1; + const request = std.fmt.allocPrint(gpa, "{{\"jsonrpc\":\"2.0\",\"id\":{d},\"method\":\"{s}\",\"params\":{s}}}", .{ id, method, params }) catch return; + defer gpa.free(request); + sendFrame(self, 0, .data, request) catch {}; +} + +fn result(reply: Reply) std.json.ObjectMap { + const r = reply.value.object.get("result").?; + return if (r == .object) r.object else .empty; +} + +fn intField(obj: std.json.ObjectMap, name: []const u8) ?i64 { + const v = obj.get(name) orelse return null; + return if (v == .integer) v.integer else null; +} + +fn strField(obj: std.json.ObjectMap, name: []const u8) ?[]const u8 { + const v = obj.get(name) orelse return null; + return if (v == .string) v.string else null; +} + +const Json = struct { + out: std.Io.Writer.Allocating, + j: std.json.Stringify, + + fn init(self: *Json) void { + self.out = .init(gpa); + self.j = .{ .writer = &self.out.writer }; + } + + fn deinit(self: *Json) void { + self.out.deinit(); + } + + fn written(self: *Json) []const u8 { + return self.out.written(); + } +}; + +fn writeRgb(j: *std.json.Stringify, rgb: Rgb) !void { + var buf: [7]u8 = undefined; + try j.write(std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch unreachable); +} + +fn writeDisplay(j: *std.json.Stringify, d: Display) !void { + try j.beginObject(); + try j.objectField("fg"); + try writeRgb(j, d.fg); + try j.objectField("bg"); + try writeRgb(j, d.bg); + try j.objectField("cursor"); + try writeRgb(j, d.cursor); + try j.objectField("cell"); + try j.beginArray(); + try j.write(d.cell[0]); + try j.write(d.cell[1]); + try j.endArray(); + try j.endObject(); +} + +fn writeAddress(address: []const u8) Error![]u8 { + var p: Json = undefined; + p.init(); + errdefer p.deinit(); + p.j.beginObject() catch return error.OutOfMemory; + p.j.objectField("terminal") catch return error.OutOfMemory; + p.j.write(address) catch return error.OutOfMemory; + p.j.endObject() catch return error.OutOfMemory; + return p.out.toOwnedSlice() catch error.OutOfMemory; +} + +/// Start a program in a new terminal. +/// +/// Every terminal is started with `keep`, so a program the core loses — its +/// holder dying, or the program killed by a signal — is replaced with a shell +/// in place rather than leaving a dead pane; and with `rm`, so one that exits +/// is forgotten straight away instead of piling up in `hc ls --all`. It has +/// no parent: a pane is nobody's child, and with none the core types no notes +/// about it into anything. +pub fn run(alloc: std.mem.Allocator, opts: RunOptions) Error!Started { + const self = connection() orelse return error.Unavailable; + + var p: Json = undefined; + p.init(); + defer p.deinit(); + writeRunParams(&p.j, opts) catch return error.OutOfMemory; + + const reply = try call(self, "terminal.run", p.written()); + defer reply.deinit(); + const r = result(reply); + const address = strField(r, "address") orelse return error.Refused; + return .{ + .address = try alloc.dupe(u8, address), + .pid = @intCast(intField(r, "pid") orelse 0), + }; +} + +fn writeRunParams(j: *std.json.Stringify, opts: RunOptions) !void { + try j.beginObject(); + try j.objectField("argv"); + try j.beginArray(); + for (opts.argv) |a| try j.write(a); + try j.endArray(); + if (opts.cwd) |dir| { + try j.objectField("cwd"); + try j.write(dir); + } + try j.objectField("env"); + try j.beginObject(); + for (opts.env) |entry| { + const eq = std.mem.indexOfScalar(u8, entry, '=') orelse continue; + try j.objectField(entry[0..eq]); + try j.write(entry[eq + 1 ..]); + } + try j.endObject(); + try j.objectField("labels"); + try j.beginObject(); + try j.objectField("playpen"); + try j.write("1"); + try j.endObject(); + try j.objectField("keep"); + try j.write(true); + try j.objectField("rm"); + try j.write(true); + try j.objectField("no_parent"); + try j.write(true); + try j.objectField("no_notify"); + try j.write(true); + try j.endObject(); +} + +/// Attach to a live terminal and start receiving its output on `stream`. +/// +/// The core opens the stream with a paint of the whole screen, and `history` +/// rows of scrollback before it — wanted when the emulator on our side is +/// empty, and not when it already holds that scrollback from before. +pub fn attach( + address: []const u8, + cols: u16, + rows: u16, + history: u32, + display: Display, + stream: Stream, +) Error!Attached { + const self = connection() orelse return error.Unavailable; + + var p: Json = undefined; + p.init(); + defer p.deinit(); + (struct { + fn write(j: *std.json.Stringify, a: []const u8, w: u16, h: u16, hist: u32, d: Display) !void { + try j.beginObject(); + try j.objectField("terminal"); + try j.write(a); + try j.objectField("cols"); + try j.write(w); + try j.objectField("rows"); + try j.write(h); + try j.objectField("history"); + try j.write(hist); + try j.objectField("display"); + try writeDisplay(j, d); + try j.endObject(); + } + }).write(&p.j, address, cols, rows, history, display) catch return error.OutOfMemory; + + const reply = try call(self, "terminal.attach", p.written()); + defer reply.deinit(); + const r = result(reply); + const channel = intField(r, "channel") orelse return error.Refused; + const ch: u32 = @intCast(channel); + + // Registered before anything else can be read, so the first paint — which + // may already be sitting in the buffer behind the reply — has somewhere to go. + self.streams.put(gpa, ch, stream) catch return error.OutOfMemory; + + const terminal = if (r.get("terminal")) |t| (if (t == .object) t.object else std.json.ObjectMap.empty) else std.json.ObjectMap.empty; + return .{ + .channel = ch, + .pid = @intCast(intField(terminal, "pid") orelse 0), + }; +} + +pub const State = enum { live, finished, gone }; + +/// What `get` reports about a terminal. +pub const Facts = struct { + state: State, + /// Its current grid size, which is the size its program is drawing for. + cols: u16 = 0, + rows: u16 = 0, +}; + +/// Whether a terminal is still running, and at what size. +pub fn get(address: []const u8) Error!Facts { + const self = connection() orelse return error.Unavailable; + const params = try writeAddress(address); + defer gpa.free(params); + + const reply = call(self, "terminal.get", params) catch |err| switch (err) { + error.NotFound => return .{ .state = .gone }, + else => return err, + }; + defer reply.deinit(); + const r = result(reply); + const s = strField(r, "state") orelse return .{ .state = .gone }; + return .{ + .state = if (std.mem.eql(u8, s, "live")) .live else .finished, + .cols = std.math.cast(u16, intField(r, "cols") orelse 0) orelse 0, + .rows = std.math.cast(u16, intField(r, "rows") orelse 0) orelse 0, + }; +} + +/// End a terminal's program, without waiting to hear how it went. +pub fn kill(address: []const u8) void { + const self = connection() orelse return; + const params = writeAddress(address) catch return; + defer gpa.free(params); + cast(self, "terminal.kill", params); +} + +/// Keystrokes and replies for the program. +pub fn input(channel: u32, bytes: []const u8) void { + const self = core orelse return; + if (bytes.len == 0) return; + sendFrame(self, channel, .data, bytes) catch {}; +} + +pub fn resize(channel: u32, cols: u16, rows: u16) void { + const self = core orelse return; + sendFrame(self, channel, .resize, &frame.resizePayload(cols, rows)) catch {}; +} + +/// The viewer's colors or cell size changed. +pub fn facts(channel: u32, display: Display) void { + const self = core orelse return; + var p: Json = undefined; + p.init(); + defer p.deinit(); + writeDisplay(&p.j, display) catch return; + sendFrame(self, channel, .facts, p.written()) catch {}; +} + +/// Stop receiving a terminal's output. The program carries on. +pub fn detach(channel: u32) void { + const self = core orelse return; + _ = self.streams.remove(channel); + sendFrame(self, channel, .close, &.{}) catch {}; +} + +test { + _ = frame; +} diff --git a/src/hc/frame.zig b/src/hc/frame.zig new file mode 100644 index 0000000..2096458 --- /dev/null +++ b/src/hc/frame.zig @@ -0,0 +1,143 @@ +//! The framed wire format herdr-core speaks once `hello` has asked for it. +//! +//! Every message is a nine-byte header and a payload: +//! +//! channel u32 LE 0 is JSON-RPC; an attached terminal gets its own +//! kind u8 what the payload is (see `Kind`) +//! length u32 LE payload bytes that follow +//! +//! Nothing here touches a socket. It is the part of the protocol that is pure +//! bytes, kept apart so it can be tested without a core to talk to — the same +//! reason the layout and snapshot grammars have their own test roots. + +const std = @import("std"); + +pub const header_len = 9; + +/// Bigger than anything the core sends — a terminal stream arrives in pieces +/// far smaller than this — and small enough that a corrupt length cannot have +/// us waiting forever for a payload that will never come. +pub const max_payload = 16 * 1024 * 1024; + +pub const Kind = enum(u8) { + /// Bytes: JSON on channel 0, terminal output and input on the others. + data = 0, + /// We have consumed this many bytes; the core may send that many more. + credit = 1, + /// The stream is over. From the core, a JSON payload saying why. + close = 2, + /// Our grid size: cols u16 LE, rows u16 LE. + resize = 3, + /// Focus in (1) or out (0). + focus = 4, + /// Our display facts changed (JSON, as `terminal.attach`'s `display`). + facts = 5, + _, +}; + +pub const Frame = struct { + channel: u32, + kind: Kind, + payload: []const u8, + + /// Bytes of input this frame occupied, header included. + pub fn len(self: Frame) usize { + return header_len + self.payload.len; + } +}; + +pub const ParseError = error{TooLarge}; + +/// The frame at the start of `bytes`, or null if it has not all arrived yet. +/// The payload is a slice of `bytes`. +pub fn parse(bytes: []const u8) ParseError!?Frame { + if (bytes.len < header_len) return null; + const length = std.mem.readInt(u32, bytes[5..9], .little); + if (length > max_payload) return error.TooLarge; + if (bytes.len < header_len + length) return null; + return .{ + .channel = std.mem.readInt(u32, bytes[0..4], .little), + .kind = @enumFromInt(bytes[4]), + .payload = bytes[header_len..][0..length], + }; +} + +/// The header for a frame carrying `payload_len` bytes. +pub fn header(channel: u32, kind: Kind, payload_len: usize) [header_len]u8 { + var out: [header_len]u8 = undefined; + std.mem.writeInt(u32, out[0..4], channel, .little); + out[4] = @intFromEnum(kind); + std.mem.writeInt(u32, out[5..9], @intCast(payload_len), .little); + return out; +} + +/// The payload of a resize frame. +pub fn resizePayload(cols: u16, rows: u16) [4]u8 { + var out: [4]u8 = undefined; + std.mem.writeInt(u16, out[0..2], cols, .little); + std.mem.writeInt(u16, out[2..4], rows, .little); + return out; +} + +/// The payload of a credit frame. +pub fn creditPayload(n: usize) [4]u8 { + var out: [4]u8 = undefined; + std.mem.writeInt(u32, &out, @intCast(@min(n, std.math.maxInt(u32))), .little); + return out; +} + +const testing = std.testing; + +test "a frame round-trips through its header" { + const payload = "hello"; + var buf: [header_len + payload.len]u8 = undefined; + buf[0..header_len].* = header(7, .data, payload.len); + @memcpy(buf[header_len..], payload); + + const f = (try parse(&buf)).?; + try testing.expectEqual(@as(u32, 7), f.channel); + try testing.expectEqual(Kind.data, f.kind); + try testing.expectEqualStrings(payload, f.payload); + try testing.expectEqual(buf.len, f.len()); +} + +test "a frame split across reads is not parsed until it has all arrived" { + var buf: [header_len + 3]u8 = undefined; + buf[0..header_len].* = header(1, .close, 3); + @memcpy(buf[header_len..], "abc"); + + try testing.expect(try parse(buf[0..4]) == null); + try testing.expect(try parse(buf[0 .. header_len + 2]) == null); + try testing.expect(try parse(&buf) != null); +} + +test "two frames back to back are read one at a time" { + var buf: [2 * header_len + 2]u8 = undefined; + buf[0..header_len].* = header(1, .data, 1); + buf[header_len] = 'a'; + buf[header_len + 1 ..][0..header_len].* = header(2, .data, 1); + buf[buf.len - 1] = 'b'; + + const first = (try parse(&buf)).?; + try testing.expectEqualStrings("a", first.payload); + const second = (try parse(buf[first.len()..])).?; + try testing.expectEqual(@as(u32, 2), second.channel); + try testing.expectEqualStrings("b", second.payload); +} + +test "a length no core would send is refused rather than waited for" { + var buf: [header_len]u8 = header(1, .data, 0); + std.mem.writeInt(u32, buf[5..9], max_payload + 1, .little); + try testing.expectError(error.TooLarge, parse(&buf)); +} + +test "a kind this build does not know survives parsing" { + var buf: [header_len]u8 = header(1, .data, 0); + buf[4] = 42; + const f = (try parse(&buf)).?; + try testing.expectEqual(@as(u8, 42), @intFromEnum(f.kind)); +} + +test "resize is cols then rows, little-endian" { + try testing.expectEqualSlices(u8, &.{ 80, 0, 24, 0 }, &resizePayload(80, 24)); +}