Skip to content

graph: key putup's own tool fingerprints out of the Tupfile's namespace - #485

Merged
typeless merged 1 commit into
mainfrom
fix/484-internal-var-namespace
Sep 21, 2026
Merged

typeless merged 1 commit into
mainfrom
fix/484-internal-var-namespace

Conversation

@typeless

Copy link
Copy Markdown
Owner

Closes #484.

A Tupfile line as ordinary as export TUP_TOOLCHAIN silently disabled tool-change detection for the whole build. The PATH swap that should have re-run every command printed Nothing to do (up to date) and left the stale output in place, with no diagnostic. export TUP_TOOL_<name> did the same to the per-command tool stat #483 added, reopening #482 through a name.

Cause

ensure_env_var_node keys every NodeType::Variable node by bare variable name in one map, and a find-by-name hit is treated as same variable, new value — it overwrites the node's name and content hash. That is right for a Tupfile variable and wrong for a node putup owns. Four producers share the key space: process_export and process_import on the user's side, the TUP_TOOLCHAIN fingerprint and the TUP_TOOL_<name> tool stat on putup's. Whichever runs second wins.

Fix

putup's two producers go through a new ensure_internal_var_node, which prefixes the key with @. No Tupfile can spell a name starting with @: parse_export and parse_import accept exactly one Identifier or Text token, and @ lexes as TokenType::At. The two key spaces are therefore disjoint by construction rather than by a reserved-name list, and callers pass the bare name and never see the prefix — a future synthetic node kind cannot forget to apply it.

On a live build the two nodes now coexist:

f4 [label="$/TUP_TOOL_mytool="]                                # the Tupfile's exported variable
f5 [label="$/@TUP_TOOL_mytool=…/second/mytool:18:17899815…"]   # putup's tool stat

Nothing is reserved after this: export TUP_TOOL_gcc is an ordinary environment variable with the semantics it should always have had. That is why docs/reference.md is untouched — there is no rule for a Tupfile author to learn.

Alternatives rejected

Both were weighed on a full test-surface comparison against this one.

Reject reserved names at parse time. Keeps two lists — the reserved names, and the names internal producers actually emit — equal by hand, leaves the same hazard standing for every name not yet on the list, and adds a user-visible error where none is needed.

A second map keyed by bare name. Leaves two further illegal states reachable: add_file_node discards SortedPairVec::insert's duplicate-key signal, so a user node whose NAME=VALUE equals an internal one silently aliases it in path_to_node; and cached_env_vars would still serve the fingerprint to a later import.

A separate parent directory plus its own map (the shape config_var_nodes already uses) was also compared. It avoids the forced rebuild, since identity hashes a Variable node's name rather than its path — but it makes a second invariant rest on the unshared "$/" string literal that src/cli/context.cpp and src/graph/builder.cpp each spell separately, moving the check to the reader.

The comparison also recommended expressing the choice as a sum-typed key (User{name} | Internal{name}) on ensure_env_var_node rather than as a second function. I did not take that: User{"TUP_TOOLCHAIN"} compiles just as readily as calling the wrong function does, so the type buys the same guarantee as two names at the cost of a new type and four touched call sites.

Costs and residuals

  • Node names changed, so INDEX_VERSION goes 26 → 27 and the first build after this re-runs every command once.
  • show index now prints the fingerprints as $/@TUP_TOOLCHAIN=… and $/@TUP_TOOL_<name>=…. No test snapshots those names and no document mentions them.
  • The prefixed nodes still enter cached_env_vars in src/cli/context.cpp. No import can match an unspellable key, so the entries are inert; filtering them would put the prefix in a second file.

Verification

Red-then-green, both shapes. On the parent commit each reports Nothing to do (up to date) with a stale output; both pass here. A third scenario pins that an exported variable named after a tool still reaches the command and still re-runs it when its value changes — it passes with or without the fix, and exists to catch the fix breaking export.

The unspellability claim is discharged by a test exhaustive over all 255 leading bytes against both keywords, mutation-checked: asserting P instead of @ fails it at byte 80.

make check: rc=0, 182606 assertions in 951 test cases across 32 e2e shards.

🤖 Generated with Claude Code

https://claude.ai/code/session_01StgwMENEyfBnEoe4pvAdtQ

A Tupfile line as ordinary as `export TUP_TOOLCHAIN` silently disabled
tool-change detection for the whole build: the PATH swap that should have
re-run every command reported "Nothing to do (up to date)" and left the
stale output in place, with no diagnostic. `export TUP_TOOL_<name>` did the
same to the per-command tool stat added in #483, which is to say it reopened
#482 through a name.

Root cause: `ensure_env_var_node` keys every `NodeType::Variable` node by
bare variable name in one map, and a find-by-name hit is treated as "same
variable, new value" - it overwrites the node's name and content hash. That
is right for a Tupfile variable and wrong for a node putup owns. Four
producers share the key space: `process_export` and `process_import` on the
user's side, the `TUP_TOOLCHAIN` fingerprint and the `TUP_TOOL_<name>` tool
stat on putup's. Whichever runs second wins.

