Skip to content

Commit 36bee6c

Browse files
committed
resilience: reconnect shells and restore terminal state
1 parent 16dd81e commit 36bee6c

6 files changed

Lines changed: 591 additions & 83 deletions

File tree

src/daemon.rs

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

1000-
// Built-in shell and exec speak their own end-to-end framing over a raw
1001-
// stream — they must NOT go through the tunnel-session (mux) path, whose
1002-
// Hello frame would corrupt the first client frame. Every other protocol
1003-
// (exposed tunnels) uses the resumable tunnel path.
1004-
let listener_task = if alpn == shell::SHELL_ALPN || alpn == exec::EXEC_ALPN {
1000+
// Built-in exec remains a one-shot raw framed stream. Built-in shell
1001+
// rides the resumable tunnel path so its PTY outlives transient iroh
1002+
// attaches; local reconnect notices are encoded as shell status frames.
1003+
let listener_task = if alpn == exec::EXEC_ALPN {
10051004
tokio::spawn(run_raw_dial_socket(
10061005
listener,
10071006
self.endpoint_rx(),
@@ -1014,6 +1013,7 @@ impl DaemonState {
10141013
lease,
10151014
))
10161015
} else {
1016+
let notices = (alpn == shell::SHELL_ALPN).then(shell_client_notices);
10171017
tokio::spawn(run_dial_socket(
10181018
listener,
10191019
self.endpoint_rx(),
@@ -1026,6 +1026,7 @@ impl DaemonState {
10261026
self.dial_failures.clone(),
10271027
self.dial_slots.clone(),
10281028
lease,
1029+
notices,
10291030
))
10301031
};
10311032
sockets.insert(
@@ -2590,16 +2591,20 @@ async fn handle_builtin_echo(connection: Connection, state: Arc<DaemonState>) ->
25902591
}
25912592

25922593
async fn handle_builtin_shell(connection: Connection, state: Arc<DaemonState>) -> Result<()> {
2593-
let peer = connection.remote_id().to_string();
2594-
let (mut send, mut recv) = connection.accept_bi().await?;
2595-
if state.allow_shell {
2596-
shell::serve_shell_session(&mut recv, &mut send, &peer).await?;
2597-
} else {
2598-
shell::serve_shell_disabled(&mut send).await?;
2599-
}
2600-
send.finish()?;
2601-
connection.closed().await;
2602-
Ok(())
2594+
let peer_id = connection.remote_id();
2595+
let (send, recv) = connection.accept_bi().await?;
2596+
tunnel::serve_connection(
2597+
connection,
2598+
send,
2599+
recv,
2600+
peer_id,
2601+
tunnel::ServerTarget::Shell {
2602+
allowed: state.allow_shell,
2603+
},
2604+
state.tunnel_sessions.clone(),
2605+
state.tunnel_drop_rx(),
2606+
)
2607+
.await
26032608
}
26042609

26052610
async fn handle_builtin_exec(connection: Connection, state: Arc<DaemonState>) -> Result<()> {
@@ -3018,6 +3023,7 @@ async fn run_dial_socket(
30183023
dial_failures: Arc<FailureBackoff>,
30193024
dial_slots: Arc<Semaphore>,
30203025
_lease: DialListenerLease,
3026+
notices: Option<tunnel::ClientConnectionNotices>,
30213027
) {
30223028
loop {
30233029
tokio::select! {
@@ -3046,13 +3052,23 @@ async fn run_dial_socket(
30463052
let cancel = daemon_cancel.clone();
30473053
let drop_rx = drop_rx.clone();
30483054
let dial_failures = dial_failures.clone();
3055+
let notices = notices.clone();
30493056
tokio::spawn(async move {
30503057
let _permit = permit;
30513058
if !dial_failures.wait(&cancel).await {
30523059
return;
30533060
}
30543061
match
3055-
tunnel::run_client_connection(local, endpoint_rx, home, peer, alpn, cancel, drop_rx)
3062+
tunnel::run_client_connection(
3063+
local,
3064+
endpoint_rx,
3065+
home,
3066+
peer,
3067+
alpn,
3068+
cancel,
3069+
drop_rx,
3070+
notices,
3071+
)
30563072
.await
30573073
{
30583074
Ok(()) => dial_failures.record_success().await,
@@ -3068,6 +3084,28 @@ async fn run_dial_socket(
30683084
}
30693085
}
30703086

3087+
fn shell_client_notices() -> tunnel::ClientConnectionNotices {
3088+
tunnel::ClientConnectionNotices::new(|event| {
3089+
let encoded = match event {
3090+
tunnel::ClientConnectionEvent::Reconnecting {
3091+
attempt,
3092+
delay,
3093+
error,
3094+
} => shell::encode_server_status(&format!(
3095+
"connection lost ({error}); reconnecting attempt {attempt} in {:.1}s",
3096+
delay.as_secs_f32()
3097+
)),
3098+
tunnel::ClientConnectionEvent::Resumed => {
3099+
shell::encode_server_status("connection restored; remote shell session resumed")
3100+
}
3101+
tunnel::ClientConnectionEvent::Failed { error } => {
3102+
shell::encode_server_error(&format!("remote shell could not resume: {error}"))
3103+
}
3104+
};
3105+
encoded.ok()
3106+
})
3107+
}
3108+
30713109
async fn run_dial_tcp_listener(
30723110
listener: TcpListener,
30733111
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

@@ -1102,48 +1104,83 @@ async fn wait_for_daemon_ready(
11021104

11031105
async fn run_shell_client(socket: &PathBuf) -> Result<i32> {
11041106
let stream = tokio::net::UnixStream::connect(socket).await?;
1105-
let (mut read, mut write) = stream.into_split();
1106-
let _raw_mode = RawModeGuard::enable_if_terminal()?;
1107+
let (mut read, write) = stream.into_split();
1108+
let mut signals = ShellSignals::new()?;
1109+
let terminal = TerminalModeGuard::enable_if_terminal()?;
11071110
let (cols, rows) = terminal_size();
1108-
shell::write_client_resize(&mut write, rows, cols).await?;
1111+
let write = Arc::new(tokio::sync::Mutex::new(write));
1112+
shell::write_client_resize(&mut *write.lock().await, rows, cols).await?;
11091113

1114+
let stdin_write = write.clone();
11101115
let stdin_task = tokio::spawn(async move {
11111116
let mut stdin = tokio::io::stdin();
11121117
let mut buf = [0u8; 8192];
11131118
loop {
11141119
let read = stdin.read(&mut buf).await?;
11151120
if read == 0 {
1116-
shell::write_client_eof(&mut write).await?;
1121+
shell::write_client_eof(&mut *stdin_write.lock().await).await?;
11171122
return Ok::<(), anyhow::Error>(());
11181123
}
1119-
shell::write_client_stdin(&mut write, &buf[..read]).await?;
1124+
shell::write_client_stdin(&mut *stdin_write.lock().await, &buf[..read]).await?;
11201125
}
11211126
});
11221127

11231128
let mut stdout = tokio::io::stdout();
11241129
let mut stderr = tokio::io::stderr();
11251130
let mut exit_code = 1;
11261131

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

11451181
stdin_task.abort();
11461182
let _ = stdin_task.await;
1183+
terminal.restore()?;
11471184
stdout.flush().await?;
11481185
stderr.flush().await?;
11491186
Ok(exit_code)
@@ -1276,10 +1313,6 @@ fn normalize_exit_code(code: i32) -> i32 {
12761313
code.clamp(0, 255)
12771314
}
12781315

1279-
struct RawModeGuard {
1280-
enabled: bool,
1281-
}
1282-
12831316
struct SocketFileGuard(PathBuf);
12841317

12851318
impl Drop for SocketFileGuard {
@@ -1288,23 +1321,91 @@ impl Drop for SocketFileGuard {
12881321
}
12891322
}
12901323

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

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

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

0 commit comments

Comments
 (0)