Skip to content

Reject a recorded type byte that names no type - #407

Merged
typeless merged 2 commits into
mainfrom
wip/399-checked-type-casts
Aug 17, 2026
Merged

Reject a recorded type byte that names no type#407
typeless merged 2 commits into
mainfrom
wip/399-checked-type-casts

Conversation

@typeless

Copy link
Copy Markdown
Owner

What was wrong

Two persisted bytes were cast straight to their enums without validation.

Entries. NodeType names 0–10, so a recorded byte of 11–255 became a well-defined value no
enumerator names. It fell through prior_paths' deliberately default:-less switch into none of
sources, generated, or unowned, and then fed the overwrite guard — a path classified as nothing. The
switch's totality comment was true of the switch and false of the function.

Edges. Same shape, worse tail. LinkType names 1–5 with no zero enumerator, so both 0 and
anything above 5 produced LinkRole::Unknown, which joins no mask and therefore routes
nothing
. A recorded dependency that silently stops routing is the failure class the incremental
campaign exists to prevent.

Why damage rather than forward compatibility

Tolerating an unnameable byte has no disposition a reader can implement. Dropping the entry is the
silent-nothing state being indicted, and substituting a classification is the substitution this area
already forbids. Forward compatibility does not apply either: the readable window's floor admits
only versions whose type vocabulary is a prefix of this one's, so a byte outside today's enum cannot
have come from a readable older version.

The fix

Enforced at the cast site, so no entry carrying an unnameable type is ever constructed.
FileEntry::from_raw and EdgeEntry::from_raw return Result and report IndexDamaged.

Deliberately unchanged:

  • Observers. No default: arm is added anywhere.
  • The announcement side. IndexDamaged is already handled, so nothing widened.
  • The totality comment. It stands verbatim — the fix makes it true rather than needing it
    reworded.

Two encoding choices worth noting: names_node_type switches exhaustively with no default:, so
adding a NodeType enumerator fails -Wswitch instead of silently widening what the reader
accepts; and names_link_type delegates to the existing link_role rather than restating the valid
range, keeping one source of truth for which link types exist.

