From f91707e2a76b3b0ef445ea4e561272ecfc403bbf Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 19 Jul 2026 20:10:13 +0800 Subject: [PATCH] fix(wsl): don't block the spawn path probing the distro's shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setup_wsl` resolved the distro's login shell with a synchronous `wsl.exe` call. The client waits for the daemon's `Spawn` reply (`terminal::remote::spawn`), so on a cold WSL start — seconds, while the distro boots — the entire window froze. Reported from a real session; the `--cd`/`-d` unit tests never saw it because they never reach the probe, and the live-PTY test only ever ran against an already-warm distro. Caching per distro was not a fix: the first WSL pane after launch is exactly when the distro is cold, so the freeze hit precisely the case the cache could not cover. Fold the decision into the one `wsl.exe` invocation we were always going to make. The command is now `sh -c` over a `case` on `$SHELL` that execs bash with our rcfile, or falls back to a plain login shell for a distro we don't integrate. It cannot block, because there is no second invocation. `$SHELL` rather than `getent passwd`: WSL populates it from the user's passwd entry, so inside the distro it already is the login shell of record — the same source `shell_kind` trusts on Unix. Written without a variable assignment so the whole thing stays one `case`, robust to the layers of quoting between the daemon and `sh`. The rcfile is now written before the shell is known. That is a local write into a throwaway dir the terminal already cleans up on drop, and paying it unconditionally is what buys the decision being free. Regression test names a distro that cannot exist and asserts setup still succeeds — if anything asked the distro a question, it could not. A timing bound would only have caught this on a cold machine, which is the same blind spot that let it ship. Removes `wsl_login_shell` and `inner_shell_kind`, both now unreachable. 736 tests pass, clippy warning count unchanged. Co-Authored-By: Claude Opus 4.8 --- src/daemon/shell_integration.rs | 182 ++++++++++++++------------------ 1 file changed, 82 insertions(+), 100 deletions(-) diff --git a/src/daemon/shell_integration.rs b/src/daemon/shell_integration.rs index ab058a73..a03054c8 100644 --- a/src/daemon/shell_integration.rs +++ b/src/daemon/shell_integration.rs @@ -810,20 +810,6 @@ fn shell_kind(program: Option<&str>) -> Option { } } -/// The shell kind named by a path *inside a distro*, where the Windows-specific -/// disambiguation in [`shell_kind`] must not apply: `/bin/bash` there is -/// genuinely bash, not the WSL launcher, even though the daemon asking the -/// question is a Windows process. -#[cfg_attr(not(windows), allow(dead_code))] -fn inner_shell_kind(path: &str) -> Option { - match Path::new(path).file_name()?.to_str()? { - "zsh" => Some(ShellKind::Zsh), - "bash" => Some(ShellKind::Bash), - "fish" => Some(ShellKind::Fish), - _ => None, - } -} - /// The distro named by a `wsl.exe` argv, if any. tty7's own launch args spell it /// `--distribution ` (`core::shells::detect_shells`); `-d` is the short /// form a user-configured shell may use. Absent means "the default distro", @@ -1075,75 +1061,59 @@ fn setup_bash() -> Option { }) } -/// The login shell of record inside `distro`, probed once and cached — the -/// daemon would otherwise pay a `wsl.exe` startup on every WSL pane spawn. -/// -/// `getent passwd` rather than `$SHELL`: the latter is whatever the launching -/// environment exported, which for a shell `wsl.exe` starts for a probe is not -/// necessarily the user's configured login shell. `$(id -u)` rather than -/// `$(id -un)` keeps the command free of nested quotes. -#[cfg(windows)] -fn wsl_login_shell(distro: Option<&str>) -> Option { - use std::sync::{Mutex, OnceLock}; - static CACHE: OnceLock>>> = OnceLock::new(); - - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - let key = distro.unwrap_or_default().to_string(); - if let Some(hit) = cache.lock().ok()?.get(&key) { - return hit.clone(); - } - - let mut cmd = std::process::Command::new("wsl.exe"); - if let Some(d) = distro { - cmd.args(["--distribution", d]); - } - cmd.args(["-e", "sh", "-c", "getent passwd $(id -u) | cut -d: -f7"]); - // `hide_console` keeps the probe from flashing a console window — tty7 is a - // GUI process (see `core::proc`). - let probed = crate::core::proc::hide_console(&mut cmd) - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| { - let s = String::from_utf8_lossy(&o.stdout).trim().to_string(); - (!s.is_empty()).then_some(s) - }); - cache.lock().ok()?.insert(key, probed.clone()); - probed -} - /// Env var carrying the rcfile path across the Windows/WSL boundary. Listed in /// `WSLENV` with the `/p` flag so WSL rewrites it to the distro's view of the /// path (`C:\Users\…` -> `/mnt/c/Users/…`), which is why we don't hardcode the /// `/mnt` automount root ourselves — it is configurable in `/etc/wsl.conf`. const WSL_RCFILE_ENV: &str = "TTY7_RC"; +/// Pick the distro's shell and exec it, *inside the distro*. +/// +/// Deliberately not a Windows-side probe. Spawning `wsl.exe` to ask which shell +/// a distro uses blocks the whole spawn path: the client waits synchronously for +/// the daemon's `Spawn` reply (see `terminal::remote::spawn`), so on a cold WSL +/// start — seconds, while the distro boots — the entire window freezes. Folding +/// the decision into the one `wsl.exe` invocation we were always going to make +/// costs nothing and cannot block, because there is no second invocation. +/// +/// `$SHELL` rather than `getent passwd`: WSL populates it from the user's passwd +/// entry, so inside the distro it already *is* the login shell of record — the +/// same source `shell_kind` trusts on Unix. Written without a variable +/// assignment so the whole thing stays one `case`, which keeps it robust to the +/// layers of quoting between here and `sh`. +const WSL_EXEC_SCRIPT: &str = concat!( + r#"case "${SHELL:-}" in "#, + r#"*/bash) exec "$SHELL" --rcfile "$TTY7_RC" -i ;; "#, + r#"*) exec "${SHELL:-/bin/sh}" -l ;; "#, + "esac" +); + /// Reach through `wsl.exe` to the distro's own shell. /// /// `wsl.exe` is a launcher, not a shell: injecting into it directly would never -/// reach the thing that draws the prompt. So we probe the distro's login shell, -/// write the matching integration rcfile on the Windows side, and hand `wsl.exe` -/// an explicit command that starts that shell with our rcfile — the distro's -/// own startup chain replayed inside it exactly as on any other bash. +/// reach the thing that draws the prompt. So we write the integration rcfile on +/// the Windows side and hand `wsl.exe` a command that starts the distro's shell +/// with it — the distro's own startup chain replayed inside it exactly as on any +/// other bash. /// -/// The argv shape is `[] -- sh -c 'exec --rcfile "$RC" -i'` -/// rather than `-- --rcfile ` because the path only exists as an -/// env var *inside* the distro after `WSLENV` translation, and `wsl.exe` execs -/// its command directly without a shell to expand it. The one-shot `sh` costs a +/// The argv shape is `[] -- sh -c