diff --git a/Cargo.lock b/Cargo.lock index 227f5aa259..2eea87d8ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8420,6 +8420,7 @@ dependencies = [ "snapshot_test", "tempfile", "toml", + "vite_shared", "which", ] diff --git a/crates/vite_cli_snapshots/Cargo.toml b/crates/vite_cli_snapshots/Cargo.toml index 846b53e092..db05861848 100644 --- a/crates/vite_cli_snapshots/Cargo.toml +++ b/crates/vite_cli_snapshots/Cargo.toml @@ -18,6 +18,7 @@ cp_r = { workspace = true } ctrlc = { workspace = true } pty_terminal_test_client = { workspace = true, features = ["testing"] } serde_json = { workspace = true } +vite_shared = { workspace = true } [dev-dependencies] cow-utils = { workspace = true } diff --git a/crates/vite_cli_snapshots/src/bin/vpt/pipe_stdin.rs b/crates/vite_cli_snapshots/src/bin/vpt/pipe_stdin.rs index 5481ab51a3..54d114db57 100644 --- a/crates/vite_cli_snapshots/src/bin/vpt/pipe_stdin.rs +++ b/crates/vite_cli_snapshots/src/bin/vpt/pipe_stdin.rs @@ -31,5 +31,5 @@ pub fn run(args: &[String]) -> Result<(), Box> { } let status = child.wait()?; - std::process::exit(status.code().unwrap_or(1)); + std::process::exit(vite_shared::exit_code_from_status(status)); } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/env_install_interrupt/test-reinstall-interrupt.js b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/env_install_interrupt/test-reinstall-interrupt.js index 76f2c3fb31..87d4a21f7f 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/env_install_interrupt/test-reinstall-interrupt.js +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/env_install_interrupt/test-reinstall-interrupt.js @@ -1,4 +1,5 @@ const { spawn } = require('child_process'); +const { constants } = require('os'); const child = spawn('vp', ['install', '-g', './long-time-install-package'], { stdio: 'inherit', @@ -10,6 +11,7 @@ setTimeout(() => { } }, 100); -child.on('close', (code) => { - process.exit(code); +child.on('close', (code, signal) => { + const signalNumber = signal && constants.signals[signal]; + process.exit(code ?? (signalNumber ? 128 + signalNumber : 1)); }); diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs index 2394bd61eb..af8e5934a5 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs @@ -768,7 +768,10 @@ fn run_step_piped( let mut output = stdout_thread.join().unwrap(); output.push_str(&stderr_thread.join().unwrap()); match status { - Some(status) => (TerminationState::Exited(i64::from(status.code().unwrap_or(-1))), output), + Some(status) => ( + TerminationState::Exited(i64::from(vite_shared::exit_code_from_status(status))), + output, + ), None => (TerminationState::TimedOut, output), } } diff --git a/crates/vite_global_cli/src/commands/vpr.rs b/crates/vite_global_cli/src/commands/vpr.rs index e043b9c8b0..84501fbddf 100644 --- a/crates/vite_global_cli/src/commands/vpr.rs +++ b/crates/vite_global_cli/src/commands/vpr.rs @@ -4,7 +4,7 @@ //! vite-plus CLI to execute tasks. use vite_path::AbsolutePath; -use vite_shared::output; +use vite_shared::{exit_code_from_status, output}; /// Main entry point for vpr execution. /// @@ -16,7 +16,7 @@ pub async fn execute_vpr(args: &[String], cwd: &AbsolutePath) -> i32 { let cwd_buf = cwd.to_absolute_path_buf(); match super::delegate::execute(cwd_buf, "run", args).await { - Ok(status) => status.code().unwrap_or(1), + Ok(status) => exit_code_from_status(status), Err(e) => { output::error(&e.to_string()); 1 diff --git a/crates/vite_global_cli/src/commands/vpx.rs b/crates/vite_global_cli/src/commands/vpx.rs index 53f840d0ea..d3e1c094e5 100644 --- a/crates/vite_global_cli/src/commands/vpx.rs +++ b/crates/vite_global_cli/src/commands/vpx.rs @@ -120,7 +120,7 @@ pub async fn execute_vpx(args: &[String], cwd: &AbsolutePath) -> i32 { args: positional, }; match vite_pm_cli::dispatch(cwd, dlx).await { - Ok(status) => status.code().unwrap_or(1), + Ok(status) => vite_shared::exit_code_from_status(status), Err(e) => { output::error(&format!("vpx: {e}")); 1 diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 1fdd658238..2f103eba69 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -31,7 +31,7 @@ use std::{ use clap::error::{ContextKind, ContextValue}; use clap_complete::env::CompleteEnv; use owo_colors::OwoColorize; -use vite_shared::output; +use vite_shared::{exit_code_from_status, output}; pub use crate::cli::try_parse_args_from; use crate::cli::{ @@ -213,7 +213,7 @@ fn exit_status_to_exit_code(exit_status: ExitStatus) -> ExitCode { ExitCode::SUCCESS } else { #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8)) + ExitCode::from(exit_code_from_status(exit_status) as u8) } } diff --git a/crates/vite_global_cli/src/shim/exec.rs b/crates/vite_global_cli/src/shim/exec.rs index bdcbd2459e..c8e5e3b679 100644 --- a/crates/vite_global_cli/src/shim/exec.rs +++ b/crates/vite_global_cli/src/shim/exec.rs @@ -4,20 +4,7 @@ //! On Windows, spawns the process and waits for completion. use vite_path::AbsolutePath; -use vite_shared::output; - -/// Convert a process ExitStatus to an exit code. -/// On Unix, if the process was killed by a signal, returns 128 + signal_number. -fn exit_code_from_status(status: std::process::ExitStatus) -> i32 { - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - if let Some(signal) = status.signal() { - return 128 + signal; - } - } - status.code().unwrap_or(1) -} +use vite_shared::{exit_code_from_status, output}; /// Spawn a tool as a child process and wait for completion. /// diff --git a/crates/vite_setup/src/install.rs b/crates/vite_setup/src/install.rs index 879af4f4a5..72004d77d7 100644 --- a/crates/vite_setup/src/install.rs +++ b/crates/vite_setup/src/install.rs @@ -293,7 +293,7 @@ pub async fn install_production_deps( if !release_age_blocked { return Err(Error::Setup( format_install_failure_message( - output.status.code().unwrap_or(-1), + vite_shared::exit_code_from_status(output.status), log_path.as_ref(), false, ) @@ -305,7 +305,7 @@ pub async fn install_production_deps( { return Err(Error::Setup( format_install_failure_message( - output.status.code().unwrap_or(-1), + vite_shared::exit_code_from_status(output.status), log_path.as_ref(), true, ) @@ -323,7 +323,7 @@ pub async fn install_production_deps( write_upgrade_log(version_dir, &retry_output.stdout, &retry_output.stderr).await; return Err(Error::Setup( format_install_failure_message( - retry_output.status.code().unwrap_or(-1), + vite_shared::exit_code_from_status(retry_output.status), retry_log_path.as_ref(), false, ) @@ -469,7 +469,7 @@ pub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> { let stderr = String::from_utf8_lossy(&output.stderr); tracing::warn!( "Shim refresh exited with code {}, continuing anyway\n{}", - output.status.code().unwrap_or(-1), + vite_shared::exit_code_from_status(output.status), stderr.trim() ); } @@ -612,7 +612,7 @@ pub async fn create_env_files(install_dir: &AbsolutePath) -> Result<(), Error> { let stderr = String::from_utf8_lossy(&output.stderr); tracing::warn!( "env setup --env-only exited with code {}, continuing anyway\n{}", - output.status.code().unwrap_or(-1), + vite_shared::exit_code_from_status(output.status), stderr.trim() ); } diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 5abf7b727f..954da50c44 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -17,6 +17,7 @@ mod json_edit; pub mod output; mod package_json; mod path_env; +mod process; pub mod string_similarity; mod tls; mod tracing; @@ -33,5 +34,6 @@ pub use path_env::{ PrependOptions, PrependResult, format_path_prepended, format_path_with_prepend, prepend_to_path_env, }; +pub use process::exit_code_from_status; pub use tls::ensure_tls_provider; pub use tracing::init_tracing; diff --git a/crates/vite_shared/src/process.rs b/crates/vite_shared/src/process.rs new file mode 100644 index 0000000000..c1e2f555d5 --- /dev/null +++ b/crates/vite_shared/src/process.rs @@ -0,0 +1,27 @@ +use std::process::ExitStatus; + +/// Convert a process status to the shell-compatible exit code used by CLI callers. +pub fn exit_code_from_status(status: ExitStatus) -> i32 { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + status.code().unwrap_or(1) +} + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use super::exit_code_from_status; + + /// Regression test for https://github.com/voidzero-dev/vite-plus/issues/2041. + #[test] + fn preserves_signal_exit_code() { + let status = + std::process::Command::new("/bin/sh").arg("-c").arg("kill -ILL $$").status().unwrap(); + assert_eq!(exit_code_from_status(status), 132); + } +} diff --git a/crates/vite_trampoline/src/main.rs b/crates/vite_trampoline/src/main.rs index ed8e76ed81..01a88ac033 100644 --- a/crates/vite_trampoline/src/main.rs +++ b/crates/vite_trampoline/src/main.rs @@ -16,9 +16,21 @@ use std::{ env, - process::{self, Command}, + process::{self, Command, ExitStatus}, }; +/// Preserve Unix signal termination using the shell's `128 + signal` convention. +fn exit_code_from_status(status: ExitStatus) -> i32 { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + status.code().unwrap_or(1) +} + fn main() { // 1. Determine tool name from our own executable filename let exe_path = env::current_exe().unwrap_or_else(|_| process::exit(1)); @@ -58,7 +70,7 @@ fn main() { // 5. Execute and propagate exit code. // Use write_all instead of eprintln!/format! to avoid pulling in core::fmt (~100KB). match cmd.status() { - Ok(s) => process::exit(s.code().unwrap_or(1)), + Ok(status) => process::exit(exit_code_from_status(status)), Err(_) => { use std::io::Write; let stderr = std::io::stderr(); @@ -71,6 +83,17 @@ fn main() { } } +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn preserves_signal_exit_code() { + let status = Command::new("/bin/sh").arg("-c").arg("kill -ILL $$").status().unwrap(); + assert_eq!(exit_code_from_status(status), 132); + } +} + /// Install a console control handler that ignores Ctrl+C, Ctrl+Break, etc. /// /// When Ctrl+C is pressed, Windows sends the event to all processes in the diff --git a/packages/cli/binding/src/cli/execution.rs b/packages/cli/binding/src/cli/execution.rs index 5a5811b23d..d0d2094a60 100644 --- a/packages/cli/binding/src/cli/execution.rs +++ b/packages/cli/binding/src/cli/execution.rs @@ -66,11 +66,11 @@ pub(super) async fn resolve_and_execute( // For interactive commands (dev, preview), use terminal guard to restore terminal state on exit if is_interactive { let status = vite_command::execute_with_terminal_guard(cmd).await?; - Ok(ExitStatus(status.code().unwrap_or(1) as u8)) + Ok(ExitStatus(vite_shared::exit_code_from_status(status) as u8)) } else { let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?; - Ok(ExitStatus(status.code().unwrap_or(1) as u8)) + Ok(ExitStatus(vite_shared::exit_code_from_status(status) as u8)) } } @@ -112,7 +112,7 @@ pub(super) async fn resolve_and_execute_with_filter( } } - Ok(ExitStatus(output.status.code().unwrap_or(1) as u8)) + Ok(ExitStatus(vite_shared::exit_code_from_status(output.status) as u8)) } pub(crate) async fn resolve_and_capture_output( @@ -135,7 +135,7 @@ pub(crate) async fn resolve_and_capture_output( let output = child.wait_with_output().await.map_err(|e| Error::Anyhow(e.into()))?; Ok(CapturedCommandOutput { - status: ExitStatus(output.status.code().unwrap_or(1) as u8), + status: ExitStatus(vite_shared::exit_code_from_status(output.status) as u8), stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned(), }) diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 5033a24339..702e8e9073 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -310,7 +310,7 @@ async fn execute_pm_command( } Err(e) => return Err(Error::Anyhow(anyhow::Error::new(e))), }; - Ok(ExitStatus(status.code().unwrap_or(1) as u8)) + Ok(ExitStatus(vite_shared::exit_code_from_status(status) as u8)) } #[cfg(test)] diff --git a/packages/cli/binding/src/exec/workspace.rs b/packages/cli/binding/src/exec/workspace.rs index 1bdc18b5d8..830681561d 100644 --- a/packages/cli/binding/src/exec/workspace.rs +++ b/packages/cli/binding/src/exec/workspace.rs @@ -187,7 +187,7 @@ pub(super) async fn execute_exec_workspace( use std::io::Write; let _ = std::io::stdout().write_all(&output.stdout); let _ = std::io::stderr().write_all(&output.stderr); - let code = output.status.code().unwrap_or(1) as u8; + let code = vite_shared::exit_code_from_status(output.status) as u8; if code > worst_exit { worst_exit = code; } @@ -250,7 +250,7 @@ pub(super) async fn execute_exec_workspace( let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?; let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?; let duration = start.elapsed(); - let code = status.code().unwrap_or(1) as u8; + let code = vite_shared::exit_code_from_status(status) as u8; if args.report_summary { let pkg_status = if code == 0 { "passed" } else { "failed" }; diff --git a/packages/cli/binding/src/utils.rs b/packages/cli/binding/src/utils.rs index 7d3e1fc9cc..95df501dba 100644 --- a/packages/cli/binding/src/utils.rs +++ b/packages/cli/binding/src/utils.rs @@ -113,8 +113,7 @@ pub async fn run_command(options: RunCommandOptions) -> Result ); } - // Get the exit code - let exit_code = result.status.code().unwrap_or(1); + let exit_code = vite_shared::exit_code_from_status(result.status); Ok(RunCommandResult { exit_code, path_accesses }) } diff --git a/packages/cli/src/utils/__tests__/command.spec.ts b/packages/cli/src/utils/__tests__/command.spec.ts index cfb110fec8..3eb4bbb5ac 100644 --- a/packages/cli/src/utils/__tests__/command.spec.ts +++ b/packages/cli/src/utils/__tests__/command.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { runCommandSilently } from '../command.ts'; +import { runCommand, runCommandSilently } from '../command.ts'; -describe('runCommandSilently', () => { +describe('command runners', () => { it('resolves with the child output when no timeout is set', async () => { const result = await runCommandSilently({ command: process.execPath, @@ -37,4 +37,30 @@ describe('runCommandSilently', () => { }); expect(result.exitCode).toBe(3); }); + + it.skipIf(process.platform === 'win32')( + 'preserves the signal exit code for captured commands', + async () => { + const result = await runCommandSilently({ + command: process.execPath, + args: ['-e', 'process.kill(process.pid, "SIGILL")'], + cwd: process.cwd(), + envs: process.env, + }); + expect(result.exitCode).toBe(132); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'preserves the signal exit code for inherited commands', + async () => { + const result = await runCommand({ + command: process.execPath, + args: ['-e', 'process.kill(process.pid, "SIGILL")'], + cwd: process.cwd(), + envs: process.env, + }); + expect(result.exitCode).toBe(132); + }, + ); }); diff --git a/packages/cli/src/utils/command.ts b/packages/cli/src/utils/command.ts index 254d3f951b..ee1061e3e5 100644 --- a/packages/cli/src/utils/command.ts +++ b/packages/cli/src/utils/command.ts @@ -1,3 +1,5 @@ +import { constants } from 'node:os'; + import spawn from 'cross-spawn'; export interface RunCommandOptions { @@ -21,6 +23,14 @@ export interface RunCommandResult extends ExecutionResult { stderr: Buffer; } +function exitCodeFromClose(code: number | null, signal: NodeJS.Signals | null) { + if (code !== null) { + return code; + } + const signalNumber = signal && constants.signals[signal]; + return signalNumber ? 128 + signalNumber : 1; +} + export async function runCommandSilently(options: RunCommandOptions): Promise { const child = spawn(options.command, options.args, { // No stdin pipe: leaving one open would deadlock any descendant `.ps1` @@ -50,14 +60,14 @@ export async function runCommandSilently(options: RunCommandOptions): Promise { stderr.push(data); }); - child.on('close', (code) => { + child.on('close', (code, signal) => { clearTimeout(timer); if (timedOut) { reject(new Error(`Command timed out after ${options.timeoutMs}ms: ${options.command}`)); return; } resolve({ - exitCode: code ?? 0, + exitCode: exitCodeFromClose(code, signal), stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), }); @@ -77,8 +87,8 @@ export async function runCommand(options: RunCommandOptions): Promise((resolve, reject) => { - child.on('close', (code) => { - resolve({ exitCode: code ?? 0 }); + child.on('close', (code, signal) => { + resolve({ exitCode: exitCodeFromClose(code, signal) }); }); child.on('error', (err) => { reject(err); diff --git a/packages/tools/src/local-npm-registry.ts b/packages/tools/src/local-npm-registry.ts index a626f0c779..2df7235d9f 100644 --- a/packages/tools/src/local-npm-registry.ts +++ b/packages/tools/src/local-npm-registry.ts @@ -49,7 +49,7 @@ import { type ServerResponse, } from 'node:http'; import { Agent as HttpsAgent, get as httpsGet, request as httpsRequest } from 'node:https'; -import { homedir, tmpdir } from 'node:os'; +import { constants as osConstants, homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { gunzipSync } from 'node:zlib'; @@ -577,7 +577,8 @@ server.listen(0, '127.0.0.1', () => { }); child.on('exit', (code, signal) => { cleanupRegistryEnv(registryEnv); - const exitCode = code ?? (signal ? 128 : 0); + const signalNumber = signal && osConstants.signals[signal]; + const exitCode = code ?? (signalNumber ? 128 + signalNumber : 1); server.close(() => process.exit(exitCode)); }); }); diff --git a/rfcs/dlx-command.md b/rfcs/dlx-command.md index 7fd0045a5b..795bc713ba 100644 --- a/rfcs/dlx-command.md +++ b/rfcs/dlx-command.md @@ -480,7 +480,7 @@ impl DlxCommand { let exit_status = package_manager.run_dlx_command(&options, &self.cwd).await?; - Ok(exit_status.code().unwrap_or(1)) + Ok(vite_shared::exit_code_from_status(exit_status)) } } ``` diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index b073288456..737b28af63 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -133,7 +133,7 @@ fn main() { // 5. Propagate exit code (error message via write_all, not eprintln!) match cmd.status() { - Ok(s) => process::exit(s.code().unwrap_or(1)), + Ok(status) => process::exit(exit_code_from_status(status)), Err(_) => { use std::io::Write; let mut stderr = std::io::stderr().lock();