From d2a71b1648c131ae74846c5c5b20bd8d22ec5773 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 16 Aug 2026 19:52:24 +0800 Subject: [PATCH 01/11] perf(trampoline): build vp-shim with build-std to shrink it to 82KB Move crates/vp_trampoline out of the workspace: Cargo ignores `panic` in per-package profile overrides, so the crate needs its own release profile with panic = "immediate-abort". A crate-local .cargo/config.toml enables build-std, which recompiles std under the size profile and removes panic formatting, unwinding, and backtrace machinery. The implementation is otherwise unchanged. With the sidecar-aware source from current main, vp-shim.exe changes from 221,696 B to 82,432 B on x86_64-pc-windows-msvc and from 200,704 B to 79,360 B on aarch64-pc-windows-msvc. The crate config keeps artifacts in the repo-root target/ directory, so CI steps, the snapshot runner, and install-global-cli find vp-shim.exe in the same place as before. Every former `cargo build -p vp_trampoline` call site now builds from the crate directory so the config applies. rust-toolchain.toml adds the rust-src component, and the root Cargo.lock drops the stale vp_trampoline entry required by `--locked` commands. --- .github/actions/build-upstream/action.yml | 12 +- .github/actions/build-windows-cli/action.yml | 17 +- .github/workflows/ci.yml | 1 + .gitignore | 4 + AGENTS.md | 2 +- Cargo.lock | 4 - Cargo.toml | 9 +- .../tests/cli_snapshots/main.rs | 2 +- crates/vp_trampoline/.cargo/config.toml | 21 +++ crates/vp_trampoline/Cargo.lock | 7 + crates/vp_trampoline/Cargo.toml | 61 ++++++-- justfile | 25 ++- package.json | 2 +- packages/cli/publish-native-addons.ts | 2 +- packages/tools/src/install-global-cli.ts | 2 +- rfcs/trampoline-exe-for-shims.md | 146 +++++++++++++++--- rust-toolchain.toml | 3 + 17 files changed, 265 insertions(+), 55 deletions(-) create mode 100644 crates/vp_trampoline/.cargo/config.toml create mode 100644 crates/vp_trampoline/Cargo.lock diff --git a/.github/actions/build-upstream/action.yml b/.github/actions/build-upstream/action.yml index 9c4bd0d8a3..22f45a7275 100644 --- a/.github/actions/build-upstream/action.yml +++ b/.github/actions/build-upstream/action.yml @@ -174,10 +174,20 @@ runs: env: INPUTS_TARGET: ${{ inputs.target }} + # The trampoline is excluded from the workspace and must build from its + # own directory so its .cargo/config.toml (build-std) applies. Pin + # CARGO_TARGET_DIR to an absolute path so the artifact still lands in the + # rust-target dir the cache and artifact paths expect. This runs on native + # Windows runners for release builds, so also treat drive-letter paths as + # absolute. - 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: | + target_dir="${CARGO_TARGET_DIR:-$PWD/target}" + case "$target_dir" in /*|[A-Za-z]:*) ;; *) target_dir="$PWD/$target_dir" ;; esac + cd crates/vp_trampoline + CARGO_TARGET_DIR="$target_dir" cargo build --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..a74525abf5 100644 --- a/.github/actions/build-windows-cli/action.yml +++ b/.github/actions/build-windows-cli/action.yml @@ -80,11 +80,26 @@ 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 trampoline is excluded from the workspace and must build from its + # own directory so its .cargo/config.toml (build-std) applies. Pin + # CARGO_TARGET_DIR to an absolute path so the artifact still lands in the + # same target/ directory the artifact list above expects. + - name: Build trampoline shim binary + if: steps.binaries-cache.outputs.cache-hit != 'true' + shell: bash + run: | + target_dir="${CARGO_TARGET_DIR:-$PWD/target}" + case "$target_dir" in /*) ;; *) target_dir="$PWD/$target_dir" ;; esac + cd crates/vp_trampoline + CARGO_TARGET_DIR="$target_dir" cargo xwin build --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..c3f42b7a3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,6 +335,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/.gitignore b/.gitignore index 056bae4644..f3ebd0e7a1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ vite # PTY snapshot runner failure artifacts (reviewed via the diff, never committed) crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/*/snapshots/*.md.new +# `cargo fmt/clippy --manifest-path crates/vp_trampoline/Cargo.toml` from the repo +# root does not read the crate config (target-dir), so it creates a nested +# target dir. +/crates/vp_trampoline/target diff --git a/AGENTS.md b/AGENTS.md index 701a619031..5971a52919 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/ # Windows shim trampoline (standalone package, excluded from 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..8ac182cea9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,10 @@ [workspace] resolver = "3" members = ["bench", "crates/*", "packages/cli/binding"] +# vp_trampoline is a standalone package: it needs its own release profile with +# panic = "immediate-abort" (cargo ignores `panic` in per-package profile +# overrides) and a crate-local build-std config. See crates/vp_trampoline/Cargo.toml. +exclude = ["crates/vp_trampoline"] [workspace.metadata.cargo-shear] ignored = [ @@ -431,11 +435,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..b5e7ac3fd9 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`", + "global vp trampoline template not found at {}; run `cd crates/vp_trampoline && cargo build`", shim.display() )); } diff --git a/crates/vp_trampoline/.cargo/config.toml b/crates/vp_trampoline/.cargo/config.toml new file mode 100644 index 0000000000..6e50bd2d41 --- /dev/null +++ b/crates/vp_trampoline/.cargo/config.toml @@ -0,0 +1,21 @@ +# This config only applies when cargo runs from this directory (config +# discovery is cwd-based), which is why the trampoline must be built with +# `cd crates/vp_trampoline && cargo build ...` and not with `-p vp_trampoline` +# from the repo root. + +[unstable] +# Recompile std with this crate's release profile (opt-level = "z" and +# panic = "immediate-abort"). This is the main size lever: ~222KB -> ~82KB on +# x86_64-pc-windows-msvc. Requires the rust-src rustup component. +build-std = ["std", "panic_abort"] +# Replace std's default features (drops panic-unwind and backtrace, enables +# the size-optimized code paths). +build-std-features = ["optimize_for_size"] +# Let `cargo test` run with the abort-family panic strategy. +panic-abort-tests = true + +[build] +# Keep artifacts in the repo-root target/ directory, where CI steps, the +# snapshot runner, and install-global-cli expect them. This path resolves +# relative to this crate directory. A CARGO_TARGET_DIR env var still wins. +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..adb4f51eb0 100644 --- a/crates/vp_trampoline/Cargo.toml +++ b/crates/vp_trampoline/Cargo.toml @@ -1,11 +1,30 @@ +# This crate is excluded from the workspace on purpose (see the root +# Cargo.toml). It needs its own release profile with panic = "immediate-abort", +# which cargo ignores in per-package profile overrides, plus the crate-local +# .cargo/config.toml that enables build-std. Build it from this directory so +# that config applies: +# +# cd crates/vp_trampoline && cargo build --release [--target ] +# +# Artifacts land in the repo-root target/ directory (see .cargo/config.toml), +# the same location as workspace builds. The build needs the pinned nightly +# toolchain and the rust-src component (both come from the repo +# rust-toolchain.toml). +# +# Size on x86_64-pc-windows-msvc: ~82KB, down from ~222KB when it was a +# workspace member built with the precompiled std. build-std recompiles std +# with this profile, and panic = "immediate-abort" compiles out the panic +# formatting, unwinding, and backtrace machinery. Background and further +# options: 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]] @@ -15,14 +34,36 @@ 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. -# 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 the workspace lints. It intentionally uses std +# types and macros directly instead of vp_shared, vt_path, vt_str, etc. to +# keep the binary size small; allow the repo-wide .clippy.toml restrictions +# that exist to funnel code through those crates. [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" +# Stronger than "abort": panics become a bare abort with no message +# formatting, so core::fmt and std::panicking never get linked. +panic = "immediate-abort" +debug = false + +# Debug builds must still optimize a little: at opt-level 0 the compiler can +# emit references to the MSVC unwinding helper __CxxFrameHandler3 even with +# panic = "immediate-abort", and the link fails (same constraint as +# uv-trampoline). +[profile.dev] +opt-level = 1 +lto = true +panic = "immediate-abort" +debug = true + +[profile.test] +inherits = "dev" + +[workspace] diff --git a/justfile b/justfile index f241b9ea11..6feb426d14 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 excluded from the workspace and tests from its own +# directory (build-std). Its only test module is unix-only, so the Windows +# recipe skips it instead of paying for a no-test build-std compile. # 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,21 @@ 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 excluded from the workspace; build it from its own +# directory so its .cargo/config.toml (build-std) applies. Artifacts still +# land in the repo-root target/ directory. +[unix] +_build-trampoline: + cd crates/vp_trampoline && cargo build + +[windows] +_build-trampoline: + Set-Location crates/vp_trampoline; cargo build + # 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 +137,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..0bbf0d1e12 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 && cd crates/vp_trampoline && cargo build --release && cd ../.. && 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..163e56365c 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} not found at ${shimSource}. Run "cd crates/vp_trampoline && cargo build --release --target ${napiTarget}" first.`, ); process.exit(1); } diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index e86c2bf577..6a1c37a027 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -127,7 +127,7 @@ export function installGlobalCli() { 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('Build it with: cd crates/vp_trampoline && cargo build --release'); process.exit(1); } } diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index 0048ec1ce4..ca65e609f1 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -124,11 +124,34 @@ layout and also records that Vite+ owns the adjacent executable. ``` crates/vp_trampoline/ -├── Cargo.toml # Zero external dependencies +├── Cargo.toml # Zero dependencies, own release profile +├── Cargo.lock # Own lockfile (the crate is not a workspace member) +├── .cargo/ +│ └── config.toml # build-std flags + target-dir = repo-root target/ ├── src/ │ └── main.rs # Sidecar parser, launcher, and portable tests ``` +The crate is excluded from the workspace (`exclude` in the root `Cargo.toml`). +Two build requirements force this: + +- The release profile sets `panic = "immediate-abort"`. Cargo ignores `panic` + in per-package profile overrides, so the crate needs its own profile. +- The crate-local `.cargo/config.toml` enables build-std. Cargo reads that + config only when it runs from the crate directory. + +Build it from the crate directory: + +```bash +cd crates/vp_trampoline && cargo build --release [--target ] +``` + +Artifacts land in the repo-root `target/` directory (the crate config sets +`target-dir = "../../target"`), so CI steps and `install-global-cli` find +`vp-shim.exe` in the same place as workspace-built binaries. The build needs +the pinned nightly toolchain and the `rust-src` component; both come from the +repo `rust-toolchain.toml`. + ### Trampoline Binary The trampoline has **zero external dependencies**. It declares the Win32 @@ -152,14 +175,22 @@ points to a missing payload. It does not infer the layout from directory paths. ### Size Optimization -| 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 | - -**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). +| Technique | Status | +| ---------------------------------------------------------------------- | ------ | +| Zero external dependencies (raw FFI, no `windows` crate) | Done | +| No direct `core::fmt` usage (avoid `eprintln!`/`format!`/`.unwrap()`) | 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 | +| `build-std-features = ["optimize_for_size"]` (drops panic-unwind, backtrace features) | Done | + +**Binary size**: ~82KB on x86_64-pc-windows-msvc (~79KB on aarch64). With the +precompiled `std` the same source built to ~222KB: the prebuilt rlib carries +`lang_start` init, panic formatting, and backtrace support, and `opt-level` +cannot remove code that a prebuilt rlib already contains. build-std recompiles +`std` under this crate's own profile, and `panic = "immediate-abort"` compiles +the panic machinery out. A further reduction to ~7KB is measured and documented +under Future Optimizations. ### Environment Variables @@ -254,18 +285,17 @@ When installing a pre-trampoline version (no `vp-shim.exe` in the package): | **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 | +| **Toolchain** | Nightly Rust (`panic="immediate-abort"`) | Nightly Rust (same technique) | +| **Binary size** | 39-47 KB | ~82 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]`. +The vite-plus trampoline embeds no data in PE resources. It reads its filename +and adjacent sidecar, resolves `vp.exe`, and starts it. Both projects use +build-std and `panic = "immediate-abort"`. The remaining gap comes from +`std::process::Command` and the `std` runtime initialization. ## Alternatives Considered @@ -283,7 +313,7 @@ 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. Trampoline achieves the same result at ~82KB. ### 5. `windows` Crate for FFI (Rejected) @@ -291,17 +321,83 @@ Adds ~100KB to the binary for a single `SetConsoleCtrlHandler` call. Raw FFI dec ## 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. +Every variant below was built with cargo-xwin and measured on +x86_64-pc-windows-msvc. The numbers serve as reference material for further +size work. + +| Variant | Toolchain | Size | +| -------------------------------------------------------------------------- | --------- | --------- | +| Current source, precompiled `std`, `opt-level="z"` + fat LTO + `panic="abort"` | stable | 221,696 B | +| Current source + build-std + `panic="immediate-abort"` (shipped today) | nightly | 82,432 B | +| Same + `#![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]` (uv-trampoline structure) | nightly | 6,656 B | + +For comparison: uv-trampoline ships 45,056 B (x64 console), Scoop's default +kiennq shim is 136,192 B (statically linked MSVC C), and Scoop once vendored +and then reverted a 317,952 B Rust shim. + +### The 7KB variant + +The floor is a raw Win32 rewrite in the uv-trampoline structure. It keeps the +behavior contract of this RFC and produces a 6,656 B exe (7,168 B on aarch64) +that imports only KERNEL32: + +- `#![no_main]` plus an exported `mainCRTStartup` symbol. The linker picks + that symbol as the console-subsystem entry point, so no `/ENTRY:` flag is + needed. `std` runtime init never runs. Requires + `build-std-features = ["compiler-builtins-mem"]` so `memcpy`/`memset` come + from compiler_builtins instead of the CRT. +- Replace `std::process::Command` with `CreateProcessW`. Build the child + command line as `""` plus the raw tail of `GetCommandLineW` after + the first (program) argument. The skip uses the MSVC rule for the program + name: quotes toggle, no backslash escapes. This forwards the caller's + quoting byte for byte, which `Command`'s re-quoting cannot guarantee. +- Set `VP_HOME` / `VP_SHIM_TOOL` with `SetEnvironmentVariableW` on our own + environment before the spawn; the child inherits it. Remove + `VP_TOOL_RECURSION` by passing a null value. +- Wait with `WaitForSingleObject`, then propagate the raw child exit code via + `GetExitCodeProcess` + `ExitProcess`. +- Heap use stays on `Vec` (the `std` System allocator is `HeapAlloc` on the + process heap; no custom allocator needed). + +### Gotchas (all hit while measuring) + +1. **`atexit` link failure**: current nightlies register TLS destructor + cleanup through C `atexit`. Under `#![no_main]` that symbol pulls + `msvcrt.lib(utility.obj)`, and the link fails with undefined `__vcrt_*` / + `__acrt_*` CRT init internals. Fix: export a no-op + `extern "C" fn atexit(...) -> i32 { 0 }`. The trampoline never needs + exit-time TLS destructors. uv's documented `rustc-link-lib=ucrt` + workaround (rust-lang/rust#143172) does not fix this pull; uv's pinned + older nightly simply predates the `atexit` registration. +2. **Subsystem**: `#![no_main]` requires an explicit + `#![windows_subsystem = "console"]`, or lld fails with "subsystem must be + defined". +3. **Do not use `+crt-static`**: it links the static CRT and grows the binary + to ~115KB. +4. **Dev profile**: at `opt-level = 0` the compiler can emit references to + the MSVC unwinding helper `__CxxFrameHandler3` even with + `panic = "immediate-abort"`, and the link fails. Keep `opt-level = 1` and + LTO in the dev profile (uv does the same). + +### Open items before adopting the 7KB variant + +- Force `HANDLE_FLAG_INHERIT` on the std handles when the parent redirects + stdio (uv does this before `CreateProcess`); verify parity with + `std::process::Command` behavior on the Windows PTY snapshot suite. +- Decide whether to assign the child to a job object with + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so a killed shim also kills its + child. Today neither the shipped trampoline nor the prototype does this. +- Consider committing prebuilt, reproducible trampoline binaries (uv checks + in `/Brepro`-normalized exes and verifies them byte for byte in CI) to + decouple the shim from toolchain drift. ## 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. Same build recipe (workspace exclusion, build-std, `panic="immediate-abort"`, cargo-xwin), plus `#![no_main]`, raw Win32, and a CI `cargo bloat` gate that rejects any `core::fmt`/`std::panicking` symbol. +- [Scoop shims](https://github.com/ScoopInstaller/Scoop/tree/master/supporting/shims): vendored native C shim (136KB, from kiennq/scoop-better-shimexe) and C# .NET shim (9.7KB); launch targets come from a sibling `.shim` text file. - [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..c60f4ced67 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" +# rust-src: crates/vp_trampoline builds std from source (build-std) to +# minimize the shim binary size. +components = ["rust-src"] From 933b2a7ca3bad98158b9898ded8e77298ae1e5a2 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 20:14:09 +0800 Subject: [PATCH 02/11] perf(trampoline): rewrite the Windows shim in raw Win32 to shrink it to 14KB On top of the build-std profile, replace the Windows implementation with `#![no_main]`, `mainCRTStartup`, and raw KERNEL32 calls in the uv-trampoline structure. The raw path reads the adjacent versioned or legacy `.shim` sidecar from current main, resolves `vp.exe`, and applies the single-root or split directory environment. The child command line uses the raw `GetCommandLineW` tail after the program argument, so the caller quoting is preserved. Redirected standard handles are made inheritable before `CreateProcessW`, and the trampoline forwards the child exit code. Launch-critical failures report the failed operation, relevant path, and Windows error code. A missing `vp.exe` also prints a recovery hint. The sidecar-aware binary is 14,336 B on both x86_64-pc-windows-msvc and aarch64-pc-windows-msvc and imports only KERNEL32. The non-Windows build keeps a portable `std::process::Command` implementation for tests. Pure helpers cover command-line parsing, sidecar parsing, file stems, and decimal formatting on every platform. --- crates/vp_trampoline/.cargo/config.toml | 11 +- crates/vp_trampoline/Cargo.toml | 11 +- crates/vp_trampoline/src/cmdline.rs | 208 ++++++++ crates/vp_trampoline/src/main.rs | 612 +++++++++++------------- crates/vp_trampoline/src/win.rs | 530 ++++++++++++++++++++ rfcs/trampoline-exe-for-shims.md | 195 ++++---- 6 files changed, 1131 insertions(+), 436 deletions(-) create mode 100644 crates/vp_trampoline/src/cmdline.rs create mode 100644 crates/vp_trampoline/src/win.rs diff --git a/crates/vp_trampoline/.cargo/config.toml b/crates/vp_trampoline/.cargo/config.toml index 6e50bd2d41..2a814dbcdf 100644 --- a/crates/vp_trampoline/.cargo/config.toml +++ b/crates/vp_trampoline/.cargo/config.toml @@ -5,12 +5,15 @@ [unstable] # Recompile std with this crate's release profile (opt-level = "z" and -# panic = "immediate-abort"). This is the main size lever: ~222KB -> ~82KB on -# x86_64-pc-windows-msvc. Requires the rust-src rustup component. +# panic = "immediate-abort"). Together with the no_main raw-Win32 source this +# takes the sidecar-aware exe to 13 KiB on x86_64-pc-windows-msvc. Requires the +# rust-src rustup component. build-std = ["std", "panic_abort"] # Replace std's default features (drops panic-unwind and backtrace, enables -# the size-optimized code paths). -build-std-features = ["optimize_for_size"] +# the size-optimized code paths). compiler-builtins-mem provides memcpy and +# friends from compiler_builtins instead of the CRT, which the #![no_main] +# entry point needs. +build-std-features = ["optimize_for_size", "compiler-builtins-mem"] # Let `cargo test` run with the abort-family panic strategy. panic-abort-tests = true diff --git a/crates/vp_trampoline/Cargo.toml b/crates/vp_trampoline/Cargo.toml index adb4f51eb0..80183a6d40 100644 --- a/crates/vp_trampoline/Cargo.toml +++ b/crates/vp_trampoline/Cargo.toml @@ -11,11 +11,12 @@ # toolchain and the rust-src component (both come from the repo # rust-toolchain.toml). # -# Size on x86_64-pc-windows-msvc: ~82KB, down from ~222KB when it was a -# workspace member built with the precompiled std. build-std recompiles std -# with this profile, and panic = "immediate-abort" compiles out the panic -# formatting, unwinding, and backtrace machinery. Background and further -# options: rfcs/trampoline-exe-for-shims.md. +# Size on x86_64-pc-windows-msvc: 13 KiB, down from ~222 KiB when the +# sidecar-aware implementation used the precompiled std. build-std recompiles +# std with this profile, panic = "immediate-abort" compiles out the panic +# formatting, unwinding, and backtrace machinery, and the Windows build uses +# #![no_main] with raw Win32 calls (src/win.rs) instead of +# std::process::Command. Background: rfcs/trampoline-exe-for-shims.md. cargo-features = ["panic-immediate-abort"] [package] diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs new file mode 100644 index 0000000000..62f7737d02 --- /dev/null +++ b/crates/vp_trampoline/src/cmdline.rs @@ -0,0 +1,208 @@ +//! Pure helpers over UTF-16 code units and bytes, shared by the Windows +//! implementation. They live outside win.rs so the unit tests run on every +//! platform. + +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; + +/// 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`. +/// +/// Sidecars record the directory layout, data root, and cache root. The parser +/// requires the versioned header and matches `vp_shared`, including UTF-8 BOM +/// and CRLF support. +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(); + if text.is_empty() { + return None; + } + + 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 }) +} + +/// Index where the raw command line's first (program) argument ends. +/// +/// This follows the MSVC parsing rule for the program name: a quote toggles +/// quoted mode and backslashes have no escaping effect. The remainder +/// (`&cmdline[result..]`, leading whitespace included) is the argument tail to +/// forward to the child verbatim. +pub fn skip_program_argument(cmdline: &[u16]) -> usize { + let mut i = 0; + while i < cmdline.len() && (cmdline[i] == SPACE || cmdline[i] == TAB) { + i += 1; + } + 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 +} + +/// Length of the file stem, matching `Path::file_stem`: everything before the +/// last `.`, except that a leading `.` never starts 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 into `buf`, returning 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 skips_leading_whitespace_and_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 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..9ad60d46a1 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -1,386 +1,342 @@ -//! 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. +//! and `npm.exe`. The trampoline gets the tool name from its filename, reads +//! the install roots from the adjacent `.shim` sidecar, and starts the +//! active `vp.exe` with the matching dispatch environment. //! //! 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 avoid the CRT startup and the +//! `std::process::Command` implementation. The standalone build recompiles +//! `std` for size and uses immediate-abort panics. Error paths still report the +//! failed operation, relevant path, and Windows error code. See +//! `rfcs/trampoline-exe-for-shims.md`. +//! +//! The non-Windows implementation exists for portable tests. Unix shims are +//! symlinks and never ship this binary. //! //! 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 picks this symbol as the console-subsystem entry point, so no +/// `/ENTRY:` flag is needed. 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 }) -} + struct ShimPointer { + data: PathBuf, + layout: ShimLayout, + } -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 VpLocation { + exe: PathBuf, + pointer: ShimPointer, } - let mut lines = text.lines(); - if lines.next()? != SHIM_POINTER_HEADER { - return None; + + /// 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 Path }, } - 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)); + /// 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) } - 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); + fn child_dir_pins(pointer: &ShimPointer) -> ChildDirPins<'_> { + match &pointer.layout { + ShimLayout::SingleRoot => ChildDirPins::SingleRoot, + ShimLayout::Split { cache } => ChildDirPins::Split { cache }, } } - 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"); + /// 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 }) + } + + 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 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); + } } - } -} -#[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 instead of using + // passthrough mode. 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!( + child_dir_pins(&location.pointer), + ChildDirPins::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!(child_dir_pins(&location.pointer), ChildDirPins::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..4d399b4bca --- /dev/null +++ b/crates/vp_trampoline/src/win.rs @@ -0,0 +1,530 @@ +//! Raw Win32 trampoline implementation. +//! +//! The `#![no_main]` entry point jumps directly here, so the CRT startup and +//! the Rust `std` runtime do not initialize. This module uses KERNEL32 calls +//! for sidecar I/O, environment setup, process creation, diagnostics, and +//! process exit. `Vec` uses the Windows process heap and needs no runtime init. + +use core::{ffi::c_void, ptr}; + +use crate::cmdline::{self, ShimLayout}; + +type Handle = *mut c_void; + +const CP_UTF8: u32 = 65001; +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; +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 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 nightlies register exit-time TLS cleanup through C `atexit`. Linking +// the CRT implementation would pull its startup machinery into this no_main +// binary. ExitProcess never runs TLS destructors, so a successful no-op is the +// correct implementation for this process. +#[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 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 = nul_terminated(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()) + }; + let error = if ok == 0 { unsafe { GetLastError() } } else { 0 }; + unsafe { CloseHandle(handle) }; + if ok == 0 { + fail_path_call(b"ReadFile", path, error); + } + 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 = value.map(nul_terminated); + let value_ptr = value.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.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[..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 plus the raw + // caller argument tail. Forwarding the tail preserves the caller's quoting. + let vp_exe = join_path(&data, without_nul(w!("current\\bin\\vp.exe"))); + let vp_exe_nul = nul_terminated(&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 and decides how to handle it. + if unsafe { SetConsoleCtrlHandler(Some(ignore_ctrl), 1) } == 0 { + report_call_failure(b"warning: SetConsoleCtrlHandler", unsafe { GetLastError() }); + } + + // 5. Reuse our startup info. When the parent supplied redirected stdio, + // make those handles inheritable before CreateProcessW. + 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 and propagate its exact exit code. + unsafe { + CloseHandle(pi.thread); + let wait = WaitForSingleObject(pi.process, INFINITE); + if wait == WAIT_FAILED { + fail_call(b"WaitForSingleObject"); + } + if wait != WAIT_OBJECT_0 { + 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/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index ca65e609f1..85c5140c08 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -129,7 +129,9 @@ crates/vp_trampoline/ ├── .cargo/ │ └── config.toml # build-std flags + target-dir = repo-root target/ ├── src/ -│ └── main.rs # Sidecar parser, launcher, and portable tests +│ ├── main.rs # Entry points + portable non-Windows fallback +│ ├── win.rs # Windows implementation: raw Win32, no_main entry +│ └── cmdline.rs # Command-line and sidecar parsers with portable tests ``` The crate is excluded from the workspace (`exclude` in the root `Cargo.toml`). @@ -154,57 +156,76 @@ repo `rust-toolchain.toml`. ### Trampoline Binary -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 trampoline performs these steps: - -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. - -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 trampoline has **zero external dependencies**: all Win32 calls are raw +`extern "system"` declarations against KERNEL32, so the heavy +`windows`/`windows-core` crates never enter the build. It also never touches +`core::fmt`; diagnostics go through `WriteFile` with a hand-rolled decimal +formatter. + +On Windows the binary is `#![no_main]` with an exported `mainCRTStartup` +symbol, so neither the CRT startup nor `std` runtime init runs. The flow in +`src/win.rs`: + +1. `GetModuleFileNameW` gives the shim path and tool name. Replacing the `.exe` + extension with `.shim` locates the per-tool sidecar. +2. `CreateFileW` and `ReadFile` load the UTF-8 sidecar. The parser requires the + versioned header and accepts the `single-root` and `split` layouts. +3. `SetEnvironmentVariableW` pins the sidecar's layout. A single-root pointer + sets `VP_HOME`. A split pointer removes `VP_HOME` and sets `VP_DATA_DIR`, + `VP_BIN_DIR`, and `VP_CACHE_DIR`. Tool shims also set `VP_SHIM_TOOL` and + remove `VP_TOOL_RECURSION`. +4. The child command line is `"\current\bin\vp.exe"` plus the raw + `GetCommandLineW` tail after the program argument. The split follows the + MSVC `argv[0]` rule: quotes toggle, and backslashes do not escape. This + preserves the caller's exact UTF-16 argument tail. +5. `SetConsoleCtrlHandler` installs a handler that ignores Ctrl+C and + Ctrl+Break; the child decides how to react. +6. `CreateProcessW` spawns the child with inherited handles and startup info. + When the parent redirected stdio (`STARTF_USESTDHANDLES`), the standard + handles are forced inheritable first, as in uv-trampoline and distlib. +7. `WaitForSingleObject`, `GetExitCodeProcess`, and `ExitProcess` propagate the + child's exit code unchanged. + +Launch-critical failures report the failed call or operation, the relevant +path, and the Windows error code when one exists. A missing `vp.exe` also prints +a recovery hint to reinstall Vite+ or run `vp env setup`. + +The non-Windows implementation uses `std::process::Command` and the same +sidecar parser for portable tests. Unix shims are symlinks and never use it. +The parser rejects missing, malformed, and unversioned sidecars. It does not +infer the layout from directory paths. ### Size Optimization | Technique | Status | | ---------------------------------------------------------------------- | ------ | | Zero external dependencies (raw FFI, no `windows` crate) | Done | -| No direct `core::fmt` usage (avoid `eprintln!`/`format!`/`.unwrap()`) | 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 | -| `build-std-features = ["optimize_for_size"]` (drops panic-unwind, backtrace features) | Done | +| `#![no_main]` + `mainCRTStartup` (no CRT startup, no `std` runtime init) | Done | +| Raw `CreateProcessW` instead of `std::process::Command` | Done | -**Binary size**: ~82KB on x86_64-pc-windows-msvc (~79KB on aarch64). With the -precompiled `std` the same source built to ~222KB: the prebuilt rlib carries -`lang_start` init, panic formatting, and backtrace support, and `opt-level` -cannot remove code that a prebuilt rlib already contains. build-std recompiles -`std` under this crate's own profile, and `panic = "immediate-abort"` compiles -the panic machinery out. A further reduction to ~7KB is measured and documented -under Future Optimizations. +**Binary size**: 13,312 B on x86_64-pc-windows-msvc and 13,824 B on +aarch64-pc-windows-msvc, including sidecar parsing and error diagnostics. The +sidecar-aware `std::process::Command` implementation was 221,696 B on x86_64. +See Future Optimizations for the measured size ladder. 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 | Purpose | +| ------------------- | ----------------------- | ------------------------------------------------------------ | +| `VP_HOME` | Single-root layout | Pins all Vite+ directories to the sidecar's data root | +| `VP_HOME` | Split layout | Removed so it cannot override the category roots | +| `VP_DATA_DIR` | Split layout | Pins the payload and state root | +| `VP_BIN_DIR` | Split layout | Pins the directory that contains the shim | +| `VP_CACHE_DIR` | Split layout | Pins the cache root | +| `VP_SHIM_TOOL` | Tool shims, except `vp` | Selects shim dispatch for the named tool | +| `VP_TOOL_RECURSION` | Removed for tool shims | Forces fresh version resolution for nested shim calls | ### Ctrl+C Handling @@ -223,10 +244,10 @@ The trampoline installs a console control handler that returns `TRUE` (1): `detect_shim_tool()` in `shim/mod.rs` checks `VP_SHIM_TOOL` env var **before** `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 pins, 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 @@ -286,16 +307,17 @@ When installing a pre-trampoline version (no `vp-shim.exe` in the package): | **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"`) | Nightly Rust (same technique) | -| **Binary size** | 39-47 KB | ~82 KB | -| **Entry point** | `#![no_main]` + `mainCRTStartup` | Standard `fn main()` | -| **Error output** | `ufmt` (no `core::fmt`) | `write_all` (no `core::fmt`) | +| **Binary size** | 39-47 KiB | 13-14 KiB | +| **Entry point** | `#![no_main]` + `mainCRTStartup` | Same approach | +| **Error output** | `ufmt` (no `core::fmt`) | `WriteFile` + Win32 error codes | | **Ctrl+C handling** | `SetConsoleCtrlHandler` → ignore | Same approach | -| **Exit code** | `GetExitCodeProcess` → `exit()` | `Command::status()` → `exit()` | +| **Exit code** | `GetExitCodeProcess` → `exit()` | Same approach | -The vite-plus trampoline embeds no data in PE resources. It reads its filename -and adjacent sidecar, resolves `vp.exe`, and starts it. Both projects use -build-std and `panic = "immediate-abort"`. The remaining gap comes from -`std::process::Command` and the `std` runtime initialization. +The Vite+ trampoline is smaller because it embeds no PE resources and needs no +path canonicalization, job objects, or GUI subsystem support. It reads a small +sidecar next to its own filename, resolves `vp.exe` under the recorded data +root, and starts it. Both projects share the same build recipe and entry-point +structure. ## Alternatives Considered @@ -313,7 +335,7 @@ Requires administrator privileges or Developer Mode. Not reliable for all users. ### 4. Copy `vp.exe` as Each Shim (Rejected) -~5-10MB per copy. Trampoline achieves the same result at ~82KB. +~5-10MB per copy. The trampoline achieves the same result in less than 14 KiB. ### 5. `windows` Crate for FFI (Rejected) @@ -322,56 +344,35 @@ Adds ~100KB to the binary for a single `SetConsoleCtrlHandler` call. Raw FFI dec ## Future Optimizations Every variant below was built with cargo-xwin and measured on -x86_64-pc-windows-msvc. The numbers serve as reference material for further -size work. - -| Variant | Toolchain | Size | -| -------------------------------------------------------------------------- | --------- | --------- | -| Current source, precompiled `std`, `opt-level="z"` + fat LTO + `panic="abort"` | stable | 221,696 B | -| Current source + build-std + `panic="immediate-abort"` (shipped today) | nightly | 82,432 B | -| Same + `#![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]` (uv-trampoline structure) | nightly | 6,656 B | +x86_64-pc-windows-msvc. The first two rows use the sidecar-aware `std` +implementation. The next five rows are earlier fixed-layout experiments. The +last row is 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 | 13,312 B | For comparison: uv-trampoline ships 45,056 B (x64 console), Scoop's default kiennq shim is 136,192 B (statically linked MSVC C), and Scoop once vendored and then reverted a 317,952 B Rust shim. -### The 7KB variant - -The floor is a raw Win32 rewrite in the uv-trampoline structure. It keeps the -behavior contract of this RFC and produces a 6,656 B exe (7,168 B on aarch64) -that imports only KERNEL32: - -- `#![no_main]` plus an exported `mainCRTStartup` symbol. The linker picks - that symbol as the console-subsystem entry point, so no `/ENTRY:` flag is - needed. `std` runtime init never runs. Requires - `build-std-features = ["compiler-builtins-mem"]` so `memcpy`/`memset` come - from compiler_builtins instead of the CRT. -- Replace `std::process::Command` with `CreateProcessW`. Build the child - command line as `""` plus the raw tail of `GetCommandLineW` after - the first (program) argument. The skip uses the MSVC rule for the program - name: quotes toggle, no backslash escapes. This forwards the caller's - quoting byte for byte, which `Command`'s re-quoting cannot guarantee. -- Set `VP_HOME` / `VP_SHIM_TOOL` with `SetEnvironmentVariableW` on our own - environment before the spawn; the child inherits it. Remove - `VP_TOOL_RECURSION` by passing a null value. -- Wait with `WaitForSingleObject`, then propagate the raw child exit code via - `GetExitCodeProcess` + `ExitProcess`. -- Heap use stays on `Vec` (the `std` System allocator is `HeapAlloc` on the - process heap; no custom allocator needed). - ### Gotchas (all hit while measuring) 1. **`atexit` link failure**: current nightlies register TLS destructor cleanup through C `atexit`. Under `#![no_main]` that symbol pulls `msvcrt.lib(utility.obj)`, and the link fails with undefined `__vcrt_*` / `__acrt_*` CRT init internals. Fix: export a no-op - `extern "C" fn atexit(...) -> i32 { 0 }`. The trampoline never needs - exit-time TLS destructors. uv's documented `rustc-link-lib=ucrt` - workaround (rust-lang/rust#143172) does not fix this pull; uv's pinned - older nightly simply predates the `atexit` registration. + `extern "C" fn atexit(...) -> i32 { 0 }` (see win.rs). The trampoline + never needs exit-time TLS destructors. uv's documented + `rustc-link-lib=ucrt` workaround (rust-lang/rust#143172) does not fix this + pull; uv's pinned older nightly simply predates the `atexit` registration. 2. **Subsystem**: `#![no_main]` requires an explicit `#![windows_subsystem = "console"]`, or lld fails with "subsystem must be defined". @@ -382,16 +383,12 @@ that imports only KERNEL32: `panic = "immediate-abort"`, and the link fails. Keep `opt-level = 1` and LTO in the dev profile (uv does the same). -### Open items before adopting the 7KB variant +### Remaining options -- Force `HANDLE_FLAG_INHERIT` on the std handles when the parent redirects - stdio (uv does this before `CreateProcess`); verify parity with - `std::process::Command` behavior on the Windows PTY snapshot suite. -- Decide whether to assign the child to a job object with - `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so a killed shim also kills its - child. Today neither the shipped trampoline nor the prototype does this. -- Consider committing prebuilt, reproducible trampoline binaries (uv checks - in `/Brepro`-normalized exes and verifies them byte for byte in CI) to +- Assign the child to a job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` + (as uv does), so a killed shim also kills its child. Costs a few KB. +- Commit prebuilt, reproducible trampoline binaries (uv checks in + `/Brepro`-normalized exes and verifies them byte for byte in CI) to decouple the shim from toolchain drift. ## References From 963734ea9104eb160ac4ce8de8b29fdccb0a33e9 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 20:14:47 +0800 Subject: [PATCH 03/11] ci(trampoline): fix Windows test archive package list and RFC formatting The Windows archive step enumerates `crates/*/`, but `vp_trampoline` is no longer a workspace member. Skip it as the `justfile` test recipe does. Portable parser and layout tests run from the standalone crate on Unix, while Windows shim behavior remains covered by the Windows CLI snapshot suite. Also format the RFC tables and refresh the measured size ladder for the sidecar-aware baseline and raw implementation. --- .github/workflows/ci.yml | 5 ++++- justfile | 4 ++-- rfcs/trampoline-exe-for-shims.md | 36 ++++++++++++++++---------------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3f42b7a3f..6db4d6529c 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 excluded from the workspace. Its portable parser and + # layout tests run on Unix, while Windows shim behavior is covered by the + # Windows CLI snapshot suite. - 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 diff --git a/justfile b/justfile index 6feb426d14..9631df2119 100644 --- a/justfile +++ b/justfile @@ -73,8 +73,8 @@ watch-check: # vp_cli_snapshots is excluded: its suite needs a built global binary and # node, and runs via `just snapshot-test` instead. # vp_trampoline is excluded from the workspace and tests from its own -# directory (build-std). Its only test module is unix-only, so the Windows -# recipe skips it instead of paying for a no-test build-std compile. +# directory on Unix. Its portable parser and layout tests run there, while +# Windows shim behavior is covered by the Windows CLI snapshot suite. # Single source of truth for cargo test, used by CI too. [unix] test: diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index 85c5140c08..946ec1d806 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -197,15 +197,15 @@ infer the layout from directory paths. ### 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 | +| 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**: 13,312 B on x86_64-pc-windows-msvc and 13,824 B on aarch64-pc-windows-msvc, including sidecar parsing and error diagnostics. The @@ -217,15 +217,15 @@ only KERNEL32. The sidecar controls the directory environment inherited by `vp.exe`: -| Variable | When | Purpose | -| ------------------- | ----------------------- | ------------------------------------------------------------ | -| `VP_HOME` | Single-root layout | Pins all Vite+ directories to the sidecar's data root | -| `VP_HOME` | Split layout | Removed so it cannot override the category roots | -| `VP_DATA_DIR` | Split layout | Pins the payload and state root | -| `VP_BIN_DIR` | Split layout | Pins the directory that contains the shim | -| `VP_CACHE_DIR` | Split layout | Pins the cache root | -| `VP_SHIM_TOOL` | Tool shims, except `vp` | Selects shim dispatch for the named tool | -| `VP_TOOL_RECURSION` | Removed for tool shims | Forces fresh version resolution for nested shim calls | +| Variable | When | Purpose | +| ------------------- | ----------------------- | ----------------------------------------------------- | +| `VP_HOME` | Single-root layout | Pins all Vite+ directories to the sidecar's data root | +| `VP_HOME` | Split layout | Removed so it cannot override the category roots | +| `VP_DATA_DIR` | Split layout | Pins the payload and state root | +| `VP_BIN_DIR` | Split layout | Pins the directory that contains the shim | +| `VP_CACHE_DIR` | Split layout | Pins the cache root | +| `VP_SHIM_TOOL` | Tool shims, except `vp` | Selects shim dispatch for the named tool | +| `VP_TOOL_RECURSION` | Removed for tool shims | Forces fresh version resolution for nested shim calls | ### Ctrl+C Handling From 0c45f817af3a9b9ea192a1ed6136aaa26eae4fa7 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 20:35:51 +0800 Subject: [PATCH 04/11] fix(trampoline): preserve Windows root paths --- crates/vp_trampoline/src/cmdline.rs | 51 +++++++++++++++++++++++++++++ crates/vp_trampoline/src/win.rs | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs index 62f7737d02..a35ce0f107 100644 --- a/crates/vp_trampoline/src/cmdline.rs +++ b/crates/vp_trampoline/src/cmdline.rs @@ -6,6 +6,10 @@ 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; /// Must match `vp_shared::SHIM_POINTER_HEADER`. pub const SHIM_POINTER_HEADER: &str = "vite-plus-shim-v1"; @@ -85,6 +89,33 @@ pub fn skip_program_argument(cmdline: &[u16]) -> usize { i } +fn is_path_separator(unit: u16) -> bool { + unit == BACKSLASH || unit == FORWARD_SLASH +} + +/// End index of a parent directory, preserving the separator when it is part +/// of a Windows root. +/// +/// Removing the separator from `C:\vp.exe` would produce the drive-relative +/// path `C:`. Device roots such as `\\?\Volume{...}\vp.exe` have the same +/// constraint. Other parents omit their trailing separator, matching +/// `Path::parent`. +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 + } +} + /// Length of the file stem, matching `Path::file_stem`: everything before the /// last `.`, except that a leading `.` never starts an extension. pub fn file_stem_len(name: &[u16]) -> usize { @@ -146,6 +177,26 @@ mod tests { 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 file_stem_matches_path_file_stem() { assert_eq!(file_stem_len(&wide("node.exe")), 4); diff --git a/crates/vp_trampoline/src/win.rs b/crates/vp_trampoline/src/win.rs index 4d399b4bca..d87a0717fa 100644 --- a/crates/vp_trampoline/src/win.rs +++ b/crates/vp_trampoline/src/win.rs @@ -397,7 +397,7 @@ pub fn run() -> ! { stderr_write(b"\"\n"); unsafe { ExitProcess(1) } }; - let bin_dir = &exe[..last_separator]; + 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); From 2aa5691762d25ec85664ebe9990d1e9750ab81a9 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 21 Aug 2026 20:54:49 +0800 Subject: [PATCH 05/11] fix(ci): build standalone trampoline in installer test --- .github/workflows/test-standalone-install.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index bb54845e5f..f16a73f10e 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -1292,7 +1292,13 @@ 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 + + target_dir="${CARGO_TARGET_DIR:-$PWD/target}" + case "$target_dir" in /*|[A-Za-z]:*) ;; *) target_dir="$PWD/$target_dir" ;; esac + cd crates/vp_trampoline + CARGO_TARGET_DIR="$target_dir" cargo build --release - name: vp-setup.exe rejects invalid directory overrides shell: pwsh From 4a42ee0dd6299bcfd575c056b20b81ee982521f1 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 22 Aug 2026 00:23:03 +0800 Subject: [PATCH 06/11] fix(trampoline): preserve extended-length Windows paths --- .github/workflows/test-standalone-install.yml | 41 ++++++++++++++ crates/vp_trampoline/.cargo/config.toml | 2 +- crates/vp_trampoline/Cargo.toml | 2 +- crates/vp_trampoline/src/cmdline.rs | 51 ++++++++++++++++++ crates/vp_trampoline/src/win.rs | 54 ++++++++++++++++++- rfcs/trampoline-exe-for-shims.md | 2 +- 6 files changed, 147 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index f16a73f10e..09fa8a06bf 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -1300,6 +1300,47 @@ jobs: cd crates/vp_trampoline CARGO_TARGET_DIR="$target_dir" cargo build --release + - name: Trampoline launches 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 "payload path is not 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 "trampoline exited with $exitCode for payload path $payload" + } + - name: vp-setup.exe rejects invalid directory overrides shell: pwsh run: | diff --git a/crates/vp_trampoline/.cargo/config.toml b/crates/vp_trampoline/.cargo/config.toml index 2a814dbcdf..14bd5f8ad4 100644 --- a/crates/vp_trampoline/.cargo/config.toml +++ b/crates/vp_trampoline/.cargo/config.toml @@ -6,7 +6,7 @@ [unstable] # Recompile std with this crate's release profile (opt-level = "z" and # panic = "immediate-abort"). Together with the no_main raw-Win32 source this -# takes the sidecar-aware exe to 13 KiB on x86_64-pc-windows-msvc. Requires the +# takes the sidecar-aware exe to 14 KiB on x86_64-pc-windows-msvc. Requires the # rust-src rustup component. build-std = ["std", "panic_abort"] # Replace std's default features (drops panic-unwind and backtrace, enables diff --git a/crates/vp_trampoline/Cargo.toml b/crates/vp_trampoline/Cargo.toml index 80183a6d40..dbea2ca869 100644 --- a/crates/vp_trampoline/Cargo.toml +++ b/crates/vp_trampoline/Cargo.toml @@ -11,7 +11,7 @@ # toolchain and the rust-src component (both come from the repo # rust-toolchain.toml). # -# Size on x86_64-pc-windows-msvc: 13 KiB, down from ~222 KiB when the +# Size on x86_64-pc-windows-msvc: 14 KiB, down from ~222 KiB when the # sidecar-aware implementation used the precompiled std. build-std recompiles # std with this profile, panic = "immediate-abort" compiles out the panic # formatting, unwinding, and backtrace machinery, and the Windows build uses diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs index a35ce0f107..b6788d1807 100644 --- a/crates/vp_trampoline/src/cmdline.rs +++ b/crates/vp_trampoline/src/cmdline.rs @@ -10,6 +10,12 @@ 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"; @@ -93,6 +99,31 @@ fn is_path_separator(unit: u16) -> bool { unit == BACKSLASH || unit == FORWARD_SLASH } +/// Add the Win32 extended-length prefix to a normalized absolute path. +/// +/// The caller must resolve `.` and `..` components and replace `/` separators +/// before calling this function because the extended-length namespace treats +/// the rest of the path verbatim. +pub fn verbatim_path(path: &[u16]) -> Vec { + let (prefix, tail) = match path { + // Keep paths that already use an 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 +} + /// End index of a parent directory, preserving the separator when it is part /// of a Windows root. /// @@ -197,6 +228,26 @@ mod tests { 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); diff --git a/crates/vp_trampoline/src/win.rs b/crates/vp_trampoline/src/win.rs index d87a0717fa..40be00447c 100644 --- a/crates/vp_trampoline/src/win.rs +++ b/crates/vp_trampoline/src/win.rs @@ -12,6 +12,8 @@ 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; @@ -27,6 +29,10 @@ 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; +// Match the conservative threshold used by Rust's Windows path handling. +// CreateDirectoryW reserves extra space below MAX_PATH, so std normalizes all +// paths at this length even when the immediate API accepts a few more units. +const LEGACY_MAX_PATH: usize = 248; const MAX_SHIM_POINTER_BYTES: i64 = 1024 * 1024; const INVALID_HANDLE_VALUE: Handle = -1isize as Handle; @@ -65,6 +71,12 @@ 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( @@ -177,6 +189,44 @@ fn join_path(base: &[u16], suffix: &[u16]) -> Vec { 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 mirrors the standard library behavior that the raw implementation +/// replaced: long paths are made absolute and normalized before entering 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() { @@ -318,7 +368,7 @@ fn pointer_path(exe: &[u16], last_separator: usize, file_name: &[u16]) -> Vec Vec { - let path_nul = nul_terminated(path); + let path_nul = win32_api_path(path); let handle = unsafe { CreateFileW( path_nul.as_ptr(), @@ -433,7 +483,7 @@ pub fn run() -> ! { // 3. Build the child command line from the active payload plus the raw // caller argument tail. Forwarding the tail preserves the caller's quoting. let vp_exe = join_path(&data, without_nul(w!("current\\bin\\vp.exe"))); - let vp_exe_nul = nul_terminated(&vp_exe); + let vp_exe_nul = win32_api_path(&vp_exe); let tail = unsafe { let command_line = GetCommandLineW(); if command_line.is_null() { diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index 946ec1d806..d0d010cbf6 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -335,7 +335,7 @@ 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 in less than 14 KiB. +~5-10MB per copy. The trampoline achieves the same result in 14 KiB. ### 5. `windows` Crate for FFI (Rejected) From 78f9c3f661916d79390c22b495b25f8bfd8fad1e Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 22 Aug 2026 12:23:59 +0800 Subject: [PATCH 07/11] fix(trampoline): address review regressions --- crates/vp_trampoline/src/cmdline.rs | 21 ++++++++++------ justfile | 12 +++------ package.json | 2 +- .../src/__tests__/build-trampoline.spec.ts | 23 +++++++++++++++++ packages/tools/src/build-trampoline.ts | 25 +++++++++++++++++++ rfcs/trampoline-exe-for-shims.md | 23 +++++++++-------- 6 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 packages/tools/src/__tests__/build-trampoline.spec.ts create mode 100644 packages/tools/src/build-trampoline.ts diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs index b6788d1807..c4f2f96cc4 100644 --- a/crates/vp_trampoline/src/cmdline.rs +++ b/crates/vp_trampoline/src/cmdline.rs @@ -74,14 +74,12 @@ pub fn parse_shim_pointer(bytes: &[u8]) -> Option> { /// Index where the raw command line's first (program) argument ends. /// /// This follows the MSVC parsing rule for the program name: a quote toggles -/// quoted mode and backslashes have no escaping effect. The remainder -/// (`&cmdline[result..]`, leading whitespace included) is the argument tail to -/// forward to the child verbatim. +/// quoted mode, backslashes have no escaping effect, and leading whitespace +/// terminates an empty program argument. The remainder (`&cmdline[result..]`, +/// leading whitespace included) is the argument tail to forward to the child +/// verbatim. pub fn skip_program_argument(cmdline: &[u16]) -> usize { let mut i = 0; - while i < cmdline.len() && (cmdline[i] == SPACE || cmdline[i] == TAB) { - i += 1; - } let mut quoted = false; while i < cmdline.len() { let c = cmdline[i]; @@ -196,8 +194,15 @@ mod tests { } #[test] - fn skips_leading_whitespace_and_bare_program() { - let cl = wide(" node"); + 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); } diff --git a/justfile b/justfile index 9631df2119..45731a373b 100644 --- a/justfile +++ b/justfile @@ -97,15 +97,11 @@ snapshot-test *args='': _install_chromium _build-trampoline cargo test -p vp_cli_snapshots -- {{args}} # The trampoline is excluded from the workspace; build it from its own -# directory so its .cargo/config.toml (build-std) applies. Artifacts still -# land in the repo-root target/ directory. -[unix] -_build-trampoline: - cd crates/vp_trampoline && cargo build - -[windows] +# directory so its .cargo/config.toml (build-std) applies. The helper anchors +# relative CARGO_TARGET_DIR values to the repo root so all binaries stay in the +# same artifact directory. _build-trampoline: - Set-Location crates/vp_trampoline; cargo build + node packages/tools/src/build-trampoline.ts # Browser-mode snapshot cases run with PLAYWRIGHT_BROWSERS_PATH=0, so the # browser must be installed into node_modules with the same setting. diff --git a/package.json b/package.json index 0bbf0d1e12..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 --release && cd crates/vp_trampoline && cargo build --release && cd ../.. && 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/tools/src/__tests__/build-trampoline.spec.ts b/packages/tools/src/__tests__/build-trampoline.spec.ts new file mode 100644 index 0000000000..50dd1b48f7 --- /dev/null +++ b/packages/tools/src/__tests__/build-trampoline.spec.ts @@ -0,0 +1,23 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, test } from 'vitest'; + +import { resolveCargoTargetDir } from '../build-trampoline.ts'; + +const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)); + +describe('resolveCargoTargetDir', () => { + test('anchors relative paths to 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); + }); +}); diff --git a/packages/tools/src/build-trampoline.ts b/packages/tools/src/build-trampoline.ts new file mode 100644 index 0000000000..057dcf3e6c --- /dev/null +++ b/packages/tools/src/build-trampoline.ts @@ -0,0 +1,25 @@ +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 buildTrampoline(args: string[] = process.argv.slice(2)) { + const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; + execFileSync(cargo, ['build', ...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/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index d0d010cbf6..655b4374fa 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -168,8 +168,10 @@ symbol, so neither the CRT startup nor `std` runtime init runs. The flow in 1. `GetModuleFileNameW` gives the shim path and tool name. Replacing the `.exe` extension with `.shim` locates the per-tool sidecar. -2. `CreateFileW` and `ReadFile` load the UTF-8 sidecar. The parser requires the - versioned header and accepts the `single-root` and `split` layouts. +2. `CreateFileW` and `ReadFile` load the UTF-8 sidecar. Long paths are made + absolute with `GetFullPathNameW`, then use the `\\?\` drive prefix or the + `\\?\UNC\` network prefix. The parser requires the versioned header and + accepts the `single-root` and `split` layouts. 3. `SetEnvironmentVariableW` pins the sidecar's layout. A single-root pointer sets `VP_HOME`. A split pointer removes `VP_HOME` and sets `VP_DATA_DIR`, `VP_BIN_DIR`, and `VP_CACHE_DIR`. Tool shims also set `VP_SHIM_TOOL` and @@ -181,6 +183,7 @@ symbol, so neither the CRT startup nor `std` runtime init runs. The flow in 5. `SetConsoleCtrlHandler` installs a handler that ignores Ctrl+C and Ctrl+Break; the child decides how to react. 6. `CreateProcessW` spawns the child with inherited handles and startup info. + The payload path uses the same extended-length normalization as the sidecar. When the parent redirected stdio (`STARTF_USESTDHANDLES`), the standard handles are forced inheritable first, as in uv-trampoline and distlib. 7. `WaitForSingleObject`, `GetExitCodeProcess`, and `ExitProcess` propagate the @@ -207,7 +210,7 @@ infer the layout from directory paths. | `#![no_main]` + `mainCRTStartup` (no CRT startup, no `std` runtime init) | Done | | Raw `CreateProcessW` instead of `std::process::Command` | Done | -**Binary size**: 13,312 B on x86_64-pc-windows-msvc and 13,824 B on +**Binary size**: 14,336 B on both x86_64-pc-windows-msvc and aarch64-pc-windows-msvc, including sidecar parsing and error diagnostics. The sidecar-aware `std::process::Command` implementation was 221,696 B on x86_64. See Future Optimizations for the measured size ladder. The executable imports @@ -307,17 +310,17 @@ When installing a pre-trampoline version (no `vp-shim.exe` in the package): | **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"`) | Nightly Rust (same technique) | -| **Binary size** | 39-47 KiB | 13-14 KiB | +| **Binary size** | 39-47 KiB | 14 KiB | | **Entry point** | `#![no_main]` + `mainCRTStartup` | Same approach | | **Error output** | `ufmt` (no `core::fmt`) | `WriteFile` + Win32 error codes | | **Ctrl+C handling** | `SetConsoleCtrlHandler` → ignore | Same approach | | **Exit code** | `GetExitCodeProcess` → `exit()` | Same approach | -The Vite+ trampoline is smaller because it embeds no PE resources and needs no -path canonicalization, job objects, or GUI subsystem support. It reads a small -sidecar next to its own filename, resolves `vp.exe` under the recorded data -root, and starts it. Both projects share the same build recipe and entry-point -structure. +The Vite+ trampoline is smaller because it embeds no PE resources and only +normalizes long file and process paths. It needs no job objects or GUI +subsystem support. It reads a small sidecar next to its own filename, resolves +`vp.exe` under the recorded data root, and starts it. Both projects share the +same build recipe and entry-point structure. ## Alternatives Considered @@ -357,7 +360,7 @@ last row is the current sidecar-aware raw implementation. | 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 | 13,312 B | +| Sidecar-aware raw Win32 + `#![no_main]` + full diagnostics (shipped) | nightly | 14,336 B | For comparison: uv-trampoline ships 45,056 B (x64 console), Scoop's default kiennq shim is 136,192 B (statically linked MSVC C), and Scoop once vendored From 9ae1d4800eb6542cbfb9627c79c633d36f72ca5f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 22 Aug 2026 12:43:39 +0800 Subject: [PATCH 08/11] refactor(ci): share trampoline build helper --- .github/actions/build-upstream/action.yml | 14 +++----------- .github/actions/build-windows-cli/action.yml | 12 +++--------- .github/workflows/test-standalone-install.yml | 6 +----- justfile | 4 ++-- .../src/__tests__/build-trampoline.spec.ts | 19 ++++++++++++++++++- packages/tools/src/build-trampoline.ts | 8 +++++++- 6 files changed, 34 insertions(+), 29 deletions(-) diff --git a/.github/actions/build-upstream/action.yml b/.github/actions/build-upstream/action.yml index 22f45a7275..ca374fa563 100644 --- a/.github/actions/build-upstream/action.yml +++ b/.github/actions/build-upstream/action.yml @@ -174,20 +174,12 @@ runs: env: INPUTS_TARGET: ${{ inputs.target }} - # The trampoline is excluded from the workspace and must build from its - # own directory so its .cargo/config.toml (build-std) applies. Pin - # CARGO_TARGET_DIR to an absolute path so the artifact still lands in the - # rust-target dir the cache and artifact paths expect. This runs on native - # Windows runners for release builds, so also treat drive-letter paths as - # absolute. + # The shared helper builds the excluded trampoline from its own directory + # and 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: | - target_dir="${CARGO_TARGET_DIR:-$PWD/target}" - case "$target_dir" in /*|[A-Za-z]:*) ;; *) target_dir="$PWD/$target_dir" ;; esac - cd crates/vp_trampoline - CARGO_TARGET_DIR="$target_dir" cargo build --release --target ${INPUTS_TARGET} + 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 a74525abf5..df2f9ce703 100644 --- a/.github/actions/build-windows-cli/action.yml +++ b/.github/actions/build-windows-cli/action.yml @@ -85,18 +85,12 @@ runs: XWIN_ACCEPT_LICENSE: '1' CXXFLAGS: -D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH - # The trampoline is excluded from the workspace and must build from its - # own directory so its .cargo/config.toml (build-std) applies. Pin - # CARGO_TARGET_DIR to an absolute path so the artifact still lands in the - # same target/ directory the artifact list above expects. + # The shared helper builds the excluded trampoline from its own directory + # and anchors CARGO_TARGET_DIR to the repository root. - name: Build trampoline shim binary if: steps.binaries-cache.outputs.cache-hit != 'true' shell: bash - run: | - target_dir="${CARGO_TARGET_DIR:-$PWD/target}" - case "$target_dir" in /*) ;; *) target_dir="$PWD/$target_dir" ;; esac - cd crates/vp_trampoline - CARGO_TARGET_DIR="$target_dir" cargo xwin build --release --target x86_64-pc-windows-msvc + run: node packages/tools/src/build-trampoline.ts --xwin --release --target x86_64-pc-windows-msvc env: XWIN_ACCEPT_LICENSE: '1' diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 09fa8a06bf..3542334c51 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -1294,11 +1294,7 @@ jobs: shell: bash run: | cargo build --release -p vp_global_cli -p vp_installer - - target_dir="${CARGO_TARGET_DIR:-$PWD/target}" - case "$target_dir" in /*|[A-Za-z]:*) ;; *) target_dir="$PWD/$target_dir" ;; esac - cd crates/vp_trampoline - CARGO_TARGET_DIR="$target_dir" cargo build --release + node packages/tools/src/build-trampoline.ts --release - name: Trampoline launches an extended-length payload path shell: pwsh diff --git a/justfile b/justfile index 45731a373b..f772e9e61e 100644 --- a/justfile +++ b/justfile @@ -100,8 +100,8 @@ snapshot-test *args='': _install_chromium _build-trampoline # directory so its .cargo/config.toml (build-std) applies. The helper anchors # relative CARGO_TARGET_DIR values to the repo root so all binaries stay in the # same artifact directory. -_build-trampoline: - node packages/tools/src/build-trampoline.ts +_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. diff --git a/packages/tools/src/__tests__/build-trampoline.spec.ts b/packages/tools/src/__tests__/build-trampoline.spec.ts index 50dd1b48f7..76d21714d8 100644 --- a/packages/tools/src/__tests__/build-trampoline.spec.ts +++ b/packages/tools/src/__tests__/build-trampoline.spec.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, test } from 'vitest'; -import { resolveCargoTargetDir } from '../build-trampoline.ts'; +import { resolveCargoArgs, resolveCargoTargetDir } from '../build-trampoline.ts'; const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)); @@ -21,3 +21,20 @@ describe('resolveCargoTargetDir', () => { expect(resolveCargoTargetDir(absolute)).toBe(absolute); }); }); + +describe('resolveCargoArgs', () => { + test('builds with cargo by default', () => { + expect(resolveCargoArgs(['--release', '--target', 'x86_64-pc-windows-msvc'])).toEqual([ + 'build', + '--release', + '--target', + 'x86_64-pc-windows-msvc', + ]); + }); + + test('builds with cargo-xwin 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 index 057dcf3e6c..ec4003b681 100644 --- a/packages/tools/src/build-trampoline.ts +++ b/packages/tools/src/build-trampoline.ts @@ -8,9 +8,15 @@ 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'); + return [...(xwin ? ['xwin'] : []), 'build', ...cargoArgs]; +} + export function buildTrampoline(args: string[] = process.argv.slice(2)) { const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; - execFileSync(cargo, ['build', ...args], { + execFileSync(cargo, resolveCargoArgs(args), { cwd: path.join(repoRoot, 'crates/vp_trampoline'), env: { ...process.env, From f05275fa904665edb063c738c9f9b32178c2ce80 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 22 Aug 2026 13:03:18 +0800 Subject: [PATCH 09/11] refactor(trampoline): simplify build workflow and docs --- .github/actions/build-upstream/action.yml | 4 +- .github/actions/build-windows-cli/action.yml | 4 +- .github/workflows/ci.yml | 6 +- .github/workflows/test-standalone-install.yml | 14 +- .gitignore | 7 +- AGENTS.md | 2 +- Cargo.toml | 7 +- .../tests/cli_snapshots/main.rs | 2 +- crates/vp_trampoline/.cargo/config.toml | 32 +-- crates/vp_trampoline/Cargo.toml | 55 ++-- crates/vp_trampoline/src/cmdline.rs | 51 ++-- crates/vp_trampoline/src/main.rs | 43 +-- crates/vp_trampoline/src/win.rs | 48 ++-- justfile | 13 +- packages/cli/publish-native-addons.ts | 2 +- .../src/__tests__/build-trampoline.spec.ts | 6 +- packages/tools/src/install-global-cli.ts | 4 +- rfcs/trampoline-exe-for-shims.md | 262 ++++++++++-------- rust-toolchain.toml | 4 +- 19 files changed, 297 insertions(+), 269 deletions(-) diff --git a/.github/actions/build-upstream/action.yml b/.github/actions/build-upstream/action.yml index ca374fa563..71bd56ff1d 100644 --- a/.github/actions/build-upstream/action.yml +++ b/.github/actions/build-upstream/action.yml @@ -174,8 +174,8 @@ runs: env: INPUTS_TARGET: ${{ inputs.target }} - # The shared helper builds the excluded trampoline from its own directory - # and anchors CARGO_TARGET_DIR to the repository root. + # 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 diff --git a/.github/actions/build-windows-cli/action.yml b/.github/actions/build-windows-cli/action.yml index df2f9ce703..fe2f2f7f49 100644 --- a/.github/actions/build-windows-cli/action.yml +++ b/.github/actions/build-windows-cli/action.yml @@ -85,8 +85,8 @@ runs: XWIN_ACCEPT_LICENSE: '1' CXXFLAGS: -D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH - # The shared helper builds the excluded trampoline from its own directory - # and anchors CARGO_TARGET_DIR to the repository root. + # 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6db4d6529c..1bddc36e9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,9 +206,9 @@ 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 excluded from the workspace. Its portable parser and - # layout tests run on Unix, while Windows shim behavior is covered by the - # Windows CLI snapshot suite. + # 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 ')" diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 3542334c51..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 @@ -1296,7 +1300,7 @@ jobs: cargo build --release -p vp_global_cli -p vp_installer node packages/tools/src/build-trampoline.ts --release - - name: Trampoline launches an extended-length payload path + - name: Test trampoline with an extended-length payload path shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -1311,7 +1315,7 @@ jobs: $payloadBin = Join-Path $data "current\bin" $payload = Join-Path $payloadBin "vp.exe" if ($payload.Length -le 260) { - throw "payload path is not longer than MAX_PATH: $payload" + throw "The payload path must be longer than MAX_PATH: $payload" } Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue @@ -1334,7 +1338,7 @@ jobs: $exitCode = $LASTEXITCODE Write-Host $output if ($exitCode -ne 0) { - throw "trampoline exited with $exitCode for payload path $payload" + throw "The trampoline exited with $exitCode for payload path $payload" } - name: vp-setup.exe rejects invalid directory overrides @@ -1747,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 f3ebd0e7a1..1f25eb0022 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +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 fmt/clippy --manifest-path crates/vp_trampoline/Cargo.toml` from the repo -# root does not read the crate config (target-dir), so it creates a nested -# target dir. +# 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 5971a52919..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 (standalone package, excluded from the workspace) +└── crates/vp_trampoline/ # Standalone Windows shim trampoline outside the workspace ``` Vite+ resolves all on-disk paths through `vp_shared::VpDirs`. diff --git a/Cargo.toml b/Cargo.toml index 8ac182cea9..d2d11a49a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [workspace] resolver = "3" members = ["bench", "crates/*", "packages/cli/binding"] -# vp_trampoline is a standalone package: it needs its own release profile with -# panic = "immediate-abort" (cargo ignores `panic` in per-package profile -# overrides) and a crate-local build-std config. See crates/vp_trampoline/Cargo.toml. +# 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] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index b5e7ac3fd9..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 `cd crates/vp_trampoline && cargo build`", + "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 index 14bd5f8ad4..fcd7c35d1c 100644 --- a/crates/vp_trampoline/.cargo/config.toml +++ b/crates/vp_trampoline/.cargo/config.toml @@ -1,24 +1,24 @@ -# This config only applies when cargo runs from this directory (config -# discovery is cwd-based), which is why the trampoline must be built with -# `cd crates/vp_trampoline && cargo build ...` and not with `-p vp_trampoline` -# from the repo root. +# 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 (opt-level = "z" and -# panic = "immediate-abort"). Together with the no_main raw-Win32 source this -# takes the sidecar-aware exe to 14 KiB on x86_64-pc-windows-msvc. Requires the -# rust-src rustup component. +# 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 std's default features (drops panic-unwind and backtrace, enables -# the size-optimized code paths). compiler-builtins-mem provides memcpy and -# friends from compiler_builtins instead of the CRT, which the #![no_main] -# entry point needs. +# 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"] -# Let `cargo test` run with the abort-family panic strategy. +# Use the abort panic strategy for `cargo test`. panic-abort-tests = true [build] -# Keep artifacts in the repo-root target/ directory, where CI steps, the -# snapshot runner, and install-global-cli expect them. This path resolves -# relative to this crate directory. A CARGO_TARGET_DIR env var still wins. +# 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.toml b/crates/vp_trampoline/Cargo.toml index dbea2ca869..60d51c8cdb 100644 --- a/crates/vp_trampoline/Cargo.toml +++ b/crates/vp_trampoline/Cargo.toml @@ -1,22 +1,22 @@ -# This crate is excluded from the workspace on purpose (see the root -# Cargo.toml). It needs its own release profile with panic = "immediate-abort", -# which cargo ignores in per-package profile overrides, plus the crate-local -# .cargo/config.toml that enables build-std. Build it from this directory so -# that config applies: +# 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: # -# cd crates/vp_trampoline && cargo build --release [--target ] +# node packages/tools/src/build-trampoline.ts --release [--target ] # -# Artifacts land in the repo-root target/ directory (see .cargo/config.toml), -# the same location as workspace builds. The build needs the pinned nightly -# toolchain and the rust-src component (both come from the repo -# rust-toolchain.toml). +# 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. # -# Size on x86_64-pc-windows-msvc: 14 KiB, down from ~222 KiB when the -# sidecar-aware implementation used the precompiled std. build-std recompiles -# std with this profile, panic = "immediate-abort" compiles out the panic -# formatting, unwinding, and backtrace machinery, and the Windows build uses -# #![no_main] with raw Win32 calls (src/win.rs) instead of -# std::process::Command. Background: rfcs/trampoline-exe-for-shims.md. +# 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] @@ -32,13 +32,12 @@ description = "Minimal Windows trampoline exe for vite-plus shims" 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. -# This crate does not inherit the workspace lints. It intentionally uses std -# types and macros directly instead of vp_shared, vt_path, vt_str, etc. to -# keep the binary size small; allow the repo-wide .clippy.toml restrictions -# that exist to funnel code through those crates. +# 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" @@ -49,15 +48,15 @@ opt-level = "z" lto = "fat" codegen-units = 1 strip = "symbols" -# Stronger than "abort": panics become a bare abort with no message -# formatting, so core::fmt and std::panicking never get linked. +# Convert panics to an immediate abort without message formatting. +# This prevents links to core::fmt and std::panicking. panic = "immediate-abort" debug = false -# Debug builds must still optimize a little: at opt-level 0 the compiler can -# emit references to the MSVC unwinding helper __CxxFrameHandler3 even with -# panic = "immediate-abort", and the link fails (same constraint as -# uv-trampoline). +# 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 diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs index c4f2f96cc4..a71809f0de 100644 --- a/crates/vp_trampoline/src/cmdline.rs +++ b/crates/vp_trampoline/src/cmdline.rs @@ -1,6 +1,6 @@ -//! Pure helpers over UTF-16 code units and bytes, shared by the Windows -//! implementation. They live outside win.rs so the unit tests run on every -//! platform. +//! 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; @@ -34,9 +34,9 @@ pub struct ShimPointer<'a> { /// Parse the UTF-8 `.shim` sidecar written by `vp_shared::VpDirs`. /// -/// Sidecars record the directory layout, data root, and cache root. The parser -/// requires the versioned header and matches `vp_shared`, including UTF-8 BOM -/// and CRLF support. +/// 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(); @@ -71,13 +71,14 @@ pub fn parse_shim_pointer(bytes: &[u8]) -> Option> { Some(ShimPointer { data, layout }) } -/// Index where the raw command line's first (program) argument ends. +/// Return the end index of the first program argument in a raw command line. /// -/// This follows the MSVC parsing rule for the program name: a quote toggles -/// quoted mode, backslashes have no escaping effect, and leading whitespace -/// terminates an empty program argument. The remainder (`&cmdline[result..]`, -/// leading whitespace included) is the argument tail to forward to the child -/// verbatim. +/// 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; @@ -99,12 +100,12 @@ fn is_path_separator(unit: u16) -> bool { /// Add the Win32 extended-length prefix to a normalized absolute path. /// -/// The caller must resolve `.` and `..` components and replace `/` separators -/// before calling this function because the extended-length namespace treats -/// the rest of the path verbatim. +/// 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 paths that already use an extended-length or NT namespace. + // Keep an existing extended-length or NT namespace. [BACKSLASH, BACKSLASH, QUESTION, BACKSLASH, ..] | [BACKSLASH, QUESTION, QUESTION, BACKSLASH, ..] => return path.to_vec(), // C:\path => \\?\C:\path @@ -122,13 +123,12 @@ pub fn verbatim_path(path: &[u16]) -> Vec { extended } -/// End index of a parent directory, preserving the separator when it is part -/// of a Windows root. +/// Return the end index of a parent directory. +/// Keep the separator when it is part of a Windows root. /// -/// Removing the separator from `C:\vp.exe` would produce the drive-relative -/// path `C:`. Device roots such as `\\?\Volume{...}\vp.exe` have the same -/// constraint. Other parents omit their trailing separator, matching -/// `Path::parent`. +/// 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 @@ -145,8 +145,8 @@ pub fn parent_dir_len(path: &[u16], last_separator: usize) -> usize { } } -/// Length of the file stem, matching `Path::file_stem`: everything before the -/// last `.`, except that a leading `.` never starts an extension. +/// 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, @@ -159,7 +159,8 @@ 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 into `buf`, returning the used suffix. +/// 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 { diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index 9ad60d46a1..c45b5724ec 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -1,21 +1,24 @@ //! 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, reads -//! the install roots from the adjacent `.shim` sidecar, and starts the -//! active `vp.exe` with the matching dispatch environment. +//! 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. //! -//! On Windows, `#![no_main]` and raw Win32 calls avoid the CRT startup and the -//! `std::process::Command` implementation. The standalone build recompiles -//! `std` for size and uses immediate-abort panics. Error paths still report the -//! failed operation, relevant path, and Windows error code. See -//! `rfcs/trampoline-exe-for-shims.md`. +//! 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. Unix shims are -//! symlinks and never ship this binary. +//! The non-Windows implementation exists for portable tests. +//! Vite+ does not ship this binary for Unix shims because they are symlinks. //! //! See: @@ -27,8 +30,9 @@ mod cmdline; #[cfg(windows)] mod win; -/// The linker picks this symbol as the console-subsystem entry point, so no -/// `/ENTRY:` flag is needed. The `std` runtime does not initialize; see win.rs. +/// 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)] @@ -67,15 +71,15 @@ mod portable { pointer: ShimPointer, } - /// How the child `vp.exe` should resolve category roots. + /// Specify how the child `vp.exe` resolves category roots. enum ChildDirPins<'a> { - /// `VP_HOME` or a grandfathered install explicitly selected one root. + /// `VP_HOME` or an installation from an earlier version selected one root. SingleRoot, - /// The versioned sidecar explicitly selected split roots. + /// The versioned sidecar explicitly selected separate roots. Split { cache: &'a Path }, } - /// Preserve Unix signal termination using the shell's `128 + signal` convention. + /// Return a Unix signal exit code with the shell's `128 + signal` convention. fn exit_code_from_status(status: ExitStatus) -> i32 { #[cfg(unix)] { @@ -144,8 +148,9 @@ mod portable { if tool_name != "vp" { cmd.env("VP_SHIM_TOOL", tool_name); - // A nested shim must resolve the version again instead of using - // passthrough mode. Must match vp_shared::env_vars::VP_TOOL_RECURSION. + // 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"); } diff --git a/crates/vp_trampoline/src/win.rs b/crates/vp_trampoline/src/win.rs index 40be00447c..460ef818b5 100644 --- a/crates/vp_trampoline/src/win.rs +++ b/crates/vp_trampoline/src/win.rs @@ -1,9 +1,10 @@ //! Raw Win32 trampoline implementation. //! -//! The `#![no_main]` entry point jumps directly here, so the CRT startup and -//! the Rust `std` runtime do not initialize. This module uses KERNEL32 calls -//! for sidecar I/O, environment setup, process creation, diagnostics, and -//! process exit. `Vec` uses the Windows process heap and needs no runtime init. +//! 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}; @@ -29,9 +30,9 @@ 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; -// Match the conservative threshold used by Rust's Windows path handling. -// CreateDirectoryW reserves extra space below MAX_PATH, so std normalizes all -// paths at this length even when the immediate API accepts a few more units. +// 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; @@ -136,10 +137,10 @@ unsafe extern "system" { fn ExitProcess(exit_code: u32) -> !; } -// Current nightlies register exit-time TLS cleanup through C `atexit`. Linking -// the CRT implementation would pull its startup machinery into this no_main -// binary. ExitProcess never runs TLS destructors, so a successful no-op is the -// correct implementation for this process. +// 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 @@ -199,9 +200,10 @@ fn is_verbatim(path: &[u16]) -> bool { /// Return a NUL-terminated path suitable for Win32 file and process APIs. /// -/// This mirrors the standard library behavior that the raw implementation -/// replaced: long paths are made absolute and normalized before entering the -/// extended-length namespace. +/// 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 { @@ -480,8 +482,9 @@ pub fn run() -> ! { set_env(w!("VP_TOOL_RECURSION"), b"VP_TOOL_RECURSION", None); } - // 3. Build the child command line from the active payload plus the raw - // caller argument tail. Forwarding the tail preserves the caller's quoting. + // 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 { @@ -503,14 +506,16 @@ pub fn run() -> ! { child_cmdline.extend_from_slice(tail); child_cmdline.push(0); - // 4. Ignore console control events in the trampoline. The child receives - // the same event and decides how to handle it. + // 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 our startup info. When the parent supplied redirected stdio, - // make those handles inheritable before CreateProcessW. + // 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) }; @@ -560,7 +565,8 @@ pub fn run() -> ! { unsafe { ExitProcess(1) } } - // 6. Wait for the child and propagate its exact exit code. + // 6. Wait for the child. + // Propagate its exact exit code. unsafe { CloseHandle(pi.thread); let wait = WaitForSingleObject(pi.process, INFINITE); diff --git a/justfile b/justfile index f772e9e61e..d2fed929b5 100644 --- a/justfile +++ b/justfile @@ -72,9 +72,9 @@ 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 excluded from the workspace and tests from its own -# directory on Unix. Its portable parser and layout tests run there, while -# Windows shim behavior is covered by the Windows CLI snapshot suite. +# 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: @@ -96,10 +96,9 @@ snapshot-test *args='': _install_chromium _build-trampoline cargo build -p vp_global_cli cargo test -p vp_cli_snapshots -- {{args}} -# The trampoline is excluded from the workspace; build it from its own -# directory so its .cargo/config.toml (build-std) applies. The helper anchors -# relative CARGO_TARGET_DIR values to the repo root so all binaries stay in the -# same artifact directory. +# 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}} diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index 163e56365c..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 "cd crates/vp_trampoline && cargo build --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 index 76d21714d8..9caeb0f072 100644 --- a/packages/tools/src/__tests__/build-trampoline.spec.ts +++ b/packages/tools/src/__tests__/build-trampoline.spec.ts @@ -8,7 +8,7 @@ import { resolveCargoArgs, resolveCargoTargetDir } from '../build-trampoline.ts' const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)); describe('resolveCargoTargetDir', () => { - test('anchors relative paths to the repository root', () => { + test('resolves relative paths from the repository root', () => { expect(resolveCargoTargetDir('artifacts')).toBe(path.join(repoRoot, 'artifacts')); }); @@ -23,7 +23,7 @@ describe('resolveCargoTargetDir', () => { }); describe('resolveCargoArgs', () => { - test('builds with cargo by default', () => { + test('uses cargo build by default', () => { expect(resolveCargoArgs(['--release', '--target', 'x86_64-pc-windows-msvc'])).toEqual([ 'build', '--release', @@ -32,7 +32,7 @@ describe('resolveCargoArgs', () => { ]); }); - test('builds with cargo-xwin when requested', () => { + 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/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index 6a1c37a027..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: cd crates/vp_trampoline && cargo build --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 655b4374fa..58438d576e 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -124,79 +124,81 @@ layout and also records that Vite+ owns the adjacent executable. ``` crates/vp_trampoline/ -├── Cargo.toml # Zero dependencies, own release profile -├── Cargo.lock # Own lockfile (the crate is not a workspace member) +├── Cargo.toml # Package settings and release profile +├── Cargo.lock # Lockfile for this standalone crate ├── .cargo/ -│ └── config.toml # build-std flags + target-dir = repo-root target/ +│ └── config.toml # build-std and artifact directory settings ├── src/ -│ ├── main.rs # Entry points + portable non-Windows fallback -│ ├── win.rs # Windows implementation: raw Win32, no_main entry -│ └── cmdline.rs # Command-line and sidecar parsers with 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 ``` -The crate is excluded from the workspace (`exclude` in the root `Cargo.toml`). -Two build requirements force this: +The root `Cargo.toml` excludes this crate from the workspace. The crate must +stay outside the workspace for two reasons: - The release profile sets `panic = "immediate-abort"`. Cargo ignores `panic` - in per-package profile overrides, so the crate needs its own profile. -- The crate-local `.cargo/config.toml` enables build-std. Cargo reads that - config only when it runs from the crate directory. + 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. -Build it from the crate directory: +From the repository root, run: ```bash -cd crates/vp_trampoline && cargo build --release [--target ] +node packages/tools/src/build-trampoline.ts --release [--target ] ``` -Artifacts land in the repo-root `target/` directory (the crate config sets -`target-dir = "../../target"`), so CI steps and `install-global-cli` find -`vp-shim.exe` in the same place as workspace-built binaries. The build needs -the pinned nightly toolchain and the `rust-src` component; both come from the -repo `rust-toolchain.toml`. +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. ### Trampoline Binary -The trampoline has **zero external dependencies**: all Win32 calls are raw -`extern "system"` declarations against KERNEL32, so the heavy -`windows`/`windows-core` crates never enter the build. It also never touches -`core::fmt`; diagnostics go through `WriteFile` with a hand-rolled decimal -formatter. - -On Windows the binary is `#![no_main]` with an exported `mainCRTStartup` -symbol, so neither the CRT startup nor `std` runtime init runs. The flow in -`src/win.rs`: - -1. `GetModuleFileNameW` gives the shim path and tool name. Replacing the `.exe` - extension with `.shim` locates the per-tool sidecar. -2. `CreateFileW` and `ReadFile` load the UTF-8 sidecar. Long paths are made - absolute with `GetFullPathNameW`, then use the `\\?\` drive prefix or the - `\\?\UNC\` network prefix. The parser requires the versioned header and - accepts the `single-root` and `split` layouts. -3. `SetEnvironmentVariableW` pins the sidecar's layout. A single-root pointer - sets `VP_HOME`. A split pointer removes `VP_HOME` and sets `VP_DATA_DIR`, - `VP_BIN_DIR`, and `VP_CACHE_DIR`. Tool shims also set `VP_SHIM_TOOL` and +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 is `"\current\bin\vp.exe"` plus the raw - `GetCommandLineW` tail after the program argument. The split follows the - MSVC `argv[0]` rule: quotes toggle, and backslashes do not escape. This - preserves the caller's exact UTF-16 argument tail. +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 decides how to react. -6. `CreateProcessW` spawns the child with inherited handles and startup info. - The payload path uses the same extended-length normalization as the sidecar. - When the parent redirected stdio (`STARTF_USESTDHANDLES`), the standard - handles are forced inheritable first, as in uv-trampoline and distlib. -7. `WaitForSingleObject`, `GetExitCodeProcess`, and `ExitProcess` propagate the - child's exit code unchanged. - -Launch-critical failures report the failed call or operation, the relevant -path, and the Windows error code when one exists. A missing `vp.exe` also prints -a recovery hint to reinstall Vite+ or run `vp env setup`. - -The non-Windows implementation uses `std::process::Command` and the same -sidecar parser for portable tests. Unix shims are symlinks and never use it. + 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 the layout from directory paths. +infer a layout from directory paths. ### Size Optimization @@ -210,25 +212,24 @@ infer the layout from directory paths. | `#![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 both x86_64-pc-windows-msvc and -aarch64-pc-windows-msvc, including sidecar parsing and error diagnostics. The -sidecar-aware `std::process::Command` implementation was 221,696 B on x86_64. -See Future Optimizations for the measured size ladder. The executable imports -only KERNEL32. +**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 Future +Optimizations for all measurements. The executable imports only KERNEL32. ### Environment Variables The sidecar controls the directory environment inherited by `vp.exe`: -| Variable | When | Purpose | -| ------------------- | ----------------------- | ----------------------------------------------------- | -| `VP_HOME` | Single-root layout | Pins all Vite+ directories to the sidecar's data root | -| `VP_HOME` | Split layout | Removed so it cannot override the category roots | -| `VP_DATA_DIR` | Split layout | Pins the payload and state root | -| `VP_BIN_DIR` | Split layout | Pins the directory that contains the shim | -| `VP_CACHE_DIR` | Split layout | Pins the cache root | -| `VP_SHIM_TOOL` | Tool shims, except `vp` | Selects shim dispatch for the named tool | -| `VP_TOOL_RECURSION` | Removed for tool shims | Forces fresh version resolution for nested shim calls | +| 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 @@ -244,12 +245,14 @@ 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) → loads the recorded directory layout - → sets VP_SHIM_TOOL=node and the directory pins, removes VP_TOOL_RECURSION + → 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) @@ -303,24 +306,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"`) | Nightly Rust (same technique) | -| **Binary size** | 39-47 KiB | 14 KiB | -| **Entry point** | `#![no_main]` + `mainCRTStartup` | Same approach | -| **Error output** | `ufmt` (no `core::fmt`) | `WriteFile` + Win32 error codes | -| **Ctrl+C handling** | `SetConsoleCtrlHandler` → ignore | Same approach | -| **Exit code** | `GetExitCodeProcess` → `exit()` | Same approach | - -The Vite+ trampoline is smaller because it embeds no PE resources and only -normalizes long file and process paths. It needs no job objects or GUI -subsystem support. It reads a small sidecar next to its own filename, resolves -`vp.exe` under the recorded data root, and starts it. Both projects share the -same build recipe and entry-point structure. +| 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 @@ -346,10 +349,10 @@ Adds ~100KB to the binary for a single `SetConsoleCtrlHandler` call. Raw FFI dec ## Future Optimizations -Every variant below was built with cargo-xwin and measured on +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 are earlier fixed-layout experiments. The -last row is the current sidecar-aware raw implementation. +implementation. The next five rows show earlier fixed-layout experiments. The +last row shows the current sidecar-aware raw implementation. | Variant | Toolchain | Size | | ---------------------------------------------------------------------------- | --------- | --------- | @@ -362,42 +365,55 @@ last row is the current sidecar-aware raw implementation. | 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: uv-trampoline ships 45,056 B (x64 console), Scoop's default -kiennq shim is 136,192 B (statically linked MSVC C), and Scoop once vendored -and then reverted a 317,952 B Rust shim. - -### Gotchas (all hit while measuring) - -1. **`atexit` link failure**: current nightlies register TLS destructor - cleanup through C `atexit`. Under `#![no_main]` that symbol pulls - `msvcrt.lib(utility.obj)`, and the link fails with undefined `__vcrt_*` / - `__acrt_*` CRT init internals. Fix: export a no-op - `extern "C" fn atexit(...) -> i32 { 0 }` (see win.rs). The trampoline - never needs exit-time TLS destructors. uv's documented - `rustc-link-lib=ucrt` workaround (rust-lang/rust#143172) does not fix this - pull; uv's pinned older nightly simply predates the `atexit` registration. -2. **Subsystem**: `#![no_main]` requires an explicit - `#![windows_subsystem = "console"]`, or lld fails with "subsystem must be - defined". -3. **Do not use `+crt-static`**: it links the static CRT and grows the binary - to ~115KB. -4. **Dev profile**: at `opt-level = 0` the compiler can emit references to - the MSVC unwinding helper `__CxxFrameHandler3` even with - `panic = "immediate-abort"`, and the link fails. Keep `opt-level = 1` and - LTO in the dev profile (uv does the same). +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` - (as uv does), so a killed shim also kills its child. Costs a few KB. -- Commit prebuilt, reproducible trampoline binaries (uv checks in - `/Brepro`-normalized exes and verifies them byte for byte in CI) to - decouple the shim from toolchain drift. +- 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. Same build recipe (workspace exclusion, build-std, `panic="immediate-abort"`, cargo-xwin), plus `#![no_main]`, raw Win32, and a CI `cargo bloat` gate that rejects any `core::fmt`/`std::panicking` symbol. -- [Scoop shims](https://github.com/ScoopInstaller/Scoop/tree/master/supporting/shims): vendored native C shim (136KB, from kiennq/scoop-better-shimexe) and C# .NET shim (9.7KB); launch targets come from a sibling `.shim` text file. +- [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 c60f4ced67..1c39b449c8 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -4,6 +4,6 @@ # - `windows_process_extensions_main_thread_handle` to get the main thread handle for Detours injection channel = "nightly-2026-08-02" profile = "default" -# rust-src: crates/vp_trampoline builds std from source (build-std) to -# minimize the shim binary size. +# vp_trampoline uses rust-src to build std from source. +# This reduces the shim binary size. components = ["rust-src"] From 38bd14f18e095e7e1a20fe5ad760aa236dcd39db Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 22 Aug 2026 22:18:14 +0800 Subject: [PATCH 10/11] refactor(trampoline): simplify implementation --- crates/vp_trampoline/src/cmdline.rs | 4 ---- crates/vp_trampoline/src/main.rs | 27 ++++++-------------------- crates/vp_trampoline/src/win.rs | 24 ++++++++++++----------- packages/tools/src/build-trampoline.ts | 7 +++++-- 4 files changed, 24 insertions(+), 38 deletions(-) diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs index a71809f0de..9941a590fa 100644 --- a/crates/vp_trampoline/src/cmdline.rs +++ b/crates/vp_trampoline/src/cmdline.rs @@ -40,10 +40,6 @@ pub struct ShimPointer<'a> { 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(); - if text.is_empty() { - return None; - } - let mut lines = text.lines(); if lines.next()? != SHIM_POINTER_HEADER { return None; diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index c45b5724ec..317e8393ed 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -71,14 +71,6 @@ mod portable { pointer: ShimPointer, } - /// Specify how the child `vp.exe` resolves category roots. - enum ChildDirPins<'a> { - /// `VP_HOME` or an installation from an earlier version selected one root. - SingleRoot, - /// The versioned sidecar explicitly selected separate roots. - Split { cache: &'a Path }, - } - /// Return a Unix signal exit code with the shell's `128 + signal` convention. fn exit_code_from_status(status: ExitStatus) -> i32 { #[cfg(unix)] @@ -91,13 +83,6 @@ mod portable { status.code().unwrap_or(1) } - fn child_dir_pins(pointer: &ShimPointer) -> ChildDirPins<'_> { - match &pointer.layout { - ShimLayout::SingleRoot => ChildDirPins::SingleRoot, - ShimLayout::Split { cache } => ChildDirPins::Split { cache }, - } - } - /// Locate `vp.exe` from `/.shim`. fn resolve_vp_exe(exe_path: &Path) -> Option { let pointer = read_shim_pointer(exe_path)?; @@ -134,11 +119,11 @@ mod portable { // 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 child_dir_pins(&location.pointer) { - ChildDirPins::SingleRoot => { + match &location.pointer.layout { + ShimLayout::SingleRoot => { cmd.env("VP_HOME", &location.pointer.data); } - ChildDirPins::Split { cache } => { + ShimLayout::Split { cache } => { cmd.env_remove("VP_HOME"); cmd.env("VP_DATA_DIR", &location.pointer.data); cmd.env("VP_BIN_DIR", bin_dir); @@ -320,8 +305,8 @@ mod portable { let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); assert!(matches!( - child_dir_pins(&location.pointer), - ChildDirPins::Split { cache: value } if value == cache + location.pointer.layout, + ShimLayout::Split { cache: value } if value == cache )); let _ = fs::remove_dir_all(&root); } @@ -340,7 +325,7 @@ mod portable { .unwrap(); let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); - assert!(matches!(child_dir_pins(&location.pointer), ChildDirPins::SingleRoot)); + 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 index 460ef818b5..26faa1a869 100644 --- a/crates/vp_trampoline/src/win.rs +++ b/crates/vp_trampoline/src/win.rs @@ -402,11 +402,12 @@ fn read_pointer_file(path: &[u16]) -> Vec { let ok = unsafe { ReadFile(handle, bytes.as_mut_ptr(), size as u32, &raw mut read, ptr::null_mut()) }; - let error = if ok == 0 { unsafe { GetLastError() } } else { 0 }; - unsafe { CloseHandle(handle) }; 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); } @@ -419,12 +420,12 @@ fn read_pointer_file(path: &[u16]) -> Vec { // --------------------------------------------------------------------------- fn set_env(name: &[u16], name_ascii: &[u8], value: Option<&[u16]>) { - let value = value.map(nul_terminated); - let value_ptr = value.as_ref().map_or(ptr::null(), |value| value.as_ptr()); + 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.is_none() && error == ERROR_ENVVAR_NOT_FOUND { + if value_nul.is_none() && error == ERROR_ENVVAR_NOT_FOUND { return; } stderr_write(b"vite-plus shim: SetEnvironmentVariableW("); @@ -570,12 +571,13 @@ pub fn run() -> ! { unsafe { CloseHandle(pi.thread); let wait = WaitForSingleObject(pi.process, INFINITE); - if wait == WAIT_FAILED { - fail_call(b"WaitForSingleObject"); - } - if wait != WAIT_OBJECT_0 { - report_call_failure(b"WaitForSingleObject returned an unexpected status", wait); - ExitProcess(1); + 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 { diff --git a/packages/tools/src/build-trampoline.ts b/packages/tools/src/build-trampoline.ts index ec4003b681..ea34393fcd 100644 --- a/packages/tools/src/build-trampoline.ts +++ b/packages/tools/src/build-trampoline.ts @@ -11,10 +11,13 @@ export function resolveCargoTargetDir(configured: string | undefined): string { export function resolveCargoArgs(args: string[]): string[] { const xwin = args.includes('--xwin'); const cargoArgs = args.filter((arg) => arg !== '--xwin'); - return [...(xwin ? ['xwin'] : []), 'build', ...cargoArgs]; + if (xwin) { + return ['xwin', 'build', ...cargoArgs]; + } + return ['build', ...cargoArgs]; } -export function buildTrampoline(args: string[] = process.argv.slice(2)) { +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'), From 2d4a47d7bba13bdbc0206399e87e506cd39eab36 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 22 Aug 2026 22:57:35 +0800 Subject: [PATCH 11/11] docs(rfc): rename trampoline size section --- rfcs/trampoline-exe-for-shims.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index 58438d576e..582696a58b 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -214,8 +214,9 @@ infer a layout from directory paths. **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 Future -Optimizations for all measurements. The executable imports only KERNEL32. +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 @@ -347,7 +348,7 @@ Requires administrator privileges or Developer Mode. Not reliable for all users. Adds ~100KB to the binary for a single `SetConsoleCtrlHandler` call. Raw FFI declaration is sufficient. -## Future Optimizations +## 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`