╭ changed ────────╮ │src/serve_ssh.zig│ ╰─────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │19 const preauth_timeout_ticks: u32 = 6; // 30 seconds │ │20 const idle_timeout_ticks: u32 = 24; // 120 seconds │ │21 const close_drain_timeout_ticks: u32 = 2; // 5-10 seconds │ │22 +const escape_timeout: std.Io.Timeout = .{ .duration = .{ .raw = .fromMilliseconds(25), .clock = .awake } };│ │23 │ │24 var active_connections: std.atomic.Value(u32) = .init(0); │ │25 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────╮ │89 }; │ │90 defer watchdog_future.cancel(ctx.io); │ │91 │ │91 - var recv_buf: [4096]u8 = undefined; │ │92 + // timed peeks need room for a complete encrypted packet│ │93 + var recv_buf: [ssh.packet_buffer_size]u8 = undefined; │ │94 var send_buf: [4096]u8 = undefined; │ │95 var stream_reader = stream.reader(ctx.io, &recv_buf); │ │96 var stream_writer = stream.writer(ctx.io, &send_buf); │ ╰────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮ │230 // for each event, rebuild the widget tree and re-render so the user │ │231 // sees the effect of their input on the same iteration. │ │232 event_loop: while (true) { │ │231 - const event = try sess.nextEvent(); │ │233 + const event = try nextTuiEvent(sess, if (terminal_maybe) |*terminal| terminal else null);│ │234 if (terminal_maybe) |*terminal| { │ │233 - switch (event) { │ │235 + if (event) |ev| switch (ev) { │ │236 .data => |payload| { │ │237 defer allocator.free(payload); │ │238 try terminal.writeBytes(payload); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │237 - while (terminal.popKey()) |key| { │ │238 - try ui.inputKey(allocator, &nav.root, key, &ui_session); │ │239 - } │ │239 }, │ │240 .resize => |sz| { │ │241 terminal_size = .{ .width = sz.width_cells, .height = sz.height_cells };│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │242 terminal.pushResize(terminal_size); │ │244 - while (terminal.popKey()) |key| { │ │245 - try ui.inputKey(allocator, &nav.root, key, &ui_session); │ │246 - } │ │243 }, │ │244 .eof, .close => break :event_loop, │ │245 + }; │ │246 + while (terminal.popKey()) |key| { │ │247 + try ui.inputKey(allocator, &nav.root, key, &ui_session); │ │248 } │ │249 } else { │ │251 - switch (event) { │ │250 + switch (event.?) { │ │251 .data => |payload| { │ │252 defer allocator.free(payload); │ │253 if (std.mem.indexOfAny(u8, payload, "\r\n") == null) continue;│ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │295 } │ │296 } │ │297 │ │298 +// only a lone ESC needs a deadline; longer CSI/SS3 fragments must remain │ │299 +// buffered until complete. a timeout leaves partial SSH packets in the reader. │ │300 +pub fn nextTuiEvent(sess: *ssh.SessionCtx, terminal: ?*StreamTerminal) !?ssh.Event {│ │301 + if (terminal) |t| { │ │302 + if (t.parser.esc_len == 1) { │ │303 + sess.conn.read_timeout = escape_timeout.toDeadline(sess.conn.io); │ │304 + defer sess.conn.read_timeout = .none; │ │305 + return sess.nextEvent() catch |err| switch (err) { │ │306 + error.Timeout => { │ │307 + try t.flushEscape(); │ │308 + return null; │ │309 + }, │ │310 + else => return err, │ │311 + }; │ │312 + } │ │313 + } │ │314 + return try sess.nextEvent(); │ │315 +} │ │316 + │ │317 // --------------------------------------------------------------------------- │ │318 // git path (exec) │ │319 // --------------------------------------------------------------------------- │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │40 pub const host_key_file_name = "ssh_host_ed25519_key"; │ │41 │ │42 const max_packet_len: u32 = 35000; // RFC 4253 §6.1 — minimum implementations must support│ │43 +pub const packet_buffer_size = 4 + max_packet_len + Poly1305.mac_length; │ │44 const max_name_list_len: u32 = 4096; │ │45 const max_auth_attempts: u32 = 20; │ │46 const max_exit_drain_packets: u32 = 32; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │47 const max_banner_lines: u32 = 32; │ │48 +// terminal grids can have several live copies during layout and rendering│ │49 +const max_pty_width: u32 = 512; │ │50 +const max_pty_height: u32 = 128; │ │51 │ │52 // SSH message type bytes (RFC 4250 §4.1) │ │53 pub const SSH_MSG_DISCONNECT: u8 = 1; │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │189 sc_cipher: Cipher, │ │190 rekey: RekeyState, │ │191 idle: ?*IdleState = null, │ │192 + // set only while the TUI waits to resolve a lone escape key │ │193 + read_timeout: std.Io.Timeout = .none, │ │194 + │ │195 + fn readPacket(self: *Conn) ![]u8 { │ │196 + if (self.read_timeout != .none) { │ │197 + // peek keeps partial packets in the reader if the timer wins. │ │198 + // join both tasks before consuming bytes or changing cipher state. │ │199 + const Result = union(enum) { ready: anyerror!void, timeout: std.Io.Cancelable!void }; │ │200 + var results: [2]Result = undefined; │ │201 + var select = std.Io.Select(Result).init(self.io, &results); │ │202 + defer select.cancelDiscard(); │ │203 + try select.concurrent(.ready, Cipher.peekPacket, .{ &self.cs_cipher, self.reader }); │ │204 + try select.concurrent(.timeout, std.Io.Timeout.sleep, .{ self.read_timeout, self.io });│ │205 + switch (try select.await()) { │ │206 + .ready => |result| try result, │ │207 + .timeout => |result| { │ │208 + try result; │ │209 + return error.Timeout; │ │210 + }, │ │211 + } │ │212 + } │ │213 + return self.cs_cipher.readPacket(self.allocator, self.reader); │ │214 + } │ │215 }; │ │216 │ │217 /// session bridge handed to the consumer's handleSession callback. exposes a │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────╮ │294 if (self.incomingBytes().len > 0) { │ │295 // hand the buffered bytes to the caller (caller owns) │ │296 const payload = try self.conn.allocator.dupe(u8, self.incomingBytes());│ │297 + errdefer self.conn.allocator.free(payload); │ │298 self.consumeIncoming(payload.len); │ │299 + try self.maybeRefillRecvWindowForBufferedInput(); │ │300 return .{ .data = payload }; │ │301 } │ │302 if (self.remote_closed) return .close; │ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