Skip to content

Commit 33aafe0

Browse files
authored
Merge branch 'master' into fix/2394-non-utf8-output
2 parents b4ddf7f + 07639de commit 33aafe0

10 files changed

Lines changed: 230 additions & 24 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
codepage output from Python) was discarded entirely, resulting in empty `stdout.log`/`stderr.log`
2929
state files, cache hits replaying no output, and missing output in run reports. Invalid bytes are
3030
now replaced with `` instead.
31+
- Fixed an issue where a task's dependents would not run when requested with a downstream scope
32+
(`moon ci`, `--dependents`, `--downstream`), if the task was first added to the action graph as a
33+
dependency of another target. For example, `moon run app:build lib:build --dependents` would skip
34+
the dependents of `lib:build` when `app:build` depends on it.
35+
- Fixed tasks that emit read-only outputs (e.g. `0444` files) failing on every cache hit with a
36+
"Permission denied" error when the `casOutputsCache` experiment is enabled. Caches that already
37+
contain read-only objects are healed automatically.
3138

3239
## 2.4.2
3340

crates/action-graph/src/action_graph_builder.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ pub struct ActionGraphBuilder<'query> {
133133

134134
// Target tracking
135135
ignored_dependencies: FxHashMap<Target, FxHashSet<Target>>,
136+
// Tasks whose dependents were out of scope when their node was created.
137+
// Consumed when the task is revisited with dependents in scope, since the
138+
// node-exists early return would otherwise skip the expansion entirely.
139+
ignored_dependents: FxHashSet<Target>,
136140
passthrough_targets: FxHashSet<Target>,
137141
primary_targets: FxHashSet<Target>,
138142

