src/gui/runtime.zig
Ref: Size: 17.4 KiB History
//! Pane attachments and reusable owned frame snapshots. Transport threads
//! never touch workspace geometry; notifications carry stable attachment keys.
const std = @import("std");
const client = @import("client");
const term = @import("term");
const model = @import("workspace.zig");
const Pump = client.session_pump.Pump;
pub const Notify = struct { ctx: ?*anyopaque = null, call: ?*const fn (?*anyopaque, model.Attachment) void = null };
pub const Live = struct {
key: model.Attachment,
pump: *Pump,
snapshot: *term.grid.Grid,
snapshot_seq: u64 = 0,
snapshot_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 },
snapshot_follow: ?client.session_pump.FollowPosition = null,
painted_seq: u64 = 0,
view_origin: u32 = 0,
status: client.session_pump.State = .{},
size: term.protocol.Size,
notify: Notify,
pending: std.atomic.Value(bool) = .init(false),
bell_until: i64 = 0,
preserve_snapshot: bool = false,
/// The pane owns the transport strings and must outlive this attachment.
/// Allocate the wake context at its final address before starting its pump.
fn start(alloc: std.mem.Allocator, pane: *const model.Pane, size: term.protocol.Size, notify: Notify, existing_only: bool, retry_initial: bool) !*Live {
const self = try alloc.create(Live);
errdefer alloc.destroy(self);
const grid = try term.grid.Grid.init(alloc, 1, 1);
errdefer grid.deinit();
self.* = .{ .key = .{ .pane = pane.id, .generation = pane.generation }, .pump = undefined, .snapshot = grid, .size = size, .notify = notify };
self.pump = try Pump.start(alloc, .{ .target = pane.identity.target, .session = pane.identity.session, .cols = size.cols, .rows = size.rows, .existing_only = existing_only, .retry_initial = retry_initial, .wake = wake, .wake_ctx = self });
return self;
}
fn destroy(self: *Live, alloc: std.mem.Allocator) void {
// Join before freeing the wake context or the pane's borrowed identity.
self.pump.stop();
self.snapshot.deinit();
alloc.destroy(self);
}
fn wake(ctx: ?*anyopaque) void {
const self: *Live = @ptrCast(@alignCast(ctx.?));
if (self.pending.swap(true, .acq_rel)) return;
if (self.notify.call) |f| f(self.notify.ctx, self.key);
}
pub fn capture(self: *Live, cols: u16, rows: u16) !u32 {
self.pump.mu.lock();
defer self.pump.mu.unlock();
if (self.preserve_snapshot and !self.pump.snapshot_ready) return 0;
self.preserve_snapshot = false;
const src = self.pump.viewGridLocked();
try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows));
self.snapshot_seq = self.pump.replica.last_seq;
self.snapshot_version = self.pump.selectionVersionLocked();
self.snapshot_follow = self.pump.followPositionLocked();
self.view_origin = self.pump.viewOriginLocked();
return self.pump.last_apply_us;
}
};
pub fn copyGrid(dst: *term.grid.Grid, src: *const term.grid.Grid, cols: u16, rows: u16) !void {
if (dst.cols != cols or dst.rows != rows) try dst.resize(cols, rows);
for (dst.lines, 0..) |*row, y| {
const from = src.row(@intCast(y));
// Offsets are copied with their text arena; no borrowed source text
// survives the pump mutex, even if another frame arrives immediately.
try row.text.resize(dst.alloc, from.text.items.len);
@memcpy(row.text.items, from.text.items);
@memcpy(row.cells, from.cells[0..cols]);
}
dst.cursor = src.cursor;
}
pub const Runtime = struct {
alloc: std.mem.Allocator,
workspace: model.Workspace,
lives: [model.max_panes]?*Live = @splat(null),
notify: Notify,
forwarding: ?*client.forward.Manager = null,
pub fn init(alloc: std.mem.Allocator, notify: Notify) Runtime {
return .{ .alloc = alloc, .workspace = model.Workspace.init(alloc), .notify = notify };
}
pub fn deinit(self: *Runtime) void {
if (self.forwarding) |manager| manager.stop();
self.forwarding = null;
for (self.lives) |p| if (p) |live| {
live.pump.say(.quit) catch unreachable;
};
for (self.lives) |p| if (p) |live| {
live.destroy(self.alloc);
};
self.workspace.deinit();
}
pub fn setForwarding(self: *Runtime, manager: *client.forward.Manager) void {
std.debug.assert(self.forwarding == null);
self.forwarding = manager;
}
fn releaseForwardIfUnused(self: *Runtime) void {
const manager = self.forwarding orelse return;
if (self.workspace.hasTarget(manager.target)) return;
self.forwarding = null;
manager.stop();
}
pub fn get(self: *Runtime, id: model.PaneId) ?*Live {
for (self.lives) |p| if (p) |live| {
if (live.key.pane == id) return live;
};
return null;
}
pub fn accepts(self: *Runtime, key: model.Attachment) bool {
const live = self.get(key.pane) orelse return false;
return live.key.generation == key.generation;
}
pub fn add(self: *Runtime, target: client.Target, session: []const u8, width: u32, height: u32, metrics: model.Metrics) !model.PaneId {
return self.addWithPolicy(target, session, width, height, metrics, false);
}
pub fn addWithPolicy(self: *Runtime, target: client.Target, session: []const u8, width: u32, height: u32, metrics: model.Metrics, existing_only: bool) !model.PaneId {
var prepared = try self.workspace.prepare(target, session, width, height, metrics);
errdefer prepared.discard(self.alloc);
const live = try self.attach(prepared.pane, prepared.placement, existing_only, false);
self.workspace.commit(prepared);
return live.key.pane;
}
/// Restore starts independent join-only pumps for the already decoded tree.
pub fn restore(self: *Runtime, layout: *const model.Layout) !void {
for (layout.items()) |placement| {
const pane = self.workspace.pane(placement.id).?;
_ = try self.attach(pane, placement, true, true);
}
}
fn attach(self: *Runtime, pane: *const model.Pane, placement: model.Placement, existing_only: bool, retry_initial: bool) !*Live {
for (&self.lives) |*slot| if (slot.* == null) {
const live = try Live.start(self.alloc, pane, .{ .cols = placement.cols, .rows = placement.rows }, self.notify, existing_only, retry_initial);
slot.* = live;
return live;
};
return error.WorkspaceFull;
}
/// All fallible construction precedes replacement. The old pump joins
/// before its identity is freed; queued notifications retain the old key.
pub fn replace(self: *Runtime, id: model.PaneId, target: client.Target, session: []const u8, placement: model.Placement, keep_snapshot: bool) !void {
const pane = self.workspace.pane(id) orelse return error.MissingPane;
if (pane.generation == std.math.maxInt(u64)) return error.IdExhausted;
var identity = try model.Identity.init(self.alloc, target, session);
errdefer identity.deinit();
const next: model.Pane = .{ .id = id, .generation = pane.generation + 1, .identity = identity };
const live = try Live.start(self.alloc, &next, .{ .cols = placement.cols, .rows = placement.rows }, self.notify, true, true);
errdefer live.destroy(self.alloc);
const old = self.get(id) orelse return error.MissingPane;
if (keep_snapshot) {
try copyGrid(live.snapshot, old.snapshot, old.snapshot.cols, old.snapshot.rows);
live.snapshot_seq = old.snapshot_seq;
live.snapshot_version = old.snapshot_version;
live.view_origin = old.view_origin;
live.preserve_snapshot = true;
}
for (&self.lives) |*slot| if (slot.* == old) {
old.destroy(self.alloc);
pane.identity.deinit();
pane.* = next;
slot.* = live;
break;
};
self.releaseForwardIfUnused();
}
pub fn retry(self: *Runtime, id: model.PaneId, placement: model.Placement) !void {
const pane = self.workspace.pane(id) orelse return error.MissingPane;
try self.replace(id, pane.identity.target, pane.identity.session, placement, true);
}
pub fn remove(self: *Runtime, id: model.PaneId) void {
for (&self.lives) |*slot| if (slot.*) |live| {
if (live.key.pane == id) {
live.destroy(self.alloc);
slot.* = null;
break;
}
};
self.workspace.remove(id);
self.releaseForwardIfUnused();
}
pub fn resize(self: *Runtime, layout: *const model.Layout) !void {
for (layout.items()) |p| {
const live = self.get(p.id) orelse continue;
const size: term.protocol.Size = .{ .cols = p.cols, .rows = p.rows };
if (!std.meta.eql(live.size, size)) {
try live.pump.say(.{ .resize = size });
live.size = size;
}
}
}
pub fn input(self: *Runtime, text: []const u8) !void {
const live = self.get(self.workspace.tab().focus orelse return) orelse return;
switch (live.status.phase) {
.dialing, .attached, .reconnecting => try live.pump.say(.{ .input = text }),
else => {},
}
}
pub fn paste(self: *Runtime, text: []const u8) !void {
const live = self.get(self.workspace.tab().focus orelse return) orelse return;
switch (live.status.phase) {
.dialing, .attached, .reconnecting => try live.pump.say(.{ .paste = text }),
else => {},
}
}
pub fn wheel(self: *Runtime, key: model.Attachment, event: client.session_pump.Wheel) !void {
const live = self.get(key.pane) orelse return;
if (!self.accepts(key) or live.status.phase != .attached) return;
try live.pump.say(.{ .wheel = event });
}
pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, gesture: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void {
const live = self.get(key.pane) orelse return error.MissingPane;
if (!self.accepts(key)) return error.StaleAttachment;
try live.pump.say(.{ .selection = .{ .id = id, .gesture = gesture, .anchor = .{ .row = range.from.row, .col = range.from.col }, .active = .{ .row = range.to.row, .col = range.to.col }, .version = version } });
}
pub fn poll(self: *Runtime, now: i64) bool {
var changed = false;
for (self.lives) |p| if (p) |live| {
changed = live.pending.swap(false, .acq_rel) or changed;
const status = live.pump.state();
if (status.phase != live.status.phase or status.exit_code != live.status.exit_code or status.ending.phase != live.status.ending.phase or status.ending.request != live.status.ending.request) changed = true;
live.status = status;
if (status.bell) {
live.bell_until = now + 200;
changed = true;
}
if (live.bell_until != 0 and now >= live.bell_until) {
live.bell_until = 0;
changed = true;
}
};
return changed;
}
};
test "frozen pane grids own text and survive live mutation and shrink" {
const a = std.testing.allocator;
const src = try term.grid.Grid.init(a, 4, 2);
defer src.deinit();
const dst = try term.grid.Grid.init(a, 1, 1);
defer dst.deinit();
try src.lines[1].text.appendSlice(a, "old");
src.lines[1].cells[2] = .{ .text_len = 3 };
try copyGrid(dst, src, 4, 2);
@memcpy(src.lines[1].text.items, "new");
src.clear();
try std.testing.expectEqualStrings("old", dst.row(1).textOf(dst.row(1).cells[2]));
try copyGrid(dst, src, 2, 1);
try std.testing.expectEqual(@as(u16, 2), dst.cols);
try std.testing.expectEqual(@as(u16, 1), dst.rows);
try std.testing.expectEqual(@as(usize, 0), dst.row(0).text.items.len);
}
fn waitPhase(live: *Live, phase: client.session_pump.Phase) !void {
const until = std.time.milliTimestamp() + 2000;
while (std.time.milliTimestamp() < until) {
if (live.pump.state().phase == phase) return;
std.Thread.sleep(std.time.ns_per_ms);
}
return error.PhaseTimeout;
}
fn forwardingTestPort() !u16 {
const fd = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP);
defer std.posix.close(fd);
const addr = try std.net.Address.parseIp4("127.0.0.1", 0);
try std.posix.bind(fd, &addr.any, addr.getOsSockLen());
try std.posix.listen(fd, 1);
var actual: std.posix.sockaddr.storage = undefined;
var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual));
try std.posix.getsockname(fd, @ptrCast(&actual), &len);
return std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort();
}
fn forwardingCanBind(port: u16) !void {
const fd = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, std.posix.IPPROTO.TCP);
defer std.posix.close(fd);
const addr = try std.net.Address.parseIp4("127.0.0.1", port);
try std.posix.bind(fd, &addr.any, addr.getOsSockLen());
}
test "forwarding lifetime follows target panes rather than focus or session" {
const a = std.testing.allocator;
var rt = Runtime.init(a, .{});
defer rt.deinit();
const m: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
const target: client.Target = .{ .via = "false" };
const first = try rt.add(target, "one", 800, 600, m);
rt.workspace.arm(.beside);
const last = try rt.add(target, "two", 800, 600, m);
_ = rt.workspace.focus(first);
const replace_port = try forwardingTestPort();
const replace_manager = try client.forward.Manager.init(a, target, &.{.{ .local_port = replace_port, .remote_port = 80 }});
rt.setForwarding(replace_manager);
rt.remove(first);
try std.testing.expect(rt.forwarding == replace_manager);
try std.testing.expectEqual(last, rt.workspace.tab().focus.?);
const placement = rt.workspace.layout(800, 600, m).get(last).?;
try rt.replace(last, .{ .via = "true" }, "three", placement, false);
try std.testing.expect(rt.forwarding == null);
try forwardingCanBind(replace_port);
const remove_port = try forwardingTestPort();
const remove_manager = try client.forward.Manager.init(a, .{ .via = "true" }, &.{.{ .local_port = remove_port, .remote_port = 80 }});
rt.setForwarding(remove_manager);
rt.remove(last);
try std.testing.expect(rt.forwarding == null);
try forwardingCanBind(remove_port);
}
test "same-pane replacement is transactional and retry keeps text through an invalid first snapshot" {
const a = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const path = try tmp.dir.realpathAlloc(a, ".");
defer a.free(path);
const command = try std.fmt.allocPrint(a, "cat {s}/snapshot -", .{path});
defer a.free(command);
var snapshot: [34]u8 = @splat(0);
term.protocol.writeSnapshotPrefix(snapshot[0..term.protocol.snapshot_prefix_len], .{ .seq = 37, .history_rows = 0, .cols = 11, .rows = 3, .epoch = 93 });
term.protocol.writeSnapshotCursor(snapshot[term.protocol.snapshot_prefix_len..][0..term.protocol.snapshot_cursor_len], 0, 0);
var bytes: std.ArrayList(u8) = .empty;
defer bytes.deinit(a);
try term.protocol.appendFrame(&bytes, a, .snapshot, snapshot[0..28]);
try tmp.dir.writeFile(.{ .sub_path = "snapshot", .data = bytes.items });
var rt = Runtime.init(a, .{});
defer rt.deinit();
const m: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
const id = try rt.add(.{ .via = command }, "kept", 800, 600, m);
const old = rt.get(id).?;
try waitPhase(old, .failed);
try old.snapshot.lines[0].text.appendSlice(a, "OLD");
old.snapshot.lines[0].cells[0] = .{ .text_len = 3 };
const before = rt.workspace.layout(800, 600, m);
const key = old.key;
var failing = std.testing.FailingAllocator.init(a, .{ .fail_index = 0 });
rt.alloc = failing.allocator();
try std.testing.expectError(error.OutOfMemory, rt.retry(id, before.get(id).?));
rt.alloc = a;
try std.testing.expect(rt.get(id).? == old and rt.accepts(key));
try std.testing.expectEqualStrings("kept", rt.workspace.pane(id).?.identity.session);
try rt.retry(id, before.get(id).?);
const replacement = rt.get(id).?;
try waitPhase(replacement, .failed);
try std.testing.expectEqual(key.generation + 1, replacement.key.generation);
try std.testing.expect(!rt.accepts(key));
try std.testing.expectEqual(id, rt.workspace.tab().focus.?);
try std.testing.expectEqualDeep(before.items(), rt.workspace.layout(800, 600, m).items());
_ = try replacement.capture(11, 3);
try std.testing.expectEqualStrings("OLD", replacement.snapshot.row(0).textOf(replacement.snapshot.row(0).cells[0]));
// A later retry with a fully valid snapshot finally replaces the cache.
bytes.clearRetainingCapacity();
try term.protocol.appendFrame(&bytes, a, .snapshot, &snapshot);
try tmp.dir.writeFile(.{ .sub_path = "snapshot", .data = bytes.items });
try rt.retry(id, before.get(id).?);
try waitPhase(rt.get(id).?, .attached);
_ = try rt.get(id).?.capture(11, 3);
try std.testing.expectEqual(@as(u64, 37), rt.get(id).?.snapshot_seq);
try std.testing.expectEqual(@as(usize, 0), rt.get(id).?.snapshot.row(0).text.items.len);
}