diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-25 15:36:50 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-25 15:36:50 +0300 |
| commit | d4a5e02e8433db156b9dabe2b81ddd1d2aca9ea6 (patch) | |
| tree | f94ebdfc2bf1e54b3d110052269c67b6ba5f6fb8 /src/persist | |
| parent | a1543f3e248f8f5266322014ccd853e478b1dcd6 (diff) | |
feat: hand off ptys during updates
Diffstat (limited to 'src/persist')
| -rw-r--r-- | src/persist/restore.rs | 370 |
1 files changed, 281 insertions, 89 deletions
diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 03bbf4e3..ef6d39f4 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -22,6 +22,17 @@ use super::{ WorkspaceSnapshot, }; +#[cfg(unix)] +pub struct ImportedPaneRuntime { + pub master_fd: std::os::fd::RawFd, + pub child_pid: u32, + pub rows: u16, + pub cols: u16, + pub cell_width_px: u32, + pub cell_height_px: u32, + pub initial_history_ansi: Option<String>, +} + struct AgentRestoreState<'a> { enabled: bool, resumed_sessions: &'a mut HashSet<String>, @@ -34,6 +45,32 @@ struct PaneRestoreStartup<'a> { reserved_agent_session: Option<String>, } +struct RestoreRuntimeContext<'a> { + scrollback_limit_bytes: usize, + default_shell: &'a str, + resume_agents_on_restore: bool, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, +} + +type RestoredSession = ( + Vec<Workspace>, + HashMap<TerminalId, TerminalState>, + HashMap<TerminalId, TerminalRuntime>, +); +type RestoredWorkspace = ( + Workspace, + Vec<TerminalState>, + HashMap<TerminalId, TerminalRuntime>, +); +type RestoredTab = ( + crate::workspace::Tab, + Vec<TerminalState>, + HashMap<TerminalId, TerminalRuntime>, +); +type RestoreFailures<T> = (T, usize); + /// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd. pub fn restore( snapshot: &SessionSnapshot, @@ -46,29 +83,156 @@ pub fn restore( events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, -) -> ( - Vec<Workspace>, - HashMap<TerminalId, TerminalState>, - HashMap<TerminalId, TerminalRuntime>, -) { +) -> RestoredSession { + let mut imported_panes = HashMap::new(); + restore_with_imports( + snapshot, + history, + rows, + cols, + scrollback_limit_bytes, + default_shell, + resume_agents_on_restore, + &mut imported_panes, + events, + render_notify, + render_dirty, + ) +} + +#[cfg(unix)] +pub fn restore_handoff( + snapshot: &SessionSnapshot, + scrollback_limit_bytes: usize, + default_shell: &str, + imports: &mut HashMap<u32, ImportedPaneRuntime>, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, +) -> std::io::Result<RestoredSession> { + restore_with_imports_strict( + snapshot, + None, + 24, + 80, + scrollback_limit_bytes, + default_shell, + false, + imports, + events, + render_notify, + render_dirty, + ) +} + +#[cfg(unix)] +fn restore_with_imports_strict( + snapshot: &SessionSnapshot, + history: Option<&SessionHistorySnapshot>, + rows: u16, + cols: u16, + scrollback_limit_bytes: usize, + default_shell: &str, + resume_agents_on_restore: bool, + imported_panes: &mut HashMap<u32, ImportedPaneRuntime>, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, +) -> std::io::Result<RestoredSession> { + let (restored, failed_imports) = restore_with_imports_and_failures( + snapshot, + history, + rows, + cols, + scrollback_limit_bytes, + default_shell, + resume_agents_on_restore, + imported_panes, + events, + render_notify, + render_dirty, + ); + if failed_imports > 0 { + return Err(std::io::Error::other(format!( + "handoff failed to restore {failed_imports} imported pane runtime(s)" + ))); + } + if !imported_panes.is_empty() { + return Err(std::io::Error::other(format!( + "handoff import did not consume {} pane runtime(s)", + imported_panes.len() + ))); + } + Ok(restored) +} + +fn restore_with_imports( + snapshot: &SessionSnapshot, + history: Option<&SessionHistorySnapshot>, + rows: u16, + cols: u16, + scrollback_limit_bytes: usize, + default_shell: &str, + resume_agents_on_restore: bool, + imported_panes: &mut HashMap<u32, ImportedPaneRuntime>, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, +) -> RestoredSession { + restore_with_imports_and_failures( + snapshot, + history, + rows, + cols, + scrollback_limit_bytes, + default_shell, + resume_agents_on_restore, + imported_panes, + events, + render_notify, + render_dirty, + ) + .0 +} + +fn restore_with_imports_and_failures( + snapshot: &SessionSnapshot, + history: Option<&SessionHistorySnapshot>, + rows: u16, + cols: u16, + scrollback_limit_bytes: usize, + default_shell: &str, + resume_agents_on_restore: bool, + imported_panes: &mut HashMap<u32, ImportedPaneRuntime>, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, +) -> RestoreFailures<RestoredSession> { let mut workspaces = Vec::new(); let mut terminals = HashMap::new(); let mut terminal_runtimes = HashMap::new(); let mut resumed_agent_sessions = HashSet::new(); + let mut failed_imports = 0; for (idx, ws_snap) in snapshot.workspaces.iter().enumerate() { - if let Some((workspace, restored_terminals, restored_runtimes)) = restore_workspace( + let runtime_context = RestoreRuntimeContext { + scrollback_limit_bytes, + default_shell, + resume_agents_on_restore, + events: events.clone(), + render_notify: render_notify.clone(), + render_dirty: render_dirty.clone(), + }; + let (restored, workspace_failed_imports) = restore_workspace( ws_snap, history.and_then(|history| history.workspaces.get(idx)), rows, cols, - scrollback_limit_bytes, - default_shell, - resume_agents_on_restore, + &runtime_context, &mut resumed_agent_sessions, - events.clone(), - render_notify.clone(), - render_dirty.clone(), - ) { + imported_panes, + ); + failed_imports += workspace_failed_imports; + if let Some((workspace, restored_terminals, restored_runtimes)) = restored { for terminal in restored_terminals { terminals.insert(terminal.id.clone(), terminal); } @@ -76,7 +240,7 @@ pub fn restore( workspaces.push(workspace); } } - (workspaces, terminals, terminal_runtimes) + ((workspaces, terminals, terminal_runtimes), failed_imports) } fn restore_workspace( @@ -84,42 +248,32 @@ fn restore_workspace( history: Option<&WorkspaceHistorySnapshot>, rows: u16, cols: u16, - scrollback_limit_bytes: usize, - default_shell: &str, - resume_agents_on_restore: bool, + runtime_context: &RestoreRuntimeContext<'_>, resumed_agent_sessions: &mut HashSet<String>, - events: mpsc::Sender<AppEvent>, - render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, -) -> Option<( - Workspace, - Vec<TerminalState>, - HashMap<TerminalId, TerminalRuntime>, -)> { + imported_panes: &mut HashMap<u32, ImportedPaneRuntime>, +) -> RestoreFailures<Option<RestoredWorkspace>> { let mut tabs = Vec::new(); let mut terminals = Vec::new(); let mut terminal_runtimes = HashMap::new(); let mut public_pane_numbers = HashMap::new(); let mut next_public_pane_number = 1; - let mut agent_restore = AgentRestoreState { - enabled: resume_agents_on_restore, - resumed_sessions: resumed_agent_sessions, - }; + let mut failed_imports = 0; for (idx, tab_snap) in snap.tabs.iter().enumerate() { - let (tab, restored_terminals, restored_runtimes) = restore_tab( + let (restored_tab, tab_failed_imports) = restore_tab( tab_snap, history.and_then(|history| history.tabs.get(idx)), idx + 1, rows, cols, - scrollback_limit_bytes, - default_shell, - &mut agent_restore, - events.clone(), - render_notify.clone(), - render_dirty.clone(), - )?; + runtime_context, + resumed_agent_sessions, + imported_panes, + ); + failed_imports += tab_failed_imports; + let Some((tab, restored_terminals, restored_runtimes)) = restored_tab else { + continue; + }; for pane_id in tab.layout.pane_ids() { public_pane_numbers.insert(pane_id, next_public_pane_number); next_public_pane_number += 1; @@ -130,13 +284,13 @@ fn restore_workspace( } if tabs.is_empty() { - return None; + return (None, failed_imports); } let worktree_space = restored_worktree_space_membership(snap.worktree_space.clone()); - Some(( - Workspace { + ( + Some(Workspace { id: snap .id .clone() @@ -153,10 +307,10 @@ fn restore_workspace( tabs, #[cfg(test)] test_runtimes: HashMap::new(), - }, - terminals, - terminal_runtimes, - )) + }) + .map(|workspace| (workspace, terminals, terminal_runtimes)), + failed_imports, + ) } fn restored_worktree_space_membership( @@ -175,17 +329,10 @@ fn restore_tab( number: usize, rows: u16, cols: u16, - scrollback_limit_bytes: usize, - default_shell: &str, - agent_restore: &mut AgentRestoreState<'_>, - events: mpsc::Sender<AppEvent>, - render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, -) -> Option<( - crate::workspace::Tab, - Vec<TerminalState>, - HashMap<TerminalId, TerminalRuntime>, -)> { + runtime_context: &RestoreRuntimeContext<'_>, + resumed_agent_sessions: &mut HashSet<String>, + imported_panes: &mut HashMap<u32, ImportedPaneRuntime>, +) -> RestoreFailures<Option<RestoredTab>> { let (node, id_map) = restore_node_remapped(&snap.layout); let reverse_id_map: HashMap<PaneId, u32> = id_map .iter() @@ -196,6 +343,7 @@ fn restore_tab( let mut panes = HashMap::new(); let mut terminals = Vec::new(); let mut terminal_runtimes = HashMap::new(); + let mut failed_imports = 0; for id in &pane_ids { let old_id = reverse_id_map.get(id); let saved_pane = old_id.and_then(|old_id| snap.panes.get(old_id)); @@ -225,13 +373,40 @@ fn restore_tab( let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); let saved_history = old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); - let startup = pane_restore_startup(saved_agent_session, saved_history, agent_restore); + let startup = { + let mut agent_restore = AgentRestoreState { + enabled: runtime_context.resume_agents_on_restore, + resumed_sessions: resumed_agent_sessions, + }; + pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore) + }; let initial_restore_agent = startup .restore_plan .as_ref() .and_then(|plan| crate::detect::parse_agent_label(&plan.agent)); - let runtime_result = if let Some(plan) = startup.restore_plan { + let old_pane_id = reverse_id_map.get(id).copied(); + let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id)); + let was_imported = imported_runtime.is_some(); + let runtime_result = if let Some(imported) = imported_runtime { + TerminalRuntime::from_handoff_fd( + crate::pane::PaneRuntimeImport { + pane_id: *id, + master_fd: imported.master_fd, + child_pid: imported.child_pid, + rows: imported.rows, + cols: imported.cols, + cell_width_px: imported.cell_width_px, + cell_height_px: imported.cell_height_px, + initial_history_ansi: imported.initial_history_ansi, + }, + runtime_context.scrollback_limit_bytes, + crate::terminal_theme::TerminalTheme::default(), + runtime_context.events.clone(), + runtime_context.render_notify.clone(), + runtime_context.render_dirty.clone(), + ) + } else if let Some(plan) = startup.restore_plan { let launch = crate::agent_resume::AgentResumeLaunch { plan: &plan, initial_history_ansi: startup.initial_history_ansi, @@ -242,12 +417,12 @@ fn restore_tab( cols, cwd.clone(), launch, - scrollback_limit_bytes, + runtime_context.scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), - default_shell, - events.clone(), - render_notify.clone(), - render_dirty.clone(), + runtime_context.default_shell, + runtime_context.events.clone(), + runtime_context.render_notify.clone(), + runtime_context.render_dirty.clone(), ) } else { TerminalRuntime::spawn_with_initial_history( @@ -255,13 +430,13 @@ fn restore_tab( rows, cols, cwd.clone(), - scrollback_limit_bytes, + runtime_context.scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), - default_shell, + runtime_context.default_shell, startup.initial_history_ansi, - events.clone(), - render_notify.clone(), - render_dirty.clone(), + runtime_context.events.clone(), + runtime_context.render_notify.clone(), + runtime_context.render_dirty.clone(), ) }; @@ -298,7 +473,16 @@ fn restore_tab( } Err(e) => { if let Some(key) = startup.reserved_agent_session.as_deref() { - agent_restore.resumed_sessions.remove(key); + resumed_agent_sessions.remove(key); + } + if was_imported { + failed_imports += 1; + error!( + tab = ?snap.custom_name, + pane_id = id.raw(), + err = %e, + "failed to restore imported pane" + ); } error!( tab = ?snap.custom_name, @@ -315,7 +499,7 @@ fn restore_tab( tab = ?snap.custom_name, "no panes could be restored for tab, dropping it" ); - return None; + return (None, failed_imports); } let surviving: HashSet<PaneId> = panes.keys().copied().collect(); @@ -324,30 +508,38 @@ fn restore_tab( tab = ?snap.custom_name, "restored tab lost all panes after pruning missing layout nodes" ); - return None; + return (None, failed_imports); }; let pane_ids = collect_pane_ids(&node); - let focus = resolve_restored_pane(snap.focused, &id_map, &surviving, &pane_ids)?; - let root_pane = resolve_restored_pane(snap.root_pane, &id_map, &surviving, &pane_ids)?; + let Some(focus) = resolve_restored_pane(snap.focused, &id_map, &surviving, &pane_ids) else { + return (None, failed_imports); + }; + let Some(root_pane) = resolve_restored_pane(snap.root_pane, &id_map, &surviving, &pane_ids) + else { + return (None, failed_imports); + }; let layout = TileLayout::from_saved(node, focus); - Some(( - crate::workspace::Tab { - custom_name: snap.custom_name.clone(), - number, - root_pane, - layout, - panes, - #[cfg(test)] - runtimes: HashMap::new(), - zoomed: snap.zoomed, - events, - render_notify, - render_dirty, - }, - terminals, - terminal_runtimes, - )) + ( + Some(( + crate::workspace::Tab { + custom_name: snap.custom_name.clone(), + number, + root_pane, + layout, + panes, + #[cfg(test)] + runtimes: HashMap::new(), + zoomed: snap.zoomed, + events: runtime_context.events.clone(), + render_notify: runtime_context.render_notify.clone(), + render_dirty: runtime_context.render_dirty.clone(), + }, + terminals, + terminal_runtimes, + )), + failed_imports, + ) } fn pane_restore_startup<'a>( |
