fix(ui): keep blocking host work off the UI thread and off gpui's pool

Five findings from review, all about where blocking work runs and what
a stale handle is still pointing at.

- `live_pane_count` ran a routed `List` — an SSH handshake, and on a WSL
  route as far as installing the server — straight from the Stop/Delete
  action handler. That is `guard_off_ui`'s debug abort in a dev build
  and a frozen window in a release one. It is now split into a UI-thread
  read and a background count, with the prompt raised through the window
  handle afterwards.
- `teardown_workspace_forwards` blocked the UI thread on a daemon reply
  that waits for the SSH server to acknowledge `cancel_tcpip_forward`.
  On a machine that has gone unreachable — exactly when someone reaches
  for Stop Workspace — it never came. Backgrounded, and `on_workspace`
  now sets a read timeout so the thread is not parked forever either.
- The file tree's and editor's watch subscriptions had no record of
  which host opened them. A reconnect inserts a fresh `RemoteHost` under
  the same `HostId`, so `set_dirs` failed on a dead `ControlClient`,
  was warned and dropped, and nothing opened a new one: after the first
  reconnect the tree stopped seeing remote changes for the life of the
  window, and the editor's external-change detection — what stops a save
  clobbering someone else's edit — was silently off. Both now compare
  the host by pointer and reopen when it differs.
- Closing a remote window that was empty *because its machine could not
  be reached* deleted the workspace: its `RemoteRef`, cached layout and
  geometry, while its panes were still running over there. Only a
  machine that answered licenses dropping the entry.
- `HostOps` ran blocking calls on gpui's background executor, which on
  Linux is a fixed pool with no blocking tier. Four stalled host calls
  on a four-core client took every worker, including the one the
  reconnect needed to clear the stall. They now run on their own elastic
  pool.
This commit is contained in:
l0ng-ai
2026-07-28 22:14:48 +08:00
parent 4486ee6849
commit 54cf9f2a8f
6 changed files with 340 additions and 38 deletions
+32 -1
View File
@@ -41,7 +41,7 @@ use gpui_component::{
};
use crate::ui::app::Tty7App;
use crate::ui::host_ops::{HostOps, MTime, WatchSub};
use crate::ui::host_ops::{HostOps, MTime, SharedHost, WatchSub};
/// Refuse to open files larger than this: the component's code editor is rated
/// to ~50K lines, and a multi-megabyte blob is almost never what a terminal
@@ -165,6 +165,15 @@ pub(crate) struct EditorPanelState {
/// and a server-side watcher recreated every time a file is opened or
/// closed. `Arc` because `set_dirs` is itself a host call.
watch: Option<Arc<WatchSub>>,
/// The host `watch` was opened against, kept so a subscription is never
/// reused across a different one.
///
/// A `HostId` is not enough to tell them apart: reconnecting removes the
/// dead `RemoteHost` and inserts a fresh one under the *same* id, so the id
/// matches while the `ControlClient` behind the old subscription is gone.
/// Compared by pointer, which distinguishes both that and an outright
/// switch to another machine.
watch_host: Option<SharedHost>,
/// A subscription is being opened; keeps a burst of opens from asking for
/// one each.
watch_opening: bool,
@@ -217,6 +226,7 @@ impl EditorPanelState {
.detach();
Self {
watch: None,
watch_host: None,
watch_opening: false,
watch_busy: false,
watch_dirty: false,
@@ -434,6 +444,25 @@ impl Tty7App {
return;
};
// Same rule as the file tree's: a subscription belongs to the host that
// opened it. A reconnect inserts a fresh `RemoteHost` under the same
// `HostId`, so the id matches while the `ControlClient` behind this
// subscription is gone — `set_dirs` then fails, is warned and dropped,
// and nothing opens a new one. The cost here is quieter and worse than
// a stale tree: external-change detection is what stops a save
// clobbering an edit made on the other side.
if !self
.editor
.watch_host
.as_ref()
.is_some_and(|opened_with| Arc::ptr_eq(opened_with, &host))
{
self.editor.watch = None;
self.editor.watch_host = None;
self.editor.watch_busy = false;
self.editor.watch_dirty = false;
}
if let Some(sub) = self.editor.watch.clone() {
if self.editor.watch_busy {
self.editor.watch_dirty = true;
@@ -462,6 +491,7 @@ impl Tty7App {
return;
}
self.editor.watch_opening = true;
let opened_host = Arc::clone(&host);
let opened_with = self.editor.watched_dirs.clone();
HostOps::run(
host,
@@ -481,6 +511,7 @@ impl Tty7App {
};
let events = sub.events().clone();
app.editor.watch = Some(sub);
app.editor.watch_host = Some(opened_host);
cx.spawn(async move |app, cx| {
while let Ok(batch) = events.recv().await {
let ok = app.update(cx, |app, _cx| {