refactor: improve performance

This commit is contained in:
zhom
2026-08-12 13:28:21 -07:00
parent d73bcfa4c9
commit c4dcc52584
12 changed files with 1497 additions and 1261 deletions
Generated
+700 -760
View File
File diff suppressed because it is too large Load Diff
+33 -27
View File
@@ -9,37 +9,39 @@ version = "0.2.2"
edition = "2021"
[dependencies]
clap = { version = "4.4", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
lazy_static = "1.4"
uuid = { version = "1.0", features = ["v4"] }
sha2 = "0.10"
zip = "4"
clap = { version = "4.6", features = ["derive"] }
tokio = { version = "1.53", features = ["full"] }
# rustls rather than the default native-tls: it drops the OpenSSL system dependency, so
# `cargo install banderole` no longer needs libssl-dev / pkg-config on Linux.
reqwest = { version = "0.13", default-features = false, features = [
"stream",
"json",
"rustls",
"webpki-roots",
"charset",
"http2",
] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
uuid = { version = "1.24", features = ["v4"] }
zip = { version = "8", default-features = false, features = ["deflate", "zstd"] }
directories = "6"
anyhow = "1.0"
walkdir = "2.4"
futures-util = "0.3"
chrono = { version = "0.4", features = ["serde"] }
tempfile = "3.20"
base64 = "0.22"
indicatif = "0.18"
log = "0.4"
env_logger = "0.11"
indicatif-log-bridge = "0.2"
console = "0.16"
flate2 = "1.0"
tar = "0.4"
sevenz-rust = "0.6"
anyhow = "1.0.104"
walkdir = "2.5"
futures-util = "0.3.33"
tempfile = "3.27"
indicatif = "0.18.6"
log = "0.4.33"
env_logger = "0.11.11"
indicatif-log-bridge = "0.2.3"
console = "0.16.4"
tar = "0.4.46"
# sevenz-rust is unmaintained; sevenz-rust2 is the maintained fork.
sevenz-rust2 = "0.21"
lzma-rs = "0.3"
[build-dependencies]
reqwest = { version = "0.12", features = ["blocking"] }
[dev-dependencies]
serial_test = "3"
serial_test = "4"
[[test]]
name = "integration_test"
@@ -61,6 +63,10 @@ harness = true
name = "concurrent_execution_integration_test"
harness = true
[[test]]
name = "launcher_semantics_integration_test"
harness = true
# Run tests sequentially to avoid resource conflicts
[profile.test]
opt-level = 0
+32 -2
View File
@@ -4,11 +4,41 @@ Create cross-platform single-executables for Node.js projects. Windows is not su
Banderole bundles your Node.js app, all dependencies, and a portable Node binary into a single native executable. On first launch, it unpacks to a cache directory for fast subsequent executions.
Unlike [Node.js SEA](https://nodejs.org/api/single-executable-applications.html) or [pkg](https://github.com/yao-pkg/pkg), banderole handles complex projects with dynamic imports and non-JavaScript files without requiring patches, but since it includes all dependencies by default, it has significantly larger filesize.
Unlike [Node.js SEA](https://nodejs.org/api/single-executable-applications.html) or [pkg](https://github.com/yao-pkg/pkg), banderole handles complex projects with dynamic imports and non-JavaScript files without requiring patches — it ships a stock Node binary and your real dependency tree rather than a patched runtime and a virtual filesystem.
## Performance
Measured on linux-x64, Node 22.17.1, against [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) 6.22.0 building the same app. Startup is the median of 60 interleaved runs (targets rotate every round, so machine drift affects both equally).
| | trivial app | | app with deps<br>(express, lodash, chalk, dayjs) | |
|---|---|---|---|---|
| | **banderole** | pkg | **banderole** | pkg |
| Executable size | **33,416,408 B** | 74,306,703 B | **35,366,424 B** | 77,346,956 B |
| Startup (median) | **16.1 ms** | 25.7 ms | **53.1 ms** | 78.8 ms |
| Resident processes | **1** | 1 | **1** | 1 |
| Resident memory (RSS) | **43.8 MiB** | 49.4 MiB | **66.4 MiB** | 76.2 MiB |
pkg's size floor is the uncompressed Node binary it embeds, which its `--compress` option does not touch. banderole compresses the Node binary with zstd and ships only the executable itself, so a bundle is roughly half the size.
The trade-off is the **first** launch, which unpacks the payload into the cache directory: 117 ms versus pkg's 30 ms for the trivial app. Every launch after that takes the warm path above.
### Scaling with project size
First-launch extraction grows with the dependency tree; steady-state launch does not, because it is one file read followed by `exec`.
| `node_modules` | files | bundle time | first launch | **every later launch** |
|---|---|---|---|---|
| — | 0 | 16 s | 159 ms | **19.4 ms** |
| 14 MB | 5,500 | 18 s | 224 ms | **22.4 ms** |
| 68 MB (real npm tree) | 9,725 | 19 s | 316 ms | **71.2 ms** |
| 143 MB | 27,500 | 20 s | 348 ms | **23.3 ms** |
| 763 MB | 110,000 | 25 s | 862 ms | **22.9 ms** |
A 763 MB dependency tree still unpacks in under a second, and warm launch is flat across the whole range. (The 68 MB row is a real npm install; its higher warm figure is Node resolving express/lodash/dayjs at runtime, not launcher overhead. The other rows are generated trees, which compress better than real ones.)
## Requirements
Banderole requires the Rust toolchain to be installed on your system to build portable executables.
Banderole requires a Rust toolchain (`cargo`, `rustc`, `rustup`) **and a working C linker**`cc`/`gcc`/`clang` on Unix, MSVC on Windows — because it compiles a small launcher for each bundle.
## Installation
+5 -1
View File
@@ -10,7 +10,11 @@ allow = [
"CC0-1.0",
"MPL-2.0",
"Zlib",
"bzip2-1.0.6"
"bzip2-1.0.6",
# rustls stack (ring, untrusted, rustls-webpki) — replaces the OpenSSL/native-tls path.
"ISC",
# Mozilla root certificate bundle shipped by webpki-roots.
"CDLA-Permissive-2.0",
]
confidence-threshold = 0.8
+135 -65
View File
@@ -14,6 +14,27 @@ use std::time::Instant;
use zip::ZipWriter;
/// Zstd level for application and dependency files.
///
/// This is chosen for the *per-entry* cost, not for throughput. `ZipWriter` builds a fresh
/// streaming encoder for every entry and cannot pledge the source size, so zstd commits to
/// the level's full parameter set and allocates its match tables per file regardless of how
/// small that file is. Measured cost of one encoder over a 108-byte input:
///
/// ```text
/// level 3 → 148 us level 9 → 850 us level 12 → 30,021 us
/// ```
///
/// That 35x cliff between 9 and 12 is fixed overhead, so it multiplies by file count: a
/// 100,000-file dependency tree costs ~15 s at level 3 and ~50 minutes at level 12, for
/// roughly 9% difference in size. Nothing above 9 is defensible for many-file payloads.
const APP_COMPRESSION_LEVEL: i64 = 3;
/// Zstd level for the single Node executable. One entry pays the per-entry overhead once,
/// and this is a fixed ~105 MB that dominates the output size, so compressing it hard is
/// worth a few seconds.
const NODE_COMPRESSION_LEVEL: i64 = 12;
/// Public entry-point used by `main.rs`.
///
/// * `project_path` path that contains a `package.json`.
@@ -27,7 +48,7 @@ pub async fn bundle_project(
project_path: PathBuf,
output_path: Option<PathBuf>,
custom_name: Option<String>,
_no_compression: bool,
no_compression: bool,
ignore_cached_versions: bool,
multi: &MultiProgress,
) -> Result<()> {
@@ -133,10 +154,29 @@ pub async fn bundle_project(
let mut zip_data: Vec<u8> = Vec::new();
{
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut zip_data));
// Always use uncompressed (Stored) for near-instant extraction
// This makes the executable larger but launch time is much faster
let opts: zip::write::FileOptions<'static, ()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
// Zstd is strictly better than Stored here: it cuts the payload several-fold and its
// decompression is fast enough that the one-time first-run extraction still gets
// *faster*, because there are far fewer bytes to write to disk. Warm launches never
// touch the archive at all, so they are unaffected either way.
//
// The level differs by payload because the two halves scale differently. Measured on
// real node_modules content, zstd throughput is 95.9 MB/s at level 3 but only
// 17.6 MB/s at level 12 — and an application's dependency tree is unbounded, so a
// high level there turns bundling a large project into minutes of compression. The
// Node binary is a fixed ~105 MB that dominates the output size, so it is worth
// compressing hard; a couple of seconds buys ~25% off the dominant term.
let opts: zip::write::FileOptions<'static, ()> = if no_compression {
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored)
} else {
zip::write::FileOptions::default()
.compression_method(zip::CompressionMethod::Zstd)
.compression_level(Some(APP_COMPRESSION_LEVEL))
};
let node_opts: zip::write::FileOptions<'static, ()> = if no_compression {
opts
} else {
opts.compression_level(Some(NODE_COMPRESSION_LEVEL))
};
// Pre-count app files
let app_files = count_files_in_dir(&source_dir, true, true);
@@ -159,17 +199,11 @@ pub async fn bundle_project(
Some(&pb_bundle),
)?;
// Count node runtime files and extend length
let node_files = count_files_in_dir(node_root, false, true);
let new_len = pb_bundle.length().unwrap_or(0) + node_files;
// Only the Node executable itself is ever resolved at runtime, so the rest of the
// distribution (headers, npm, corepack, docs, man pages) is dead weight.
let new_len = pb_bundle.length().unwrap_or(0) + 1;
pb_bundle.set_length(new_len);
add_dir_to_zip(
&mut zip,
node_root,
Path::new("node"),
opts,
Some(&pb_bundle),
)?;
add_node_runtime_to_zip(&mut zip, node_root, node_opts, Some(&pb_bundle))?;
zip.finish()?;
}
pb_bundle.finish_and_clear();
@@ -230,6 +264,92 @@ fn count_files_in_dir(dir: &Path, exclude_node_modules: bool, follow_links: bool
count
}
/// Add just the Node.js executable to the archive, at the path the launcher resolves.
///
/// The launcher only ever execs `node/bin/node` (or `node/node.exe`), so bundling the whole
/// distribution ships ~65 MB of headers, npm and docs that nothing can reach. The binary is
/// also stripped first where the platform allows it, which removes another ~14%.
fn add_node_runtime_to_zip<W>(
zip: &mut ZipWriter<W>,
node_root: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
{
let (source_rel, archive_path) = if cfg!(windows) {
(PathBuf::from("node.exe"), "node/node.exe")
} else {
(PathBuf::from("bin").join("node"), "node/bin/node")
};
let node_binary = node_root.join(&source_rel);
anyhow::ensure!(
node_binary.exists(),
"Node.js executable not found at {}",
node_binary.display()
);
let node_binary = strip_node_binary(&node_binary).unwrap_or(node_binary);
let opts = opts.unix_permissions(0o755).large_file(true);
zip.start_file(archive_path, opts)?;
let mut file = fs::File::open(&node_binary)
.with_context(|| format!("Failed to open {}", node_binary.display()))?;
std::io::copy(&mut file, zip)
.with_context(|| format!("Failed to add {} to archive", node_binary.display()))?;
if let Some(pb) = progress {
pb.inc(1);
}
Ok(())
}
/// Produce a stripped copy of the Node binary next to the cached distribution, reusing it on
/// subsequent bundles. Returns `None` when stripping is unavailable or fails, in which case
/// the caller falls back to the original binary.
fn strip_node_binary(node_binary: &Path) -> Option<PathBuf> {
// macOS ships signed binaries; stripping invalidates the signature and Gatekeeper then
// kills the process, so leave those alone.
if cfg!(target_os = "macos") || cfg!(windows) {
return None;
}
let stripped = node_binary.with_extension("stripped");
if let (Ok(orig), Ok(strip_meta)) = (node_binary.metadata(), stripped.metadata()) {
// Reuse only if the stripped copy is newer than the original and non-empty.
if strip_meta.len() > 0 {
if let (Ok(a), Ok(b)) = (orig.modified(), strip_meta.modified()) {
if b >= a {
return Some(stripped);
}
}
}
}
fs::copy(node_binary, &stripped).ok()?;
let status = std::process::Command::new("strip")
.arg("--strip-all")
.arg(&stripped)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => {
debug!("Stripped Node binary at {}", stripped.display());
Some(stripped)
}
_ => {
let _ = fs::remove_file(&stripped);
None
}
}
}
/// Bundle dependencies with improved package manager support
fn bundle_dependencies<W>(
zip: &mut ZipWriter<W>,
@@ -1360,56 +1480,6 @@ fn resolve_output_path(
// Utility helpers
// ────────────────────────────────────────────────────────────────────────────
fn add_dir_to_zip<W>(
zip: &mut ZipWriter<W>,
src_dir: &Path,
dest_dir: &Path,
opts: zip::write::FileOptions<'static, ()>,
progress: Option<&ProgressBar>,
) -> Result<()>
where
W: Write + Read + std::io::Seek,
{
for entry in walkdir::WalkDir::new(src_dir).follow_links(true) {
let entry = entry?;
let path = entry.path();
let rel_path = path.strip_prefix(src_dir).unwrap();
let zip_path = dest_dir.join(rel_path);
if entry.file_type().is_dir() {
zip.add_directory(zip_path.to_string_lossy().as_ref(), opts)?;
continue;
}
if !entry.file_type().is_file() && !entry.file_type().is_symlink() {
continue;
}
let file_opts = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(path)?;
let permissions = metadata.permissions();
let mode = permissions.mode();
opts.unix_permissions(mode)
}
#[cfg(not(unix))]
{
opts
}
};
zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?;
let data = fs::read(path).context("Failed to read file while zipping")?;
zip.write_all(&data)?;
if let Some(pb) = progress {
pb.inc(1);
}
}
Ok(())
}
/// Add directory to zip without following symlinks but preserving them
fn add_dir_to_zip_no_follow<W>(
zip: &mut ZipWriter<W>,
+39 -14
View File
@@ -105,6 +105,10 @@ fn build_executable_with_progress(
// Actual build; consume Cargo JSON messages to compute progress without a dry-run
let mut cmd = Command::new("cargo");
cmd.current_dir(build_dir)
// A CARGO_TARGET_DIR inherited from the caller's environment would redirect the
// launcher build somewhere else entirely, and we would then fail to find the binary
// under build_dir/target.
.env_remove("CARGO_TARGET_DIR")
.args([
"build",
"--release",
@@ -323,25 +327,45 @@ fn build_executable_with_progress(
.ok()
.map(|s| s.clone())
.unwrap_or_default();
let trim_tail = |mut s: String| {
// Keep the *end* of a long log: that is where the failure is. (The previous version
// discarded the result of `split_off`, which truncated the string in place and then
// printed the middle of the log, never the error itself.)
let trim_tail = |s: String| {
const MAX: usize = 4000;
if s.len() > MAX {
s.split_off(s.len() - MAX)
} else {
String::new()
};
if s.len() > MAX {
s[s.len() - MAX..].to_string()
} else {
s
match s.char_indices().nth_back(MAX.saturating_sub(1)) {
Some((idx, _)) if s.len() > MAX => s[idx..].to_string(),
_ => s,
}
};
let out_tail = trim_tail(out);
let err_tail = trim_tail(err);
// cargo emits diagnostics as JSON on stdout; surface the rendered compiler errors
// directly instead of making the user read the raw message stream.
let diagnostics: Vec<String> = out
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|v| v.get("reason").and_then(|r| r.as_str()) == Some("compiler-message"))
.filter_map(|v| {
let msg = v.get("message")?;
if msg.get("level").and_then(|l| l.as_str()) != Some("error") {
return None;
}
msg.get("rendered")
.and_then(|r| r.as_str())
.map(|s| s.to_string())
})
.collect();
if !diagnostics.is_empty() {
anyhow::bail!(
"Cargo build failed.\nCompiler errors:\n{}",
trim_tail(diagnostics.join("\n"))
);
}
anyhow::bail!(
"Cargo build failed.\nLast stdout:\n{}\nLast stderr:\n{}",
out_tail,
err_tail
trim_tail(out),
trim_tail(err)
);
}
@@ -393,6 +417,7 @@ fn compute_total_via_cargo_metadata(build_dir: &Path, target_triple: &str) -> Re
fn run_metadata(build_dir: &Path, args: &[&str]) -> Result<serde_json::Value> {
let output = Command::new("cargo")
.current_dir(build_dir)
.env_remove("CARGO_TARGET_DIR")
.args(args)
.output()
.with_context(|| format!("Failed to run cargo {}", args.join(" ")))?;
+4 -6
View File
@@ -3,17 +3,15 @@ use crate::platform::Platform;
use anyhow::{Context, Result};
use futures_util::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use lazy_static::lazy_static;
use log::info;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::{LazyLock, Mutex};
use tokio::fs;
use tokio::io::AsyncWriteExt;
lazy_static! {
static ref NODE_VERSION_CACHE: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
}
static NODE_VERSION_CACHE: LazyLock<Mutex<HashMap<String, PathBuf>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub struct NodeDownloader {
platform: Platform,
@@ -295,7 +293,7 @@ impl NodeDownloader {
if let Some(pb) = &progress {
pb.set_message("Extracting 7z archive");
}
sevenz_rust::decompress_file(&archive_path, &target_dir)
sevenz_rust2::decompress_file(&archive_path, &target_dir)
.context("Failed to extract 7z archive")?;
// Post-process: many Node archives have a single top-level folder. Flatten it.
+3 -5
View File
@@ -1,13 +1,11 @@
use anyhow::{Context, Result};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::sync::Mutex;
use std::sync::{LazyLock, Mutex};
use tokio::time::{Duration, Instant};
lazy_static! {
static ref VERSION_CACHE: Mutex<VersionCache> = Mutex::new(VersionCache::new());
}
static VERSION_CACHE: LazyLock<Mutex<VersionCache>> =
LazyLock::new(|| Mutex::new(VersionCache::new()));
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeVersion {
+12 -10
View File
@@ -6,19 +6,21 @@ edition = "2021"
[dependencies]
anyhow = "1.0"
directories = "6"
zip = "4"
# Only the codecs the bundler can actually emit. The default feature set drags in bzip2,
# liblzma, aes, zopfli, deflate64 and ppmd-rust, none of which are ever used.
zip = { version = "8", default-features = false, features = ["deflate", "zstd"] }
serde_json = "1.0"
fs2 = "0.4"
walkdir = "2.4"
rayon = "1.10"
# fs2 is unmaintained (last release 2018); fs4 is the maintained fork.
fs4 = { version = "1.1", default-features = false, features = ["sync"] }
[build-dependencies]
# No build dependencies needed - data is embedded at compile time
# Optimize for size and performance
# The launcher's own code is a rounding error next to the embedded payload, so optimize the
# extraction path for speed rather than for size.
[profile.release]
opt-level = "z" # Optimize for size
lto = true # Enable Link Time Optimization
codegen-units = 1 # Reduce number of codegen units to increase optimizations
panic = "abort" # Abort on panic (smaller binary)
strip = true # Strip symbols from binary
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
strip = true
+299 -354
View File
@@ -1,449 +1,394 @@
use anyhow::{Context, Result};
use directories::BaseDirs;
use fs2::FileExt;
use rayon::prelude::*;
use fs4::FileExt;
use std::env;
use std::fs;
use std::io::Cursor;
use std::collections::BTreeSet;
use std::io::{Cursor, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use zip::ZipArchive;
// These will be replaced during the build process with actual embedded data
// The build script will generate a data.rs file with the actual data
include!(concat!(env!("OUT_DIR"), "/data.rs"));
/// Name of the marker file written after a successful extraction. Its contents are the
/// resolved main script path, so the warm path never has to parse package.json.
const READY_FILE: &str = ".ready";
/// Entries at or below this size are decompressed into a reusable buffer and written with a
/// single `write_all`; larger ones stream through a buffered writer.
const SMALL_ENTRY_BYTES: u64 = 8 * 1024 * 1024;
/// One archive entry's destination, resolved during the planning pass so the parallel write
/// pass never has to touch shared metadata.
struct PlannedFile {
index: usize,
path: PathBuf,
size: u64,
#[cfg(unix)]
mode: Option<u32>,
}
fn main() -> Result<()> {
let args: Vec<String> = env::args().collect();
// args_os, not args: a bundled app must be able to receive non-UTF-8 arguments such as
// a filename in an arbitrary encoding. `env::args()` panics on those.
let args: Vec<std::ffi::OsString> = env::args_os().collect();
// Get cache directory
let cache_dir = get_cache_dir().context("Failed to determine cache directory")?;
let app_dir = cache_dir.join(&BUILD_ID);
let ready_file = app_dir.join(".ready");
let cache_dir = get_cache_dir_fast().context("Failed to determine cache directory")?;
let app_dir = cache_dir.join(BUILD_ID);
// Check if already extracted and ready
if ready_file.exists() && is_extraction_valid(&app_dir)? {
return run_app(&app_dir, &args[1..]);
// Warm path: a single read of the ready marker gives us everything we need. If anything
// about the cache is stale or damaged, `run_app` returns and we fall through to a full
// re-extraction below, so we do not need to stat the payload up front.
if let Ok(main_script) = fs::read_to_string(app_dir.join(READY_FILE)) {
let main_script = main_script.trim();
if !main_script.is_empty() {
run_app(&app_dir, main_script, &args[1..])?;
}
}
// Use file locking to prevent concurrent extraction
let lock_file_path = cache_dir.join(format!("{}.lock", BUILD_ID));
// Cold path: extract under an exclusive lock so concurrent launches cooperate.
fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
let lock_file_path = cache_dir.join(format!("{BUILD_ID}.lock"));
let lock_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_file_path)
.with_context(|| format!("Failed to create lock file at {}", lock_file_path.display()))?;
// Acquire exclusive lock
lock_file
.lock_exclusive()
.context("Failed to acquire extraction lock")?;
FileExt::lock(&lock_file).context("Failed to acquire extraction lock")?;
// Double-check if extraction completed while waiting for lock
if ready_file.exists() && is_extraction_valid(&app_dir)? {
// Release lock and run
lock_file.unlock().ok();
return run_app(&app_dir, &args[1..]);
// Another process may have completed the extraction while we waited for the lock. Keep
// holding the lock across this attempt: a successful exec drops it automatically (the fd
// is close-on-exec), and if the cache turns out to be unusable we still own the right to
// re-extract below.
let ready_path = app_dir.join(READY_FILE);
if let Ok(main_script) = fs::read_to_string(&ready_path) {
let main_script = main_script.trim().to_string();
if !main_script.is_empty() {
run_app(&app_dir, &main_script, &args[1..])?;
}
}
// Extract application if needed
extract_application(&app_dir)
.with_context(|| format!("Failed to extract application to {}", app_dir.display()))?;
// Mark as ready
fs::write(&ready_file, "ready")
.with_context(|| format!("Failed to create ready file at {}", ready_file.display()))?;
let main_script = find_main_script(&app_dir.join("app"))?;
// Release lock
lock_file
.unlock()
// The ready marker doubles as the cache of the resolved main script. Write it last so a
// partially extracted directory is never mistaken for a usable one.
fs::write(&ready_path, &main_script)
.with_context(|| format!("Failed to create ready file at {}", ready_path.display()))?;
FileExt::unlock(&lock_file)
.context("Failed to release extraction lock")?;
// Run the application
run_app(&app_dir, &args[1..])
run_app(&app_dir, &main_script, &args[1..])?;
// `run_app` only returns when it could not start Node at all.
Err(anyhow::anyhow!(
"Failed to execute Node.js application from {}",
app_dir.display()
))
}
fn get_cache_dir() -> Result<PathBuf> {
let cache_dir = BaseDirs::new().unwrap().cache_dir().join("banderole");
fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
Ok(cache_dir)
fn get_cache_dir_fast() -> Result<PathBuf> {
// Deliberately does not create the directory: the warm path never needs it to exist, and
// create_dir_all costs a syscall on every launch.
let base = BaseDirs::new().context("Failed to determine base directories")?;
Ok(base.cache_dir().join("banderole"))
}
fn get_node_executable_path(app_dir: &Path) -> PathBuf {
let node_dir = app_dir.join("node");
if cfg!(windows) {
// Prefer common locations first
let candidates = [node_dir.join("node.exe")];
for c in candidates {
if c.exists() {
return c;
}
}
// Recursively search for node.exe under node/
if node_dir.exists() {
for entry in walkdir::WalkDir::new(&node_dir).follow_links(true) {
if let Ok(e) = entry {
let p = e.path();
if p.is_file() {
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
if name.eq_ignore_ascii_case("node.exe") {
return p.to_path_buf();
}
}
}
}
}
}
// Fallback: default where Windows Node is usually at after extraction
node_dir.join("node.exe")
} else {
// On Unix systems, Node.js is in node/bin/node
let candidate = node_dir.join("bin").join("node");
if candidate.exists() {
candidate
} else {
// As a last resort, search recursively
if node_dir.exists() {
for entry in walkdir::WalkDir::new(&node_dir).follow_links(true) {
if let Ok(e) = entry {
let p = e.path();
if p.is_file() {
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
if name == "node" {
return p.to_path_buf();
}
}
}
}
}
}
candidate
}
node_dir.join("bin").join("node")
}
}
fn is_extraction_valid(app_dir: &Path) -> Result<bool> {
let app_package_json = app_dir.join("app").join("package.json");
let node_executable = get_node_executable_path(app_dir);
#[cfg(windows)]
let node_executable = node_executable
.canonicalize()
.unwrap_or_else(|_| node_executable.clone());
let package_exists = app_package_json.exists();
let node_exists = node_executable.exists();
if !package_exists || !node_exists {
// Log debugging information for failed validation
eprintln!("Extraction validation failed:");
eprintln!(" App directory: {}", app_dir.display());
eprintln!(
" Package.json exists: {} ({})",
package_exists,
app_package_json.display()
);
eprintln!(
" Node executable exists: {} ({})",
node_exists,
node_executable.display()
);
if let Ok(entries) = fs::read_dir(app_dir) {
eprintln!(" App directory contents:");
for entry in entries.flatten() {
eprintln!(" - {}", entry.file_name().to_string_lossy());
}
}
if let Ok(entries) = fs::read_dir(app_dir.join("node")) {
eprintln!(" Node directory contents:");
for entry in entries.flatten() {
eprintln!(" - {}", entry.file_name().to_string_lossy());
}
}
}
Ok(package_exists && node_exists)
}
struct FileEntry {
path: PathBuf,
data: Vec<u8>,
#[cfg(unix)]
mode: Option<u32>,
}
struct DirEntry {
path: PathBuf,
}
fn extract_application(app_dir: &Path) -> Result<()> {
// Remove existing directory if it exists to ensure clean extraction
if app_dir.exists() {
fs::remove_dir_all(app_dir).context("Failed to remove existing app directory")?;
}
// Create app directory
fs::create_dir_all(app_dir).context("Failed to create app directory")?;
// Read ZIP data directly from embedded bytes (no XZ decompression needed)
let cursor = Cursor::new(ZIP_DATA);
let mut archive = ZipArchive::new(cursor).context("Failed to open embedded zip archive")?;
// First pass: collect all entries from the ZIP archive
let mut files = Vec::new();
let mut dirs = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
// Get the file name from the zip entry (clone to owned String to avoid borrow issues)
let file_name = file.name().to_string();
// Skip entries with invalid characters or paths
if file_name.is_empty() || file_name.contains('\0') {
continue;
}
// Determine if this is a directory entry
let is_directory = file_name.ends_with('/') || file.is_dir();
// Skip empty directory entries that are just the trailing slash
if is_directory && (file_name == "/" || file_name.trim_matches('/').is_empty()) {
continue;
}
// Remove trailing slash for proper path construction
let clean_file_name = if is_directory {
file_name.trim_end_matches('/').to_string()
} else {
file_name.clone()
};
// Skip if the cleaned name is empty (shouldn't happen but be safe)
if clean_file_name.is_empty() {
continue;
}
// Use proper path handling instead of string replacement
// Split the path by forward slashes and join using PathBuf for proper platform handling
let path_components: Vec<&str> = clean_file_name
.split('/')
.filter(|s| !s.is_empty())
.collect();
// Skip if no valid path components
if path_components.is_empty() {
continue;
}
let mut outpath = app_dir.to_path_buf();
for component in path_components {
outpath = outpath.join(component);
}
// Ensure the path is within the app directory (security check)
if !outpath.starts_with(app_dir) {
continue;
}
if is_directory {
dirs.push(DirEntry { path: outpath });
} else {
// Read file data into memory
let mut data = Vec::new();
std::io::copy(&mut file, &mut data)
.with_context(|| format!("Failed to read file data from {}", file_name))?;
// Parsed exactly once. `ZipArchive` holds its central directory behind an `Arc`, so the
// per-worker clones below are O(1) — re-opening the archive per worker instead would
// re-parse every entry per thread, which is the difference between 110k and 1.7M entry
// parses on a large project.
let archive =
ZipArchive::new(Cursor::new(ZIP_DATA)).context("Failed to open embedded zip archive")?;
let len = archive.len();
// Resolve every entry's destination path up front (metadata only), so the write pass can
// run in parallel without touching the shared central directory.
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
let mut files: Vec<PlannedFile> = Vec::with_capacity(len);
{
let mut archive = archive.clone();
// Entries arrive in directory-walk order, so consecutive files nearly always share a
// parent. Remembering the last one turns ~100k set insertions into ~10k.
let mut last_parent: Option<PathBuf> = None;
for index in 0..len {
let entry = archive
.by_index_raw(index)
.context("Failed to read zip entry")?;
let is_dir = entry.is_dir() || entry.name().ends_with('/');
let Some(path) = safe_join(app_dir, entry.name()) else {
continue;
};
let size = entry.size();
#[cfg(unix)]
let mode = file.unix_mode();
let mode = entry.unix_mode();
drop(entry);
files.push(FileEntry {
path: outpath,
data,
if is_dir {
dirs.insert(path);
continue;
}
if let Some(parent) = path.parent() {
// Archives do not reliably carry directory entries, so every file's parent is
// recorded too.
if last_parent.as_deref() != Some(parent) {
dirs.insert(parent.to_path_buf());
last_parent = Some(parent.to_path_buf());
}
}
files.push(PlannedFile {
index,
path,
size,
#[cfg(unix)]
mode,
});
}
}
// Second pass: create all directories (must be sequential to avoid conflicts)
for dir in dirs {
fs::create_dir_all(&dir.path)
.with_context(|| format!("Failed to create directory '{}'", dir.path.display()))?;
// Sorted order means a parent is always created before its children, and `create_dir_all`
// then short-circuits on the already-existing prefix.
for dir in &dirs {
fs::create_dir_all(dir)
.with_context(|| format!("Failed to create directory '{}'", dir.display()))?;
}
// Third pass: write all files in parallel for maximum speed
files.par_iter().try_for_each(|entry| -> Result<()> {
// Create parent directories first
if let Some(parent) = entry.path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create parent directory '{}' for file '{}'",
parent.display(),
entry.path.display()
)
})?;
// Largest first. The bundler appends the ~105 MB Node binary as the *last* entry, so any
// static split of the work list hands it to one worker that is still writing long after
// the rest have finished. Longest-job-first plus a shared cursor keeps every core busy to
// the end.
files.sort_unstable_by(|a, b| b.size.cmp(&a.size));
let threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.min(files.len().max(1));
let next = AtomicUsize::new(0);
let result: Result<()> = std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(threads);
for _ in 0..threads {
let mut archive = archive.clone();
let files = &files;
let next = &next;
handles.push(scope.spawn(move || -> Result<()> {
// Reused across entries so the common small-file case is one allocation per
// worker rather than one per file.
let mut buf: Vec<u8> = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(planned) = files.get(i) else { break };
let path = &planned.path;
let mut entry = archive
.by_index(planned.index)
.context("Failed to read zip entry")?;
let mut out = fs::File::create(path)
.with_context(|| format!("Failed to create file {}", path.display()))?;
if planned.size <= SMALL_ENTRY_BYTES {
buf.clear();
buf.reserve(planned.size as usize);
entry
.read_to_end(&mut buf)
.with_context(|| format!("Failed to read {}", path.display()))?;
out.write_all(&buf).with_context(|| {
format!("Failed to write file to {}", path.display())
})?;
} else {
// A big entry through io::copy's 8 KiB loop is tens of thousands of
// write syscalls; buffer it instead.
let mut writer = std::io::BufWriter::with_capacity(1 << 20, &mut out);
std::io::copy(&mut entry, &mut writer).with_context(|| {
format!("Failed to write file to {}", path.display())
})?;
writer.flush().with_context(|| {
format!("Failed to flush file {}", path.display())
})?;
}
#[cfg(unix)]
{
if let Some(mode) = planned.mode {
use std::os::unix::fs::PermissionsExt;
// fchmod on the open descriptor: no second path resolution, and
// identical semantics to the previous path-based set_permissions.
out.set_permissions(std::fs::Permissions::from_mode(mode))
.with_context(|| {
format!("Failed to set permissions on {}", path.display())
})?;
}
}
}
Ok(())
}));
}
// Write file
fs::write(&entry.path, &entry.data)
.with_context(|| format!("Failed to write file to {}", entry.path.display()))?;
// Set executable permissions on Unix systems
#[cfg(unix)]
{
if let Some(mode) = entry.mode {
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(mode);
fs::set_permissions(&entry.path, permissions).with_context(|| {
format!("Failed to set permissions on {}", entry.path.display())
})?;
}
for handle in handles {
handle
.join()
.map_err(|_| anyhow::anyhow!("extraction worker panicked"))??;
}
Ok(())
})?;
});
result?;
Ok(())
}
fn run_app(app_dir: &Path, args: &[String]) -> Result<()> {
/// Join a zip entry name onto `root`, rejecting absolute paths and traversal escapes.
///
/// Both `/` and `\` are treated as separators regardless of host platform. Zip names are
/// specified to use `/`, but `PathBuf::push` also splits on `\` under Windows — so accepting
/// a backslash as an ordinary character here would let an entry named `a\..\..\evil` escape
/// `root` on Windows while passing a component-wise `starts_with` check.
fn safe_join(root: &Path, name: &str) -> Option<PathBuf> {
if name.is_empty() || name.contains('\0') {
return None;
}
let mut out = root.to_path_buf();
let mut pushed = false;
for component in name
.split(['/', '\\'])
.filter(|s| !s.is_empty() && *s != ".")
{
if component == ".." {
return None;
}
// Reject anything Windows would reinterpret: drive-relative prefixes, and trailing
// dots or spaces which the Win32 layer silently strips.
if component.contains(':') || component.ends_with('.') || component.ends_with(' ') {
return None;
}
out.push(component);
pushed = true;
}
if !pushed || !out.starts_with(root) {
return None;
}
Some(out)
}
/// Start the bundled Node.js app. On Unix this *replaces* the current process, so it only
/// returns if Node could not be started at all.
fn run_app(app_dir: &Path, main_script: &str, args: &[std::ffi::OsString]) -> Result<()> {
let app_path = app_dir.join("app");
let node_executable = get_node_executable_path(app_dir);
// Verify Node.js executable exists and is accessible
if !node_executable.exists() {
let app_dir_contents = fs::read_dir(&app_dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>()
})
.unwrap_or_else(|e| vec![format!("Error reading app dir: {}", e)]);
// Returning from here leaves the caller free to wipe and re-extract `app_dir`, so the
// process must not be left with its cwd inside that directory.
let previous_cwd = env::current_dir().ok();
let restore_cwd = || {
if let Some(cwd) = previous_cwd.as_ref() {
let _ = env::set_current_dir(cwd);
}
};
let node_dir_contents = fs::read_dir(app_dir.join("node"))
.map(|entries| {
entries
.filter_map(|e| e.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>()
})
.unwrap_or_else(|e| vec![format!("Error reading node dir: {}", e)]);
if env::set_current_dir(&app_path).is_err() {
return Ok(());
}
return Err(anyhow::anyhow!(
"Node.js executable not found at: {}\nPlatform: {} {}\nApp directory contents: {:?}\nNode directory contents: {:?}",
node_executable.display(),
std::env::consts::OS,
std::env::consts::ARCH,
app_dir_contents,
node_dir_contents
));
let mut command = Command::new(&node_executable);
command.arg(main_script).args(args);
// Persist V8's compiled bytecode next to the extracted app so it survives between runs.
// This is what closes the gap with bundlers that ship precompiled bytecode: without it
// every launch recompiles the app's JavaScript, and the cost grows with dependency
// count. Node < 22.1 simply ignores the variable, and a caller that sets it explicitly
// keeps their own choice.
if env::var_os("NODE_COMPILE_CACHE").is_none() {
command.env("NODE_COMPILE_CACHE", app_dir.join(".v8cache"));
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// Replaces this process image with Node: no fork, no resident parent holding the
// launcher's address space for the lifetime of the app, and signals/exit status are
// delivered by the kernel directly to Node.
let err = command.exec();
// exec() only returns on failure.
restore_cwd();
if node_executable.exists() {
return Err(anyhow::anyhow!(err).context(format!(
"Failed to execute Node.js at {}",
node_executable.display()
)));
}
return Ok(());
}
// On Windows, verify the executable is actually executable
#[cfg(windows)]
{
if let Ok(metadata) = fs::metadata(&node_executable) {
if !metadata.is_file() {
return Err(anyhow::anyhow!(
"Node.js executable path exists but is not a file: {}",
use std::process::Stdio;
let mut last_err: Option<std::io::Error> = None;
for attempt in 1..=8u32 {
match command
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
{
Ok(status) => std::process::exit(status.code().unwrap_or(1)),
Err(e) => {
last_err = Some(e);
std::thread::sleep(std::time::Duration::from_millis(50 * attempt as u64));
}
}
}
restore_cwd();
if node_executable.exists() {
if let Some(e) = last_err {
return Err(anyhow::anyhow!(e).context(format!(
"Failed to execute Node.js at {}",
node_executable.display()
));
}
} else {
return Err(anyhow::anyhow!(
"Cannot read metadata for Node.js executable: {}",
node_executable.display()
));
}
}
// Verify app directory exists
if !app_path.exists() {
return Err(anyhow::anyhow!(
"App directory not found at: {}",
app_path.display()
));
}
// Change to app directory
env::set_current_dir(&app_path)
.with_context(|| format!("Failed to change to app directory: {}", app_path.display()))?;
// Find main script from package.json
let main_script = find_main_script(&app_path)?;
// Build command arguments
let mut cmd_args = vec![main_script.clone()];
cmd_args.extend(args.iter().cloned());
let mut last_err: Option<anyhow::Error> = None;
let max_attempts: u32 = 8;
let mut status: Option<std::process::ExitStatus> = None;
for attempt in 1..=max_attempts {
let status_res = Command::new(&node_executable)
.args(&cmd_args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
match status_res {
Ok(s) => {
status = Some(s);
break;
}
Err(e) => {
last_err = Some(anyhow::anyhow!(e).context(format!(
"Failed to execute Node.js application (attempt {attempt}/{max_attempts})\nExecutable: {}\nMain script: {}\nArgs: {:?}\nWorking directory: {}",
node_executable.display(),
main_script,
cmd_args,
app_path.display()
)));
#[cfg(windows)]
{
use std::time::Duration;
std::thread::sleep(Duration::from_millis(50 * attempt as u64));
}
#[cfg(not(windows))]
{
if attempt >= 2 {
break;
}
}
}
}
Ok(())
}
let status = status.ok_or_else(|| {
last_err.unwrap_or_else(|| {
anyhow::anyhow!(
"Failed to execute Node.js application after {} attempts",
max_attempts
)
})
})?;
std::process::exit(status.code().unwrap_or(1));
}
fn find_main_script(app_path: &Path) -> Result<String> {
let package_json_path = app_path.join("package.json");
if package_json_path.exists() {
let package_content =
fs::read_to_string(&package_json_path).context("Failed to read package.json")?;
if let Ok(package_json) = serde_json::from_str::<serde_json::Value>(&package_content) {
if let Ok(content) = fs::read_to_string(&package_json_path) {
if let Ok(package_json) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(main) = package_json["main"].as_str() {
return Ok(main.to_string());
if !main.trim().is_empty() {
return Ok(main.to_string());
}
}
}
}
// Default to index.js
Ok("index.js".to_string())
}
+17 -17
View File
@@ -816,7 +816,7 @@ impl BundlerTestHelper {
exec_to_run.display(),
args,
env_vars,
&work_dir
work_dir
)
})?;
Ok(output)
@@ -862,24 +862,24 @@ impl BundlerTestHelper {
pub struct TestCacheManager;
impl TestCacheManager {
/// The directory the launcher extracts bundles into, matching `directories::BaseDirs`.
pub fn application_cache_dir() -> PathBuf {
if cfg!(windows) {
std::env::var_os("LOCALAPPDATA")
.map(|d| PathBuf::from(d).join("banderole"))
.unwrap_or_else(|| PathBuf::from("banderole-cache"))
} else if let Some(xdg_cache) = std::env::var_os("XDG_CACHE_HOME") {
PathBuf::from(xdg_cache).join("banderole")
} else if let Some(home) = std::env::var_os("HOME") {
PathBuf::from(home).join(".cache").join("banderole")
} else {
PathBuf::from("/tmp").join("banderole-cache")
}
}
/// Clear application cache for testing
pub fn clear_application_cache() -> Result<()> {
// Determine cache directory based on platform
let cache_dir = if cfg!(windows) {
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
std::path::PathBuf::from(local_app_data).join("banderole")
} else {
return Ok(()); // Can't determine cache dir, skip cleanup
}
} else if let Some(xdg_cache) = std::env::var_os("XDG_CACHE_HOME") {
std::path::PathBuf::from(xdg_cache).join("banderole")
} else if let Some(home) = std::env::var_os("HOME") {
std::path::PathBuf::from(home)
.join(".cache")
.join("banderole")
} else {
std::path::PathBuf::from("/tmp").join("banderole-cache")
};
let cache_dir = Self::application_cache_dir();
if cache_dir.exists() {
println!("Clearing application cache at: {}", cache_dir.display());
@@ -0,0 +1,218 @@
//! Covers the launcher behaviour introduced when the runtime switched from
//! "spawn Node as a child and wait" to "exec() into Node", and when the payload
//! was reduced to just the Node executable.
mod common;
use common::{BundlerTestHelper, TestCacheManager};
use serial_test::serial;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
/// Writes a project whose entrypoint can exit with a chosen code or idle forever.
fn write_probe_project(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let app = root.join("probe-app");
fs::create_dir_all(&app)?;
fs::write(
app.join("package.json"),
r#"{
"name": "probe-app",
"version": "1.0.0",
"main": "index.js"
}"#,
)?;
fs::write(
app.join("index.js"),
r#"
const mode = process.argv[2] || "hello";
if (mode === "exit") {
process.exit(Number(process.argv[3] || 0));
} else if (mode === "idle") {
console.log("ready");
setInterval(() => {}, 1000);
} else if (mode === "args") {
console.log(JSON.stringify(process.argv.slice(2)));
} else {
console.log("probe ok");
}
"#,
)?;
Ok(app)
}
/// The launcher must hand the process over to Node rather than supervising it, so exit
/// codes come straight from the app and no launcher process stays resident.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_launcher_execs_into_node() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let app = write_probe_project(temp_dir.path())?;
let out_dir = temp_dir.path().join("out");
fs::create_dir_all(&out_dir)?;
TestCacheManager::clear_application_cache()?;
let exe = BundlerTestHelper::bundle_project(&app, &out_dir, Some("probe-app"))?;
// First launch performs the extraction; second launch takes the warm path. Both must
// behave identically.
for pass in ["cold", "warm"] {
let out = BundlerTestHelper::run_executable(&exe, &[], &[])?;
assert!(
out.status.success(),
"{pass} launch failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("probe ok"),
"{pass} launch produced unexpected stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
}
// Arbitrary non-zero exit codes must survive. Under the old spawn+wait launcher this
// went through `status.code().unwrap_or(1)`; under exec() the kernel reports it directly.
for code in [0, 3, 42] {
let out = BundlerTestHelper::run_executable(&exe, &["exit", &code.to_string()], &[])?;
assert_eq!(
out.status.code(),
Some(code),
"exit code {code} was not propagated"
);
}
// Arguments after the executable name must reach the script untouched.
let out = BundlerTestHelper::run_executable(&exe, &["args", "--flag", "value"], &[])?;
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"--flag\"") && stdout.contains("\"value\""),
"arguments were not forwarded: {stdout}"
);
Ok(())
}
/// The launcher process must be *replaced* by Node, not remain as its parent: a resident
/// parent doubles the process count and the memory held while the app is idle or sleeping.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_no_launcher_process_remains_resident() -> Result<(), Box<dyn std::error::Error>> {
use std::io::Read;
use std::process::Stdio;
let temp_dir = TempDir::new()?;
let app = write_probe_project(temp_dir.path())?;
let out_dir = temp_dir.path().join("out");
fs::create_dir_all(&out_dir)?;
TestCacheManager::clear_application_cache()?;
let exe = BundlerTestHelper::bundle_project(&app, &out_dir, Some("probe-app"))?;
// Warm the cache so the timing below is not racing the first-run extraction.
BundlerTestHelper::run_executable(&exe, &[], &[])?;
let mut child = Command::new(&exe)
.arg("idle")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
// Wait for the app to announce itself so we know exec() has already happened.
let mut stdout = child.stdout.take().expect("piped stdout");
let mut buf = [0u8; 32];
let n = stdout.read(&mut buf)?;
assert!(
String::from_utf8_lossy(&buf[..n]).contains("ready"),
"idle app never became ready"
);
// Sample /proc *before* asserting anything, then always reap the child, so a failed
// assertion cannot leave an idle Node process behind.
let pid = child.id();
let children =
fs::read_to_string(format!("/proc/{pid}/task/{pid}/children")).unwrap_or_default();
let comm = fs::read_to_string(format!("/proc/{pid}/comm")).ok();
child.kill().ok();
child.wait().ok();
// The spawned pid itself is now Node, so it must have no children of its own.
assert!(
children.trim().is_empty(),
"launcher still has child processes ({children:?}); it did not exec into Node"
);
if let Some(comm) = comm {
assert_eq!(
comm.trim(),
"node",
"spawned process should have been replaced by Node"
);
}
Ok(())
}
/// Only the Node executable belongs in the payload. Shipping the whole distribution added
/// ~65 MB of headers, npm and docs that the launcher can never reach.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_payload_contains_only_node_executable() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let app = write_probe_project(temp_dir.path())?;
let out_dir = temp_dir.path().join("out");
fs::create_dir_all(&out_dir)?;
TestCacheManager::clear_application_cache()?;
let exe = BundlerTestHelper::bundle_project(&app, &out_dir, Some("probe-app"))?;
// A full Node distribution is ~186 MB uncompressed; the compressed single binary is
// well under a third of that. The bound is deliberately loose so ordinary Node version
// drift does not make this flaky.
let size = fs::metadata(&exe)?.len();
assert!(
size < 80 * 1024 * 1024,
"bundle is {size} bytes; expected well under 80 MB, so the payload is likely \
carrying the whole Node distribution again"
);
// Run it so the cache directory is populated, then inspect what actually landed there.
let out = BundlerTestHelper::run_executable(&exe, &[], &[])?;
assert!(out.status.success());
let cache_root = TestCacheManager::application_cache_dir();
let app_dir = fs::read_dir(&cache_root)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.find(|p| p.is_dir() && p.join("node").exists())
.ok_or("no extracted bundle found in the cache directory")?;
let node_dir = app_dir.join("node");
let node_binary = if cfg!(windows) {
node_dir.join("node.exe")
} else {
node_dir.join("bin").join("node")
};
assert!(
node_binary.exists(),
"extracted bundle is missing the Node executable at {}",
node_binary.display()
);
for unwanted in ["include", "share"] {
assert!(
!node_dir.join(unwanted).exists(),
"extracted bundle still ships node/{unwanted}"
);
}
assert!(
!node_dir.join("lib").join("node_modules").exists(),
"extracted bundle still ships npm/corepack under node/lib/node_modules"
);
Ok(())
}