Bound terminal output under sustained load
ci / test (push) Canceled after 0s
ci / fuzz-smoke (push) Canceled after 0s
ci / macos-client (macos-aarch64, macos-14) (push) Canceled after 0s
ci / macos-client (macos-x86_64, macos-13) (push) Canceled after 0s
ci / windows-client (push) Canceled after 0s
ci / package-release (linux-x86_64, ubuntu-latest, , , ) (push) Canceled after 0s
ci / package-release (macos-aarch64, macos-14, , , ) (push) Canceled after 0s
ci / package-release (macos-x86_64, macos-13, , , ) (push) Canceled after 0s
ci / package-release (windows-aarch64, windows-latest, aarch64, windows, aarch64-pc-windows-msvc) (push) Canceled after 0s
ci / package-release (windows-x86_64, windows-latest, , , ) (push) Canceled after 0s
ci / remote-bench (push) Canceled after 0s
ci / publish-gitea-release (push) Canceled after 0s

This commit is contained in:
DuProcess
2026-07-17 19:08:56 -04:00
parent d4de2915a1
commit d0917c952f
3 changed files with 75 additions and 38 deletions
+1
View File
@@ -2509,6 +2509,7 @@ async fn proxy_stdio_loop(mut transport: DoshTransport, stream_id: u64) -> Resul
})?;
let mut stdout = tokio::io::stdout();
let mut maintenance = tokio::time::interval(Duration::from_millis(50));
maintenance.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut stdin_closed = false;
loop {
tokio::select! {
+33 -28
View File
@@ -27,7 +27,9 @@ use dosh::protocol::{
StreamClose, StreamData, StreamEof, StreamOpen, StreamOpenOk, StreamOpenReject,
StreamWindowAdjust, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
};
use dosh::pty::{PtyHandle, PtyOutput, adopt_pty_from_fd, spawn_pty_session};
use dosh::pty::{
PTY_OUTPUT_QUEUE_CAPACITY, PtyHandle, PtyOutput, adopt_pty_from_fd, spawn_pty_session,
};
use dosh::udp::{is_transient_udp_error, is_transient_udp_send_error};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
@@ -209,7 +211,7 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
],
);
let (pty_tx, mut pty_rx) = mpsc::unbounded_channel();
let (pty_tx, mut pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let state = Arc::new(Mutex::new(ServerState::new(
config.clone(),
secret,
@@ -242,6 +244,7 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
let retransmit_socket = Arc::clone(&socket);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(100));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
if let Err(err) = retransmit_pending(&retransmit_state, &retransmit_socket).await {
@@ -256,6 +259,7 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
let cleanup_state = Arc::clone(&state);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
cleanup_disconnected_clients(&cleanup_state);
@@ -270,6 +274,7 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
let flush_state = Arc::clone(&state);
tokio::spawn(async move {
let mut interval = tokio::time::interval(SCREEN_PERSIST_MAX_AGE);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
flush_persistent_screens(&flush_state);
@@ -293,7 +298,7 @@ async fn serve(config_path: Option<std::path::PathBuf>) -> Result<()> {
struct ServerState {
config: ServerConfig,
secret: [u8; 32],
pty_tx: mpsc::UnboundedSender<PtyOutput>,
pty_tx: mpsc::Sender<PtyOutput>,
sessions: HashMap<String, Session>,
pending_native: HashMap<[u8; 16], PendingNativeAuth>,
next_server_stream_id: u64,
@@ -504,7 +509,7 @@ impl ServerState {
fn new(
config: ServerConfig,
secret: [u8; 32],
pty_tx: mpsc::UnboundedSender<PtyOutput>,
pty_tx: mpsc::Sender<PtyOutput>,
) -> Self {
let per_minute = config.native_auth_rate_limit_per_minute;
Self {
@@ -4517,7 +4522,7 @@ mod tests {
#[test]
fn persists_terminal_sessions_when_enabled() {
let (pty_tx, _rx) = mpsc::unbounded_channel();
let (pty_tx, _rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let config = ServerConfig {
persist_sessions: true,
prewarm_sessions: vec!["default".to_string()],
@@ -4531,7 +4536,7 @@ mod tests {
#[test]
fn persist_disabled_never_persists() {
let (pty_tx, _rx) = mpsc::unbounded_channel();
let (pty_tx, _rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let config = ServerConfig {
persist_sessions: false,
prewarm_sessions: vec!["default".to_string()],
@@ -4655,7 +4660,7 @@ mod tests {
#[tokio::test]
async fn no_client_pty_output_before_first_reattach_keeps_restored_screen() {
let restored = b"\x1b[?1049lRESTORED_AFTER_RESTART".to_vec();
let (pty_tx, _rx) = mpsc::unbounded_channel();
let (pty_tx, _rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let state = Arc::new(Mutex::new(ServerState::new(
ServerConfig {
persist_sessions: true,
@@ -4733,7 +4738,7 @@ mod tests {
#[tokio::test]
async fn unknown_resume_reject_keeps_client_id() {
let (pty_tx, _rx) = mpsc::unbounded_channel();
let (pty_tx, _rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let state = Arc::new(Mutex::new(ServerState::new(
ServerConfig::default(),
[0u8; 32],
@@ -4957,7 +4962,7 @@ mod tests {
#[test]
fn client_index_stays_in_sync_with_session_clients() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
state
.ensure_session("work", 80, 24, "forward-only", &[])
@@ -5004,7 +5009,7 @@ mod tests {
#[test]
fn cleanup_purges_timed_out_clients_from_index() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let config = ServerConfig {
client_timeout_secs: 1,
..ServerConfig::default()
@@ -5052,7 +5057,7 @@ mod tests {
#[tokio::test]
async fn forged_plaintext_detach_does_not_remove_client() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
state
.ensure_session("work", 80, 24, "forward-only", &[])
@@ -5078,7 +5083,7 @@ mod tests {
#[tokio::test]
async fn authenticated_detach_removes_client() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
state
.ensure_session("work", 80, 24, "forward-only", &[])
@@ -5109,7 +5114,7 @@ mod tests {
#[tokio::test]
async fn duplicate_stream_open_for_open_stream_resends_ok() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [11u8; 16];
let session_key = [12u8; 32];
@@ -5173,7 +5178,7 @@ mod tests {
#[tokio::test]
async fn retired_stream_open_is_rejected_not_reopened() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [15u8; 16];
let session_key = [16u8; 32];
@@ -5243,7 +5248,7 @@ mod tests {
#[tokio::test]
async fn pending_server_stream_open_is_retransmitted() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [13u8; 16];
let session_key = [14u8; 32];
@@ -5298,7 +5303,7 @@ mod tests {
#[tokio::test]
async fn server_stream_retransmit_uses_observed_rtt() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [15u8; 16];
let session_key = [16u8; 32];
@@ -5357,7 +5362,7 @@ mod tests {
#[test]
fn forward_only_session_does_not_allocate_pty() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
state
@@ -5369,7 +5374,7 @@ mod tests {
#[test]
fn cleanup_reaps_abandoned_sessions_but_keeps_prewarmed() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let config = ServerConfig {
client_timeout_secs: 1,
prewarm_sessions: vec!["default".to_string()],
@@ -5454,7 +5459,7 @@ mod tests {
#[tokio::test]
async fn stream_data_waits_for_open_and_credit() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [7u8; 16];
let session_key = [9u8; 32];
@@ -5558,7 +5563,7 @@ mod tests {
#[tokio::test]
async fn stream_eof_to_client_does_not_retire_stream() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [13u8; 16];
let session_key = [14u8; 32];
@@ -5647,7 +5652,7 @@ mod tests {
#[tokio::test]
async fn pending_server_stream_eof_is_retransmitted() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [17u8; 16];
let session_key = [18u8; 32];
@@ -5707,7 +5712,7 @@ mod tests {
#[tokio::test]
async fn pending_server_stream_close_is_retransmitted() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [19u8; 16];
let session_key = [20u8; 32];
@@ -5763,7 +5768,7 @@ mod tests {
#[tokio::test]
async fn pending_server_stream_close_expires_after_attempt_cap() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [21u8; 16];
let session_key = [22u8; 32];
@@ -5808,7 +5813,7 @@ mod tests {
#[tokio::test]
async fn pending_server_stream_window_adjust_is_retransmitted() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [23u8; 16];
let session_key = [24u8; 32];
@@ -5871,7 +5876,7 @@ mod tests {
#[tokio::test]
async fn pending_server_stream_window_adjust_expires_after_attempt_cap() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [25u8; 16];
let session_key = [26u8; 32];
@@ -5922,7 +5927,7 @@ mod tests {
#[tokio::test]
async fn server_stream_send_splits_large_writes() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [11u8; 16];
let session_key = [12u8; 32];
@@ -6029,7 +6034,7 @@ mod tests {
#[tokio::test]
async fn blocked_stream_data_does_not_block_terminal_frames() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let mut state = ServerState::new(ServerConfig::default(), [0u8; 32], pty_tx);
let client_id = [8u8; 16];
let session_key = [10u8; 32];
@@ -6134,7 +6139,7 @@ mod tests {
#[tokio::test]
async fn live_terminal_output_is_never_replaced_by_paced_snapshots() {
let (pty_tx, _pty_rx) = mpsc::unbounded_channel();
let (pty_tx, _pty_rx) = mpsc::channel(PTY_OUTPUT_QUEUE_CAPACITY);
let config = ServerConfig {
output_frame_interval_ms: 1000,
..ServerConfig::default()
+41 -10
View File
@@ -12,6 +12,10 @@ use tokio::sync::mpsc;
// TUIs often write several KiB on the first draw; sending that as one UDP
// datagram can fragment and vanish, leaving only a blank alternate screen.
const PTY_OUTPUT_CHUNK_BYTES: usize = 1024;
/// Shared server queue bound. At the current chunk size this limits queued PTY
/// output to roughly 1 MiB before the producing shell receives normal PTY
/// backpressure.
pub const PTY_OUTPUT_QUEUE_CAPACITY: usize = 1024;
/// Backing for a PTY master held by the server.
///
@@ -126,7 +130,7 @@ pub fn spawn_pty_session(
cols: u16,
rows: u16,
env: &[(String, String)],
tx: mpsc::UnboundedSender<PtyOutput>,
tx: mpsc::Sender<PtyOutput>,
) -> Result<PtyHandle> {
let pty_system = NativePtySystem::default();
let pair = pty_system
@@ -232,7 +236,7 @@ pub fn build_shell_command(shell: &str, env: &[(String, String)]) -> CommandBuil
pub fn adopt_pty_from_fd(
session: String,
master_fd: RawFd,
tx: mpsc::UnboundedSender<PtyOutput>,
tx: mpsc::Sender<PtyOutput>,
) -> Result<PtyHandle> {
// Take ownership of the fd. A clone gives us an independent reader so the
// reader thread and the writer/resize side hold separate `File`s and don't
@@ -252,7 +256,7 @@ pub fn adopt_pty_from_fd(
fn spawn_reader_thread(
session: String,
mut reader: Box<dyn Read + Send>,
tx: mpsc::UnboundedSender<PtyOutput>,
tx: mpsc::Sender<PtyOutput>,
) -> Result<()> {
let reader_session = session.clone();
thread::Builder::new()
@@ -262,7 +266,7 @@ fn spawn_reader_thread(
loop {
match reader.read(&mut buf) {
Ok(0) => {
let _ = tx.send(PtyOutput {
let _ = tx.blocking_send(PtyOutput {
session: reader_session.clone(),
bytes: Vec::new(),
exited: true,
@@ -271,15 +275,20 @@ fn spawn_reader_thread(
}
Ok(n) => {
for chunk in buf[..n].chunks(PTY_OUTPUT_CHUNK_BYTES) {
let _ = tx.send(PtyOutput {
session: reader_session.clone(),
bytes: chunk.to_vec(),
exited: false,
});
if tx
.blocking_send(PtyOutput {
session: reader_session.clone(),
bytes: chunk.to_vec(),
exited: false,
})
.is_err()
{
return;
}
}
}
Err(_) => {
let _ = tx.send(PtyOutput {
let _ = tx.blocking_send(PtyOutput {
session: reader_session.clone(),
bytes: Vec::new(),
exited: true,
@@ -298,6 +307,28 @@ mod tests {
use super::*;
const _: () = assert!(PTY_OUTPUT_CHUNK_BYTES <= 1200);
const _: () = assert!(PTY_OUTPUT_QUEUE_CAPACITY * PTY_OUTPUT_CHUNK_BYTES <= 1024 * 1024);
#[test]
fn pty_output_queue_capacity_is_memory_bounded() {
let (tx, _rx) = mpsc::channel::<PtyOutput>(PTY_OUTPUT_QUEUE_CAPACITY);
for index in 0..PTY_OUTPUT_QUEUE_CAPACITY {
tx.try_send(PtyOutput {
session: "load".to_string(),
bytes: vec![index as u8; PTY_OUTPUT_CHUNK_BYTES],
exited: false,
})
.unwrap();
}
assert!(matches!(
tx.try_send(PtyOutput {
session: "load".to_string(),
bytes: vec![0; PTY_OUTPUT_CHUNK_BYTES],
exited: false,
}),
Err(mpsc::error::TrySendError::Full(_))
));
}
#[test]
fn terminfo_available_detects_known_and_unknown() {