Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .clud/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"optimize": {
"rust": {
"install_soldr": true,
"soldr_version": "0.7.11",
"use_soldr_shims": true
}
}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ block fails CI with a copy-paste fix.
## Scheduled Benchmarks

- **`benchmark-build-comparison.yml`** -- Arduino CLI vs PlatformIO vs fbuild Blink cold/warm benchmark; runs nightly, manually, and for relevant pushes to `main`, then force-publishes the one-commit `benchmark-stats` branch and deploys its site to GitHub Pages
- **`esp32s3-size-parity.yml`** -- builds an ESP32-S3 Blink with PlatformIO and fbuild on the same pinned pioarduino platform and fails if fbuild's `firmware.bin` is larger (#1432); runs once a day and manually, never on push/PR

## Per-Board Builds (push/PR)

Expand Down
55 changes: 55 additions & 0 deletions .github/workflows/esp32s3-size-parity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: ESP32-S3 size parity (#1432)

on:
# Two cold ESP32 builds (PlatformIO + fbuild) per run, so this is a smoke
# test that runs at most once a day, plus on demand. No push/PR trigger.
workflow_dispatch:
schedule:
- cron: '41 8 * * *'

# Non-PR events key on run_id so each gets its own group -- a shared group
# keeps only ONE pending run, which would silently drop a queued dispatch.
concurrency:
group: esp32s3-size-parity.yml-${{ github.run_id }}
cancel-in-progress: false

permissions:
contents: read

env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PLATFORMIO_CORE_VERSION: "6.1.19"

jobs:
size-parity:
name: fbuild <= PlatformIO (esp32s3 Blink)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false

- uses: astral-sh/setup-uv@v3

- name: Setup soldr
uses: zackees/setup-soldr@v0
with:
version: 0.8.23
cache: true
build-cache: true
target-cache: true
prebuild-deps: none
linker: platform-default
cache-payload-warn-bytes: 2GiB

# pioarduino's platform refuses Python 3.14+ ("Python version must be
# between 3.10 and 3.13"), so pin the interpreter PlatformIO runs on.
- name: Install PlatformIO
run: uv tool install --python 3.13 "platformio==${PLATFORMIO_CORE_VERSION}"

- name: Compare ESP32-S3 Blink firmware size
run: |
soldr cargo test -p fbuild-build --test esp32s3_size_parity -- --ignored --nocapture --test-threads=1
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions ci/platform_boundary_research.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ crates/fbuild-core/Cargo.toml 56 native_dependency libc fs host_mechanic
crates/fbuild-core/Cargo.toml 58 target_dependency_table [target.'cfg(windows)'.dependencies] fs host_mechanic
crates/fbuild-core/Cargo.toml 66 native_dependency windows-sys fs host_mechanic
crates/fbuild-core/src/platform/executable.rs 56 native_path std::env::current_exe host_executable host_mechanic
crates/fbuild-core/src/platform/linux/device.rs 33 native_path std::os::unix::fs::OpenOptionsExt fs host_mechanic
crates/fbuild-core/src/platform/linux/device.rs 41 native_path libc:: process host_mechanic
crates/fbuild-core/src/platform/linux/device.rs 41 native_path libc:: process host_mechanic
crates/fbuild-core/src/platform/linux/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic
crates/fbuild-core/src/platform/linux/fs.rs 40 native_path std::os::unix::fs::symlink fs host_mechanic
crates/fbuild-core/src/platform/linux/fs.rs 84 native_path std::os::unix::ffi::OsStrExt process host_mechanic
Expand Down
90 changes: 90 additions & 0 deletions crates/fbuild-build-engine/src/linker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ fn elf_is_up_to_date<'a>(elf_path: &Path, inputs: impl Iterator<Item = &'a PathB
true
}

/// Sidecar next to an archive naming the objects it was built from.
fn archive_manifest_path(archive: &Path) -> PathBuf {
let mut name = archive.as_os_str().to_owned();
name.push(".inputs");
PathBuf::from(name)
}

