Integrate with herdr cli.

This commit is contained in:
Greyson Parrelli committed 2026-11-02 10:47:08 -05:00
1 parent baa2907391
commit b5af181a4d
11 files changed
+1623 -75

No files matched your search

+18
View File
@@ -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`:
+12
View File
@@ -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
+14
View File
@@ -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),
} });
}
+8 -2
View File
@@ -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;
+423 -22
View File
@@ -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);
if (opts.command.len > 0) {
self.startup_command = try alloc.dupe(u8, opts.command);
}
// 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.pty.master,
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);
}
}
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(.{
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);
},
}
}
+16
View File
@@ -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);
+79 -26
View File
@@ -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.
+17 -7
View File
@@ -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),
),
};
}
+66 -18
View File
@@ -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,8 +2380,22 @@ 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(
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.",
.{
@@ -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
+827
View File
@@ -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;
}
+143
View File
@@ -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));
}