╭ changed ─────────────────╮ │src/serve_ssh_protocol.zig│ ╰──────────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │88 // enough that small interactive sessions never need a WINDOW_ADJUST. │ │89 const initial_recv_window: u32 = 1 << 20; │ │90 const max_packet_size: u32 = 32768; │ │91 -const max_incoming_buffered: usize = initial_recv_window; │ │92 -const incoming_refill_threshold: usize = max_incoming_buffered / 2; │ │91 +// ceiling on unread CHANNEL_DATA held for the consumer. window credit is only│ │92 +// ever restored up to what's left of this, so the peer can't send more than │ │93 +// the buffer will take. │ │94 +const max_incoming_buffered: u32 = initial_recv_window; │ │95 │ │96 /// shared with the host's watchdog. `activity` is bumped only for non-ignored│ │97 /// session packets, `authenticated` releases the hard pre-auth deadline, and │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ │169 pub const Event = union(enum) { │ │170 data: []u8, // CHANNEL_DATA payload — caller owns and must free via sess.conn.allocator│ │171 resize: PtySize, // window-change request │ │170 - close, // peer sent EOF or CHANNEL_CLOSE │ │172 + eof, // peer sent CHANNEL_EOF: no more input, but we may still write │ │173 + close, // peer sent CHANNEL_CLOSE, or the transport hit EOF │ │174 }; │ │175 │ │176 /// per-connection transport state: the byte stream, both ciphers, and what a │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │204 // incoming_start) until drained via SessionReader or nextEvent. │ │205 incoming_buffer: std.ArrayList(u8) = .empty, │ │206 incoming_start: usize = 0, │ │207 + // peer sent CHANNEL_EOF — no more input arrives, but the channel stays │ │208 + // open and we may keep writing (RFC 4254 §5.3) │ │209 incoming_eof: bool = false, │ │210 + // peer sent CHANNEL_CLOSE, or the transport died — the channel is gone │ │211 + remote_closed: bool = false, │ │212 + // .eof is handed to the caller once; later pumps wait for the close │ │213 + eof_reported: bool = false, │ │214 // resize seen by the background pump, returned by the next nextEvent call│ │215 pending_resize: ?PtySize = null, │ │216 // reused per-chunk message buffer for writeChannel │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │235 } │ │236 │ │237 // append to the incoming buffer, first compacting away the consumed │ │229 - // prefix so the buffer only ever holds unread bytes │ │238 + // prefix so the buffer only ever holds unread bytes. bounded by the │ │239 + // receive window, which is never credited past max_incoming_buffered. │ │240 fn appendIncoming(self: *SessionCtx, payload: []const u8) !void { │ │241 if (self.incoming_start > 0) { │ │242 const remaining = self.incoming_buffer.items.len - self.incoming_start;│ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │244 self.incoming_buffer.shrinkRetainingCapacity(remaining); │ │245 self.incoming_start = 0; │ │246 } │ │237 - if (payload.len > max_incoming_buffered - self.incoming_buffer.items.len) {│ │238 - return error.IncomingBufferExceeded; │ │239 - } │ │247 try self.incoming_buffer.appendSlice(self.conn.allocator, payload); │ │248 } │ │249 │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────╮ │269 self.consumeIncoming(payload.len); │ │270 return .{ .data = payload }; │ │271 } │ │265 - if (self.incoming_eof) { │ │266 - self.closed = true; │ │267 - return .close; │ │272 + if (self.remote_closed) return .close; │ │273 + if (self.incoming_eof and !self.eof_reported) {│ │274 + self.eof_reported = true; │ │275 + return .eof; │ │276 } │ │277 try self.processOneBackgroundPacket(); │ │278 } │ ╰────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │301 var rest = bytes; │ │302 while (rest.len > 0) { │ │303 while (self.channel.remote_window == 0) { │ │296 - if (self.incoming_eof) return error.RemoteClosed; │ │304 + if (self.remote_closed) return error.RemoteClosed; │ │305 try self.processOneBackgroundPacket(); │ │306 } │ │307 // also clamp to our own max so the chunk fits writePacket's buffer│ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │331 const packet = readSessionPacket(conn) catch |err| switch (err) {│ │332 error.EndOfStream => { │ │333 self.incoming_eof = true; │ │334 + self.remote_closed = true; │ │335 return; │ │336 }, │ │337 else => return err, │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │369 try replyChannelRequest(conn, self.channel, want_reply, false);│ │370 } │ │371 }, │ │363 - SSH_MSG_CHANNEL_EOF, SSH_MSG_CHANNEL_CLOSE => { │ │372 + SSH_MSG_CHANNEL_EOF => { │ │373 + try parseChannelId(packet, self.channel.local_id); │ │374 + self.incoming_eof = true; │ │375 + }, │ │376 + SSH_MSG_CHANNEL_CLOSE => { │ │377 try parseChannelId(packet, self.channel.local_id); │ │378 self.incoming_eof = true; │ │379 + self.remote_closed = true; │ │380 }, │ │381 SSH_MSG_GLOBAL_REQUEST => try handleGlobalRequest(conn, packet), │ │368 - else => {}, │ │382 + else => try sendUnimplemented(conn), │ │383 } │ │384 } │ │385 │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │386 fn maybeRefillRecvWindowForBufferedInput(self: *SessionCtx) !void { │ │373 - if (self.incomingBytes().len >= incoming_refill_threshold) return; │ │374 - try maybeRefillRecvWindow(self.conn, self.channel); │ │387 + // credit only what the incoming buffer can still take │ │388 + const buffered: u32 = @intCast(self.incomingBytes().len); │ │389 + try maybeRefillRecvWindow(self.conn, self.channel, max_incoming_buffered - buffered);│ │390 } │ │391 │ │392 /// signal the consumer's exit status and tear the channel down. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