diff --git a/dag/gunbc/target_invocation.dag b/dag/gunbc/target_invocation.dag index 7d722bb3e99..7bdabbb04c7 100644 --- a/dag/gunbc/target_invocation.dag +++ b/dag/gunbc/target_invocation.dag @@ -266,3 +266,101 @@ fn invocation_refusal_rendered(cause: TargetInvocationRefusal) -> String { concat("gunbc test: target has no bound producer: ", render_label(l: t)) } } + +// IS THE BINARY ANSWERING FOR THE TREE IT IS ABOUT TO READ? +// +// Every instrument behind `gunbc test` is compiled into the binary that runs it, and reads a live +// tree beside it. A binary whose inputs have changed since it was built answers for code that is +// no longer there, and its reading is reported under the current tree's name -- a silent wrong +// answer, four incidents in one day before this check existed. So the invocation's first act is +// to ask whether the binary still answers for its inputs, and refuse when it does not or when +// nothing can say. +// +// THE INPUT SET IS THE ONE RUSTC RECORDED, NOT A LIST WE AUTHOR. Cargo's dep-info beside the +// binary (`.d`) lists every file the compile read, `include_str!` files included, so the +// set is exact, not a path heuristic. Keying on HEAD equality instead would refuse a correct binary +// after every unrelated commit, which teaches people to route around the check +// (calm-boar-904's ruling on #13007). +// +// The comparison is cargo's own rule: an input newer than the BUILD START, read from the unit's +// fingerprint dep-info, which cargo rewinds to when compilation began. The binary's own mtime is +// the wrong anchor: an input edited after rustc read it but before the link finished is older +// than the binary and was never compiled. So a refusal here means exactly "cargo build would +// rebuild". It is NOT content-exact: an input whose mtime moved +// without its bytes changing refuses too, as cargo would rebuild. A content-exact check needs a +// digest recorded AFTER the compile that writes dep-info, which no in-tree build hook can do. +// +// An input outside this worktree is the shared-target hazard: `/cargo-target` serves a binary +// another worktree built, and its dep-info names that worktree's files. Nothing here can say +// whether those files match ours, so that is undecided, not fresh. +// +// The host classifies each input (the effectful mtime and path reads); this function decides +// the precedence: undecided beats stale beats fresh, so an unjudgeable input is never masked by a +// staleness verdict and a stale input never by a fresh one. +type BinaryInput + = InputNotNewer { path: String } + | InputNewer { path: String } + | InputMissing { path: String } + | InputOutsideWorktree { path: String } + +type BinaryInputObservation + = InputsObserved { binary: String, inputs: List } + | DepInfoUnreadable { binary: String, reason: String } + +type BinaryFreshness + = BinaryFresh { binary: String, inputs: Int } + | BinaryStale { binary: String, input: String, why: String } + | BinaryFreshnessUndecided { binary: String, reason: String } + +// One input's step. An undecided verdict absorbs every later input; a stale one absorbs every +// later input except an outside one, which outranks it. +fn binary_freshness_step(acc: BinaryFreshness, input: BinaryInput) -> BinaryFreshness { + match acc { + BinaryFreshnessUndecided { binary: _, reason: _ } => acc + BinaryStale { binary: b, input: _, why: _ } => + match input { + InputOutsideWorktree { path: p } => binary_built_elsewhere(binary: b, path: p) + InputNewer { path: _ } => acc + InputMissing { path: _ } => acc + InputNotNewer { path: _ } => acc + } + BinaryFresh { binary: b, inputs: n } => + match input { + InputOutsideWorktree { path: p } => binary_built_elsewhere(binary: b, path: p) + InputNewer { path: p } => BinaryStale { binary: b, input: p, why: "changed after the binary was built" } + InputMissing { path: p } => BinaryStale { binary: b, input: p, why: "no longer exists" } + InputNotNewer { path: _ } => BinaryFresh { binary: b, inputs: n + 1 } + } + } +} + +fn binary_built_elsewhere(binary: String, path: String) -> BinaryFreshness { + BinaryFreshnessUndecided { + binary: binary, + reason: concat(concat("its dep-info names an input outside this worktree (", path), + "); it was built from another checkout, so nothing here says whether it answers for this one") + } +} + +fn assess_binary_freshness(observed: BinaryInputObservation) -> BinaryFreshness { + match observed { + DepInfoUnreadable { binary: b, reason: r } => BinaryFreshnessUndecided { binary: b, reason: r } + InputsObserved { binary: b, inputs: is } => + fold(is, init: BinaryFresh { binary: b, inputs: 0 }, f: fn(acc, i) { + binary_freshness_step(acc: acc, input: i) + }) + } +} + +// Only `BinaryFresh` admits the invocation; both other arms terminate `InvocationRefused` (exit +// 2, no observation) with their line, never a warning printed beside a reading. +fn binary_freshness_rendered(f: BinaryFreshness) -> String { + match f { + BinaryFresh { binary: b, inputs: _ } => concat("gunbc test: binary fresh against its dep-info: ", b) + BinaryStale { binary: b, input: p, why: w } => + concat(concat(concat(concat(concat(concat( + "gunbc test: REFUSED cause=StaleBinary — ", b), ": input "), p), " "), w), "; rebuild before measuring") + BinaryFreshnessUndecided { binary: b, reason: r } => + concat(concat(concat("gunbc test: REFUSED cause=BinaryFreshnessUndecided — ", b), ": "), r) + } +} diff --git a/dag/gunbc/target_invocation_seed_growth.dag b/dag/gunbc/target_invocation_seed_growth.dag index 85b8639d272..a1a0d1fa5b0 100644 --- a/dag/gunbc/target_invocation_seed_growth.dag +++ b/dag/gunbc/target_invocation_seed_growth.dag @@ -468,9 +468,79 @@ data target_invocation_seed_growth_justification: SeedGrowthJustification = Seed module_path: "v1_compiler.behavioral_receipt_host", decl_name: "run_plan", field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "BinaryInput", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "BinaryInputObservation", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "BinaryFreshness", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "binary_built_elsewhere", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "binary_freshness_step", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "assess_binary_freshness", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "binary_freshness_rendered", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "dep_info_inputs", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "classify_binary_inputs", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "observe_binary_inputs", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "test_verb_checked", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "test_verb_after", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "build_start_anchor", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "observe_binary_inputs_of", + field: WholeDeclaration } ], - reason: "The verb's leaf producer is host Rust because its subject is a filesystem walk the hermetic floor refuses; the route around that leaf is hand-mirrored because none of the three modeled modules is in the v1 seed's emitted closure, and the two alternatives are worse -- routing through the interpreter makes it load-bearing for a NEW capability while two lanes delete it, and growing the emitter's closure to reach extdeps.bazel.label is a larger seed change than the verb it serves. What the growth buys is that the repository's instruments become addressable by label at all: before it, no target in this repository could be run by name. The diagnostic-census producer and its cli_run carriers joined under the same reasoning: the whole-tree compile-clean closure is a host effect no hermetic witness may run, and the census restores the reachability the gunbc#9160 sweep removed with the unrostered bin. The 2026-09-09 raw-census rework added the identity, disposition, refusal and specimen carriers: binding the observation to its source vector, compiler, policy, schema and closure is hashing and a total-match specimen over CompilerDiagnostic, both host facts. The 2026-09-20 pattern admission added the parse_target_pattern mirror (TargetPattern, TargetPatternRefusal, target_pattern_refusal_text, parse_pattern_package) beside the parse_label mirror it delegates the single-target form to, plus test_operand_set_form_refusal_rendered: a SET form is admitted by the grammar and REFUSED by the route with status 2 (operator ruling 2026-09-21), because its only admissible executor is the native test route and no interpreter delegation may stand in for it -- so that growth was the admission mirror and one refusal text, and no selection or execution semantics. In the same change the host's OperandNotALabel refusal arm and label_refusal_rendered deleted as unconstructible: every label refusal now arrives inside PatternLabelRefused. THAT SCOPE SENTENCE NO LONGER DESCRIBES THIS SEAM, AND IT IS CORRECTED HERE RATHER THAN LEFT STANDING (v2 foundation package 9). The native test route is now reachable by operand, and both of the things the old sentence excluded are present: SELECTION -- target_pattern_within with target_pattern_package, package_within and render_target_pattern, the mirror of the containment that decides WHICH executor answers an operand, plus native_route_default_pattern and its rendered form, the one seed-side spelling of the native universe that the lane runner and the verb both consume; and EXECUTION SEMANTICS -- run_native_test_route, which spawns the emitted compiler over the operand's pattern and partitions its answer, with native_lane_runner's NativeRouteOutcome, NativeRunAdmission and run_required_v2_native_inner carrying that partition as a typed value so no consumer recovers it from rendered text. This is the first execution semantics on this seam and it is counted as such: DESIGN section 7 asks for a declared row that is COUNTABLE, and a row whose reason excludes what the file now holds is worse than an uncounted declaration, because it tells a reader the seed is smaller than it is. THE MIGRATION TRIGGER IS UNCHANGED BY THIS GROWTH and is the same one the route has always waited on: these mirrors dissolve when the modeled modules enter an emitted closure, at which point the route is the .dag fold and this file is deleted rather than trimmed. A LATER GROWTH ADDED THE MEMBER-SCOPED STANDING and is counted here too: NativeMemberVerdict and NativeMemberTermination mirror v2.compiler.native_test_vocabulary NativeTestVerdict at its discriminant and gunbc.instrument_targets native_route_member_termination's result, native_member_termination mirrors that fold ARM FOR ARM INCLUDING ITS PRECEDENCE, and native_member_outcome maps the result onto this verb's termination. That growth is NOT a new authority -- the fold is modeled and its precedence is argued in the .dag source -- BUT ITS PARITY WITH THE MODEL IS UNCHECKED, AND AN EARLIER VERSION OF THIS SENTENCE CLAIMED OTHERWISE (review 70196). It said the mirror carries a discriminating red so that drift from the model is detectable. That is rung inflation of exactly the kind DESIGN section 4b(1) names, and it contradicted this row's own standing sentence two paragraphs above: what is NOT claimed is that the mirror is checked, only that it is enumerated. WHAT THE TWO TESTS ACTUALLY DO: the Rust test the_precedence_matches_the_modeled_fold compares native_member_termination against literals hard-coded in Rust, and the .dag witnesses compare native_route_member_termination against literals authored in .dag. NOTHING COMPARES THE TWO. Change the precedence in the model and update its .dag witness to match, and both suites stay green while the host answers differently -- the Rust test catches a Rust-side edit, which is the mirror drifting from its own test rather than from its model. AND THAT TEST DOES NOT RUN ON THE MERGE PATH AT ALL: cargo test -p v1-compiler --lib is executed by no CI step, which is the declared drop gunbc.rung_drop rust_unit_tests_off_the_merge_path. So this growth sits at the SAME mitigatable rung as the rest of this row and for the same reason, and its parity is carried by review diligence. WHAT WOULD CLIMB IT is a parity check that runs the same discriminating cases through BOTH folds and compares the results -- which needs the model executed from the host, and would additionally need a CI step that runs it before it establishes anything. It dissolves on the SAME trigger as the rest of this row.", + reason: "The verb's leaf producer is host Rust because its subject is a filesystem walk the hermetic floor refuses; the route around that leaf is hand-mirrored because none of the three modeled modules is in the v1 seed's emitted closure, and the two alternatives are worse -- routing through the interpreter makes it load-bearing for a NEW capability while two lanes delete it, and growing the emitter's closure to reach extdeps.bazel.label is a larger seed change than the verb it serves. What the growth buys is that the repository's instruments become addressable by label at all: before it, no target in this repository could be run by name. The diagnostic-census producer and its cli_run carriers joined under the same reasoning: the whole-tree compile-clean closure is a host effect no hermetic witness may run, and the census restores the reachability the gunbc#9160 sweep removed with the unrostered bin. The 2026-09-09 raw-census rework added the identity, disposition, refusal and specimen carriers: binding the observation to its source vector, compiler, policy, schema and closure is hashing and a total-match specimen over CompilerDiagnostic, both host facts. The 2026-09-20 pattern admission added the parse_target_pattern mirror (TargetPattern, TargetPatternRefusal, target_pattern_refusal_text, parse_pattern_package) beside the parse_label mirror it delegates the single-target form to, plus test_operand_set_form_refusal_rendered: a SET form is admitted by the grammar and REFUSED by the route with status 2 (operator ruling 2026-09-21), because its only admissible executor is the native test route and no interpreter delegation may stand in for it -- so that growth was the admission mirror and one refusal text, and no selection or execution semantics. In the same change the host's OperandNotALabel refusal arm and label_refusal_rendered deleted as unconstructible: every label refusal now arrives inside PatternLabelRefused. THAT SCOPE SENTENCE NO LONGER DESCRIBES THIS SEAM, AND IT IS CORRECTED HERE RATHER THAN LEFT STANDING (v2 foundation package 9). The native test route is now reachable by operand, and both of the things the old sentence excluded are present: SELECTION -- target_pattern_within with target_pattern_package, package_within and render_target_pattern, the mirror of the containment that decides WHICH executor answers an operand, plus native_route_default_pattern and its rendered form, the one seed-side spelling of the native universe that the lane runner and the verb both consume; and EXECUTION SEMANTICS -- run_native_test_route, which spawns the emitted compiler over the operand's pattern and partitions its answer, with native_lane_runner's NativeRouteOutcome, NativeRunAdmission and run_required_v2_native_inner carrying that partition as a typed value so no consumer recovers it from rendered text. This is the first execution semantics on this seam and it is counted as such: DESIGN section 7 asks for a declared row that is COUNTABLE, and a row whose reason excludes what the file now holds is worse than an uncounted declaration, because it tells a reader the seed is smaller than it is. THE MIGRATION TRIGGER IS UNCHANGED BY THIS GROWTH and is the same one the route has always waited on: these mirrors dissolve when the modeled modules enter an emitted closure, at which point the route is the .dag fold and this file is deleted rather than trimmed. A LATER GROWTH ADDED THE MEMBER-SCOPED STANDING and is counted here too: NativeMemberVerdict and NativeMemberTermination mirror v2.compiler.native_test_vocabulary NativeTestVerdict at its discriminant and gunbc.instrument_targets native_route_member_termination's result, native_member_termination mirrors that fold ARM FOR ARM INCLUDING ITS PRECEDENCE, and native_member_outcome maps the result onto this verb's termination. That growth is NOT a new authority -- the fold is modeled and its precedence is argued in the .dag source -- BUT ITS PARITY WITH THE MODEL IS UNCHECKED, AND AN EARLIER VERSION OF THIS SENTENCE CLAIMED OTHERWISE (review 70196). It said the mirror carries a discriminating red so that drift from the model is detectable. That is rung inflation of exactly the kind DESIGN section 4b(1) names, and it contradicted this row's own standing sentence two paragraphs above: what is NOT claimed is that the mirror is checked, only that it is enumerated. WHAT THE TWO TESTS ACTUALLY DO: the Rust test the_precedence_matches_the_modeled_fold compares native_member_termination against literals hard-coded in Rust, and the .dag witnesses compare native_route_member_termination against literals authored in .dag. NOTHING COMPARES THE TWO. Change the precedence in the model and update its .dag witness to match, and both suites stay green while the host answers differently -- the Rust test catches a Rust-side edit, which is the mirror drifting from its own test rather than from its model. AND THAT TEST DOES NOT RUN ON THE MERGE PATH AT ALL: cargo test -p v1-compiler --lib is executed by no CI step, which is the declared drop gunbc.rung_drop rust_unit_tests_off_the_merge_path. So this growth sits at the SAME mitigatable rung as the rest of this row and for the same reason, and its parity is carried by review diligence. WHAT WOULD CLIMB IT is a parity check that runs the same discriminating cases through BOTH folds and compares the results -- which needs the model executed from the host, and would additionally need a CI step that runs it before it establishes anything. It dissolves on the SAME trigger as the rest of this row. THE 2026-10-02 FRESHNESS GROWTH (gunbc#13007) added the binary-freshness mirror: BinaryInput, BinaryInputObservation, BinaryFreshness, binary_built_elsewhere, binary_freshness_step, assess_binary_freshness and binary_freshness_rendered mirror gunbc.target_invocation arm for arm, and dep_info_inputs, classify_binary_inputs, build_start_anchor, observe_binary_inputs_of, observe_binary_inputs, test_verb_checked and test_verb_after are the effectful half -- reading the running binary's cargo dep-info, its fingerprint unit's build-start stamp, and each input's mtime, which no .dag fold can do. Its parity with the model is unchecked in exactly the sense stated above for the member fold, and it dissolves on the same trigger.", owning_dissolution_lane: "v1-hand-queue-drain" as RoadmapNodeId, trigger: "Discharge each declaration when its modeled realization is emitted rather than authored. The target_invocation_host subset deletes when gunbc.target_invocation and gunbc.instrument_targets enter the emitted closure of whatever renders gunbc's CLI; the behavioral_receipt_host subset deletes when the three already-modeled behavioral producers gain emitted native realization. A PARTIAL migration is admissible and expected: label admission dissolves when extdeps.bazel.label is emitted, independently of either producer family. Moving a declaration to another host file or adding a parity witness does not discharge it -- both leave the hand-authored declaration standing.\n\nTHE floor_memory_supervisor SUBSET HAS A DIFFERENT AND HONESTLY WEAKER TRIGGER, stated separately because assimilating it to the sentence above would promise a discharge that is not in sight. Its operations are resource effects -- read /proc/self/cgroup, spawn a child, wait on it, read /sys/fs/cgroup after the child is gone -- and NONE of the four has a bound native realization. The mechanism once named here was wrong and is corrected rather than softened: it read that resource operations resolve only inside a workflow function realized by the seed INTERPRETER. There is no workflow function -- gunbc#11528 deleted the concept -- and admissibility is the `uses` clause, an effect requirement. The measured refusal `receiver type Primitive(Filesystem) establishes no method surface` off a plain fn stands; what it establishes is that a resource METHOD needs a declared requirement, not that an emitted binary cannot perform an effect. So this subset does NOT discharge by the emitter reaching gunbc.instrument_targets, and it does not discharge on the Filesystem work of demand_engine_program D11 either, because spawn and wait are outside that denominator. It discharges when the substrate can express a SUPERVISED CHILD PROCESS -- spawn, wait on a termination status, and read a filesystem path after that child is gone -- as modeled effects with typed refusals, at which point the fold replaces this file and the dispatch arm calls it. Until that capability exists the subset is frozen rather than shrinking, and the honest reading is that it is the newest and least dischargeable growth on this row. WHAT DOES NOT DISCHARGE IT: moving the judgment further into .dag (already done -- the verdict is gunbc.floor_memory_demand's and no threshold lives in the Rust), nor adding witnesses over the read, because neither removes a single one of the four effects.", current_boundary: "src/v1/stage0/src/target_invocation_host.rs; src/v1/stage0/src/behavioral_receipt_host.rs (native realization of the already-modeled behavioral producers, relocated from claim_executor); src/v1/stage0/src/cli_run.rs (two #[path] mod lines plus the `floor_memory_supervisor` mod line); src/v1/stage0/src/cli_run/floor_memory_supervisor.rs (the supervised cgroup read behind //gunbc/instruments:floor-memory-qualification: create nothing, spawn the measured child into the supervisor's OWN cgroup, wait, then read memory.peak and memory.events from outside the dead process. It is host Rust because every operation in it is one no .dag fold can perform -- reading /proc/self/cgroup, spawning a process, waiting on it, and reading /sys/fs/cgroup after it is gone -- and because an in-process observer cannot report the terminations that matter: a Drop guard does not run on abort, process::exit or SIGKILL, and Rust's allocation-error handler aborts, so OOM and allocation failure are exactly the cases only an outliving supervisor can report. The JUDGMENT over what it reads is NOT here: it is gunbc.floor_memory_demand qualify_floor_memory_from_readings, and this module deliberately holds no threshold, no arm and no verdict); src/v1/stage0/src/cli_run/compile_clean.rs (the raw diagnostic census carriers: CensusGateDisposition, CensusSeverity, RawDiagnosticClassObservation, CompileCleanDiagnosticCensusIdentity, CompileCleanDiagnosticCensus, CompileCleanDiagnosticCensusRefusal, compile_clean_diagnostic_class_specimen, compile_clean_diagnostic_census, compile_clean_diagnostic_policy_snapshot, census_instance_disposition, compiler_diagnostic_constructor_census, census_gate_tag, census_severity_tag, census_identity_digest, diagnostic_census_refusal_rendered); generated gunbc_cli_dispatch_generated.rs (modeled Commands variant and generic retained-host selection); src/v1/stage0/src/main.rs (retained target producer host method only); dag/gunbc/target_invocation_seed_growth.dag" diff --git a/dag/test/claim/target_invocation_witness_test.dag b/dag/test/claim/target_invocation_witness_test.dag index 058e9c58e31..4d6e92e2b15 100644 --- a/dag/test/claim/target_invocation_witness_test.dag +++ b/dag/test/claim/target_invocation_witness_test.dag @@ -41,6 +41,10 @@ import gunbc.target_binding { BareChannelEntryAbsent, BareChannelIndexRefused, } import gunbc.target_invocation { + BinaryInput, InputNotNewer, InputNewer, InputMissing, InputOutsideWorktree, + BinaryInputObservation, InputsObserved, DepInfoUnreadable, + BinaryFreshness, BinaryFresh, BinaryStale, BinaryFreshnessUndecided, + assess_binary_freshness, TargetInvocationRoute, TargetInvocationRouted, TargetInvocationRouteRefused, TargetInvocationRefusal, OperandNotALabel, InvocationRegistryRefused, InvocationLabelNotCanonical, @@ -1096,3 +1100,38 @@ test fn a_refused_single_target_renders_the_label_grammars_located_cause() -> Bo _ => false } } + +// STALE-BINARY CONTROLS. Before `assess_binary_freshness` existed every arm below ran its +// instrument; the stale and undecided arms are the reds that old behaviour lacked. The fresh arm +// is the positive control that an unrelated commit no longer refuses: no input moved. +fn observed(inputs: List) -> BinaryInputObservation { + InputsObserved { binary: "bin", inputs: inputs } +} + +test fn a_binary_whose_inputs_did_not_move_is_fresh() -> Bool { + match assess_binary_freshness(observed: observed(inputs: [InputNotNewer { path: "a.rs" }, InputNotNewer { path: "b.dag" }])) { + BinaryFresh { binary: _, inputs: n } => n == 2 + _ => false + } +} + +test fn the_first_changed_input_is_named_as_stale() -> Bool { + match assess_binary_freshness(observed: observed(inputs: [InputNotNewer { path: "a.rs" }, InputNewer { path: "b.rs" }, InputMissing { path: "c.rs" }])) { + BinaryStale { binary: _, input: p, why: _ } => p == "b.rs" + _ => false + } +} + +test fn an_input_from_another_worktree_outranks_staleness() -> Bool { + match assess_binary_freshness(observed: observed(inputs: [InputNewer { path: "b.rs" }, InputOutsideWorktree { path: "/w/other/a.rs" }])) { + BinaryFreshnessUndecided { binary: _, reason: _ } => true + _ => false + } +} + +test fn unreadable_dep_info_is_undecided_not_fresh() -> Bool { + match assess_binary_freshness(observed: DepInfoUnreadable { binary: "bin", reason: "no bin.d" }) { + BinaryFreshnessUndecided { binary: _, reason: _ } => true + _ => false + } +} diff --git a/src/v1/stage0/src/main.rs b/src/v1/stage0/src/main.rs index 593ab524cb8..11f41a859ef 100644 --- a/src/v1/stage0/src/main.rs +++ b/src/v1/stage0/src/main.rs @@ -795,7 +795,7 @@ fn retained_dispatch(command: RetainedCommands, dry_run: bool) -> ! { // each refuses instrument producers by design, so either would answer a question this // verb is not asking. RetainedCommands::Test { target } => { - let outcome = cli_run::target_invocation_host::test_verb(&target); + let outcome = cli_run::target_invocation_host::test_verb_checked(&target); Verdict { status: cli_run::target_invocation_host::invocation_exit_status( outcome.termination, diff --git a/src/v1/stage0/src/target_invocation_host.rs b/src/v1/stage0/src/target_invocation_host.rs index f3c0b46f0d7..0fa2bb4a878 100644 --- a/src/v1/stage0/src/target_invocation_host.rs +++ b/src/v1/stage0/src/target_invocation_host.rs @@ -1649,6 +1649,324 @@ fn test_operand_set_form_refusal_rendered(operand: &str) -> String { ) } +/// `gunbc.target_invocation` `BinaryInput`, mirrored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BinaryInput { + NotNewer(String), + Newer(String), + Missing(String), + OutsideWorktree(String), +} + +/// `gunbc.target_invocation` `BinaryInputObservation`, mirrored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BinaryInputObservation { + Observed { + binary: String, + inputs: Vec, + }, + DepInfoUnreadable { + binary: String, + reason: String, + }, +} + +/// `gunbc.target_invocation` `BinaryFreshness`, mirrored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BinaryFreshness { + Fresh { + binary: String, + inputs: usize, + }, + Stale { + binary: String, + input: String, + why: String, + }, + Undecided { + binary: String, + reason: String, + }, +} + +fn binary_built_elsewhere(binary: &str, path: &str) -> BinaryFreshness { + BinaryFreshness::Undecided { + binary: binary.to_string(), + reason: format!( + "its dep-info names an input outside this worktree ({path}); it was built from another checkout, so nothing here says whether it answers for this one" + ), + } +} + +/// `gunbc.target_invocation` `binary_freshness_step`, mirrored arm for arm. +fn binary_freshness_step(acc: BinaryFreshness, input: &BinaryInput) -> BinaryFreshness { + match acc { + BinaryFreshness::Undecided { .. } => acc, + BinaryFreshness::Stale { ref binary, .. } => match input { + BinaryInput::OutsideWorktree(p) => binary_built_elsewhere(binary, p), + _ => acc, + }, + BinaryFreshness::Fresh { binary, inputs } => match input { + BinaryInput::OutsideWorktree(p) => binary_built_elsewhere(&binary, p), + BinaryInput::Newer(p) => BinaryFreshness::Stale { + binary, + input: p.clone(), + why: "changed after the binary was built".to_string(), + }, + BinaryInput::Missing(p) => BinaryFreshness::Stale { + binary, + input: p.clone(), + why: "no longer exists".to_string(), + }, + BinaryInput::NotNewer(_) => BinaryFreshness::Fresh { + binary, + inputs: inputs + 1, + }, + }, + } +} + +/// `gunbc.target_invocation` `assess_binary_freshness`, mirrored. +pub fn assess_binary_freshness(observed: &BinaryInputObservation) -> BinaryFreshness { + match observed { + BinaryInputObservation::DepInfoUnreadable { binary, reason } => { + BinaryFreshness::Undecided { + binary: binary.clone(), + reason: reason.clone(), + } + } + BinaryInputObservation::Observed { binary, inputs } => inputs.iter().fold( + BinaryFreshness::Fresh { + binary: binary.clone(), + inputs: 0, + }, + binary_freshness_step, + ), + } +} + +/// `gunbc.target_invocation` `binary_freshness_rendered`, mirrored. +pub fn binary_freshness_rendered(f: &BinaryFreshness) -> String { + match f { + BinaryFreshness::Fresh { binary, .. } => { + format!("gunbc test: binary fresh against its dep-info: {binary}") + } + BinaryFreshness::Stale { binary, input, why } => format!( + "gunbc test: REFUSED cause=StaleBinary — {binary}: input {input} {why}; rebuild before measuring" + ), + BinaryFreshness::Undecided { binary, reason } => { + format!("gunbc test: REFUSED cause=BinaryFreshnessUndecided — {binary}: {reason}") + } + } +} + +/// The inputs of a cargo dep-info file: the first rule's prerequisites, `\ `-escaped spaces +/// unescaped. Cargo's uplifted `.d` is one rule, `: ...`. +pub fn dep_info_inputs(dep_info: &str) -> Result, String> { + let rule = dep_info + .lines() + .find(|l| !l.trim().is_empty() && !l.starts_with('#')) + .ok_or("dep-info carries no rule")?; + let (_, prerequisites) = rule + .split_once(": ") + .ok_or("dep-info rule has no `: ` separator")?; + let mut inputs = Vec::new(); + let mut current = String::new(); + let mut chars = prerequisites.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' if chars.peek() == Some(&' ') => { + current.push(' '); + chars.next(); + } + ' ' => { + if !current.is_empty() { + inputs.push(std::mem::take(&mut current)); + } + } + other => current.push(other), + } + } + if !current.is_empty() { + inputs.push(current); + } + if inputs.is_empty() { + return Err("dep-info lists no inputs".to_string()); + } + Ok(inputs) +} + +/// THE CRATE THE BINARY WAS BUILT FROM is the directory of the first `Cargo.toml` its dep-info +/// lists; the checkout is that manifest's `src/v1/stage0` ancestor. Comparing that ONE root +/// against ours is the "built elsewhere" test -- not a list of excluded prefixes. Registry and +/// sysroot inputs are outside every checkout and are judged by mtime like any other input. +/// +/// Git paths are skipped, with a reason: build.rs watches `HEAD`, `index`, `packed-refs` and the +/// branch ref only to re-stamp `GUNBC_BUILD_IDENTITY`, the version string. `packed-refs` moves on +/// any fetch in any worktree, so judging them would refuse a binary whose code did not change -- +/// the spurious refusal this check exists not to make. The cost is that a fresh binary may print +/// an older commit as its identity; its bytes are still those that commit built. +pub fn classify_binary_inputs( + inputs: &[String], + workspace: &std::path::Path, + built_at: std::time::SystemTime, +) -> Result, String> { + // NO BUILD ROOT IS A REFUSAL, NOT A SKIPPED CHECK (review 74282). If no input names the + // crate manifest, the checkout the binary was built from is unknown, and judging the + // remaining inputs by mtime alone would answer `Fresh` for a binary another worktree built. + let build_root = inputs + .iter() + .find(|p| p.ends_with("/src/v1/stage0/Cargo.toml")) + .map(|p| p.trim_end_matches("/src/v1/stage0/Cargo.toml").to_string()) + .ok_or_else(|| { + "its dep-info names no src/v1/stage0/Cargo.toml, so the checkout it was built from is unknown" + .to_string() + })?; + let mut out = Vec::new(); + if std::path::Path::new(&build_root) != workspace { + out.push(BinaryInput::OutsideWorktree(build_root.clone())); + } + for input in inputs { + if input.contains("/.git/") { + continue; + } + // Only NotFound is `Missing`; any other stat failure (permission, I/O) is a reading we + // could not take, which refuses with its own cause rather than a wrong label (review 74474). + let classified = match std::fs::metadata(input).and_then(|m| m.modified()) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + BinaryInput::Missing(input.clone()) + } + Err(e) => return Err(format!("cannot stat dep-info input {input}: {e}")), + Ok(modified) if modified > built_at => BinaryInput::Newer(input.clone()), + Ok(_) => BinaryInput::NotNewer(input.clone()), + }; + out.push(classified); + } + Ok(out) +} + +/// THE ANCHOR IS WHEN COMPILATION STARTED, NOT WHEN THE BINARY WAS LINKED. An input edited after +/// rustc read it but before the link finished is older than the binary and newer than the +/// compile, so comparing against the binary's mtime would answer `Fresh` where cargo rebuilds +/// (calm-boar-904's objection on #13007). Cargo's own comparator is the unit's fingerprint +/// dep-info, `.fingerprint/-/dep-bin-`, whose mtime it rewinds to build start. +/// +/// THE UNIT IS DERIVED, NOT SEARCHED FOR BY NAME. The uplifted executable is a hard link to +/// `deps/-`; that file is found by inode, its `` names exactly one fingerprint +/// directory, and `dep-bin-` in it is the anchor. Many fingerprint directories carry +/// `dep-bin-gunbc` (one per past build configuration), so picking by name or by newest mtime +/// would be a guess. Every break in the chain -- no hard-link twin, two twins, zero or several +/// fingerprint directories -- is an error, which refuses. +pub fn build_start_anchor(exe: &std::path::Path) -> Result { + use std::os::unix::fs::MetadataExt; + let profile_dir = exe + .parent() + .ok_or_else(|| format!("{} has no parent directory", exe.display()))?; + let name = exe + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("{} has no file name", exe.display()))?; + let inode = std::fs::metadata(exe) + .map_err(|e| format!("cannot stat {}: {e}", exe.display()))? + .ino(); + let deps = profile_dir.join("deps"); + let prefix = format!("{name}-"); + let twins: Vec = std::fs::read_dir(&deps) + .map_err(|e| format!("cannot list {}: {e}", deps.display()))? + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.metadata().map(|m| m.ino() == inode).unwrap_or(false)) + .filter_map(|entry| entry.file_name().to_str().map(str::to_string)) + .filter(|file| file.starts_with(&prefix) && !file.ends_with(".d")) + .collect(); + let hash = match twins.as_slice() { + [one] => one[prefix.len()..].to_string(), + _ => { + return Err(format!( + "the binary has {} hard-link twins under {}, so the compile unit that built it is unknown", + twins.len(), + deps.display() + )) + } + }; + let fingerprint = profile_dir.join(".fingerprint"); + let suffix = format!("-{hash}"); + let units: Vec = std::fs::read_dir(&fingerprint) + .map_err(|e| format!("cannot list {}: {e}", fingerprint.display()))? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(&suffix)) + .unwrap_or(false) + }) + .collect(); + let unit = match units.as_slice() { + [one] => one.join(format!("dep-bin-{name}")), + _ => { + return Err(format!( + "{} fingerprint units end in {suffix}, so the build-start anchor is unknown", + units.len() + )) + } + }; + std::fs::metadata(&unit) + .and_then(|m| m.modified()) + .map_err(|e| format!("cannot read the build-start anchor {}: {e}", unit.display())) +} + +/// The effectful half: read the binary's dep-info, anchor at build start, and stat its inputs. +/// Every failure is `DepInfoUnreadable`, which refuses -- never a pass because the question could +/// not be asked. +pub fn observe_binary_inputs_of( + exe: &std::path::Path, + workspace: &std::path::Path, +) -> BinaryInputObservation { + let binary = exe.display().to_string(); + let dep_info_path = exe.with_extension("d"); + let observed = (|| { + let build_start = build_start_anchor(exe)?; + let text = std::fs::read_to_string(&dep_info_path) + .map_err(|e| format!("cannot read dep-info {}: {e}", dep_info_path.display()))?; + let inputs = dep_info_inputs(&text)?; + classify_binary_inputs(&inputs, workspace, build_start) + })(); + match observed { + Ok(inputs) => BinaryInputObservation::Observed { binary, inputs }, + Err(reason) => BinaryInputObservation::DepInfoUnreadable { binary, reason }, + } +} + +fn observe_binary_inputs() -> BinaryInputObservation { + match std::env::current_exe() { + Ok(exe) => observe_binary_inputs_of(&exe, &cli_run::process_workspace_root()), + Err(e) => BinaryInputObservation::DepInfoUnreadable { + binary: "".to_string(), + reason: e.to_string(), + }, + } +} + +/// THE INVOCATION'S FIRST ACT: refuse a binary that no longer answers for its own inputs +/// (`gunbc.target_invocation` `assess_binary_freshness`), then route. Kept beside `test_verb` +/// rather than inside it so the routing tests stay a function of the operand alone. +pub fn test_verb_checked(operand: &str) -> InvocationOutcome { + test_verb_after(operand, &assess_binary_freshness(&observe_binary_inputs())) +} + +fn test_verb_after(operand: &str, freshness: &BinaryFreshness) -> InvocationOutcome { + let line = binary_freshness_rendered(freshness); + match freshness { + BinaryFreshness::Fresh { inputs, .. } => { + eprintln!("{line} inputs={inputs}"); + test_verb(operand) + } + BinaryFreshness::Stale { .. } | BinaryFreshness::Undecided { .. } => InvocationOutcome { + termination: Termination::Refused, + message: line, + }, + } +} + pub fn test_verb(operand: &str) -> InvocationOutcome { let pattern = match parse_target_pattern(operand) { Ok(pattern) => pattern, @@ -2516,3 +2834,189 @@ mod native_route_termination_tests { assert_eq!(out.termination, Termination::ObservationDidNotHold); } } + +#[cfg(test)] +mod binary_freshness_tests { + use super::*; + + /// THE OLD BEHAVIOUR RAN THE INSTRUMENT ON EVERY ARM BELOW; the stale and undecided arms are + /// the refusals it lacked. Mirrors `test.claim.target_invocation_witness`'s freshness tests. + #[test] + fn freshness_precedence_matches_the_model() { + let obs = |inputs: Vec| BinaryInputObservation::Observed { + binary: "bin".into(), + inputs, + }; + let fresh = assess_binary_freshness(&obs(vec![ + BinaryInput::NotNewer("a".into()), + BinaryInput::NotNewer("b".into()), + ])); + assert!(matches!(fresh, BinaryFreshness::Fresh { inputs: 2, .. })); + match assess_binary_freshness(&obs(vec![ + BinaryInput::NotNewer("a".into()), + BinaryInput::Newer("b".into()), + BinaryInput::Missing("c".into()), + ])) { + BinaryFreshness::Stale { input, .. } => assert_eq!(input, "b"), + other => panic!("{other:?}"), + } + assert!(matches!( + assess_binary_freshness(&obs(vec![ + BinaryInput::Newer("b".into()), + BinaryInput::OutsideWorktree("/w/other".into()), + ])), + BinaryFreshness::Undecided { .. } + )); + assert!(matches!( + assess_binary_freshness(&BinaryInputObservation::DepInfoUnreadable { + binary: "bin".into(), + reason: "none".into() + }), + BinaryFreshness::Undecided { .. } + )); + } + + /// The observation over REAL files: a file touched after the build instant is named; a + /// git watch path is skipped; another checkout's manifest is outside; escaped spaces parse. + #[test] + fn dep_info_classification_over_real_files() { + let dir = std::env::temp_dir().join(format!("freshness-{}", std::process::id())); + let ws = dir.join("ws"); + std::fs::create_dir_all(ws.join("src/v1/stage0")).unwrap(); + let old = ws.join("old file.rs"); + std::fs::write(&old, "x").unwrap(); + let built_at = std::time::SystemTime::now(); + std::thread::sleep(std::time::Duration::from_millis(20)); + let new = ws.join("new.rs"); + std::fs::write(&new, "y").unwrap(); + let manifest = ws.join("src/v1/stage0/Cargo.toml"); + std::fs::write(&manifest, "").unwrap(); + let text = format!( + "/t/gunbc: {} {} {} {} /nowhere/.git/index\n", + manifest.display(), + old.display().to_string().replace(' ', "\\ "), + new.display(), + ws.join("gone.rs").display() + ); + let inputs = dep_info_inputs(&text).unwrap(); + assert_eq!(inputs.len(), 5); + let classified = classify_binary_inputs(&inputs, &ws, built_at).unwrap(); + assert_eq!( + classified.len(), + 4, + "the .git path is skipped: {classified:?}" + ); + assert!(classified.contains(&BinaryInput::NotNewer(old.display().to_string()))); + assert!(classified.contains(&BinaryInput::Newer(new.display().to_string()))); + assert!(classified.contains(&BinaryInput::Missing( + ws.join("gone.rs").display().to_string() + ))); + let elsewhere = classify_binary_inputs(&inputs, &dir.join("other"), built_at).unwrap(); + assert_eq!( + elsewhere[0], + BinaryInput::OutsideWorktree(ws.display().to_string()) + ); + // NO MANIFEST, NO VERDICT: dep-info without the crate's Cargo.toml refuses rather than + // judging the other inputs by mtime alone (review 74282's silent-Fresh case). + let no_manifest = dep_info_inputs(&format!( + "/t/gunbc: {}\n", + old.display().to_string().replace(' ', "\\ ") + )) + .unwrap(); + assert!(classify_binary_inputs(&no_manifest, &dir.join("other"), built_at).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// THE MID-BUILD EDIT (calm-boar-904): build_start < input_mtime < binary_mtime must be + /// StaleBinary. Against the binary's mtime it read Fresh. Laid out as cargo lays it out: an + /// uplifted binary hard-linked to deps/-, and .fingerprint/-/dep-bin- + /// stamped at build start. A decoy unit with the same file name and a later stamp must not be + /// chosen, because only the hash names the unit. + #[test] + fn an_input_edited_during_the_build_is_stale() { + use std::time::{Duration, SystemTime}; + let dir = std::env::temp_dir().join(format!("anchor-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let ws = dir.join("ws"); + let release = dir.join("target/release"); + std::fs::create_dir_all(ws.join("src/v1/stage0")).unwrap(); + std::fs::create_dir_all(release.join("deps")).unwrap(); + std::fs::create_dir_all(release.join(".fingerprint/pkg-abc123")).unwrap(); + std::fs::create_dir_all(release.join(".fingerprint/pkg-decoy99")).unwrap(); + let t0 = SystemTime::now() - Duration::from_secs(100); + let stamp = |path: &std::path::Path, at: SystemTime| { + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(at) + .unwrap(); + }; + let start = release.join(".fingerprint/pkg-abc123/dep-bin-gunbc"); + std::fs::write(&start, "").unwrap(); + stamp(&start, t0); + let decoy = release.join(".fingerprint/pkg-decoy99/dep-bin-gunbc"); + std::fs::write(&decoy, "").unwrap(); + stamp(&decoy, t0 + Duration::from_secs(80)); + let manifest = ws.join("src/v1/stage0/Cargo.toml"); + std::fs::write(&manifest, "").unwrap(); + stamp(&manifest, t0 - Duration::from_secs(10)); + let edited = ws.join("edited.rs"); + std::fs::write(&edited, "").unwrap(); + stamp(&edited, t0 + Duration::from_secs(30)); + let linked = release.join("deps/gunbc-abc123"); + std::fs::write(&linked, "bin").unwrap(); + stamp(&linked, t0 + Duration::from_secs(60)); + let exe = release.join("gunbc"); + std::fs::hard_link(&linked, &exe).unwrap(); + std::fs::write( + release.join("gunbc.d"), + format!( + "{}: {} {}\n", + exe.display(), + manifest.display(), + edited.display() + ), + ) + .unwrap(); + assert_eq!(build_start_anchor(&exe).unwrap(), t0); + match assess_binary_freshness(&observe_binary_inputs_of(&exe, &ws)) { + BinaryFreshness::Stale { input, .. } => assert_eq!(input, edited.display().to_string()), + other => panic!("a mid-build edit must be stale: {other:?}"), + } + // THE OLD ANCHOR, FOR CONTRAST: against the binary's own mtime the same tree reads fresh. + let at_link = std::fs::metadata(&exe).unwrap().modified().unwrap(); + let inputs = + dep_info_inputs(&std::fs::read_to_string(release.join("gunbc.d")).unwrap()).unwrap(); + assert!(matches!( + assess_binary_freshness(&BinaryInputObservation::Observed { + binary: "b".into(), + inputs: classify_binary_inputs(&inputs, &ws, at_link).unwrap() + }), + BinaryFreshness::Fresh { .. } + )); + // A copied binary has no hard-link twin: undecided, not fresh. + let copied = release.join("copied"); + std::fs::copy(&linked, &copied).unwrap(); + assert!(build_start_anchor(&copied).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// THE ROUTE: a stale verdict refuses with status 2 and never reaches the producer. + #[test] + fn a_stale_binary_is_refused_before_any_instrument_runs() { + let stale = BinaryFreshness::Stale { + binary: "bin".into(), + input: "a.rs".into(), + why: "changed after the binary was built".into(), + }; + let outcome = test_verb_after("//gunbc/instruments:self-host", &stale); + assert_eq!(outcome.termination, Termination::Refused); + assert!( + outcome.message.contains("cause=StaleBinary"), + "{}", + outcome.message + ); + assert!(outcome.message.contains("a.rs")); + } +}