fn archive_manifest(objects: &[PathBuf]) -> String {
objects
.iter()
.map(|o| o.to_string_lossy())
.collect::<Vec<_>>()
.join("\n")
}

/// Trait for platform-specific linkers.
///
/// FastLED/fbuild#820 (Phase B of #813): every method that invokes a
Expand Down Expand Up @@ -413,6 +428,36 @@ impl LinkerBase {
Ok(())
}

/// Whether `archive` already holds exactly `objects` and no object is newer.
///
/// The object list is compared through a sidecar manifest: an mtime check
/// alone would keep members of objects that no longer exist, and a stale
/// member can still satisfy a symbol at link time.
pub fn archive_is_current(archive: &Path, objects: &[PathBuf]) -> bool {
match std::fs::read_to_string(archive_manifest_path(archive)) {
Ok(manifest) => {
manifest == archive_manifest(objects) && elf_is_up_to_date(archive, objects.iter())
}
Err(_) => false,
}
}

/// Rebuild `archive` from `objects` unless [`Self::archive_is_current`]
/// holds, so an unchanged archive keeps its mtime and the link can skip.
pub async fn archive_if_stale(
ar_path: &Path,
objects: &[PathBuf],
archive: &Path,
tool_label: &str,
) -> Result<()> {
if Self::archive_is_current(archive, objects) {
return Ok(());
}
Self::archive(ar_path, objects, archive, tool_label).await?;
std::fs::write(archive_manifest_path(archive), archive_manifest(objects))?;
Ok(())
}

