Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions src/client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome {
var raw = saved;
rawMode(&raw);
try posix.tcsetattr(tty, .FLUSH, raw);
defer restoreTty(tty, saved);
// Set by the outcome paths below when a held C-d may still be
// repeating; read by the deferred restore.
var drain_guard_ms: i64 = drain_guard_short_ms;
defer restoreTty(tty, saved, drain_guard_ms);
try protocol.writeAll(1, enter_sequence);

// Handshake with our current size.
Expand Down Expand Up @@ -143,9 +146,19 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome {
switch (msg.type) {
.output => try protocol.writeAll(1, msg.payload),
.detached => {
return if (std.mem.eql(u8, msg.payload, "stolen")) .stolen else .detached;
if (std.mem.eql(u8, msg.payload, "stolen")) return .stolen;
if (std.mem.eql(u8, msg.payload, "detached-eof")) {
drain_guard_ms = drain_guard_eof_ms;
}
return .detached;
},
.exit => {
// Sessions often end because the user typed
// C-d at the session's shell; treat the tail
// as EOF-dangerous.
drain_guard_ms = drain_guard_eof_ms;
return .ended;
},
.exit => return .ended,
else => {},
}
}
Expand Down Expand Up @@ -176,8 +189,63 @@ pub fn rawMode(t: *posix.termios) void {
t.cc[@intFromEnum(posix.V.TIME)] = 0;
}

fn restoreTty(tty: posix.fd_t, saved: posix.termios) void {
/// Read and discard terminal input until it goes quiet.
///
/// When a detach is triggered by a key the user is still holding, the
/// terminal keeps producing input after the daemon has already decided
/// to detach: auto-repeats of the command key, kitty release reports,
/// impatient re-presses, and (on a remote connection) anything in
/// flight during the round trip. The final TCSAFLUSH only discards
/// what has reached the tty queue at that instant, so without this
/// wait the tail is delivered to the shell that regains the terminal:
/// a stray `d` typed at the prompt, or worse, a leaked C-d that EOFs
/// the login shell and ends the SSH session.
///
/// Runs while the terminal is still in raw mode, so the discarded
/// bytes are never echoed. Two timers bound the wait: `guard_ms`
/// covers the silence between the triggering press and the first
/// auto-repeat (keyboard repeat delays reach ~660ms on common
/// configurations, so EOF-dangerous detaches use the long guard),
/// then each absorbed chunk extends the wait by a short tail until
/// the input stays quiet, all capped at drain_cap_ms.
fn drainInput(tty: posix.fd_t, guard_ms: i64) void {
const start = std.time.milliTimestamp();
const cap = start + drain_cap_ms;
var deadline = start + guard_ms;
var buf: [256]u8 = undefined;
while (true) {
const now = std.time.milliTimestamp();
const until = @min(deadline, cap);
if (now >= until) return;
var fds = [_]posix.pollfd{
.{ .fd = tty, .events = posix.POLL.IN, .revents = 0 },
};
const ready = posix.poll(&fds, @intCast(until - now)) catch return;
if (ready == 0) return;
const n = posix.read(tty, &buf) catch return;
if (n == 0) return;
deadline = @max(deadline, std.time.milliTimestamp() + drain_tail_ms);
}
}

/// Guard for detaches with no reason to expect a held key.
const drain_guard_short_ms = 300;
/// Guard for flows where the user plausibly holds C-d, the byte that
/// EOFs a cooked-mode shell: a C-a C-d detach and a session that ends
/// while attached (often a C-d typed at the session's own shell).
const drain_guard_eof_ms = 800;
const drain_tail_ms = 100;
const drain_cap_ms = 1500;

fn restoreTty(tty: posix.fd_t, saved: posix.termios, guard_ms: i64) void {
// Screen restore first: the user sees the detach immediately, and
// a kitty-mode terminal stops CSI-u key reporting as soon as the
// reset reaches it, so a still-held key repeats in legacy bytes
// that the drain below absorbs. Only then hand the tty back; the
// FLUSH discards anything that slips in between the last drained
// read and the mode switch.
protocol.writeAll(1, restore_sequence) catch {};
drainInput(tty, guard_ms);
posix.tcsetattr(tty, .FLUSH, saved) catch {};
}

Expand Down
13 changes: 12 additions & 1 deletion src/daemon.zig
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,22 @@ pub const Daemon = struct {
}

fn handleKeyCommand(self: *Daemon, conn: *Conn, cmd: keys.Command) !void {
// A detach earlier in the same input batch ended the
// attachment. Bytes after the detach key (auto-repeats of a
// held C-d arriving coalesced, or keys typed during the
// detach round trip) belong to no window; forwarding them
// would EOF or garble the program the user just left.
if (!conn.attached) return;
switch (cmd) {
.forward => |bytes| if (self.liveWindow()) |w| {
w.writeInput(bytes) catch {};
},
.detach => self.detachConn(conn, "detached"),
.detach => |byte| self.detachConn(
conn,
// A C-d triggered detach warns the client that the
// user may be holding the byte that EOFs shells.
if (byte == 0x04) "detached-eof" else "detached",
),
.redraw => try self.repaintTo(conn),
.unknown => |byte| if (std.ascii.isPrint(byte))
self.message(conn, "unknown key: ^A {c}", .{byte})
Expand Down
36 changes: 19 additions & 17 deletions src/keys.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ pub const escape_byte: u8 = 0x01; // C-a
pub const Command = union(enum) {
/// Bytes to forward to the window.
forward: []const u8,
detach,
/// The command key that triggered the detach; C-d (0x04) marks
/// the detach as EOF-dangerous for the client's input drain.
detach: u8,
redraw,
unknown: u8,
};
Expand Down Expand Up @@ -216,7 +218,7 @@ pub const Parser = struct {

fn dispatch(byte: u8, handler: anytype) !void {
switch (byte) {
'd', 0x04 => try handler.command(.detach),
'd', 0x04 => try handler.command(.{ .detach = byte }),
'l', 0x0c => try handler.command(.redraw),
'a' => try handler.command(.{ .forward = &.{escape_byte} }),
else => try handler.command(.{ .unknown = byte }),
Expand Down Expand Up @@ -299,7 +301,7 @@ test "prefix commands" {
try std.testing.expectEqualStrings("abdef", h.forwarded.items);
try std.testing.expectEqual(@as(usize, 2), h.cmds.items.len);
try std.testing.expectEqual(Command.redraw, h.cmds.items[0]);
try std.testing.expectEqual(Command.detach, h.cmds.items[1]);
try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[1]);
}

test "literal escape via C-a a" {
Expand All @@ -320,7 +322,7 @@ test "prefix split across feeds" {
try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len);
try p.feed("d", false, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]);
}

test "control variants match screen defaults" {
Expand All @@ -329,7 +331,7 @@ test "control variants match screen defaults" {
var p: Parser = .{};
try p.feed("\x01\x04\x01\x0c", false, &h);
try std.testing.expectEqual(@as(usize, 2), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]);
try std.testing.expectEqual(Command.redraw, h.cmds.items[1]);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}
Expand All @@ -342,7 +344,7 @@ test "holding the prefix key stays armed until a command key" {
// the window (an unconsumed 0x04 would EOF a shell).
try p.feed("\x01\x01\x01\x04", false, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}

Expand All @@ -360,7 +362,7 @@ test "kitty: encoded Ctrl+A starts the prefix, plain d detaches" {
var p: Parser = .{};
try p.feed("\x1b[97;5ud", true, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}

Expand All @@ -370,7 +372,7 @@ test "kitty: encoded Ctrl+A then encoded Ctrl+D detaches" {
var p: Parser = .{};
try p.feed("\x1b[97;5u\x1b[100;5u", true, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}

Expand All @@ -380,7 +382,7 @@ test "kitty: report-all plain d arrives as CSI-u and detaches" {
var p: Parser = .{};
try p.feed("\x1b[97;5u\x1b[100u", true, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]);
}

test "kitty: sequence split across feeds" {
Expand All @@ -391,7 +393,7 @@ test "kitty: sequence split across feeds" {
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
try p.feed("ud", true, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]);
}

test "kitty: press and release events" {
Expand All @@ -401,7 +403,7 @@ test "kitty: press and release events" {
// Press (explicit event), release while pending, then the command.
try p.feed("\x1b[97;5:1u\x1b[97;5:3ud", true, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
// A stray prefix release outside a pending sequence is swallowed.
try p.feed("\x1b[97;5:3u", true, &h);
Expand All @@ -416,7 +418,7 @@ test "kitty: prefix auto-repeat stays armed" {
// With event types: press, repeat, then encoded Ctrl+D.
try p.feed("\x1b[97;5u\x1b[97;5:2u\x1b[100;5u", true, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}

Expand All @@ -427,7 +429,7 @@ test "kitty: prefix repeat without event types stays armed" {
// Without the event-types flag a repeat looks like a second press.
try p.feed("\x1b[97;5u\x1b[97;5u\x1b[100;5u", true, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}

Expand All @@ -439,7 +441,7 @@ test "kitty: modifier key events while armed do not eat the command" {
// (kitty report-all-keys flag) is not the command key.
try p.feed("\x1b[97;5u\x1b[57442;5u\x1b[100;5u", true, &h);
try std.testing.expectEqual(@as(usize, 1), h.cmds.items.len);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]);
try std.testing.expectEqual(@as(usize, 0), h.forwarded.items.len);
}

Expand All @@ -449,7 +451,7 @@ test "kitty: lock modifiers do not hide the prefix" {
var p: Parser = .{};
// mods 69 = 1 + ctrl(4) + caps lock(64).
try p.feed("\x1b[97;69ud", true, &h);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]);
}

test "kitty: other CSI-u keys pass through verbatim" {
Expand Down Expand Up @@ -487,7 +489,7 @@ test "kitty: raw 0x01 still works" {
defer h.deinit();
var p: Parser = .{};
try p.feed("\x01d", true, &h);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 'd' }, h.cmds.items[0]);
}

test "kitty: CSI-u encodings pass through when kitty mode is off" {
Expand Down Expand Up @@ -517,5 +519,5 @@ test "kitty: pending command key in CSI-u form split across feeds" {
try p.feed("\x1b[97;5u\x1b[100;", true, &h);
try std.testing.expectEqual(@as(usize, 0), h.cmds.items.len);
try p.feed("5u", true, &h);
try std.testing.expectEqual(Command.detach, h.cmds.items[0]);
try std.testing.expectEqual(Command{ .detach = 0x04 }, h.cmds.items[0]);
}
Loading
Loading