Files
nyaterm/src-tauri/src/core/ssh/io.rs
T
Kang 280272aaaf feat(terminal): add binary input handling to XTerminal and session management (#444)
- Implemented `sendSessionBinaryInput` function to send binary data to terminal sessions.
- Enhanced `XTerminal` component to handle binary input events, ensuring proper data transmission.
- Updated session commands to support raw data writing, allowing for better handling of binary inputs.
- Added tests for binary input functionality to ensure accurate data processing without text bookkeeping.
2026-09-26 23:27:23 +08:00

3111 lines
112 KiB
Rust

use super::client::{
SshDiagnosticContext, SshDiagnosticStage, SshHandle, SshPostLoginConfig, SshStartupCommand,
};
use crate::config::SftpCwdFollowMode;
use crate::core::capture::OutputCaptureProcessor;
use crate::core::monitoring::stats::RemoteStatsSampler;
use crate::core::ssh::osc::{self, OscStripper, ShellKind};
use crate::core::terminal_session::local::split_startup_passthrough;
use crate::core::terminal_session::{
TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input,
};
use crate::core::zmodem::{
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemDownloadOoDrain,
ZmodemEvent, ZmodemTransfer, ZmodemUploadDrain, start_zmodem_transfer,
};
use crate::core::{
InputOrigin, RecordingManager, SessionCommand, SessionCommandReceiver, SessionCommandSender,
SessionCwdReplacement, SessionManager, SessionOutputCoalescer, SharedCwd, replace_cwd_state,
update_cwd_if_changed,
};
use crate::error::{AppError, AppResult};
use russh::{ChannelMsg, client};
use std::{pin::Pin, sync::Arc, time::Instant};
use tauri::{AppHandle, Emitter, Manager};
use tokio::time::{Duration, Sleep, timeout};
const INJECT_TIMEOUT_SECS: u64 = 8;
const INITIAL_INJECT_DELAY_MS: u64 = 500;
const SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES: usize = 64 * 1024;
const SUPPRESSION_DIAGNOSTIC_INITIAL_MS: u64 = 1_000;
const SUPPRESSION_DIAGNOSTIC_INTERVAL_MS: u64 = 2_000;
#[derive(Debug)]
enum ShellDetectionResult {
Detected { shell: ShellKind },
NoSupportedShell,
TimedOut,
ChannelOpenFailed,
ExecFailed,
}
/// Tries to detect the remote shell via an exec channel with a timeout.
///
/// Produces the same runtime outcome as the previous `Option<ShellKind>`
/// contract: only `Detected` enables integration, all other outcomes skip it.
async fn detect_shell_type<H: client::Handler>(
handle: &mut client::Handle<H>,
session_id: &str,
timeout_ms: u64,
) -> ShellDetectionResult {
tracing::info!(
session_id = %session_id,
timeout_ms,
"SSH shell detection starting"
);
let started = Instant::now();
let fut = async {
let mut ch = match handle.channel_open_session().await {
Ok(channel) => channel,
Err(error) => {
return Err(("channel_open", error.to_string(), 0usize));
}
};
tracing::info!(
session_id = %session_id,
"SSH shell detection channel opened"
);
if let Err(error) = ch
.exec(
true,
r#"printf '%s\n' "$SHELL"; ps -p $$ -o comm= 2>/dev/null || true"#,
)
.await
{
return Err(("exec", error.to_string(), 0usize));
}
tracing::info!(
session_id = %session_id,
"SSH shell detection exec request sent"
);
let mut output = String::new();
let mut output_bytes = 0usize;
while let Some(msg) = ch.wait().await {
if let ChannelMsg::Data { ref data } = msg {
output_bytes += data.len();
output.push_str(&String::from_utf8_lossy(data));
}
}
Ok((ShellKind::from_name(output.trim()), output_bytes))
};
match timeout(Duration::from_millis(timeout_ms), fut).await {
Ok(Ok((shell, output_bytes))) if shell != ShellKind::Unknown => {
tracing::info!(
session_id = %session_id,
shell = ?shell,
elapsed_ms = started.elapsed().as_millis() as u64,
output_bytes,
"SSH shell detection completed"
);
ShellDetectionResult::Detected { shell }
}
Ok(Ok((_shell, output_bytes))) => {
tracing::info!(
session_id = %session_id,
elapsed_ms = started.elapsed().as_millis() as u64,
output_bytes,
"SSH shell detection returned no supported shell"
);
ShellDetectionResult::NoSupportedShell
}
Ok(Err(("channel_open", error, _output_bytes))) => {
tracing::warn!(
session_id = %session_id,
elapsed_ms = started.elapsed().as_millis() as u64,
%error,
"SSH shell detection channel open failed"
);
ShellDetectionResult::ChannelOpenFailed
}
Ok(Err(("exec", error, _output_bytes))) => {
tracing::warn!(
session_id = %session_id,
elapsed_ms = started.elapsed().as_millis() as u64,
%error,
"SSH shell detection exec failed"
);
ShellDetectionResult::ExecFailed
}
Ok(Err((_stage, error, _output_bytes))) => {
tracing::warn!(
session_id = %session_id,
elapsed_ms = started.elapsed().as_millis() as u64,
%error,
"SSH shell detection failed"
);
ShellDetectionResult::NoSupportedShell
}
Err(_) => {
tracing::warn!(
session_id = %session_id,
timeout_ms,
elapsed_ms = started.elapsed().as_millis() as u64,
"SSH shell detection timed out"
);
ShellDetectionResult::TimedOut
}
}
}
async fn exec_remote_command<H: client::Handler>(
handle: &mut client::Handle<H>,
command: &str,
timeout_ms: u64,
) -> AppResult<String> {
let fut = async {
let mut ch = handle.channel_open_session().await.map_err(|error| {
AppError::Channel(format!("Failed to open exec channel: {}", error))
})?;
ch.exec(true, command).await.map_err(|error| {
AppError::Channel(format!("Failed to execute remote command: {}", error))
})?;
let mut output = String::new();
let mut exit_status = None;
while let Some(msg) = ch.wait().await {
match msg {
ChannelMsg::Data { ref data } | ChannelMsg::ExtendedData { ref data, .. } => {
output.push_str(&String::from_utf8_lossy(data));
}
ChannelMsg::ExitStatus {
exit_status: status,
} => {
exit_status = Some(status);
}
_ => {}
}
}
match exit_status.unwrap_or(0) {
0 => Ok(output),
status => Err(AppError::Channel(format!(
"Remote command exited with status {status}: {output}"
))),
}
};
timeout(Duration::from_millis(timeout_ms), fut)
.await
.map_err(|_| AppError::Channel("Remote command timed out".to_string()))?
}
fn sh_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn remote_install_command(shell: ShellKind) -> Option<String> {
let script = osc::persistent_script(shell)?;
let block = osc::rc_managed_block(shell)?;
let script_path = osc::persistent_script_path(shell)?;
let rc_path = osc::rc_file_path(shell)?;
Some(format!(
r#"set -eu
script_path={script_path}
rc_path={rc_path}
mkdir -p "$HOME/.config/nyaterm"
case "$rc_path" in */*) mkdir -p "${{rc_path%/*}}" ;; esac
script_tmp="${{script_path}}.tmp.$$"
cat > "$script_tmp" <<'NYATERM_SCRIPT_EOF'
{script}
NYATERM_SCRIPT_EOF
if [ ! -f "$script_path" ] || ! cmp -s "$script_tmp" "$script_path"; then
mv "$script_tmp" "$script_path"
else
rm -f "$script_tmp"
fi
block_tmp="${{script_path}}.block.$$"
cat > "$block_tmp" <<'NYATERM_BLOCK_EOF'
{block}
NYATERM_BLOCK_EOF
rc_tmp="${{rc_path}}.tmp.$$"
start={start}
end={end}
if [ -f "$rc_path" ] && grep -F "$start" "$rc_path" >/dev/null 2>&1 && grep -F "$end" "$rc_path" >/dev/null 2>&1; then
NYATERM_BLOCK_FILE="$block_tmp" awk -v start="$start" -v end="$end" '
$0 == start {{
if (!done) {{
while ((getline line < ENVIRON["NYATERM_BLOCK_FILE"]) > 0) print line
close(ENVIRON["NYATERM_BLOCK_FILE"])
done=1
}}
skip=1
next
}}
$0 == end {{ skip=0; next }}
!skip {{ print }}
END {{
if (!done) {{
if (NR > 0) print ""
while ((getline line < ENVIRON["NYATERM_BLOCK_FILE"]) > 0) print line
}}
}}
' "$rc_path" > "$rc_tmp"
else
if [ -f "$rc_path" ]; then
cat "$rc_path" > "$rc_tmp"
if [ -s "$rc_tmp" ]; then printf '\n' >> "$rc_tmp"; fi
else
: > "$rc_tmp"
fi
cat "$block_tmp" >> "$rc_tmp"
fi
if [ ! -f "$rc_path" ] || ! cmp -s "$rc_tmp" "$rc_path"; then
if [ -f "$rc_path" ] && [ ! -f "$rc_path.nyaterm.bak" ]; then
cp "$rc_path" "$rc_path.nyaterm.bak" 2>/dev/null || true
fi
mv "$rc_tmp" "$rc_path"
else
rm -f "$rc_tmp"
fi
rm -f "$block_tmp"
"#,
script_path = script_path,
rc_path = rc_path,
script = script,
block = block,
start = sh_single_quote(osc::MANAGED_BLOCK_START),
end = sh_single_quote(osc::MANAGED_BLOCK_END),
))
}
async fn install_remote_shell_integration<H: client::Handler>(
handle: &mut client::Handle<H>,
shell: ShellKind,
) -> AppResult<()> {
let Some(command) = remote_install_command(shell) else {
return Err(AppError::Config(format!(
"No persistent shell integration available for {shell:?}"
)));
};
exec_remote_command(handle, &command, 3000)
.await
.map(|_| ())
}
const CHANNEL_REQUEST_REPLY_TIMEOUT_MS: u64 = 10_000;
async fn wait_channel_request_reply(
channel: &mut russh::Channel<client::Msg>,
request_name: &str,
) -> AppResult<()> {
let reply = timeout(
Duration::from_millis(CHANNEL_REQUEST_REPLY_TIMEOUT_MS),
async {
loop {
match channel.wait().await {
Some(ChannelMsg::Success) => return Ok(()),
Some(ChannelMsg::Failure) => {
return Err(AppError::Channel(format!(
"{request_name} request rejected by server"
)));
}
Some(ChannelMsg::Close | ChannelMsg::Eof) | None => {
return Err(AppError::Channel(format!(
"SSH channel closed before {request_name} request completed"
)));
}
Some(_) => {}
}
}
},
)
.await;
match reply {
Ok(result) => result,
Err(_) => Err(AppError::Channel(format!(
"{request_name} request timed out"
))),
}
}
async fn close_failed_interactive_channel(
channel: &russh::Channel<client::Msg>,
session_id: &str,
stage: &str,
) {
if let Err(error) = channel.close().await {
tracing::debug!(
session_id = %session_id,
stage,
%error,
"Failed to close rejected SSH interactive channel"
);
}
}
/// Opens a PTY shell channel and detects the remote shell type.
///
/// Returns `(channel, Option<injection_script>, ready_marker)`.
/// The injection script is **not** sent here — the IO loop defers it until
/// the shell has produced its initial output (banner / MOTD) so that the
/// welcome text is not swallowed.
pub(super) async fn open_shell_channel<H: client::Handler>(
handle: &mut client::Handle<H>,
session_id: &str,
x11_fake_cookie_hex: Option<&str>,
agent_forwarding: bool,
terminal_type: &str,
sftp_enabled: bool,
network_device_profile: bool,
cwd_follow_mode: SftpCwdFollowMode,
shell_detection_timeout_ms: u64,
diagnostics: Option<SshDiagnosticContext>,
) -> AppResult<(
russh::Channel<client::Msg>,
Option<String>,
String,
Option<ShellKind>,
Option<String>,
)> {
if let Some(diagnostics) = diagnostics.as_ref() {
diagnostics.set_stage(SshDiagnosticStage::OpeningInteractiveChannel);
}
tracing::info!(
session_id = %session_id,
"SSH interactive channel opening"
);
let mut channel = match handle.channel_open_session().await {
Ok(channel) => {
tracing::info!(
session_id = %session_id,
"SSH interactive channel opened"
);
channel
}
Err(error) => {
tracing::warn!(
session_id = %session_id,
%error,
"SSH interactive channel open failed"
);
return Err(AppError::Channel(format!(
"Failed to open channel: {}",
error
)));
}
};
let mut local_notice = None;
if agent_forwarding {
if let Err(error) = channel.agent_forward(false).await {
tracing::warn!(
session_id = %session_id,
%error,
"Could not enable SSH Agent forwarding"
);
}
}
if let Some(fake_cookie_hex) = x11_fake_cookie_hex {
match channel
.request_x11(true, false, "MIT-MAGIC-COOKIE-1", fake_cookie_hex, 0)
.await
{
Ok(()) => {
if let Err(error) = wait_channel_request_reply(&mut channel, "X11").await {
tracing::warn!(
session_id = %session_id,
%error,
"Could not enable X11 forwarding"
);
local_notice = Some(super::x11_forwarding::enable_failed_message());
}
}
Err(error) => {
tracing::warn!(
session_id = %session_id,
%error,
"Could not enable X11 forwarding"
);
local_notice = Some(super::x11_forwarding::enable_failed_message());
}
}
}
if let Some(diagnostics) = diagnostics.as_ref() {
diagnostics.set_stage(SshDiagnosticStage::RequestingPty);
}
tracing::info!(
session_id = %session_id,
terminal = %terminal_type,
cols = 80,
rows = 24,
"SSH PTY request sending"
);
if let Err(error) = channel
.request_pty(true, terminal_type, 80, 24, 0, 0, &[])
.await
{
tracing::warn!(
session_id = %session_id,
%error,
"SSH PTY request failed"
);
close_failed_interactive_channel(&channel, session_id, "pty-send").await;
return Err(AppError::Channel(format!("PTY request failed: {}", error)));
}
if let Err(error) = wait_channel_request_reply(&mut channel, "PTY").await {
tracing::warn!(
session_id = %session_id,
%error,
"SSH PTY request rejected"
);
close_failed_interactive_channel(&channel, session_id, "pty-reply").await;
return Err(error);
}
tracing::info!(
session_id = %session_id,
"SSH PTY request completed"
);
if let Some(diagnostics) = diagnostics.as_ref() {
diagnostics.set_stage(SshDiagnosticStage::RequestingShell);
}
tracing::info!(
session_id = %session_id,
"SSH shell request sending"
);
if let Err(error) = channel.request_shell(true).await {
tracing::warn!(
session_id = %session_id,
%error,
"SSH shell request failed"
);
close_failed_interactive_channel(&channel, session_id, "shell-send").await;
return Err(AppError::Channel(format!(
"Shell request failed: {}",
error
)));
}
if let Err(error) = wait_channel_request_reply(&mut channel, "Shell").await {
tracing::warn!(
session_id = %session_id,
%error,
"SSH shell request rejected"
);
close_failed_interactive_channel(&channel, session_id, "shell-reply").await;
return Err(error);
}
tracing::info!(
session_id = %session_id,
"SSH shell request completed"
);
if let Some(diagnostics) = diagnostics.as_ref() {
diagnostics.set_stage(SshDiagnosticStage::PreparingIntegration);
}
let ready_marker = osc::build_ready_marker(session_id);
let mut detected_shell = None;
let injection_script = match cwd_follow_mode {
SftpCwdFollowMode::Off => {
let reason = if network_device_profile {
"network_device_profile"
} else if sftp_enabled {
"cwd_follow_disabled"
} else {
"sftp_disabled"
};
tracing::info!(
session_id = %session_id,
mode = ?cwd_follow_mode,
detected_shell = ?detected_shell,
has_injection_script = false,
has_ready_marker = false,
reason,
"SSH shell integration skipped"
);
None
}
SftpCwdFollowMode::ShellIntegration | SftpCwdFollowMode::RcFile => {
if let Some(diagnostics) = diagnostics.as_ref() {
diagnostics.set_stage(SshDiagnosticStage::DetectingShell);
}
let detection_result =
detect_shell_type(handle, session_id, shell_detection_timeout_ms).await;
if let Some(diagnostics) = diagnostics.as_ref() {
diagnostics.set_stage(SshDiagnosticStage::PreparingIntegration);
}
match detection_result {
ShellDetectionResult::Detected { shell: shell_kind } => {
detected_shell = Some(shell_kind);
let script = if cwd_follow_mode == SftpCwdFollowMode::RcFile {
match install_remote_shell_integration(handle, shell_kind).await {
Ok(()) => {
tracing::debug!(
session_id = %session_id,
shell = ?shell_kind,
"Remote shell integration files are installed"
);
osc::activation_script(shell_kind, &ready_marker)
}
Err(error) => {
tracing::warn!(
session_id = %session_id,
shell = ?shell_kind,
%error,
"Failed to install remote shell integration files; falling back to session injection"
);
osc::injection_script(shell_kind, &ready_marker)
}
}
} else {
osc::injection_script(shell_kind, &ready_marker)
};
if script.is_some() {
tracing::info!(
session_id = %session_id,
mode = ?cwd_follow_mode,
detected_shell = ?detected_shell,
has_injection_script = true,
has_ready_marker = true,
"SSH shell integration prepared"
);
} else {
tracing::info!(
session_id = %session_id,
mode = ?cwd_follow_mode,
detected_shell = ?detected_shell,
has_injection_script = false,
has_ready_marker = false,
reason = "no_script_available",
"SSH shell integration skipped"
);
}
script
}
ShellDetectionResult::TimedOut => {
tracing::info!(
session_id = %session_id,
mode = ?cwd_follow_mode,
detected_shell = ?detected_shell,
has_injection_script = false,
has_ready_marker = false,
reason = "shell_detection_timeout",
"SSH shell integration skipped"
);
None
}
ShellDetectionResult::NoSupportedShell => {
tracing::info!(
session_id = %session_id,
mode = ?cwd_follow_mode,
detected_shell = ?detected_shell,
has_injection_script = false,
has_ready_marker = false,
reason = "shell_unknown",
"SSH shell integration skipped"
);
None
}
ShellDetectionResult::ChannelOpenFailed | ShellDetectionResult::ExecFailed => {
tracing::info!(
session_id = %session_id,
mode = ?cwd_follow_mode,
detected_shell = ?detected_shell,
has_injection_script = false,
has_ready_marker = false,
reason = "shell_unknown",
"SSH shell integration skipped"
);
None
}
}
}
};
Ok((
channel,
injection_script,
ready_marker,
detected_shell,
local_notice,
))
}
/// Injection phase state machine.
///
/// ```text
/// ┌─────────────┐ first data ┌─────────────┐ ready marker ┌────────┐
/// │ WaitInitial │──────────────▶│ Suppressing │──────────────▶│ Normal │
/// └─────────────┘ (inject sent) └─────────────┘ └────────┘
/// │ timeout
/// └──────────────────────▶│ Normal │
/// ```
///
/// When no injection script is provided we start directly in `Normal`.
#[derive(Debug, PartialEq)]
enum IoPhase {
/// Passing through all output; waiting for the first data chunk so we
/// can display the banner / MOTD before injecting.
WaitInitial,
/// Injection script sent; discarding visible output (echo) until the
/// ready marker appears.
Suppressing,
/// Normal operation — strip our OSC sequences, forward everything else.
Normal,
}
impl std::fmt::Display for IoPhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WaitInitial => f.write_str("WaitInitial"),
Self::Suppressing => f.write_str("Suppressing"),
Self::Normal => f.write_str("Normal"),
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum InjectionEvent {
None,
Inject,
Ready { visible_after_ready: String },
Failed { visible_after_ready: String },
RuntimeFailed { visible: String },
}
#[derive(Debug, PartialEq, Eq)]
enum InjectionTimeoutEvent {
None,
FallbackToNormal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InjectionTimeoutSource {
Deadline,
WallClock,
}
impl std::fmt::Display for InjectionTimeoutSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Deadline => f.write_str("deadline"),
Self::WallClock => f.write_str("wall_clock"),
}
}
}
#[derive(Debug, Default)]
struct SuppressionDiagnostics {
rx_bytes_total: u64,
rx_chunks: u64,
first_rx_after_ms: Option<u64>,
last_rx_after_ms: Option<u64>,
pre_ready_write_bytes: u64,
pre_ready_write_chunks: u64,
last_diagnostic_at: Option<Instant>,
}
impl SuppressionDiagnostics {
fn reset(&mut self) {
*self = Self::default();
}
fn record_rx(&mut self, bytes: usize, injection_sent_at: &Instant, now: Instant) {
let elapsed_ms = elapsed_ms_at(injection_sent_at, now);
self.rx_bytes_total = self
.rx_bytes_total
.saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX));
self.rx_chunks = self.rx_chunks.saturating_add(1);
self.first_rx_after_ms.get_or_insert(elapsed_ms);
self.last_rx_after_ms = Some(elapsed_ms);
}
fn record_pre_ready_write(&mut self, bytes: usize) {
self.pre_ready_write_bytes = self
.pre_ready_write_bytes
.saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX));
self.pre_ready_write_chunks = self.pre_ready_write_chunks.saturating_add(1);
}
fn should_log_suppression_diagnostic(
&mut self,
injection_sent_at: &Instant,
now: Instant,
) -> bool {
if now.saturating_duration_since(*injection_sent_at)
< Duration::from_millis(SUPPRESSION_DIAGNOSTIC_INITIAL_MS)
{
return false;
}
if self.last_diagnostic_at.is_some_and(|last| {
now.saturating_duration_since(last)
< Duration::from_millis(SUPPRESSION_DIAGNOSTIC_INTERVAL_MS)
}) {
return false;
}
self.last_diagnostic_at = Some(now);
true
}
}
struct PendingStartupCommand {
input: Vec<u8>,
delay_ms: u64,
}
/// Build renderer-supplied startup input without allowing terminal controls.
pub(super) fn build_startup_command_input(command: &str) -> Option<Vec<u8>> {
if command.trim().is_empty() || contains_terminal_control(command) {
return None;
}
let mut input = command.as_bytes().to_vec();
if !input.ends_with(b"\r") {
input.push(b'\r');
}
Some(input)
}
fn build_post_login_command_input(command: &str) -> Option<Vec<u8>> {
if command.trim().is_empty() {
return None;
}
let normalized = command.replace("\r\n", "\r").replace('\n', "\r");
let mut input = normalized.into_bytes();
if !input.ends_with(b"\r") {
input.push(b'\r');
}
Some(input)
}
fn contains_terminal_control(value: &str) -> bool {
value
.chars()
.any(|ch| matches!(ch, '\u{0000}'..='\u{001F}' | '\u{007F}'..='\u{009F}'))
}
fn arm_post_login_timer(
phase: &IoPhase,
pending_post_login: &Option<PendingStartupCommand>,
post_login_deadline: &mut Option<Pin<Box<Sleep>>>,
) {
if *phase != IoPhase::Normal {
return;
}
if post_login_deadline.is_none() {
if let Some(pending) = pending_post_login.as_ref() {
*post_login_deadline = Some(Box::pin(tokio::time::sleep(Duration::from_millis(
pending.delay_ms,
))));
}
}
}
fn should_send_initial_injection(phase: &IoPhase, has_pending_script: bool) -> bool {
*phase == IoPhase::WaitInitial && has_pending_script
}
fn cancel_pending_injection_for_input(
phase: &mut IoPhase,
pending_script: &mut Option<String>,
origin: InputOrigin,
) -> bool {
if *phase != IoPhase::WaitInitial
|| pending_script.is_none()
|| origin == InputOrigin::TerminalResponse
{
return false;
}
pending_script.take();
*phase = IoPhase::Normal;
true
}
async fn handle_input_before_initial_injection(
phase: &mut IoPhase,
pending_script: &mut Option<String>,
origin: InputOrigin,
manager: &SessionManager,
session_id: &str,
pending_post_login: &Option<PendingStartupCommand>,
post_login_deadline: &mut Option<Pin<Box<Sleep>>>,
) {
if !cancel_pending_injection_for_input(phase, pending_script, origin) {
return;
}
tracing::info!(
session_id = %session_id,
?origin,
phase_transition = "WaitInitial -> Normal",
"SSH shell integration injection cancelled by terminal input"
);
manager
.set_dynamic_title_integration_active(session_id, false)
.await;
arm_post_login_timer(phase, pending_post_login, post_login_deadline);
}
fn on_initial_injection_sent(phase: &mut IoPhase) {
if *phase == IoPhase::WaitInitial {
*phase = IoPhase::Suppressing;
}
}
fn handle_injection_result(phase: &mut IoPhase, result: &osc::OscResult) -> InjectionEvent {
match phase {
IoPhase::WaitInitial => InjectionEvent::Inject,
IoPhase::Suppressing if result.ready_failed => {
*phase = IoPhase::Normal;
InjectionEvent::Failed {
visible_after_ready: result.visible_after_ready.clone(),
}
}
IoPhase::Suppressing if result.ready => {
*phase = IoPhase::Normal;
InjectionEvent::Ready {
visible_after_ready: result.visible_after_ready.clone(),
}
}
IoPhase::Suppressing => InjectionEvent::None,
IoPhase::Normal if result.ready_failed => InjectionEvent::RuntimeFailed {
visible: result.visible.clone(),
},
IoPhase::Normal => InjectionEvent::None,
}
}
fn handle_injection_timeout(phase: &mut IoPhase) -> InjectionTimeoutEvent {
match phase {
IoPhase::Suppressing => {
*phase = IoPhase::Normal;
InjectionTimeoutEvent::FallbackToNormal
}
IoPhase::WaitInitial | IoPhase::Normal => InjectionTimeoutEvent::None,
}
}
fn elapsed_ms_at(started_at: &Instant, now: Instant) -> u64 {
u64::try_from(now.saturating_duration_since(*started_at).as_millis()).unwrap_or(u64::MAX)
}
fn injection_has_timed_out_at(
phase: &IoPhase,
injection_sent_at: Option<&Instant>,
now: Instant,
) -> bool {
*phase == IoPhase::Suppressing
&& injection_sent_at.is_some_and(|started| {
now.saturating_duration_since(*started) >= Duration::from_secs(INJECT_TIMEOUT_SECS)
})
}
fn injection_has_timed_out(phase: &IoPhase, injection_sent_at: Option<&Instant>) -> bool {
injection_has_timed_out_at(phase, injection_sent_at, Instant::now())
}
fn append_suppressed_visible_and_take_passthrough(buffer: &mut String, visible: &str) -> String {
buffer.push_str(visible);
let (mut suppressed, passthrough) = split_startup_passthrough(buffer);
while suppressed.len() > SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES {
let Some(first_char) = suppressed.chars().next() else {
break;
};
suppressed.drain(..first_char.len_utf8());
}
*buffer = suppressed;
passthrough
}
fn discard_suppressed_output(buffer: &mut String, flushed_osc_buffer: String) -> usize {
let discarded_len = buffer.len() + flushed_osc_buffer.len();
buffer.clear();
discarded_len
}
#[allow(clippy::too_many_arguments)]
async fn send_shell_integration_injection(
channel: &mut russh::Channel<client::Msg>,
pending_script: &mut Option<String>,
inject_deadline: &mut Pin<&mut Sleep>,
phase: &mut IoPhase,
session_id: &str,
shell_kind: Option<ShellKind>,
injection_sent_at: &mut Option<Instant>,
suppression_diagnostics: &mut SuppressionDiagnostics,
) {
let Some(script) = pending_script.take() else {
return;
};
let script_bytes = script.len();
let script_lines = script.lines().count();
let max_line_bytes = script.lines().map(str::len).max().unwrap_or_default();
tracing::info!(
session_id = %session_id,
shell = ?shell_kind,
phase = %phase,
script_bytes,
script_lines,
max_line_bytes,
"SSH shell integration injection sending"
);
match channel.data(script.as_bytes()).await {
Ok(()) => {
let sent_at = Instant::now();
*injection_sent_at = Some(sent_at);
suppression_diagnostics.reset();
on_initial_injection_sent(phase);
inject_deadline
.as_mut()
.reset(tokio::time::Instant::now() + Duration::from_secs(INJECT_TIMEOUT_SECS));
tracing::info!(
session_id = %session_id,
shell = ?shell_kind,
script_bytes,
script_lines,
max_line_bytes,
phase_transition = "WaitInitial -> Suppressing",
"SSH shell integration injection sent"
);
}
Err(error) => {
let previous_phase = phase.to_string();
*phase = IoPhase::Normal;
*injection_sent_at = None;
let phase_transition = format!("{previous_phase} -> {phase}");
tracing::warn!(
session_id = %session_id,
shell = ?shell_kind,
error = %error,
phase_transition = %phase_transition,
"SSH shell integration injection send failed; continuing in passive mode"
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn fallback_shell_integration_timeout(
phase: &mut IoPhase,
stripper: &mut OscStripper,
suppressed_visible_fallback: &mut String,
session_id: &str,
shell_kind: Option<ShellKind>,
injection_sent_at: Option<&Instant>,
suppression_diagnostics: &SuppressionDiagnostics,
timeout_source: InjectionTimeoutSource,
pending_post_login: &Option<PendingStartupCommand>,
post_login_deadline: &mut Option<Pin<Box<Sleep>>>,
) -> bool {
if *phase != IoPhase::Suppressing || injection_sent_at.is_none() {
return false;
}
let previous_phase = phase.to_string();
let timeout_event = handle_injection_timeout(phase);
debug_assert_eq!(timeout_event, InjectionTimeoutEvent::FallbackToNormal);
let flushed = stripper.flush();
let osc_buffer_bytes = flushed.len();
let suppressed_visible_bytes = suppressed_visible_fallback.len();
let discarded_visible_bytes = discard_suppressed_output(suppressed_visible_fallback, flushed);
let elapsed_ms = injection_sent_at
.map(|started| elapsed_ms_at(started, Instant::now()))
.unwrap_or(0);
let phase_transition = format!("{previous_phase} -> {phase}");
tracing::warn!(
session_id = %session_id,
shell = ?shell_kind,
elapsed_ms,
injection_timeout_secs = INJECT_TIMEOUT_SECS,
suppression_rx_bytes = suppression_diagnostics.rx_bytes_total,
suppression_rx_chunks = suppression_diagnostics.rx_chunks,
first_suppression_rx_after_ms = ?suppression_diagnostics.first_rx_after_ms,
last_suppression_rx_after_ms = ?suppression_diagnostics.last_rx_after_ms,
suppressed_visible_bytes,
osc_buffer_bytes,
discarded_visible_bytes,
pre_ready_write_bytes = suppression_diagnostics.pre_ready_write_bytes,
pre_ready_write_chunks = suppression_diagnostics.pre_ready_write_chunks,
timeout_source = %timeout_source,
phase_transition = %phase_transition,
"SSH shell integration injection timed out"
);
arm_post_login_timer(phase, pending_post_login, post_login_deadline);
true
}
#[allow(clippy::too_many_arguments)]
async fn handle_osc_result(
app: &AppHandle,
output: &Arc<SessionOutputCoalescer>,
cwd_event: &str,
cwd: &SharedCwd,
recording_mgr: &Option<Arc<RecordingManager>>,
session_id: &str,
manager: &Arc<SessionManager>,
channel: &mut russh::Channel<client::Msg>,
pending_script: &mut Option<String>,
inject_deadline: &mut Pin<&mut Sleep>,
phase: &mut IoPhase,
result: &osc::OscResult,
suppressed_visible_fallback: &mut String,
shell_kind: Option<ShellKind>,
injection_sent_at: &mut Option<Instant>,
suppression_diagnostics: &mut SuppressionDiagnostics,
) {
let was_suppressing = *phase == IoPhase::Suppressing;
let injection_event = handle_injection_result(phase, result);
let suppressed_passthrough = if was_suppressing {
let before_ready_len = result
.visible
.len()
.saturating_sub(result.visible_after_ready.len());
append_suppressed_visible_and_take_passthrough(
suppressed_visible_fallback,
&result.visible[..before_ready_len],
)
} else {
String::new()
};
match injection_event {
InjectionEvent::Inject => {
emit_output(
app,
output,
cwd_event,
cwd,
recording_mgr,
session_id,
manager,
result,
)
.await;
send_shell_integration_injection(
channel,
pending_script,
inject_deadline,
phase,
session_id,
shell_kind,
injection_sent_at,
suppression_diagnostics,
)
.await;
}
InjectionEvent::Ready {
visible_after_ready,
} => {
let suppressed_visible_bytes = result
.visible
.len()
.saturating_sub(visible_after_ready.len())
+ suppressed_visible_fallback.len();
suppressed_visible_fallback.clear();
tracing::info!(
session_id = %session_id,
shell = ?shell_kind,
elapsed_ms = injection_sent_at
.as_ref()
.map(|started| started.elapsed().as_millis() as u64)
.unwrap_or(0),
suppressed_visible_bytes,
ready_after_visible_bytes = visible_after_ready.len(),
phase_transition = "Suppressing -> Normal",
"SSH shell integration ready marker received"
);
// Exact OSC 0/2 and terminal queries retain their original order
// while source-command echo remains suppressed.
let mut release_output = suppressed_passthrough;
release_output.push_str(&visible_after_ready);
emit_metadata(app, cwd_event, cwd, manager, session_id, result).await;
emit_visible_text(output, recording_mgr, session_id, &release_output);
}
InjectionEvent::Failed {
visible_after_ready,
} => {
let suppressed_visible_bytes = result
.visible
.len()
.saturating_sub(visible_after_ready.len())
+ suppressed_visible_fallback.len();
suppressed_visible_fallback.clear();
let mut release_output = suppressed_passthrough;
release_output.push_str(&visible_after_ready);
tracing::warn!(
session_id = %session_id,
shell = ?shell_kind,
elapsed_ms = injection_sent_at
.as_ref()
.map(|started| started.elapsed().as_millis() as u64)
.unwrap_or(0),
suppressed_visible_bytes,
phase_transition = "Suppressing -> Normal",
"SSH shell integration reported installation failure; continuing in passive mode"
);
emit_metadata(app, cwd_event, cwd, manager, session_id, result).await;
emit_visible_text(output, recording_mgr, session_id, &release_output);
}
InjectionEvent::RuntimeFailed { visible } => {
tracing::warn!(
session_id = %session_id,
shell = ?shell_kind,
"SSH shell integration failed after startup; clearing stale cwd"
);
emit_metadata(app, cwd_event, cwd, manager, session_id, result).await;
let changes = replace_cwd_state(
cwd,
SessionCwdReplacement {
legacy_path: None,
operational_path: None,
presentation: None,
},
)
.await;
if changes.operational_changed {
let _ = app.emit(cwd_event, "");
}
emit_visible_text(output, recording_mgr, session_id, &visible);
}
InjectionEvent::None if *phase == IoPhase::Normal => {
emit_output(
app,
output,
cwd_event,
cwd,
recording_mgr,
session_id,
manager,
result,
)
.await;
}
InjectionEvent::None => {
// Transport terminal queries and bounded OSC 0/2 in their exact
// source order while all remaining startup text stays hidden.
emit_visible_text(output, recording_mgr, session_id, &suppressed_passthrough);
emit_metadata(app, cwd_event, cwd, manager, session_id, result).await;
}
}
}
pub(super) async fn ssh_io_loop(
app: AppHandle,
session_id: String,
manager: Arc<SessionManager>,
mut channel: russh::Channel<client::Msg>,
_handle: SshHandle,
mut cmd_rx: SessionCommandReceiver,
output_control_tx: SessionCommandSender,
cwd: SharedCwd,
connection_id: Option<String>,
injection_script: Option<String>,
ready_marker: String,
shell_kind: Option<ShellKind>,
post_login: Option<SshPostLoginConfig>,
startup_command: Option<SshStartupCommand>,
backspace_mode: String,
initial_notice: Option<String>,
encoding: String,
diagnostics: Option<SshDiagnosticContext>,
) {
let backspace_as_bs = backspace_mode == "ctrl_h";
let output_event = format!("terminal-output-{}", session_id);
let cwd_event = format!("cwd-changed-{}", session_id);
let closed_event = format!("session-closed-{}", session_id);
let recording_mgr: Option<Arc<RecordingManager>> = app
.try_state::<Arc<RecordingManager>>()
.map(|state| state.inner().clone());
let output =
SessionOutputCoalescer::for_app(app.clone(), output_event.clone(), output_control_tx);
let mut output_decoder = TerminalOutputDecoder::new(&encoding);
if let Some(notice) = initial_notice {
output.push_owned(notice);
}
let mut stripper = OscStripper::new(&ready_marker);
let mut capture_processor = OutputCaptureProcessor::new();
let has_injection_script = injection_script.is_some();
let mut phase = if has_injection_script {
IoPhase::WaitInitial
} else {
IoPhase::Normal
};
if let Some(diagnostics) = diagnostics.as_ref() {
diagnostics.set_stage(SshDiagnosticStage::IoRunning);
}
tracing::info!(
session_id = %session_id,
initial_phase = %phase,
has_injection_script,
shell_kind = ?shell_kind,
"SSH IO loop started"
);
let mut suppressed_visible_fallback = String::new();
let mut pending_script = injection_script;
let mut initial_remote_data_logged = false;
let mut injection_sent_at: Option<Instant> = None;
let mut suppression_diagnostics = SuppressionDiagnostics::default();
let mut pending_post_login = post_login.and_then(|config| {
build_post_login_command_input(&config.command).map(|input| PendingStartupCommand {
input,
delay_ms: config.delay_ms,
})
});
let mut pending_startup_command = startup_command.and_then(|config| {
build_startup_command_input(&config.command).map(|input| PendingStartupCommand {
input,
delay_ms: config.delay_ms,
})
});
if pending_post_login.is_none() {
pending_post_login = pending_startup_command.take();
}
let mut post_login_deadline: Option<Pin<Box<Sleep>>> = None;
arm_post_login_timer(&phase, &pending_post_login, &mut post_login_deadline);
let mut remote_exit_status: Option<u32> = None;
let mut remote_exit_signal: Option<String> = None;
let mut output_paused = false;
let mut zmodem_detector = ZmodemDetector::new();
let mut zmodem_transfer: Option<ZmodemTransfer> = None;
let mut zmodem_upload_drain = ZmodemUploadDrain::new();
let mut zmodem_download_oo_drain = ZmodemDownloadOoDrain::new();
let zmodem_event_name = format!("zmodem-event-{session_id}");
let initial_inject_deadline =
tokio::time::sleep(std::time::Duration::from_millis(INITIAL_INJECT_DELAY_MS));
tokio::pin!(initial_inject_deadline);
let inject_deadline = tokio::time::sleep(std::time::Duration::from_secs(INJECT_TIMEOUT_SECS));
tokio::pin!(inject_deadline);
let close_reason = loop {
if injection_has_timed_out(&phase, injection_sent_at.as_ref()) {
fallback_shell_integration_timeout(
&mut phase,
&mut stripper,
&mut suppressed_visible_fallback,
&session_id,
shell_kind,
injection_sent_at.as_ref(),
&suppression_diagnostics,
InjectionTimeoutSource::WallClock,
&pending_post_login,
&mut post_login_deadline,
);
}
tokio::select! {
biased;
_ = &mut inject_deadline,
if phase == IoPhase::Suppressing && injection_sent_at.is_some() =>
{
fallback_shell_integration_timeout(
&mut phase,
&mut stripper,
&mut suppressed_visible_fallback,
&session_id,
shell_kind,
injection_sent_at.as_ref(),
&suppression_diagnostics,
InjectionTimeoutSource::Deadline,
&pending_post_login,
&mut post_login_deadline,
);
}
cmd = cmd_rx.recv() => {
match cmd {
Some(SessionCommand::AttachConfirmed { ack }) => {
output.attach_confirmed(ack);
}
Some(SessionCommand::DetachRenderer) => {
output.detach();
}
Some(SessionCommand::Write { data, raw, origin, .. }) => {
if zmodem_transfer.is_some()
|| zmodem_upload_drain.should_suppress(std::time::Instant::now())
{
continue;
}
handle_input_before_initial_injection(
&mut phase,
&mut pending_script,
origin,
&manager,
&session_id,
&pending_post_login,
&mut post_login_deadline,
)
.await;
let send_data = prepare_terminal_write_input(data, &encoding, raw, backspace_as_bs);
if phase == IoPhase::Suppressing {
suppression_diagnostics.record_pre_ready_write(send_data.len());
}
let _ = channel.data(&send_data[..]).await;
}
Some(SessionCommand::Resize { cols, rows }) => {
let _ = channel.window_change(cols, rows, 0, 0).await;
}
Some(SessionCommand::PauseOutput) => {
output_paused = true;
}
Some(SessionCommand::ResumeOutput) => {
output_paused = false;
}
Some(SessionCommand::AckOutput { bytes }) => {
output.ack(bytes);
}
Some(SessionCommand::CaptureExec { marker_id, wrapped_command, result_tx }) => {
handle_input_before_initial_injection(
&mut phase,
&mut pending_script,
InputOrigin::AiAgent,
&manager,
&session_id,
&pending_post_login,
&mut post_login_deadline,
)
.await;
capture_processor.register(marker_id, result_tx);
let send_command = encode_terminal_input(&wrapped_command, &encoding);
let _ = channel.data(&send_command[..]).await;
}
Some(SessionCommand::CancelCapture { marker_id }) => {
capture_processor.cancel(&marker_id);
}
Some(SessionCommand::Close) => {
let _ = channel.close().await;
break "local-close-request";
}
Some(SessionCommand::ZmodemAcceptDownload { save_dir }) => {
if let Some(ref mut transfer) = zmodem_transfer {
let actions = transfer.accept_download(save_dir);
handle_zmodem_actions(&app, &zmodem_event_name, &mut channel, actions).await;
if transfer.is_done() {
zmodem_transfer = None;
}
}
}
Some(SessionCommand::ZmodemAcceptUpload {
files,
conflict_mode,
preserve_timestamps,
}) => {
if let Some(ref mut transfer) = zmodem_transfer {
let actions =
transfer.accept_upload(files, conflict_mode, preserve_timestamps);
handle_zmodem_actions(&app, &zmodem_event_name, &mut channel, actions).await;
if transfer.is_done() {
zmodem_transfer = None;
}
} else {
tracing::warn!(
session_id = %session_id,
"Received ZmodemAcceptUpload without an active transfer"
);
}
}
Some(SessionCommand::ZmodemCancel) => {
manager.clear_pending_zmodem_upload(&session_id).await;
if let Some(ref mut transfer) = zmodem_transfer {
let actions = transfer.cancel();
handle_zmodem_actions(&app, &zmodem_event_name, &mut channel, actions).await;
}
zmodem_transfer = None;
}
Some(SessionCommand::SerialModemUpload { result_tx, .. }) => {
let _ = result_tx.send(Err(
"Direct modem upload is only available for Serial sessions".to_string(),
));
}
None => {
let _ = channel.close().await;
break "session-command-channel-closed";
}
}
}
msg = channel.wait(), if !output_paused => {
match msg {
Some(ChannelMsg::Data { ref data }) => {
if phase == IoPhase::Suppressing {
let now = Instant::now();
if let Some(sent_at) = injection_sent_at.as_ref() {
suppression_diagnostics.record_rx(data.len(), sent_at, now);
if injection_has_timed_out_at(&phase, Some(sent_at), now) {
fallback_shell_integration_timeout(
&mut phase,
&mut stripper,
&mut suppressed_visible_fallback,
&session_id,
shell_kind,
injection_sent_at.as_ref(),
&suppression_diagnostics,
InjectionTimeoutSource::WallClock,
&pending_post_login,
&mut post_login_deadline,
);
} else if suppression_diagnostics
.should_log_suppression_diagnostic(sent_at, now)
{
tracing::info!(
session_id = %session_id,
shell = ?shell_kind,
elapsed_ms = elapsed_ms_at(sent_at, now),
suppression_rx_bytes = suppression_diagnostics.rx_bytes_total,
suppression_rx_chunks = suppression_diagnostics.rx_chunks,
last_rx_after_ms = ?suppression_diagnostics.last_rx_after_ms,
osc_buffer_bytes = stripper.buffered_len(),
"SSH shell integration still suppressing"
);
}
}
}
if !initial_remote_data_logged {
initial_remote_data_logged = true;
tracing::info!(
session_id = %session_id,
bytes = data.len(),
phase = %phase,
"SSH terminal received initial remote data"
);
}
// ZMODEM: if a transfer is active, route raw bytes to it.
if let Some(ref mut transfer) = zmodem_transfer {
let direction = transfer.direction();
let actions = transfer.feed_incoming(data);
handle_zmodem_actions(&app, &zmodem_event_name, &mut channel, actions).await;
if transfer.is_done() {
zmodem_transfer = None;
zmodem_detector.reset();
if direction == ZmodemDirection::Upload {
zmodem_upload_drain.start(std::time::Instant::now());
} else if direction == ZmodemDirection::Download {
zmodem_download_oo_drain.start(std::time::Instant::now());
}
}
continue;
}
let data = zmodem_upload_drain.filter(data, std::time::Instant::now());
if data.is_empty() {
continue;
}
let data = zmodem_download_oo_drain
.filter(data, std::time::Instant::now());
if data.is_empty() {
continue;
}
// ZMODEM: detect header in raw bytes before lossy UTF-8 conversion.
// Detection must run in every phase so an early `rz` is not missed
// while shell-integration output is still being suppressed.
match zmodem_detector.feed(data) {
ZmodemDetectResult::Detected { direction, passthrough, initial_bytes } => {
// Forward any pre-header bytes to the terminal.
if !passthrough.is_empty() {
if let Some(ref recorder) = recording_mgr {
recorder.write_raw_output(&session_id, &passthrough);
}
let pre = output_decoder.decode(&passthrough);
if !pre.is_empty() {
output.push_owned(pre);
}
}
let prepared_upload = if direction == ZmodemDirection::Upload {
manager.take_pending_zmodem_upload(&session_id).await
} else {
None
};
let (transfer, bootstrap_actions) =
start_zmodem_transfer(direction, &initial_bytes, prepared_upload);
zmodem_transfer = Some(transfer);
handle_zmodem_actions(
&app,
&zmodem_event_name,
&mut channel,
bootstrap_actions,
)
.await;
let _ = app.emit(&zmodem_event_name, &ZmodemEvent::Detected { direction });
tracing::info!(
session_id = %session_id,
?direction,
"ZMODEM transfer detected"
);
continue;
}
ZmodemDetectResult::NoMatch { passthrough } if passthrough.is_empty() => {
continue;
}
ZmodemDetectResult::NoMatch { passthrough } => {
if let Some(ref recorder) = recording_mgr {
recorder.write_raw_output(&session_id, &passthrough);
}
let text = output_decoder.decode(&passthrough);
let mut result = stripper.push(&text);
if capture_processor.has_active() {
result.visible = capture_processor.process(&result.visible);
}
handle_osc_result(
&app,
&output,
&cwd_event,
&cwd,
&recording_mgr,
&session_id,
&manager,
&mut channel,
&mut pending_script,
&mut inject_deadline,
&mut phase,
&result,
&mut suppressed_visible_fallback,
shell_kind,
&mut injection_sent_at,
&mut suppression_diagnostics,
).await;
arm_post_login_timer(
&phase,
&pending_post_login,
&mut post_login_deadline,
);
continue;
}
}
}
Some(ChannelMsg::ExtendedData { ref data, .. }) => {
if let Some(ref recorder) = recording_mgr {
recorder.write_raw_output(&session_id, data);
}
let text = output_decoder.decode(data);
if let Some(ref recorder) = recording_mgr {
recorder.write_output(&session_id, &text);
}
output.push_owned(text);
}
Some(ChannelMsg::ExitStatus { exit_status }) => {
remote_exit_status = Some(exit_status);
tracing::info!(
session_id = %session_id,
exit_status,
"SSH remote process reported exit status"
);
}
Some(ChannelMsg::ExitSignal {
signal_name,
core_dumped,
error_message,
lang_tag,
}) => {
remote_exit_signal = Some(format!("{signal_name:?}"));
tracing::warn!(
session_id = %session_id,
signal = ?signal_name,
core_dumped,
error_message = %error_message,
lang_tag = %lang_tag,
"SSH remote process exited on signal"
);
}
Some(ChannelMsg::Eof) => {
tracing::info!(
session_id = %session_id,
"SSH channel received EOF from remote"
);
break "remote-channel-eof";
}
Some(ChannelMsg::Close) => {
tracing::info!(
session_id = %session_id,
"SSH channel received close from remote"
);
break "remote-channel-close";
}
None => {
tracing::info!(
session_id = %session_id,
"SSH channel stream ended"
);
break "channel-stream-ended";
}
_ => {}
}
}
_ = &mut initial_inject_deadline, if should_send_initial_injection(&phase, pending_script.is_some()) => {
send_shell_integration_injection(
&mut channel,
&mut pending_script,
&mut inject_deadline,
&mut phase,
&session_id,
shell_kind,
&mut injection_sent_at,
&mut suppression_diagnostics,
).await;
arm_post_login_timer(&phase, &pending_post_login, &mut post_login_deadline);
}
_ = async {
if let Some(deadline) = post_login_deadline.as_mut() {
deadline.as_mut().await;
}
}, if post_login_deadline.is_some() => {
post_login_deadline = None;
if zmodem_transfer.is_some() {
post_login_deadline = Some(Box::pin(tokio::time::sleep(
Duration::from_millis(250),
)));
continue;
}
if let Some(pending) = pending_post_login.take() {
let _ = channel.data(&pending.input[..]).await;
tracing::info!(
session_id = %session_id,
delay_ms = pending.delay_ms,
"Sent SSH post-login command"
);
arm_post_login_timer(
&phase,
&pending_startup_command,
&mut post_login_deadline,
);
continue;
}
if let Some(pending) = pending_startup_command.take() {
let _ = channel.data(&pending.input[..]).await;
tracing::info!(
session_id = %session_id,
delay_ms = pending.delay_ms,
"Sent SSH startup command"
);
}
}
}
};
output.close();
if let Some(ref recorder) = recording_mgr {
recorder.cleanup_session(&session_id);
}
manager.remove_session(&session_id).await;
if let Some(stats_sampler) = app.try_state::<Arc<RemoteStatsSampler>>() {
stats_sampler.clear_session(&session_id).await;
}
if let Some(ref conn_id) = connection_id {
if let Some(tunnel_mgr) = app.try_state::<Arc<super::TunnelManager>>() {
tunnel_mgr
.close_auto_tunnels_for_connection(&app, conn_id)
.await;
}
}
tracing::info!(
session_id = %session_id,
close_reason,
remote_exit_status,
remote_exit_signal = remote_exit_signal.as_deref(),
"SSH session closed"
);
let _ = app.emit(&closed_event, ());
}
async fn run_sftp_only_session_commands(
session_id: &str,
manager: &Arc<SessionManager>,
mut cmd_rx: SessionCommandReceiver,
mut disconnect_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> &'static str {
let close_reason = loop {
tokio::select! {
disconnect = disconnect_rx.recv() => {
match disconnect {
Some(reason) => {
tracing::info!(
session_id = %session_id,
%reason,
"SFTP-only SSH transport disconnected"
);
break "remote-transport-disconnect";
}
None => break "remote-transport-ended",
}
}
command = cmd_rx.recv() => match command {
Some(SessionCommand::AttachConfirmed { ack }) => {
let _ = ack.send(());
}
Some(SessionCommand::CaptureExec { result_tx, .. }) => {
drop(result_tx);
}
Some(SessionCommand::SerialModemUpload { result_tx, .. }) => {
let _ = result_tx.send(Err(
"Direct modem upload is only available for Serial sessions".to_string(),
));
}
Some(SessionCommand::Close) => break "local-close-request",
Some(
SessionCommand::DetachRenderer
| SessionCommand::Write { .. }
| SessionCommand::PauseOutput
| SessionCommand::ResumeOutput
| SessionCommand::AckOutput { .. }
| SessionCommand::Resize { .. }
| SessionCommand::CancelCapture { .. }
| SessionCommand::ZmodemAcceptDownload { .. }
| SessionCommand::ZmodemAcceptUpload { .. }
| SessionCommand::ZmodemCancel,
) => {}
None => break "session-command-channel-closed",
}
}
};
manager.remove_session(session_id).await;
close_reason
}
pub(super) async fn sftp_only_ssh_lifecycle_loop(
app: AppHandle,
session_id: String,
manager: Arc<SessionManager>,
_handle: SshHandle,
cmd_rx: SessionCommandReceiver,
disconnect_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
connection_id: Option<String>,
) {
tracing::info!(session_id = %session_id, "SFTP-only SSH lifecycle loop started");
let close_reason =
run_sftp_only_session_commands(&session_id, &manager, cmd_rx, disconnect_rx).await;
if let Some(stats_sampler) = app.try_state::<Arc<RemoteStatsSampler>>() {
stats_sampler.clear_session(&session_id).await;
}
if let Some(ref conn_id) = connection_id {
if let Some(tunnel_mgr) = app.try_state::<Arc<super::TunnelManager>>() {
tunnel_mgr
.close_auto_tunnels_for_connection(&app, conn_id)
.await;
}
}
tracing::info!(
session_id = %session_id,
close_reason,
"SFTP-only SSH session closed"
);
let closed_event = format!("session-closed-{session_id}");
let _ = app.emit(&closed_event, ());
}
async fn handle_zmodem_actions(
app: &AppHandle,
event_name: &str,
channel: &mut russh::Channel<client::Msg>,
actions: Vec<ZmodemAction>,
) {
for action in actions {
match action {
ZmodemAction::SendToRemote(data) => {
let _ = channel.data(&data[..]).await;
}
ZmodemAction::EmitEvent(event) => {
let _ = app.emit(event_name, &event);
}
}
}
}
/// Helper: emit visible text + CWD updates from an [`OscResult`].
async fn emit_output(
app: &AppHandle,
output: &Arc<SessionOutputCoalescer>,
cwd_event: &str,
cwd: &SharedCwd,
recording_mgr: &Option<Arc<RecordingManager>>,
session_id: &str,
manager: &Arc<SessionManager>,
result: &osc::OscResult,
) {
emit_metadata(app, cwd_event, cwd, manager, session_id, result).await;
emit_visible_text(output, recording_mgr, session_id, &result.visible);
}
async fn emit_metadata(
app: &AppHandle,
cwd_event: &str,
cwd: &SharedCwd,
manager: &Arc<SessionManager>,
session_id: &str,
result: &osc::OscResult,
) {
for path in &result.cwd_paths {
if let Some(next_cwd) = update_cwd_if_changed(cwd, path).await {
let _ = app.emit(cwd_event, &next_cwd);
}
}
for command in &result.accepted_commands {
let accepted = manager
.confirm_command_submission(session_id, command.clone())
.await;
if accepted {
let _ = app.emit(
"session-command-accepted",
serde_json::json!({
"sessionId": session_id,
"command": command,
}),
);
}
}
}
fn emit_visible_text(
output: &Arc<SessionOutputCoalescer>,
recording_mgr: &Option<Arc<RecordingManager>>,
session_id: &str,
visible: &str,
) {
if visible.is_empty() {
return;
}
if let Some(recorder) = recording_mgr {
recorder.write_output(session_id, visible);
}
output.push(visible);
}
#[cfg(test)]
mod tests {
use super::{
INITIAL_INJECT_DELAY_MS, INJECT_TIMEOUT_SECS, InjectionEvent, InjectionTimeoutEvent,
InjectionTimeoutSource, IoPhase, PendingStartupCommand,
SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES, SUPPRESSION_DIAGNOSTIC_INITIAL_MS,
SUPPRESSION_DIAGNOSTIC_INTERVAL_MS, SuppressionDiagnostics,
append_suppressed_visible_and_take_passthrough, build_post_login_command_input,
build_startup_command_input, cancel_pending_injection_for_input, discard_suppressed_output,
fallback_shell_integration_timeout, handle_injection_result, handle_injection_timeout,
injection_has_timed_out_at, on_initial_injection_sent, open_shell_channel,
run_sftp_only_session_commands, should_send_initial_injection,
};
use crate::config::{AiExecutionProfile, SftpCwdFollowMode, SshProfile};
use crate::core::InputOrigin;
use crate::core::ssh::osc::{OscResult, OscStripper, build_ready_marker};
use crate::core::{
DynamicTitleCapabilities, SessionCommand, SessionHandle, SessionInfo, SessionManager,
SessionType, session_command_channel,
};
use russh::{Channel, ChannelId, Disconnect, client, server};
use std::pin::Pin;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{mpsc, oneshot};
use tokio::time::{Duration, Sleep, timeout};
struct TestClient;
impl client::Handler for TestClient {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::PublicKey,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
fn assert_fake_server_session(result: Result<(), russh::Error>) {
match result {
Ok(()) => {}
Err(russh::Error::IO(error)) if error.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(error) => panic!("fake SSH server session: {error:?}"),
}
}
struct TestServer {
agent_request_tx: mpsc::UnboundedSender<()>,
reject_shell: bool,
accept_x11: bool,
}
impl server::Handler for TestServer {
type Error = russh::Error;
async fn auth_none(&mut self, _user: &str) -> Result<server::Auth, Self::Error> {
Ok(server::Auth::Accept)
}
async fn channel_open_session(
&mut self,
_channel: Channel<server::Msg>,
reply: server::ChannelOpenHandle,
_session: &mut server::Session,
) -> Result<(), Self::Error> {
reply.accept().await;
Ok(())
}
async fn pty_request(
&mut self,
channel: ChannelId,
_term: &str,
_col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut server::Session,
) -> Result<(), Self::Error> {
let _ = session.channel_success(channel);
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), Self::Error> {
if self.reject_shell {
let _ = session.channel_failure(channel);
} else {
let _ = session.channel_success(channel);
}
Ok(())
}
async fn x11_request(
&mut self,
channel: ChannelId,
_single_connection: bool,
_x11_auth_protocol: &str,
_x11_auth_cookie: &str,
_x11_screen_number: u32,
session: &mut server::Session,
) -> Result<(), Self::Error> {
if self.accept_x11 {
let _ = session.channel_success(channel);
} else {
let _ = session.channel_failure(channel);
}
Ok(())
}
async fn agent_request(
&mut self,
_channel: ChannelId,
_session: &mut server::Session,
) -> Result<bool, Self::Error> {
let _ = self.agent_request_tx.send(());
Ok(true)
}
}
async fn assert_agent_forwarding_request(enabled: bool) {
let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024);
let (agent_request_tx, mut agent_request_rx) = mpsc::unbounded_channel();
let mut rng = russh::keys::key::safe_rng();
let server_config = Arc::new(server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.expect("test server key"),
],
..server::Config::default()
});
let server_task = tokio::spawn(async move {
let session = server::run_stream(
server_config,
server_stream,
TestServer {
agent_request_tx,
reject_shell: false,
accept_x11: true,
},
)
.await
.expect("fake SSH server handshake");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
Arc::new(client::Config::default()),
client_stream,
TestClient,
)
.await
.expect("fake SSH client handshake");
assert!(
handle
.authenticate_none("test")
.await
.expect("none authentication")
.success()
);
let (channel, ..) = open_shell_channel(
&mut handle,
"agent-forwarding-test",
None,
enabled,
"xterm-256color",
false,
false,
SftpCwdFollowMode::Off,
100,
None,
)
.await
.expect("interactive shell channel");
if enabled {
timeout(Duration::from_secs(1), agent_request_rx.recv())
.await
.expect("fake server should receive Agent forwarding request")
.expect("Agent forwarding request channel");
} else {
assert!(
timeout(Duration::from_millis(100), agent_request_rx.recv())
.await
.is_err(),
"disabled forwarding must not send auth-agent-req@openssh.com"
);
}
drop(channel);
handle
.disconnect(Disconnect::ByApplication, "test complete", "")
.await
.expect("disconnect fake SSH session");
timeout(Duration::from_secs(1), server_task)
.await
.expect("fake SSH server should stop")
.expect("fake SSH server task");
}
#[tokio::test]
async fn interactive_shell_rejection_is_reported() {
let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024);
let (agent_request_tx, _agent_request_rx) = mpsc::unbounded_channel();
let mut rng = russh::keys::key::safe_rng();
let server_config = Arc::new(server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.expect("test server key"),
],
..server::Config::default()
});
let server_task = tokio::spawn(async move {
let session = server::run_stream(
server_config,
server_stream,
TestServer {
agent_request_tx,
reject_shell: true,
accept_x11: true,
},
)
.await
.expect("fake SSH server handshake");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
Arc::new(client::Config::default()),
client_stream,
TestClient,
)
.await
.expect("fake SSH client handshake");
assert!(
handle
.authenticate_none("test")
.await
.expect("none authentication")
.success()
);
let result = open_shell_channel(
&mut handle,
"shell-rejection-test",
None,
false,
"xterm-256color",
true,
false,
SftpCwdFollowMode::Off,
100,
None,
)
.await;
let error = match result {
Ok(_) => panic!("rejected shell request must fail interactive setup"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("Shell request rejected by server")
);
handle
.disconnect(Disconnect::ByApplication, "test complete", "")
.await
.expect("disconnect fake SSH session");
timeout(Duration::from_secs(1), server_task)
.await
.expect("fake SSH server should stop")
.expect("fake SSH server task");
}
#[tokio::test]
async fn interactive_shell_requests_agent_forwarding_only_when_enabled() {
assert_agent_forwarding_request(false).await;
assert_agent_forwarding_request(true).await;
}
#[tokio::test]
async fn x11_success_does_not_hide_shell_rejection() {
let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024);
let (agent_request_tx, _agent_request_rx) = mpsc::unbounded_channel();
let mut rng = russh::keys::key::safe_rng();
let server_config = Arc::new(server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.expect("test server key"),
],
..server::Config::default()
});
let server_task = tokio::spawn(async move {
let session = server::run_stream(
server_config,
server_stream,
TestServer {
agent_request_tx,
reject_shell: true,
accept_x11: true,
},
)
.await
.expect("fake SSH server handshake");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
Arc::new(client::Config::default()),
client_stream,
TestClient,
)
.await
.expect("fake SSH client handshake");
assert!(
handle
.authenticate_none("test")
.await
.expect("none authentication")
.success()
);
let error = open_shell_channel(
&mut handle,
"x11-shell-rejection-test",
Some("00112233445566778899aabbccddeeff"),
false,
"xterm-256color",
true,
false,
SftpCwdFollowMode::Off,
100,
None,
)
.await
.expect_err("Shell rejection must not be consumed as an earlier X11 reply");
assert!(
error
.to_string()
.contains("Shell request rejected by server")
);
handle
.disconnect(Disconnect::ByApplication, "test complete", "")
.await
.expect("disconnect fake SSH session");
timeout(Duration::from_secs(1), server_task)
.await
.expect("fake SSH server should stop")
.expect("fake SSH server task");
}
#[tokio::test]
async fn x11_rejection_is_non_fatal_when_pty_and_shell_succeed() {
let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024);
let (agent_request_tx, _agent_request_rx) = mpsc::unbounded_channel();
let mut rng = russh::keys::key::safe_rng();
let server_config = Arc::new(server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.expect("test server key"),
],
..server::Config::default()
});
let server_task = tokio::spawn(async move {
let session = server::run_stream(
server_config,
server_stream,
TestServer {
agent_request_tx,
reject_shell: false,
accept_x11: false,
},
)
.await
.expect("fake SSH server handshake");
assert_fake_server_session(session.await);
});
let mut handle = client::connect_stream(
Arc::new(client::Config::default()),
client_stream,
TestClient,
)
.await
.expect("fake SSH client handshake");
assert!(
handle
.authenticate_none("test")
.await
.expect("none authentication")
.success()
);
let (channel, _, _, _, notice) = open_shell_channel(
&mut handle,
"x11-rejection-test",
Some("00112233445566778899aabbccddeeff"),
false,
"xterm-256color",
true,
false,
SftpCwdFollowMode::Off,
100,
None,
)
.await
.expect("X11 rejection must not fail an otherwise valid interactive shell");
assert!(notice.is_some());
drop(channel);
handle
.disconnect(Disconnect::ByApplication, "test complete", "")
.await
.expect("disconnect fake SSH session");
timeout(Duration::from_secs(1), server_task)
.await
.expect("fake SSH server should stop")
.expect("fake SSH server task");
}
#[tokio::test]
async fn sftp_only_session_close_cleans_up() {
let session_id = "sftp-only-session".to_string();
let manager = Arc::new(SessionManager::new());
let (cmd_tx, cmd_rx) = session_command_channel(session_id.clone());
let info = SessionInfo {
id: session_id.clone(),
name: "sftp-only".to_string(),
session_type: SessionType::SSH,
started_at: String::new(),
connection_id: None,
connected: true,
owner_window_label: None,
ai_execution_profile: AiExecutionProfile::Disabled,
injection_active: false,
dynamic_title_capabilities: DynamicTitleCapabilities::default(),
remote_file_browser_enabled: true,
remote_stats_enabled: false,
ssh_profile: Some(SshProfile::Standard),
ssh_runtime_mode: Some(crate::config::SshRuntimeMode::Sftp),
};
manager
.add_session(SessionHandle {
info,
cmd_tx,
startup_input_barrier: None,
ssh_config: None,
ssh_handle: None,
cwd: Arc::new(tokio::sync::Mutex::new(Default::default())),
remote_fs: None,
})
.await;
let loop_manager = manager.clone();
let loop_session_id = session_id.clone();
let (_disconnect_tx, disconnect_rx) = mpsc::unbounded_channel();
let loop_task = tokio::spawn(async move {
run_sftp_only_session_commands(&loop_session_id, &loop_manager, cmd_rx, disconnect_rx)
.await
});
let (attach_tx, attach_rx) = oneshot::channel();
manager
.send_command(
&session_id,
SessionCommand::AttachConfirmed { ack: attach_tx },
)
.await
.expect("attach command");
timeout(Duration::from_secs(1), attach_rx)
.await
.expect("attach acknowledgement must not block")
.expect("attach acknowledgement sender");
let (capture_tx, capture_rx) = oneshot::channel();
manager
.send_command(
&session_id,
SessionCommand::CaptureExec {
marker_id: "capture".to_string(),
wrapped_command: b"echo blocked".to_vec(),
result_tx: capture_tx,
},
)
.await
.expect("capture command");
let capture_result = timeout(Duration::from_secs(1), capture_rx)
.await
.expect("capture result sender must be dropped immediately");
assert!(capture_result.is_err());
manager
.send_command(&session_id, SessionCommand::Close)
.await
.expect("close command");
let close_reason = timeout(Duration::from_secs(1), loop_task)
.await
.expect("SFTP-only loop must close promptly")
.expect("SFTP-only loop task");
assert_eq!(close_reason, "local-close-request");
assert!(manager.list_sessions().await.is_empty());
}
#[tokio::test]
async fn sftp_only_session_remote_disconnect_cleans_up() {
let session_id = "sftp-only-remote-disconnect".to_string();
let manager = Arc::new(SessionManager::new());
let (cmd_tx, cmd_rx) = session_command_channel(session_id.clone());
let info = SessionInfo {
id: session_id.clone(),
name: "sftp-only".to_string(),
session_type: SessionType::SSH,
started_at: String::new(),
connection_id: None,
connected: true,
owner_window_label: None,
ai_execution_profile: AiExecutionProfile::Disabled,
injection_active: false,
dynamic_title_capabilities: DynamicTitleCapabilities::default(),
remote_file_browser_enabled: true,
remote_stats_enabled: false,
ssh_profile: Some(SshProfile::Standard),
ssh_runtime_mode: Some(crate::config::SshRuntimeMode::Sftp),
};
manager
.add_session(SessionHandle {
info,
cmd_tx,
startup_input_barrier: None,
ssh_config: None,
ssh_handle: None,
cwd: Arc::new(tokio::sync::Mutex::new(Default::default())),
remote_fs: None,
})
.await;
let (disconnect_tx, disconnect_rx) = mpsc::unbounded_channel();
let loop_manager = manager.clone();
let loop_session_id = session_id.clone();
let loop_task = tokio::spawn(async move {
run_sftp_only_session_commands(&loop_session_id, &loop_manager, cmd_rx, disconnect_rx)
.await
});
disconnect_tx
.send("SSH server disconnected: test".to_string())
.expect("disconnect notification");
let close_reason = timeout(Duration::from_secs(1), loop_task)
.await
.expect("SFTP-only loop must react to remote disconnect")
.expect("SFTP-only loop task");
assert_eq!(close_reason, "remote-transport-disconnect");
assert!(manager.list_sessions().await.is_empty());
}
#[test]
fn post_login_input_normalizes_line_endings_and_adds_enter() {
let input = build_post_login_command_input("cd /opt/app\nclear").expect("input");
assert_eq!(input, b"cd /opt/app\rclear\r");
}
#[test]
fn post_login_input_preserves_existing_trailing_enter() {
let input = build_post_login_command_input("uptime\r").expect("input");
assert_eq!(input, b"uptime\r");
}
#[test]
fn post_login_input_ignores_blank_commands() {
assert!(build_post_login_command_input(" \n\t ").is_none());
}
#[test]
fn startup_input_rejects_c0_del_and_c1_controls() {
for code in 0x00..=0x1f {
let command = format!("cd /tmp/a{}b", char::from_u32(code).expect("C0 code point"));
assert!(
build_startup_command_input(&command).is_none(),
"C0 control U+{code:04X} must be rejected"
);
}
for code in 0x7f..=0x9f {
let command = format!("cd /tmp/a{}b", char::from_u32(code).expect("C1 code point"));
assert!(
build_startup_command_input(&command).is_none(),
"DEL/C1 control U+{code:04X} must be rejected"
);
}
}
#[test]
fn startup_input_adds_enter_to_a_safe_command() {
let input = build_startup_command_input("cd '/opt/app'").expect("input");
assert_eq!(input, b"cd '/opt/app'\r");
}
fn osc_result(ready: bool, visible_after_ready: &str) -> OscResult {
OscResult {
visible: "suppressed output".to_string(),
visible_after_ready: visible_after_ready.to_string(),
cwd_paths: Vec::new(),
cwd_payloads: Vec::new(),
cwd_payload_invalidated: false,
cwd_payload_events: Vec::new(),
ready,
ready_failed: false,
accepted_commands: Vec::new(),
}
}
#[test]
fn initial_inject_delay_is_500ms_and_wait_initial_can_inject_without_output() {
assert_eq!(INITIAL_INJECT_DELAY_MS, 500);
assert!(should_send_initial_injection(&IoPhase::WaitInitial, true));
assert!(!should_send_initial_injection(&IoPhase::WaitInitial, false));
assert!(!should_send_initial_injection(&IoPhase::Suppressing, true));
assert!(!should_send_initial_injection(&IoPhase::Normal, true));
let mut phase = IoPhase::WaitInitial;
assert_eq!(
handle_injection_result(&mut phase, &osc_result(false, "")),
InjectionEvent::Inject
);
assert_eq!(phase, IoPhase::WaitInitial);
on_initial_injection_sent(&mut phase);
assert_eq!(phase, IoPhase::Suppressing);
}
#[test]
fn real_input_before_initial_injection_cancels_pending_script_and_preserves_output() {
let ready_marker = build_ready_marker("session-1");
let mut stripper = OscStripper::new(&ready_marker);
let mut phase = IoPhase::WaitInitial;
let mut pending_script =
Some("source ~/.config/nyaterm/shell-integration.bash\n".to_string());
assert!(cancel_pending_injection_for_input(
&mut phase,
&mut pending_script,
InputOrigin::Keyboard,
));
assert_eq!(phase, IoPhase::Normal);
assert!(pending_script.is_none());
let chunks = [
"\x1b[38;5;14mubuntu-logo\x1b[0m\nOS: Ubuntu 22.04\n",
"Kernel: 6.8.0\nUptime: 1 day\n",
];
let mut visible = String::new();
for chunk in chunks {
let result = stripper.push(chunk);
assert_eq!(
handle_injection_result(&mut phase, &result),
InjectionEvent::None
);
assert_eq!(phase, IoPhase::Normal);
visible.push_str(&result.visible);
}
assert_eq!(visible, chunks.concat());
}
#[test]
fn terminal_response_keeps_pending_initial_injection() {
let mut phase = IoPhase::WaitInitial;
let mut pending_script = Some("integration-script".to_string());
assert!(!cancel_pending_injection_for_input(
&mut phase,
&mut pending_script,
InputOrigin::TerminalResponse,
));
assert_eq!(phase, IoPhase::WaitInitial);
assert!(pending_script.is_some());
assert_eq!(
handle_injection_result(&mut phase, &osc_result(false, "")),
InjectionEvent::Inject
);
on_initial_injection_sent(&mut phase);
assert_eq!(phase, IoPhase::Suppressing);
}
#[test]
fn input_after_initial_injection_does_not_end_suppression() {
let ready_marker = build_ready_marker("session-1");
let mut stripper = OscStripper::new(&ready_marker);
let mut phase = IoPhase::Suppressing;
let mut pending_script = None;
assert!(!cancel_pending_injection_for_input(
&mut phase,
&mut pending_script,
InputOrigin::Keyboard,
));
let source_echo = stripper.push("hidden integration echo");
assert_eq!(
handle_injection_result(&mut phase, &source_echo),
InjectionEvent::None
);
assert_eq!(phase, IoPhase::Suppressing);
let ready = stripper.push(&ready_marker);
assert!(matches!(
handle_injection_result(&mut phase, &ready),
InjectionEvent::Ready { .. }
));
assert_eq!(phase, IoPhase::Normal);
}
#[test]
fn ready_marker_in_suppressing_enters_normal_and_preserves_prompt_after_ready() {
let mut phase = IoPhase::Suppressing;
let sent_at = Instant::now();
assert!(!injection_has_timed_out_at(
&phase,
Some(&sent_at),
sent_at + Duration::from_millis(7_900)
));
let result = osc_result(true, "[user@host ~]$ ");
let event = handle_injection_result(&mut phase, &result);
assert_eq!(phase, IoPhase::Normal);
assert_eq!(
event,
InjectionEvent::Ready {
visible_after_ready: "[user@host ~]$ ".to_string()
}
);
}
#[test]
fn failure_marker_in_suppressing_enters_normal_and_preserves_prompt() {
let mut phase = IoPhase::Suppressing;
let mut result = osc_result(false, "prompt-after-failure");
result.ready_failed = true;
let event = handle_injection_result(&mut phase, &result);
assert_eq!(phase, IoPhase::Normal);
assert_eq!(
event,
InjectionEvent::Failed {
visible_after_ready: "prompt-after-failure".to_string()
}
);
}
#[test]
fn failure_marker_after_startup_preserves_visible_output_and_reports_runtime_failure() {
let mut phase = IoPhase::Normal;
let mut result = osc_result(false, "prompt-after-failure");
result.visible = "beforeprompt-after-failure".to_string();
result.ready_failed = true;
let event = handle_injection_result(&mut phase, &result);
assert_eq!(phase, IoPhase::Normal);
assert_eq!(
event,
InjectionEvent::RuntimeFailed {
visible: "beforeprompt-after-failure".to_string()
}
);
}
#[test]
fn suppression_transport_preserves_interleaved_title_query_order() {
let mut buffer = String::new();
let controls = concat!(
"\x1b]2;first\x07",
"\x1b[c",
"\x1b]0;final\x1b\\",
"\x1b]11;?\x07"
);
let passthrough = append_suppressed_visible_and_take_passthrough(
&mut buffer,
&format!("hidden{controls}tail"),
);
assert_eq!(passthrough, controls);
assert_eq!(buffer, "hiddentail");
}
#[test]
fn oversized_startup_queries_and_titles_remain_suppressed() {
let mut buffer = String::new();
let huge_dcs = format!("\x1bP$q{}\x1b\\", "x".repeat(17 * 1024));
let huge_title = format!("\x1b]2;{}\x07", "x".repeat(17 * 1024));
let passthrough = append_suppressed_visible_and_take_passthrough(
&mut buffer,
&format!("{huge_dcs}{huge_title}"),
);
assert!(passthrough.is_empty());
assert!(buffer.len() <= SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES);
}
#[test]
fn suppression_title_transport_is_exactly_once_across_split_ready_and_normal_phases() {
let ready_marker = build_ready_marker("session-1");
let mut stripper = OscStripper::new(&ready_marker);
let mut phase = IoPhase::Suppressing;
let mut buffer = String::new();
let mut emitted = String::new();
let incomplete = stripper.push("hidden source\x1b]2;split");
let _ = handle_injection_result(&mut phase, &incomplete);
emitted.push_str(&append_suppressed_visible_and_take_passthrough(
&mut buffer,
&incomplete.visible,
));
let completed = stripper.push(" title\x07hidden tail");
let _ = handle_injection_result(&mut phase, &completed);
emitted.push_str(&append_suppressed_visible_and_take_passthrough(
&mut buffer,
&completed.visible,
));
let ready = stripper.push(&format!("{ready_marker}[user@host]$ "));
let event = handle_injection_result(&mut phase, &ready);
let before_ready_len = ready.visible.len() - ready.visible_after_ready.len();
emitted.push_str(&append_suppressed_visible_and_take_passthrough(
&mut buffer,
&ready.visible[..before_ready_len],
));
assert!(matches!(event, InjectionEvent::Ready { .. }));
emitted.push_str(&ready.visible_after_ready);
buffer.clear();
let normal = stripper.push("\x1b]2;normal title\x07prompt");
let event = handle_injection_result(&mut phase, &normal);
assert_eq!(event, InjectionEvent::None);
emitted.push_str(&normal.visible);
assert_eq!(phase, IoPhase::Normal);
assert_eq!(emitted.matches("\x1b]2;split title\x07").count(), 1);
assert_eq!(emitted.matches("\x1b]2;normal title\x07").count(), 1);
assert!(!emitted.contains("hidden source"));
assert!(!emitted.contains("hidden tail"));
assert!(
emitted.find("\x1b]2;split title\x07").unwrap()
< emitted.find("[user@host]$ ").unwrap()
);
assert!(
emitted.find("[user@host]$ ").unwrap()
< emitted.find("\x1b]2;normal title\x07").unwrap()
);
}
#[test]
fn title_transport_cap_is_per_sequence_and_keeps_final_semantics() {
let first = format!("\x1b]2;{}\x07", "x".repeat(16_395));
let final_title = "\x1b]2;final\x07";
let mut buffer = String::new();
let passthrough = append_suppressed_visible_and_take_passthrough(
&mut buffer,
&format!("{first}{final_title}"),
);
assert_eq!(passthrough, format!("{first}{final_title}"));
assert!(passthrough.ends_with(final_title));
assert!(buffer.is_empty());
}
#[test]
fn delivered_suppression_title_survives_timeout_fallback() {
let mut buffer = String::new();
let delivered = append_suppressed_visible_and_take_passthrough(
&mut buffer,
"\x1b]2;delivered\x07hidden",
);
let mut phase = IoPhase::Suppressing;
assert_eq!(
handle_injection_timeout(&mut phase),
InjectionTimeoutEvent::FallbackToNormal
);
assert_eq!(delivered, "\x1b]2;delivered\x07");
assert_eq!(buffer, "hidden");
assert_eq!(phase, IoPhase::Normal);
}
#[test]
fn injection_timeout_is_8s_and_only_applies_after_injection_is_sent() {
assert_eq!(INJECT_TIMEOUT_SECS, 8);
let mut wait_initial = IoPhase::WaitInitial;
assert_eq!(
handle_injection_timeout(&mut wait_initial),
InjectionTimeoutEvent::None
);
assert_eq!(wait_initial, IoPhase::WaitInitial);
let sent_at = Instant::now();
assert!(!injection_has_timed_out_at(
&wait_initial,
Some(&sent_at),
sent_at + Duration::from_secs(INJECT_TIMEOUT_SECS)
));
let mut suppressing = IoPhase::Suppressing;
assert!(!injection_has_timed_out_at(
&suppressing,
None,
sent_at + Duration::from_secs(INJECT_TIMEOUT_SECS)
));
assert!(injection_has_timed_out_at(
&suppressing,
Some(&sent_at),
sent_at + Duration::from_secs(INJECT_TIMEOUT_SECS)
));
assert_eq!(
handle_injection_timeout(&mut suppressing),
InjectionTimeoutEvent::FallbackToNormal
);
assert_eq!(suppressing, IoPhase::Normal);
}
#[test]
fn continuous_remote_data_cannot_extend_wall_clock_timeout() {
let sent_at = Instant::now();
let mut phase = IoPhase::Suppressing;
let mut diagnostics = SuppressionDiagnostics::default();
for chunk in 0..80 {
let now = sent_at + Duration::from_millis(chunk * 100);
diagnostics.record_rx(4, &sent_at, now);
assert!(!injection_has_timed_out_at(&phase, Some(&sent_at), now));
}
let timeout_at = sent_at + Duration::from_secs(INJECT_TIMEOUT_SECS);
diagnostics.record_rx(4, &sent_at, timeout_at);
assert!(injection_has_timed_out_at(
&phase,
Some(&sent_at),
timeout_at
));
assert_eq!(diagnostics.rx_bytes_total, 324);
assert_eq!(diagnostics.rx_chunks, 81);
assert_eq!(diagnostics.first_rx_after_ms, Some(0));
assert_eq!(diagnostics.last_rx_after_ms, Some(8_000));
assert_eq!(
handle_injection_timeout(&mut phase),
InjectionTimeoutEvent::FallbackToNormal
);
assert_eq!(phase, IoPhase::Normal);
}
#[tokio::test]
async fn expired_injection_deadline_wins_over_ready_remote_data() {
let deadline = tokio::time::sleep(Duration::ZERO);
tokio::pin!(deadline);
let remote_data = std::future::ready(());
let selected = tokio::select! {
biased;
() = &mut deadline => "deadline",
() = remote_data => "remote_data",
};
assert_eq!(selected, "deadline");
}
#[test]
fn ready_marker_after_timeout_stays_normal_and_is_safely_stripped() {
let ready_marker = build_ready_marker("session-1");
let mut stripper = OscStripper::new(&ready_marker);
let mut phase = IoPhase::Suppressing;
assert_eq!(
handle_injection_timeout(&mut phase),
InjectionTimeoutEvent::FallbackToNormal
);
let result = stripper.push(&format!("{ready_marker}late prompt"));
let event = handle_injection_result(&mut phase, &result);
assert_eq!(phase, IoPhase::Normal);
assert_eq!(event, InjectionEvent::None);
assert_eq!(result.visible, "late prompt");
assert!(result.ready);
}
#[test]
fn pre_ready_writes_are_counted_without_extending_timeout() {
let sent_at = Instant::now();
let phase = IoPhase::Suppressing;
let mut diagnostics = SuppressionDiagnostics::default();
diagnostics.record_pre_ready_write(3);
diagnostics.record_pre_ready_write(5);
assert_eq!(diagnostics.pre_ready_write_bytes, 8);
assert_eq!(diagnostics.pre_ready_write_chunks, 2);
assert!(injection_has_timed_out_at(
&phase,
Some(&sent_at),
sent_at + Duration::from_secs(INJECT_TIMEOUT_SECS)
));
}
#[test]
fn suppression_diagnostic_is_delayed_and_rate_limited() {
assert_eq!(SUPPRESSION_DIAGNOSTIC_INITIAL_MS, 1_000);
assert_eq!(SUPPRESSION_DIAGNOSTIC_INTERVAL_MS, 2_000);
let sent_at = Instant::now();
let mut diagnostics = SuppressionDiagnostics::default();
assert!(
!diagnostics
.should_log_suppression_diagnostic(&sent_at, sent_at + Duration::from_millis(999))
);
assert!(
diagnostics
.should_log_suppression_diagnostic(&sent_at, sent_at + Duration::from_secs(1))
);
assert!(
!diagnostics.should_log_suppression_diagnostic(
&sent_at,
sent_at + Duration::from_millis(2_999)
)
);
assert!(
diagnostics
.should_log_suppression_diagnostic(&sent_at, sent_at + Duration::from_secs(3))
);
}
#[tokio::test]
async fn timeout_fallback_is_idempotent_flushes_buffers_and_arms_post_login() {
let ready_marker = build_ready_marker("session-1");
let mut stripper = OscStripper::new(&ready_marker);
let _ = stripper.push("\x1b]2;unfinished");
let mut suppressed_visible = "hidden startup output".to_string();
let mut phase = IoPhase::Suppressing;
let sent_at = Instant::now()
.checked_sub(Duration::from_secs(INJECT_TIMEOUT_SECS))
.expect("test instant should support an eight-second subtraction");
let diagnostics = SuppressionDiagnostics::default();
let pending_post_login = Some(PendingStartupCommand {
input: b"uptime\r".to_vec(),
delay_ms: 1,
});
let mut post_login_deadline: Option<Pin<Box<Sleep>>> = None;
assert!(fallback_shell_integration_timeout(
&mut phase,
&mut stripper,
&mut suppressed_visible,
"session-1",
Some(crate::core::ssh::osc::ShellKind::Bash),
Some(&sent_at),
&diagnostics,
InjectionTimeoutSource::Deadline,
&pending_post_login,
&mut post_login_deadline,
));
assert_eq!(phase, IoPhase::Normal);
assert!(suppressed_visible.is_empty());
assert_eq!(stripper.buffered_len(), 0);
assert!(post_login_deadline.is_some());
assert!(!fallback_shell_integration_timeout(
&mut phase,
&mut stripper,
&mut suppressed_visible,
"session-1",
Some(crate::core::ssh::osc::ShellKind::Bash),
Some(&sent_at),
&diagnostics,
InjectionTimeoutSource::WallClock,
&pending_post_login,
&mut post_login_deadline,
));
}
#[test]
fn suppressed_visible_fallback_keeps_recent_text_and_extracts_split_passthrough() {
let mut buffer = String::new();
assert!(
append_suppressed_visible_and_take_passthrough(&mut buffer, "hidden\x1b[").is_empty()
);
let passthrough =
append_suppressed_visible_and_take_passthrough(&mut buffer, "cstill-hidden");
assert_eq!(passthrough, "\x1b[c");
assert_eq!(buffer, "hiddenstill-hidden");
}
#[test]
fn suppressed_visible_fallback_is_bounded_after_passthrough_extraction() {
let mut buffer = String::new();
let passthrough = append_suppressed_visible_and_take_passthrough(
&mut buffer,
&format!(
"{}\x1b]11;?\x07",
"x".repeat(SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES + 16)
),
);
assert_eq!(passthrough, "\x1b]11;?\x07");
assert_eq!(buffer.len(), SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES);
}
#[test]
fn timeout_discard_clears_suppressed_visible_and_buffered_osc_text() {
let mut buffer = "prompt".to_string();
let discarded_len = discard_suppressed_output(&mut buffer, "tail".to_string());
assert_eq!(discarded_len, "prompttail".len());
assert!(buffer.is_empty());
}
#[tokio::test]
async fn post_login_timer_only_arms_after_injection_is_normal() {
let pending_post_login = Some(PendingStartupCommand {
input: b"uptime\r".to_vec(),
delay_ms: 1,
});
let mut post_login_deadline: Option<Pin<Box<Sleep>>> = None;
super::arm_post_login_timer(
&IoPhase::WaitInitial,
&pending_post_login,
&mut post_login_deadline,
);
assert!(post_login_deadline.is_none());
super::arm_post_login_timer(
&IoPhase::Suppressing,
&pending_post_login,
&mut post_login_deadline,
);
assert!(post_login_deadline.is_none());
super::arm_post_login_timer(
&IoPhase::Normal,
&pending_post_login,
&mut post_login_deadline,
);
assert!(post_login_deadline.is_some());
post_login_deadline.as_mut().unwrap().as_mut().await;
}
#[tokio::test]
async fn early_input_cancellation_arms_post_login_timer() {
let pending_post_login = Some(PendingStartupCommand {
input: b"uptime\r".to_vec(),
delay_ms: 1,
});
let mut pending_script = Some("integration-script".to_string());
let mut phase = IoPhase::WaitInitial;
let mut post_login_deadline: Option<Pin<Box<Sleep>>> = None;
assert!(cancel_pending_injection_for_input(
&mut phase,
&mut pending_script,
InputOrigin::QuickCommand,
));
super::arm_post_login_timer(&phase, &pending_post_login, &mut post_login_deadline);
assert_eq!(phase, IoPhase::Normal);
assert!(post_login_deadline.is_some());
post_login_deadline.as_mut().unwrap().as_mut().await;
}
#[tokio::test]
async fn capture_exec_before_initial_injection_cancels_pending_script() {
let pending_post_login = Some(PendingStartupCommand {
input: b"uptime\r".to_vec(),
delay_ms: 1,
});
let mut pending_script = Some("integration-script".to_string());
let mut phase = IoPhase::WaitInitial;
let mut post_login_deadline: Option<Pin<Box<Sleep>>> = None;
assert!(cancel_pending_injection_for_input(
&mut phase,
&mut pending_script,
InputOrigin::AiAgent,
));
super::arm_post_login_timer(&phase, &pending_post_login, &mut post_login_deadline);
assert_eq!(phase, IoPhase::Normal);
assert!(pending_script.is_none());
assert!(post_login_deadline.is_some());
post_login_deadline.as_mut().unwrap().as_mut().await;
}
}