/// Report firmware size by running the size tool and parsing its output.
pub async fn report_size(
size_path: &Path,
Expand Down Expand Up @@ -590,6 +635,51 @@ impl LinkerBase {
mod tests {
use super::*;

fn set_mtime(path: &Path, time: std::time::SystemTime) {
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(time)
.unwrap();
}

#[test]
fn archive_is_current_requires_the_same_objects_and_no_newer_object() {
let tmp = tempfile::TempDir::new().unwrap();
let a = tmp.path().join("a.o");
let b = tmp.path().join("b.o");
std::fs::write(&a, b"a").unwrap();
std::fs::write(&b, b"b").unwrap();
let archive = tmp.path().join("libcore.a");
let objects = vec![a.clone(), b.clone()];
assert!(
!LinkerBase::archive_is_current(&archive, &objects),
"no archive yet"
);

// What `archive_if_stale` leaves behind, without running `ar`.
std::fs::write(&archive, b"!<arch>\n").unwrap();
std::fs::write(archive_manifest_path(&archive), archive_manifest(&objects)).unwrap();
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
set_mtime(&a, past);
set_mtime(&b, past);
assert!(LinkerBase::archive_is_current(&archive, &objects));

assert!(
!LinkerBase::archive_is_current(&archive, std::slice::from_ref(&a)),
"an object left the build, so its member is stale"
);
set_mtime(
&b,
std::time::SystemTime::now() + std::time::Duration::from_secs(60),
);
assert!(
!LinkerBase::archive_is_current(&archive, &objects),
"an object was rebuilt after the archive"
);
}

/// Absolute path for the running platform (`/x` is *not* absolute on
/// Windows — it has a root but no drive prefix).
fn abs(tail: &str) -> PathBuf {
Expand Down
38 changes: 38 additions & 0 deletions crates/fbuild-build-engine/src/package_override.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ pub fn resolve_override(
fbuild_config::parse_platform_packages_value(raw, package_name)
}

/// Look up the pin for a platform package such as `platform-espressif32`.
///
/// A `platform_packages` entry wins, as in PlatformIO. Otherwise a
/// `platform = <archive URL>` pin applies: ignoring it silently built against a
/// different framework release than the one the ini named (FastLED/fbuild#1432).
pub fn resolve_platform_override(
env_config: &HashMap<String, String>,
package_name: &str,
) -> Option<PackageOverride> {
resolve_override(env_config, package_name)
.or_else(|| fbuild_config::parse_platform_archive_url(env_config.get("platform")?))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -82,6 +95,31 @@ mod tests {
assert_eq!(ovr.version, "0.0.0+gdeadbee");
}

const PLATFORM_54: &str = "https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip";
const PLATFORM_55: &str = "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.35/platform-espressif32.zip";

#[test]
fn platform_archive_url_pins_the_platform_package() {
let env = env(&[("platform", PLATFORM_54)]);
let ovr = resolve_platform_override(&env, "platform-espressif32").expect("pin honored");
assert_eq!(ovr.url, PLATFORM_54);
assert_eq!(ovr.version, "54.03.20");
}

#[test]
fn platform_packages_entry_wins_over_platform_url() {
let packages = format!("platform-espressif32@{PLATFORM_55}");
let env = env(&[("platform", PLATFORM_54), ("platform_packages", &packages)]);
let ovr = resolve_platform_override(&env, "platform-espressif32").unwrap();
assert_eq!(ovr.url, PLATFORM_55);
}

#[test]
fn unpinned_platform_name_resolves_no_override() {
let env = env(&[("platform", "espressif32")]);
assert!(resolve_platform_override(&env, "platform-espressif32").is_none());
}

#[test]
fn version_pin_only_returns_none() {
// `name @ 1.2.3` is a registry version pin, not a URL override.
Expand Down
4 changes: 2 additions & 2 deletions crates/fbuild-build-esp/src/esp32/configs/esp32.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,11 @@

"profiles": {
"release": {
"compile_flags": ["-Os", "-flto=auto", "-fno-fat-lto-objects", "-fno-omit-frame-pointer"],
"compile_flags": ["-Os", "-flto=auto", "-fno-fat-lto-objects"],
"link_flags": ["-flto=auto", "-fuse-linker-plugin"]
},
"quick": {
"compile_flags": ["-Os", "-g0", "-fno-omit-frame-pointer"],
"compile_flags": ["-Os", "-g0"],
"link_flags": []
}
},
Expand Down
4 changes: 2 additions & 2 deletions crates/fbuild-build-esp/src/esp32/configs/esp32s2.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,11 @@

"profiles": {
"release": {
"compile_flags": ["-Os", "-flto=auto", "-fno-fat-lto-objects", "-fno-omit-frame-pointer"],
"compile_flags": ["-Os", "-flto=auto", "-fno-fat-lto-objects"],
"link_flags": ["-flto=auto", "-fuse-linker-plugin"]
},
"quick": {
"compile_flags": ["-Os", "-g0", "-fno-omit-frame-pointer"],
"compile_flags": ["-Os", "-g0"],
"link_flags": []
}
},
Expand Down
4 changes: 2 additions & 2 deletions crates/fbuild-build-esp/src/esp32/configs/esp32s3.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,11 @@

"profiles": {
"release": {
"compile_flags": ["-Os", "-flto=auto", "-fno-fat-lto-objects", "-fno-omit-frame-pointer"],
"compile_flags": ["-Os", "-flto=auto", "-fno-fat-lto-objects"],
"link_flags": ["-flto=auto", "-fuse-linker-plugin"]
},
"quick": {
"compile_flags": ["-Os", "-g0", "-fno-omit-frame-pointer"],
"compile_flags": ["-Os", "-g0"],
"link_flags": []
}
},
Expand Down
25 changes: 24 additions & 1 deletion crates/fbuild-build-esp/src/esp32/esp32_compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ mod tests {
use crate::esp32::mcu_config::get_mcu_config;

fn test_compiler(mcu: &str) -> Esp32Compiler {
test_compiler_with_profile(mcu, BuildProfile::Release)
}

fn test_compiler_with_profile(mcu: &str, profile: BuildProfile) -> Esp32Compiler {
let config = get_mcu_config(mcu).unwrap();
let mut defines = config.defines_map();
defines.insert("PLATFORMIO".to_string(), "1".to_string());
Expand All @@ -279,7 +283,7 @@ mod tests {
"160000000L",
defines,
vec![PathBuf::from("/framework/cores/esp32")],
BuildProfile::Release,
profile,
false,
)
}
Expand Down Expand Up @@ -320,6 +324,25 @@ mod tests {
assert!(!flags.iter().any(|f| f.starts_with("-march=")));
}

#[test]
fn xtensa_profiles_omit_the_frame_pointer() {
// Xtensa backtraces unwind through the windowed ABI, so a frame pointer
// gives the crash decoder nothing and costs flash in every compiled
// function. PlatformIO does not pass it (FastLED/fbuild#1432).
for mcu in ["esp32", "esp32s2", "esp32s3"] {
for profile in [BuildProfile::Release, BuildProfile::Quick] {
let compiler = test_compiler_with_profile(mcu, profile);
for flags in [compiler.c_flags(), compiler.cpp_flags()] {
assert!(
!flags.contains(&"-fno-omit-frame-pointer".to_string()),
"{mcu} {} profile keeps -fno-omit-frame-pointer",
profile.as_dir_name()
);
}
}
}
}

#[test]
fn test_defines_in_flags() {
let compiler = test_compiler("esp32c6");
Expand Down
31 changes: 28 additions & 3 deletions crates/fbuild-build-esp/src/esp32/orchestrator/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use super::cdc::warn_if_cdc_on_boot;
use super::embed_stage::stage_embed_files;
use super::fingerprint::Esp32FingerprintMetadata;
use super::framework_libs::compile_framework_builtin_libs;
use super::helpers::{compile_db_is_current, profile_label};
use super::helpers::{compile_db_is_current, framework_macro_prefix_map, profile_label};
use super::local_libs::compile_local_libraries;
use super::packages::resolve_pioarduino_packages;

Expand Down Expand Up @@ -304,6 +304,8 @@ impl BuildOrchestrator for Esp32Orchestrator {
// Read user build_flags early — needed for both library and sketch compilation.
// SDK defines (from flags/defines) are prepended so user flags can override them.
let mut user_flags = sdk_defines;
// Before the user's build_flags, so their own prefix maps still win.
user_flags.extend(framework_macro_prefix_map(&core_dir));
let mut user_build_flags = ctx.config.get_build_flags(&params.env_name)?;
user_build_flags.extend(params.extra_build_flags.clone());
user_flags.extend(user_build_flags.clone());
Expand Down Expand Up @@ -791,8 +793,31 @@ impl BuildOrchestrator for Esp32Orchestrator {
};

// 12-13. Link + convert
// Library archives join core_objects in the archives parameter
let mut all_archives: Vec<std::path::PathBuf> = core_objects;
// The core reaches the linker as an archive, like PlatformIO's
// libFrameworkArduino.a. Loose objects are always linked whole, which
// kept every core file's constructors and the SDK code they reach:
// about 11 KB on an ESP32-S3 Blink (FastLED/fbuild#1432).
let mut all_archives: Vec<std::path::PathBuf> = Vec::new();
if !core_objects.is_empty() {
let _g = perf.phase("archive-core");
let core_archive = core_build_dir.join("libFrameworkArduino.a");
let ar_path = toolchain.get_ar_path();
let gcc_ar_path = toolchain.get_gcc_ar_path();
let archiver = crate::pipeline::pick_archiver(
&ar_path,
&gcc_ar_path,
&compiler.c_flags(),
&compiler.cpp_flags(),
);
crate::linker::LinkerBase::archive_if_stale(
archiver,
&core_objects,
&core_archive,
"ar",
)
.await?;
all_archives.push(core_archive);
}
all_archives.extend(library_archives);

let linker = Esp32Linker::new(
Expand Down
Loading
Loading