The recovery read (read_prior_paths, #291) reports the record as lost on a damaged byte rather
than proceeding with a partial table — no prior knowledge is strictly safer than knowledge that
silently omits entries.

Spec

New requirement REQ-READ-REJECT-UNKNOWN-TYPE, discharged by three tests — entry type, edge type,
and the easily-missed edge-type-zero case, which is invalid precisely because LinkType has no zero
enumerator.

Verification

RED quoted for both legs before any production code was written.

Gate Result
build 0
format 0
tidy 0
iwyu 0
spec-check 0
[index] 0 — 7356 assertions

The full suite is green when this work is carried onto the #397 base. On a main base it fails only
at test_builder.cpp:87, which makes scratch under a read-only /tmp on the development machine —
the environment defect PR #406 fixes, and a file this change does not touch. CI runners have a
writable /tmp, so CI is the honest gate here.

Fixes #399

A persisted entry type was cast to NodeType and a persisted edge type to
LinkType without checking either against its enum. NodeType names 0-10, so
a recorded byte of 11-255 became a well-defined value no enumerator names;
it then fell through prior_paths' deliberately default-less switch into
none of sources, generated, or unowned, and fed the overwrite guard as a
path classified as nothing. The switch's totality comment was true of the
switch and false of the function.

The edge side was the same shape with a worse tail: LinkType names 1-5 and
has no zero enumerator, so both 0 and anything above 5 produced
LinkRole::Unknown, which joins no mask and therefore routes nothing — a
recorded dependency silently ceasing to route is the failure class the
incremental campaign exists to prevent.

An unnameable type byte is damage, not a value from a future version.
Tolerating it has no disposition a reader can implement: dropping the entry
is the silent-nothing state itself, and substituting a classification is
the substitution this area already forbids. Forward compatibility does not
apply either, because the readable window's floor admits only versions
whose type vocabulary is a prefix of this one's.

Enforce at the cast site so no entry carrying an unnameable type is ever
constructed: FileEntry::from_raw and EdgeEntry::from_raw now return
Result and report IndexDamaged, which the announcement side already covers.
Observers are untouched, no default: arm is added anywhere, and the
totality comment stands unchanged because the fix makes it true.

names_node_type switches exhaustively with no default, so adding a NodeType
enumerator fails -Wswitch rather than silently widening what the reader
accepts. names_link_type delegates to link_role instead of restating the
valid range, keeping one source of truth for which link types exist.

The recovery read (read_prior_paths, #291) reports the record as lost on a
damaged byte rather than proceeding with a partial table — no prior
knowledge is strictly safer than knowledge that omits entries.

Verified: RED quoted for both legs before any production code. build,
format, tidy, iwyu, spec-check all exit 0; [index] tag 0 with 7356
assertions. The full suite is green when carried onto the #397 base; on
main it fails only in test_builder.cpp:87, which makes scratch under a
read-only /tmp on this machine and is exactly what PR #406 fixes. This
change does not touch that file.

Fixes #399
@typeless
typeless marked this pull request as draft August 17, 2026 02:32
@typeless

Copy link
Copy Markdown
Owner Author

Holding this as a draft — a third leg belongs in it before it merges.

The architect ruled the persisted flags word CLOSED, making it part of this issue rather than a follow-up. I verified both load-bearing claims at the line before accepting:

  • open_index passes INDEX_VERSION as min_version (src/index/reader.cpp:122), so the full read's window is exact-version. No record this binary full-reads can carry a legitimately newer flag vocabulary, which is what an open bitmask would buy. The recovery read never consumes flags at all.
  • get_node_flags (include/pup/index/format.hpp:233) reconstructs the entire 16-bit word and set_node_flags writes it back whole, unmasked. So an unknown bit is not merely inert — it carries: garbage read today is written back into a fresh, checksummed record and thereafter looks authentic.

The builder's original observation was right on its own terms and is confirmed as fact — every consumer is a has_flag membership test, which is exactly why this PR did not need the flags word for correctness. What changes the disposition is that leaving it open would mean stating three conventional invariants in order to avoid one line of validation. Closing is cheaper than stating, and it fails in the right direction: forgetting to extend the mask when a flag is added fails loudly rather than silently widening what the reader accepts.

Planned addition, deliberately small:

  • a mask test in FileEntry::from_raw beside names_node_type, reporting IndexDamaged;
  • the mask constant declared beside the enum;
  • the sibling command-entry raw.flags word, which is milder — the read masks only MustRerun and the write re-derives, so it is self-cleaning — but is the same three-line close.

The spec requirement will be worded over the class — a recorded value outside the vocabulary its version defines — so no flags-specific carve-out sentence is ever needed.

No merge until that lands. The merge decision is the user's regardless.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

PR metrics

Performance (gcc example, Linux)

Workload Instructions CPU time Page faults D1 miss LL miss Wall Peak RSS
parse 1701 M (+0.2%) 0.5 s 14.9 k 0.7% 0.1% 0.512 s 34 MB
dry-run 2318 M (-2.1%) 0.6 s 16.3 k 0.8% 0% 0.59 s 39 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 6102
Graph edges 381415
Index size (bytes) 7683630
Implicit deps 344126
Hash computations 0 (-100.0%)
Hashes skipped (stat cache) 5833 (+3.5%)
Stat calls 5885
Parse time (ms) 461.4
Total time (ms) 588.7
Runner CPU AMD EPYC 9V74 80-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 (AMD EPYC 7763 64-Core Processor).

Binary size (Linux)

Binary .text .data .bss File
putup 566.2 KB (+0.1%) 2.3 KB 98.7 KB 671.2 KB

Code churn (whole codebase, last 30d)

Files Lines written Still present Churned Churn rate
114 9748 8087 1661 17%

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. 4362 lines were deleted in the window in total, most of them older than it.

Where the churn is
File Lines written then discarded
src/cli/cmd_build.cpp 592
src/graph/scanners/clang_cl.cpp 295
src/graph/scanners/gcc.cpp 113
src/cli/context.cpp 82
src/core/layout.cpp 69
src/graph/scanners/dep_words.cpp 64
src/graph/builder.cpp 62
src/platform/file_io-posix.cpp 42
src/cli/strict_checks.cpp 36
src/cli/cmd_clean.cpp 35

Test coverage (lines)

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

102 files · 16646/18886 lines covered

Deltas vs main@26ddb6105.

Updated for a611bb4

@typeless

Copy link
Copy Markdown
Owner Author

CI is 13/13 green on this branch as it stands (run against base main).

Worth recording because it settles the branch-base question this PR was opened under: the local make test red on a main base is confined to test_builder.cpp:87, which makes scratch under a read-only /tmp on the development machine. CI runners have a writable /tmp, so every job passes here. That is the #397 environment defect — PR #406 is its fix, and this change does not touch that file.

So this branch did not need to stack on #406, and it does not. The two PRs are independent.

This PR nonetheless stays a draft until the flags-word leg lands, per the ruling recorded above — green CI is not the same as complete.

The persisted node and command flags words were read back with no check
against their vocabularies. NodeFlags names bits 0-4 and CommandFlag bit 0,
but get_node_flags reconstructs the whole 16-bit word and set_node_flags
writes the whole word back, neither masked -- so a garbage high bit read
today is written into a fresh, re-checksummed record and thereafter looks
authentic. An unnamed bit is not inert; it carries.

Openness buys nothing to offset that: read_index opens at exactly
INDEX_VERSION, so no record it reads can carry a legitimately newer flag
vocabulary.

Validate at the cast site, where the type bytes are already validated:
FileEntry::from_raw rejects a node flags word with a bit outside the
readable mask, and CommandEntry::from_raw -- now returning Result -- rejects
a command flags word the same way.

Neither mask is written out. RECORDED_NODE_FLAGS_MASK is derived by a
constexpr bit sweep over an exhaustive is_recorded_node_flag switch with no
default arm, so a new flag that forgets the mask is a -Wswitch build error
rather than a reader that silently rejects a legitimate record. The command
mask derives from the existing flag_category for the same reason, minting no
second classifier; that made flag_category's "nothing calls this" comment
false, so it is gone.

The vocabulary a word is judged against spans every version the readable
window admits, uniformly rather than per-version -- the check takes no
version and branches on none. NodeFlags::Inactive held bit 5 through index
version 13 and was retired without a format change, and read_prior_paths
reads back to INDEX_LAYOUT_FLOOR, so bit 5 stays readable: validating
against today's live mask alone turns a legitimate v9-v13 record into Lost.
A read word is also carried whole into the next record written, so a
current-version record holding a retired bit is an honest descendant of a
pre-retirement one rather than damage.

Bit 5 is now a hole, and a sixth flag would naturally take it -- at which
point an old record's stale Inactive bit would silently mean the new flag,
which is misinterpretation rather than the loud over-rejection this fix
started from. A disjointness static_assert makes reassigning a retired bit a
build error, and the value pin's message routes a future retirement into the
retired mask instead of letting the bit vanish.

Both from_raw doc headers now state their rejection contracts, which neither
did for the failure modes this change gives them: FileEntry's named only the
type byte, and CommandEntry's said nothing at all on a function that had
just gained a failure mode.

Verified: RED quoted for both legs before any production code; the retired
bit is pinned by a test proved to fail against the live-mask-only shape, and
the disjointness assert was proved to fire by reassigning a retired bit.
build, format, tidy, iwyu, spec-check all exit 0; whole suite green in one
process, 170265 assertions in 841 test cases.
@typeless
typeless marked this pull request as ready for review August 17, 2026 04:48
@typeless
typeless merged commit dc82b73 into main Aug 17, 2026
13 checks passed
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.

An out-of-range NodeType byte falls out of prior_paths into no list, defeating the totality its own comment claims

1 participant