Repository: ninja-build/ninja (2026-03-22T18:26:46Z)

Issue:
depfile dependency on dyndep output does not trigger rebuild
In this build file, `copy` depends on `other`, but when `other` changes, `copy` is not rebuilt:

```ninja
rule dyndep
  command = printf 'ninja_dyndep_version = 1\nbuild stamp | other: dyndep\n' > $out

rule build_with_dyndep
  command = touch $out other
  dyndep = $out.dd

rule depends_on_dyndep
  command = printf '%s: other\n' $out > $out.d && cp other $out
  depfile = $out.d

build stamp.dd: dyndep
build stamp: build_with_dyndep || stamp.dd
build copy: depends_on_dyndep || stamp
```

```
$ ninja --version
1.12.1
$ ninja -v
[1/3] printf 'ninja_dyndep_version = 1\nbuild stamp | other: dyndep\n' > stamp.dd
[2/3] touch stamp other
[3/3] printf '%s: other\n' copy > copy.d && cp other copy
$ # edit build.ninja to add some spaces to build_with_dyndep's command
$ ninja -v
[1/1] touch stamp     other
$ ninja -v
[1/1] printf '%s: other\n' copy > copy.d && cp other copy
$ ninja -v
ninja: no work to do.
$ ninja -t missingdeps
Processed 3 nodes.
No missing dependencies on generated files found.
```


Discussion:
While trying to write a test case for my generator, I realized that if the rule for the dyndep file itself also runs, then so does the rule that depends on the dynamic output:

```
$ ninja -v
[1/3] printf 'ninja_dyndep_version = 1\nbuild stamp | other: dyndep\n' > stamp.dd
[2/3] touch stamp other
[3/3] printf '%s: other\n' copy > copy.d && cp other copy
$ # add spaces to both dyndep and build_with_dyndep
$ ninja -v
[1/2] printf     'ninja_dyndep_version = 1\nbuild stamp | other: dyndep\n' > stamp.dd
[2/3] touch stamp     other
[3/3] printf '%s: other\n' copy > copy.d && cp other copy
$ ninja -v
ninja: no work to do.
```

However, changing the last line to `build copy: depends_on_dyndep || stamp stamp.dd` (i.e., adding `stamp.dd`) does not work around the bug.
Thanks for the report. Using `ninja -v -d explain` is useful to understand what's going on:

```bash
# Initial invocation
$ ninja -v -d explain
ninja explain: output stamp.dd doesn't exist
[1/3] printf 'ninja_dyndep_version = 1\nbuild stamp | other: dyndep\n' > stamp.dd
ninja explain: output stamp doesn't exist
[2/3] touch stamp other
ninja explain: depfile 'copy.d' is missing
[3/3] printf '%s: other\n' copy > copy.d && cp other copy

# Second one, after adding spaces to the build_with_dyndep command
$ ninja -v -d explain
ninja explain: command line changed for stamp
[1/1] touch stamp other

# Third invocation
$ $NINJA -v -d explain
ninja explain: output copy older than most recent input other (1754660242406683726 vs 1754660274551264634)
[1/1] printf '%s: other\n' copy > copy.d && cp other copy
```

So it looks like Ninja decided to rebuild `stamp` but failed to consider that this command modified `other`.
I wonder if this is consistent with the definition of out-of-order dependencies (from the manual, `stamp` being rebuilt does not trigger `copy` to be rebuilt per se unless another reason point to it).

