src/tui/wall_host.zig
Ref: Size: 21.2 KiB History
//! The wall's daemons: the hosts file's lines resolved to targets, the table
//! that holds them, and the poller that asks each for its live sessions once a
//! second. A host contributes nothing but the GRADE of the panes the layout
//! already gave it — `applyHostList` binds a pending pane whose session the
//! list names, marks `gone` one it does not, and vanishes a live pane whose
//! shell has ended. The layout is the only source of tiles.
const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
const hosts = @import("client").hosts;
const wall_layout = @import("wall_layout.zig");
const wv = @import("wallview.zig");
const Shared = wv.Shared;
const Tile = wv.Tile;
const Wall = wv.Wall;
pub const Resolved = struct {
target: client.Target,
/// The user's spelling verbatim, `#NAME` included — the label bar
/// shows what was typed, not a rebuilt approximation of it.
label: []const u8,
session: []const u8,
/// Whether this tile's attaches offer this client's ssh-agent (`mux
/// -A`). Per tile rather than per wall: a tile whose host the user did
/// not offer the agent to must not hand that stranger the keys, even
/// while an `-A` tile is on the same wall.
agent: bool = false,
};
const HostSpec = client.HostSpec;
/// One wording for every spelling the wall will not take.
fn badHost(shared: *Shared, e: anyerror) void {
// `hosts.reason` rather than the error name: the grammar's own sentence
// is the one that tells a user what to type instead.
var buf: [128]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "[bad host: {s}]", .{hosts.reason(e)}) catch "[bad host]";
wv.setNotice(shared, text);
}
/// The sentence for host lines the wall has no room for; null when they all
/// fit. A wall silently missing a machine the user WROTE DOWN is the lie the
/// hosts file exists to stop telling — the session count's own argument.
pub fn hostsOverCapacity(buf: []u8, listed: usize) ?[]const u8 {
const over = listed -| wv.max_tiles;
if (over == 0) return null;
return std.fmt.bufPrint(buf, "[+{d} host{s} in the file not shown]", .{
over,
if (over == 1) "" else "s",
}) catch "[hosts in the file not shown]";
}
/// The lowest slot a forgotten host has finished leaving. Both halves, for
/// `freeSlot`'s reason: `forgotten` is the ask, `poller_done` is the answer.
fn freeHostSlot(host_table: []const Host) ?usize {
for (host_table, 0..) |*h, i| {
if (h.forgotten.load(.acquire) and h.poller_done.load(.acquire)) return i;
}
return null;
}
/// What the picker's `a` did. `listed` carries the row the spelling is
/// ALREADY on: an unchanged popup and an unmoved selection is the prompt
/// answering a user who typed a real host with nothing at all.
pub const AddHost = union(enum) { added: usize, listed: usize, refused };
/// The picker's `a`, answered: the spelling names a DAEMON. Every arm sets
/// the sentence it is; the caller only polls what it just made.
pub fn addHost(
alloc: std.mem.Allocator,
shared: *Shared,
host_table: []Host,
hosts_live: *usize,
spelling: []const u8,
key: ?[]const u8,
idle_ms: u32,
path: ?[]const u8,
) AddHost {
// No tile is born here, and none comes later either: a host is a row,
// and Enter on that row is what puts a pane on the wall. Twice is once
// — a second row for one daemon is a second poller grading the same
// panes, and a picker naming one machine twice.
for (host_table[0..hosts_live.*], 0..) |*h, hi| {
// A forgotten host is not on the wall, so its slot must not refuse
// the spelling back: `x` then `a` on the same host is one of the
// two things the picker is for.
if (h.forgotten.load(.acquire)) continue;
if (std.mem.eql(u8, h.spec.spelling, spelling)) {
wv.setNotice(shared, "[that host is already on the wall]");
return .{ .listed = hi };
}
}
// The prompt is `mux hosts add` typed from inside, so it refuses what
// that refuses: `-A box` is a mistyped flag, not a host. Ahead of the
// dupe, so the answer to a typo allocates nothing.
if (hosts.flagLike(spelling)) {
badHost(shared, error.FlagLikeTarget);
return .refused;
}
// Ahead of the dupe and the resolve, both of which allocate: a full wall is
// full whatever the spelling means, and a re-opened prompt must not leak a
// copy per refusal. A forgotten slot whose poller has left is FREE, or a
// table that only grew would spend the wall after 32 edits.
const reuse = freeHostSlot(host_table[0..hosts_live.*]);
if (reuse == null and hosts_live.* >= host_table.len) {
wv.setNotice(shared, "[no room on the wall for another host]");
return .refused;
}
// The filter's buffer is the next read's; the table keeps this copy.
const own = alloc.dupe(u8, spelling) catch {
wv.setNotice(shared, "[could not add that host]");
return .refused;
};
const spec = client.resolveHost(alloc, own, key, idle_ms) catch |err| {
alloc.free(own);
badHost(shared, err);
return .refused;
};
// The file before the table: a host the user is looking at and one they get
// back next time are the same host. The host is this wall's either way — a
// file that will not take the line costs the NEXT wall, not this one.
if (path) |p| if (recordHost(alloc, spec.target, spec.spelling, p)) |err| {
var nb: [128]u8 = undefined;
wv.setNotice(shared, std.fmt.bufPrint(
&nb,
"[hosts file not updated: {s}]",
.{hosts.reason(err)},
) catch "[hosts file not updated]");
};
const at = reuse orelse hosts_live.*;
// The departed spec is not freed: `run`'s allocator is an arena that
// never returns, exactly as the tile labels beside it are not freed.
host_table[at] = .{
.spec = spec,
.shared = shared,
.self_name = wv.selfSession(spec.target, std.posix.getenv(proto.sock_env), std.posix.getenv(proto.session_env)),
};
if (reuse == null) hosts_live.* += 1;
return .{ .added = at };
}
/// A diff's plan, bounded by the wall's own capacity so an append can never
/// overrun. Every plan here is a list of TILE indices, so the bound cannot
/// bite — a wall holds at most `wv.max_tiles` of them — and the guard is
/// what keeps that true if a caller ever hands it something else.
fn Fixed(comptime T: type) type {
return struct {
items: [wv.max_tiles]T = undefined,
len: usize = 0,
fn append(self: *@This(), v: T) void {
if (self.len == self.items.len) return;
self.items[self.len] = v;
self.len += 1;
}
pub fn get(self: *const @This(), i: usize) T {
return self.items[i];
}
};
}
pub const TileIdxs = Fixed(usize);
/// Only a host's own sessions are its list's to keep or to drop.
pub fn ownedBy(t: *const Tile, host: usize) bool {
const h = t.host orelse return false;
return h == host;
}
/// The whole of "a host GRADES the panes the layout gave it", per HOST so
/// that two daemons may share a session name. A name in the list that no
/// pane spells is nobody's business: the layout is the only source of tiles.
/// Pure: the rule is testable without a daemon.
pub fn planHostDiff(
tiles: []Tile,
present: []const bool,
live: usize,
host: usize,
list: []const u8,
self_name: ?[]const u8,
binds: *TileIdxs,
vanish: *TileIdxs,
gones: *TileIdxs,
) void {
// `proto.sessionsIter` carries the trust policy: a peer's reply is bounded
// only in total, so it yields only lines that are names — which is what
// keeps a hostile line from matching a pane by accident.
var it = proto.sessionsIter(list);
while (it.next()) |name| {
// The shell mux is running inside cannot be a pane of the wall it is
// painting, so its name never wakes one — `seedLayout` refuses such
// a leaf, and this is the poll's own half of that refusal.
if (self_name) |self| if (std.mem.eql(u8, self, name)) continue;
for (tiles[0..live], present[0..live], 0..) |*t, p, i| {
if (p and ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) {
// The layout's guess, confirmed by the host's own list:
// wake the pending pane where it already stands.
if (t.pending) binds.append(i);
break;
}
}
}
for (tiles[0..live], present[0..live], 0..) |*t, p, i| {
if (!p or !ownedBy(t, host)) continue;
// A tile whose CREATING attach has not landed names a session the
// daemon does not have yet, not one it dropped — and `pokeHost`
// asks for this list at exactly that moment.
if (t.creates and !t.ever_up.load(.acquire)) continue;
// The same walk the binds came out of: a pane's name was a name when
// the layout seeded it or the picker made it, so filtering the list
// cannot drop a real match.
const keep = proto.sessionsHas(list, proto.resolveName(t.r.session));
if (keep) {
t.missed_once = false;
continue;
}
// A seeded pane the host itself disowns is GONE, never vanished:
// its rect is the user's authored layout, and collapsing it here is
// the one-second re-cut the seed exists to prevent. Listed only on
// the transition — the poll returns every second, and re-dressing
// a pane per poll would repaint its bar for nothing.
if (t.pending) {
if (t.state != .gone) gones.append(i);
continue;
}
// A LIVE pump gets one list's grace: the daemon drains `exit_status`
// before clearing the slot, but the poll takes milliseconds the pump can
// be descheduled for — and vanishing the tile first loses the shell's
// exit code. A dead pump has no code left to lose.
if (t.alive.load(.acquire) and !t.missed_once) {
t.missed_once = true;
continue;
}
vanish.append(i);
}
}
/// One daemon on the wall, as the run knows it: the poller writes, the
/// keyboard reads.
pub const Host = struct {
spec: HostSpec,
shared: *Shared,
/// Never born as a tile here: see `showsSelf`.
self_name: ?[]const u8 = null,
/// The session poll itself, shared with the browser hub: its list, its
/// `reachable`, its `poke` and its 50 ms slices.
poll: client.SessionPoll = .{},
/// Whether a list of this host's has reached the WALL. Here rather than an
/// array beside the table, which the picker's `a` would index out of bounds.
/// Keyboard-thread only, so no lock.
applied: bool = false,
/// The drift word as last APPLIED to this host's tiles, so a poll that
/// repeats the same news asks for no repaint — one bump a second per
/// host is a flicker, not information. Keyboard-thread only, like
/// `applied`.
drift: [wv.drift_max]u8 = undefined,
drift_len: usize = 0,
/// Forgotten in the picker: off the file, off the rows, and its poller
/// exits for good. The SLOT stays — a poller thread holds this pointer
/// and every tile's `host` indexes this array — so nothing compacts.
forgotten: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// `Tile.pump_done`'s twin, and `addHost` needs both halves for the
/// same reason `freeSlot` does: a forgotten slot rewritten while its
/// poller still holds the pointer is a thread dialling a replaced spec.
poller_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// Answering `keep` for `client.SessionPoll.run`: the wall is up and
/// the picker has not forgotten this host.
fn keep(p: *anyopaque) bool {
const self: *Host = @ptrCast(@alignCast(p));
return self.shared.running.load(.acquire) and !self.forgotten.load(.acquire);
}
fn wake(p: *anyopaque) void {
const self: *Host = @ptrCast(@alignCast(p));
wv.ringKeyboard(self.shared);
}
};
pub fn pollHost(h: *Host) void {
h.poll.run(h.spec.poll_target, .{ .ctx = h, .keep = Host.keep, .wake = Host.wake });
// Last: past here nothing reads `h`, which is what lets `addHost` take
// the slot back.
h.poller_done.store(true, .release);
}
/// A chord that births asks its host for a list NOW: the tile is already on
/// the wall, and it is graded against a list that must name the session the
/// chord just made rather than the one from a second ago.
pub fn pokeHost(host_table: []Host, t: *const Tile) void {
const hi = t.host orelse return;
if (hi < host_table.len) host_table[hi].poll.poke.store(true, .release);
}
/// Every host with news, applied to the wall. True when any reported.
pub fn applyReadyLists(w: Wall) bool {
var news = false;
for (w.hosts, 0..) |*h, hi| {
if (!h.poll.list_ready.swap(false, .acq_rel)) continue;
news = true;
// A poll already in flight when the host was forgotten still lands,
// and the forget has already taken that host's panes off the wall:
// there is nothing left for its list to grade, and a slot the user
// emptied must not act on the wall on its way out.
if (h.forgotten.load(.acquire)) continue;
applyHostList(w, hi);
h.applied = true;
}
return news;
}
/// The host's drift word, written onto every tile it owns. Written even
/// when unchanged — a tile born THIS cycle has not been dressed yet, and a
/// blind rewrite of at most `wv.drift_max` bytes per tile is cheaper than
/// remembering which tiles are new — but a REPAINT is asked for only on
/// change, so a poll that repeats the same news costs the screen nothing.
/// Only a reachable host's answer speaks: a silent box keeps its last word
/// the way its panes keep their rects.
fn applyDrift(w: Wall, hi: usize, list: []const u8) void {
const h = &w.hosts[hi];
var word_buf: [wv.drift_max]u8 = undefined;
const word = wv.driftWord(&word_buf, w.shared.own_version, proto.parseSessionsMeta(list));
const changed = !std.mem.eql(u8, word, h.drift[0..h.drift_len]);
@memcpy(h.drift[0..word.len], word);
h.drift_len = word.len;
w.shared.paint_mu.lock();
defer w.shared.paint_mu.unlock();
for (w.liveTiles(), w.livePresent()) |*t, p| {
if (!p or !ownedBy(t, hi)) continue;
@memcpy(t.drift[0..word.len], word);
t.drift_len = word.len;
}
if (!changed) return;
// The pumps redraw their bars on the bump within a poll slice; a
// pumpless pane — pending, gone — has only the keyboard's hand, the
// same one that paints it in `dressSilent`.
_ = w.shared.repaint_gen.fetchAdd(1, .release);
if (w.shared.labelRows() != 0) wv.paintDeadBarsLocked(w.liveTiles());
}
/// A silent host DRESSES its saved panes rather than taking them: losing an
/// eight-pane setup to one quiet box is worse than reading the word.
fn dressSilent(w: Wall, hi: usize) void {
w.shared.paint_mu.lock();
defer w.shared.paint_mu.unlock();
var dressed = false;
for (w.liveTiles(), w.livePresent()) |*t, p| {
if (!p or !t.pending or !ownedBy(t, hi) or t.state == .@"unreachable") continue;
t.state = .@"unreachable";
dressed = true;
}
// A pending pane has no pump, so no doorbell reaches its bar: the
// keyboard is the only thread that can repaint one.
if (dressed and w.shared.labelRows() != 0) wv.paintDeadBarsLocked(w.liveTiles());
}
/// One host's list, applied to the wall. The keyboard thread only: it is
/// the single writer of the tile array and the layout tree.
pub fn applyHostList(w: Wall, hi: usize) void {
const h = &w.hosts[hi];
const reachable = h.poll.reachable.load(.acquire);
var list_buf: [proto.sessions_reply_max]u8 = undefined;
var list: []const u8 = "";
if (reachable) list = h.poll.snapshot(&list_buf);
// Whether the focus was on a real tile when this list arrived. An empty
// wall has none, and a vanish can take the one there was — either way
// the wall owes the tile it ends up with a `setFocus`, which is the only
// thing that arms a claim.
const had_focus = w.shared.sel < w.live.* and w.present[w.shared.sel];
var changed = false;
// Only a list DRIVES the diff. A host that has gone quiet keeps its
// tiles, which reconnect on their own; vanishing them on a failed poll
// would tear a wall down over one dropped packet.
if (reachable) {
var binds = TileIdxs{};
var vanish = TileIdxs{};
// Where the seeded panes this list disowns land.
var gones = TileIdxs{};
planHostDiff(w.liveTiles(), w.livePresent(), w.live.*, hi, list, h.self_name, &binds, &vanish, &gones);
// Binds first, and they do not count as change: a pane whose rect
// was cut at seed time wakes in place, and re-cuts nothing.
for (binds.items[0..binds.len]) |bi| wv.bindTile(w, bi);
// Dressed, never re-cut: `changed` stays as it was, because the whole
// point of a gone pane is that its rect survives the daemon that
// forgot its session. Same shape as `dressSilent`, one state along.
if (gones.len > 0) {
w.shared.paint_mu.lock();
defer w.shared.paint_mu.unlock();
for (gones.items[0..gones.len]) |gi| w.liveTiles()[gi].state = .gone;
// A gone pane has no pump, so no doorbell can repaint its bar:
// the keyboard paints it here, the same hand that dressed it.
if (w.shared.labelRows() != 0) wv.paintDeadBarsLocked(w.liveTiles());
}
for (vanish.items[0..vanish.len]) |v| {
wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, v, null);
changed = true;
}
// Once for the whole loop, through the one save path: a pane whose
// session the daemon no longer lists is off this wall, and a file
// that still named it would seat it again — as a `gone` pane the
// user has to dismiss by hand — on the next start. This runs on
// the keyboard thread (`applyReadyLists`), like every other save.
if (vanish.len > 0) wall_layout.persist(w);
// A name the list carries that no pane spells ends here, unsaid: the
// user's layout says which sessions this wall shows, and a session
// somebody else started on the same daemon is not this wall's to add
// — nor is it something to apologise for in a notice.
applyDrift(w, hi, list);
} else dressSilent(w, hi);
if ((!had_focus or w.shared.sel >= w.live.* or !w.present[w.shared.sel]) and
wv.presentCount(w.livePresent()) > 0)
wv.setFocus(w.liveTiles(), w.shared, wall_layout.firstPresent(w.livePresent()) orelse 0);
if (changed) wall_layout.relayout(w, w.shared.sel);
}
/// The host grammar's own spelling of a target, for a wall entered by
/// `mux TARGET` rather than off the file: the sidecar's key and the line
/// `mux hosts` prints have to read like the one that would have named it.
pub fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 {
return switch (target) {
.sock => |p| try std.fmt.allocPrint(alloc, hosts.sock_prefix ++ "{s}", .{p}),
.hand => |h| try alloc.dupe(u8, h.host),
.quic => |q| try std.fmt.allocPrint(alloc, hosts.quic_prefix ++ "{s}", .{q.host_port}),
// `--via` has no form in that grammar — an arbitrary command is not
// an address — so the label is honest and is not a spelling.
.via => |c| try std.fmt.allocPrint(alloc, "--via {s}", .{c}),
};
}
/// The hosts file is what the user asked to SEE, not what answered.
pub fn recordHost(
alloc: std.mem.Allocator,
target: client.Target,
spelling: []const u8,
path: []const u8,
) ?anyerror {
// Both doors write the line on the USER's word: a daemon that never answers
// is a host the file still remembers. The failure comes BACK rather than
// being printed, because where it may be said differs by door — stderr
// before the wall takes the screen, a notice after. `--via` has no form in
// the host grammar, so it records nothing and does so silently.
if (target == .via) return null;
_ = hosts.record(alloc, path, spelling) catch |err| return err;
return null;
}
/// The rest of the file, after the host the user named.
pub fn otherHosts(
alloc: std.mem.Allocator,
specs: *std.ArrayList(HostSpec),
first: []const u8,
path: []const u8,
key: ?[]const u8,
idle_ms: u32,
) void {
const h = hosts.load(alloc, path) catch |err| {
std.debug.print("mux: hosts file ignored ({s}): {s}\n", .{ path, hosts.reason(err) });
return;
};
for (h.lines.items) |line| {
if (std.mem.eql(u8, line, first)) continue;
const spec = client.resolveHost(alloc, line, key, idle_ms) catch |err| {
// Said and skipped, not refused: what was asked for here is a
// session, and it is already open. Only bare `mux`, where the
// wall itself is the ask, turns a bad line into an exit code.
std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) });
continue;
};
specs.append(alloc, spec) catch return;
}
}