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 822d69e94b..399b40b3f1 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 f7b64ae47c..0ba81070b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,11 +206,13 @@ 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 and its only test module + # is unix-only, so it has nothing to contribute to the Windows archive. - name: Build test archive run: | eval "$(cargo xwin env --target x86_64-pc-windows-msvc | grep '^export ')" unset RUSTFLAGS - cargo nextest archive $(for d in crates/*/; do n=$(basename $d); [ "$n" = "vp_cli_snapshots" ] || echo -n "-p $n "; done) -p vite-plus-cli \ + cargo nextest archive $(for d in crates/*/; do n=$(basename $d); [ "$n" = "vp_cli_snapshots" ] || [ "$n" = "vp_trampoline" ] || echo -n "-p $n "; done) -p vite-plus-cli \ --target x86_64-pc-windows-msvc --archive-file windows-tests.tar.zst - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -335,6 +337,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 34a7d5d012..7e4e23f872 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ vite-plus/ ├── crates/vp_setup/ # Setup helpers ├── crates/vp_shared/ # Shared Rust env config, tracing, output, utilities ├── crates/vp_static_config/ # Static extraction of vite.config.* data -└── crates/vp_trampoline/ # Windows shim trampoline +└── crates/vp_trampoline/ # Windows shim trampoline (standalone package, excluded from the workspace) ``` `packages/test` is no longer tracked. The public test API is `vite-plus/test*`, generated by `packages/cli/build.ts` as shims over upstream `vitest` and `@vitest/browser*` exports. diff --git a/Cargo.lock b/Cargo.lock index c86cd2578e..4a81bf10c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8695,10 +8695,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 d94e4453ba..791eaf14bf 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 = [ @@ -427,11 +431,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 c0991f0014..195b065c57 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -538,7 +538,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..ef872eb561 --- /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: ~208KB -> ~72KB 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..d70f89331d 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: ~72KB, down from ~208KB 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 3e26c59b16..781c38fd6f 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -101,7 +101,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 c9f7c516a4..32a7397dfe 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -94,11 +94,34 @@ Each trampoline is a copy of `vp-shim.exe` (the template binary distributed alon ``` 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 # ~90 lines, single-file binary +│ └── main.rs # ~90 lines, single-file binary ``` +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** — the Win32 FFI call (`SetConsoleCtrlHandler`) is declared inline to avoid the heavy `windows`/`windows-core` crates. It also avoids `core::fmt` (~100KB overhead) by never using `format!`, `eprintln!`, `println!`, or `.unwrap()`. @@ -157,14 +180,22 @@ fn install_ctrl_handler() { ### 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**: ~72KB on x86_64-pc-windows-msvc (~70KB on aarch64). With the +precompiled `std` the same source built to ~208KB: 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 @@ -252,14 +283,14 @@ 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) | None (uses filename + relative path) | | **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 | ~72 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 is significantly simpler because it doesn't need to embed data in PE resources — it just reads its own filename, finds `vp.exe` at a fixed relative path, and spawns it. 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 is simpler because it embeds no data in PE resources: it reads its own filename, finds `vp.exe` at a fixed relative path, and spawns it. Both projects build with build-std and `panic = "immediate-abort"`. The remaining ~27KB gap comes from `std::process::Command` and `std` runtime init, which uv replaces with raw `CreateProcessA` and `#![no_main]` (see Future Optimizations for the measured equivalent here). ## Alternatives Considered @@ -277,7 +308,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 ~100KB. +~5-10MB per copy. Trampoline achieves the same result at ~72KB. ### 5. `windows` Crate for FFI (Rejected) @@ -285,17 +316,83 @@ Adds ~100KB to the binary for a single `SetConsoleCtrlHandler` call. Raw FFI dec ## Future Optimizations -If the ~100KB 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 | 212,992 B | +| Current source + build-std + `panic="immediate-abort"` (shipped today) | nightly | 73,728 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"]