putup's two now go through `ensure_internal_var_node`, which prefixes the key
with `@`. No Tupfile can spell a name starting with `@`: `parse_export` and
`parse_import` accept one Identifier or Text token, and `@` lexes as
`TokenType::At` - so the two key spaces are disjoint by construction, not by
a reserved-name list. Callers pass the bare name and never see the prefix,
so a future synthetic node kind cannot forget to apply it.

Two alternatives were measured against this one and rejected. Rejecting a
reserved name at parse keeps two lists - the reserved names and the names
internal producers actually emit - equal by hand, leaves the same hazard
standing for every name not yet on the list, and adds a user-visible error
where none is needed. A second map keyed by bare name leaves two further
illegal states reachable: `add_file_node` discards `SortedPairVec::insert`'s
duplicate-key signal, so a user node whose `NAME=VALUE` equals an internal
one silently aliases it in `path_to_node`; and `cached_env_vars` would still
serve the fingerprint to a later `import`.

Nothing is reserved after this. `export TUP_TOOL_gcc` is an ordinary
environment variable with the semantics it should always have had. A third
scenario pins that, and passes with or without this change - it is a
coexistence pin against a future fix that reserves the names instead, not
part of the red.

`show index` now prints the fingerprints as `$/@TUP_TOOLCHAIN=...` and
`$/@TUP_TOOL_<name>=...`. No test snapshots those names and no document
mentions them.

Residual: the prefixed nodes still enter `cached_env_vars` in
`src/cli/context.cpp`. No `import` can match an unspellable key, so the
entries are inert; filtering them would put the prefix in a second file.

Node names changed, so INDEX_VERSION goes 26 to 27 and the first build after
this re-runs every command once.

Verified red-then-green: both reported shapes fail with "Nothing to do (up to
date)" and a stale output on a control built from the parent commit with the
tests and version bump present and only the builder change reverted, and pass
here. The
unspellability law is exhaustive over all 255 leading bytes against both
keywords, and was mutation-checked - asserting `P` instead of `@` fails it
at byte 80.

Ref: #484

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

PR metrics

Performance (gcc example, Linux)

Workload Instructions CPU time Page faults D1 miss LL miss Wall Peak RSS
parse 1758 M (+0.1%) 0.63 s 15.2 k 0.7% (+0.1pp) 0.1% (+0.1pp) 0.629 s 35 MB (-0.3MB)
dry-run 2484 M (+0.1%) 0.71 s 16.8 k 0.8% (+0.2pp) 0% 0.731 s 41.5 MB

Deterministic signals: instructions (cachegrind-simulated instruction reads — exact across runs, no PMU needed), page faults, peak RSS, and the cachegrind D1/LL miss rates. CPU time is user+sys from time(1).

Internal statistics (gcc example, up-to-date dry run)

Metric Value
Tupfiles parsed 24
Commands 3545
Commands scheduled 0
Files checked 5834
Files changed 0
Files in index 6188
Graph edges 384927
Index size (bytes) 7869819
Implicit deps 344126
Hash computations 197
Hashes skipped (stat cache) 5636
Stat calls 5885
Parse time (ms) 542.6
Total time (ms) 709.5
Runner CPU AMD EPYC 7763 64-Core Processor

Counters from putup -n --stat on the fully-built gcc example (up-to-date dry run): deterministic work measures — a jump in commands scheduled, hash computations, or stat calls is a real behavior change, not noise. Timings are the minimum over repeated runs, compared only against a baseline from the same CPU model; the counters are the regression signal.
Timing deltas suppressed: baseline ran on different hardware (Intel(R) Xeon(R) 6973P-C).

Binary size (Linux)

Binary .text .data .bss File
putup 601.1 KB (+0.1%) 2.4 KB (+4.3%) 98.8 KB 711.8 KB

Code churn (whole codebase, last 30d)

Files Lines written Still present Churned Churn rate
31 2407 2074 333 13.8%

Of the lines written across the codebase in the last 30 days, how many are already gone — work that was written and then discarded or rewritten inside the same window. This is the state of the tree including this PR, not a measure of the PR itself. Only code we write is counted: tests, examples, vendored and generated files, CI plumbing and prose are excluded. 1597 lines were deleted in the window in total, most of them older than it.

Where the churn is
File Lines written then discarded
src/parser/eval.cpp 77
src/graph/builder.cpp 72
src/graph/dag.cpp 53
src/index/entry.cpp 44
include/pup/core/token_list.hpp 23
include/pup/parser/eval.hpp 16
src/cli/cmd_build.cpp 8
src/index/reader.cpp 8
include/pup/core/instruction.hpp 7
src/core/instruction.cpp 7

Test coverage (lines)

Overall Median file Min file Max file
88.9% 96.9% 14.7% include/pup/parser/token.hpp 100.0% include/pup/core/arena.hpp

105 files · 17874/20104 lines covered

Deltas vs main@2987923bf.

Updated for c02eeeb

@typeless
typeless merged commit 04ed389 into main Sep 21, 2026
13 checks passed
@typeless
typeless deleted the fix/484-internal-var-namespace branch September 21, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

export or import of TUP_TOOLCHAIN or TUP_TOOL_<name> overwrites putup's own tool node and silently disables tool-change detection

1 participant