@@ -159,6 +163,7 @@ impl<'query> ActionGraphBuilder<'query> {
159163
nodes: FxHashMap::default(),
160164
options,
161165
ignored_dependencies: FxHashMap::default(),
166+
ignored_dependents: FxHashSet::default(),
162167
passthrough_targets: FxHashSet::default(),
163168
primary_targets: FxHashSet::default(),
164169
serial_edges: FxHashSet::default(),
@@ -998,6 +1003,12 @@ impl<'query> ActionGraphBuilder<'query> {
9981003
false
9991004
};
10001005

1006+
let had_ignored_dependents = if should_run_dependents {
1007+
self.ignored_dependents.remove(&task.target)
1008+
} else {
1009+
false
1010+
};
1011+
10011012
// Check if the node exists to avoid all the overhead below
10021013
if let Some(index) = self.get_index_from_node(&node) {
10031014
if had_ignored_dependencies && !task.deps.is_empty() {
@@ -1008,6 +1019,12 @@ impl<'query> ActionGraphBuilder<'query> {
10081019
self.link_optional_requirements(index, edges)?;
10091020
}
10101021

1022+
if had_ignored_dependents {
1023+
child_reqs.skip_affected = false;
1024+
1025+
Box::pin(self.run_task_dependents(task, &child_reqs, state)).await?;
1026+
}
1027+
10111028
return Ok(Some(index));
10121029
}
10131030

@@ -1047,6 +1064,8 @@ impl<'query> ActionGraphBuilder<'query> {
10471064
child_reqs.skip_affected = false;
10481065

10491066
Box::pin(self.run_task_dependents(task, &child_reqs, state)).await?;
1067+
} else {
1068+
self.ignored_dependents.insert(task.target.clone());
10501069
}
10511070

10521071
Ok(Some(index))

crates/action-graph/tests/action_graph_builder_test.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2049,6 +2049,54 @@ mod action_graph_builder {
20492049

20502050
assert_snapshot!(graph.to_dot());
20512051
}
2052+
2053+
#[tokio::test(flavor = "multi_thread")]
2054+
async fn expands_skipped_dependents_when_task_is_revisited_in_scope() {
2055+
let sandbox = create_sandbox("tasks");
2056+
let mut container = ActionGraphContainer::new(sandbox.path());
2057+
2058+
let wg = container.create_workspace_graph().await;
2059+
let mut builder = container.create_builder(wg.clone()).await;
2060+
2061+
let parent = wg.get_task_from_project("deps", "parent1").unwrap();
2062+
let task = wg.get_task_from_project("deps", "base").unwrap();
2063+
2064+
// First insert the task as a dependency of another target,
2065+
// with dependents out of scope
2066+
builder
2067+
.run_task(
2068+
&parent,
2069+
&RunRequirements {
2070+
dependencies: UpstreamScope::Deep,
2071+
dependents: DownstreamScope::None,
2072+
..RunRequirements::default()
2073+
},
2074+
)
2075+
.await
2076+
.unwrap();
2077+
2078+
// Then run it as an explicit target with dependents in scope
2079+
builder
2080+
.run_task(
2081+
&task,
2082+
&RunRequirements {
2083+
dependencies: UpstreamScope::Deep,
2084+
dependents: DownstreamScope::Direct,
2085+
..RunRequirements::default()
2086+
},
2087+
)
2088+
.await
2089+
.unwrap();
2090+
2091+
let (_, graph) = builder.build();
2092+
2093+
assert_snapshot!(graph.to_dot());
2094+
2095+
assert!(topo(graph).into_iter().any(|node| matches!(
2096+
node,
2097+
ActionNode::RunTask(inner) if inner.target == Target::parse("deps:parent2").unwrap()
2098+
)));
2099+
}
20522100
}
20532101
}
20542102

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
source: crates/action-graph/tests/action_graph_builder_test.rs
3+
expression: graph.to_dot()
4+
---
5+
digraph {
6+
0 [ label="SyncWorkspace"]
7+
1 [ label="SyncProject(deps)"]
8+
2 [ label="RunTask(deps:parent1)"]
9+
3 [ label="RunTask(deps:base)"]
10+
4 [ label="RunTask(deps:parent2)"]
11+
5 [ label="SyncProject(deps-external)"]
12+
6 [ label="RunTask(deps-external:external)"]
13+
1 -> 0 [ label="required"]
14+
3 -> 1 [ label="required"]
15+
2 -> 1 [ label="required"]
16+
2 -> 3 [ label="required"]
17+
4 -> 1 [ label="required"]
18+
4 -> 3 [ label="required"]
19+
5 -> 0 [ label="required"]
20+
5 -> 1 [ label="required"]
21+
6 -> 5 [ label="required"]
22+
6 -> 3 [ label="required"]
23+
}

crates/cache/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ pub use cache_engine::*;
66
pub use hash_engine::*;
77
pub use moon_cache_item::*;
88
pub use moon_cache_storage::*;
9-
pub use moon_cas::CasStore;
9+
pub use moon_cas::{CasStore, grant_owner_write_access};
1010
pub use moon_hash::*;
1111
pub use state_engine::*;
1212

