From 0c5c6236c9ae3b16c7fd02574d3f87537a938eee Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 17:56:55 +0800 Subject: [PATCH 1/4] fix(plan): skip cmd->ps1 rewrite when stdin is not a terminal The plan-time `.cmd` -> `powershell -File <.ps1>` rewrite was applied unconditionally on Windows. The npm/pnpm/yarn `.ps1` wrappers read stdin (`$MyInvocation.ExpectingInput` -> `$input | & node ...`) and block forever when stdin is a non-TTY pipe or null, as on CI runners: the build finishes but the wrapper never exits while draining stdin, holding the runner's stdio pipe open until the job is cancelled hours later. Gate the rewrite on `is_stdin_terminal()`, mirroring vite_command's ps1_shim. Without a terminal there is no Ctrl+C prompt to corrupt, so falling back to the `.cmd` (which never reads stdin) is strictly safer. Ref https://github.com/voidzero-dev/vite-plus/issues/1489 --- crates/vite_task_plan/src/ps1_shim.rs | 63 ++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs index 0bcb381c8..c14e478d4 100644 --- a/crates/vite_task_plan/src/ps1_shim.rs +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -47,13 +47,32 @@ pub fn rewrite_cmd_shim_with_args( workspace_root: &AbsolutePath, ) -> (Arc, Arc<[Str]>) { if let Some(host) = vite_powershell::powershell_host() - && let Some(rewritten) = rewrite_with_host(&resolved, &args, cwd, workspace_root, host) + && let Some(rewritten) = + rewrite_with_host(&resolved, &args, cwd, workspace_root, host, is_stdin_terminal()) { return rewritten; } (resolved, args) } +/// Cached `stdin.is_terminal()`. The `.ps1` wrappers npm/pnpm/yarn emit read +/// stdin (`$MyInvocation.ExpectingInput` -> `$input | & node ...`) and hang +/// forever when stdin is a non-TTY pipe or null (CI, scripts, editor tasks): +/// the wrapper drains stdin to EOF, which never comes, so the process never +/// exits and holds the runner's stdio pipe open. Without a terminal there is +/// also no Ctrl+C "Terminate batch job (Y/N)?" prompt to corrupt, so leaving +/// the `.cmd` in place is strictly safer. stdin's TTY-ness is fixed for the +/// process lifetime, and execution inherits stdin into the spawned children. +/// +/// See . +#[cfg(windows)] +fn is_stdin_terminal() -> bool { + use std::{io::IsTerminal, sync::LazyLock}; + + static IS_TTY: LazyLock = LazyLock::new(|| std::io::stdin().is_terminal()); + *IS_TTY +} + #[cfg(not(windows))] #[must_use] pub const fn rewrite_cmd_shim_with_args( @@ -74,7 +93,14 @@ fn rewrite_with_host( cwd: &AbsolutePath, workspace_root: &AbsolutePath, host: &Arc, + is_interactive: bool, ) -> Option<(Arc, Arc<[Str]>)> { + // Only route through PowerShell when stdin is an interactive terminal. The + // `.ps1` wrappers hang on a non-TTY stdin pipe (CI), and without a terminal + // there is no Ctrl+C prompt to protect against. See `is_stdin_terminal`. + if !is_interactive { + return None; + } if !is_in_workspace_node_modules_bin(resolved, workspace_root) { return None; } @@ -162,7 +188,7 @@ mod tests { let args: Arc<[Str]> = Arc::from(vec![Str::from("--port"), Str::from("3000")]); let (program, rewritten_args) = - rewrite_with_host(&resolved, &args, &workspace, &workspace, &host) + rewrite_with_host(&resolved, &args, &workspace, &workspace, &host, true) .expect("should rewrite"); assert_eq!(program.as_path(), host.as_path()); @@ -182,6 +208,29 @@ mod tests { ); } + /// Regression for the CI hang: the npm/pnpm/yarn `.ps1` wrappers read stdin + /// and block forever on a non-TTY pipe, so a structurally-valid shim must + /// NOT be rewritten when stdin is not an interactive terminal. The spawn + /// then falls back to the `.cmd` directly, which never reads stdin. + /// See . + #[test] + fn skips_rewrite_when_not_interactive() { + let dir = tempdir().unwrap(); + let workspace = abs(dir.path().canonicalize().unwrap()); + let bin = bin_dir(workspace.as_path()); + fs::write(bin.join("vite.cmd"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let host = host_arc(&workspace); + let resolved = abs(bin.join("vite.cmd")); + let args: Arc<[Str]> = Arc::from(vec![Str::from("build")]); + + assert!( + rewrite_with_host(&resolved, &args, &workspace, &workspace, &host, false).is_none(), + "non-interactive spawns must not be rewritten through PowerShell" + ); + } + #[test] fn rewrites_cmd_to_cwd_relative_ps1_in_hoisted_monorepo_subpackage() { // Task cwd is `/packages/foo`; shim lives at hoisted @@ -202,7 +251,7 @@ mod tests { let args: Arc<[Str]> = Arc::from(vec![]); let (_program, rewritten_args) = - rewrite_with_host(&resolved, &args, &sub_pkg, &workspace, &host) + rewrite_with_host(&resolved, &args, &sub_pkg, &workspace, &host, true) .expect("should rewrite"); assert_eq!( @@ -222,7 +271,7 @@ mod tests { let resolved = abs(bin.join("vite.cmd")); let args: Arc<[Str]> = Arc::from(vec![Str::from("build")]); - assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host).is_none()); + assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host, true).is_none()); } #[test] @@ -236,7 +285,7 @@ mod tests { let resolved = abs(workspace.as_path().join("where.cmd")); let args: Arc<[Str]> = Arc::from(vec![]); - assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host).is_none()); + assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host, true).is_none()); } #[test] @@ -251,7 +300,7 @@ mod tests { let resolved = abs(bin.join("node.exe")); let args: Arc<[Str]> = Arc::from(vec![Str::from("--version")]); - assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host).is_none()); + assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host, true).is_none()); } #[test] @@ -276,6 +325,6 @@ mod tests { let resolved = abs(global_bin.join("vite.cmd")); let args: Arc<[Str]> = Arc::from(vec![]); - assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host).is_none()); + assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host, true).is_none()); } } From e8241fc2a4c10ecfb309eceb14053b097704ae97 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 18:31:09 +0800 Subject: [PATCH 2/4] test(plan): update windows_cmd_shim_rewrite snapshot for the stdin gate The plan-time `.cmd` -> PowerShell rewrite is now gated on an interactive terminal (is_stdin_terminal). The snapshot test runner has no TTY on stdin, so the rewrite is skipped and the plan records the `node_modules/.bin` shim directly instead of `powershell -File ...vite.ps1`. Update both fixture snapshots and the fixture comment to match the gated behavior. --- .../windows_cmd_shim_rewrite/snapshots.toml | 14 +++++++++----- .../snapshots/query_dev_filter_from_root.jsonc | 18 +++--------------- .../snapshots/query_dev_in_subpackage.jsonc | 18 +++--------------- 3 files changed, 15 insertions(+), 35 deletions(-) diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml index 104500af5..31de8bb34 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml @@ -1,8 +1,12 @@ -# Windows-only: verifies the `.cmd` → PowerShell rewrite at plan time, and -# that the resulting cache key stays portable regardless of how the user -# navigated to the sub-package task. Both plan cases below target the same -# task; their snapshots should be byte-identical apart from the plan query -# itself, proving the cache key doesn't depend on invocation style. +# Windows-only. The `.cmd` → PowerShell rewrite at plan time is gated on an +# interactive terminal (see `vite_task_plan::ps1_shim::is_stdin_terminal`): the +# `.ps1` wrappers hang on a non-TTY stdin pipe, as on CI. The test runner has no +# TTY on stdin, so the rewrite is skipped here and the plan records the +# `node_modules/.bin` shim directly instead of `powershell -File …vite.ps1`. +# This still verifies the cache key stays portable regardless of how the user +# navigated to the sub-package task: both plan cases below target the same task +# and their snapshots should be byte-identical apart from the plan query itself, +# proving the cache key doesn't depend on invocation style. platform = "windows" # Direct invocation: user `cd`'d into the sub-package before running. diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc index 64204785b..f082e3b25 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc @@ -30,17 +30,11 @@ "spawn_fingerprint": { "cwd": "packages/foo", "program_fingerprint": { - "OutsideWorkspace": { - "program_name": "" + "InsideWorkspace": { + "relative_program_path": "packages/foo/node_modules/.bin/vite.cmd" } }, "args": [ - "-NoProfile", - "-NoLogo", - "-ExecutionPolicy", - "Bypass", - "-File", - "node_modules/.bin/vite.ps1", "--port", "3000" ], @@ -72,14 +66,8 @@ } }, "spawn_command": { - "program_path": "", + "program_path": "/packages/foo/node_modules/.bin/vite", "args": [ - "-NoProfile", - "-NoLogo", - "-ExecutionPolicy", - "Bypass", - "-File", - "node_modules/.bin/vite.ps1", "--port", "3000" ], diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc index 137559471..f8e12919d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc @@ -30,17 +30,11 @@ "spawn_fingerprint": { "cwd": "packages/foo", "program_fingerprint": { - "OutsideWorkspace": { - "program_name": "" + "InsideWorkspace": { + "relative_program_path": "packages/foo/node_modules/.bin/vite.cmd" } }, "args": [ - "-NoProfile", - "-NoLogo", - "-ExecutionPolicy", - "Bypass", - "-File", - "node_modules/.bin/vite.ps1", "--port", "3000" ], @@ -72,14 +66,8 @@ } }, "spawn_command": { - "program_path": "", + "program_path": "/packages/foo/node_modules/.bin/vite", "args": [ - "-NoProfile", - "-NoLogo", - "-ExecutionPolicy", - "Bypass", - "-File", - "node_modules/.bin/vite.ps1", "--port", "3000" ], From 0068d09b24f8f1c27eb1aee3901058c784942ce4 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 18:44:51 +0800 Subject: [PATCH 3/4] refactor: move is_stdin_terminal into the vite_powershell crate Both vite_task_plan::ps1_shim and vite-plus's vite_command::ps1_shim need the same cached stdin-TTY gate to decide whether to rewrite `.cmd` to PowerShell. Host it once in vite_powershell (alongside POWERSHELL_PREFIX, powershell_host, find_ps1_sibling) so both consumers share one implementation instead of duplicating it across the two repos. --- crates/vite_powershell/src/lib.rs | 31 ++++++++++++++++++++++-- crates/vite_task_plan/src/ps1_shim.rs | 35 +++++++++------------------ 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/crates/vite_powershell/src/lib.rs b/crates/vite_powershell/src/lib.rs index d59ab9c4a..2819cdf35 100644 --- a/crates/vite_powershell/src/lib.rs +++ b/crates/vite_powershell/src/lib.rs @@ -7,8 +7,8 @@ //! and lets Ctrl+C propagate cleanly. //! //! This crate carries only the platform-shared primitives (the -//! `PowerShell` host lookup, the fixed argument prefix, and the -//! sibling-`.ps1` discovery). Higher-level wrappers in +//! `PowerShell` host lookup, the fixed argument prefix, the +//! sibling-`.ps1` discovery, and the stdin-TTY gate). Higher-level wrappers in //! `vite_task_plan::ps1_shim` (cwd-relative arg rewrite, scoped to //! `node_modules/.bin`) and `vite_command::ps1_shim` (absolute-path //! arg rewrite, applied to any `.cmd`) compose these primitives with @@ -76,6 +76,25 @@ pub fn find_ps1_sibling(resolved: &AbsolutePath) -> Option { Some(ps1) } +/// Cached `stdin.is_terminal()`. The TTY-ness of stdin is fixed for the +/// process lifetime, so the underlying syscall runs at most once per process. +/// +/// Gates the `.cmd` -> PowerShell `.ps1` rewrite that both `vite_task_plan` +/// and `vite_command` perform: the npm/pnpm/yarn `.ps1` wrappers read stdin +/// (`$MyInvocation.ExpectingInput` -> `$input | & node ...`) and hang forever +/// on a non-TTY pipe or null, as on CI runners. Without a terminal there is +/// also no Ctrl+C "Terminate batch job (Y/N)?" prompt to corrupt, so callers +/// fall back to the `.cmd` (which never reads stdin) when this returns `false`. +/// +/// See . +#[must_use] +pub fn is_stdin_terminal() -> bool { + use std::{io::IsTerminal, sync::LazyLock}; + + static IS_TTY: LazyLock = LazyLock::new(|| std::io::stdin().is_terminal()); + *IS_TTY +} + #[cfg(test)] mod tests { use std::fs; @@ -143,4 +162,12 @@ mod tests { let resolved = abs(root.as_path().join("node")); assert!(find_ps1_sibling(&resolved).is_none()); } + + #[test] + fn is_stdin_terminal_is_idempotent() { + // The value depends on how the test runner wires stdin (non-TTY under + // nextest), so assert the cached result is stable rather than a fixed + // value. + assert_eq!(is_stdin_terminal(), is_stdin_terminal()); + } } diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs index c14e478d4..91d3a1da7 100644 --- a/crates/vite_task_plan/src/ps1_shim.rs +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -22,8 +22,8 @@ //! left alone even if it happens to live under some other `node_modules/.bin`. //! //! Cross-platform primitives (`POWERSHELL_PREFIX`, `powershell_host`, -//! `find_ps1_sibling`) live in the `vite_powershell` crate so -//! `vite_command::ps1_shim` can share them. +//! `find_ps1_sibling`, `is_stdin_terminal`) live in the `vite_powershell` +//! crate so `vite_command::ps1_shim` can share them. //! //! See . @@ -47,32 +47,20 @@ pub fn rewrite_cmd_shim_with_args( workspace_root: &AbsolutePath, ) -> (Arc, Arc<[Str]>) { if let Some(host) = vite_powershell::powershell_host() - && let Some(rewritten) = - rewrite_with_host(&resolved, &args, cwd, workspace_root, host, is_stdin_terminal()) + && let Some(rewritten) = rewrite_with_host( + &resolved, + &args, + cwd, + workspace_root, + host, + vite_powershell::is_stdin_terminal(), + ) { return rewritten; } (resolved, args) } -/// Cached `stdin.is_terminal()`. The `.ps1` wrappers npm/pnpm/yarn emit read -/// stdin (`$MyInvocation.ExpectingInput` -> `$input | & node ...`) and hang -/// forever when stdin is a non-TTY pipe or null (CI, scripts, editor tasks): -/// the wrapper drains stdin to EOF, which never comes, so the process never -/// exits and holds the runner's stdio pipe open. Without a terminal there is -/// also no Ctrl+C "Terminate batch job (Y/N)?" prompt to corrupt, so leaving -/// the `.cmd` in place is strictly safer. stdin's TTY-ness is fixed for the -/// process lifetime, and execution inherits stdin into the spawned children. -/// -/// See . -#[cfg(windows)] -fn is_stdin_terminal() -> bool { - use std::{io::IsTerminal, sync::LazyLock}; - - static IS_TTY: LazyLock = LazyLock::new(|| std::io::stdin().is_terminal()); - *IS_TTY -} - #[cfg(not(windows))] #[must_use] pub const fn rewrite_cmd_shim_with_args( @@ -97,7 +85,8 @@ fn rewrite_with_host( ) -> Option<(Arc, Arc<[Str]>)> { // Only route through PowerShell when stdin is an interactive terminal. The // `.ps1` wrappers hang on a non-TTY stdin pipe (CI), and without a terminal - // there is no Ctrl+C prompt to protect against. See `is_stdin_terminal`. + // there is no Ctrl+C prompt to protect against. See + // `vite_powershell::is_stdin_terminal`. if !is_interactive { return None; } From 513cf2089c95f80cbb4fd7648d070833b01a5d8b Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 28 Jun 2026 18:49:03 +0800 Subject: [PATCH 4/4] docs: add changelog entry for the Windows ps1 stdin hang fix (#491) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5d39ed8..5c112e278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Windows builds no longer hang on CI when a `node_modules/.bin` `.cmd` shim is routed through PowerShell: the npm/pnpm/yarn `.ps1` wrappers read stdin and block forever on a non-TTY pipe, so the PowerShell rewrite is now skipped when stdin is not an interactive terminal, falling back to the `.cmd` (which never reads stdin) ([#491](https://github.com/voidzero-dev/vite-task/pull/491)). - **Added** First-party support for caching `vite build` with zero cache config, giving Vite projects correct cache hits out of the box ([vitejs/vite#22453](https://github.com/vitejs/vite/pull/22453)). - **Added** Support for specifying tasks from dependency packages in `dependsOn`, such as `dependsOn: [{ "task": "build", "from": "dependencies" }]` ([#479](https://github.com/voidzero-dev/vite-task/pull/479)). - **Added** [`@voidzero-dev/vite-task-client`](https://npmx.dev/package/@voidzero-dev/vite-task-client), allowing tools to report cache information to Vite Task at runtime so users do not need to configure it manually ([#441](https://github.com/voidzero-dev/vite-task/pull/441), [#454](https://github.com/voidzero-dev/vite-task/pull/454), [#449](https://github.com/voidzero-dev/vite-task/pull/449), [#450](https://github.com/voidzero-dev/vite-task/pull/450), [#458](https://github.com/voidzero-dev/vite-task/pull/458), [#431](https://github.com/voidzero-dev/vite-task/pull/431), [#459](https://github.com/voidzero-dev/vite-task/pull/459), [#472](https://github.com/voidzero-dev/vite-task/pull/472)).