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