Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions src/executor/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,24 @@ impl Host {
let Some(executable) = EXECUTABLE.get() else {
return Ok(None);
};
let directory = directory()?;
// A nested invocation joins the host state of the ancestor that admitted
// it. HOME and ASTER_EXECUTION_DIR describe where a fresh invocation
// keeps its state; a nested workspace may legitimately differ (a test
// sandbox with its own HOME), and a different directory could not see
// the ancestor's locks. The inherited context is authoritative.
let inherited = std::env::var(CONTEXT_ENV)
.ok()
.map(|s| serde_json::from_str::<Family>(&s))
.transpose()
.context("invalid inherited execution context")?;
let directory = match &inherited {
Some(family) => family
.directory
.parent()
.context("inherited execution context has no host directory")?
.to_path_buf(),
None => directory()?,
};
fs::create_dir_all(&directory)?;
let directory = fs::canonicalize(directory)?;
use std::os::unix::fs::PermissionsExt;
Expand Down Expand Up @@ -121,7 +138,9 @@ impl Host {
value
} else {
let value = previous.context("active execution epoch has no capacity")?;
if requested.is_some_and(|n| n != value) {
// A nested invocation borrows its ancestor's slot and never takes
// one of its own, so its requested job count cannot conflict.
if inherited.is_none() && requested.is_some_and(|n| n != value) {
bail!("host execution capacity is {value} while another invocation is active; requested {}. Use the active capacity or change it when all invocations finish", requested.unwrap());
}
value
Expand All @@ -133,17 +152,6 @@ impl Host {
// exclusive-to-shared conversion is not atomic.
FileExt::lock_shared(&epoch)?;
drop(authority);
let inherited = std::env::var(CONTEXT_ENV)
.ok()
.map(|s| serde_json::from_str::<Family>(&s))
.transpose()
.context("invalid inherited execution context")?;
if inherited
.as_ref()
.is_some_and(|family| family.directory.parent() != Some(directory.as_path()))
{
bail!("nested execution cannot change ASTER_EXECUTION_DIR");
}
Ok(Some(Arc::new(Self {
directory,
capacity,
Expand Down
63 changes: 63 additions & 0 deletions tests/execution_capacity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,3 +726,66 @@ fn a_chain_target_holding_a_resource_keeps_it_while_its_step_lives() {
let results = fs::read_to_string(b.join("results.ndjson")).unwrap();
assert!(results.contains("\"runs\""), "{results}");
}

/// A nested workspace is often a sandbox with its own HOME (a test fixture that
/// runs Aster on a temp workspace) and its own job count. Before the inherited
/// context chose the host directory, the nested Aster recomputed
/// `$HOME/.cache/aster/execution-v1`, found a different directory and failed
/// every target in 0 ms with "nested execution cannot change
/// ASTER_EXECUTION_DIR".
#[test]
fn nested_workspace_with_its_own_home_and_jobs_runs_its_targets() {
let f = Fixture::new();
let outer = f.root.path().join("outer");
let inner = f.root.path().join("inner");
let outer_home = f.root.path().join("outer-home");
let inner_home = f.root.path().join("inner-home");
for dir in [&outer, &inner, &outer_home, &inner_home] {
fs::create_dir(dir).unwrap();
}
for (dir, name) in [(&outer, "outer"), (&inner, "inner")] {
fs::write(dir.join("package.json"), format!(r#"{{"name":"{name}"}}"#)).unwrap();
}
fs::write(
inner.join("aster.toml"),
"name = 'inner'\n[targets.lint]\ncommand = 'true'\ncache = { enabled = false }\n[targets.test]\ncommand = 'true'\ncache = { enabled = false }\n",
)
.unwrap();
// The nested run reports through JSON so a failure carries its reason.
let script = format!(
"cd {} && HOME={} ASTER_JOBS=1 {} run //:lint //:test --json > {}",
aster::executor::quote_command_argument(&inner.display().to_string()),
aster::executor::quote_command_argument(&inner_home.display().to_string()),
aster::executor::quote_command_argument(&f.binary.display().to_string()),
aster::executor::quote_command_argument(&outer.join("nested.json").display().to_string()),
);
fs::write(
outer.join("aster.toml"),
format!(
"name = 'outer'\n[targets.nested]\ncommand = {:?}\ncache = {{ enabled = false }}\n",
format!("sh -c {}", aster::executor::quote_command_argument(&script))
),
)
.unwrap();

// Neither level sets ASTER_EXECUTION_DIR, as in CI: the outer state lives
// under the outer HOME and the inner HOME differs.
let status = Command::new(&f.binary)
.current_dir(&outer)
.env("HOME", &outer_home)
.env("ASTER_JOBS", "2")
.env_remove("ASTER_EXECUTION_DIR")
.env_remove("ASTER_EXECUTION_CONTEXT")
.args(["run", "//:nested", "--quiet"])
.status()
.unwrap();
let nested = fs::read_to_string(outer.join("nested.json")).unwrap_or_default();
assert!(
status.success(),
"outer run failed; nested output: {nested}"
);
assert!(
nested.contains(r#""passed":2,"failed":0"#),
"nested targets did not pass: {nested}"
);
}
Loading