Skip to content

Commit 7b3db17

Browse files
committed
resilience: reconnect shells and restore terminal state
1 parent 4200ef4 commit 7b3db17

6 files changed

Lines changed: 621 additions & 83 deletions

File tree

src/daemon.rs

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -999,11 +999,10 @@ impl DaemonState {
999999
let listener_cancel = CancellationToken::new();
10001000
let lease = DialListenerLease::new(self.active_dial_listeners.clone());
10011001

1002-
// Built-in shell and exec speak their own end-to-end framing over a raw
1003-
// stream — they must NOT go through the tunnel-session (mux) path, whose
1004-
// Hello frame would corrupt the first client frame. Every other protocol
1005-
// (exposed tunnels) uses the resumable tunnel path.
1006-
let listener_task = if alpn == shell::SHELL_ALPN || alpn == exec::EXEC_ALPN {
1002+
// Built-in exec remains a one-shot raw framed stream. Built-in shell
1003+
// rides the resumable tunnel path so its PTY outlives transient iroh
1004+
// attaches; local reconnect notices are encoded as shell status frames.
1005+
let listener_task = if alpn == exec::EXEC_ALPN {
10071006
tokio::spawn(run_raw_dial_socket(
10081007
listener,
10091008
self.endpoint_rx(),
@@ -1016,6 +1015,7 @@ impl DaemonState {
10161015
lease,
10171016
))
10181017
} else {
1018+
let notices = (alpn == shell::SHELL_ALPN).then(shell_client_notices);
10191019
tokio::spawn(run_dial_socket(
10201020
listener,
10211021
self.endpoint_rx(),
@@ -1028,6 +1028,7 @@ impl DaemonState {
10281028
self.dial_failures.clone(),
10291029
self.dial_slots.clone(),
10301030
lease,
1031+
notices,
10311032
))
10321033
};
10331034
sockets.insert(
@@ -2639,16 +2640,20 @@ async fn handle_builtin_echo(connection: Connection, state: Arc<DaemonState>) ->
26392640
}
26402641

26412642
async fn handle_builtin_shell(connection: Connection, state: Arc<DaemonState>) -> Result<()> {
2642-
let peer = connection.remote_id().to_string();
2643-
let (mut send, mut recv) = connection.accept_bi().await?;
2644-
if state.allow_shell {
2645-
shell::serve_shell_session(&mut recv, &mut send, &peer).await?;
2646-
} else {
2647-
shell::serve_shell_disabled(&mut send).await?;
2648-
}
2649-
send.finish()?;
2650-
connection.closed().await;
2651-
Ok(())
2643+
let peer_id = connection.remote_id();
2644+
let (send, recv) = connection.accept_bi().await?;
2645+
tunnel::serve_connection(
2646+
connection,
2647+
send,
2648+
recv,
2649+
peer_id,
2650+
tunnel::ServerTarget::Shell {
2651+
allowed: state.allow_shell,
2652+
},
2653+
state.tunnel_sessions.clone(),
2654+
state.tunnel_drop_rx(),
2655+
)
2656+
.await
26522657
}
26532658

26542659
async fn handle_builtin_exec(connection: Connection, state: Arc<DaemonState>) -> Result<()> {
@@ -3067,6 +3072,7 @@ async fn run_dial_socket(
30673072
dial_failures: Arc<FailureBackoff>,
30683073
dial_slots: Arc<Semaphore>,
30693074
_lease: DialListenerLease,
3075+
notices: Option<tunnel::ClientConnectionNotices>,
30703076
) {
30713077
loop {
30723078
tokio::select! {
@@ -3095,13 +3101,23 @@ async fn run_dial_socket(
30953101
let cancel = daemon_cancel.clone();
30963102
let drop_rx = drop_rx.clone();
30973103
let dial_failures = dial_failures.clone();
3104+
let notices = notices.clone();
30983105
tokio::spawn(async move {
30993106
let _permit = permit;
31003107
if !dial_failures.wait(&cancel).await {
31013108
return;
31023109
}
31033110
match
3104-
tunnel::run_client_connection(local, endpoint_rx, home, peer, alpn, cancel, drop_rx)
3111+
tunnel::run_client_connection(
3112+
local,
3113+
endpoint_rx,
3114+
home,
3115+
peer,
3116+
alpn,
3117+
cancel,
3118+
drop_rx,
3119+
notices,
3120+
)
31053121
.await
31063122
{
31073123
Ok(()) => dial_failures.record_success().await,
@@ -3117,6 +3133,28 @@ async fn run_dial_socket(
31173133
}
31183134
}
31193135

3136+
fn shell_client_notices() -> tunnel::ClientConnectionNotices {
3137+
tunnel::ClientConnectionNotices::new(|event| {
3138+
let encoded = match event {
3139+
tunnel::ClientConnectionEvent::Reconnecting {
3140+
attempt,
3141+
delay,
3142+
error,
3143+
} => shell::encode_server_status(&format!(
3144+
"connection lost ({error}); reconnecting attempt {attempt} in {:.1}s",
3145+
delay.as_secs_f32()
3146+
)),
3147+
tunnel::ClientConnectionEvent::Resumed => {
3148+
shell::encode_server_status("connection restored; remote shell session resumed")
3149+
}
3150+
tunnel::ClientConnectionEvent::Failed { error } => {
3151+
shell::encode_server_error(&format!("remote shell could not resume: {error}"))
3152+
}
3153+
};
3154+
encoded.ok()
3155+
})
3156+
}
3157+
31203158
async fn run_dial_tcp_listener(
31213159
listener: TcpListener,
31223160
endpoint_rx: watch::Receiver<CurrentEndpoint>,

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod pathwatch;
1414
pub mod service;
1515
pub mod shell;
1616
pub mod sync;
17+
pub mod terminal;
1718
mod tunnel;
1819

1920
const SPIKE_ALPN: &[u8] = b"fabric/spike/echo/0";

src/main.rs

Lines changed: 135 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::{
33
io::IsTerminal,
44
path::PathBuf,
55
process::{Command as ProcessCommand, Stdio},
6+
sync::Arc,
67
time::{Duration, Instant},
78
};
89

@@ -21,6 +22,7 @@ use fabric::{
2122
service::{self, DEFAULT_MEMORY_MAX_MB, ServiceInstallOptions},
2223
shell::{self, ServerFrame},
2324
sync::config::{SyncBook, SyncEntry, SyncPeers, SyncPolicy},
25+
terminal::TerminalModeGuard,
2426
};
2527
use tokio::io::{AsyncReadExt, AsyncWriteExt};
2628

@@ -1106,48 +1108,83 @@ async fn wait_for_daemon_ready(
11061108

11071109
async fn run_shell_client(socket: &PathBuf) -> Result<i32> {
11081110
let stream = tokio::net::UnixStream::connect(socket).await?;
1109-
let (mut read, mut write) = stream.into_split();
1110-
let _raw_mode = RawModeGuard::enable_if_terminal()?;
1111+
let (mut read, write) = stream.into_split();
1112+
let mut signals = ShellSignals::new()?;
1113+
let terminal = TerminalModeGuard::enable_if_terminal()?;
11111114
let (cols, rows) = terminal_size();
1112-
shell::write_client_resize(&mut write, rows, cols).await?;
1115+
let write = Arc::new(tokio::sync::Mutex::new(write));
1116+
shell::write_client_resize(&mut *write.lock().await, rows, cols).await?;
11131117

1118+
let stdin_write = write.clone();
11141119
let stdin_task = tokio::spawn(async move {
11151120
let mut stdin = tokio::io::stdin();
11161121
let mut buf = [0u8; 8192];
11171122
loop {
11181123
let read = stdin.read(&mut buf).await?;
11191124
if read == 0 {
1120-
shell::write_client_eof(&mut write).await?;
1125+
shell::write_client_eof(&mut *stdin_write.lock().await).await?;
11211126
return Ok::<(), anyhow::Error>(());
11221127
}
1123-
shell::write_client_stdin(&mut write, &buf[..read]).await?;
1128+
shell::write_client_stdin(&mut *stdin_write.lock().await, &buf[..read]).await?;
11241129
}
11251130
});
11261131

11271132
let mut stdout = tokio::io::stdout();
11281133
let mut stderr = tokio::io::stderr();
11291134
let mut exit_code = 1;
11301135

1131-
while let Some(frame) = shell::read_server_frame(&mut read).await? {
1132-
match frame {
1133-
ServerFrame::Output(bytes) => {
1134-
stdout.write_all(&bytes).await?;
1135-
stdout.flush().await?;
1136-
}
1137-
ServerFrame::Error(message) => {
1138-
stderr.write_all(message.as_bytes()).await?;
1139-
stderr.write_all(b"\n").await?;
1140-
stderr.flush().await?;
1136+
loop {
1137+
tokio::select! {
1138+
frame = shell::read_server_frame(&mut read) => {
1139+
let Some(frame) = frame? else {
1140+
break;
1141+
};
1142+
match frame {
1143+
ServerFrame::Output(bytes) => {
1144+
stdout.write_all(&bytes).await?;
1145+
stdout.flush().await?;
1146+
}
1147+
ServerFrame::Error(message) => {
1148+
stderr.write_all(message.as_bytes()).await?;
1149+
stderr.write_all(b"\n").await?;
1150+
stderr.flush().await?;
1151+
}
1152+
ServerFrame::Status(message) => {
1153+
stderr.write_all(message.as_bytes()).await?;
1154+
stderr.write_all(b"\n").await?;
1155+
stderr.flush().await?;
1156+
}
1157+
ServerFrame::Exit(code) => {
1158+
exit_code = normalize_exit_code(code);
1159+
break;
1160+
}
1161+
}
11411162
}
1142-
ServerFrame::Exit(code) => {
1143-
exit_code = normalize_exit_code(code);
1144-
break;
1163+
signal = signals.recv() => {
1164+
match signal {
1165+
ShellSignal::Resize => {
1166+
let (cols, rows) = terminal_size();
1167+
shell::write_client_resize(&mut *write.lock().await, rows, cols).await?;
1168+
}
1169+
ShellSignal::Suspend => {
1170+
terminal.restore()?;
1171+
suspend_current_process();
1172+
terminal.reenter_raw()?;
1173+
let (cols, rows) = terminal_size();
1174+
shell::write_client_resize(&mut *write.lock().await, rows, cols).await?;
1175+
}
1176+
ShellSignal::Terminate(signal) => {
1177+
terminal.restore()?;
1178+
terminate_with_signal(signal);
1179+
}
1180+
}
11451181
}
11461182
}
11471183
}
11481184

11491185
stdin_task.abort();
11501186
let _ = stdin_task.await;
1187+
terminal.restore()?;
11511188
stdout.flush().await?;
11521189
stderr.flush().await?;
11531190
Ok(exit_code)
@@ -1280,10 +1317,6 @@ fn normalize_exit_code(code: i32) -> i32 {
12801317
code.clamp(0, 255)
12811318
}
12821319

1283-
struct RawModeGuard {
1284-
enabled: bool,
1285-
}
1286-
12871320
struct SocketFileGuard(PathBuf);
12881321

12891322
impl Drop for SocketFileGuard {
@@ -1292,23 +1325,91 @@ impl Drop for SocketFileGuard {
12921325
}
12931326
}
12941327

1295-
impl RawModeGuard {
1296-
fn enable_if_terminal() -> Result<Self> {
1297-
if std::io::stdin().is_terminal() {
1298-
crossterm::terminal::enable_raw_mode()?;
1299-
Ok(Self { enabled: true })
1300-
} else {
1301-
Ok(Self { enabled: false })
1328+
enum ShellSignal {
1329+
Resize,
1330+
Suspend,
1331+
Terminate(i32),
1332+
}
1333+
1334+
#[cfg(unix)]
1335+
struct ShellSignals {
1336+
hangup: tokio::signal::unix::Signal,
1337+
interrupt: tokio::signal::unix::Signal,
1338+
quit: tokio::signal::unix::Signal,
1339+
terminate: tokio::signal::unix::Signal,
1340+
suspend: tokio::signal::unix::Signal,
1341+
resize: tokio::signal::unix::Signal,
1342+
}
1343+
1344+
#[cfg(not(unix))]
1345+
struct ShellSignals;
1346+
1347+
#[cfg(unix)]
1348+
impl ShellSignals {
1349+
fn new() -> Result<Self> {
1350+
use tokio::signal::unix::{SignalKind, signal};
1351+
1352+
Ok(Self {
1353+
hangup: signal(SignalKind::hangup())?,
1354+
interrupt: signal(SignalKind::interrupt())?,
1355+
quit: signal(SignalKind::quit())?,
1356+
terminate: signal(SignalKind::terminate())?,
1357+
suspend: signal(SignalKind::from_raw(libc::SIGTSTP))?,
1358+
resize: signal(SignalKind::window_change())?,
1359+
})
1360+
}
1361+
1362+
async fn recv(&mut self) -> ShellSignal {
1363+
tokio::select! {
1364+
_ = self.hangup.recv() => ShellSignal::Terminate(libc::SIGHUP),
1365+
_ = self.interrupt.recv() => ShellSignal::Terminate(libc::SIGINT),
1366+
_ = self.quit.recv() => ShellSignal::Terminate(libc::SIGQUIT),
1367+
_ = self.terminate.recv() => ShellSignal::Terminate(libc::SIGTERM),
1368+
_ = self.suspend.recv() => ShellSignal::Suspend,
1369+
_ = self.resize.recv() => ShellSignal::Resize,
13021370
}
13031371
}
13041372
}
13051373

1306-
impl Drop for RawModeGuard {
1307-
fn drop(&mut self) {
1308-
if self.enabled {
1309-
let _ = crossterm::terminal::disable_raw_mode();
1310-
}
1374+
#[cfg(not(unix))]
1375+
impl ShellSignals {
1376+
fn new() -> Result<Self> {
1377+
Ok(Self)
13111378
}
1379+
1380+
async fn recv(&mut self) -> ShellSignal {
1381+
std::future::pending().await
1382+
}
1383+
}
1384+
1385+
#[cfg(unix)]
1386+
fn suspend_current_process() {
1387+
// SIGTSTP is intercepted above so we can restore the terminal first. SIGSTOP
1388+
// cannot be caught, which guarantees one real stop; execution resumes here
1389+
// after the process receives SIGCONT.
1390+
unsafe {
1391+
libc::raise(libc::SIGSTOP);
1392+
}
1393+
}
1394+
1395+
#[cfg(not(unix))]
1396+
fn suspend_current_process() {}
1397+
1398+
#[cfg(unix)]
1399+
fn terminate_with_signal(signal: i32) -> ! {
1400+
// Tokio installed the process signal handler. Restore the default action
1401+
// after restoring termios, then re-raise so parents observe a signal exit
1402+
// instead of a fabricated numeric status.
1403+
unsafe {
1404+
libc::signal(signal, libc::SIG_DFL);
1405+
libc::raise(signal);
1406+
libc::_exit(128 + signal);
1407+
}
1408+
}
1409+
1410+
#[cfg(not(unix))]
1411+
fn terminate_with_signal(_signal: i32) -> ! {
1412+
std::process::exit(1)
13121413
}
13131414

13141415
async fn spawn_daemon(home: &FabricHome, options: DaemonOptions) -> Result<()> {

0 commit comments

Comments
 (0)