a73x

src/server/server_sessions.zig

Ref:   Size: 10.9 KiB   History

//! The daemon's session table: up to `max_sessions` slots, and everything that
//! creates, finds, names, counts or reaps one. These are the only functions
//! with a RULE about the table; every other reader just walks it, which is why
//! `table` is a pub field rather than an iterator.
//!
//! What it needs back from the daemon arrives as a `*Server` per call, never a
//! back-pointer: `Server` is returned by value from `init`.
//!
//! Names are stored INLINE, and that is load-bearing: iterating by value copies
//! a Session, and a name sliced out of that copy dies with the iteration.

const std = @import("std");
const proto = @import("term").protocol;
const Engine = @import("engine").Engine;
const Pty = @import("pty").Pty;
const srv_mod = @import("server.zig");
const Server = srv_mod.Server;
const Session = srv_mod.Session;
const SpawnPlan = Server.SpawnPlan;
const max_sessions = srv_mod.max_sessions;
const max_clients = srv_mod.max_clients;
const min_session_cols = srv_mod.min_session_cols;
const min_session_rows = srv_mod.min_session_rows;
const AgentRelay = @import("server_agent.zig").AgentRelay;
const AgentSock = @import("server_agent.zig").AgentSock;

/// The daemon's sessions, as one value.
pub const SessionTable = struct {
    /// Every slot the daemon has. Walked directly by pumpOnce, deinit and
    /// writeManifestTo; the rules that are not "walk it" are the methods
    /// below.
    table: [max_sessions]?Session = @splat(null),

    /// Null if it could not be made. Created exclusively at 0700 under a name
    /// A session instance's identity in every snapshot it sends.
    pub fn freshEpoch() u64 {
        // Random rather than a counter or timestamp: nothing on disk survives a
        // daemon, and two started in the same millisecond must still differ.
        // Never 0 — that is a client saying it holds nothing. An adopted session
        // mints one too, so every returning client takes the snapshot path.
        var epoch: u64 = 0;
        while (epoch == 0) epoch = std.crypto.random.int(u64);
        return epoch;
    }

    /// Takes ownership of `agent`: it lands on the Session or is released
    /// here; no teardown path can reach it.
    pub fn create(
        alloc: std.mem.Allocator,
        plan: SpawnPlan,
        name: []const u8,
        cols: u16,
        rows: u16,
        agent: ?AgentSock,
    ) !Session {
        var agent_var = agent;
        errdefer if (agent_var) |*a| a.release(alloc);

        // The wire's number, not the engine's default: the daemon caps what
        // it queues at exactly what a term_event frame can carry, so a
        // payload the engine accepted can never be one the client refuses.
        const eng = try Engine.init(alloc, .{
            .cols = cols,
            .rows = rows,
            .clipboard_max = proto.clipboard_base64_max,
        });
        errdefer eng.deinit();

        // The env pairs the shared plan cannot carry, because both are THIS
        // session's: its name, and the agent socket bound for it. Freed as soon
        // as `spawnArgv` returns — the child read them before the exec.
        var name_z: [proto.session_name_max + 1]u8 = undefined;
        @memcpy(name_z[0..name.len], name);
        name_z[name.len] = 0;
        const env = try alloc.alloc(Pty.EnvPair, plan.env.len + 2);
        defer alloc.free(env);
        @memcpy(env[0..plan.env.len], plan.env);
        env[plan.env.len] = .{ .key = proto.session_env, .value = name_z[0..name.len :0] };
        // Always written, overwriting whatever the daemon inherited: a session
        // pointed at the DAEMON's ssh-agent reaches past the client watching it,
        // and every client would share one identity. Null — an UNSET — on the
        // bind failure paths, which would otherwise fall through to exactly that.
        env[plan.env.len + 1] = .{
            .key = proto.agent_sock_env,
            .value = if (agent) |a| a.path else null,
        };

        var pty = try Pty.spawnArgv(.{
            .cols = cols,
            .rows = rows,
            .argv = plan.argv,
            .env = env,
        });
        errdefer pty.deinit();

        var s = Session{ .eng = eng, .pty = pty, .epoch = freshEpoch() };
        s.agent_sock = agent_var;
        @memcpy(s.name_buf[0..name.len], name);
        s.name_len = @intCast(name.len);
        return s;
    }

    /// Decoders deliberately do not validate names, so an unfiltered `{s}`
    /// would hand an operator's terminal ANSI and OSC a peer chose and can
    /// repeat.
    pub fn safeName(wire_name: []const u8) []const u8 {
        const name = proto.resolveName(wire_name);
        return if (proto.validSessionName(name)) name else "<invalid>";
    }

    /// Join a live session by name. "" is the default session's wire
    /// spelling. Null means: no such session (and this call never creates).
    pub fn find(self: *SessionTable, wire_name: []const u8) ?usize {
        const name = proto.resolveName(wire_name);
        for (&self.table, 0..) |*slot, si| {
            if (slot.*) |*s| {
                if (std.mem.eql(u8, s.name(), name)) return si;
            }
        }
        return null;
    }

    /// Attach-or-create. Creation demands a size the session can live at — the
    /// SAME threshold `applySize` enforces, since 1x1 is a size clients really
    /// send and gating on merely nonzero creates a session no resize can move.
    /// A 0x0 attach makes no claim at all, and a client with no size must never
    /// be the reason a shell spawns.
    ///
    /// Null is a refusal: bad name, table full, too small, or a failed spawn —
    /// and only the spawn logs, being the one operational cause among four.
    pub fn resolve(self: *SessionTable, srv: *Server, wire_name: []const u8, cols: u16, rows: u16) ?usize {
        const name = proto.resolveName(wire_name);
        if (!proto.validSessionName(name)) return null;
        if (self.find(name)) |si| return si;
        if (cols < min_session_cols or rows < min_session_rows) return null;
        var free: ?usize = null;
        for (&self.table, 0..) |*slot, si| {
            if (slot.* == null) {
                free = si;
                break;
            }
        }
        const si = free orelse return null;
        self.table[si] = create(
            srv.alloc,
            srv.spawn_plan,
            name,
            cols,
            rows,
            AgentRelay.bindSock(srv.alloc, srv.agents.dir, name),
        ) catch |err| {
            std.debug.print("mux d: session {s}: cannot spawn: {s}\n", .{ name, @errorName(err) });
            return null;
        };
        return si;
    }

    /// Tear down every session whose shell has exited: its clients told and
    /// dropped, its slot nulled, its name freed. Answers nothing, because a
    /// shell's exit code is a fact about that shell and never about the daemon:
    /// an emptied table is a daemon with nothing on it, not a daemon that is
    /// leaving. Only `mux d stop` ends one (decisions.md), which is what lets a
    /// later birth take the default session name back.
    pub fn reap(self: *SessionTable, srv: *Server) void {
        for (&self.table, 0..) |*slot, si| {
            const s = if (slot.*) |*sp| sp else continue;
            const exited = s.pty.checkExited();
            // The escalation an accepted end promised, and only that: the
            // teardown below stays the one path, reached on the next pass
            // when checkExited answers. Cleared first so it fires once.
            if (s.end_by_ms) |by| {
                if (exited == null and srv_mod.monoMs() >= by) {
                    s.end_by_ms = null;
                    // ESRCH is the only errno reachable here — the child was
                    // reaped between `checkExited` and this line, which is
                    // the outcome the signal was for. Nothing else can fail:
                    // the daemon owns this pid and SIGKILL is never refused.
                    std.posix.kill(s.pty.child, std.posix.SIG.KILL) catch {};
                }
            }
            if (exited) |code| {
                // THIS session's clients only: `exit_status` is a fact about one
                // shell. Queued then drained under a 250ms deadline, because a
                // client that misses the frame reads EOF, reports a lost
                // connection and exits 1 — losing the shell's real code rather
                // than delaying it. The budget can stack to
                // `max_sessions` x 250ms if the whole table dies at once.
                //
                // One client this cannot reach: one that attaches DURING the
                // drain, since the frames were queued before it existed. That is
                // why the drop loop re-reads the client table.
                for (0..max_clients) |i| {
                    const c = srv.clients[i] orelse continue;
                    if (c.session != si) continue;
                    _ = srv.queueFrame(i, .exit_status, &.{@intCast(code & 0xff)});
                }
                srv.drainPending(250);
                for (0..max_clients) |i| {
                    const c = srv.clients[i] orelse continue;
                    if (c.session == si) srv.dropClient(i);
                }
                // Nulling the slot frees the name for re-creation under a NEW
                // epoch, so a client quoting this instance's seqs resyncs by
                // snapshot. What still has to be ordered is the slot itself:
                // it goes null only after everything below has run, so no
                // pass over the table can find a half-freed session.
                s.freeOwned(srv.alloc);
                s.pty.deinit();
                s.closeAgent(srv.alloc);
                // After the listener, so nothing can be accepted into a session
                // being torn down. Normally a no-op, but a client that reattached
                // elsewhere keeps its channels and is still here to be told.
                srv.agents.closeOfSession(srv, si);
                slot.* = null;
            }
        }
    }

    /// The `sessions_reply` payload: live names, '\n'-separated, in SLOT order —
    /// the only order the daemon has, and stable across replies, so a client
    /// cycling the list sees the same ring unless a session came or went.
    pub fn text(self: *const SessionTable, buf: []u8) []const u8 {
        var w: std.Io.Writer = .fixed(buf);
        for (self.table) |slot| {
            const s = slot orelse continue;
            // Infallible by sessions_text_len's arithmetic: at most
            // max_sessions names, each at most session_name_max long, each
            // paying for its own separator.
            if (w.buffered().len > 0) w.writeByte('\n') catch unreachable;
            w.writeAll(s.name()) catch unreachable;
        }
        return w.buffered();
    }

    /// `sessions=` on the stats main line, and how many tail segments follow.
    pub fn live(self: *const SessionTable) usize {
        return srv_mod.countLive(&self.table);
    }
};