crates/cas/src/cas.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use moon_blob::{Blob, BlobCleanStats};
33
use moon_config::CacheCasConfig;
44
use moon_hash::{ContentHash, Digest};
55
use rustc_hash::FxHashSet;
6-
use starbase_utils::fs;
6+
use starbase_utils::fs::{self, FsError};
77
use starbase_utils::hash::{
88
self, hex,
99
sha256::native::{Digest as ShaDigest, Sha256},
@@ -94,6 +94,12 @@ impl CasStore {
9494

9595
fs::reflink_file(source, &guard.path)?;
9696

97+
// The reflink also clones the source's permissions. Grant the owner
98+
// write access so a read-only source (e.g. a task emitting read-only
99+
// outputs, #2608) doesn't produce a read-only object, which would fail
100+
// later store mutations like `touch`
101+
grant_owner_write_access(&guard.path)?;
102+
97103
// No fsync: see `write` for rationale.
98104
self.commit_temp_file(hash, &mut guard)?;
99105

@@ -382,3 +388,29 @@ impl CasStore {
382388
Ok(())
383389
}
384390
}
391+
392+
/// Grant the owner write permission on the file, leaving all other bits intact.
393+
/// Reflinks clone the source's permissions, so both storing and hydrating a
394+
/// read-only file must restore writability on the clone.
395+
pub fn grant_owner_write_access(path: &Path) -> miette::Result<()> {
396+
let mut perms = fs::metadata(path)?.permissions();
397+
398+
#[cfg(unix)]
399+
{
400+
use std::os::unix::fs::PermissionsExt;
401+
402+
perms.set_mode(perms.mode() | 0o200);
403+
}
404+
405+
// The readonly attribute is the only permission Windows has
406+
#[cfg(not(unix))]
407+
#[allow(clippy::permissions_set_readonly_false)]
408+
perms.set_readonly(false);
409+
410+
std::fs::set_permissions(path, perms).map_err(|error| FsError::Perms {
411+
path: path.to_path_buf(),
412+
error: Box::new(error),
413+
})?;
414+
415+
Ok(())
416+
}

crates/cas/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ mod cas;
22
mod cas_error;
33
mod gc;
44

5-
pub use cas::CasStore;
5+
pub use cas::{CasStore, grant_owner_write_access};
66
pub use cas_error::CasError;

crates/cas/tests/cas_test.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,40 @@ mod cas {
231231
assert_eq!(count_temp_files(&store), 0);
232232
}
233233

234+
#[test]
235+
fn normalizes_read_only_source_permissions() {
236+
// A reflink clones the source's permissions, so a read-only output
237+
// must not produce a read-only object — later store mutations and
238+
// hydration clones would fail on it (#2608).
239+
let sandbox = create_empty_sandbox();
240+
let store = create_store(&sandbox);
241+
242+
let source = sandbox.path().join("input.txt");
243+
std::fs::write(&source, b"read only").unwrap();
244+
245+
let mut perms = std::fs::metadata(&source).unwrap().permissions();
246+
perms.set_readonly(true);
247+
std::fs::set_permissions(&source, perms).unwrap();
248+
249+
let digest = store.store_file(&source).unwrap();
250+
let object_perms = std::fs::metadata(store.object_path(&digest.hash))
251+
.unwrap()
252+
.permissions();
253+
254+
// Restore the source so the sandbox can clean up on Windows,
255+
// where read-only files block directory removal.
256+
#[cfg(windows)]
257+
#[allow(clippy::permissions_set_readonly_false)]
258+
{
259+
let mut perms = std::fs::metadata(&source).unwrap().permissions();
260+
perms.set_readonly(false);
261+
std::fs::set_permissions(&source, perms).unwrap();
262+
}
263+
264+
assert!(!object_perms.readonly());
265+
assert_eq!(store.read(&digest.hash).unwrap(), b"read only");
266+
}
267+
234268
#[test]
235269
fn warm_cache_creates_no_temp_file() {
236270
// Regression: a file already present in the store must short-circuit

crates/task-runner/src/output_hydrater.rs

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ use crate::run_state::TaskRunState;
22
use crate::task_runner_error::TaskRunnerError;
33
use miette::IntoDiagnostic;
44
use moon_app_context::AppContext;
5-
use moon_cache::{Manifest, ManifestFile, ManifestSource, ManifestSymlink, StorageOptions};
5+
use moon_cache::{
6+
Manifest, ManifestFile, ManifestSource, StorageOptions, grant_owner_write_access,
7+
};
68
use moon_common::{
79
color,
810
path::{WorkspaceRelativePath, clean_components},
@@ -157,7 +159,6 @@ impl OutputHydrater<'_> {
157159
}
158160
})?,
159161
output_path,
160-
link,
161162
)?;
162163
}
163164

@@ -257,6 +258,12 @@ impl OutputHydrater<'_> {
257258
let fd = if let Some(source) = &file.source_path {
258259
fs::reflink_file(source, &output_path)?;
259260

261+
// The reflink clones the source's permissions, which may lack the
262+
// write bit (stores populated before objects were normalized may
263+
// contain read-only blobs), so restore it before opening a handle
264+
// to apply the mtime/mode below
265+
grant_owner_write_access(&output_path)?;
266+
260267
fs::open_file_for_writing(&output_path)?
261268
}
262269
// Otherwise write the bytes from the manifest
@@ -284,14 +291,10 @@ impl OutputHydrater<'_> {
284291
Ok(())
285292
}
286293

