You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Command identity is the cross-build join key and the change detector. It is computed by hashing the fully rendered command text — compute_command_identity (src/graph/dag.cpp:1034) folds in expand_instruction(...) at dag.cpp:1041, whose %f is expanded from cmd->inputs.
cmd->inputs is not declared data. It is the result of glob resolution performed during the parse, and expand_glob_pattern (src/graph/builder.cpp:661) unions two halves:
the graph half — for (auto id : nodes_of_type(ctx.state->graph, NodeType::Generated)) (builder.cpp:690), a scan of the partially built graph, whose membership is whatever the parse has produced so far.
So identity is a function of the parse traversal, not of the project.
Three facts make that concrete:
Parse order comes from sort_dirs_by_depth (src/cli/context.cpp:686), which despite its name sorts by byte length of the directory path, descending (:699, :702), then lexicographically.
The parse fixpoint (context.cpp:928) re-runs only while new Tupfile directories are discovered. It never re-runs because the Generated set grew, and there is no glob re-resolution pass.
The mitigation added in Merge filesystem and generated glob matches into one path-ordered list (fixes #177, fixes #178) #180, request_demand_driven_parse(*ctx.eval, abs_pattern_dir_sv) (builder.cpp:685), force-parses the directory the pattern points at — not the directory that produces the match. That is right when a rule writes into its own directory and silent when a third directory writes into the pattern's directory.
build 1: cat > ../build/m/out.txt <- %f empty; m parses before n, so b/gen.q is not in the graph yet
build 2: (nothing runs) exit 0
build 3: (nothing runs) exit 0
build 4: (nothing runs) exit 0
build/m/out.txt: 0 bytes, permanently
The rule names an input that a sibling directory generates, and putup ships an empty artifact and reports success forever. It never heals: the filesystem half searches the source tree, where the generated file never appears (it lands in build/b/gen.q), and the graph half already ran.
Rename the producing directory and the answer changes. Same project, n/ renamed to a/:
Whether this project builds correctly depends on the name of a directory.
In-tree the same project self-heals on build 2 — the filesystem half then finds the generated file sitting next to the sources — at the cost of %f, and therefore identity, changing between build 1 and build 2 of an unchanged project.
Why the recent work does not close this
#171/#174, #180 and #181 purified the order of glob matches, and #180 additionally fixed membership shadowing between the two halves. This is membership as a function of traversal, which none of them touch. #180's description claimed to have removed the fixpoint dependence; that claim has been corrected on the PR.
Analysis: identity does two jobs that want opposite things
find_by_identity (src/cli/cmd_build.cpp:929) serves preserve_old_implicit_edges (:835), merge_out_of_scope_commands (:986), reconcile_input_set (:1406) and remove_stale_outputs (:1517) — a join key, which wants maximal stability. detect_new_commands (:1346) reruns a command whose identity is absent from the old index — a change detector, which wants maximal sensitivity. Conflating them means every source of churn is simultaneously a spurious rebuild and a lost join.
The index version history is the receipt: v11, v15, v16 and v17 all exist because the identity function's input changed.
Options
D — enforce injectivity first (cheap, do this regardless).build_identity_map (cmd_build.cpp:915) appends without a uniqueness check and its comparator ignores the NodeId, so equal-hash order is unspecified; find_by_identity is a lower_bound returning an arbitrary twin. An std::adjacent_find after the sort turns a silent mis-join into a diagnosable error. This is #170, and it is a few lines.
A — resolve globs after the parse fixpoint. Parse rules with patterns unresolved, then resolve every pattern once against the completed graph plus filesystem, then compute identity. Makes the input set a function of project state by construction, which is how tup gets it: tup resolves globs out of its node table rather than mid-parse. Cost: a resolution pass and identity computation move after parsing; rule-time constructs that consume %f during evaluation need care.
Not recommended — excluding foreign-generated matches by rule. Reviewed and rejected: it would leave the demonstrated case rendering empty on every build rather than fixing it, it regresses cases that work today (a producer that sorts first resolves correctly out-of-tree right now), and it breaks putup's own build graph, where test/runner/Tupfile names ../unit/putup_test$(_exe) and ../../putup$(_exe) — foreign-generated files named explicitly across directories.
Not recommended as stated — keying identity on the output set. Not well-defined here: src/graph/builder.cpp deliberately permits two commands with mutually exclusive guards to declare the same output, so the output set is not unique.
Scope
This issue is the mechanism. #170 is the injectivity half and stays open as its own issue; option D above is its fix.
Not covered here: anything about how the index records state across builds — that is a separate root cause, filed separately.
Provenance
Mechanism and citations verified against merged main. The demonstrations above were run directly. One further claim from the research pass — that an identity collision causes remove_stale_outputs to skip a deletion permanently — did not reproduce when I tried it (the surviving twin recreates the file, so the observation is confounded); it is deliberately not asserted here.
Summary
Command identity is the cross-build join key and the change detector. It is computed by hashing the fully rendered command text —
compute_command_identity(src/graph/dag.cpp:1034) folds inexpand_instruction(...)atdag.cpp:1041, whose%fis expanded fromcmd->inputs.cmd->inputsis not declared data. It is the result of glob resolution performed during the parse, andexpand_glob_pattern(src/graph/builder.cpp:661) unions two halves:for (auto id : nodes_of_type(ctx.state->graph, NodeType::Generated))(builder.cpp:690), a scan of the partially built graph, whose membership is whatever the parse has produced so far.So identity is a function of the parse traversal, not of the project.
Three facts make that concrete:
sort_dirs_by_depth(src/cli/context.cpp:686), which despite its name sorts by byte length of the directory path, descending (:699,:702), then lexicographically.context.cpp:928) re-runs only while new Tupfile directories are discovered. It never re-runs because theGeneratedset grew, and there is no glob re-resolution pass.request_demand_driven_parse(*ctx.eval, abs_pattern_dir_sv)(builder.cpp:685), force-parses the directory the pattern points at — not the directory that produces the match. That is right when a rule writes into its own directory and silent when a third directory writes into the pattern's directory.Demonstrated: a permanent 0-byte artifact, exit 0
Root
Tupfile.ini;b/an empty directory;m/Tupfile=: ../b/*.q |> cat %f > %o |> out.txt;n/Tupfile=: |> echo x > %o |> ../b/gen.q. Out-of-tree (putup -B build):The rule names an input that a sibling directory generates, and putup ships an empty artifact and reports success forever. It never heals: the filesystem half searches the source tree, where the generated file never appears (it lands in
build/b/gen.q), and the graph half already ran.Rename the producing directory and the answer changes. Same project,
n/renamed toa/:Whether this project builds correctly depends on the name of a directory.
In-tree the same project self-heals on build 2 — the filesystem half then finds the generated file sitting next to the sources — at the cost of
%f, and therefore identity, changing between build 1 and build 2 of an unchanged project.Why the recent work does not close this
#171/#174, #180 and #181 purified the order of glob matches, and #180 additionally fixed membership shadowing between the two halves. This is membership as a function of traversal, which none of them touch. #180's description claimed to have removed the fixpoint dependence; that claim has been corrected on the PR.
Analysis: identity does two jobs that want opposite things
find_by_identity(src/cli/cmd_build.cpp:929) servespreserve_old_implicit_edges(:835),merge_out_of_scope_commands(:986),reconcile_input_set(:1406) andremove_stale_outputs(:1517) — a join key, which wants maximal stability.detect_new_commands(:1346) reruns a command whose identity is absent from the old index — a change detector, which wants maximal sensitivity. Conflating them means every source of churn is simultaneously a spurious rebuild and a lost join.The index version history is the receipt: v11, v15, v16 and v17 all exist because the identity function's input changed.
Options
D — enforce injectivity first (cheap, do this regardless).
build_identity_map(cmd_build.cpp:915) appends without a uniqueness check and its comparator ignores the NodeId, so equal-hash order is unspecified;find_by_identityis alower_boundreturning an arbitrary twin. Anstd::adjacent_findafter the sort turns a silent mis-join into a diagnosable error. This is #170, and it is a few lines.A — resolve globs after the parse fixpoint. Parse rules with patterns unresolved, then resolve every pattern once against the completed graph plus filesystem, then compute identity. Makes the input set a function of project state by construction, which is how tup gets it: tup resolves globs out of its node table rather than mid-parse. Cost: a resolution pass and identity computation move after parsing; rule-time constructs that consume
%fduring evaluation need care.Not recommended — excluding foreign-generated matches by rule. Reviewed and rejected: it would leave the demonstrated case rendering empty on every build rather than fixing it, it regresses cases that work today (a producer that sorts first resolves correctly out-of-tree right now), and it breaks putup's own build graph, where
test/runner/Tupfilenames../unit/putup_test$(_exe)and../../putup$(_exe)— foreign-generated files named explicitly across directories.Not recommended as stated — keying identity on the output set. Not well-defined here:
src/graph/builder.cppdeliberately permits two commands with mutually exclusive guards to declare the same output, so the output set is not unique.Scope
This issue is the mechanism. #170 is the injectivity half and stays open as its own issue; option D above is its fix.
Not covered here: anything about how the index records state across builds — that is a separate root cause, filed separately.
Provenance
Mechanism and citations verified against merged main. The demonstrations above were run directly. One further claim from the research pass — that an identity collision causes
remove_stale_outputsto skip a deletion permanently — did not reproduce when I tried it (the surviving twin recreates the file, so the observation is confounded); it is deliberately not asserted here.🤖 Generated with Claude Code
https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA