diff --git a/.github/actions/build-upstream/action.yml b/.github/actions/build-upstream/action.yml index 9c4bd0d8a3..71bd56ff1d 100644 --- a/.github/actions/build-upstream/action.yml +++ b/.github/actions/build-upstream/action.yml @@ -174,10 +174,12 @@ runs: env: INPUTS_TARGET: ${{ inputs.target }} + # The helper builds the excluded trampoline from its crate directory. + # It anchors CARGO_TARGET_DIR to the repository root. - name: Build trampoline shim binary (Windows only) if: steps.native.outputs.build == 'true' && contains(inputs.target, 'windows') shell: bash - run: cargo build --release --target ${INPUTS_TARGET} -p vp_trampoline + run: node packages/tools/src/build-trampoline.ts --release --target "${INPUTS_TARGET}" env: INPUTS_TARGET: ${{ inputs.target }} diff --git a/.github/actions/build-windows-cli/action.yml b/.github/actions/build-windows-cli/action.yml index 49d5aee6c8..fe2f2f7f49 100644 --- a/.github/actions/build-windows-cli/action.yml +++ b/.github/actions/build-windows-cli/action.yml @@ -80,11 +80,20 @@ runs: - name: Build Rust CLI binaries if: steps.binaries-cache.outputs.cache-hit != 'true' shell: bash - run: cargo xwin build --release --target x86_64-pc-windows-msvc -p vp_global_cli -p vp_trampoline -p vp_installer + run: cargo xwin build --release --target x86_64-pc-windows-msvc -p vp_global_cli -p vp_installer env: XWIN_ACCEPT_LICENSE: '1' CXXFLAGS: -D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH + # The helper builds the excluded trampoline from its crate directory. + # It anchors CARGO_TARGET_DIR to the repository root. + - name: Build trampoline shim binary + if: steps.binaries-cache.outputs.cache-hit != 'true' + shell: bash + run: node packages/tools/src/build-trampoline.ts --xwin --release --target x86_64-pc-windows-msvc + env: + XWIN_ACCEPT_LICENSE: '1' + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.artifact-name }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18a78863ff..1bddc36e9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,11 +206,14 @@ jobs: # Keep the package selection in sync with the `test` recipe in justfile. # vp_cli_snapshots is excluded there too: its snapshot suite needs a # built vp and node at runtime and joins the Windows archive later. + # vp_trampoline is not a workspace member. + # Run its portable parser and layout tests on Unix. + # The Windows CLI snapshot suite tests Windows shim behavior. - name: Build test archive run: | eval "$(cargo xwin env --target x86_64-pc-windows-msvc | grep '^export ')" unset RUSTFLAGS - cargo nextest archive $(for d in crates/*/; do n=$(basename $d); [ "$n" = "vp_cli_snapshots" ] || echo -n "-p $n "; done) -p vite-plus-cli \ + cargo nextest archive $(for d in crates/*/; do n=$(basename $d); [ "$n" = "vp_cli_snapshots" ] || [ "$n" = "vp_trampoline" ] || echo -n "-p $n "; done) -p vite-plus-cli \ --target x86_64-pc-windows-msvc --archive-file windows-tests.tar.zst - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -335,6 +338,7 @@ jobs: - run: | cargo shear cargo fmt --check + cargo fmt --manifest-path crates/vp_trampoline/Cargo.toml --check just lint # RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --document-private-items diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index bb54845e5f..c217953f98 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -1269,6 +1269,10 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - uses: ./.github/actions/clone + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Pin VP_HOME to USERPROFILE # Namespace's Windows runners run jobs under a service account whose real # profile (C:\Windows\system32\config\systemprofile) differs from @@ -1292,7 +1296,50 @@ jobs: - name: Build Windows installers shell: bash - run: cargo build --release -p vp_global_cli -p vp_installer -p vp_trampoline + run: | + cargo build --release -p vp_global_cli -p vp_installer + node packages/tools/src/build-trampoline.ts --release + + - name: Test trampoline with an extended-length payload path + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $root = Join-Path $env:RUNNER_TEMP "vp-trampoline-long-payload" + $bin = Join-Path $root "bin" + $data = Join-Path $root "data" + $cache = Join-Path $root "cache" + $segment = "segment-" + ("x" * 60) + while ((Join-Path $data "current\bin\vp.exe").Length -le 300) { + $data = Join-Path $data $segment + } + $payloadBin = Join-Path $data "current\bin" + $payload = Join-Path $payloadBin "vp.exe" + if ($payload.Length -le 260) { + throw "The payload path must be longer than MAX_PATH: $payload" + } + + Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue + [System.IO.Directory]::CreateDirectory($bin) | Out-Null + [System.IO.Directory]::CreateDirectory($payloadBin) | Out-Null + [System.IO.File]::Copy( + (Join-Path $env:DEV_DRIVE "target/release/vp-shim.exe"), + (Join-Path $bin "vp.exe"), + $true + ) + [System.IO.File]::Copy( + (Join-Path $env:DEV_DRIVE "target/release/vp.exe"), + $payload, + $true + ) + $pointer = "vite-plus-shim-v1`nlayout=split`ndata=$data`ncache=$cache`n" + [System.IO.File]::WriteAllText((Join-Path $bin "vp.shim"), $pointer) + + $output = (& (Join-Path $bin "vp.exe") --version 2>&1) | Out-String + $exitCode = $LASTEXITCODE + Write-Host $output + if ($exitCode -ne 0) { + throw "The trampoline exited with $exitCode for payload path $payload" + } - name: vp-setup.exe rejects invalid directory overrides shell: pwsh @@ -1704,10 +1751,6 @@ jobs: } & $vp --version - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - name: Start local preview registry for vp-setup.exe shell: bash run: | diff --git a/.gitignore b/.gitignore index 056bae4644..1f25eb0022 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ vite # PTY snapshot runner failure artifacts (reviewed via the diff, never committed) crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/*/snapshots/*.md.new +# Cargo does not read the crate config when these commands run from the repo root: +# `cargo fmt --manifest-path crates/vp_trampoline/Cargo.toml` +# `cargo clippy --manifest-path crates/vp_trampoline/Cargo.toml` +# These commands create the nested target directory below. +/crates/vp_trampoline/target diff --git a/AGENTS.md b/AGENTS.md index 701a619031..34cc3a6465 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ vite-plus/ ├── crates/vp_shared/ # Shared Rust env config, tracing, output, utilities ├── crates/vp_static_config/ # Static extraction of vite.config.* data ├── crates/vp_toolchain/ # toolchain.json manifest model, validation, and `why` hints -└── crates/vp_trampoline/ # Windows shim trampoline +└── crates/vp_trampoline/ # Standalone Windows shim trampoline outside the workspace ``` Vite+ resolves all on-disk paths through `vp_shared::VpDirs`. diff --git a/Cargo.lock b/Cargo.lock index b7b39b72bc..1d1202709a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8740,10 +8740,6 @@ dependencies = [ "vt_str", ] -[[package]] -name = "vp_trampoline" -version = "0.0.0" - [[package]] name = "vsimd" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 5fba3c63dd..d2d11a49a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "3" members = ["bench", "crates/*", "packages/cli/binding"] +# vp_trampoline is a standalone package. +# It needs a separate release profile and a crate-local build-std config. +# Cargo ignores `panic` in per-package profile overrides. +# See crates/vp_trampoline/Cargo.toml. +exclude = ["crates/vp_trampoline"] [workspace.metadata.cargo-shear] ignored = [ @@ -431,11 +436,6 @@ strip = "symbols" # set to `false` for debug information debug = false # set to `true` for debug information panic = "abort" # Let it crash and force ourselves to write safe Rust. -# The trampoline binary is copied per shim tool (~5-10 copies), so optimize for -# size instead of speed. This reduces it from ~200KB to ~100KB on Windows. -[profile.release.package.vp_trampoline] -opt-level = "z" - # The installer binary is downloaded by users, so optimize for size. [profile.release.package.vp_installer] opt-level = "z" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index 8505c6e72b..c85c8d86aa 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -592,7 +592,7 @@ impl CaseHome { .join("vp-shim.exe"); if !shim.is_file() { return Err(format!( - "global vp trampoline template not found at {}; run `cargo build -p vp_trampoline`", + "The global vp trampoline template does not exist at {}. Run `node packages/tools/src/build-trampoline.ts`.", shim.display() )); } diff --git a/crates/vp_trampoline/.cargo/config.toml b/crates/vp_trampoline/.cargo/config.toml new file mode 100644 index 0000000000..fcd7c35d1c --- /dev/null +++ b/crates/vp_trampoline/.cargo/config.toml @@ -0,0 +1,24 @@ +# Cargo reads this config only when it runs from this directory. +# Run `node packages/tools/src/build-trampoline.ts` from the repository root. +# The helper runs Cargo from this directory. + +[unstable] +# Recompile std with this crate's release profile. +# The profile uses opt-level = "z" and panic = "immediate-abort". +# With the raw Win32 source, this reduces the x64 executable to 14 KiB. +# This operation needs the rust-src component. +build-std = ["std", "panic_abort"] +# Replace the default std features to remove panic-unwind and backtrace. +# The optimize_for_size feature enables smaller code paths. +# compiler-builtins-mem supplies memory functions without the CRT. +# The #![no_main] entry point needs these functions. +build-std-features = ["optimize_for_size", "compiler-builtins-mem"] +# Use the abort panic strategy for `cargo test`. +panic-abort-tests = true + +[build] +# Store artifacts in the repository target/ directory. +# CI, the snapshot runner, and install-global-cli read artifacts there. +# Cargo resolves this path from the crate directory. +# CARGO_TARGET_DIR overrides this value. +target-dir = "../../target" diff --git a/crates/vp_trampoline/Cargo.lock b/crates/vp_trampoline/Cargo.lock new file mode 100644 index 0000000000..0f29d0ba63 --- /dev/null +++ b/crates/vp_trampoline/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "vp_trampoline" +version = "0.0.0" diff --git a/crates/vp_trampoline/Cargo.toml b/crates/vp_trampoline/Cargo.toml index 2bba10336a..60d51c8cdb 100644 --- a/crates/vp_trampoline/Cargo.toml +++ b/crates/vp_trampoline/Cargo.toml @@ -1,28 +1,69 @@ +# The root Cargo.toml excludes this crate from the workspace. +# This crate needs a separate release profile with panic = "immediate-abort". +# Cargo ignores `panic` in per-package profile overrides. +# The crate-local .cargo/config.toml enables build-std. +# From the repository root, run: +# +# node packages/tools/src/build-trampoline.ts --release [--target ] +# +# The crate config stores artifacts in the repository target/ directory. +# Workspace builds use the same directory. +# The build uses the pinned nightly toolchain and the rust-src component. +# The repository rust-toolchain.toml supplies both items. +# +# The x86_64-pc-windows-msvc executable is 14 KiB. +# The implementation with precompiled std was approximately 222 KiB. +# build-std recompiles std with this profile. +# panic = "immediate-abort" removes panic formatting, unwinding, and backtraces. +# src/win.rs uses #![no_main] and raw Win32 calls instead of std::process::Command. +# For more information, see rfcs/trampoline-exe-for-shims.md. +cargo-features = ["panic-immediate-abort"] + [package] name = "vp_trampoline" version = "0.0.0" -authors.workspace = true -edition.workspace = true -license.workspace = true +authors = ["Vite+ Authors"] +edition = "2024" +license = "MIT" publish = false -rust-version.workspace = true description = "Minimal Windows trampoline exe for vite-plus shims" [[bin]] name = "vp-shim" path = "src/main.rs" -# No dependencies — the single Win32 FFI call (SetConsoleCtrlHandler) is -# declared inline to avoid pulling in the heavy `windows`/`windows-core` crates. +# This crate has no dependencies. +# It declares raw Win32 FFI calls to avoid the `windows` and `windows-core` crates. -# Override workspace lints: this is a standalone minimal binary that intentionally -# avoids dependencies on vp_shared, vt_path, vt_str, etc. to keep binary -# size small. It uses std types and macros directly. +# This crate does not inherit workspace lints. +# It uses std types and macros directly to keep the binary small. +# Thus, allow the .clippy.toml rules that require shared project abstractions. [lints.clippy] disallowed_macros = "allow" disallowed_types = "allow" disallowed_methods = "allow" -# Note: Release profile is defined at workspace root (Cargo.toml). -# The workspace already sets lto="fat", codegen-units=1, strip="symbols", panic="abort". -# For even smaller binaries, consider building this crate separately with opt-level="z". +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +strip = "symbols" +# Convert panics to an immediate abort without message formatting. +# This prevents links to core::fmt and std::panicking. +panic = "immediate-abort" +debug = false + +# Optimize debug builds at opt-level 1. +# At opt-level 0, the compiler can reference the MSVC helper __CxxFrameHandler3. +# This reference causes a link failure, even with panic = "immediate-abort". +# uv-trampoline has the same constraint. +[profile.dev] +opt-level = 1 +lto = true +panic = "immediate-abort" +debug = true + +[profile.test] +inherits = "dev" + +[workspace] diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs new file mode 100644 index 0000000000..9941a590fa --- /dev/null +++ b/crates/vp_trampoline/src/cmdline.rs @@ -0,0 +1,312 @@ +//! Portable helpers for UTF-16 code units and bytes. +//! The Windows implementation uses these helpers. +//! They stay outside win.rs so their unit tests run on all platforms. + +const SPACE: u16 = b' ' as u16; +const TAB: u16 = b'\t' as u16; +const QUOTE: u16 = b'"' as u16; +const DOT: u16 = b'.' as u16; +const COLON: u16 = b':' as u16; +const QUESTION: u16 = b'?' as u16; +const BACKSLASH: u16 = b'\\' as u16; +const FORWARD_SLASH: u16 = b'/' as u16; +const U: u16 = b'U' as u16; +const N: u16 = b'N' as u16; +const C: u16 = b'C' as u16; + +const VERBATIM_PREFIX: &[u16] = &[BACKSLASH, BACKSLASH, QUESTION, BACKSLASH]; +const UNC_PREFIX: &[u16] = &[BACKSLASH, BACKSLASH, QUESTION, BACKSLASH, U, N, C, BACKSLASH]; + +/// Must match `vp_shared::SHIM_POINTER_HEADER`. +pub const SHIM_POINTER_HEADER: &str = "vite-plus-shim-v1"; + +#[derive(Debug, PartialEq, Eq)] +pub enum ShimLayout<'a> { + SingleRoot, + Split { cache: &'a str }, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ShimPointer<'a> { + pub data: &'a str, + pub layout: ShimLayout<'a>, +} + +/// Parse the UTF-8 `.shim` sidecar written by `vp_shared::VpDirs`. +/// +/// A sidecar records the directory layout, data root, and cache root. +/// The parser requires the versioned header. +/// The parser supports a UTF-8 BOM and CRLF line endings, as `vp_shared` does. +pub fn parse_shim_pointer(bytes: &[u8]) -> Option> { + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + let text = core::str::from_utf8(bytes).ok()?.trim(); + let mut lines = text.lines(); + if lines.next()? != SHIM_POINTER_HEADER { + return None; + } + + let mut layout = None; + let mut data = None; + let mut cache = None; + for line in lines { + if let Some(value) = line.strip_prefix("layout=") { + layout = Some(value); + } else if let Some(value) = line.strip_prefix("data=") { + data = (!value.is_empty()).then_some(value); + } else if let Some(value) = line.strip_prefix("cache=") { + cache = (!value.is_empty()).then_some(value); + } + } + + let data = data?; + let layout = match layout? { + "single-root" => ShimLayout::SingleRoot, + "split" => ShimLayout::Split { cache: cache? }, + _ => return None, + }; + Some(ShimPointer { data, layout }) +} + +/// Return the end index of the first program argument in a raw command line. +/// +/// This function follows the MSVC rule for a program name. +/// A quote starts or stops quoted mode. +/// Backslashes do not escape characters. +/// Leading whitespace ends an empty program argument. +/// Forward `&cmdline[result..]` to the child without changes. +/// This remaining text includes its leading whitespace. +pub fn skip_program_argument(cmdline: &[u16]) -> usize { + let mut i = 0; + let mut quoted = false; + while i < cmdline.len() { + let c = cmdline[i]; + if c == QUOTE { + quoted = !quoted; + } else if (c == SPACE || c == TAB) && !quoted { + break; + } + i += 1; + } + i +} + +fn is_path_separator(unit: u16) -> bool { + unit == BACKSLASH || unit == FORWARD_SLASH +} + +/// Add the Win32 extended-length prefix to a normalized absolute path. +/// +/// Before the call, resolve `.` and `..` components. +/// Before the call, replace `/` separators. +/// The extended-length namespace uses the remaining path without changes. +pub fn verbatim_path(path: &[u16]) -> Vec { + let (prefix, tail) = match path { + // Keep an existing extended-length or NT namespace. + [BACKSLASH, BACKSLASH, QUESTION, BACKSLASH, ..] + | [BACKSLASH, QUESTION, QUESTION, BACKSLASH, ..] => return path.to_vec(), + // C:\path => \\?\C:\path + [_, COLON, BACKSLASH, ..] => (VERBATIM_PREFIX, path), + // \\.\device => \\?\device + [BACKSLASH, BACKSLASH, DOT, BACKSLASH, tail @ ..] => (VERBATIM_PREFIX, tail), + // \\server\share => \\?\UNC\server\share + [BACKSLASH, BACKSLASH, tail @ ..] => (UNC_PREFIX, tail), + _ => return path.to_vec(), + }; + + let mut extended = Vec::with_capacity(prefix.len() + tail.len()); + extended.extend_from_slice(prefix); + extended.extend_from_slice(tail); + extended +} + +/// Return the end index of a parent directory. +/// Keep the separator when it is part of a Windows root. +/// +/// Without the separator, `C:\vp.exe` produces the drive-relative path `C:`. +/// Device roots such as `\\?\Volume{...}\vp.exe` have the same constraint. +/// Other parent paths omit the final separator, as `Path::parent` does. +pub fn parent_dir_len(path: &[u16], last_separator: usize) -> usize { + let drive_root = last_separator >= 1 && path[last_separator - 1] == COLON; + let device_root = last_separator >= 4 + && is_path_separator(path[0]) + && is_path_separator(path[1]) + && (path[2] == QUESTION || path[2] == DOT) + && is_path_separator(path[3]) + && !path[4..last_separator].iter().any(|&unit| is_path_separator(unit)); + + if last_separator == 0 || drive_root || device_root { + last_separator + 1 + } else { + last_separator + } +} + +/// Return the file-stem length, as `Path::file_stem` does. +/// The stem ends before the last `.`, but a leading `.` does not start an extension. +pub fn file_stem_len(name: &[u16]) -> usize { + match name.iter().skip(1).rposition(|&c| c == DOT) { + Some(pos) => pos + 1, + None => name.len(), + } +} + +/// Case-sensitive comparison of a UTF-16 slice against an ASCII string. +pub fn eq_ascii(wide: &[u16], ascii: &[u8]) -> bool { + wide.len() == ascii.len() && wide.iter().zip(ascii).all(|(&w, &a)| w == u16::from(a)) +} + +/// Format `value` as decimal ASCII in `buf`. +/// Return the used suffix. +pub fn format_u32(mut value: u32, buf: &mut [u8; 10]) -> &[u8] { + let mut i = buf.len(); + loop { + i -= 1; + buf[i] = b'0' + (value % 10) as u8; + value /= 10; + if value == 0 { + break; + } + } + &buf[i..] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wide(s: &str) -> Vec { + s.encode_utf16().collect() + } + + #[test] + fn skips_unquoted_program() { + let cl = wide(r"C:\bin\node.exe --version"); + assert_eq!(&cl[skip_program_argument(&cl)..], &wide(" --version")[..]); + } + + #[test] + fn skips_quoted_program_with_spaces() { + let cl = wide(r#""C:\Program Files\node.exe" -e "1 + 1""#); + assert_eq!(&cl[skip_program_argument(&cl)..], &wide(r#" -e "1 + 1""#)[..]); + } + + #[test] + fn treats_leading_whitespace_as_an_empty_program() { + for cl in [wide(" script.js --flag"), wide("\tscript.js --flag")] { + assert_eq!(skip_program_argument(&cl), 0); + } + } + + #[test] + fn skips_bare_program() { + let cl = wide("node"); + assert_eq!(skip_program_argument(&cl), cl.len()); + assert_eq!(skip_program_argument(&[]), 0); + } + + #[test] + fn keeps_argument_tail_verbatim() { + let cl = wide(r#"npx "a b\" literal" --flag"#); + assert_eq!(&cl[skip_program_argument(&cl)..], &wide(r#" "a b\" literal" --flag"#)[..]); + } + + #[test] + fn parent_directory_preserves_windows_roots() { + fn parent(path: &str) -> Vec { + let path = wide(path); + let last_separator = path.iter().rposition(|&unit| is_path_separator(unit)).unwrap(); + path[..parent_dir_len(&path, last_separator)].to_vec() + } + + assert_eq!(parent(r"C:\vp.exe"), wide("C:\\")); + assert_eq!(parent(r"C:\bin\vp.exe"), wide(r"C:\bin")); + assert_eq!(parent(r"\\?\C:\vp.exe"), wide("\\\\?\\C:\\")); + assert_eq!( + parent(r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\vp.exe"), + wide("\\\\?\\Volume{01234567-89ab-cdef-0123-456789abcdef}\\") + ); + assert_eq!(parent(r"\\?\UNC\server\share\vp.exe"), wide(r"\\?\UNC\server\share")); + assert_eq!(parent(r"\\server\share\vp.exe"), wide(r"\\server\share")); + assert_eq!(parent(r"\vp.exe"), wide("\\")); + } + + #[test] + fn prefixes_normalized_absolute_paths_for_win32() { + assert_eq!(verbatim_path(&wide(r"C:\data\vp.exe")), wide(r"\\?\C:\data\vp.exe")); + assert_eq!( + verbatim_path(&wide(r"\\server\share\vp.exe")), + wide(r"\\?\UNC\server\share\vp.exe") + ); + assert_eq!( + verbatim_path(&wide(r"\\.\Volume{123}\vp.exe")), + wide(r"\\?\Volume{123}\vp.exe") + ); + } + + #[test] + fn keeps_existing_namespaces_and_relative_paths() { + for path in [r"\\?\C:\data\vp.exe", r"\??\C:\data\vp.exe", r"data\vp.exe"] { + assert_eq!(verbatim_path(&wide(path)), wide(path)); + } + } + + #[test] + fn file_stem_matches_path_file_stem() { + assert_eq!(file_stem_len(&wide("node.exe")), 4); + assert_eq!(file_stem_len(&wide("node")), 4); + assert_eq!(file_stem_len(&wide("NODE.EXE")), 4); + assert_eq!(file_stem_len(&wide("a.b.exe")), 3); + assert_eq!(file_stem_len(&wide(".hidden")), 7); + assert_eq!(file_stem_len(&wide("node.")), 4); + } + + #[test] + fn eq_ascii_is_exact() { + assert!(eq_ascii(&wide("vp"), b"vp")); + assert!(!eq_ascii(&wide("VP"), b"vp")); + assert!(!eq_ascii(&wide("vpx"), b"vp")); + } + + #[test] + fn formats_decimal() { + let mut buf = [0u8; 10]; + assert_eq!(format_u32(0, &mut buf), b"0"); + let mut buf = [0u8; 10]; + assert_eq!(format_u32(203, &mut buf), b"203"); + let mut buf = [0u8; 10]; + assert_eq!(format_u32(u32::MAX, &mut buf), b"4294967295"); + } + + #[test] + fn parses_versioned_shim_pointers() { + assert_eq!( + parse_shim_pointer( + b"vite-plus-shim-v1\nlayout=single-root\ndata=C:\\vp\ncache=C:\\cache\n" + ), + Some(ShimPointer { data: r"C:\vp", layout: ShimLayout::SingleRoot }) + ); + assert_eq!( + parse_shim_pointer( + b"\xEF\xBB\xBFvite-plus-shim-v1\r\nlayout=split\r\ndata=D:\\data\r\ncache=C:\\cache\r\n", + ), + Some(ShimPointer { + data: r"D:\data", + layout: ShimLayout::Split { cache: r"C:\cache" }, + }) + ); + } + + #[test] + fn rejects_invalid_shim_pointers() { + assert_eq!(parse_shim_pointer(b""), None); + assert_eq!(parse_shim_pointer(b"\xff"), None); + assert_eq!(parse_shim_pointer(b" C:\\vite-plus\\data\r\n"), None); + assert_eq!(parse_shim_pointer(b"vite-plus-shim-v1\nlayout=split\ndata=C:\\data\n"), None); + assert_eq!( + parse_shim_pointer( + b"vite-plus-shim-v1\nlayout=unknown\ndata=C:\\data\ncache=C:\\cache\n", + ), + None + ); + } +} diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index 8c8a17cbda..317e8393ed 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -1,386 +1,332 @@ -//! Minimal Windows trampoline for vite-plus shims. +//! Minimal Windows trampoline for Vite+ shims. //! -//! Vite+ copies and renames this binary for each shim tool, such as `node.exe` -//! and `npm.exe`. The trampoline gets the tool name from its filename. It then -//! starts `vp.exe` with the `VP_SHIM_TOOL` environment variable. This variable -//! puts `vp.exe` in shim dispatch mode. +//! Vite+ copies and renames this binary for each shim tool. +//! Examples include `node.exe` and `npm.exe`. +//! The trampoline reads the tool name from its file name. +//! It reads the install roots from the adjacent `.shim` sidecar. +//! It sets the dispatch environment for that tool. +//! It starts the active `vp.exe`. //! //! The trampoline ignores Ctrl+C because the child process handles it. This //! prevents the termination prompt that `.cmd` wrappers produce. //! -//! **Size optimization:** `core::fmt` adds approximately 100 KB. This binary -//! does not use `format!`, `eprintln!`, `println!`, or `.unwrap()`. Each error -//! path calls `process::exit(1)` directly. +//! On Windows, `#![no_main]` and raw Win32 calls omit the CRT startup. +//! They also omit the `std::process::Command` implementation. +//! The standalone build recompiles `std` for size. +//! It uses immediate-abort panics. +//! Failure messages include the operation, path, and Windows error code. +//! See `rfcs/trampoline-exe-for-shims.md`. +//! +//! The non-Windows implementation exists for portable tests. +//! Vite+ does not ship this binary for Unix shims because they are symlinks. //! //! See: -use std::{ - env, - 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) -} +#![cfg_attr(windows, no_main)] +#![cfg_attr(windows, windows_subsystem = "console")] -/// Must match [`vp_shared::SHIM_POINTER_EXTENSION`]. Keep a local copy so this -/// binary has no dependency on `vp_shared`. Each trampoline reads -/// `.shim` next to itself. For example, `node.exe` reads `node.shim`. -const SHIM_POINTER_EXTENSION: &str = "shim"; -/// Must match [`vp_shared::SHIM_POINTER_HEADER`]. -const SHIM_POINTER_HEADER: &str = "vite-plus-shim-v1"; +#[cfg_attr(not(windows), allow(dead_code))] +mod cmdline; +#[cfg(windows)] +mod win; -enum ShimLayout { - SingleRoot, - Split { cache: std::path::PathBuf }, +/// The linker uses this symbol as the console entry point. +/// Thus, the build does not need an `/ENTRY:` flag. +/// The `std` runtime does not initialize. See win.rs. +#[cfg(windows)] +#[unsafe(no_mangle)] +#[allow(non_snake_case)] +pub extern "C" fn mainCRTStartup() -> ! { + win::run() } -struct ShimPointer { - data: std::path::PathBuf, - layout: ShimLayout, +#[cfg(not(windows))] +fn main() { + portable::run(); } -struct VpLocation { - exe: std::path::PathBuf, - pointer: ShimPointer, -} +#[cfg(any(not(windows), test))] +#[cfg_attr(windows, allow(dead_code))] +mod portable { + use std::{ + env, + path::{Path, PathBuf}, + process::{self, Command, ExitStatus}, + }; -/// How the child `vp.exe` should resolve category roots. -enum ChildDirPins<'a> { - /// `VP_HOME` or a grandfathered install explicitly selected one root. - SingleRoot, - /// The versioned sidecar explicitly selected split roots. - Split { cache: &'a std::path::Path }, -} + use crate::cmdline::{self, ShimLayout as ParsedShimLayout}; -fn child_dir_pins(pointer: &ShimPointer) -> ChildDirPins<'_> { - match &pointer.layout { - ShimLayout::SingleRoot => ChildDirPins::SingleRoot, - ShimLayout::Split { cache } => ChildDirPins::Split { cache }, + enum ShimLayout { + SingleRoot, + Split { cache: PathBuf }, } -} -/// Locate `vp.exe` from `/.shim`. -/// -/// `EnvConfig` in the child `vp.exe` owns the directory variables. This binary -/// must not read `VP_HOME` or `VP_*_DIR`. Installation and `vp env setup` write -/// a sidecar for each trampoline copy. Thus, this function does not check -/// sibling layout paths. -fn resolve_vp_exe(exe_path: &std::path::Path) -> Option { - let pointer = read_shim_pointer(exe_path)?; - let exe = pointer.data.join("current").join("bin").join("vp.exe"); - exe.exists().then_some(VpLocation { exe, pointer }) -} - -fn read_shim_pointer(exe_path: &std::path::Path) -> Option { - let bytes = std::fs::read(exe_path.with_extension(SHIM_POINTER_EXTENSION)).ok()?; - let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes.as_slice()); - let text = std::str::from_utf8(bytes).ok()?.trim(); - if text.is_empty() { - return None; + struct ShimPointer { + data: PathBuf, + layout: ShimLayout, } - let mut lines = text.lines(); - if lines.next()? != SHIM_POINTER_HEADER { - return None; + + struct VpLocation { + exe: PathBuf, + pointer: ShimPointer, } - let mut layout = None; - let mut data = None; - let mut cache = None; - for line in lines { - if let Some(value) = line.strip_prefix("layout=") { - layout = Some(value); - } else if let Some(value) = line.strip_prefix("data=") { - data = (!value.is_empty()).then(|| std::path::PathBuf::from(value)); - } else if let Some(value) = line.strip_prefix("cache=") { - cache = (!value.is_empty()).then(|| std::path::PathBuf::from(value)); + /// Return a Unix signal exit code with 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) } - let data = data?; - let layout = match layout? { - "single-root" => ShimLayout::SingleRoot, - "split" => ShimLayout::Split { cache: cache? }, - _ => return None, - }; - Some(ShimPointer { data, layout }) -} - -fn main() { - // 1. Determine tool name from our own executable filename - let exe_path = env::current_exe().unwrap_or_else(|_| process::exit(1)); - let tool_name = - exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1)); - - // 2. Locate vp.exe via `.shim` (written next to every trampoline). - let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); - let Some(location) = resolve_vp_exe(&exe_path) else { - use std::io::Write; - let stderr = std::io::stderr(); - let mut handle = stderr.lock(); - let _ = handle.write_all(b"vite-plus: could not locate vp.exe through .shim\n"); - process::exit(1); - }; - // 3. Install a Ctrl+C handler that ignores the signal. The child handles - // the signal. This prevents the termination prompt from cmd.exe. - #[cfg(windows)] - install_ctrl_handler(); - - // 4. Spawn vp.exe - // - Single root: set VP_HOME. - // - Split: clear VP_HOME and pin VP_DATA_DIR / VP_BIN_DIR / VP_CACHE_DIR. - // - If tool is "vp", run in normal CLI mode (no VP_SHIM_TOOL) - // - Otherwise, set VP_SHIM_TOOL so vp.exe enters shim dispatch - let mut cmd = Command::new(&location.exe); - cmd.args(env::args_os().skip(1)); - match child_dir_pins(&location.pointer) { - ChildDirPins::SingleRoot => { - cmd.env("VP_HOME", &location.pointer.data); - } - ChildDirPins::Split { cache } => { - cmd.env_remove("VP_HOME"); - cmd.env("VP_DATA_DIR", &location.pointer.data); - cmd.env("VP_BIN_DIR", bin_dir); - cmd.env("VP_CACHE_DIR", cache); - } + /// Locate `vp.exe` from `/.shim`. + fn resolve_vp_exe(exe_path: &Path) -> Option { + let pointer = read_shim_pointer(exe_path)?; + let exe = pointer.data.join("current").join("bin").join("vp.exe"); + exe.exists().then_some(VpLocation { exe, pointer }) } - if tool_name != "vp" { - cmd.env("VP_SHIM_TOOL", tool_name); - // Clear the recursion marker before a nested shim call, such as npm - // starting node. The nested shim must resolve the version again instead - // of using passthrough mode. Old .cmd wrappers used `vp env exec`, which - // cleared this marker in exec.rs. The trampoline does not use that path. - // Must match vp_shared::env_vars::VP_TOOL_RECURSION - cmd.env_remove("VP_TOOL_RECURSION"); + fn read_shim_pointer(exe_path: &Path) -> Option { + let bytes = std::fs::read(exe_path.with_extension("shim")).ok()?; + let parsed = cmdline::parse_shim_pointer(&bytes)?; + let layout = match parsed.layout { + ParsedShimLayout::SingleRoot => ShimLayout::SingleRoot, + ParsedShimLayout::Split { cache } => ShimLayout::Split { cache: PathBuf::from(cache) }, + }; + Some(ShimPointer { data: PathBuf::from(parsed.data), layout }) } - // 5. Execute and propagate exit code. - // Use write_all instead of eprintln!/format! to avoid pulling in core::fmt (~100KB). - match cmd.status() { - Ok(status) => process::exit(exit_code_from_status(status)), - Err(_) => { + pub fn run() { + // 1. Determine the tool name from our own executable filename. + let exe_path = env::current_exe().unwrap_or_else(|_| process::exit(1)); + let tool_name = + exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1)); + + // 2. Locate vp.exe via `.shim` (written next to every trampoline). + let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); + let Some(location) = resolve_vp_exe(&exe_path) else { use std::io::Write; let stderr = std::io::stderr(); let mut handle = stderr.lock(); - let _ = handle.write_all(b"vite-plus: could not execute "); - let _ = handle.write_all(location.exe.as_os_str().as_encoded_bytes()); - let _ = handle.write_all(b"\n"); + let _ = handle.write_all(b"vite-plus: could not locate vp.exe through .shim\n"); process::exit(1); + }; + + // 3. Spawn vp.exe with the directory layout pinned by the sidecar. + let mut cmd = Command::new(&location.exe); + cmd.args(env::args_os().skip(1)); + match &location.pointer.layout { + ShimLayout::SingleRoot => { + cmd.env("VP_HOME", &location.pointer.data); + } + ShimLayout::Split { cache } => { + cmd.env_remove("VP_HOME"); + cmd.env("VP_DATA_DIR", &location.pointer.data); + cmd.env("VP_BIN_DIR", bin_dir); + cmd.env("VP_CACHE_DIR", cache); + } } - } -} -#[cfg(all(test, unix))] -mod tests { - use super::*; + if tool_name != "vp" { + cmd.env("VP_SHIM_TOOL", tool_name); + // A nested shim must resolve the version again. + // It must not use passthrough mode. + // This name must match vp_shared::env_vars::VP_TOOL_RECURSION. + cmd.env_remove("VP_TOOL_RECURSION"); + } - #[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); + // 4. Execute and propagate the exit code. + match cmd.status() { + Ok(status) => process::exit(exit_code_from_status(status)), + Err(_) => { + use std::io::Write; + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_all(b"vite-plus: could not execute "); + let _ = handle.write_all(location.exe.as_os_str().as_encoded_bytes()); + let _ = handle.write_all(b"\n"); + process::exit(1); + } + } } -} -#[cfg(test)] -mod resolve_tests { - use std::{fs, path::Path}; + #[cfg(test)] + mod tests { + use std::{fs, path::Path}; - use super::*; + use super::*; - fn write_exe(path: &Path) { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); + fn write_exe(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"").unwrap(); } - fs::write(path, b"").unwrap(); - } - - fn versioned_pointer(layout: &str, data: &Path, cache: &Path) -> String { - format!( - "{SHIM_POINTER_HEADER}\nlayout={layout}\ndata={}\ncache={}\n", - data.display(), - cache.display() - ) - } - - #[test] - fn missing_pointer_does_not_probe_sibling_layout() { - let root = std::env::temp_dir().join(format!("vp-trampoline-no-ptr-{}", process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(root.join("bin")).unwrap(); - write_exe(&root.join("current").join("bin").join("vp.exe")); - write_exe(&root.join("data").join("current").join("bin").join("vp.exe")); - assert!(resolve_vp_exe(&root.join("bin").join("vp.exe")).is_none()); - let _ = fs::remove_dir_all(&root); - } + fn versioned_pointer(layout: &str, data: &Path, cache: &Path) -> String { + format!( + "{}\nlayout={layout}\ndata={}\ncache={}\n", + cmdline::SHIM_POINTER_HEADER, + data.display(), + cache.display() + ) + } - #[test] - fn pointer_without_payload_is_none() { - let root = std::env::temp_dir().join(format!("vp-trampoline-empty-{}", process::id())); - let _ = fs::remove_dir_all(&root); - let bin = root.join("bin"); - let data = root.join("data-root"); - fs::create_dir_all(&bin).unwrap(); - fs::create_dir_all(&data).unwrap(); - fs::write(bin.join("vp.shim"), versioned_pointer("split", &data, &root.join("cache"))) - .unwrap(); + #[test] + #[cfg(unix)] + 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); + } - assert!(resolve_vp_exe(&bin.join("vp.exe")).is_none()); - let _ = fs::remove_dir_all(&root); - } + #[test] + fn missing_pointer_does_not_probe_sibling_layout() { + let root = env::temp_dir().join(format!("vp-trampoline-no-ptr-{}", process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("bin")).unwrap(); + write_exe(&root.join("current").join("bin").join("vp.exe")); + write_exe(&root.join("data").join("current").join("bin").join("vp.exe")); - #[test] - fn pointer_file_locates_data_root() { - let root = std::env::temp_dir().join(format!("vp-trampoline-ptr-{}", process::id())); - let _ = fs::remove_dir_all(&root); - let bin = root.join("custom-bin"); - let data = root.join("custom-data"); - fs::create_dir_all(&bin).unwrap(); - write_exe(&data.join("current").join("bin").join("vp.exe")); - write_exe(&root.join("data").join("current").join("bin").join("vp.exe")); - fs::write(bin.join("vp.shim"), versioned_pointer("split", &data, &root.join("cache"))) - .unwrap(); + assert!(resolve_vp_exe(&root.join("bin").join("vp.exe")).is_none()); + let _ = fs::remove_dir_all(&root); + } - let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); - assert_eq!(location.exe, data.join("current").join("bin").join("vp.exe")); - assert_eq!(location.pointer.data, data); - assert!(matches!(location.pointer.layout, ShimLayout::Split { .. })); - let _ = fs::remove_dir_all(&root); - } + #[test] + fn pointer_without_payload_is_none() { + let root = env::temp_dir().join(format!("vp-trampoline-empty-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("bin"); + let data = root.join("data-root"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(&data).unwrap(); + fs::write(bin.join("vp.shim"), versioned_pointer("split", &data, &root.join("cache"))) + .unwrap(); + + assert!(resolve_vp_exe(&bin.join("vp.exe")).is_none()); + let _ = fs::remove_dir_all(&root); + } - #[test] - fn unversioned_pointer_is_rejected() { - let root = - std::env::temp_dir().join(format!("vp-trampoline-unversioned-{}", process::id())); - let _ = fs::remove_dir_all(&root); - let bin = root.join("bin"); - let data = root.join("data"); - fs::create_dir_all(&bin).unwrap(); - write_exe(&data.join("current").join("bin").join("vp.exe")); - fs::write(bin.join("vp.shim"), format!("{}\n", data.display())).unwrap(); - - assert!(resolve_vp_exe(&bin.join("vp.exe")).is_none()); - let _ = fs::remove_dir_all(&root); - } + #[test] + fn pointer_file_locates_data_root() { + let root = env::temp_dir().join(format!("vp-trampoline-ptr-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("custom-bin"); + let data = root.join("custom-data"); + fs::create_dir_all(&bin).unwrap(); + write_exe(&data.join("current").join("bin").join("vp.exe")); + fs::write(bin.join("vp.shim"), versioned_pointer("split", &data, &root.join("cache"))) + .unwrap(); + + let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); + assert_eq!(location.exe, data.join("current").join("bin").join("vp.exe")); + assert_eq!(location.pointer.data, data); + assert!(matches!(location.pointer.layout, ShimLayout::Split { .. })); + let _ = fs::remove_dir_all(&root); + } - #[test] - fn pointer_file_is_per_exe_name() { - let root = std::env::temp_dir().join(format!("vp-trampoline-per-exe-{}", process::id())); - let _ = fs::remove_dir_all(&root); - let bin = root.join("bin"); - let node_data = root.join("node-data"); - let decoy_data = root.join("decoy-data"); - fs::create_dir_all(&bin).unwrap(); - write_exe(&node_data.join("current").join("bin").join("vp.exe")); - write_exe(&decoy_data.join("current").join("bin").join("vp.exe")); - fs::write( - bin.join("vp.shim"), - versioned_pointer("split", &decoy_data, &root.join("cache")), - ) - .unwrap(); - fs::write( - bin.join("node.shim"), - versioned_pointer("split", &node_data, &root.join("cache")), - ) - .unwrap(); - - let location = resolve_vp_exe(&bin.join("node.exe")).unwrap(); - assert_eq!(location.exe, node_data.join("current").join("bin").join("vp.exe")); - assert_eq!(location.pointer.data, node_data); - let _ = fs::remove_dir_all(&root); - } + #[test] + fn unversioned_pointer_is_rejected() { + let root = env::temp_dir().join(format!("vp-trampoline-unversioned-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("bin"); + let data = root.join("data"); + fs::create_dir_all(&bin).unwrap(); + write_exe(&data.join("current").join("bin").join("vp.exe")); + fs::write(bin.join("vp.shim"), format!("{}\n", data.display())).unwrap(); + + assert!(resolve_vp_exe(&bin.join("vp.exe")).is_none()); + let _ = fs::remove_dir_all(&root); + } - #[test] - fn pointer_file_ignores_utf8_bom_and_crlf() { - let root = std::env::temp_dir().join(format!("vp-trampoline-bom-{}", process::id())); - let _ = fs::remove_dir_all(&root); - let bin = root.join("bin"); - let data = root.join("data-root"); - fs::create_dir_all(&bin).unwrap(); - write_exe(&data.join("current").join("bin").join("vp.exe")); - let mut contents = vec![0xEF, 0xBB, 0xBF]; - contents.extend_from_slice( - versioned_pointer("split", &data, &root.join("cache")).replace('\n', "\r\n").as_bytes(), - ); - fs::write(bin.join("vp.shim"), contents).unwrap(); - - let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); - assert_eq!(location.exe, data.join("current").join("bin").join("vp.exe")); - assert_eq!(location.pointer.data, data); - assert!(matches!(location.pointer.layout, ShimLayout::Split { .. })); - let _ = fs::remove_dir_all(&root); - } + #[test] + fn pointer_file_is_per_exe_name() { + let root = env::temp_dir().join(format!("vp-trampoline-per-exe-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("bin"); + let node_data = root.join("node-data"); + let decoy_data = root.join("decoy-data"); + fs::create_dir_all(&bin).unwrap(); + write_exe(&node_data.join("current").join("bin").join("vp.exe")); + write_exe(&decoy_data.join("current").join("bin").join("vp.exe")); + fs::write( + bin.join("vp.shim"), + versioned_pointer("split", &decoy_data, &root.join("cache")), + ) + .unwrap(); + fs::write( + bin.join("node.shim"), + versioned_pointer("split", &node_data, &root.join("cache")), + ) + .unwrap(); - #[test] - fn explicit_split_does_not_become_single_root_when_bin_is_under_data() { - let root = std::env::temp_dir().join(format!("vp-trampoline-split-{}", process::id())); - let _ = fs::remove_dir_all(&root); - let data = root.join("data"); - let bin = data.join("bin"); - let cache = root.join("platform-cache"); - write_exe(&data.join("current").join("bin").join("vp.exe")); - fs::create_dir_all(&bin).unwrap(); - fs::write(bin.join("vp.shim"), versioned_pointer("split", &data, &cache)).unwrap(); - - let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); - assert!(matches!( - child_dir_pins(&location.pointer), - ChildDirPins::Split { cache: value } if value == cache - )); - let _ = fs::remove_dir_all(&root); - } + let location = resolve_vp_exe(&bin.join("node.exe")).unwrap(); + assert_eq!(location.exe, node_data.join("current").join("bin").join("vp.exe")); + assert_eq!(location.pointer.data, node_data); + let _ = fs::remove_dir_all(&root); + } - #[test] - fn explicit_single_root_sets_vp_home() { - let root = - std::env::temp_dir().join(format!("vp-trampoline-single-root-{}", process::id())); - let _ = fs::remove_dir_all(&root); - let bin = root.join("bin"); - write_exe(&root.join("current").join("bin").join("vp.exe")); - fs::create_dir_all(&bin).unwrap(); - fs::write( - bin.join("vp.shim"), - versioned_pointer("single-root", &root, &root.join("cache")), - ) - .unwrap(); - - let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); - assert!(matches!(child_dir_pins(&location.pointer), ChildDirPins::SingleRoot)); - let _ = fs::remove_dir_all(&root); - } -} + #[test] + fn pointer_file_ignores_utf8_bom_and_crlf() { + let root = env::temp_dir().join(format!("vp-trampoline-bom-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("bin"); + let data = root.join("data-root"); + fs::create_dir_all(&bin).unwrap(); + write_exe(&data.join("current").join("bin").join("vp.exe")); + let mut contents = vec![0xEF, 0xBB, 0xBF]; + contents.extend_from_slice( + versioned_pointer("split", &data, &root.join("cache")) + .replace('\n', "\r\n") + .as_bytes(), + ); + fs::write(bin.join("vp.shim"), contents).unwrap(); + + let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); + assert_eq!(location.exe, data.join("current").join("bin").join("vp.exe")); + assert_eq!(location.pointer.data, data); + assert!(matches!(location.pointer.layout, ShimLayout::Split { .. })); + let _ = fs::remove_dir_all(&root); + } -/// 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 -/// console group. By returning TRUE (1), we tell Windows we handled the event -/// (by ignoring it). The child process also receives the event and can -/// decide how to respond (typically by exiting gracefully). -/// -/// This is the same pattern used by uv-trampoline and Python's distlib launcher. -#[cfg(windows)] -fn install_ctrl_handler() { - // Raw FFI declaration to avoid pulling in the heavy `windows`/`windows-core` crates. - // Signature: https://learn.microsoft.com/en-us/windows/console/setconsolectrlhandler - type HandlerRoutine = unsafe extern "system" fn(ctrl_type: u32) -> i32; - unsafe extern "system" { - fn SetConsoleCtrlHandler(handler: Option, add: i32) -> i32; - } + #[test] + fn explicit_split_does_not_become_single_root_when_bin_is_under_data() { + let root = env::temp_dir().join(format!("vp-trampoline-split-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let data = root.join("data"); + let bin = data.join("bin"); + let cache = root.join("platform-cache"); + write_exe(&data.join("current").join("bin").join("vp.exe")); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join("vp.shim"), versioned_pointer("split", &data, &cache)).unwrap(); + + let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); + assert!(matches!( + location.pointer.layout, + ShimLayout::Split { cache: value } if value == cache + )); + let _ = fs::remove_dir_all(&root); + } - unsafe extern "system" fn handler(_ctrl_type: u32) -> i32 { - 1 // TRUE - signal handled (ignored) - } + #[test] + fn explicit_single_root_sets_vp_home() { + let root = env::temp_dir().join(format!("vp-trampoline-single-root-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("bin"); + write_exe(&root.join("current").join("bin").join("vp.exe")); + fs::create_dir_all(&bin).unwrap(); + fs::write( + bin.join("vp.shim"), + versioned_pointer("single-root", &root, &root.join("cache")), + ) + .unwrap(); - unsafe { - SetConsoleCtrlHandler(Some(handler), 1); + let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); + assert!(matches!(location.pointer.layout, ShimLayout::SingleRoot)); + let _ = fs::remove_dir_all(&root); + } } } diff --git a/crates/vp_trampoline/src/win.rs b/crates/vp_trampoline/src/win.rs new file mode 100644 index 0000000000..26faa1a869 --- /dev/null +++ b/crates/vp_trampoline/src/win.rs @@ -0,0 +1,588 @@ +//! Raw Win32 trampoline implementation. +//! +//! The `#![no_main]` entry point calls this module directly. +//! Thus, the CRT startup and the Rust `std` runtime do not initialize. +//! This module uses KERNEL32 calls for all operating-system operations. +//! These operations include file I/O, environment setup, and process control. +//! `Vec` uses the Windows process heap and does not need runtime initialization. + +use core::{ffi::c_void, ptr}; + +use crate::cmdline::{self, ShimLayout}; + +type Handle = *mut c_void; + +const CP_UTF8: u32 = 65001; +const BACKSLASH: u16 = b'\\' as u16; +const QUESTION: u16 = b'?' as u16; +const GENERIC_READ: u32 = 0x8000_0000; +const FILE_SHARE_READ: u32 = 0x0000_0001; +const FILE_SHARE_WRITE: u32 = 0x0000_0002; +const FILE_SHARE_DELETE: u32 = 0x0000_0004; +const OPEN_EXISTING: u32 = 3; +const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080; +const INFINITE: u32 = 0xFFFF_FFFF; +const STD_ERROR_HANDLE: u32 = -12i32 as u32; +const STARTF_USESTDHANDLES: u32 = 0x0000_0100; +const HANDLE_FLAG_INHERIT: u32 = 0x0000_0001; +const WAIT_OBJECT_0: u32 = 0; +const WAIT_FAILED: u32 = 0xFFFF_FFFF; +const ERROR_FILE_NOT_FOUND: u32 = 2; +const ERROR_PATH_NOT_FOUND: u32 = 3; +const ERROR_ENVVAR_NOT_FOUND: u32 = 203; +// Use the conservative threshold from Rust's Windows path handling. +// CreateDirectoryW reserves space below MAX_PATH. +// Thus, std normalizes paths at this length, even if another API accepts more units. +const LEGACY_MAX_PATH: usize = 248; +const MAX_SHIM_POINTER_BYTES: i64 = 1024 * 1024; +const INVALID_HANDLE_VALUE: Handle = -1isize as Handle; + +#[repr(C)] +struct StartupInfoW { + cb: u32, + reserved: *mut u16, + desktop: *mut u16, + title: *mut u16, + x: u32, + y: u32, + x_size: u32, + y_size: u32, + x_count_chars: u32, + y_count_chars: u32, + fill_attribute: u32, + flags: u32, + show_window: u16, + cb_reserved2: u16, + reserved2: *mut u8, + std_input: Handle, + std_output: Handle, + std_error: Handle, +} + +#[repr(C)] +struct ProcessInformation { + process: Handle, + thread: Handle, + process_id: u32, + thread_id: u32, +} + +type HandlerRoutine = unsafe extern "system" fn(ctrl_type: u32) -> i32; + +#[link(name = "kernel32")] +unsafe extern "system" { + fn GetModuleFileNameW(module: Handle, filename: *mut u16, size: u32) -> u32; + fn GetFullPathNameW( + file_name: *const u16, + buffer_length: u32, + buffer: *mut u16, + file_part: *mut *mut u16, + ) -> u32; + fn GetCommandLineW() -> *const u16; + fn GetLastError() -> u32; + fn CreateFileW( + file_name: *const u16, + desired_access: u32, + share_mode: u32, + security_attributes: *const c_void, + creation_disposition: u32, + flags_and_attributes: u32, + template_file: Handle, + ) -> Handle; + fn GetFileSizeEx(file: Handle, file_size: *mut i64) -> i32; + fn ReadFile( + file: Handle, + buffer: *mut u8, + bytes_to_read: u32, + bytes_read: *mut u32, + overlapped: *mut c_void, + ) -> i32; + fn SetEnvironmentVariableW(name: *const u16, value: *const u16) -> i32; + fn GetStartupInfoW(si: *mut StartupInfoW); + fn SetHandleInformation(object: Handle, mask: u32, flags: u32) -> i32; + fn CreateProcessW( + application_name: *const u16, + command_line: *mut u16, + process_attributes: *const c_void, + thread_attributes: *const c_void, + inherit_handles: i32, + creation_flags: u32, + environment: *const c_void, + current_directory: *const u16, + startup_info: *const StartupInfoW, + process_information: *mut ProcessInformation, + ) -> i32; + fn WaitForSingleObject(handle: Handle, milliseconds: u32) -> u32; + fn GetExitCodeProcess(process: Handle, exit_code: *mut u32) -> i32; + fn CloseHandle(handle: Handle) -> i32; + fn SetConsoleCtrlHandler(handler: Option, add: i32) -> i32; + fn GetStdHandle(std_handle: u32) -> Handle; + fn WriteFile( + handle: Handle, + buffer: *const u8, + bytes_to_write: u32, + bytes_written: *mut u32, + overlapped: *mut c_void, + ) -> i32; + fn WideCharToMultiByte( + codepage: u32, + flags: u32, + wide: *const u16, + wide_len: i32, + out: *mut u8, + out_len: i32, + default_char: *const u8, + used_default: *mut i32, + ) -> i32; + fn ExitProcess(exit_code: u32) -> !; +} + +// Current nightly toolchains register TLS cleanup through C `atexit`. +// The CRT implementation would add its startup code to this no_main binary. +// ExitProcess does not run TLS destructors. +// Thus, this process uses a successful no-op implementation. +#[unsafe(no_mangle)] +pub extern "C" fn atexit(_f: Option) -> i32 { + 0 +} + +/// NUL-terminated UTF-16 literal (compile-time, ASCII input only). +macro_rules! w { + ($s:literal) => {{ + const S: &str = $s; + const N: usize = S.len(); + const OUT: [u16; N + 1] = { + let mut out = [0u16; N + 1]; + let bytes = S.as_bytes(); + let mut i = 0; + while i < N { + out[i] = bytes[i] as u16; + i += 1; + } + out + }; + &OUT + }}; +} + +fn without_nul(wide: &[u16]) -> &[u16] { + &wide[..wide.len() - 1] +} + +fn nul_terminated(wide: &[u16]) -> Vec { + let mut out = Vec::with_capacity(wide.len() + 1); + out.extend_from_slice(wide); + out.push(0); + out +} + +fn is_separator(value: u16) -> bool { + value == b'\\' as u16 || value == b'/' as u16 +} + +fn join_path(base: &[u16], suffix: &[u16]) -> Vec { + let mut path = Vec::with_capacity(base.len() + suffix.len() + 1); + path.extend_from_slice(base); + if path.last().is_some_and(|&last| !is_separator(last)) { + path.push(b'\\' as u16); + } + path.extend_from_slice(suffix); + path +} + +fn is_verbatim(path: &[u16]) -> bool { + matches!( + path, + [BACKSLASH, BACKSLASH, QUESTION, BACKSLASH, ..] + | [BACKSLASH, QUESTION, QUESTION, BACKSLASH, ..] + ) +} + +/// Return a NUL-terminated path suitable for Win32 file and process APIs. +/// +/// This function matches the behavior of the replaced standard-library code. +/// It makes long paths absolute. +/// It normalizes the paths. +/// It then puts the paths in the extended-length namespace. +fn win32_api_path(path: &[u16]) -> Vec { + let path_nul = nul_terminated(path); + if is_verbatim(path) || path_nul.len() < LEGACY_MAX_PATH { + return path_nul; + } + + let required = + unsafe { GetFullPathNameW(path_nul.as_ptr(), 0, ptr::null_mut(), ptr::null_mut()) }; + if required == 0 { + fail_path_call(b"GetFullPathNameW", path, unsafe { GetLastError() }); + } + let mut absolute = Vec::with_capacity(required as usize); + let len = unsafe { + GetFullPathNameW(path_nul.as_ptr(), required, absolute.as_mut_ptr(), ptr::null_mut()) + }; + if len == 0 || len >= required { + fail_path_call(b"GetFullPathNameW", path, unsafe { GetLastError() }); + } + unsafe { absolute.set_len(len as usize) }; + + let mut extended = cmdline::verbatim_path(&absolute); + extended.push(0); + extended +} + +fn utf8_path(text: &str) -> Option> { + let mut path = Vec::with_capacity(text.len()); + for unit in text.encode_utf16() { + if unit == 0 { + return None; + } + path.push(unit); + } + (!path.is_empty()).then_some(path) +} + +// --------------------------------------------------------------------------- +// Diagnostics. Error paths are cold and avoid core::fmt. +// --------------------------------------------------------------------------- + +fn stderr_write(bytes: &[u8]) { + unsafe { + let stderr = GetStdHandle(STD_ERROR_HANDLE); + if !stderr.is_null() && stderr != INVALID_HANDLE_VALUE { + let mut written = 0u32; + WriteFile( + stderr, + bytes.as_ptr(), + bytes.len() as u32, + &raw mut written, + ptr::null_mut(), + ); + } + } +} + +/// Write a UTF-16 slice to stderr as UTF-8 (best effort). +fn stderr_write_wide(wide: &[u16]) { + if wide.is_empty() { + return; + } + let len = unsafe { + WideCharToMultiByte( + CP_UTF8, + 0, + wide.as_ptr(), + wide.len() as i32, + ptr::null_mut(), + 0, + ptr::null(), + ptr::null_mut(), + ) + }; + if len <= 0 { + stderr_write(b""); + return; + } + let mut utf8 = Vec::with_capacity(len as usize); + let written = unsafe { + WideCharToMultiByte( + CP_UTF8, + 0, + wide.as_ptr(), + wide.len() as i32, + utf8.as_mut_ptr(), + len, + ptr::null(), + ptr::null_mut(), + ) + }; + if written > 0 { + unsafe { utf8.set_len(written as usize) }; + stderr_write(&utf8); + } +} + +fn stderr_write_num(value: u32) { + let mut buf = [0u8; 10]; + stderr_write(cmdline::format_u32(value, &mut buf)); +} + +#[cold] +fn report_call_failure(what: &[u8], error: u32) { + stderr_write(b"vite-plus shim: "); + stderr_write(what); + stderr_write(b" failed (Windows error "); + stderr_write_num(error); + stderr_write(b")\n"); +} + +#[cold] +fn fail_call(what: &[u8]) -> ! { + report_call_failure(what, unsafe { GetLastError() }); + unsafe { ExitProcess(1) } +} + +#[cold] +fn fail_path_call(what: &[u8], path: &[u16], error: u32) -> ! { + stderr_write(b"vite-plus shim: "); + stderr_write(what); + stderr_write(b" failed for \""); + stderr_write_wide(path); + stderr_write(b"\" (Windows error "); + stderr_write_num(error); + stderr_write(b")\n"); + unsafe { ExitProcess(1) } +} + +#[cold] +fn fail_invalid_pointer(path: &[u16]) -> ! { + stderr_write(b"vite-plus shim: invalid or unsupported shim pointer \""); + stderr_write_wide(path); + stderr_write(b"\"; reinstall vite-plus or run `vp env setup`\n"); + unsafe { ExitProcess(1) } +} + +// --------------------------------------------------------------------------- +// Sidecar and path handling. +// --------------------------------------------------------------------------- + +fn module_path() -> Vec { + let mut buf = Vec::with_capacity(512); + loop { + let cap = buf.capacity(); + let len = unsafe { GetModuleFileNameW(ptr::null_mut(), buf.as_mut_ptr(), cap as u32) }; + if len == 0 { + fail_call(b"GetModuleFileNameW"); + } + if (len as usize) < cap { + unsafe { buf.set_len(len as usize) }; + return buf; + } + buf.reserve(cap * 2); + } +} + +fn pointer_path(exe: &[u16], last_separator: usize, file_name: &[u16]) -> Vec { + let stem_len = cmdline::file_stem_len(file_name); + let mut path = Vec::with_capacity(last_separator + stem_len + 6); + path.extend_from_slice(&exe[..last_separator + 1]); + path.extend_from_slice(&file_name[..stem_len]); + path.extend_from_slice(without_nul(w!(".shim"))); + path +} + +fn read_pointer_file(path: &[u16]) -> Vec { + let path_nul = win32_api_path(path); + let handle = unsafe { + CreateFileW( + path_nul.as_ptr(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + fail_path_call(b"CreateFileW", path, unsafe { GetLastError() }); + } + + let mut size = 0i64; + if unsafe { GetFileSizeEx(handle, &raw mut size) } == 0 { + let error = unsafe { GetLastError() }; + unsafe { CloseHandle(handle) }; + fail_path_call(b"GetFileSizeEx", path, error); + } + if !(1..=MAX_SHIM_POINTER_BYTES).contains(&size) { + unsafe { CloseHandle(handle) }; + fail_invalid_pointer(path); + } + + let mut bytes = Vec::with_capacity(size as usize); + let mut read = 0u32; + let ok = unsafe { + ReadFile(handle, bytes.as_mut_ptr(), size as u32, &raw mut read, ptr::null_mut()) + }; + if ok == 0 { + let error = unsafe { GetLastError() }; + unsafe { CloseHandle(handle) }; + fail_path_call(b"ReadFile", path, error); + } + unsafe { CloseHandle(handle) }; + if i64::from(read) != size { + fail_invalid_pointer(path); + } + unsafe { bytes.set_len(read as usize) }; + bytes +} + +// --------------------------------------------------------------------------- +// Process environment and launch. +// --------------------------------------------------------------------------- + +fn set_env(name: &[u16], name_ascii: &[u8], value: Option<&[u16]>) { + let value_nul = value.map(nul_terminated); + let value_ptr = value_nul.as_ref().map_or(ptr::null(), |value| value.as_ptr()); + let ok = unsafe { SetEnvironmentVariableW(name.as_ptr(), value_ptr) }; + if ok == 0 { + let error = unsafe { GetLastError() }; + if value_nul.is_none() && error == ERROR_ENVVAR_NOT_FOUND { + return; + } + stderr_write(b"vite-plus shim: SetEnvironmentVariableW("); + stderr_write(name_ascii); + stderr_write(b") failed (Windows error "); + stderr_write_num(error); + stderr_write(b")\n"); + unsafe { ExitProcess(1) } + } +} + +unsafe extern "system" fn ignore_ctrl(_ctrl_type: u32) -> i32 { + 1 +} + +pub fn run() -> ! { + // 1. Resolve the tool, bin directory, and per-tool sidecar from our path. + let exe = module_path(); + let Some(last_separator) = exe.iter().rposition(|&unit| is_separator(unit)) else { + stderr_write(b"vite-plus shim: cannot resolve the shim directory from \""); + stderr_write_wide(&exe); + stderr_write(b"\"\n"); + unsafe { ExitProcess(1) } + }; + let bin_dir = &exe[..cmdline::parent_dir_len(&exe, last_separator)]; + let file_name = &exe[last_separator + 1..]; + let tool = &file_name[..cmdline::file_stem_len(file_name)]; + let pointer_path = pointer_path(&exe, last_separator, file_name); + let pointer_bytes = read_pointer_file(&pointer_path); + let Some(parsed) = cmdline::parse_shim_pointer(&pointer_bytes) else { + fail_invalid_pointer(&pointer_path); + }; + let Some(data) = utf8_path(parsed.data) else { + fail_invalid_pointer(&pointer_path); + }; + + // 2. Pin the directory layout selected by the sidecar. + match parsed.layout { + ShimLayout::SingleRoot => { + set_env(w!("VP_HOME"), b"VP_HOME", Some(&data)); + } + ShimLayout::Split { cache } => { + let Some(cache) = utf8_path(cache) else { + fail_invalid_pointer(&pointer_path); + }; + set_env(w!("VP_HOME"), b"VP_HOME", None); + set_env(w!("VP_DATA_DIR"), b"VP_DATA_DIR", Some(&data)); + set_env(w!("VP_BIN_DIR"), b"VP_BIN_DIR", Some(bin_dir)); + set_env(w!("VP_CACHE_DIR"), b"VP_CACHE_DIR", Some(&cache)); + } + } + + if !cmdline::eq_ascii(tool, b"vp") { + set_env(w!("VP_SHIM_TOOL"), b"VP_SHIM_TOOL", Some(tool)); + set_env(w!("VP_TOOL_RECURSION"), b"VP_TOOL_RECURSION", None); + } + + // 3. Build the child command line from the active payload. + // Append the caller's raw argument tail without changes. + // This preserves the caller's quotation marks. + let vp_exe = join_path(&data, without_nul(w!("current\\bin\\vp.exe"))); + let vp_exe_nul = win32_api_path(&vp_exe); + let tail = unsafe { + let command_line = GetCommandLineW(); + if command_line.is_null() { + fail_call(b"GetCommandLineW"); + } + let mut len = 0usize; + while *command_line.add(len) != 0 { + len += 1; + } + let all = core::slice::from_raw_parts(command_line, len); + &all[cmdline::skip_program_argument(all)..] + }; + let mut child_cmdline = Vec::with_capacity(vp_exe.len() + tail.len() + 3); + child_cmdline.push(b'"' as u16); + child_cmdline.extend_from_slice(&vp_exe); + child_cmdline.push(b'"' as u16); + child_cmdline.extend_from_slice(tail); + child_cmdline.push(0); + + // 4. Ignore console control events in the trampoline. + // The child receives the same event. + // The child handles the event. + if unsafe { SetConsoleCtrlHandler(Some(ignore_ctrl), 1) } == 0 { + report_call_failure(b"warning: SetConsoleCtrlHandler", unsafe { GetLastError() }); + } + + // 5. Reuse the trampoline startup information. + // If the parent redirected standard I/O, make those handles inheritable. + // Do this before the CreateProcessW call. + let mut si = unsafe { core::mem::zeroed::() }; + si.cb = size_of::() as u32; + unsafe { GetStartupInfoW(&raw mut si) }; + if si.flags & STARTF_USESTDHANDLES != 0 { + for handle in [si.std_input, si.std_output, si.std_error] { + if !handle.is_null() + && handle != INVALID_HANDLE_VALUE + && unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } + == 0 + { + report_call_failure(b"warning: SetHandleInformation", unsafe { GetLastError() }); + } + } + } + + let mut pi = ProcessInformation { + process: ptr::null_mut(), + thread: ptr::null_mut(), + process_id: 0, + thread_id: 0, + }; + let ok = unsafe { + CreateProcessW( + vp_exe_nul.as_ptr(), + child_cmdline.as_mut_ptr(), + ptr::null(), + ptr::null(), + 1, + 0, + ptr::null(), + ptr::null(), + &raw const si, + &raw mut pi, + ) + }; + if ok == 0 { + let error = unsafe { GetLastError() }; + stderr_write(b"vite-plus: could not execute \""); + stderr_write_wide(&vp_exe); + stderr_write(b"\" (Windows error "); + stderr_write_num(error); + stderr_write(b")"); + if error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND { + stderr_write(b": vp.exe is missing; reinstall vite-plus or run `vp env setup`"); + } + stderr_write(b"\n"); + unsafe { ExitProcess(1) } + } + + // 6. Wait for the child. + // Propagate its exact exit code. + unsafe { + CloseHandle(pi.thread); + let wait = WaitForSingleObject(pi.process, INFINITE); + match wait { + WAIT_OBJECT_0 => {} + WAIT_FAILED => fail_call(b"WaitForSingleObject"), + _ => { + report_call_failure(b"WaitForSingleObject returned an unexpected status", wait); + ExitProcess(1); + } + } + let mut code = 1u32; + if GetExitCodeProcess(pi.process, &raw mut code) == 0 { + fail_call(b"GetExitCodeProcess"); + } + ExitProcess(code) + } +} diff --git a/justfile b/justfile index f241b9ea11..d2fed929b5 100644 --- a/justfile +++ b/justfile @@ -59,6 +59,7 @@ watch *args='': fmt: cargo shear --fix cargo fmt --all + cargo fmt --manifest-path crates/vp_trampoline/Cargo.toml pnpm fmt check: @@ -71,14 +72,18 @@ watch-check: # vite-plus-cli (lives outside crates/) to catch type sync issues. # vp_cli_snapshots is excluded: its suite needs a built global binary and # node, and runs via `just snapshot-test` instead. +# vp_trampoline is not a workspace member. +# On Unix, run its portable parser and layout tests separately. +# The Windows CLI snapshot suite tests Windows shim behavior. # Single source of truth for cargo test, used by CI too. [unix] test: - RUST_MIN_STACK=8388608 cargo test $(for d in crates/*/; do n=$(basename $d); [ "$n" = "vp_cli_snapshots" ] || echo -n "-p $n "; done) -p vite-plus-cli + RUST_MIN_STACK=8388608 cargo test $(for d in crates/*/; do n=$(basename $d); [ "$n" = "vp_cli_snapshots" ] || [ "$n" = "vp_trampoline" ] || echo -n "-p $n "; done) -p vite-plus-cli + cd crates/vp_trampoline && cargo test [windows] test: - $packages = Get-ChildItem -Path crates -Directory | Where-Object { $_.Name -ne 'vp_cli_snapshots' } | ForEach-Object { '-p'; $_.Name }; $Env:RUST_MIN_STACK='8388608'; $Env:__COMPAT_LAYER='RunAsInvoker'; cargo test @packages -p vite-plus-cli + $packages = Get-ChildItem -Path crates -Directory | Where-Object { $_.Name -ne 'vp_cli_snapshots' -and $_.Name -ne 'vp_trampoline' } | ForEach-Object { '-p'; $_.Name }; $Env:RUST_MIN_STACK='8388608'; $Env:__COMPAT_LAYER='RunAsInvoker'; cargo test @packages -p vite-plus-cli # PTY-based CLI snapshot tests (crates/vp_cli_snapshots). Builds the global # binary and shim template first so the runner never tests a stale build, and @@ -87,10 +92,16 @@ test: # `UPDATE_SNAPSHOTS=1 just snapshot-test`. Local-flavor cases additionally # need a built packages/cli (`pnpm build`); the runner fails fast when dist # is missing or stale. Use snapshot-test-global on checkouts without one. -snapshot-test *args='': _install_chromium - cargo build -p vp_global_cli -p vp_trampoline +snapshot-test *args='': _install_chromium _build-trampoline + cargo build -p vp_global_cli cargo test -p vp_cli_snapshots -- {{args}} +# The trampoline is not a workspace member. +# The helper runs Cargo from the crate directory so the build-std config applies. +# It resolves relative CARGO_TARGET_DIR values from the repository root. +_build-trampoline *args='': + node packages/tools/src/build-trampoline.ts {{args}} + # Browser-mode snapshot cases run with PLAYWRIGHT_BROWSERS_PATH=0, so the # browser must be installed into node_modules with the same setting. [unix] @@ -121,6 +132,7 @@ lint: -A clippy::redundant_else \ -A clippy::unused_async_trait_impl \ -A clippy::useless_borrows_in_formatting + cargo clippy --manifest-path crates/vp_trampoline/Cargo.toml --all-targets -- --deny warnings [unix] doc: diff --git a/package.json b/package.json index 3aa75c5396..2ff30ab5c5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "pnpm -F rolldown build-binding:release && pnpm -F rolldown build-node && pnpm -F vite build-types && pnpm -F @voidzero-dev/* -F vite-plus build", - "bootstrap-cli": "pnpm build && cargo build -p vp_global_cli -p vp_trampoline --release && pnpm install-global-cli", + "bootstrap-cli": "pnpm build && cargo build -p vp_global_cli --release && node packages/tools/src/build-trampoline.ts --release && pnpm install-global-cli", "bootstrap-cli:ci": "pnpm install-global-cli", "install-global-cli": "tool install-global-cli", "local-registry": "node packages/tools/src/local-npm-registry.ts", diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index 3b639ffe8c..b4eb1abad1 100644 --- a/packages/cli/publish-native-addons.ts +++ b/packages/cli/publish-native-addons.ts @@ -186,7 +186,7 @@ for (const napiTarget of pkg.napi.targets) { const shimSource = join(repoRoot, 'target', napiTarget, 'release', shimName); if (!existsSync(shimSource)) { console.error( - `Error: ${shimName} not found at ${shimSource}. Run "cargo build -p vp_trampoline --release --target ${napiTarget}" first.`, + `Error: ${shimName} does not exist at ${shimSource}. Run "node packages/tools/src/build-trampoline.ts --release --target ${napiTarget}" first.`, ); process.exit(1); } diff --git a/packages/tools/src/__tests__/build-trampoline.spec.ts b/packages/tools/src/__tests__/build-trampoline.spec.ts new file mode 100644 index 0000000000..9caeb0f072 --- /dev/null +++ b/packages/tools/src/__tests__/build-trampoline.spec.ts @@ -0,0 +1,40 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, test } from 'vitest'; + +import { resolveCargoArgs, resolveCargoTargetDir } from '../build-trampoline.ts'; + +const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)); + +describe('resolveCargoTargetDir', () => { + test('resolves relative paths from the repository root', () => { + expect(resolveCargoTargetDir('artifacts')).toBe(path.join(repoRoot, 'artifacts')); + }); + + test('uses the repository target directory by default', () => { + expect(resolveCargoTargetDir(undefined)).toBe(path.join(repoRoot, 'target')); + }); + + test('preserves absolute paths', () => { + const absolute = path.resolve(repoRoot, 'custom-artifacts'); + expect(resolveCargoTargetDir(absolute)).toBe(absolute); + }); +}); + +describe('resolveCargoArgs', () => { + test('uses cargo build by default', () => { + expect(resolveCargoArgs(['--release', '--target', 'x86_64-pc-windows-msvc'])).toEqual([ + 'build', + '--release', + '--target', + 'x86_64-pc-windows-msvc', + ]); + }); + + test('uses cargo xwin build when requested', () => { + expect(resolveCargoArgs(['--xwin', '--release', '--target', 'x86_64-pc-windows-msvc'])).toEqual( + ['xwin', 'build', '--release', '--target', 'x86_64-pc-windows-msvc'], + ); + }); +}); diff --git a/packages/tools/src/build-trampoline.ts b/packages/tools/src/build-trampoline.ts new file mode 100644 index 0000000000..ea34393fcd --- /dev/null +++ b/packages/tools/src/build-trampoline.ts @@ -0,0 +1,34 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); + +export function resolveCargoTargetDir(configured: string | undefined): string { + return path.resolve(repoRoot, configured || 'target'); +} + +export function resolveCargoArgs(args: string[]): string[] { + const xwin = args.includes('--xwin'); + const cargoArgs = args.filter((arg) => arg !== '--xwin'); + if (xwin) { + return ['xwin', 'build', ...cargoArgs]; + } + return ['build', ...cargoArgs]; +} + +export function buildTrampoline(args: string[] = process.argv.slice(2)): void { + const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; + execFileSync(cargo, resolveCargoArgs(args), { + cwd: path.join(repoRoot, 'crates/vp_trampoline'), + env: { + ...process.env, + CARGO_TARGET_DIR: resolveCargoTargetDir(process.env.CARGO_TARGET_DIR), + }, + stdio: 'inherit', + }); +} + +if (import.meta.main) { + buildTrampoline(); +} diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index e86c2bf577..23450c39d9 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -126,8 +126,8 @@ export function installGlobalCli() { if (isWindows) { const shimPath = path.join(path.dirname(binaryPath), 'vp-shim.exe'); if (!existsSync(shimPath)) { - console.error(`Error: vp-shim.exe not found at ${shimPath}`); - console.error('Build it with: cargo build -p vp_trampoline --release'); + console.error(`Error: vp-shim.exe does not exist at ${shimPath}`); + console.error('Build it with: node packages/tools/src/build-trampoline.ts --release'); process.exit(1); } } diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index 0048ec1ce4..582696a58b 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -124,56 +124,113 @@ layout and also records that Vite+ owns the adjacent executable. ``` crates/vp_trampoline/ -├── Cargo.toml # Zero external dependencies +├── Cargo.toml # Package settings and release profile +├── Cargo.lock # Lockfile for this standalone crate +├── .cargo/ +│ └── config.toml # build-std and artifact directory settings ├── src/ -│ └── main.rs # Sidecar parser, launcher, and portable tests +│ ├── main.rs # Entry points and portable implementation +│ ├── win.rs # Raw Win32 code for the no_main entry point +│ └── cmdline.rs # Portable parsers and tests ``` -### Trampoline Binary +The root `Cargo.toml` excludes this crate from the workspace. The crate must +stay outside the workspace for two reasons: -The trampoline has **zero external dependencies**. It declares the Win32 -`SetConsoleCtrlHandler` call inline to avoid the `windows` and `windows-core` -crates. It also avoids direct `core::fmt` use. It does not use `format!`, -`eprintln!`, `println!`, or `.unwrap()` in the production path. +- The release profile sets `panic = "immediate-abort"`. Cargo ignores `panic` + in per-package profile overrides. Thus, the crate needs a separate profile. +- The crate-local `.cargo/config.toml` enables build-std. Cargo reads this file + only when it runs from the crate directory. -The trampoline performs these steps: +From the repository root, run: -1. Read its executable path and get the tool name from the file stem. -2. Read the same-stem `.shim` file. Require the `vite-plus-shim-v1` header and - parse the layout, data root, and cache root. -3. Resolve the child executable as `/current/bin/vp.exe`. -4. Pin the recorded layout in the child environment. A `single-root` sidecar - sets `VP_HOME`. A `split` sidecar removes `VP_HOME` and sets `VP_DATA_DIR`, - `VP_BIN_DIR`, and `VP_CACHE_DIR`. -5. Install the Ctrl+C handler, start the child, and propagate its exit code. +```bash +node packages/tools/src/build-trampoline.ts --release [--target ] +``` -The trampoline fails if the sidecar is missing, malformed, unversioned, or -points to a missing payload. It does not infer the layout from directory paths. +The crate config stores artifacts in the repository `target/` directory. It +sets `target-dir = "../../target"`. CI and `install-global-cli` find +`vp-shim.exe` in the same directory as workspace binaries. The build uses the +pinned nightly toolchain and the `rust-src` component. The repository +`rust-toolchain.toml` supplies both items. -### Size Optimization +### Trampoline Binary -| Technique | Savings | Status | -| ------------------------------------------------------------------------------------- | -------------------------- | ------ | -| Zero external dependencies (raw FFI) | ~20KB (vs `windows` crate) | Done | -| No direct `core::fmt` usage (avoid `eprintln!`/`format!`/`.unwrap()`) | Marginal | Done | -| Workspace profile: `lto="fat"`, `codegen-units=1`, `strip="symbols"`, `panic="abort"` | Inherited | Done | -| Per-package `opt-level="z"` (optimize for size) | ~5-10% | Done | +The trampoline has no external dependencies. It declares all Win32 calls as +raw `extern "system"` functions from KERNEL32. Thus, it does not use the +`windows` or `windows-core` crate. It also does not use `core::fmt`. +Diagnostics use `WriteFile` and a small decimal formatter. + +On Windows, the binary uses `#![no_main]` and exports `mainCRTStartup`. Thus, +the CRT startup and the `std` runtime do not initialize. `src/win.rs` uses this +sequence: + +1. `GetModuleFileNameW` returns the shim path and tool name. The code replaces + the `.exe` extension with `.shim` to find the sidecar. +2. `CreateFileW` and `ReadFile` load the UTF-8 sidecar. `GetFullPathNameW` + makes long paths absolute. The code then adds the `\\?\` drive prefix or + the `\\?\UNC\` network prefix. The parser requires the versioned header. + It accepts the `single-root` and `split` layouts. +3. `SetEnvironmentVariableW` sets the directory layout. A single-root pointer + sets `VP_HOME`. A split pointer removes `VP_HOME`. It sets `VP_DATA_DIR`, + `VP_BIN_DIR`, and `VP_CACHE_DIR`. Tool shims also set `VP_SHIM_TOOL`. They + remove `VP_TOOL_RECURSION`. +4. The child command line starts with `"\current\bin\vp.exe"`. The code + appends the raw `GetCommandLineW` text after the program argument. It uses + the MSVC `argv[0]` rule. Quotation marks start or stop quoted mode. + Backslashes do not escape characters. This preserves the exact UTF-16 + argument text from the caller. +5. `SetConsoleCtrlHandler` installs a handler that ignores Ctrl+C and + Ctrl+Break. The child process handles these events. +6. `CreateProcessW` starts the child with inherited handles and startup + information. The payload and sidecar paths use the same extended-length + normalization. If the parent redirects standard I/O, the code makes the + standard handles inheritable. It does this before `CreateProcessW`, as + uv-trampoline and distlib do. +7. `WaitForSingleObject` waits for the child. `GetExitCodeProcess` reads its + exit code. `ExitProcess` returns that code without changes. + +For a critical launch failure, the trampoline reports the failed operation and +the applicable path. It includes the Windows error code when Windows supplies +one. If `vp.exe` is missing, it tells the user to reinstall Vite+ or run +`vp env setup`. + +The non-Windows implementation uses `std::process::Command`. Portable tests use +the same sidecar parser. Unix shims are symlinks and do not use this binary. +The parser rejects missing, malformed, and unversioned sidecars. It does not +infer a layout from directory paths. -**Binary size**: ~200KB on Windows. The floor is set by `std::process::Command` which internally pulls in `core::fmt` for error formatting regardless of whether our code uses it. Further reduction to ~40-50KB (matching uv-trampoline) would require replacing `Command` with raw `CreateProcessW` and using nightly Rust (see Future Optimizations). +### Size Optimization + +| Technique | Status | +| ------------------------------------------------------------------------ | ------ | +| Zero external dependencies (raw FFI, no `windows` crate) | Done | +| No `core::fmt` (diagnostics via `WriteFile` + manual decimal formatter) | Done | +| Own profile: `opt-level="z"`, `lto="fat"`, `codegen-units=1`, `strip` | Done | +| build-std: recompile `std` with this profile (`-Zbuild-std`) | Done | +| `panic = "immediate-abort"` (no panic formatting, unwinding, backtrace) | Done | +| `#![no_main]` + `mainCRTStartup` (no CRT startup, no `std` runtime init) | Done | +| Raw `CreateProcessW` instead of `std::process::Command` | Done | + +**Binary size**: 14,336 B on x86_64-pc-windows-msvc and +aarch64-pc-windows-msvc. This size includes the sidecar parser and diagnostics. +The x86_64 `std::process::Command` implementation was 221,696 B. See Size +Measurements and Build Constraints for all measurements. The executable imports +only KERNEL32. ### Environment Variables -The trampoline pins the selected directory layout before it starts `vp.exe`: +The sidecar controls the directory environment inherited by `vp.exe`: -| Variable | When | Purpose | -| ------------------- | -------------------------- | ------------------------------------------------------------------------------ | -| `VP_HOME` | `single-root` sidecar | Pins the data root as the single install root | -| `VP_HOME` | `split` sidecar | Removed so it cannot override the recorded split layout | -| `VP_DATA_DIR` | `split` sidecar | Pins the recorded data root | -| `VP_BIN_DIR` | `split` sidecar | Pins the trampoline executable directory | -| `VP_CACHE_DIR` | `split` sidecar | Pins the recorded cache root | -| `VP_SHIM_TOOL` | Tool shims only (not `vp`) | Tells `vp.exe` to enter shim dispatch mode for the named tool | -| `VP_TOOL_RECURSION` | Removed for tool shims | Clears the recursion marker for fresh version resolution in nested invocations | +| Variable | When | Trampoline action | +| ------------------- | ----------------------- | ------------------------------------------------------ | +| `VP_HOME` | Single-root layout | Sets all Vite+ directories from the sidecar data root | +| `VP_HOME` | Split layout | Removes the value so it cannot override separate roots | +| `VP_DATA_DIR` | Split layout | Sets the payload and state root | +| `VP_BIN_DIR` | Split layout | Sets the directory that contains the shim | +| `VP_CACHE_DIR` | Split layout | Sets the cache root | +| `VP_SHIM_TOOL` | Tool shims, except `vp` | Selects the named tool for shim dispatch | +| `VP_TOOL_RECURSION` | Tool shims | Removes the value so nested shims resolve versions | ### Ctrl+C Handling @@ -189,13 +246,15 @@ The trampoline installs a console control handler that returns `TRUE` (1): ### Integration with Shim Detection -`detect_shim_tool()` in `shim/mod.rs` checks `VP_SHIM_TOOL` env var **before** `argv[0]`: +`detect_shim_tool()` in `shim/mod.rs` checks `VP_SHIM_TOOL` before it checks +`argv[0]`: ``` -Trampoline (/node.exe + /node.shim) - → reads the recorded layout, data root, and cache root - → pins the layout, sets VP_SHIM_TOOL=node, and removes VP_TOOL_RECURSION - → spawns /current/bin/vp.exe with the original args +Trampoline (node.exe + node.shim) + → loads the recorded directory layout + → sets VP_SHIM_TOOL=node and the directory variables + → removes VP_TOOL_RECURSION + → spawns /current/bin/vp.exe with the original argument tail → detect_shim_tool() reads env var → "node" → dispatch("node", args) → resolves Node.js version, executes real node @@ -248,24 +307,24 @@ When installing a pre-trampoline version (no `vp-shim.exe` in the package): ## Comparison with uv-trampoline -| Aspect | uv-trampoline | vite-plus trampoline | -| ------------------- | ---------------------------------------- | --------------------------------- | -| **Purpose** | Launch Python with embedded script | Forward to `vp.exe` | -| **Complexity** | High (PE resources, zipimport) | Low (filename + spawn) | -| **Data embedding** | PE resources (kind, path, script ZIP) | Adjacent directory-layout sidecar | -| **Dependencies** | `windows` crate (unsafe, no CRT) | Zero (raw FFI declaration) | -| **Toolchain** | Nightly Rust (`panic="immediate-abort"`) | Stable Rust | -| **Binary size** | 39-47 KB | ~200 KB | -| **Entry point** | `#![no_main]` + `mainCRTStartup` | Standard `fn main()` | -| **Error output** | `ufmt` (no `core::fmt`) | `write_all` (no `core::fmt`) | -| **Ctrl+C handling** | `SetConsoleCtrlHandler` → ignore | Same approach | -| **Exit code** | `GetExitCodeProcess` → `exit()` | `Command::status()` → `exit()` | - -The vite-plus trampoline does not embed data in PE resources. It reads its own -filename and adjacent sidecar. It then resolves `vp.exe` under the recorded data -root. The ~150KB size difference from uv-trampoline comes from -`std::process::Command` (which internally pulls in `core::fmt`) versus raw -`CreateProcessW` with nightly-only `#![no_main]`. +| Aspect | uv-trampoline | vite-plus trampoline | +| ------------------- | ---------------------------------------- | ------------------------------------ | +| **Purpose** | Launch Python with embedded script | Forward to `vp.exe` | +| **Complexity** | High (PE resources, zipimport) | Low (filename + spawn) | +| **Data embedding** | PE resources (kind, path, script ZIP) | Adjacent directory-layout sidecar | +| **Dependencies** | `windows` crate (unsafe, no CRT) | None (raw FFI declarations) | +| **Toolchain** | Nightly Rust (`panic="immediate-abort"`) | Nightly Rust (same technique) | +| **Binary size** | 39-47 KiB | 14 KiB | +| **Entry point** | `#![no_main]` + `mainCRTStartup` | `#![no_main]` + `mainCRTStartup` | +| **Error output** | `ufmt` (no `core::fmt`) | `WriteFile` + Win32 error codes | +| **Ctrl+C handling** | `SetConsoleCtrlHandler` → ignore | `SetConsoleCtrlHandler` → ignore | +| **Exit code** | `GetExitCodeProcess` → `exit()` | `GetExitCodeProcess` → `ExitProcess` | + +The Vite+ trampoline is smaller because it does not embed PE resources. It +normalizes only long sidecar and payload paths. It does not need job objects or +GUI subsystem support. It reads a small sidecar next to its file. It finds +`vp.exe` under the recorded data root and starts it. Both projects use the same +build method and entry-point structure. ## Alternatives Considered @@ -283,25 +342,79 @@ Requires administrator privileges or Developer Mode. Not reliable for all users. ### 4. Copy `vp.exe` as Each Shim (Rejected) -~5-10MB per copy. The trampoline achieves the same result at ~200KB. +~5-10MB per copy. The trampoline achieves the same result in 14 KiB. ### 5. `windows` Crate for FFI (Rejected) Adds ~100KB to the binary for a single `SetConsoleCtrlHandler` call. Raw FFI declaration is sufficient. -## Future Optimizations - -If the ~200KB binary size needs to be reduced further: - -1. **Switch to nightly Rust** with `panic="immediate-abort"` and `#![no_main]` + `mainCRTStartup` (~50KB savings) -2. **Use raw Win32 `CreateProcessW`** instead of `std::process::Command` (eliminates most of std's process machinery) -3. **Pre-build and check in** trampoline binaries (like uv does) to decouple the trampoline build from the workspace toolchain - -These would bring the binary to ~40-50KB, matching uv-trampoline, at the cost of requiring a nightly toolchain and more unsafe code. +## Size Measurements and Build Constraints + +We built each variant below with cargo-xwin. We measured each variant on +x86_64-pc-windows-msvc. The first two rows use the sidecar-aware `std` +implementation. The next five rows show earlier fixed-layout experiments. The +last row shows the current sidecar-aware raw implementation. + +| Variant | Toolchain | Size | +| ---------------------------------------------------------------------------- | --------- | --------- | +| Sidecar-aware `std::process::Command`, precompiled `std` | stable | 221,696 B | +| Same source + build-std + `panic="immediate-abort"` | nightly | 82,432 B | +| Fixed-layout `std` source + `#![no_main]` + `mainCRTStartup` + `atexit` stub | nightly | 69,632 B | +| Raw Win32 rewrite, normal `main`, stable, no build-std | stable | 105,984 B | +| Raw Win32 rewrite, normal `main` + build-std | nightly | 13,824 B | +| Raw Win32 rewrite + `#![no_main]`, no diagnostics | nightly | 6,656 B | +| Fixed-layout raw Win32 + `#![no_main]` + full diagnostics | nightly | 8,192 B | +| Sidecar-aware raw Win32 + `#![no_main]` + full diagnostics (shipped) | nightly | 14,336 B | + +For comparison, the uv-trampoline x64 console binary is 45,056 B. The default +Scoop kiennq shim is 136,192 B and uses statically linked MSVC C. Scoop also +added and then removed a 317,952 B Rust shim. + +### Build Constraints + +1. **`atexit` link failure**: Current nightly toolchains register TLS cleanup + through C `atexit`. With `#![no_main]`, that symbol links + `msvcrt.lib(utility.obj)`. The link then fails on undefined `__vcrt_*` and + `__acrt_*` CRT initialization symbols. Export this no-op function: + + ```rust + extern "C" fn atexit(...) -> i32 { 0 } + ``` + + See `src/win.rs`. The trampoline does not run TLS destructors at process exit. + The documented `rustc-link-lib=ucrt` workaround does not fix this link. See + rust-lang/rust#143172. The older nightly toolchain that uv uses does not + register `atexit`. + +2. **Subsystem**: `#![no_main]` needs + `#![windows_subsystem = "console"]`. Without this attribute, lld reports + that the subsystem is not defined. +3. **Static CRT**: Do not use `+crt-static`. It links the static CRT and + increases the binary size to approximately 115 KiB. +4. **Development profile**: Use `opt-level = 1` and LTO. At `opt-level = 0`, + the compiler can reference the MSVC helper `__CxxFrameHandler3`. This causes + a link failure, even with `panic = "immediate-abort"`. uv uses the same + settings. + +### Remaining options + +- Assign the child to a job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. + uv uses this option. It makes Windows stop the child when Windows stops the + shim. It increases the binary size by a few KiB. +- Commit reproducible trampoline binaries. uv commits `/Brepro`-normalized + executables and compares each byte in CI. This option isolates the shim from + toolchain changes. ## References - [Issue #835](https://github.com/voidzero-dev/vite-plus/issues/835): Original feature request with video reproduction -- [uv-trampoline](https://github.com/astral-sh/uv/tree/main/crates/uv-trampoline): Reference implementation by astral-sh (~40KB with nightly Rust) +- [uv-trampoline](https://github.com/astral-sh/uv/tree/main/crates/uv-trampoline): + Reference implementation by astral-sh. It uses workspace exclusion, + build-std, `panic="immediate-abort"`, cargo-xwin, `#![no_main]`, and raw + Win32. Its CI rejects `core::fmt` and `std::panicking` symbols. +- [Scoop shims](https://github.com/ScoopInstaller/Scoop/tree/master/supporting/shims): + Native C shim from kiennq/scoop-better-shimexe and C# .NET shim. The C shim + is 136 KiB. The C# shim is 9.7 KiB. A sibling `.shim` file specifies the + launch target. - [RFC: env-command](./env-command.md): Shim architecture documentation - [RFC: upgrade-command](./upgrade-command.md): Upgrade/rollback flow diff --git a/rust-toolchain.toml b/rust-toolchain.toml index bdd8f8fc8f..1c39b449c8 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -4,3 +4,6 @@ # - `windows_process_extensions_main_thread_handle` to get the main thread handle for Detours injection channel = "nightly-2026-08-02" profile = "default" +# vp_trampoline uses rust-src to build std from source. +# This reduces the shim binary size. +components = ["rust-src"]