#![cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime};
fn temp_dir(stem: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cordis-hmr-test-{stem}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn target_dir() -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.unwrap()
.join("target")
})
}
fn rustc() -> PathBuf {
if let Some(rustc) = std::env::var_os("RUSTC") {
let rustc = PathBuf::from(rustc);
if rustc.is_file() {
return rustc;
}
}
let sibling = PathBuf::from(env!("CARGO")).parent().unwrap().join("rustc");
if sibling.is_file() {
return sibling;
}
PathBuf::from("rustc")
}
fn find_rlib(name: &str) -> PathBuf {
let debug = target_dir().join("debug");
let prefix = format!("lib{name}-");
let mut candidates: Vec<(SystemTime, PathBuf)> = Vec::new();
let uplifted = debug.join(format!("lib{name}.rlib"));
if let Ok(metadata) = std::fs::metadata(&uplifted) {
candidates.push((metadata.modified().unwrap(), uplifted));
}
if let Ok(entries) = std::fs::read_dir(debug.join("deps")) {
for entry in entries.flatten() {
let path = entry.path();
let is_match = path
.extension()
.is_some_and(|extension| extension == "rlib")
&& path
.file_name()
.and_then(|file| file.to_str())
.is_some_and(|file| file.starts_with(&prefix));
if is_match {
let mtime = entry.metadata().unwrap().modified().unwrap();
candidates.push((mtime, path));
}
}
}
candidates.sort_by_key(|candidate| candidate.0);
candidates
.pop()
.unwrap_or_else(|| panic!("no rlib found for {name}"))
.1
}
fn cdylib_file_name(crate_name: &str) -> String {
if cfg!(target_os = "windows") {
format!("{crate_name}.dll")
} else if cfg!(target_os = "macos") {
format!("lib{crate_name}.dylib")
} else {
format!("lib{crate_name}.so")
}
}
fn native_search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(entries) = std::fs::read_dir(target_dir().join("debug/build")) {
for entry in entries.flatten() {
let out = entry.path().join("out");
if out.is_dir() {
dirs.push(out);
}
}
}
let cargo_home = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| {
["HOME", "USERPROFILE"].iter().find_map(|key| {
std::env::var_os(key).map(|home| PathBuf::from(home).join(".cargo"))
})
});
if let Some(registry) = cargo_home.map(|home| home.join("registry/src")) {
if let Ok(indices) = std::fs::read_dir(®istry) {
for index in indices.flatten() {
if let Ok(crates) = std::fs::read_dir(index.path()) {
for krate in crates.flatten() {
let lib = krate.path().join("lib");
if krate.file_name().to_string_lossy().starts_with("windows_")
&& lib.is_dir()
{
dirs.push(lib);
}
}
}
}
}
}
dirs
}
fn json_path(path: &Path) -> String {
path.display().to_string().replace('\\', "/")
}
fn compile_plugin(out_dir: &Path, crate_name: &str, cfgs: &[&str]) -> PathBuf {
std::fs::create_dir_all(out_dir).unwrap();
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/file_writer.rs");
let mut command = Command::new(rustc());
command
.args(["--edition", "2024", "--crate-type", "cdylib"])
.arg(format!("--crate-name={crate_name}"))
.arg("--out-dir")
.arg(out_dir)
.arg(&source)
.arg("-L")
.arg(format!(
"dependency={}",
target_dir().join("debug/deps").display()
))
.arg("--extern")
.arg(format!(
"cordis_loader={}",
find_rlib("cordis_loader").display()
));
for dir in native_search_dirs() {
command.arg("-L").arg(format!("native={}", dir.display()));
}
for cfg in cfgs {
command.arg(format!("--cfg={cfg}"));
}
let output = command.output().expect("run rustc");
assert!(
output.status.success(),
"compiling plugin {crate_name} failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
out_dir.join(cdylib_file_name(crate_name))
}
fn wait_for(what: &str, check: impl Fn() -> bool) {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
if check() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("timed out waiting for {what}");
}
#[test]
fn replacing_a_plugin_library_hot_restarts_the_worker() {
let dir = temp_dir("replace");
let plugins = dir.join("plugins");
std::fs::create_dir_all(&plugins).unwrap();
let build = dir.join("build");
let v1 = compile_plugin(&build, "file_writer", &[]);
let v2 = compile_plugin(&build, "file_writer_v2", &["hmr_v2"]);
std::fs::copy(&v1, plugins.join(cdylib_file_name("file_writer"))).unwrap();
let out = dir.join("out.txt");
let config = dir.join("cordis.json");
std::fs::write(
&config,
format!(
r#"{{"entries":[{{"id":"w","name":"file_writer","config":{{"out":"{}"}}}}]}}"#,
json_path(&out)
),
)
.unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_cordis"))
.arg("run")
.arg(&config)
.arg("--plugin-dir")
.arg(&plugins)
.process_group(0)
.stderr(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.expect("spawn cordis binary");
wait_for("the first build to write v1", || {
std::fs::read_to_string(&out).ok().as_deref() == Some("v1")
});
let staged = dir.join("staged.so");
std::fs::copy(&v2, &staged).unwrap();
std::fs::rename(&staged, plugins.join(cdylib_file_name("file_writer"))).unwrap();
wait_for("the restarted worker to write v2", || {
std::fs::read_to_string(&out).ok().as_deref() == Some("v2")
});
if let Ok(Some(status)) = child.try_wait() {
panic!("cordis exited early with {status}");
}
let _ = Command::new("kill")
.args(["-TERM", "--", &format!("-{}", child.id())])
.status();
let output = child.wait_with_output().expect("wait for cordis");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("plugin library changed, restarting worker"),
"stderr: {stderr}"
);
assert!(stderr.contains("respawning"), "stderr: {stderr}");
assert_eq!(output.status.code(), Some(0), "daemon should exit 0");
let _ = std::fs::remove_dir_all(&dir);
}