Compile-time: deepen the crate split — break up fbuild-build (37.7K LOC) and fbuild-packages (21.7K LOC), the serial mid-chain giants
Sibling of soldr#1490 (soldr's monocrate un-collapse) — same zccache-inspired strategy, applied to fbuild's actual shape.
First, the publishing question (answered)
fbuild is NOT published to crates.io. Verified live: fbuild, fbuild-core, fbuild-cli all return "crate does not exist" from the crates.io API. Distribution is PyPI (fbuild 2.4.0 via release-auto.yml) + GitHub Releases; no workflow references cargo publish or CARGO_REGISTRY_TOKEN.
Consequences:
- No synthetic/amalgamated publish crate is needed. zccache's
ci/publish_amalgamate.py exists solely to serve crates.io consumers a single self-contained zccache crate while keeping the 18-crate internal split for parallel compiles. With no crates.io surface, fbuild needs only the internal-split half of that strategy — which it already partially has.
- Hygiene now: no fbuild crate sets
publish = false today, so every workspace member is accidentally publishable. Add publish = false to all crates/*/Cargo.toml (zccache marks every internal crate this way). One trivial PR.
- If fbuild ever wants a crates.io presence (
cargo install fbuild — the name is currently free): adopt zccache's ci/publish_amalgamate.py pattern wholesale — a release-time script that copies each internal crate's src/ into a facade crate as modules, rewrites fbuild_core:: → crate::core:: paths, strips internal path-deps from the manifest, and asserts self-containment before cargo publish of exactly one crate. That is a documented, proven recipe; do not hand-maintain a parallel monocrate.
Why compiles are still slow despite the 13-crate split
The workspace is already split, but the dependency graph is a serial spine with two giants in the middle:
fbuild-core (11.4K) → fbuild-paths (1.0K) → fbuild-config (6.1K)
→ fbuild-packages (21.7K) ←── giant #2
→ fbuild-build (37.7K) ←── giant #1
→ fbuild-cli (13.9K) ∥ fbuild-daemon (16.2K)
(src LOC; side branches: fbuild-serial 7.7K hangs off core; fbuild-deploy 8.1K off packages+serial; fbuild-library-select 1.1K + fbuild-header-scan 1.0K off packages/paths; fbuild-python 3.3K; fbuild-test-support is dev-dep-only everywhere — verified.)
~59K LOC of the critical path is just two crates that compile strictly one-after-the-other, and every edit inside either one recompiles the whole crate plus cli + daemon. rustc's frontend is serial per crate — a one-line change in the teensy orchestrator recompiles all 37.7K LOC of fbuild-build.
Phase A — split fbuild-build into engine + parallel platform crates + facade
Measured internal structure (this is why the split is cheap)
A scan of crate::<module> references inside fbuild-build/src shows:
- ENGINE → PLATFORM edges: zero. The shared engine (
pipeline, compiler, compile_many, source_scanner, linker, build_fingerprint, compile_database, symbol_analyzer, shrink, framework_libs, framework_core_cache, script_runtime, flag_overlay, build_info, build_output, eh_frame_policy, zccache_embedded, arduino_props, compile_backend, parallel, perf_log, package_override, … ≈ 19K LOC) never reaches into a platform module. The only place that names platforms is lib.rs: the PlatformSupport trait (lib.rs:62) and the get_platform_support(Platform) factory (lib.rs:76, callers: fbuild-daemon/src/handlers/emulator/select.rs, fbuild-daemon/src/handlers/operations/deploy.rs). Platform itself lives in fbuild-core.
- PLATFORM → ENGINE: pervasive and expected (18 distinct engine modules referenced) — platforms sit cleanly above the engine.
- PLATFORM → PLATFORM: three patterns, all handleable:
crate::esp32::mcu_config::DefineEntry — referenced by 8 other platforms. A shared type living in the wrong place. Move to the engine layer (e.g. mcu_config module), re-export from esp32::mcu_config for zero caller churn.
esp8266 → esp32 (10 refs) — genuine reuse of esp32 tooling. Keep esp8266 and esp32 in the same crate.
{stm32, rp2040, apollo3, nxplpc} → generic_arm (2–5 refs each) — ARM-family common code. Keep all ARM platforms in one crate with generic_arm.
Target shape
fbuild-build-engine (~19K) all shared engine modules + PlatformSupport trait
+ mcu_config (DefineEntry moved from esp32)
├── fbuild-build-esp (~5.7K) esp32 (4,450) + esp8266 (1,242)
├── fbuild-build-arm (~10.6K) generic_arm, stm32, rp2040, apollo3, nxplpc,
│ sam, nrf52, silabs, teensy, renesas
└── fbuild-build-mcu (~2.5K) avr (1,258) + ch32v (1,264)
↓ (all three compile IN PARALLEL after engine)
fbuild-build (facade, ~200 LOC) keeps the crate name; owns get_platform_support();
re-exports everything at today's paths
The three platform crates depend only on fbuild-build-engine (plus the existing lower crates); they compile concurrently. fbuild-cli / fbuild-daemon manifests are unchanged — they keep depending on fbuild-build, and every existing path (fbuild_build::pipeline::…, fbuild_build::esp32::…, fbuild_build::PlatformSupport, fbuild_build::get_platform_support) keeps resolving through the facade's re-exports.
Mechanics (same invariants as soldr#1490 — read before editing)
- M1 — moved trees stay nested under their module name.
fbuild-build-esp/src/lib.rs = pub mod esp32; pub mod esp8266; — not flattened. All crate::esp32::… paths inside the moved files keep resolving.
- M2 — crate-root re-exports satisfy cross-tree paths. Platform modules reference 18 engine modules as
crate::<engine_mod>::…. Each platform crate's lib.rs adds pub use fbuild_build_engine::{pipeline, compiler, source_scanner, linker, /* …all 18 */}; so those paths compile unchanged. Same trick for crate::mcu_config after the DefineEntry move.
- M3 — the facade preserves the public surface.
fbuild-build/src/lib.rs becomes: pub use fbuild_build_engine::*; + pub use fbuild_build_esp::{esp32, esp8266}; + pub use fbuild_build_arm::{generic_arm, stm32, …}; + pub use fbuild_build_mcu::{avr, ch32v}; + the get_platform_support() factory (the ONE piece of code that must see both the trait and all implementations, so it lives at the top). If you find yourself rewriting use statements in cli/daemon, you've violated an invariant — stop and re-read.
- Trait home: move
PlatformSupport (and BuildOrchestrator if it also lives in lib.rs) into an engine module (e.g. engine::platform_support), re-export from the facade root so fbuild_build::PlatformSupport is unchanged. Platform crates implement the trait from fbuild-build-engine.
- Dep manifests: compiler-error-driven from the workspace dep list;
publish = false; [lints] workspace = true; prune with machete/udeps after green.
Steps (one PR each)
- A0 — prep, in-crate, no new crates: (1) move
esp32::mcu_config::DefineEntry (plus whatever the 8 references drag with it) to a shared mcu_config engine module; leave pub use at the old path. (2) Move PlatformSupport/BuildOrchestrator trait definitions from lib.rs into an engine module with root re-exports. (3) Re-run the adjacency scan; require ENGINE→PLATFORM = 0 and PLATFORM→PLATFORM only within the planned crate groupings.
- A1 — extract
fbuild-build-engine (git mv the ~22 engine modules; facade pub use fbuild_build_engine::*). Update ci/check_workspace_crates.py APPROVED_MEMBERS + root Cargo.toml members + CLAUDE.md in the same PR (see "crate-gate" below).
- A2 — extract the three platform crates (can be one PR; they're mechanical after A1).
- A3 — move
fbuild-build's integration tests: keep them in the facade crate (they see everything via re-exports; zero import churn expected).
Phase B — split fbuild-packages into fetch-primitives + parallel library/toolchain + facade
Measured internal adjacency of fbuild-packages/src:
| module |
LOC |
refs |
library/ |
10,398 |
→ extractor(5), downloader(5), http(4), cache(1) |
toolchain/ |
4,391 |
→ http(2), cache(1), library(1), downloader(1), extractor(1) |
disk_cache/ |
2,906 |
→ cache(1) |
lnk/ |
1,399 |
→ disk_cache(3), extractor(1), downloader(1), library(1) |
downloader, extractor, http, cache, install_lock (top-level files) |
~1,770 |
leaf primitives |
Target shape:
fbuild-packages-fetch (~4.7K) http, downloader, extractor, cache, install_lock, disk_cache
├── fbuild-library (~10.4K) library/
└── fbuild-toolchain (~5.8K) toolchain/ + lnk/
↓ (parallel)
fbuild-packages (facade) keeps the name; re-exports everything at today's paths
- B0 — prep: identify and break the two single-reference back-edges
toolchain → library and lnk → library (grep -rn "crate::library::" crates/fbuild-packages/src/toolchain crates/fbuild-packages/src/lnk). Each is one item — move it down to the fetch layer or duplicate the tiny type, whichever is honest.
- B1 — extract the three crates + facade, same M1–M3 mechanics. Consumers (
fbuild-build, fbuild-deploy, fbuild-cli, fbuild-daemon, fbuild-library-select, fbuild-test-support) keep their fbuild-packages dep and paths unchanged.
Resulting critical path
Before: core → paths → config → packages(21.7K) → build(37.7K) → daemon(16.2K) (~94K serial)
After: core → paths → config → pkgs-fetch(4.7K) → [library ∥ toolchain]
→ build-engine(19K) → [esp ∥ arm ∥ mcu] → facades → [cli ∥ daemon] (~79K, wide middle)
Bigger than the clean-build math is the incremental scope: an edit to the teensy orchestrator today recompiles 37.7K + cli + daemon; after, it recompiles ~10.6K (fbuild-build-arm) + a ~200-LOC facade + cli/daemon. An edit to library/ stops recompiling toolchain-side consumers and vice versa. Record cargo build --timings (clean + one-file-touch in esp32/, teensy/, library/) before Phase A and after each phase; post numbers here.
crate-gate / monocrate policy
ci/check_workspace_crates.py (enforced by crate-gate.yml, policy from #560) forbids new workspace crates without maintainer sign-off: "If a new crate is genuinely unavoidable … add it to APPROVED_MEMBERS in the same PR, with a one-line rationale."
This issue is that sign-off for the seven new members (fbuild-build-engine, -esp, -arm, -mcu, fbuild-packages-fetch, fbuild-library, fbuild-toolchain). Each extraction PR updates APPROVED_MEMBERS + root members + the CLAUDE.md policy text together. Update the policy wording to reflect the refined rule: modules-first for new functionality, but compile-parallelism splits are sanctioned when backed by --timings data — the original policy goal (no drive-by crates) survives; the gate stays.
Gates for every PR
Repo-standard: ./test, clippy -D warnings, fmt, dylint, uv run --script ci/check_workspace_crates.py locally, and the --timings measurement posted in the PR. Route all Rust commands through the repo's enforced wrapper per CLAUDE.md. One phase per PR; don't start N+1 before N merges. Re-run the adjacency scan after each phase and paste it in the PR description.
Out of scope / follow-ups
- Splitting
fbuild-daemon (16.2K) or fbuild-cli (13.9K): they're at the top of the DAG (nothing waits on them except the bins), so splitting buys much less. Revisit with post-Phase-B timings.
- Splitting
fbuild-build-engine further (pipeline vs analyzers like symbol_analyzer/shrink/compile_database): possible second wave if engine dominates timings.
- Reserving the
fbuild crates.io name (requires publishing a minimal placeholder): maintainer decision, independent of this work.
Compile-time: deepen the crate split — break up
fbuild-build(37.7K LOC) andfbuild-packages(21.7K LOC), the serial mid-chain giantsSibling of soldr#1490 (soldr's monocrate un-collapse) — same zccache-inspired strategy, applied to fbuild's actual shape.
First, the publishing question (answered)
fbuild is NOT published to crates.io. Verified live:
fbuild,fbuild-core,fbuild-cliall return "crate does not exist" from the crates.io API. Distribution is PyPI (fbuild2.4.0 viarelease-auto.yml) + GitHub Releases; no workflow referencescargo publishorCARGO_REGISTRY_TOKEN.Consequences:
ci/publish_amalgamate.pyexists solely to serve crates.io consumers a single self-containedzccachecrate while keeping the 18-crate internal split for parallel compiles. With no crates.io surface, fbuild needs only the internal-split half of that strategy — which it already partially has.publish = falsetoday, so every workspace member is accidentally publishable. Addpublish = falseto allcrates/*/Cargo.toml(zccache marks every internal crate this way). One trivial PR.cargo install fbuild— the name is currently free): adopt zccache'sci/publish_amalgamate.pypattern wholesale — a release-time script that copies each internal crate'ssrc/into a facade crate as modules, rewritesfbuild_core::→crate::core::paths, strips internal path-deps from the manifest, and asserts self-containment beforecargo publishof exactly one crate. That is a documented, proven recipe; do not hand-maintain a parallel monocrate.Why compiles are still slow despite the 13-crate split
The workspace is already split, but the dependency graph is a serial spine with two giants in the middle:
(src LOC; side branches:
fbuild-serial7.7K hangs off core;fbuild-deploy8.1K off packages+serial;fbuild-library-select1.1K +fbuild-header-scan1.0K off packages/paths;fbuild-python3.3K;fbuild-test-supportis dev-dep-only everywhere — verified.)~59K LOC of the critical path is just two crates that compile strictly one-after-the-other, and every edit inside either one recompiles the whole crate plus
cli+daemon. rustc's frontend is serial per crate — a one-line change in the teensy orchestrator recompiles all 37.7K LOC offbuild-build.Phase A — split
fbuild-buildinto engine + parallel platform crates + facadeMeasured internal structure (this is why the split is cheap)
A scan of
crate::<module>references insidefbuild-build/srcshows:pipeline,compiler,compile_many,source_scanner,linker,build_fingerprint,compile_database,symbol_analyzer,shrink,framework_libs,framework_core_cache,script_runtime,flag_overlay,build_info,build_output,eh_frame_policy,zccache_embedded,arduino_props,compile_backend,parallel,perf_log,package_override, … ≈ 19K LOC) never reaches into a platform module. The only place that names platforms islib.rs: thePlatformSupporttrait (lib.rs:62) and theget_platform_support(Platform)factory (lib.rs:76, callers:fbuild-daemon/src/handlers/emulator/select.rs,fbuild-daemon/src/handlers/operations/deploy.rs).Platformitself lives infbuild-core.crate::esp32::mcu_config::DefineEntry— referenced by 8 other platforms. A shared type living in the wrong place. Move to the engine layer (e.g.mcu_configmodule), re-export fromesp32::mcu_configfor zero caller churn.esp8266 → esp32(10 refs) — genuine reuse of esp32 tooling. Keep esp8266 and esp32 in the same crate.{stm32, rp2040, apollo3, nxplpc} → generic_arm(2–5 refs each) — ARM-family common code. Keep all ARM platforms in one crate withgeneric_arm.Target shape
The three platform crates depend only on
fbuild-build-engine(plus the existing lower crates); they compile concurrently.fbuild-cli/fbuild-daemonmanifests are unchanged — they keep depending onfbuild-build, and every existing path (fbuild_build::pipeline::…,fbuild_build::esp32::…,fbuild_build::PlatformSupport,fbuild_build::get_platform_support) keeps resolving through the facade's re-exports.Mechanics (same invariants as soldr#1490 — read before editing)
fbuild-build-esp/src/lib.rs=pub mod esp32; pub mod esp8266;— not flattened. Allcrate::esp32::…paths inside the moved files keep resolving.crate::<engine_mod>::…. Each platform crate'slib.rsaddspub use fbuild_build_engine::{pipeline, compiler, source_scanner, linker, /* …all 18 */};so those paths compile unchanged. Same trick forcrate::mcu_configafter the DefineEntry move.fbuild-build/src/lib.rsbecomes:pub use fbuild_build_engine::*;+pub use fbuild_build_esp::{esp32, esp8266};+pub use fbuild_build_arm::{generic_arm, stm32, …};+pub use fbuild_build_mcu::{avr, ch32v};+ theget_platform_support()factory (the ONE piece of code that must see both the trait and all implementations, so it lives at the top). If you find yourself rewritingusestatements in cli/daemon, you've violated an invariant — stop and re-read.PlatformSupport(andBuildOrchestratorif it also lives inlib.rs) into an engine module (e.g.engine::platform_support), re-export from the facade root sofbuild_build::PlatformSupportis unchanged. Platform crates implement the trait fromfbuild-build-engine.publish = false;[lints] workspace = true; prune with machete/udeps after green.Steps (one PR each)
esp32::mcu_config::DefineEntry(plus whatever the 8 references drag with it) to a sharedmcu_configengine module; leavepub useat the old path. (2) MovePlatformSupport/BuildOrchestratortrait definitions fromlib.rsinto an engine module with root re-exports. (3) Re-run the adjacency scan; require ENGINE→PLATFORM = 0 and PLATFORM→PLATFORM only within the planned crate groupings.fbuild-build-engine(git mv the ~22 engine modules; facadepub use fbuild_build_engine::*). Updateci/check_workspace_crates.pyAPPROVED_MEMBERS+ rootCargo.tomlmembers + CLAUDE.md in the same PR (see "crate-gate" below).fbuild-build's integration tests: keep them in the facade crate (they see everything via re-exports; zero import churn expected).Phase B — split
fbuild-packagesinto fetch-primitives + parallel library/toolchain + facadeMeasured internal adjacency of
fbuild-packages/src:library/toolchain/disk_cache/lnk/downloader,extractor,http,cache,install_lock(top-level files)Target shape:
toolchain → libraryandlnk → library(grep -rn "crate::library::" crates/fbuild-packages/src/toolchain crates/fbuild-packages/src/lnk). Each is one item — move it down to the fetch layer or duplicate the tiny type, whichever is honest.fbuild-build,fbuild-deploy,fbuild-cli,fbuild-daemon,fbuild-library-select,fbuild-test-support) keep theirfbuild-packagesdep and paths unchanged.Resulting critical path
Bigger than the clean-build math is the incremental scope: an edit to the teensy orchestrator today recompiles 37.7K + cli + daemon; after, it recompiles ~10.6K (
fbuild-build-arm) + a ~200-LOC facade + cli/daemon. An edit tolibrary/stops recompilingtoolchain-side consumers and vice versa. Recordcargo build --timings(clean + one-file-touch inesp32/,teensy/,library/) before Phase A and after each phase; post numbers here.crate-gate / monocrate policy
ci/check_workspace_crates.py(enforced bycrate-gate.yml, policy from #560) forbids new workspace crates without maintainer sign-off: "If a new crate is genuinely unavoidable … add it toAPPROVED_MEMBERSin the same PR, with a one-line rationale."This issue is that sign-off for the seven new members (
fbuild-build-engine,-esp,-arm,-mcu,fbuild-packages-fetch,fbuild-library,fbuild-toolchain). Each extraction PR updatesAPPROVED_MEMBERS+ rootmembers+ the CLAUDE.md policy text together. Update the policy wording to reflect the refined rule: modules-first for new functionality, but compile-parallelism splits are sanctioned when backed by--timingsdata — the original policy goal (no drive-by crates) survives; the gate stays.Gates for every PR
Repo-standard:
./test, clippy-D warnings, fmt, dylint,uv run --script ci/check_workspace_crates.pylocally, and the--timingsmeasurement posted in the PR. Route all Rust commands through the repo's enforced wrapper per CLAUDE.md. One phase per PR; don't start N+1 before N merges. Re-run the adjacency scan after each phase and paste it in the PR description.Out of scope / follow-ups
fbuild-daemon(16.2K) orfbuild-cli(13.9K): they're at the top of the DAG (nothing waits on them except the bins), so splitting buys much less. Revisit with post-Phase-B timings.fbuild-build-enginefurther (pipeline vs analyzers likesymbol_analyzer/shrink/compile_database): possible second wave if engine dominates timings.fbuildcrates.io name (requires publishing a minimal placeholder): maintainer decision, independent of this work.