Files
core/test/dever-tests/tests/native_cache.rs
T

594 lines
20 KiB
Rust

mod support;
#[path = "../../../crates/dever-core/src/native/build/cache.rs"]
#[allow(dead_code)]
mod cache;
#[path = "../../../crates/dever-cli/src/api.rs"]
mod cli_api;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::Instant;
use dever_core::native::{NativeProgram, compile_with_cache};
use support::temp::TemporaryDirectory;
fn rustc() -> OsString {
std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())
}
fn compile(source: &str, root: &Path, compiler: &OsStr) -> NativeProgram {
let sources = support::sources(source);
compile_with_cache(
&support::checked(&sources),
&sources,
"main.main",
compiler,
root,
)
.unwrap()
}
const HELLO: &str = "main() () { dever.io.println(\"cache hello\") }\n";
#[test]
fn identified_artifacts_share_owned_cache_and_reject_modified_bytes() {
use dever_core::native::compile_artifact;
let directory = TemporaryDirectory::new();
let root = directory.path().join("cache");
let first = compile_artifact(&root, "llvm-source-and-pack-1", |_, output| {
fs::write(output, b"complete artifact").map_err(|error| error.to_string())
})
.unwrap();
assert!(!first.cache_hit());
let second = compile_artifact(&root, "llvm-source-and-pack-1", |_, _| {
panic!("an exact cache hit must not invoke the compiler")
})
.unwrap();
assert!(second.cache_hit());
assert_ne!(first.executable(), second.executable());
let output = directory.path().join("published");
second.save(&output).unwrap();
assert_eq!(
second.save(&output).unwrap_err().kind(),
std::io::ErrorKind::AlreadyExists
);
let temporary = first.executable().parent().unwrap().to_path_buf();
drop(first);
assert!(!temporary.exists());
assert_eq!(fs::read(&output).unwrap(), b"complete artifact");
let stored = fs::read_dir(&root).unwrap().next().unwrap().unwrap().path();
fs::write(stored.join("program"), b"tampered").unwrap();
let result = compile_artifact(&root, "llvm-source-and-pack-1", |_, _| {
panic!("corruption must fail before compilation")
});
assert!(result.err().unwrap().contains("incomplete or modified"));
let changed = compile_artifact(&root, "llvm-source-and-pack-2", |_, output| {
fs::write(output, b"changed input").map_err(|error| error.to_string())
})
.unwrap();
assert!(!changed.cache_hit());
}
#[test]
fn identified_artifact_failure_releases_directory_without_publication() {
use dever_core::native::compile_artifact;
let directory = TemporaryDirectory::new();
let root = directory.path().join("cache");
let mut temporary = None;
let result = compile_artifact(&root, "failed-link", |build, output| {
temporary = Some(build.to_path_buf());
fs::write(output, b"incomplete").unwrap();
Err("link rejected".into())
});
assert_eq!(result.err().unwrap(), "link rejected");
assert!(!temporary.unwrap().exists());
assert!(!root.exists());
}
#[test]
fn workspace_profiles_keep_independent_tests_without_debug_or_incremental_artifacts() {
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let cargo = fs::read_to_string(workspace.join("Cargo.toml")).unwrap();
for profile in ["dev", "test"] {
let section = cargo
.split(&format!("[profile.{profile}]"))
.nth(1)
.unwrap_or_else(|| panic!("missing profile.{profile}"))
.split("\n[")
.next()
.unwrap();
assert!(
section.contains("debug = 0"),
"profile.{profile} enables debuginfo"
);
assert!(
section.contains("incremental = false"),
"profile.{profile} enables incremental"
);
}
let release = cargo
.split("[profile.release]")
.nth(1)
.unwrap()
.split("\n[")
.next()
.unwrap();
assert!(release.contains("codegen-units = 1"));
assert!(release.contains("lto = \"fat\""));
let tests = fs::read_to_string(workspace.join("test/dever-tests/Cargo.toml")).unwrap();
for target in [
"native_cache",
"api_routes",
"sqlite_orm",
"postgres_orm",
"external_component",
] {
assert!(
tests.contains(&format!("name = \"{target}\"")),
"missing test target {target}"
);
}
assert!(
tests.matches("[[test]]").count() >= 60,
"test targets were unexpectedly merged"
);
}
#[test]
fn repeated_build_reuses_native_code_and_keeps_owned_output_semantics() {
let directory = TemporaryDirectory::new();
let root = directory.path().join("cache");
let started = Instant::now();
let first = compile(HELLO, &root, &rustc());
let first_time = started.elapsed();
assert!(!first.cache_hit());
let started = Instant::now();
let second = compile(HELLO, &root, &rustc());
eprintln!(
"native cache: cold_ms={}, hit_ms={}",
first_time.as_millis(),
started.elapsed().as_millis()
);
assert!(second.cache_hit());
assert_ne!(first.executable(), second.executable());
assert_eq!(
Command::new(second.executable()).output().unwrap().stdout,
b"cache hello\n"
);
let output = directory.path().join("saved");
second.save(&output).unwrap();
let original = fs::read(&output).unwrap();
assert_eq!(
second.save(&output).unwrap_err().kind(),
std::io::ErrorKind::AlreadyExists
);
assert_eq!(fs::read(&output).unwrap(), original);
let temporary = second.executable().parent().unwrap().to_owned();
drop(second);
assert!(!temporary.exists());
for entry in fs::read_dir(&root).unwrap() {
let mut files: Vec<_> = fs::read_dir(entry.unwrap().path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
files.sort();
assert_eq!(files, ["executable-fingerprint", "identity", "program"]);
}
let modified = HELLO.replace("cache hello", "changed source");
let changed = compile(&modified, &root, &rustc());
assert!(!changed.cache_hit());
assert_eq!(
Command::new(changed.executable()).output().unwrap().stdout,
b"changed source\n"
);
}
#[test]
fn modified_cached_executables_are_never_run() {
let directory = TemporaryDirectory::new();
let root = directory.path().join("cache");
let native = compile(HELLO, &root, &rustc());
let entry = fs::read_dir(&root).unwrap().next().unwrap().unwrap().path();
fs::write(entry.join("program"), "incomplete executable").unwrap();
let sources = support::sources(HELLO);
let result = compile_with_cache(
&support::checked(&sources),
&sources,
"main.main",
&rustc(),
&root,
);
let Err(error) = result else {
panic!("modified executable must not be reused")
};
assert!(error.contains("incomplete or modified"), "{error}");
assert_eq!(
Command::new(native.executable()).output().unwrap().stdout,
b"cache hello\n"
);
}
#[test]
fn concurrent_publish_never_exposes_partial_artifacts() {
let directory = TemporaryDirectory::new();
let root = directory.path().join("cache");
let natives = std::thread::scope(|scope| {
let first = scope.spawn(|| compile(HELLO, &root, &rustc()));
let second = scope.spawn(|| compile(HELLO, &root, &rustc()));
[first.join().unwrap(), second.join().unwrap()]
});
for native in natives {
assert_eq!(
Command::new(native.executable()).output().unwrap().stdout,
b"cache hello\n"
);
}
assert!(compile(HELLO, &root, &rustc()).cache_hit());
assert_eq!(fs::read_dir(root).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn changing_compiler_bytes_invalidates_even_when_version_is_unchanged() {
use std::os::unix::fs::{PermissionsExt, symlink};
let directory = TemporaryDirectory::new();
let root = directory.path().join("cache");
let sysroot = Command::new(rustc())
.args(["--print", "sysroot"])
.output()
.unwrap();
assert!(sysroot.status.success());
let bin = Path::new(std::str::from_utf8(&sysroot.stdout).unwrap().trim()).join("bin");
let wrapper = directory.path().join("rustc");
let quoted = bin
.join("rustc")
.display()
.to_string()
.replace('\'', "'\\''");
let script = format!("#!/bin/sh\nexec '{quoted}' \"$@\"\n");
fs::write(&wrapper, &script).unwrap();
fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o700)).unwrap();
symlink(bin.join("cargo"), directory.path().join("cargo")).unwrap();
assert!(!compile(HELLO, &root, wrapper.as_os_str()).cache_hit());
assert!(compile(HELLO, &root, wrapper.as_os_str()).cache_hit());
fs::write(&wrapper, format!("{script}# changed wrapper\n")).unwrap();
assert!(!compile(HELLO, &root, wrapper.as_os_str()).cache_hit());
}
#[test]
fn runtime_dependencies_toolchain_version_and_options_are_cache_inputs() {
let directory = TemporaryDirectory::new();
let runtime = directory.path().join("runtime");
fs::create_dir(&runtime).unwrap();
fs::create_dir(runtime.join("deps")).unwrap();
fs::write(runtime.join("libdever_runtime.rlib"), "runtime version one").unwrap();
fs::write(
runtime.join("deps/libdependency.rlib"),
"dependency version one",
)
.unwrap();
let root = directory.path().join("cache");
fs::create_dir(&root).unwrap();
let program = directory.path().join("program");
fs::write(&program, "test executable bytes").unwrap();
let inputs = cache::RuntimeInputs::read(&runtime).unwrap();
let memoized_inputs = cache::RuntimeInputs::read(&runtime).unwrap();
let original = cache::Cache::new(
&root,
"generated",
&rustc(),
b"version one",
&inputs,
&["opt-level=3"],
)
.unwrap();
let staging = directory.path().join("staging");
fs::create_dir(&staging).unwrap();
original.publish(&program, &staging).unwrap();
assert!(
original
.restore(&directory.path().join("restored"))
.unwrap()
);
let memoized = cache::Cache::new(
&root,
"generated",
&rustc(),
b"version one",
&memoized_inputs,
&["opt-level=3"],
)
.unwrap();
assert!(
memoized
.restore(&directory.path().join("memoized"))
.unwrap()
);
for (version, options) in [
(b"version two".as_slice(), "opt-level=3"),
(b"version one".as_slice(), "opt-level=2"),
] {
let changed =
cache::Cache::new(&root, "generated", &rustc(), version, &inputs, &[options]).unwrap();
assert!(!changed.restore(&directory.path().join("absent")).unwrap());
}
for path in [
runtime.join("libdever_runtime.rlib"),
runtime.join("deps/libdependency.rlib"),
] {
fs::write(path, "changed bytes").unwrap();
let changed_inputs = cache::RuntimeInputs::read(&runtime).unwrap();
let changed = cache::Cache::new(
&root,
"generated",
&rustc(),
b"version one",
&changed_inputs,
&["opt-level=3"],
)
.unwrap();
assert!(!changed.restore(&directory.path().join("absent")).unwrap());
}
let snapshot = directory.path().join("snapshot");
inputs.write(&snapshot).unwrap();
assert_eq!(
fs::read_to_string(snapshot.join("libdever_runtime.rlib")).unwrap(),
"runtime version one"
);
assert_eq!(
fs::read_dir(&runtime)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".dever-runtime-identity-")
})
.count(),
1
);
}
#[test]
fn runtime_input_views_are_exact_reused_and_concurrently_published() {
let directory = TemporaryDirectory::new();
let source = directory.path().join("cargo/release/deps");
fs::create_dir_all(&source).unwrap();
let runtime = directory.path().join("cargo/release/libdever_runtime.rlib");
fs::write(&runtime, "runtime one").unwrap();
let first_dependency = source.join("libfirst-1.rlib");
let second_dependency = source.join("libsecond-2.rlib");
fs::write(&first_dependency, "first").unwrap();
fs::write(&second_dependency, "second").unwrap();
let inputs = directory.path().join("inputs");
let first =
cache::RuntimeInputs::publish(&inputs, &runtime, std::slice::from_ref(&first_dependency))
.unwrap();
fs::remove_file(&runtime).unwrap();
fs::write(&runtime, "runtime two").unwrap();
let second = std::thread::scope(|scope| {
let left = scope.spawn(|| {
cache::RuntimeInputs::publish(
&inputs,
&runtime,
std::slice::from_ref(&second_dependency),
)
.unwrap()
});
let right = scope.spawn(|| {
cache::RuntimeInputs::publish(
&inputs,
&runtime,
std::slice::from_ref(&second_dependency),
)
.unwrap()
});
[left.join().unwrap(), right.join().unwrap()]
});
assert_eq!(fs::read_dir(&inputs).unwrap().count(), 2);
let first_output = directory.path().join("first-output");
first.write(&first_output).unwrap();
assert!(first_output.join("deps/libfirst-1.rlib").is_file());
assert!(!first_output.join("deps/libsecond-2.rlib").exists());
for (index, input) in second.into_iter().enumerate() {
let output = directory.path().join(format!("second-output-{index}"));
input.write(&output).unwrap();
assert_eq!(
fs::read_to_string(output.join("libdever_runtime.rlib")).unwrap(),
"runtime two"
);
assert!(output.join("deps/libsecond-2.rlib").is_file());
assert!(!output.join("deps/libfirst-1.rlib").exists());
}
}
#[cfg(unix)]
#[test]
fn runtime_input_views_reject_unknown_files_and_symbolic_links() {
use std::os::unix::fs::symlink;
let directory = TemporaryDirectory::new();
let runtime = directory.path().join("runtime");
fs::create_dir(&runtime).unwrap();
fs::create_dir(runtime.join("deps")).unwrap();
fs::write(runtime.join("libdever_runtime.rlib"), "runtime").unwrap();
fs::write(runtime.join("deps/libdependency.rlib"), "dependency").unwrap();
fs::write(runtime.join("deps/unknown.txt"), "unknown").unwrap();
assert!(cache::RuntimeInputs::read(&runtime).is_err());
fs::remove_file(runtime.join("deps/unknown.txt")).unwrap();
fs::remove_file(runtime.join("deps/libdependency.rlib")).unwrap();
let outside = directory.path().join("outside.rlib");
fs::write(&outside, "outside").unwrap();
symlink(&outside, runtime.join("deps/libdependency.rlib")).unwrap();
assert!(cache::RuntimeInputs::read(&runtime).is_err());
}
#[test]
fn checked_program_cache_preserves_source_identity_and_rechecks_api_baselines() {
let original = support::sources(HELLO);
let started = Instant::now();
let program = support::checked(&original);
let cold = started.elapsed();
let started = Instant::now();
let cached = support::checked(&original);
eprintln!(
"frontend cache: cold_us={}, hit_us={}",
cold.as_micros(),
started.elapsed().as_micros()
);
assert_eq!(program.api_snapshot(), cached.api_snapshot());
let directory = TemporaryDirectory::new();
let baseline = directory.path().join("dever.api");
cli_api::write(&program, Some(&baseline)).unwrap();
cli_api::check_baseline(&cached, directory.path()).unwrap();
fs::write(&baseline, "changed baseline").unwrap();
assert!(cli_api::check_baseline(&support::checked(&original), directory.path()).is_err());
let mut wrong_path = dever_core::source::SourceMap::default();
wrong_path.add("wrong.dever", HELLO);
let wrong_program = support::checked(&wrong_path);
assert!(dever_core::native::emit(&wrong_program, &wrong_path, "main.main").is_err());
let changed = support::sources(&HELLO.replace("dever.io.println", "unknown"));
let errors = dever_core::check(&changed).unwrap_err();
let repeated_errors = dever_core::check(&changed).unwrap_err();
assert_eq!(
errors[0].render(&changed),
repeated_errors[0].render(&changed)
);
assert!(errors.iter().any(|error| error.code == "C004"));
let mut additional = dever_core::source::SourceMap::default();
additional.add("main.dever", HELLO);
additional.add("user/account/app.dever", "extra() () {}\n");
fs::write(&baseline, program.api_snapshot()).unwrap();
assert!(cli_api::check_baseline(&support::checked(&additional), directory.path()).is_err());
}
#[test]
#[ignore = "requires a freshly built local dever-cli binary"]
fn actual_cli_checks_baseline_on_check_run_and_cached_build() {
let compiler = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join(format!("dever{}", std::env::consts::EXE_SUFFIX));
assert!(
compiler.is_file(),
"build dever-cli before running this test"
);
let directory = TemporaryDirectory::new();
let domain = directory.path().join("module/sample/cache");
fs::create_dir_all(&domain).unwrap();
fs::create_dir(directory.path().join("config")).unwrap();
fs::write(
domain.join("app.dever"),
"hello() (message: Text) { message = \"cache hello\" }\n",
)
.unwrap();
fs::write(domain.join("api.dever"), "cmd hello = app.hello\n").unwrap();
fs::write(directory.path().join("config/setting.json"), "{}\n").unwrap();
let baseline = directory.path().join("dever.api");
let api = Command::new(&compiler)
.arg("api")
.arg(directory.path())
.arg("--output")
.arg(&baseline)
.output()
.unwrap();
assert!(
api.status.success(),
"{}",
String::from_utf8_lossy(&api.stderr)
);
let check = Command::new(&compiler)
.arg("check")
.arg(directory.path())
.output()
.unwrap();
assert!(
check.status.success(),
"{}",
String::from_utf8_lossy(&check.stderr)
);
let run = Command::new(&compiler)
.arg("run")
.arg(directory.path())
.args(["--", "sample.cache.hello", "{}"])
.output()
.unwrap();
assert!(
run.status.success(),
"{}",
String::from_utf8_lossy(&run.stderr)
);
let expected = serde_json::json!({"code": 0, "message": "ok", "data": "cache hello"});
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&run.stdout).unwrap(),
expected
);
let saved = directory.path().join("saved");
let build = Command::new(&compiler)
.arg("build")
.arg(directory.path())
.arg("--output")
.arg(&saved)
.output()
.unwrap();
assert!(
build.status.success(),
"{}",
String::from_utf8_lossy(&build.stderr)
);
let standalone = Command::new(saved)
.current_dir(directory.path())
.args(["sample.cache.hello", "{}"])
.output()
.unwrap();
assert!(
standalone.status.success(),
"{}",
String::from_utf8_lossy(&standalone.stderr)
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&standalone.stdout).unwrap(),
expected
);
fs::write(baseline, "changed baseline").unwrap();
let blocked_output = directory.path().join("blocked");
for action in ["check", "run", "build"] {
let mut command = Command::new(&compiler);
command.arg(action).arg(directory.path());
if action == "build" {
command.arg("--output").arg(&blocked_output);
} else if action == "run" {
command.args(["--", "sample.cache.hello", "{}"]);
}
let output = command.output().unwrap();
assert!(
!output.status.success(),
"{action} skipped changed baseline"
);
assert!(
String::from_utf8_lossy(&output.stderr).contains("API baseline"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.is_empty());
}
assert!(!blocked_output.exists());
}