Files
banderole/tests/launcher_semantics_integration_test.rs
T
2026-08-12 13:28:21 -07:00

219 lines
7.4 KiB
Rust

//! 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(())
}