Skip to content

decode,graph: defer BuildDecl.target resolution to graph construction - #115

Merged
bryan-minimal merged 1 commit into
mainfrom
bryan/target-option-hydration
Apr 13, 2026
Merged

bryan-minimal merged 1 commit into
mainfrom
bryan/target-option-hydration

Conversation

@bryan-minimal

Copy link
Copy Markdown
Member

Summary

Defers target resolution on BuildDecl from decode-time to graph-construction-time, so graphs constructed on one arch for execution on a different arch (e.g. buildbot on amd64 dispatching to res-server-arm64) produce specs whose target field matches the destination host.

Tom proposed the approach on Slack; this is the implementation.

Root cause

decode::BuildDecl.target previously had type Target and defaulted at decode time via target.unwrap_or(Target::host()) (crates/decode/src/builds.rs:1228 on main). Target::host() is a const fn dispatched by cfg!() at compile time, so it captured the architecture of the process running the decoder, not the target the caller actually requested via graph_from_all_packages_with_target(target).

When the decoder and the executor ran on the same host (every historical invocation until now) the check at crates/op/src/specs.rs:256 silently passed. The first cross-host graph construction — an amd64 build-bot VM generating a graph to dispatch to res-server-arm64 — hit:

cannot build spec with target amd64/linux on arm64/linux

See the full trace in the issue / in this debugging session.

Fix

Change BuildDecl.target from Target to Option<Target>:

  • None means "the NCL didn't set it; inherit from the graph this spec is being constructed into."
  • Some(_) preserves the ability for an NCL author to pin a spec to an explicit target (no package currently does this, but the capability is retained).

Hydration happens at the decode→graph seam in graph::BuildSpec::from_decoded, which consults a new Loader::for_target field. The Loader's for_target is populated in Graph::ingest by cloning self.target before the graph moves into the loader's RefCell. This works because Graph::new_from_chain (already on main from 39d2d52) sets out.target = for_target before calling ingest, so by the time the loader runs, self.target holds the correct value.

Scope of changes

Only two files touched. Grepping for readers of BuildDecl.target across the entire workspace:

File Kind Notes
crates/graph/src/graph.rs:166 Reader (only prod one) Updated to bd.target.clone().unwrap_or_else(|| loader.for_target.clone())
crates/decode/src/builds.rs:750 Definition Type: Target → Option<Target>
crates/decode/src/builds.rs:1228 Writer (from_term) target.unwrap_or(Target::host()) → target, (pass-through)
crates/decode/src/builds.rs:1424-1429 Test (simple_buildspec) target == Target::host() → target.is_none()
crates/decode/src/builds.rs:1569 Test (NCL-declared target) Wrapped in Some(...)
crates/decode/src/lib.rs Holds Arena<BuildDecl> / BuildDecl::default() No changes — Option::default() = None, correct.
crates/minimal/src/cmd_dep.rs edge_ref.target() etc. Unrelated — petgraph edge accessors, not BuildDecl.target.

No wire-protocol change. No pkgs change. No res-server change. No changes to Target type itself.

Tests

  • cargo test -p decode — 42/42 passing. The two test updates at :1424-1429 (now asserts target.is_none()) and :1569 (now wraps in Some(...)) both pass.
  • cargo test -p graph — the 44 non-spec_hasher tests pass. The 5 spec_hasher::tests::* failures are pre-existing on main in my macOS dev environment (cargo test -p graph spec_hasher fails identically with my changes stashed). They are not caused by this PR. CI will run on Linux where they should pass.
  • cargo check -p decode -p graph — clean.
  • cargo check -p mctx / anything pulling in sandbox2 — fails on my macOS dev host due to procfs / caps being Linux-only, not related to this PR. CI covers the full workspace on ubuntu-latest.
  • cargo fmt --check -p decode -p graph — clean.
  • cargo clippy -p decode -p graph -- -D warnings — clean.

Operational note for downstream consumers

Existing entries in any layer_cache_dir (~/.cache/minimal/... on dev hosts, and the one on the build-bot prod VM) were written under the old schema where every BuildDecl had a concrete target: Target baked in — almost always amd64/linux if the writer was an amd64 buildbot process. Those JSON payloads will deserialize cleanly under Option<Target> as Some(amd64/linux) (serde handles this transparently), but they will continue to carry the wrong cached target until rewritten, so arm64 res-server dispatch on a cold re-run will still fail against a stale cache.

Recommended post-merge step on any host that caches layers (especially build-bot on prod):

# From a session on build-bot:
sudo rm -rf /var/lib/buildbot/layer_cache   # or wherever `layer_cache_dir` points

Then re-trigger a run — the fresh decode will produce Some(None) → hydrated from graph targets and dispatch correctly.

Test plan (for reviewer)

  • cargo fmt --check clean
  • cargo clippy -p decode -p graph -- -D warnings clean
  • cargo test -p decode 42/42 pass
  • cargo test -p graph (non-spec_hasher) pass
  • Full CI on Linux (spec_hasher, mctx, op/specs, sandbox2, cross-arch release builds)
  • After merge + gominimal/build-servers rev bump + fresh buildbot binary + layer_cache clear: arm64 dispatch through res-server-arm64 should progress past op/src/specs.rs:256 and actually run builds

Related

  • gominimal/infra#18 merged earlier today: deploys the second res-server instance on arm64.
  • gominimal/build-servers will need a follow-up bump of the minimal git dep revs to point at this commit once it lands.

🤖 Generated with Claude Code

Change BuildDecl.target from Target to Option<Target>. The decoder used
to eagerly fall back to Target::host() when the NCL didn't set target
explicitly, which silently captured the arch of whatever process was
running the decoder - fine when the decoder and executor were the same
process, catastrophic when a graph is constructed on one arch for
dispatch to another.

Hydration now happens at the decode to graph seam in
graph::BuildSpec::from_decoded via a new Loader::for_target field,
populated in Graph::ingest from self.target (which was set by
new_from_chain's existing out.target = for_target call).

Motivation: buildbot on amd64 dispatching graphs to res-server-arm64
was producing specs stamped amd64/linux, failing the host-match check
in op::specs. See gominimal/infra#18.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@bryan-minimal
bryan-minimal merged commit 74bae1b into main Apr 13, 2026
6 of 7 checks passed
@bryan-minimal
bryan-minimal deleted the bryan/target-option-hydration branch April 13, 2026 21:34
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.

2 participants