Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions src-tauri/src/ssh/channel_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,21 @@ pub struct ChannelIo {
pub shutdown_tx: mpsc::Sender<()>,
}

/// The far side reported how the remote command ended before closing the
/// channel. Only a command that ran to completion — the user typing `exit`, a
/// one-shot command finishing, a signal killing it — gets an exit-status or
/// exit-signal; a dropped link closes the channel with neither. The frontend
/// reconnects on a drop and must not on a deliberate exit (#180).
fn is_remote_exit(msg: &ChannelMsg) -> bool {
matches!(
msg,
ChannelMsg::ExitStatus { .. } | ChannelMsg::ExitSignal { .. }
)
}

/// Spawn the I/O loop for an opened channel: forward input and resizes, emit the
/// channel's output as `ssh-output-<session_id>`, and emit `ssh-closed-<id>` when
/// the far side ends it.
/// the far side ends it, carrying whether the remote command exited on its own.
pub fn spawn_channel_io(
app: AppHandle,
session_id: &str,
Expand All @@ -42,6 +54,7 @@ pub fn spawn_channel_io_split(
let mut writer = write_half.make_writer();

tokio::spawn(async move {
let mut remote_exit = false;
loop {
tokio::select! {
_ = shutdown_rx.recv() => break,
Expand All @@ -62,9 +75,12 @@ pub fn spawn_channel_io_split(
let _ = app.emit(&event_name, data.as_ref());
}
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
let _ = app.emit(&close_event, ());
let _ = app.emit(&close_event, remote_exit);
break;
}
// Sent just before Eof/Close, so the flag is set by the
// time the close event goes out.
Some(ref m) if is_remote_exit(m) => remote_exit = true,
_ => {}
}
}
Expand Down Expand Up @@ -125,3 +141,26 @@ pub async fn open_exec_session(

Ok(new_session_id)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn only_a_reported_exit_counts_as_a_remote_exit() {
// A shell the user quit, or a command that finished, is reported.
assert!(is_remote_exit(&ChannelMsg::ExitStatus { exit_status: 0 }));
assert!(is_remote_exit(&ChannelMsg::ExitSignal {
signal_name: russh::Sig::TERM,
core_dumped: false,
error_message: String::new(),
lang_tag: String::new(),
}));
// A dropped link only ever produces these, and must stay reconnectable.
assert!(!is_remote_exit(&ChannelMsg::Eof));
assert!(!is_remote_exit(&ChannelMsg::Close));
assert!(!is_remote_exit(&ChannelMsg::WindowAdjusted {
new_size: 4096
}));
}
}
12 changes: 2 additions & 10 deletions src/components/layout/MainPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/sessionStore";
import { reconnectWithBackoff } from "@/stores/reconnectBackoff";
import { handleSessionClosed } from "@/stores/reconnectBackoffCore";
import { sessionClosed } from "@/stores/reconnectBackoff";
import { useUIStore } from "@/stores/uiStore";
import { useVaultStore } from "@/stores/vaultStore";
import { useTeamStore } from "@/stores/teamStore";
Expand Down Expand Up @@ -170,7 +169,6 @@ function useSelectedTeamId(): string | null {

export default function MainPanel() {
const { sessions, activeSessionId } = useSessionStore();
const markDisconnected = useSessionStore((s) => s.markDisconnected);
const reconnect = useSessionStore((s) => s.reconnect);
const reconnectWithPassphrase = useSessionStore((s) => s.reconnectWithPassphrase);
const retryConnect = useSessionStore((s) => s.retryConnect);
Expand Down Expand Up @@ -287,13 +285,7 @@ export default function MainPanel() {
<HostAwareTerminalView
session={session}
active={session.id === activeSessionId && session.status === "connected" && !overlayContent}
onClosed={() =>
handleSessionClosed(session.type, session.id, {
status: (id) => useSessionStore.getState().sessions.find((s) => s.id === id)?.status,
markDisconnected,
reconnectWithBackoff,
})
}
onClosed={(remoteExit) => sessionClosed(session.type, session.id, remoteExit)}
/>
)}
{session.id === activeSessionId && !overlayContent && (
Expand Down
12 changes: 2 additions & 10 deletions src/components/mobile/MobileSessionView.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { useSessionStore } from "@/stores/sessionStore";
import { reconnectWithBackoff } from "@/stores/reconnectBackoff";
import { handleSessionClosed } from "@/stores/reconnectBackoffCore";
import { sessionClosed } from "@/stores/reconnectBackoff";
import { HostAwareTerminalView, SessionConnectionOverlay } from "@/components/terminal/SessionView";
import MobileTerminalGestures from "./MobileTerminalGestures";
import type { TerminalSession } from "@/types";

/** Mobile-only wrapper: renders the shared terminal compact inside a hard-clipped box. */
export default function MobileSessionView({ session, active }: { session: TerminalSession; active: boolean }) {
const markDisconnected = useSessionStore((s) => s.markDisconnected);
const reconnect = useSessionStore((s) => s.reconnect);
const reconnectWithPassphrase = useSessionStore((s) => s.reconnectWithPassphrase);
const retryConnect = useSessionStore((s) => s.retryConnect);
Expand All @@ -28,13 +26,7 @@ export default function MobileSessionView({ session, active }: { session: Termin
session={session}
active={active}
compact
onClosed={() =>
handleSessionClosed(session.type, session.id, {
status: (id) => useSessionStore.getState().sessions.find((s) => s.id === id)?.status,
markDisconnected,
reconnectWithBackoff,
})
}
onClosed={(remoteExit) => sessionClosed(session.type, session.id, remoteExit)}
/>
{session.status === "connected" && <MobileTerminalGestures sessionId={session.id} active={active} />}
</div>
Expand Down
12 changes: 2 additions & 10 deletions src/components/panes/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import MultiplayerTerminalView from "@/components/terminal/MultiplayerTerminalVi
import { MultiplayerBar } from "@/components/terminal/MultiplayerBar";
import { HostAwareTerminalView, SessionConnectionOverlay } from "@/components/terminal/SessionView";
import { useSessionStore } from "@/stores/sessionStore";
import { reconnectWithBackoff } from "@/stores/reconnectBackoff";
import { handleSessionClosed } from "@/stores/reconnectBackoffCore";
import { sessionClosed } from "@/stores/reconnectBackoff";
import type { TerminalSession } from "@/types";

export function PaneTerminal({ session, active }: { session: TerminalSession; active: boolean }) {
const markDisconnected = useSessionStore((s) => s.markDisconnected);
const reconnect = useSessionStore((s) => s.reconnect);
const removeSession = useSessionStore((s) => s.removeSession);
const reconnectWithPassphrase = useSessionStore((s) => s.reconnectWithPassphrase);
Expand Down Expand Up @@ -39,13 +37,7 @@ export function PaneTerminal({ session, active }: { session: TerminalSession; ac
session={session}
active={active && session.status === "connected"}
statusBar={false}
onClosed={() =>
handleSessionClosed(session.type, session.id, {
status: (id) => useSessionStore.getState().sessions.find((s) => s.id === id)?.status,
markDisconnected,
reconnectWithBackoff,
})
}
onClosed={(remoteExit) => sessionClosed(session.type, session.id, remoteExit)}
/>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/terminal/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function HostAwareTerminalView({
}: {
session: TerminalSession;
active: boolean;
onClosed: () => void;
onClosed: (remoteExit: boolean) => void;
/** Mobile: render the terminal compact (no minimap) and suppress the status-bar footer. */
compact?: boolean;
/** Split panes carry no status bar of their own. */
Expand Down
2 changes: 1 addition & 1 deletion src/components/terminal/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import "@xterm/xterm/css/xterm.css";
interface Props {
sessionId: string;
sessionType: "ssh" | "local" | "serial";
onClosed?: () => void;
onClosed?: (remoteExit: boolean) => void;
active?: boolean;
inputGate?: React.RefObject<() => boolean>;
encoding?: string;
Expand Down
12 changes: 6 additions & 6 deletions src/hooks/useTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { getPlatform } from "@/utils/platform";
interface UseTerminalOptions {
sessionId: string;
sessionType: "ssh" | "local" | "serial";
onClosed?: () => void;
onClosed?: (remoteExit: boolean) => void;
/** If provided, input is only sent to the process when this returns true. */
inputGate?: React.RefObject<() => boolean>;
encoding?: string;
Expand Down Expand Up @@ -169,7 +169,7 @@ type CacheEntry = {
/** Mirror of the useTerminal `inputGate` so module-level senders (writeToSession)
* honor the same multiplayer control-holder gate as the onData handler. */
inputGateRef: { current: (() => boolean) | undefined };
onClosedRef: { current: (() => void) | undefined };
onClosedRef: { current: ((remoteExit: boolean) => void) | undefined };
onResizeRef: { current: ((cols: number, rows: number) => void) | undefined };
dispose: () => void; // full teardown, called only when the session is deleted
};
Expand Down Expand Up @@ -1071,7 +1071,7 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod
onLocalOutput(sessionId, (data) => { term.write(decoder ? decoder.decode(data) : data, () => scheduleMinimapNotify(entry)); }),
onLocalClosed(sessionId, () => {
term.write("\r\n\x1b[90m--- Session closed ---\x1b[0m\r\n");
entry.onClosedRef.current?.();
entry.onClosedRef.current?.(false);
}),
];
unlistenPromises.push(...localListeners);
Expand All @@ -1089,7 +1089,7 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod
unlistenPromises.push(
onSerialClosed(sessionId, () => {
term.write("\r\n\x1b[90m--- Serial connection closed ---\x1b[0m\r\n");
entry.onClosedRef.current?.();
entry.onClosedRef.current?.(false);
}),
);
} else {
Expand All @@ -1100,8 +1100,8 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod
}),
);
unlistenPromises.push(
onSshClosed(sessionId, () => {
entry.onClosedRef.current?.();
onSshClosed(sessionId, (remoteExit) => {
entry.onClosedRef.current?.(remoteExit);
}),
);
// Persistent sessions (tmux/screen) hide the shell's OSC 7 from the
Expand Down
8 changes: 5 additions & 3 deletions src/services/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,14 @@ export async function onSshOutput(
});
}

/** `remoteExit` is true when the far side sent an exit-status/exit-signal
* before closing — the remote command ended on its own, it was not a drop. */
export async function onSshClosed(
sessionId: string,
callback: () => void,
callback: (remoteExit: boolean) => void,
): Promise<UnlistenFn> {
return listen(`ssh-closed-${sessionId}`, () => {
callback();
return listen<boolean>(`ssh-closed-${sessionId}`, (event) => {
callback(event.payload === true);
});
}

Expand Down
23 changes: 22 additions & 1 deletion src/stores/reconnectBackoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,12 @@ globalThis.setTimeout = realSetTimeout;
// --- handleSessionClosed: start reconnect only on an unexpected close ---
(() => {
const calls: string[] = [];
const deps = (status: SessionStatus) => ({
const deps = (status: SessionStatus, persist = false) => ({
status: () => status,
persist: () => persist,
markDisconnected: () => calls.push("disconnect"),
reconnectWithBackoff: () => calls.push("backoff"),
endSession: () => calls.push("end"),
});

calls.length = 0;
Expand All @@ -185,5 +187,24 @@ globalThis.setTimeout = realSetTimeout;
calls.length = 0;
handleSessionClosed("local", "s1", deps("connected"));
assertEqual(calls, ["disconnect"], "local close marks disconnected without reconnecting");

// The remote shell exited on purpose (`exit`): the channel carried an
// exit-status, so this is not a drop and reconnecting would resurrect a
// session the user just closed (#180).
calls.length = 0;
handleSessionClosed("ssh", "s1", deps("connected"), true);
assertEqual(calls, ["end"], "a clean remote exit ends the session instead of reconnecting");

// Persistent sessions run inside tmux/screen: the wrapper exiting can also
// mean a detach, so the attach probe stays the judge of whether it ended.
calls.length = 0;
handleSessionClosed("ssh", "s1", deps("connected", true), true);
assertEqual(calls, ["backoff"], "a persistent session still reconnects on a clean wrapper exit");

// A dropped link carries no exit-status.
calls.length = 0;
handleSessionClosed("ssh", "s1", deps("connected"), false);
assertEqual(calls, ["backoff"], "a drop with no exit-status still reconnects");
})();

});
22 changes: 21 additions & 1 deletion src/stores/reconnectBackoff.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useSessionStore } from "./sessionStore";
import { type BackoffStore, runBackoff } from "./reconnectBackoffCore";
import { type BackoffStore, handleSessionClosed, runBackoff } from "./reconnectBackoffCore";

const liveStore: BackoffStore = {
status: (id) => useSessionStore.getState().sessions.find((s) => s.id === id)?.status,
Expand All @@ -22,3 +22,23 @@ export function reconnectWithBackoff(sessionId: string): Promise<boolean> {
}
return runBackoff(sessionId, liveStore);
}

/** `handleSessionClosed` bound to the live stores — every terminal view routes
* its channel-closed event through this. */
export function sessionClosed(sessionType: string, sessionId: string, remoteExit: boolean): void {
handleSessionClosed(
sessionType,
sessionId,
{
status: (id) => useSessionStore.getState().sessions.find((s) => s.id === id)?.status,
persist: (id) => !!useSessionStore.getState().sessions.find((s) => s.id === id)?.persist,
markDisconnected: (id) => useSessionStore.getState().markDisconnected(id),
reconnectWithBackoff,
endSession: (id) => {
// The shell is already gone; this drops the transport and the tab.
void import("@/services/closeSession").then(({ closeSession }) => closeSession(id));
},
},
remoteExit,
);
}
13 changes: 13 additions & 0 deletions src/stores/reconnectBackoffCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,33 @@ export async function runBackoff(sessionId: string, store: BackoffStore): Promis
* so the steady overlay never flickers and no second loop spawns. The loop owns
* the 'reconnecting' (connecting) state, so we don't set it here.
*
* `remoteExit` means the far side sent an exit-status/exit-signal before the
* close: the shell ended on purpose (the user typed `exit`), not a dropped
* link, so the session is over and reconnecting would resurrect it (#180).
* Persistent sessions are excluded: their wrapper also exits on a tmux/screen
* detach, so there the attach probe (SESSION_ENDED) stays the judge.
*
* local: just mark disconnected (no reconnect). */
export function handleSessionClosed(
sessionType: string,
sessionId: string,
deps: {
status: (id: string) => SessionStatus;
persist: (id: string) => boolean;
markDisconnected: (id: string) => void;
reconnectWithBackoff: (id: string) => void;
endSession: (id: string) => void;
},
remoteExit = false,
): void {
if (sessionType !== "ssh" && sessionType !== "serial") {
deps.markDisconnected(sessionId);
return;
}
if (deps.status(sessionId) !== "connected") return;
if (remoteExit && !deps.persist(sessionId)) {
deps.endSession(sessionId);
return;
}
deps.reconnectWithBackoff(sessionId);
}