I suspect a bug in `Plan::DyndepsLoaded()` or the way it is invoked.
The issue can be located [here](https://github.com/ninja-build/ninja/blob/master/src/graph.cc#L182), it is in `DependencyScan::RecomputeNodeDirty`. 
Only the current input is checked, this is fine if no dyndep did apply in one of the inputs. In case dyndep applied, all inputs needs to be checked, as Dyndep might have changed the graph.

See this [commit](https://github.com/ninja-build/ninja/pull/2675/commits/11c7292763a849447495854a6df35a0b13a43079) for a proposed solution. This has been added to [Pull Request#2675](https://github.com/ninja-build/ninja/pull/2675) as it is related to a similar issue [#2653](https://github.com/ninja-build/ninja/issues/2653).
Here the most relevant code [snippet](https://github.com/moritzx22/ninja/blob/fixissue%232653/src/graph.cc#L227C4-L235C15).

Applying the proposed solution will report as following:
```
$ ninja -v -d explain
ninja explain: output stamp.dd doesn't exist
[1/3] printf 'ninja_dyndep_version = 1\nbuild stamp | other: dyndep\n' > stamp.dd
ninja explain: output stamp doesn't exist
ninja explain: output stamp doesn't exist
[2/3] touch  stamp other
ninja explain: depfile 'copy.d' is missing
ninja explain: output copy doesn't exist
[3/3] printf '%s: other\n' copy > copy.d && cp other copy

# Second one, after adding spaces to the build_with_dyndep command
$ ninja -v -d explain
ninja explain: command line changed for stamp
[1/2] touch  stamp other
ninja explain: stamp is dirty
[2/2] printf '%s: other\n' copy > copy.d && cp other copy
```
Since #1937 we hook up dyndep-discovered outputs to depfile-discovered inputs.  This issue is actually due to graph traversal ordering.  The depfile here is needed only to express a case in which the first build works.  The same problem can occur without a depfile by modifying the OP's case with

<details><summary>this patch</summary>

```patch
diff --git a/build.ninja b/build.ninja
index f679cdd..c2a86c4 100644
--- a/build.ninja
+++ b/build.ninja
@@ -6,9 +6,8 @@ rule build_with_dyndep
   dyndep = $out.dd

 rule depends_on_dyndep
-  command = printf '%s: other\n' $out > $out.d && cp other $out
-  depfile = $out.d
+  command = cp other $out

 build stamp.dd: dyndep
 build stamp: build_with_dyndep || stamp.dd
-build copy: depends_on_dyndep || stamp
+build copy: depends_on_dyndep | other || stamp
```

</details>

and manually creating `other` ahead of time.

#2749 fixes this and adds test cases with and without a depfile.




Patch:
diff --git a/src/graph.cc b/src/graph.cc
index 8e6ff697e0..fdb5661c28 100644
--- a/src/graph.cc
+++ b/src/graph.cc
@@ -168,14 +168,18 @@ bool DependencyScan::RecomputeNodeDirty(Node* node, std::vector<Node*>* stack,
   validation_nodes->insert(validation_nodes->end(),
       edge->validations_.begin(), edge->validations_.end());
 
-  // Visit all inputs; we're dirty if any of the inputs are dirty.
+  // Visit all inputs before checking if any of them is ready.
+  // Newly encountered edges may load dyndep files and gain
+  // outputs that correspond to some of our inputs.
+  for (Node* i : edge->inputs_) {
+    if (!RecomputeNodeDirty(i, stack, validation_nodes, err))
+      return false;
+  }
+
+  // We're dirty if any of the inputs is dirty.
   Node* most_recent_input = NULL;
   for (vector<Node*>::iterator i = edge->inputs_.begin();
        i != edge->inputs_.end(); ++i) {
-    // Visit this input.
-    if (!RecomputeNodeDirty(*i, stack, validation_nodes, err))
-      return false;
-
     // If an input is not ready, neither are our outputs.
     if (Edge* in_edge = (*i)->in_edge()) {
       if (!in_edge->outputs_ready_)


Test patch:
diff --git a/src/graph_test.cc b/src/graph_test.cc
index afd4d0eb96..45a8add629 100644
--- a/src/graph_test.cc
+++ b/src/graph_test.cc
@@ -895,6 +895,72 @@ TEST_F(GraphTest, DyndepImplicitInputNewer) {
   EXPECT_TRUE(GetNode("out")->dirty());
 }
 
+TEST_F(GraphTest, DyndepOutputIsDependentInput) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule r\n"
+"  command = unused\n"
+"build tmp: r in || dd\n"
+"  dyndep = dd\n"
+"build out: r | tmp.imp || tmp\n"
+));
+  fs_.Create("tmp", "");
+  fs_.Create("tmp.imp", "");
+  fs_.Create("out", "");
+  fs_.Create("dd",
+"ninja_dyndep_version = 1\n"
+"build tmp | tmp.imp: dyndep\n"
+);
+  fs_.Tick();
+  fs_.Create("in", "");
+
+  string err;
+  EXPECT_TRUE(scan_.RecomputeDirty(GetNode("out"), NULL, &err));
+  ASSERT_EQ("", err);
+
+  EXPECT_FALSE(GetNode("in")->dirty());
+  EXPECT_FALSE(GetNode("dd")->dirty());
+
+  EXPECT_TRUE(GetNode("tmp")->dirty());
+  EXPECT_TRUE(GetNode("tmp.imp")->dirty());
+
+  // "out" is dirty due to implicit dependency on dyndep-specified output
+  EXPECT_TRUE(GetNode("out")->dirty());
+}
+
+TEST_F(GraphTest, DyndepOutputIsDependentInputFromDepfile) {
+  ASSERT_NO_FATAL_FAILURE(AssertParse(&state_,
+"rule r\n"
+"  command = unused\n"
+"build tmp: r in || dd\n"
+"  dyndep = dd\n"
+"build out: r || tmp\n"
+"  depfile = out.d\n"
+));
+  fs_.Create("tmp", "");
+  fs_.Create("tmp.imp", "");
+  fs_.Create("out", "");
+  fs_.Create("out.d", "out: tmp.imp\n");
+  fs_.Create("dd",
+"ninja_dyndep_version = 1\n"
+"build tmp | tmp.imp: dyndep\n"
+);
+  fs_.Tick();
+  fs_.Create("in", "");
+
+  string err;
+  EXPECT_TRUE(scan_.RecomputeDirty(GetNode("out"), NULL, &err));
+  ASSERT_EQ("", err);
+
+  EXPECT_FALSE(GetNode("in")->dirty());
+  EXPECT_FALSE(GetNode("dd")->dirty());
+
+  EXPECT_TRUE(GetNode("tmp")->dirty());
+  EXPECT_TRUE(GetNode("tmp.imp")->dirty());
+
+  // "out" is dirty due to depfile dependency on dyndep-specified output
+  EXPECT_TRUE(GetNode("out")->dirty());
+}
+
 TEST_F(GraphTest, DyndepFileReady) {
   AssertParse(&state_,
 "rule r\n"