From a41b7601fc12b091ed73dff8bf7c9aade72693d1 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Tue, 18 Aug 2026 17:25:16 -0600 Subject: [PATCH 1/4] Handle server disconnects in interactive jobs Co-Authored-By: Claude Mythos 5 --- client/src/interactive.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/interactive.rs b/client/src/interactive.rs index 3de053c1..e88f2874 100644 --- a/client/src/interactive.rs +++ b/client/src/interactive.rs @@ -68,9 +68,10 @@ where buffer.truncate(0); } - // Handle a message from the server. - Some(Ok(message)) = stream.next() => { - match Message::try_from(message)? { + // Handle a message from the server, or its disconnection. + message = stream.next() => { + let Some(message) = message else { break }; + match Message::try_from(message?)? { Message::Control(control) => match control { Control::WindowChange(new) => { set_window_size(stdin, &mut stdout_async, new.clone()).await?; From d987746689c85f1baf99e05c3db0f446c8034f4f Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Tue, 18 Aug 2026 17:29:23 -0600 Subject: [PATCH 2/4] Spell the interactive escape character visibly Co-Authored-By: Claude Mythos 5 --- client/src/interactive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/interactive.rs b/client/src/interactive.rs index e88f2874..71600379 100644 --- a/client/src/interactive.rs +++ b/client/src/interactive.rs @@ -61,7 +61,7 @@ where // Relay input from the terminal. Ok(n) = stdin_async.read_buf(&mut buffer) => { // EOF or the telnet(1) “escape character” - if n == 0 || buffer == "" { + if n == 0 || buffer == "\x1d" { break; } stream.send(Message::Data(buffer.copy_to_bytes(n)).try_into()?).await?; From f671b6b4f1b5f4717a1371455d3f38f6b33996c0 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Tue, 18 Aug 2026 17:35:44 -0600 Subject: [PATCH 3/4] Restore the terminal mode of both stdin and stdout Co-Authored-By: Claude Mythos 5 --- client/src/interactive.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/client/src/interactive.rs b/client/src/interactive.rs index 71600379..8abb0fce 100644 --- a/client/src/interactive.rs +++ b/client/src/interactive.rs @@ -34,8 +34,8 @@ where let mut stdout = stdout(); let mode = raw_mode(&stdin)?; let result = interactive_job_inner(&mut stdin, &mut stdout, stream).await; - restore_mode(&stdin, mode)?; - result + let restored = restore_mode(&stdin, &stdout, mode); + result.and(restored) } async fn interactive_job_inner( @@ -166,12 +166,11 @@ fn raw_mode(fd: impl AsFd) -> Result { /// Restore the client terminal to its previous, "cooked" mode. /// Does not send any terminal escape sequences, and so may not /// actually reset the terminal to a good state. -fn restore_mode(fd: impl AsFd, mode: Termios) -> Result<(), Error> { - tcsetattr(&fd, OptionalActions::Drain, &mode)?; - - // AsyncFd sets O_NONBLOCK, so we have to turn it back off again. - let flags = fcntl_getfl(&fd)?; - fcntl_setfl(&fd, flags & !OFlags::NONBLOCK)?; - +fn restore_mode(stdin: impl AsFd, stdout: impl AsFd, mode: Termios) -> Result<(), Error> { + for fd in [stdin.as_fd(), stdout.as_fd()] { + let flags = fcntl_getfl(fd)?; + fcntl_setfl(fd, flags & !OFlags::NONBLOCK)?; + } + tcsetattr(&stdin, OptionalActions::Drain, &mode)?; Ok(()) } From 2779e169f2c15772006470a0d92df9648f674c74 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Tue, 18 Aug 2026 17:41:22 -0600 Subject: [PATCH 4/4] Keep watching a job whose start request timed out Co-Authored-By: Claude Mythos 5 --- client/src/commands.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/commands.rs b/client/src/commands.rs index 24da97e6..c2407945 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -1277,11 +1277,13 @@ async fn job_start( select! { // Wait for the start request to finish. start_result = &mut start, if !started => { - if let Err(error) = start_result { - ctx.job_watch_finished(&job_id); - return Err(error); + match start_result { + Ok(_) | Err(CommandError::TimedOut) => ctx.job_started(&job), + Err(error) => { + ctx.job_watch_finished(&job_id); + return Err(error); + } } - ctx.job_started(&job); started = true; } @@ -1981,6 +1983,7 @@ impl From> for CommandError { use ClientError::*; match error { InvalidRequest(e) => CommandError::Client(format!("Invalid request: {e}")), + CommunicationError(e) if e.is_timeout() => CommandError::TimedOut, CommunicationError(e) => CommandError::Client(format!("Communication error: {e}")), InvalidUpgrade(e) => CommandError::Client(e.to_string()), ErrorResponse(e) if e.status() == StatusCode::NOT_FOUND => {