diff --git a/architecture/notes/nebula_app/ssh_session/2026-09-26-pane-local-forwarding.md b/architecture/notes/nebula_app/ssh_session/2026-09-26-pane-local-forwarding.md new file mode 100644 index 00000000..dc82e3f3 --- /dev/null +++ b/architecture/notes/nebula_app/ssh_session/2026-09-26-pane-local-forwarding.md @@ -0,0 +1,78 @@ +# Pane-owned local SSH forwarding + +## Status + +Reviewed for integration on 2026-09-26. + +## Context + +An authenticated native SSH pane needs a small local forwarding control. Opening +another SSH process would duplicate authentication and connection ownership. +A listener must not survive its pane, including normal shell exit, and a pending +creation must not finish after the owner has closed. + +## Evidence + +- `ssh_session/forward.rs` reuses authenticated pooled sessions and opens + direct-tcpip channels through the existing SSH runtime. +- The original change cleared established forwards on failure but not normal + exit; its detached creation task outlived the pane. +- An unrestricted accept loop could create arbitrary numbers of channel tasks + and bidirectional copy buffers from local clients. +- The legacy dialog child scroller clipped the port controls: the real-click + regression left the port value unchanged after typing. The form now uses + `DialogContent`, with explicit input geometry and read-back assertions. +- The original screenshot-only readiness override read the process environment + from a render-time query and bypassed real SSH readiness in test builds. + +## Decision + +Keep listeners in the terminal view and the pending GPUI operation in an owned +Task. Exit, connection failure and view destruction release both. The existing +network-to-GPUI bridge cancels its future when the result receiver is dropped; +a stale completion cannot install a forward or show a toast for a dead pane. + +Each listener accepts at most 64 concurrent channel tasks. Excess clients wait +in the OS TCP backlog until a slot is available. This bounds task/copy-buffer +cost per listener; it is an engineering limit, not a throughput guarantee. +Completed channels are reaped and failures are logged without killing the SSH +transport shared by the terminal and other forwards. + +The endpoints remain loopback-only on both sides. Creating and stopping a +forward requires explicit user action. New labels use typed catalog messages. +Readiness uses actual session state; screenshots cannot enable SSH on local panes. + +## Rejected alternatives + +- A second SSH process or global forwarding service would duplicate lifetime + and authentication policy. +- Detached creation cannot guarantee cancellation when the pane disappears. +- Unbounded per-client tasks allow local load to grow memory without a limit. +- Persisted rules, automatic discovery and remote/dynamic forwarding are outside + this local forwarding capability. + +## Consequences + +The new behavior adds no persistent thread or timer. UI rendering does not read +files, resolve destinations or inspect environment variables. File/profile +resolution and network I/O remain in the existing background runtime. A forward +owns its channels but never disconnects the shared authenticated transport. + +## Validation + +Regression coverage exercises real SSH channel exchange, half-close, occupied +ports, channel rejection, listener/connection disposal, and bounded concurrency. +UI regressions click the actual port fields and confirmation/cancel controls, +check invalid input, and reject submission after the pane stops being ready. +Terminal and bridge regressions cover normal exit, failure and pending-work +cancellation. These tests do not claim arbitrary-host throughput or physical +platform visual acceptance. + +## Supersedes + +None. + +## Revisit when + +Revisit the limit and UI state model if measured workloads need more concurrent +channels, or when persistent/reconnecting forwards are explicitly introduced. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 4b9a99d3..b47d9453 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -1000,5 +1000,16 @@ "empty": "This directory is empty.", "error_retry": "{error} (click Reload in the top-right to retry)", "busy_other": "Another host is transferring: {destination}" - } + }, + "ssh.ports.button": "Ports", + "ssh.ports.title": "SSH port forwarding", + "ssh.ports.empty": "No forwarded ports", + "ssh.ports.stop": "Stop forwarding", + "ssh.ports.remote": "Remote port", + "ssh.ports.local": "Local port", + "ssh.ports.forward": "Forward", + "ssh.ports.invalid": "Enter ports from 1–65535", + "ssh.ports.pending": "Starting port forwarding…", + "ssh.ports.unavailable": "SSH network runtime unavailable", + "ssh.ports.started": "Forwarding 127.0.0.1:{local} to {host} port {remote}" } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index a78fd32e..4a112b61 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -1000,5 +1000,16 @@ "empty": "此目录为空。", "error_retry": "{error}(点右上角重新读取)", "busy_other": "另一主机正在传输:{destination}" - } + }, + "ssh.ports.button": "端口", + "ssh.ports.title": "SSH 端口转发", + "ssh.ports.empty": "暂无端口转发", + "ssh.ports.stop": "停止转发", + "ssh.ports.remote": "服务器端口", + "ssh.ports.local": "本地端口", + "ssh.ports.forward": "转发", + "ssh.ports.invalid": "请输入 1–65535 的端口", + "ssh.ports.pending": "正在启动端口转发…", + "ssh.ports.unavailable": "SSH 网络运行时不可用", + "ssh.ports.started": "正在将 127.0.0.1:{local} 转发至 {host} 的 {remote} 端口" } diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index eddb5c6f..24ff8df0 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -336,6 +336,9 @@ pub struct TerminalView { path_drop: path_drop::PathDropState, /// SSH 直连目的地(`user@host[:port]`);本地会话为 None。 pub ssh_destination: Option, + /// 本 pane 拥有的本地端口转发;pane 销毁即停止监听。 + pub(crate) port_forwards: Vec, + pub(crate) port_forward_task: Option>, ssh_label: Option, /// 创建本地 PTY 时冻结的受控环境,供独立 `pane.exec` child 复用。 pub(crate) exec_context: Option, @@ -706,6 +709,8 @@ impl TerminalView { /// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。 fn mark_exited(&mut self, message: String, cx: &mut Context) { + self.port_forward_task = None; + self.port_forwards.clear(); self.confirmation.invalidate(); self.pending_runtime_submit = None; self.pending_shell_command = None; @@ -971,6 +976,9 @@ impl TerminalView { /// 通道,撞上一个还没建立的传输——用户看到的是文件面板先报一个错,然后 /// 终端才连上。 pub fn ready_ssh_destination(&self) -> Option<&str> { + if self.exited.is_some() { + return None; + } let destination = self.ssh_destination.as_deref()?; matches!(self.ssh_stage, Some(crate::ssh_session::SshStage::Ready)).then_some(destination) } diff --git a/nebula_app/src/gpui_shell/terminal/view/output_tests.rs b/nebula_app/src/gpui_shell/terminal/view/output_tests.rs index 595bf150..c6ba8cc2 100644 --- a/nebula_app/src/gpui_shell/terminal/view/output_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/output_tests.rs @@ -177,3 +177,28 @@ fn hidden_wakeup_still_flushes_shell_commands_and_pending_enter(cx: &mut TestApp assert!(view.pending_runtime_submit.is_none()); }); } + +#[gpui::test] +fn ssh_exit_and_failure_cancel_pending_forward_and_hide_remote_actions(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + for failed in [false, true] { + view.update(window, |view, cx| { + view.exited = None; + view.ssh_destination = Some("fixture@localhost".into()); + view.apply_ssh_stage(crate::ssh_session::SshStage::Ready, cx); + assert_eq!(view.ready_ssh_destination(), Some("fixture@localhost")); + view.port_forward_task = + Some(cx.spawn(async |_, _| std::future::pending::<()>().await)); + if failed { + view.apply_ssh_stage( + crate::ssh_session::SshStage::Failed("disconnected".into()), + cx, + ); + } else { + view.process_event(TermEvent::Exit, cx); + } + assert!(view.port_forward_task.is_none()); + assert!(view.ready_ssh_destination().is_none()); + }); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index 19402253..ca32c59b 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -111,6 +111,8 @@ impl TerminalView { ); self.ssh_connect_last_step = std::time::Instant::now(); if matches!(stage, crate::ssh_session::SshStage::Failed(_)) { + self.port_forward_task = None; + self.port_forwards.clear(); self.pending_runtime_submit = None; self.pending_shell_command = None; self.command_running = false; diff --git a/nebula_app/src/gpui_shell/terminal/view/startup.rs b/nebula_app/src/gpui_shell/terminal/view/startup.rs index 58167db2..f21b497a 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup.rs @@ -284,6 +284,8 @@ impl TerminalView { image_paste: image_paste::ImagePasteState::default(), path_drop: path_drop::PathDropState::default(), ssh_destination, + port_forwards: Vec::new(), + port_forward_task: None, ssh_label: None, exec_context, ssh_stage: None, diff --git a/nebula_app/src/gpui_shell/workspace/remote_files.rs b/nebula_app/src/gpui_shell/workspace/remote_files.rs index 0b412b15..1fca94ec 100644 --- a/nebula_app/src/gpui_shell/workspace/remote_files.rs +++ b/nebula_app/src/gpui_shell/workspace/remote_files.rs @@ -920,16 +920,20 @@ fn format_transfer_bytes(bytes: u64) -> String { /// /// 返回 `None` 表示网络 runtime 起不来或任务被丢弃——调用方据此报"连接不 /// 可用",而不是把它和"远端答了个错误"混为一谈。 -async fn remote_call(work: F) -> Option +pub(super) async fn remote_call(work: F) -> Option where T: Send + 'static, F: FnOnce() -> Fut + Send + 'static, Fut: std::future::Future + Send, { let runtime = crate::ssh_session::runtime().ok()?; - let (tx, rx) = tokio::sync::oneshot::channel(); + let (mut tx, rx) = tokio::sync::oneshot::channel(); runtime.spawn(async move { - let _ = tx.send(work().await); + tokio::select! { + biased; + _ = tx.closed() => {}, + result = work() => { let _ = tx.send(result); }, + } }); rx.await.ok() } @@ -950,6 +954,37 @@ mod tests { } } + #[test] + fn dropped_remote_call_cancels_its_network_work() { + struct SignalDrop(Option>); + impl Drop for SignalDrop { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + } + crate::ssh_session::runtime().unwrap().block_on(async { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel(); + let caller = tokio::spawn(super::remote_call(move || async move { + let _signal = SignalDrop(Some(dropped_tx)); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + })); + tokio::time::timeout(std::time::Duration::from_secs(5), started_rx) + .await + .unwrap() + .unwrap(); + caller.abort(); + let _ = caller.await; + tokio::time::timeout(std::time::Duration::from_secs(5), dropped_rx) + .await + .unwrap() + .unwrap(); + }); + } + #[test] fn switching_remote_panes_restores_listing_selection_and_scroll_owner_without_reload() { let mut browser = RemoteBrowser::default(); diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index 8d6074a4..b9d98576 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -868,6 +868,7 @@ impl NebulaWorkspace { let settings_active_fg = cx.theme().sidebar_accent_foreground; let sidebar_visible = !self.sidebar_collapsed && !self.reader_focus_active(cx); let language = crate::gpui_shell::config::ui_language(cx); + let port_forward_button = self.render_port_forward_button(cx); h_flex() .size_full() .items_center() @@ -932,6 +933,7 @@ impl NebulaWorkspace { this.toggle_command_manager(window, cx); })), ) + .when_some(port_forward_button, |controls, button| controls.child(button)) .child(self.render_right_sidebar_button(settings_active, cx)), ) .into_any_element() diff --git a/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs b/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs index fa89fd15..163c6c1d 100644 --- a/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs +++ b/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs @@ -2,11 +2,18 @@ use std::cell::Cell; use std::rc::Rc; use std::sync::Arc; -use gpui::{App, AppContext as _, ParentElement as _, Styled as _, Window, div}; +use gpui::{ + App, AppContext as _, Context, Entity, InteractiveElement as _, ParentElement as _, + Styled as _, Window, div, +}; use crate::gpui_shell::prelude::*; +use crate::gpui_shell::terminal::view::TerminalView; +use crate::i18n::Message; use crate::ssh_prompt::{Prompt, PromptKind, PromptResponse}; +use super::{NebulaWorkspace, workspace_ui_language}; + pub(super) fn show(request: Arc, window: &mut Window, cx: &mut App) { if !request.is_pending() { return; @@ -176,4 +183,359 @@ mod tests { _ => panic!("confirm must deliver the entered password"), } } + #[gpui::test] + fn ports_dialog_rejects_invalid_input_and_cancel_releases_focus(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_reduce_motion(true); + }); + let (_, cx) = + cx.add_window_view(|window, cx| Root::new(cx.new(|_| DialogProbe), window, cx)); + let owner = cx.update(|window, cx| { + cx.new(|cx| { + TerminalView::new( + 42, + (80, 24), + crate::gpui_shell::terminal::view::TerminalLaunch::Local { + cwd: None, + shell: Some(nebula_terminal::tty::Shell::new( + "pebrel-test-missing-shell-executable".into(), + vec![], + )), + shell_name: None, + }, + window, + cx, + ) + }) + }); + let inputs = cx.update(|window, cx| { + show_port_forward_dialog(owner.clone(), "fixture@localhost".into(), window, cx) + }); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); + cx.simulate_input("65536"); + assert_eq!(inputs[0].read_with(cx, |input, _| input.value()).as_ref(), "65536"); + click(cx, "confirm-dialog-ok"); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); + assert!( + cx.debug_bounds("confirm-dialog-ok").is_some(), + "invalid input keeps the dialog open" + ); + owner.read_with(cx, |view, _| { + assert!(view.port_forward_task.is_none()); + assert!(view.port_forwards.is_empty()); + }); + let select_all = match crate::platform::Platform::current() { + crate::platform::Platform::MacOS => "cmd-a", + _ => "ctrl-a", + }; + for (index, selector) in ["ssh-forward-remote", "ssh-forward-local"].into_iter().enumerate() + { + let bounds = cx.debug_bounds(selector).unwrap(); + assert!(f32::from(bounds.size.height) >= 28.0); + assert!(f32::from(bounds.size.width) >= 100.0); + click(cx, selector); + cx.simulate_keystrokes(select_all); + cx.simulate_input("3000"); + assert_eq!( + inputs[index].read_with(cx, |input, _| input.value()).as_ref(), + "3000", + "clicking {selector} must focus and edit that field" + ); + } + click(cx, "confirm-dialog-ok"); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); + assert!( + cx.debug_bounds("confirm-dialog-ok").is_none(), + "valid ports close the dialog when its pane is no longer ready" + ); + owner.read_with(cx, |view, _| assert!(view.port_forward_task.is_none())); + cx.update(|window, cx| { + show_port_forward_dialog(owner.clone(), "fixture@localhost".into(), window, cx) + }); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); + click(cx, "confirm-dialog-cancel"); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); + assert!(cx.debug_bounds("confirm-dialog-ok").is_none()); + } +} + +impl NebulaWorkspace { + fn active_ready_ssh_view(&self, cx: &App) -> Option> { + if self.settings_open { + return None; + } + let view = self.tabs.get(self.active)?.focused_view()?.clone(); + view.read(cx).ready_ssh_destination()?; + Some(view) + } + + pub(super) fn render_port_forward_button(&self, cx: &mut Context) -> Option