a73x

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;
    }
}