287-
// Windows lint!
288-
#[allow(unused_variables)]
289-
fn link_output_file(
290-
&self,
291-
from_path: PathBuf,
292-
to_path: PathBuf,
293-
link: &ManifestSymlink,
294-
) -> miette::Result<()> {
294+
// The manifest's unix mode is deliberately not applied: it records the
295+
// followed target's mode (which the target's own manifest entry restores),
296+
// and a chmod through the link would modify the target, not the link
297+
fn link_output_file(&self, from_path: PathBuf, to_path: PathBuf) -> miette::Result<()> {
295298
if let Some(parent) = to_path.parent() {
296299
fs::create_dir_all(parent)?;
297300
}
@@ -314,19 +317,9 @@ impl OutputHydrater<'_> {
314317

315318
#[cfg(unix)]
316319
{
317-
use std::os::unix::fs::{PermissionsExt, symlink};
320+
use std::os::unix::fs::symlink;
318321

319322
symlink(&from_path, &to_path).map_err(map_error)?;
320-
321-
if let Some(mode) = &link.unix_mode {
322-
let fd = fs::open_file_for_writing(&to_path)?;
323-
324-
fd.set_permissions(std::fs::Permissions::from_mode(*mode))
325-
.map_err(|error| FsError::Write {
326-
path: to_path.clone(),
327-
error: Box::new(error),
328-
})?;
329-
}
330323
}
331324

332325
Ok(())

crates/task-runner/tests/output_hydrater_test.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,56 @@ mod output_hydrater {
213213
);
214214
}
215215

216+
#[cfg(unix)]
217+
#[tokio::test(flavor = "multi_thread")]
218+
async fn hydrates_read_only_output_file_from_cas() {
219+
use std::os::unix::fs::PermissionsExt;
220+
221+
// A task can emit read-only outputs (#2608). Hydration must replace
222+
// the existing read-only file and re-apply the recorded mode, even
223+
// when the blob itself is read-only (stores populated before
224+
// objects were normalized).
225+
let container = TaskRunnerContainer::new("archive", "file-outputs").await;
226+
container
227+
.sandbox
228+
.create_file("project/file.txt", "read only");
229+
230+
let file_path = container.sandbox.path().join("project/file.txt");
231+
232+
fs::set_permissions(&file_path, fs::Permissions::from_mode(0o444)).unwrap();
233+
234+
let mut state = container.create_state();
235+
setup_cas_state(&mut state);
236+
237+
let source = archive_and_load(&container, &state).await;
238+
239+
let blob_digest = Digest::from_bytes(b"read only").unwrap();
240+
let blob_path = container
241+
.sandbox
242+
.path()
243+
.join(".moon/cache/blobs")
244+
.join(blob_digest.hash.prefix())
245+
.join(blob_digest.hash.suffix());
246+
247+
fs::set_permissions(&blob_path, fs::Permissions::from_mode(0o444)).unwrap();
248+
249+
// Hydrate over the still-existing read-only output, as a
250+
// subsequent cache-hit run would
251+
assert_hydrated(
252+
container
253+
.create_hydrator()
254+
.hydrate(HydrateFrom::Storage(Box::new(source)), "hash123", &state)
255+
.await
256+
.unwrap(),
257+
);
258+
259+
assert_eq!(fs::read_to_string(&file_path).unwrap(), "read only");
260+
assert_eq!(
261+
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
262+
0o444
263+
);
264+
}
265+
216266
#[tokio::test(flavor = "multi_thread")]
217267
async fn doesnt_hydrate_if_cache_disabled() {
218268
let container = TaskRunnerContainer::new("archive", "file-outputs").await;

0 commit comments

Comments
 (0)