fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)
Navigating (layout/tab switch) tore the xterm view down and called handle.close(), killing the backend PTY and cutting off running AIs. Now the view's cleanup only detaches; only an explicit user action kills a PTY. Backend: - PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most recent) + re-subscribable fan-out broadcast replacing the single-take output_rx. Reader thread feeds both the ring buffer and current subscribers; on EOF it closes subscribers (streams end) while keeping scrollback for late re-attach. - PtyPort: new scrollback() method; subscribe_output is now re-subscribable (all impls + test fakes updated). - reattach_terminal IPC command: returns scrollback and re-wires a fresh output channel on the live session without re-spawning. - CloseRequested hook kills all live PTYs cleanly on app shutdown. - TerminalSessions::handles() to enumerate live sessions at shutdown. Frontend: - TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks. - TerminalView cleanup detaches (never close); on mount it re-attaches to a persisted session (repainting scrollback) instead of opening a new PTY. - LayoutGrid persists the cell's session id via setSession; AgentsPanel tracks per-agent session ids — both drive reattach-vs-open. Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained; re-subscription delivers post-reattach output; TerminalView detaches (not closes) on unmount and reattaches with a known session; mock detach/reattach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -28,7 +28,7 @@ use crate::dto::{
|
||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto,
|
||||
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto,
|
||||
RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
||||
ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
|
||||
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||
@ -244,6 +244,69 @@ pub async fn close_terminal(
|
||||
result
|
||||
}
|
||||
|
||||
/// `reattach_terminal` — re-bind a view to a **still-living** PTY without
|
||||
/// re-spawning it.
|
||||
///
|
||||
/// Navigation (switching layout/tab) tears the xterm view down but must NOT kill
|
||||
/// the backend PTY (the AI keeps running). When the view comes back it calls this
|
||||
/// command, which:
|
||||
/// 1. reads the session's retained **scrollback** so the terminal can repaint,
|
||||
/// 2. registers the new per-session [`Channel`] in the [`PtyBridge`],
|
||||
/// 3. starts a fresh output pump subscribed to the live PTY (re-subscribable
|
||||
/// broadcast), so new bytes flow to the new channel.
|
||||
///
|
||||
/// Returns the scrollback bytes; the frontend writes them into xterm first, then
|
||||
/// receives subsequent output over `on_output`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND`/`PROCESS`
|
||||
/// if the session is no longer alive).
|
||||
#[tauri::command]
|
||||
pub fn reattach_terminal(
|
||||
session_id: String,
|
||||
on_output: Channel<PtyChunk>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ReattachResultDto, ErrorDto> {
|
||||
let sid = parse_session_id(&session_id)?;
|
||||
let handle = PtyHandle { session_id: sid };
|
||||
|
||||
// (1) Snapshot the scrollback. A NotFound here means the PTY is gone (was
|
||||
// explicitly closed or exited) — surfaced as an error so the caller falls
|
||||
// back to opening a fresh terminal.
|
||||
let scrollback = state
|
||||
.pty_port
|
||||
.scrollback(&handle)
|
||||
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
|
||||
|
||||
// (2) Register the new output channel for this session, replacing any stale
|
||||
// one from a previous attach.
|
||||
state.pty_bridge.register(sid, on_output);
|
||||
|
||||
// (3) Subscribe afresh to the live byte stream and pump it to the channel.
|
||||
match state.pty_port.subscribe_output(&handle) {
|
||||
Ok(stream) => {
|
||||
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
|
||||
std::thread::spawn(move || {
|
||||
for chunk in stream {
|
||||
if !bridge.send_output(&sid, chunk) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bridge.unregister(&sid);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
state.pty_bridge.unregister(&sid);
|
||||
return Err(ErrorDto::from(AppError::from(e)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ReattachResultDto {
|
||||
session_id,
|
||||
scrollback,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout (L4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -245,6 +245,18 @@ impl From<OpenTerminalOutput> for TerminalSessionDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live
|
||||
/// session, repainted into the re-mounting xterm before the new output stream is
|
||||
/// wired. Bytes are serialised as a number array, matching the PTY output channel.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReattachResultDto {
|
||||
/// The session that was re-attached (echoed back for the frontend).
|
||||
pub session_id: String,
|
||||
/// The most-recent retained output bytes (scrollback ring buffer).
|
||||
pub scrollback: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Request DTO for `write_terminal`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@ -48,6 +48,28 @@ pub fn run() {
|
||||
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
|
||||
|
||||
app.manage(app_state);
|
||||
|
||||
// Kill all live PTYs cleanly when the main window is closing. This is
|
||||
// independent of the per-view (navigation/layout) lifecycle — those
|
||||
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
|
||||
// A brutal crash is best-effort and out of scope.
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if let Some(state) = handle.try_state::<AppState>() {
|
||||
let pty = std::sync::Arc::clone(&state.pty_port);
|
||||
let handles = state.terminal_sessions.handles();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
for h in handles {
|
||||
let _ = pty.kill(&h).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@ -60,6 +82,7 @@ pub fn run() {
|
||||
commands::write_terminal,
|
||||
commands::resize_terminal,
|
||||
commands::close_terminal,
|
||||
commands::reattach_terminal,
|
||||
commands::load_layout,
|
||||
commands::mutate_layout,
|
||||
commands::list_layouts,
|
||||
|
||||
Reference in New Issue
Block a user