Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TS_NODE_PROJECT=env.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name: string = 'cli.json';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { name } from '@lib/name';
console.log('tsconfig', name);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name: string = 'env.json';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "compilerOptions": { "paths": { "@lib/*": ["./a/*"] } } }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "nodeOptions": { "title": "vpx-custom-config" } }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "compilerOptions": { "paths": { "@lib/*": ["./b/*"] } } }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('args', JSON.stringify(process.argv.slice(2)), 'config', process.title === 'vpx-config');
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"nodeOptions":{"title":"vpx-config"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Cleans up for 300 ms after Ctrl+C before it exits, so a CLI that exits first shows.
import { emitMilestone } from './milestone.ts';

const timer = setInterval(() => {}, 1000);
process.on('SIGINT', () => {
console.log('SIGINT: cleaning up');
setTimeout(() => {
console.log('cleaned up');
clearInterval(timer);
}, 300);
});
emitMilestone('ready');
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Emits a runner milestone (a window title update), so a step can wait for it.
export function emitMilestone(name: string): void {
setImmediate(() => {
const encodedName = Buffer.from(name).toString('base64url');
process.stdout.write(`\x1b]2;vt-pty-terminal-test:${'0'.repeat(32)}:${encodedName}\x1b\\`);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "command-vpx-script-ctrl-c",
"private": true,
"type": "module",
"scripts": {
"cleanup": "vpx ./ctrlc.ts"
},
"engines": {
"node": "22.18.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ steps = [
["node", "verify-watch.mjs", "tsconfig", "--watch"],
["node", "verify-watch.mjs", "created", "--watch"],
{ argv = ["node", "verify-watch.mjs", "pinned", "--tsconfig", "tsconfig.b.json", "--watch"], comment = "--tsconfig as a vpx option" },
{ argv = ["node", "verify-watch.mjs", "pinned-env", "--watch", "--tsconfig", "tsconfig.b.json"], comment = "--tsconfig among the Node.js options wins over a TS_NODE_PROJECT from an env file" },
{ argv = ["node", "verify-watch.mjs", "pinned-config", "--watch"], comment = "--watch before a separate config file path, and --tsconfig among the Node.js options after it, which wins over a TS_NODE_PROJECT from an env file" },
]

[[case]]
Expand Down Expand Up @@ -225,3 +225,50 @@ cwd = "signals"
env = { TMPDIR = "/tmp" }
comment = "A SIGINT sent to the vpx --watch PID reaches the script once, and so does a SIGINT to its whole process group, as Ctrl+C sends it."
steps = [["node", "watch.mjs"]]

[[case]]
name = "vpx_script_bare_config_flag"
vp = ["local", "global"]
# Detection has no Windows-specific code, and the shared case table runs there; this saves
# a Node.js download on Windows.
skip-platforms = ["windows"]
cwd = "config-file"
# The first release where the project-local bin sees the bare flag: before 26.12, the
# Node.js running the bin rewrites it to `--experimental-config-file=node.config.json`.
env = { VP_NODE_VERSION = "26.12.0" }
comment = "From Node.js 24.19 and 26.1, a bare --experimental-config-file reads node.config.json and takes no value, so the arguments after it keep their meaning."
steps = [
{ argv = ["vpx", "--experimental-config-file", "./main.ts", "--watch"], comment = "--watch after the script belongs to the script" },
{ argv = ["vpx", "--experimental-config-file", "--tsconfig", "cli.json", "--env-file=.env", "./alias.ts"], comment = "--tsconfig is still vpx's, and wins over the TS_NODE_PROJECT in .env" },
{ argv = ["vpx", "--experimental-config-file", "--watch", "--include", "extra.json"], comment = "--watch still selects the watcher, which takes --include and reports the missing entry", continue-on-failure = true },
]

[[case]]
name = "vpx_script_config_file_path"
vp = ["local", "global"]
skip-platforms = ["windows"]
cwd = "config-file"
# The fixture's Node.js, 22.18.0, takes the next argument as the config file. Node.js
# warns that the flag is experimental, with a PID, in the launcher too.
env = { NODE_NO_WARNINGS = "1" }
comment = "Before Node.js 24.19 and 26.1, --experimental-config-file takes a separate .json path, so --tsconfig, --watch, and a watcher option after it keep their meaning."
steps = [
{ argv = ["vpx", "--experimental-config-file", "custom.json", "--tsconfig", "cli.json", "--env-file=.env", "./alias.ts"], comment = "--tsconfig after the path wins over the TS_NODE_PROJECT in .env" },
{ argv = ["vpx", "--experimental-config-file", "custom.json", "--watch", "--include", "extra.json"], comment = "--watch after the path selects the watcher, which takes --include and reports the missing entry", continue-on-failure = true },
]

[[case]]
name = "vp_run_vpx_script_ctrl_c_cleanup"
vp = "global"
# On Windows, the global vpx waits for the script through `vp.exe`, which Ctrl+C used to
# end at once with STATUS_CONTROL_C_EXIT (0xC000013A). The runner starts steps with Ctrl+C
# ignored, which children inherit; vite-task clears that, so the script under `vp run`
# gets Ctrl+C there, where it would not under a direct `vpx`.
cwd = "ctrl-c"
comment = "Ctrl+C under vp run reaches the script that vpx runs, and vp run exits after the script's SIGINT cleanup."
steps = [
{ argv = ["vp", "run", "cleanup"], interactions = [
{ "expect-milestone" = "ready" },
{ "write-key" = "ctrl-c" },
] },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# vp_run_vpx_script_ctrl_c_cleanup

Ctrl+C under vp run reaches the script that vpx runs, and vp run exits after the script's SIGINT cleanup.

## `vp run cleanup`

**→ expect-milestone:** `ready`

```
VITE+ - The Unified Toolchain for the Web

$ vpx ./ctrlc.ts ⊘ cache disabled
```

**← write-key:** `ctrl-c`

```
VITE+ - The Unified Toolchain for the Web

$ vpx ./ctrlc.ts ⊘ cache disabled
SIGINT: cleaning up
cleaned up
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# vpx_script_bare_config_flag

From Node.js 24.19 and 26.1, a bare --experimental-config-file reads node.config.json and takes no value, so the arguments after it keep their meaning.

## `vpx --experimental-config-file ./main.ts --watch`

--watch after the script belongs to the script

```
args ["--watch"] config true
```

## `vpx --experimental-config-file --tsconfig cli.json --env-file=.env ./alias.ts`

--tsconfig is still vpx's, and wins over the TS_NODE_PROJECT in .env

```
tsconfig cli.json
```

## `vpx --experimental-config-file --watch --include extra.json`

--watch still selects the watcher, which takes --include and reports the missing entry

**Exit code:** 1

```
vpx --watch: missing <entry>
Usage: vpx --watch [options] [node options] [--] <entry> [script args...]
Run "vpx --watch --help" for the options.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# vpx_script_bare_config_flag

From Node.js 24.19 and 26.1, a bare --experimental-config-file reads node.config.json and takes no value, so the arguments after it keep their meaning.

## `vpx --experimental-config-file ./main.ts --watch`

--watch after the script belongs to the script

```
args ["--watch"] config true
```

## `vpx --experimental-config-file --tsconfig cli.json --env-file=.env ./alias.ts`

--tsconfig is still vpx's, and wins over the TS_NODE_PROJECT in .env

```
tsconfig cli.json
```

## `vpx --experimental-config-file --watch --include extra.json`

--watch still selects the watcher, which takes --include and reports the missing entry

**Exit code:** 1

```
vpx --watch: missing <entry>
Usage: vpx --watch [options] [node options] [--] <entry> [script args...]
Run "vpx --watch --help" for the options.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# vpx_script_config_file_path

Before Node.js 24.19 and 26.1, --experimental-config-file takes a separate .json path, so --tsconfig, --watch, and a watcher option after it keep their meaning.

## `vpx --experimental-config-file custom.json --tsconfig cli.json --env-file=.env ./alias.ts`

--tsconfig after the path wins over the TS_NODE_PROJECT in .env

```
tsconfig cli.json
```

## `vpx --experimental-config-file custom.json --watch --include extra.json`

--watch after the path selects the watcher, which takes --include and reports the missing entry

**Exit code:** 1

```
vpx --watch: missing <entry>
Usage: vpx --watch [options] [node options] [--] <entry> [script args...]
Run "vpx --watch --help" for the options.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# vpx_script_config_file_path

Before Node.js 24.19 and 26.1, --experimental-config-file takes a separate .json path, so --tsconfig, --watch, and a watcher option after it keep their meaning.

## `vpx --experimental-config-file custom.json --tsconfig cli.json --env-file=.env ./alias.ts`

--tsconfig after the path wins over the TS_NODE_PROJECT in .env

```
tsconfig cli.json
```

## `vpx --experimental-config-file custom.json --watch --include extra.json`

--watch after the path selects the watcher, which takes --include and reports the missing entry

**Exit code:** 1

```
vpx --watch: missing <entry>
Usage: vpx --watch [options] [node options] [--] <entry> [script args...]
Run "vpx --watch --help" for the options.
```
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ vpx --watch restarted after the file an import looked for was created
vpx --tsconfig tsconfig.b.json --watch restarted after an edit to the tsconfig it was given
```

## `node verify-watch.mjs pinned-env --watch --tsconfig tsconfig.b.json`
## `node verify-watch.mjs pinned-config --watch`

--tsconfig among the Node.js options wins over a TS_NODE_PROJECT from an env file
--watch before a separate config file path, and --tsconfig among the Node.js options after it, which wins over a TS_NODE_PROJECT from an env file

```
vpx --watch --tsconfig tsconfig.b.json --env-file=.env.tsconfig restarted after an edit to the tsconfig it was given
vpx --watch --experimental-config-file custom.json --tsconfig tsconfig.b.json --env-file=.env.tsconfig restarted after an edit to the tsconfig it was given
```
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ vpx --watch restarted after the file an import looked for was created
vpx --tsconfig tsconfig.b.json --watch restarted after an edit to the tsconfig it was given
```

## `node verify-watch.mjs pinned-env --watch --tsconfig tsconfig.b.json`
## `node verify-watch.mjs pinned-config --watch`

--tsconfig among the Node.js options wins over a TS_NODE_PROJECT from an env file
--watch before a separate config file path, and --tsconfig among the Node.js options after it, which wins over a TS_NODE_PROJECT from an env file

```
vpx --watch --tsconfig tsconfig.b.json --env-file=.env.tsconfig restarted after an edit to the tsconfig it was given
vpx --watch --experimental-config-file custom.json --tsconfig tsconfig.b.json --env-file=.env.tsconfig restarted after an edit to the tsconfig it was given
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "nodeOptions": { "title": "vpx-custom-config" } }
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,19 @@ const SCENARIOS = {
done: 'restarted after the file an import looked for was created',
},
};
// `pinned` with an env file whose TS_NODE_PROJECT names `tsconfig.json`. The scenario
// passes it: on this script's command line Node.js would read it for this script too.
SCENARIOS['pinned-env'] = { ...SCENARIOS.pinned, args: ['--env-file=.env.tsconfig'] };
// `pinned` with a separate config file path before `--tsconfig`, and an env file whose
// TS_NODE_PROJECT names `tsconfig.json`. The scenario passes them: on this script's command
// line, Node.js would read the config file and the env file for this script too.
SCENARIOS['pinned-config'] = {
...SCENARIOS.pinned,
args: [
'--experimental-config-file',
'custom.json',
'--tsconfig',
'tsconfig.b.json',
'--env-file=.env.tsconfig',
],
};

const [name, ...vpxArgs] = process.argv.slice(2);
const scenario = SCENARIOS[name];
Expand Down
70 changes: 54 additions & 16 deletions crates/vp_command/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,25 +126,17 @@ pub fn build_command(bin_path: &AbsolutePath, cwd: &AbsolutePath) -> Command {
/// is interrupted (e.g., via Ctrl+C) while the terminal is in a non-standard state.
///
/// On Unix, saves the terminal state before spawning the child process and restores
/// it after the child exits. On Windows, this is a simple pass-through.
/// it after the child exits, and ignores SIGINT meanwhile. On Windows, Ctrl+C is ignored
/// meanwhile (`ConsoleCtrlGuard`), so this process waits for the child's own cleanup.
pub async fn execute_with_terminal_guard(mut cmd: Command) -> Result<ExitStatus, Error> {
// Save terminal state and ignore SIGINT (Unix) or Ctrl+C (Windows) until the child exits.
#[cfg(unix)]
{
use nix::libc::STDIN_FILENO;

// Save terminal state and ignore sigint signal for current process before spawning child
let _guard = TerminalStateGuard::save(STDIN_FILENO);

// Spawn and wait for child - guard will restore terminal state on drop
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
child.wait().await.map_err(|e| Error::Anyhow(e.into()))
}
let _guard = TerminalStateGuard::save(nix::libc::STDIN_FILENO);
#[cfg(windows)]
let _guard = ConsoleCtrlGuard::install();

#[cfg(not(unix))]
{
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
child.wait().await.map_err(|e| Error::Anyhow(e.into()))
}
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
child.wait().await.map_err(|e| Error::Anyhow(e.into()))
}

/// Build a `tokio::process::Command` for shell execution.
Expand Down Expand Up @@ -214,6 +206,9 @@ where
);
let mut cmd = build_command(&program, cwd);
cmd.args(&prefix_args).args(&args).envs(envs);
// Wait out the child's own Ctrl+C cleanup; see `ConsoleCtrlGuard`.
#[cfg(windows)]
let _guard = ConsoleCtrlGuard::install();
let status = cmd.status().await?;
Ok(status)
}
Expand Down Expand Up @@ -408,6 +403,49 @@ impl Drop for TerminalStateGuard {
}
}

/// While alive, Ctrl+C and Ctrl+Break do not end this process, as `TerminalStateGuard`
/// ignores SIGINT on Unix: the console sends them to the child too, and this process
/// waits for the child to finish its cleanup and exits with its code. A handler routine,
/// unlike `SetConsoleCtrlHandler(NULL, TRUE)`, is not inherited, so the child still gets
/// the event. The `vpx.exe` trampoline does the same.
#[cfg(windows)]
pub struct ConsoleCtrlGuard(());

#[cfg(windows)]
unsafe extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(ctrl_type: u32) -> i32>,
add: i32,
) -> i32;
}

#[cfg(windows)]
impl ConsoleCtrlGuard {
const CTRL_BREAK_EVENT: u32 = 1;
const CTRL_C_EVENT: u32 = 0;

/// Handle Ctrl+C and Ctrl+Break by doing nothing; leave other events, such as closing
/// the console, to the next handler.
unsafe extern "system" fn ignore(ctrl_type: u32) -> i32 {
i32::from(ctrl_type == Self::CTRL_C_EVENT || ctrl_type == Self::CTRL_BREAK_EVENT)
}

#[must_use]
pub fn install() -> Self {
// SAFETY: registers a handler routine that only returns a value.
unsafe { SetConsoleCtrlHandler(Some(Self::ignore), 1) };
Self(())
}
}

#[cfg(windows)]
impl Drop for ConsoleCtrlGuard {
fn drop(&mut self) {
// SAFETY: removes the routine install() registered; a no-op if it was not.
unsafe { SetConsoleCtrlHandler(Some(Self::ignore), 0) };
}
}

#[cfg(test)]
mod tests {
use tempfile::{TempDir, tempdir};
Expand Down
Loading
Loading