From ba80179bab08408020460db751afd3f0dd3f5e88 Mon Sep 17 00:00:00 2001 From: zackees Date: Tue, 14 Jul 2026 18:40:16 -0700 Subject: [PATCH] fix(rp2040): match upstream UF2 deploy semantics --- agents/docs/README.md | 2 +- agents/docs/deploy-architecture.md | 19 +- agents/docs/serial-testing.md | 16 +- .../src/rp2040/orchestrator.rs | 97 ++- crates/fbuild-core/src/usb/data.rs | 2 +- .../src/handlers/operations/deploy_port.rs | 102 +++- crates/fbuild-deploy/src/rp2040.rs | 558 ++++++++++++++---- crates/fbuild-toolchain/src/toolchain/mod.rs | 2 + .../src/toolchain/rp2040_picotool.rs | 212 +++++++ setup.py | 85 ++- uv.lock | 2 +- 11 files changed, 946 insertions(+), 151 deletions(-) create mode 100644 crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs diff --git a/agents/docs/README.md b/agents/docs/README.md index c8bf58eb..46cbfbe1 100644 --- a/agents/docs/README.md +++ b/agents/docs/README.md @@ -21,7 +21,7 @@ routing table sends an agent here by task. harness for `port_class` + `family_for_port` validation against an actual ESP32. **Reach for this** when you touch `crates/fbuild-serial/src/port_class.rs`, - `crates/fbuild-serial/src/boards.rs::family_for_vid_pid`, or any + FastLED/boards catalogue ingestion/family classification, or any DTR/RTS handling in `SharedSerialManager::open_port`. See FastLED/fbuild#899 (resolved). diff --git a/agents/docs/deploy-architecture.md b/agents/docs/deploy-architecture.md index 847ff27f..23f97c26 100644 --- a/agents/docs/deploy-architecture.md +++ b/agents/docs/deploy-architecture.md @@ -91,14 +91,17 @@ The follow-up issues track the full polymorphic version: The right sequence today: -1. **VID:PID first.** Add the new board's USB endpoint to - `crates/fbuild-serial/src/boards.rs::BOARD_FINGERPRINTS`. If the - data port is a *different* USB endpoint from the bootloader, add - `ENVIRONMENT_TO_VCOM` too. -2. **Family classification.** Add the new VID:PID to - `family_for_vid_pid`. RP2350 is almost certainly `CdcAcmBridge` - (1200-bps touch reset, host-ready idle); inherit the existing - RP2040 row's conventions. +1. **VID:PID first.** Publish the board's bootloader and runtime USB + endpoints in [FastLED/boards](https://github.com/FastLED/boards), then + exercise fbuild's normal catalogue ingestion path. Never add a literal + VID/PID to `BOARD_FINGERPRINTS`, `ENVIRONMENT_TO_VCOM`, generated Rust, or + any other production fallback in this repository. +2. **Family classification.** Resolve the published FastLED/boards product + identity and map that metadata to `CdcAcmBridge` (or the appropriate + family behavior). If the catalogue cannot express a needed distinction, + extend its published schema instead of embedding another ID table in + fbuild. RP2350 normally uses 1200-bps touch and host-ready idle, matching + the RP2040 convention. 3. **DTR/RTS matrix.** Add a row to [`docs/usb-cdc-control-line-matrix.md`](../../docs/usb-cdc-control-line-matrix.md) for the new chip, with a datasheet citation and capture date. diff --git a/agents/docs/serial-testing.md b/agents/docs/serial-testing.md index a32c25ae..85a49504 100644 --- a/agents/docs/serial-testing.md +++ b/agents/docs/serial-testing.md @@ -8,10 +8,10 @@ when you need to validate one of: - A change to `crates/fbuild-serial/src/port_class.rs` — the OS-side kernel-class detection introduced in #895. Sysfs paths can drift between kernel versions; real-device validation catches it. -- A new entry in the `crates/fbuild-serial/src/boards.rs::family_for_vid_pid` - table — the disagreement warning shipped in #897 will fire here if - the table's implied class disagrees with what the kernel actually - binds. +- A new FastLED/boards USB catalogue record or data-driven family-classifier + change. The disagreement warning shipped in #897 will fire if the catalogue + identity's implied class disagrees with what the kernel actually binds. Never + add a production VID/PID literal to the legacy table. - Anything that touches `SharedSerialManager::open_port`'s DTR/RTS handling — getting `(false, false)` vs `(true, true)` wrong on attach is the difference between "firmware runs" and "firmware @@ -75,7 +75,8 @@ The script: 3. Ensures `cdc_acm` is loaded in Ubuntu. 4. Builds + runs a small Rust program (the source code is in the script as a here-doc) that calls `port_class::detect_port_kernel_class`, - `family_for_vid_pid`, and `family_for_port` against `/dev/ttyACM0`, + the catalogue-backed family lookup and `family_for_port` against + `/dev/ttyACM0`, and asserts both signals return `Esp32NativeUsbCdc` / `CdcAcm`. 5. `usbipd detach` (user-mode) — leaves the bind in place for the next session. @@ -121,7 +122,8 @@ Expected last line: a "cdc_acm 1-1:1.0: ttyACM0: USB ACM device" line. 3. Verify `readlink /sys/class/tty/ttyACM0/device/driver` resolves to `.../bus/usb/drivers/cdc_acm`. If it resolves to something - else, the VID/PID table or `port_class::linux::classify_driver` - may need updating. + else, the FastLED/boards record/ingestion or + `port_class::linux::classify_driver` may need updating. Do not patch in a + literal VID/PID fallback. 4. Worst case, the kernel driver name changed across mainline Linux versions — bump the Microsoft WSL kernel and re-test. diff --git a/crates/fbuild-build-arm/src/rp2040/orchestrator.rs b/crates/fbuild-build-arm/src/rp2040/orchestrator.rs index 27890a41..a8240c8a 100644 --- a/crates/fbuild-build-arm/src/rp2040/orchestrator.rs +++ b/crates/fbuild-build-arm/src/rp2040/orchestrator.rs @@ -80,6 +80,13 @@ impl BuildOrchestrator for Rp2040Orchestrator { let toolchain_dir = fbuild_packages::Package::ensure_installed(&toolchain).await?; tracing::info!("rp2040 pqt-gcc toolchain at {}", toolchain_dir.display()); + // Arduino-Pico generates its canonical UF2 from the linked ELF with + // the managed pqt-picotool package. Do the same here rather than + // flattening ELF segments in an fbuild-specific encoder. + let picotool = fbuild_packages::toolchain::Rp2040Picotool::new(¶ms.project_dir); + let picotool_dir = fbuild_packages::Package::ensure_installed(&picotool).await?; + tracing::info!("managed picotool at {}", picotool_dir.display()); + use fbuild_packages::Toolchain; pipeline::log_toolchain_version( &toolchain.get_gcc_path(), @@ -134,12 +141,20 @@ impl BuildOrchestrator for Rp2040Orchestrator { crate::eh_frame_policy::EhFramePolicy::Preserve => "preserve", }, })?; - let (fast_elf, [fast_bin], fast_compile_db) = - expected_fast_path_artifacts(&build_dir, ¶ms.project_dir, ["firmware.bin"]); + let (fast_elf, [fast_bin, fast_uf2], fast_compile_db) = expected_fast_path_artifacts( + &build_dir, + ¶ms.project_dir, + ["firmware.bin", "firmware.uf2"], + ); let fast_path = FastPathContract::for_project_outputs( &build_dir, ¶ms.project_dir, - [fast_elf.clone(), fast_bin.clone(), fast_compile_db.clone()], + [ + fast_elf.clone(), + fast_bin, + fast_uf2.clone(), + fast_compile_db.clone(), + ], ); if !params.compiledb_only @@ -159,7 +174,7 @@ impl BuildOrchestrator for Rp2040Orchestrator { let elapsed = start.elapsed().as_secs_f64(); return Ok(BuildResult { success: true, - firmware_path: Some(fast_bin), + firmware_path: Some(fast_uf2), elf_path: Some(fast_elf), size_info: hit.size_info, symbol_map: None, @@ -324,7 +339,8 @@ impl BuildOrchestrator for Rp2040Orchestrator { support_link_inputs.push(boot2_object); // 9. Run shared sequential build pipeline - let build_result = pipeline::run_sequential_build_with_libs( + let board_mcu = ctx.board.mcu.clone(); + let mut build_result = pipeline::run_sequential_build_with_libs( &compiler, &linker, ctx, @@ -338,6 +354,23 @@ impl BuildOrchestrator for Rp2040Orchestrator { ) .await?; + if build_result.success && !params.compiledb_only { + let elf = build_result.elf_path.as_deref().ok_or_else(|| { + fbuild_core::FbuildError::BuildFailed( + "RP2040 link succeeded without a firmware ELF artifact".to_string(), + ) + })?; + let uf2 = elf.with_extension("uf2"); + convert_elf_to_uf2( + &picotool.executable(), + elf, + &uf2, + &board_mcu, + ) + .await?; + build_result.firmware_path = Some(uf2); + } + if build_result.success && !params.compiledb_only && !params.symbol_analysis @@ -358,6 +391,60 @@ impl BuildOrchestrator for Rp2040Orchestrator { } } +async fn convert_elf_to_uf2( + picotool: &Path, + elf: &Path, + uf2: &Path, + mcu: &str, +) -> Result<()> { + let family = if mcu.to_ascii_lowercase().starts_with("rp2350") { + "rp2350-arm-s" + } else { + "rp2040" + }; + let mut args = vec![ + picotool.to_string_lossy().to_string(), + "uf2".to_string(), + "convert".to_string(), + elf.to_string_lossy().to_string(), + uf2.to_string_lossy().to_string(), + "--family".to_string(), + family.to_string(), + ]; + if family.starts_with("rp2350") { + args.push("--abs-block".to_string()); + } + let args_ref: Vec<&str> = args.iter().map(String::as_str).collect(); + let output = fbuild_core::subprocess::run_command( + &args_ref, + None, + None, + Some(std::time::Duration::from_secs(30)), + ) + .await?; + if !output.success() { + return Err(fbuild_core::FbuildError::BuildFailed(format!( + "managed picotool could not convert {} to {} for {family}: {}{}{}", + elf.display(), + uf2.display(), + output.stderr.trim(), + if output.stderr.is_empty() || output.stdout.is_empty() { + "" + } else { + "\n" + }, + output.stdout.trim() + ))); + } + if !uf2.is_file() { + return Err(fbuild_core::FbuildError::BuildFailed(format!( + "managed picotool reported success without creating {}", + uf2.display() + ))); + } + Ok(()) +} + /// Create an RP2040 orchestrator. pub fn create() -> Box { Box::new(Rp2040Orchestrator) diff --git a/crates/fbuild-core/src/usb/data.rs b/crates/fbuild-core/src/usb/data.rs index ae6dfb8b..d38c6609 100644 --- a/crates/fbuild-core/src/usb/data.rs +++ b/crates/fbuild-core/src/usb/data.rs @@ -218,7 +218,7 @@ pub fn try_install_online_cache(path: &Path) -> bool { }; let mut packed: HashMap = HashMap::with_capacity(parsed.len() * 4); for (vid_str, value) in parsed { - let Some(vid) = parse_hex_u16(&vid_str) else { + let Some(vid) = parse_hex_u16(vid_str) else { continue; }; // Accept fbuild's legacy {vendor, products:[[pid,name]]} shape and diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy_port.rs b/crates/fbuild-daemon/src/handlers/operations/deploy_port.rs index 03249205..30182563 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy_port.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy_port.rs @@ -29,11 +29,48 @@ pub(super) fn choose_deploy_port( }; } - // RP2040/RP2350 stock boards deploy through the ROM BOOTSEL volume and - // may have no application CDC port at all before the UF2 copy. Never - // fall back to an unrelated serial endpoint (for example Windows COM1); - // the RP2040 deployer discovers the post-flash CDC port itself. + // A stock/blank Pico may have no CDC port, but a previously flashed Pico + // needs its catalogue-identified CDC port passed through for the 1200-bps + // reset. Never select by a built-in VID or fall back to an unrelated COM + // port: FastLED/boards data is the sole identity source. if platform == Platform::RaspberryPi { + let expected_generation = board + .map(|board| board.mcu.to_ascii_lowercase()) + .filter(|mcu| mcu.starts_with("rp2350")) + .map_or(RpGeneration::Rp2040, |_| RpGeneration::Rp2350); + let mut matches: Vec<_> = devices + .into_iter() + .filter(|device| device.is_connected) + .filter_map(|device| { + let identity = device + .vid + .zip(device.pid) + .and_then(|(vid, pid)| fbuild_core::usb::try_resolve(vid, pid)); + identity_matches_rp_generation(identity.as_ref(), expected_generation).then_some(PortCandidate { + port: device.port, + vid: device.vid, + pid: device.pid, + description: device.description, + }) + }) + .collect(); + matches.sort_by(|a, b| a.port.cmp(&b.port)); + if matches.len() == 1 { + log_connect("deploy", &matches[0]); + return DeployPortChoice { + port: Some(matches[0].port.clone()), + warning: None, + }; + } + if matches.len() > 1 { + return DeployPortChoice { + port: None, + warning: Some(format!( + "multiple FastLED/boards-identified Raspberry Pi CDC ports are connected: {}; pass -p/--port to select the deployment target", + format_candidates(matches.iter()) + )), + }; + } return DeployPortChoice { port: None, warning: None, @@ -107,6 +144,32 @@ pub(super) fn choose_deploy_port( } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RpGeneration { + Rp2040, + Rp2350, +} + +fn identity_matches_rp_generation( + identity: Option<&fbuild_core::usb::UsbInfo>, + expected: RpGeneration, +) -> bool { + classify_rp_generation(identity) == Some(expected) +} + +fn classify_rp_generation( + identity: Option<&fbuild_core::usb::UsbInfo>, +) -> Option { + let product = identity?.product.to_ascii_lowercase(); + if product.contains("rp2350") || product.contains("pico 2") { + Some(RpGeneration::Rp2350) + } else if product.contains("rp2040") || product.contains("raspberry pi pico") { + Some(RpGeneration::Rp2040) + } else { + None + } +} + pub(super) fn append_warning_to_stderr(stderr: &mut Option, warning: Option) { let Some(warning) = warning else { return; @@ -133,7 +196,9 @@ fn expected_vids(platform: Platform, board: Option<&BoardConfig>) -> Vec { Platform::Espressif32 => &[0x303A], Platform::AtmelAvr | Platform::AtmelMegaAvr => &[0x2341, 0x2A03, 0x1A86, 0x10C4, 0x0403], Platform::NxpLpc => &[0x1FC9, 0x0D28], - Platform::RaspberryPi => &[0x2E8A], + // Raspberry Pi identities are consumed from the FastLED/boards USB + // catalogue by the RP2040 deployer. Never embed a family VID here. + Platform::RaspberryPi => &[], _ => &[], }; @@ -248,6 +313,33 @@ mod tests { assert!(choice.warning.is_none()); } + #[test] + fn raspberry_pi_identity_is_catalogue_driven() { + let pico = fbuild_core::usb::UsbInfo { + // Dataset aggregation can preserve a framework/vendor label here; + // generation comes from the curated product identity. + vendor: "Arduino".to_string(), + product: "Raspberry Pi Pico".to_string(), + }; + let pico2 = fbuild_core::usb::UsbInfo { + vendor: "Arduino".to_string(), + product: "Raspberry Pi Pico 2".to_string(), + }; + assert!(identity_matches_rp_generation( + Some(&pico), + RpGeneration::Rp2040 + )); + assert!(!identity_matches_rp_generation( + Some(&pico2), + RpGeneration::Rp2040 + )); + assert!(identity_matches_rp_generation( + Some(&pico2), + RpGeneration::Rp2350 + )); + assert!(!identity_matches_rp_generation(None, RpGeneration::Rp2040)); + } + #[test] fn selects_single_matching_teensy_vid() { let choice = choose_deploy_port( diff --git a/crates/fbuild-deploy/src/rp2040.rs b/crates/fbuild-deploy/src/rp2040.rs index 9ca56ec9..f07f6d25 100644 --- a/crates/fbuild-deploy/src/rp2040.rs +++ b/crates/fbuild-deploy/src/rp2040.rs @@ -5,13 +5,12 @@ //! exposes a mass-storage volume containing `INFO_UF2.TXT`. Copying a UF2 //! file to that volume is the documented stock-board deployment path. +use std::collections::BTreeSet; use std::fs; -use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use fbuild_core::{FbuildError, Result}; -use object::{Object, ObjectSegment}; use crate::{DeployOutcome, Deployer, DeploymentResult}; @@ -66,55 +65,6 @@ fn encode_uf2_at_address(binary: &[u8], base_address: u32, family_id: u32) -> Ve output } -/// Convert the loadable flash segments of an ELF into a sparse-free UF2 image. -/// -/// Arduino-Pico's managed `elf2uf2` path is the reference implementation. A -/// raw BIN loses segment load addresses (and can flatten RAM sections after -/// flash), so prefer this path whenever the linker emitted a sibling ELF. -fn encode_uf2_from_elf(elf_bytes: &[u8], family_id: u32) -> Result> { - let file = object::File::parse(elf_bytes) - .map_err(|error| FbuildError::DeployFailed(format!("invalid RP2040 ELF: {error}")))?; - let mut flash: BTreeMap = BTreeMap::new(); - for segment in file.segments() { - let address = segment.address(); - let data = segment.data().map_err(|error| { - FbuildError::DeployFailed(format!("failed to read RP2040 ELF segment: {error}")) - })?; - if data.is_empty() || address >= 0x1100_0000 || address.saturating_add(data.len() as u64) <= 0x1000_0000 { - continue; - } - let start = address.max(0x1000_0000) as usize; - let skip = start as u64 - address; - let max_len = 0x1100_0000usize.saturating_sub(start); - for (offset, byte) in data - .iter() - .enumerate() - .skip(skip as usize) - .take(max_len) - { - let target = start + offset - skip as usize; - flash.insert(target as u32, *byte); - } - } - let Some(((first, _), (last, _))) = flash.iter().next().zip(flash.iter().next_back()) else { - return Err(FbuildError::DeployFailed( - "RP2040 ELF contains no loadable flash segments".into(), - )); - }; - let first = *first; - let last = *last; - if first != RP2040_UF2_BASE_ADDRESS { - return Err(FbuildError::DeployFailed(format!( - "RP2040 ELF flash starts at 0x{first:08X}, expected 0x{RP2040_UF2_BASE_ADDRESS:08X}" - ))); - } - let mut image = vec![0u8; last as usize - first as usize + 1]; - for (address, byte) in flash { - image[address as usize - first as usize] = byte; - } - Ok(encode_uf2_at_address(&image, first, family_id)) -} - fn put_u32(buffer: &mut [u8], offset: usize, value: u32) { buffer[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } @@ -157,10 +107,13 @@ pub fn find_uf2_volume(roots: &[PathBuf]) -> Option { } fn find_uf2_volumes(roots: &[PathBuf]) -> Vec { - let mut matches = Vec::new(); + // Linux roots intentionally overlap (`/media/$USER` and `/media`) so + // discovery still works when HOME is unavailable. Deduplicate paths + // before applying the multi-board safety check. + let mut matches = BTreeSet::new(); for root in roots { if has_uf2_marker(root) { - matches.push(root.clone()); + matches.insert(root.clone()); } let Ok(entries) = fs::read_dir(root) else { continue; @@ -168,11 +121,11 @@ fn find_uf2_volumes(roots: &[PathBuf]) -> Vec { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() && has_uf2_marker(&path) { - matches.push(path); + matches.insert(path); } } } - matches + matches.into_iter().collect() } fn find_uf2_volume_until(timeout: Duration) -> Result> { @@ -200,13 +153,28 @@ fn find_uf2_volume_until(timeout: Duration) -> Result> { } fn touch_1200bps(port: &str) -> Result<()> { - match serialport::new(port, 1200) + match serialport::new(port, 9600) .timeout(Duration::from_secs(2)) .open() { - Ok(_) => { - // The Pico ROM observes the baud rate on close. The short hold - // avoids racing CDC ACM implementations that apply it lazily. + Ok(mut serial) => { + // Match Arduino-Pico's reset handshake: assert/deassert DTR, + // switch to the magic baud, then close the handle. + serial.write_data_terminal_ready(true).map_err(|error| { + FbuildError::SerialError(format!( + "failed to assert DTR while resetting {port}: {error}" + )) + })?; + serial.write_data_terminal_ready(false).map_err(|error| { + FbuildError::SerialError(format!( + "failed to deassert DTR while resetting {port}: {error}" + )) + })?; + serial.set_baud_rate(1200).map_err(|error| { + FbuildError::SerialError(format!( + "failed to set the RP2040 reset baud on {port}: {error}" + )) + })?; std::thread::sleep(Duration::from_millis(100)); Ok(()) } @@ -218,55 +186,215 @@ fn touch_1200bps(port: &str) -> Result<()> { } fn write_uf2(firmware_path: &Path, volume: &Path, family_id: u32) -> Result { - let bytes = fs::read(firmware_path).map_err(|error| { + let input_bytes = fs::read(firmware_path).map_err(|error| { FbuildError::DeployFailed(format!( "failed to read {}: {error}", firmware_path.display() )) })?; - let uf2 = if firmware_path + let input_is_uf2 = firmware_path .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("uf2")) - { - bytes + .is_some_and(|ext| ext.eq_ignore_ascii_case("uf2")); + let sibling_uf2 = firmware_path.with_extension("uf2"); + let (artifact, uf2) = if input_is_uf2 { + reject_stale_uf2(firmware_path)?; + (firmware_path.to_path_buf(), input_bytes) } else if firmware_path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("bin")) - && firmware_path.with_extension("elf").is_file() + && sibling_uf2.is_file() { - let elf_path = firmware_path.with_extension("elf"); - let elf_bytes = fs::read(&elf_path).map_err(|error| { + reject_stale_uf2(&sibling_uf2)?; + let bytes = fs::read(&sibling_uf2).map_err(|error| { FbuildError::DeployFailed(format!( - "failed to read RP2040 ELF {}: {error}", - elf_path.display() + "failed to read managed RP2040 UF2 {}: {error}", + sibling_uf2.display() )) })?; - encode_uf2_from_elf(&elf_bytes, family_id)? + (sibling_uf2, bytes) } else { - encode_uf2_for_family(&bytes, family_id) + let bytes = encode_uf2_for_family(&input_bytes, family_id); + let artifact = firmware_path.with_extension("uf2"); + fs::write(&artifact, &bytes).map_err(|error| { + FbuildError::DeployFailed(format!( + "failed to write RP2040 UF2 artifact {}: {error}", + artifact.display() + )) + })?; + (artifact, bytes) }; - let destination = volume.join("firmware.uf2"); - let mut file = fs::File::create(&destination).map_err(|error| { - FbuildError::DeployFailed(format!( - "failed to create UF2 at {}: {error}", - destination.display() - )) - })?; - std::io::Write::write_all(&mut file, &uf2).map_err(|error| { - FbuildError::DeployFailed(format!( - "failed to copy UF2 to {}: {error}", - destination.display() - )) - })?; - file.sync_all().map_err(|error| { - FbuildError::DeployFailed(format!( - "failed to flush UF2 to {}: {error}", - destination.display() - )) - })?; + validate_uf2(&uf2, family_id)?; + + // Match Arduino-Pico's proven ROM-volume upload path. RPI-RP2 is a + // virtual FAT volume, not ordinary persistent storage. Copying a local + // artifact to NEW.UF2 also follows the host's normal mass-storage copy + // path instead of treating the virtual volume as a persistent file. + let destination = volume.join("NEW.UF2"); + copy_uf2_artifact(&artifact, &destination, volume)?; + // Do not reopen or fsync the destination after the copy handle closes. + // A successful RP2040 ROM transfer ejects the virtual FAT volume at that + // point, so a post-copy open can turn success into a spurious failure. Ok(destination) } +fn reject_stale_uf2(uf2: &Path) -> Result<()> { + let uf2_modified = fs::metadata(uf2) + .and_then(|metadata| metadata.modified()) + .map_err(|error| { + FbuildError::DeployFailed(format!( + "failed to inspect RP2040 UF2 artifact {}: {error}", + uf2.display() + )) + })?; + for extension in ["elf", "bin"] { + let source = uf2.with_extension(extension); + let Ok(source_modified) = fs::metadata(&source).and_then(|metadata| metadata.modified()) + else { + continue; + }; + if source_modified > uf2_modified { + return Err(FbuildError::DeployFailed(format!( + "RP2040 UF2 {} is older than {}; rebuild without --skip-build before deploying", + uf2.display(), + source.display() + ))); + } + } + Ok(()) +} + +fn copy_uf2_artifact(artifact: &Path, destination: &Path, volume: &Path) -> Result<()> { + copy_uf2_artifact_with(artifact, destination, volume, |source, target| { + fs::copy(source, target) + }) +} + +fn copy_uf2_artifact_with( + artifact: &Path, + destination: &Path, + volume: &Path, + copy: F, +) -> Result<()> +where + F: FnOnce(&Path, &Path) -> std::io::Result, +{ + match copy(artifact, destination) { + Ok(_) => Ok(()), + Err(copy_error) => { + // Some hosts report a final I/O error when the ROM ejects the + // virtual FAT volume immediately after accepting the last block. + // Disappearance is the ROM's positive transition signal; if the + // marker remains, preserve the original actionable copy error. + if is_device_disappearance_error(©_error) + && wait_for_volume_disappearance(volume, Duration::from_secs(2)).is_ok() + { + tracing::debug!( + error = %copy_error, + volume = %volume.display(), + "RP2040 volume ejected while the host finalized NEW.UF2; treating transfer as accepted" + ); + Ok(()) + } else { + Err(FbuildError::DeployFailed(format!( + "failed to copy RP2040 UF2 {} to {}: {copy_error}", + artifact.display(), + destination.display() + ))) + } + } + } +} + +fn is_device_disappearance_error(error: &std::io::Error) -> bool { + use std::io::ErrorKind; + + matches!( + error.kind(), + ErrorKind::NotFound + | ErrorKind::BrokenPipe + | ErrorKind::ConnectionAborted + | ErrorKind::ConnectionReset + | ErrorKind::UnexpectedEof + ) || matches!( + error.raw_os_error(), + // Windows: FILE/PATH_NOT_FOUND, INVALID_HANDLE, NOT_READY, + // DEVICE_NOT_CONNECTED. Unix: ENODEV. + Some(2 | 3 | 6 | 21 | 1167 | 19) + ) +} + +fn validate_uf2(bytes: &[u8], expected_family: u32) -> Result<()> { + if bytes.is_empty() || bytes.len() % UF2_BLOCK_SIZE != 0 { + return Err(FbuildError::DeployFailed(format!( + "malformed RP2040 UF2: size {} is not a non-zero multiple of {UF2_BLOCK_SIZE}", + bytes.len() + ))); + } + let block_count = bytes.len() / UF2_BLOCK_SIZE; + let mut seen = vec![false; block_count]; + let flash_end = if expected_family == RP2350_FAMILY_ID { + 0x1400_0000u32 + } else { + 0x1100_0000u32 + }; + for (index, block) in bytes.chunks_exact(UF2_BLOCK_SIZE).enumerate() { + let field = |offset: usize| { + u32::from_le_bytes(block[offset..offset + 4].try_into().expect("four-byte UF2 field")) + }; + if field(0) != UF2_MAGIC_START0 + || field(4) != UF2_MAGIC_START1 + || field(508) != UF2_MAGIC_END + { + return Err(FbuildError::DeployFailed(format!( + "malformed RP2040 UF2: invalid magic in block {index}" + ))); + } + if field(8) & UF2_FLAG_FAMILY_ID_PRESENT == 0 || field(28) != expected_family { + return Err(FbuildError::DeployFailed(format!( + "wrong UF2 family in block {index}: expected 0x{expected_family:08X}, found 0x{:08X}", + field(28) + ))); + } + let target_address = field(12); + let block_number = field(20) as usize; + if field(16) != UF2_PAYLOAD_SIZE as u32 + || block_number >= block_count + || field(24) as usize != block_count + || target_address % UF2_PAYLOAD_SIZE as u32 != 0 + || !(RP2040_UF2_BASE_ADDRESS..flash_end).contains(&target_address) + { + return Err(FbuildError::DeployFailed(format!( + "malformed RP2040 UF2 block metadata at block {index}" + ))); + } + if std::mem::replace(&mut seen[block_number], true) { + return Err(FbuildError::DeployFailed(format!( + "malformed RP2040 UF2: duplicate block number {block_number}" + ))); + } + } + if seen.iter().any(|present| !present) { + return Err(FbuildError::DeployFailed( + "malformed RP2040 UF2: block-number sequence is incomplete".to_string(), + )); + } + Ok(()) +} + +fn wait_for_volume_disappearance(volume: &Path, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if !has_uf2_marker(volume) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(FbuildError::DeployFailed(format!( + "RP2040 BOOTSEL volume {} did not eject after NEW.UF2; the ROM did not accept the image", + volume.display() + ))) +} + /// Deploys RP2040-family firmware through the stock BOOTSEL mass-storage /// interface. `bootloader_timeout` is configurable for deterministic tests. pub struct Rp2040Deployer { @@ -306,36 +434,84 @@ impl Rp2040Deployer { } } -fn wait_for_cdc_port(previous: Option<&str>, timeout: Duration) -> Option { +fn catalogue_pico_cdc_ports(expected_family: u32) -> Result> { + let ports = fbuild_serial::ports::available_ports().map_err(|error| { + FbuildError::SerialError(format!("failed to enumerate post-deploy serial ports: {error}")) + })?; + let mut names: Vec = ports + .into_iter() + .filter(|port| { + let serialport::SerialPortType::UsbPort(usb) = &port.port_type else { + return false; + }; + fbuild_core::usb::try_resolve(usb.vid, usb.pid) + .is_some_and(|identity| identity_matches_family(&identity, expected_family)) + }) + .map(|port| port.port_name) + .collect(); + names.sort(); + Ok(names) +} + +fn wait_for_cdc_port( + previous_port: Option<&str>, + before: &BTreeSet, + expected_family: u32, + timeout: Duration, +) -> Result { let deadline = Instant::now() + timeout; loop { - if let Ok(ports) = fbuild_serial::ports::available_ports() { - let names: Vec = ports - .into_iter() - .filter(|port| { - matches!( - port.port_type, - serialport::SerialPortType::UsbPort(ref usb) - if usb.vid == 0x2E8A - ) - }) - .map(|port| port.port_name) - .collect(); - if let Some(old) = previous { - if names.iter().any(|name| name == old) { - return Some(old.to_string()); - } - } else if let Some(name) = names.into_iter().next() { - return Some(name); - } + let names = catalogue_pico_cdc_ports(expected_family)?; + if let Some(selected) = select_cdc_candidate(previous_port, before, &names)? { + return Ok(selected); } if Instant::now() >= deadline { - return None; + return Err(FbuildError::DeployFailed( + "RP2040 firmware was transferred, but no catalogue-identified runtime CDC port appeared; verify that the firmware enables USB serial and that FastLED/boards USB data is current" + .to_string(), + )); } std::thread::sleep(Duration::from_millis(100)); } } +fn identity_matches_family(identity: &fbuild_core::usb::UsbInfo, family_id: u32) -> bool { + let product = identity.product.to_ascii_lowercase(); + let generation = if product.contains("rp2350") || product.contains("pico 2") { + Some(RP2350_FAMILY_ID) + } else if product.contains("rp2040") || product.contains("raspberry pi pico") { + Some(RP2040_FAMILY_ID) + } else { + None + }; + generation == Some(family_id) +} + +fn select_cdc_candidate( + previous_port: Option<&str>, + before: &BTreeSet, + names: &[String], +) -> Result> { + if let Some(old) = previous_port { + if names.iter().any(|name| name == old) { + return Ok(Some(old.to_string())); + } + } + let new_names: Vec<_> = names + .iter() + .filter(|name| !before.contains(*name)) + .cloned() + .collect(); + match new_names.as_slice() { + [] => Ok(None), + [only] => Ok(Some(only.clone())), + many => Err(FbuildError::DeployFailed(format!( + "multiple new Raspberry Pi CDC ports appeared after deploy: {}; pass -p/--port to select one", + many.join(", ") + ))), + } +} + #[async_trait::async_trait] impl Deployer for Rp2040Deployer { async fn deploy( @@ -347,6 +523,17 @@ impl Deployer for Rp2040Deployer { ) -> Result { let port = port.map(str::trim).filter(|value| !value.is_empty()); let original_port = port.map(str::to_string); + let family_id = self.family_id; + let ports_before: BTreeSet = + tokio::task::spawn_blocking(move || catalogue_pico_cdc_ports(family_id)) + .await + .map_err(|error| { + FbuildError::DeployFailed(format!( + "RP2040 serial snapshot task failed: {error}" + )) + })?? + .into_iter() + .collect(); if let Some(port) = port { let port = port.to_string(); tokio::task::spawn_blocking(move || touch_1200bps(&port)) @@ -372,21 +559,36 @@ impl Deployer for Rp2040Deployer { .map_err(|error| { FbuildError::DeployFailed(format!("RP2040 UF2 writer failed: {error}")) })??; - let recovery_port = original_port.clone(); + let volume_for_wait = volume.clone(); let post_timeout = self.post_deploy_timeout; + tokio::task::spawn_blocking(move || { + wait_for_volume_disappearance(&volume_for_wait, post_timeout) + }) + .await + .map_err(|error| { + FbuildError::DeployFailed(format!("RP2040 eject watcher failed: {error}")) + })??; + let recovery_port = original_port.clone(); + let family_id = self.family_id; let discovered_port = tokio::task::spawn_blocking(move || { - wait_for_cdc_port(recovery_port.as_deref(), post_timeout) + wait_for_cdc_port( + recovery_port.as_deref(), + &ports_before, + family_id, + post_timeout, + ) }) .await - .unwrap_or(None) - .or(original_port); + .map_err(|error| { + FbuildError::DeployFailed(format!("RP2040 CDC watcher failed: {error}")) + })??; Ok(DeploymentResult { success: true, message: format!( "firmware copied to RP2040 BOOTSEL volume {}", volume.display() ), - port: discovered_port, + port: Some(discovered_port), stdout: format!("wrote {}", destination.display()), stderr: String::new(), outcome: DeployOutcome::FullFlash, @@ -479,6 +681,19 @@ mod tests { assert_eq!(find_uf2_volume(&[root.path().to_path_buf()]), Some(volume)); } + #[test] + fn overlapping_linux_roots_do_not_duplicate_one_volume() { + let root = tempdir().unwrap(); + let user_root = root.path().join("media").join("alice"); + let volume = user_root.join("RPI-RP2"); + fs::create_dir_all(&volume).unwrap(); + fs::write(volume.join("INFO_UF2.TXT"), "Model: Raspberry Pi RP2").unwrap(); + assert_eq!( + find_uf2_volumes(&[user_root, root.path().join("media")]), + vec![volume] + ); + } + #[test] fn writes_bin_as_uf2_to_marker_volume() { let root = tempdir().unwrap(); @@ -486,10 +701,119 @@ mod tests { let firmware = root.path().join("firmware.bin"); fs::write(&firmware, [1u8, 2, 3]).unwrap(); let destination = write_uf2(&firmware, root.path(), RP2040_FAMILY_ID).unwrap(); - assert_eq!(destination.file_name().unwrap(), "firmware.uf2"); + assert_eq!(destination.file_name().unwrap(), "NEW.UF2"); + let artifact = firmware.with_extension("uf2"); + assert!(artifact.is_file()); + assert_eq!(fs::read(&artifact).unwrap(), fs::read(&destination).unwrap()); assert_eq!( fs::metadata(destination).unwrap().len(), UF2_BLOCK_SIZE as u64 ); } + + #[test] + fn rejects_wrong_family_and_malformed_uf2() { + let wrong = encode_uf2_for_family(&[1, 2, 3], RP2350_FAMILY_ID); + let error = validate_uf2(&wrong, RP2040_FAMILY_ID).unwrap_err(); + assert!(error.to_string().contains("wrong UF2 family")); + + let error = validate_uf2(&[0; 511], RP2040_FAMILY_ID).unwrap_err(); + assert!(error.to_string().contains("malformed RP2040 UF2")); + + let mut duplicate = encode_uf2(&[0; 300]); + duplicate[512 + 20..512 + 24].copy_from_slice(&0u32.to_le_bytes()); + let error = validate_uf2(&duplicate, RP2040_FAMILY_ID).unwrap_err(); + assert!(error.to_string().contains("duplicate block number")); + + let mut bad_address = encode_uf2(&[0; 1]); + bad_address[12..16].copy_from_slice(&0x2000_0000u32.to_le_bytes()); + let error = validate_uf2(&bad_address, RP2040_FAMILY_ID).unwrap_err(); + assert!(error.to_string().contains("block metadata")); + } + + #[test] + fn changed_cdc_port_is_returned_instead_of_stale_port() { + let before = BTreeSet::from(["COM7".to_string()]); + let after = vec!["COM12".to_string()]; + assert_eq!( + select_cdc_candidate(Some("COM7"), &before, &after).unwrap(), + Some("COM12".to_string()) + ); + } + + #[test] + fn multiple_new_cdc_ports_are_rejected() { + let error = select_cdc_candidate( + None, + &BTreeSet::new(), + &["COM12".to_string(), "COM13".to_string()], + ) + .unwrap_err(); + assert!(error.to_string().contains("multiple new Raspberry Pi CDC ports")); + } + + #[test] + fn catalogue_identity_must_match_target_generation() { + let pico = fbuild_core::usb::UsbInfo { + vendor: "Arduino".to_string(), + product: "Raspberry Pi Pico".to_string(), + }; + let pico2 = fbuild_core::usb::UsbInfo { + vendor: "Arduino".to_string(), + product: "Raspberry Pi Pico 2".to_string(), + }; + assert!(identity_matches_family(&pico, RP2040_FAMILY_ID)); + assert!(!identity_matches_family(&pico2, RP2040_FAMILY_ID)); + assert!(identity_matches_family(&pico2, RP2350_FAMILY_ID)); + } + + #[test] + fn successful_rom_transfer_waits_for_marker_disappearance() { + let volume = tempdir().unwrap(); + let marker = volume.path().join("INFO_UF2.TXT"); + fs::write(&marker, "Model: Raspberry Pi RP2").unwrap(); + let marker_for_thread = marker.clone(); + let remover = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(25)); + fs::remove_file(marker_for_thread).unwrap(); + }); + wait_for_volume_disappearance(volume.path(), Duration::from_secs(1)).unwrap(); + remover.join().unwrap(); + } + + #[test] + fn copy_error_is_accepted_only_when_rom_volume_disappears() { + let root = tempdir().unwrap(); + let marker = root.path().join("INFO_UF2.TXT"); + let artifact = root.path().join("firmware.uf2"); + let destination = root.path().join("NEW.UF2"); + fs::write(&marker, "Model: Raspberry Pi RP2").unwrap(); + fs::write(&artifact, encode_uf2(&[1, 2, 3])).unwrap(); + + copy_uf2_artifact_with(&artifact, &destination, root.path(), |_, _| { + fs::remove_file(&marker).unwrap(); + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "device disappeared after final block", + )) + }) + .unwrap(); + + fs::write(&marker, "Model: Raspberry Pi RP2").unwrap(); + let error = copy_uf2_artifact_with(&artifact, &destination, root.path(), |_, _| { + Err(std::io::Error::other("corrupt volume")) + }) + .unwrap_err(); + assert!(error.to_string().contains("corrupt volume")); + + fs::remove_file(&marker).unwrap(); + let error = copy_uf2_artifact_with(&artifact, &destination, root.path(), |_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "source permission denied", + )) + }) + .unwrap_err(); + assert!(error.to_string().contains("source permission denied")); + } } diff --git a/crates/fbuild-toolchain/src/toolchain/mod.rs b/crates/fbuild-toolchain/src/toolchain/mod.rs index 886db7d0..a6ae2e98 100644 --- a/crates/fbuild-toolchain/src/toolchain/mod.rs +++ b/crates/fbuild-toolchain/src/toolchain/mod.rs @@ -9,6 +9,7 @@ pub mod esp32_metadata; pub mod esp8266; pub mod esp_qemu; pub mod riscv; +pub mod rp2040_picotool; pub mod rp2040_pqt; pub mod teensy_arm; @@ -22,5 +23,6 @@ pub use esp8266::Esp8266Toolchain; pub use esp_qemu::build_windows_qemu_path_env; pub use esp_qemu::{EspQemu, EspQemuArch, EspQemuRiscv32, EspQemuXtensa}; pub use riscv::RiscvToolchain; +pub use rp2040_picotool::Rp2040Picotool; pub use rp2040_pqt::Rp2040PqtToolchain; pub use teensy_arm::TeensyArmToolchain; diff --git a/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs b/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs new file mode 100644 index 00000000..86ea5d51 --- /dev/null +++ b/crates/fbuild-toolchain/src/toolchain/rp2040_picotool.rs @@ -0,0 +1,212 @@ +//! Managed picotool package from earlephilhower's pico-quick-toolchain. +//! +//! Arduino-Pico uses this exact release family to convert the linked ELF to +//! UF2. Keeping it under fbuild's package manager avoids host `PATH` lookup +//! while preserving Raspberry Pi's official ELF validation and sparse UF2 +//! layout. + +use std::path::{Path, PathBuf}; + +use crate::{CacheSubdir, PackageBase, PackageInfo}; + +const PICOTOOL_VERSION: &str = "4.0.1-8a9af99"; +const PICOTOOL_BASE_URL: &str = + "https://github.com/earlephilhower/pico-quick-toolchain/releases/download/4.0.1"; + +pub struct Rp2040Picotool { + base: PackageBase, + install_dir: Option, +} + +impl Rp2040Picotool { + pub fn new(project_dir: &Path) -> Self { + let package = platform_package(); + Self { + base: PackageBase::new( + "rp2040-picotool", + PICOTOOL_VERSION, + &format!("{PICOTOOL_BASE_URL}/{}", package.filename), + PICOTOOL_BASE_URL, + Some(package.checksum), + CacheSubdir::Toolchains, + project_dir, + ), + install_dir: None, + } + } + + #[cfg(test)] + fn with_cache_root(project_dir: &Path, cache_root: &Path) -> Self { + let package = platform_package(); + Self { + base: PackageBase::with_cache_root( + "rp2040-picotool", + PICOTOOL_VERSION, + &format!("{PICOTOOL_BASE_URL}/{}", package.filename), + PICOTOOL_BASE_URL, + Some(package.checksum), + CacheSubdir::Toolchains, + project_dir, + cache_root, + ), + install_dir: None, + } + } + + fn resolved_dir(&self) -> PathBuf { + self.install_dir + .clone() + .unwrap_or_else(|| find_picotool_root(&self.base.install_path())) + } + + pub fn executable(&self) -> PathBuf { + self.resolved_dir().join(picotool_name()) + } + + fn validate(install_dir: &Path) -> fbuild_core::Result<()> { + let executable = find_picotool_root(install_dir).join(picotool_name()); + if !executable.is_file() { + return Err(fbuild_core::FbuildError::PackageError(format!( + "managed picotool executable not found at {}", + executable.display() + ))); + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl crate::Package for Rp2040Picotool { + async fn ensure_installed(&self) -> fbuild_core::Result { + if self.is_installed() { + return Ok(self.resolved_dir()); + } + let install_path = self.base.staged_install(Self::validate).await?; + Ok(find_picotool_root(&install_path)) + } + + fn is_installed(&self) -> bool { + self.base.is_cached() && self.executable().is_file() + } + + fn get_info(&self) -> PackageInfo { + self.base.get_info() + } +} + +struct PlatformPackage { + filename: &'static str, + checksum: &'static str, +} + +fn all_platform_packages() -> [(&'static str, PlatformPackage); 5] { + [ + ( + "windows", + PlatformPackage { + filename: "x86_64-w64-mingw32.picotool-8a9af99.240929.zip", + checksum: "d4a43c8172f6b32de412a08e4deac4ef50218f5955c9cda85411b252fcecaea3", + }, + ), + ( + "macos-arm64", + PlatformPackage { + filename: "aarch64-apple-darwin20.4.picotool-8a9af99.240929.tar.gz", + checksum: "71eb93270747c5910893f36f5552affd4c254f085b4a7850765b29eec28040ec", + }, + ), + ( + "macos-x86_64", + PlatformPackage { + filename: "x86_64-apple-darwin20.4.picotool-8a9af99.240929.tar.gz", + checksum: "a8d30f63e421901000d2b2520f047d1dc586f827f41a3ef52056fd92272ff051", + }, + ), + ( + "linux-aarch64", + PlatformPackage { + filename: "aarch64-linux-gnu.picotool-8a9af99.240929.tar.gz", + checksum: "1f73e2c6ce8c7503678dfacec3d2ea889e0f5a161912eff68b290cf405206094", + }, + ), + ( + "linux-x86_64", + PlatformPackage { + filename: "x86_64-linux-gnu.picotool-8a9af99.240929.tar.gz", + checksum: "4c5b43afd1e9dba149753089c9715e110f2612cbd47fa005fb033adbe5237ad8", + }, + ), + ] +} + +fn platform_package() -> PlatformPackage { + let key = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + if cfg!(target_arch = "aarch64") { + "macos-arm64" + } else { + "macos-x86_64" + } + } else if cfg!(target_arch = "aarch64") { + "linux-aarch64" + } else { + "linux-x86_64" + }; + + all_platform_packages() + .into_iter() + .find(|(candidate, _)| *candidate == key) + .map(|(_, package)| package) + .expect("no managed picotool package for current platform") +} + +fn find_picotool_root(install_dir: &Path) -> PathBuf { + if install_dir.join(picotool_name()).is_file() { + return install_dir.to_path_buf(); + } + let nested = install_dir.join("picotool"); + if nested.join(picotool_name()).is_file() { + return nested; + } + install_dir.to_path_buf() +} + +fn picotool_name() -> &'static str { + if cfg!(windows) { + "picotool.exe" + } else { + "picotool" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Package; + + #[test] + fn package_is_pinned_for_every_supported_host() { + for (_, package) in all_platform_packages() { + assert_eq!(package.checksum.len(), 64); + assert!(package.checksum.chars().all(|c| c.is_ascii_hexdigit())); + assert!(package.filename.contains("picotool-8a9af99")); + } + } + + #[test] + fn finds_nested_archive_root() { + let temp = tempfile::tempdir().unwrap(); + let nested = temp.path().join("picotool"); + std::fs::create_dir(&nested).unwrap(); + std::fs::write(nested.join(picotool_name()), []).unwrap(); + assert_eq!(find_picotool_root(temp.path()), nested); + } + + #[test] + fn uncached_package_is_not_installed() { + let temp = tempfile::tempdir().unwrap(); + let package = Rp2040Picotool::with_cache_root(temp.path(), &temp.path().join("cache")); + assert!(!package.is_installed()); + } +} diff --git a/setup.py b/setup.py index eb37e37b..35d7d7d6 100644 --- a/setup.py +++ b/setup.py @@ -89,8 +89,12 @@ REPO_ROOT = Path(__file__).resolve().parent TARGET_BINARY_NAME = "fbuild.exe" if sys.platform == "win32" else "fbuild" +TARGET_DAEMON_NAME = ( + "fbuild-daemon.exe" if sys.platform == "win32" else "fbuild-daemon" +) STAGED_BIN_DIR = REPO_ROOT / "ci" / "bin" STAGED_BINARY_PATH = STAGED_BIN_DIR / TARGET_BINARY_NAME +STAGED_DAEMON_PATH = STAGED_BIN_DIR / TARGET_DAEMON_NAME # FastLED/fbuild#829: PyO3 extension produced by `fbuild-python`. Python # extension module conventions: `.pyd` on Windows, `.so` on Linux/macOS. @@ -150,6 +154,20 @@ def _staged_binary_is_up_to_date() -> bool: return True +def _staged_daemon_is_up_to_date() -> bool: + """True if the staged daemon matches the current Rust workspace inputs.""" + if not STAGED_DAEMON_PATH.is_file(): + return False + staged_mtime = STAGED_DAEMON_PATH.stat().st_mtime + for path in _iter_cargo_inputs(): + try: + if path.stat().st_mtime > staged_mtime: + return False + except FileNotFoundError: + return False + return True + + def _staged_native_ext_is_up_to_date() -> bool: """True if the staged PyO3 extension exists and is newer than every cargo input. @@ -219,6 +237,28 @@ def _find_fbuild_executable_from_json(stdout: str) -> Optional[Path]: return binary_path +def _find_fbuild_daemon_from_json(stdout: str) -> Optional[Path]: + """Return the `fbuild-daemon` executable from cargo's artifact stream.""" + binary_path: Optional[Path] = None + for line in stdout.splitlines(): + line = line.strip() + if not line or not line.startswith("{"): + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("reason") != "compiler-artifact": + continue + target = msg.get("target") or {} + if target.get("name") != "fbuild-daemon": + continue + executable = msg.get("executable") + if executable: + binary_path = Path(executable) + return binary_path + + def _use_release_profile() -> bool: """True when this build should produce a release-optimized binary. @@ -255,6 +295,22 @@ def _find_fbuild_executable_by_search() -> Optional[Path]: return None +def _find_fbuild_daemon_by_search() -> Optional[Path]: + """Fallback lookup for the companion daemon binary.""" + profile_dir = _profile_subdir() + target_root = Path(os.environ.get("CARGO_TARGET_DIR", REPO_ROOT / "target")) + candidates = [target_root / profile_dir / TARGET_DAEMON_NAME] + if target_root.is_dir(): + for child in target_root.iterdir(): + candidate = child / profile_dir / TARGET_DAEMON_NAME + if candidate.is_file(): + candidates.append(candidate) + for candidate in candidates: + if candidate.is_file(): + return candidate + return None + + def _find_native_cdylib_from_json(stdout: str) -> Optional[Path]: """Walk cargo's structured artifact stream and return the path to the `_native` cdylib produced by `fbuild-python`. @@ -377,14 +433,16 @@ def _build_fbuild_python() -> Path: return cdylib_path -def _build_fbuild_cli() -> Path: - """Run `soldr cargo build` and return the path to the built executable.""" +def _build_fbuild_binaries() -> tuple[Path, Path]: + """Build and return the CLI and its version-matched daemon.""" cmd = [ "soldr", "cargo", "build", "-p", "fbuild-cli", + "-p", + "fbuild-daemon", "--message-format=json-render-diagnostics", ] if _use_release_profile(): @@ -425,7 +483,18 @@ def _build_fbuild_cli() -> Path: ) sys.exit(1) - return binary_path + daemon_path = _find_fbuild_daemon_from_json(proc.stdout) + if daemon_path is None or not daemon_path.is_file(): + daemon_path = _find_fbuild_daemon_by_search() + if daemon_path is None or not daemon_path.is_file(): + sys.stderr.write( + "ERROR: cargo build succeeded but no `fbuild-daemon` binary was found.\n" + "A source/editable install must stage CLI and daemon together; refusing\n" + "to install a combination that can silently run stale deploy code.\n" + ) + sys.exit(1) + + return binary_path, daemon_path class BuildWithCargo(build_py): @@ -440,21 +509,25 @@ def run(self) -> None: # noqa: D401 — setuptools API name # change (e.g. version bump, lockfile churn, --reinstall-package). if ( _staged_binary_is_up_to_date() + and _staged_daemon_is_up_to_date() and _staged_native_ext_is_up_to_date() ): sys.stderr.write( f" staged artifacts up-to-date " - f"({STAGED_BINARY_PATH.name}, {STAGED_NATIVE_EXT_PATH.name}); " + f"({STAGED_BINARY_PATH.name}, {STAGED_DAEMON_PATH.name}, " + f"{STAGED_NATIVE_EXT_PATH.name}); " f"skipping cargo\n" ) super().run() return _require_soldr() - binary_path = _build_fbuild_cli() + binary_path, daemon_path = _build_fbuild_binaries() STAGED_BIN_DIR.mkdir(parents=True, exist_ok=True) shutil.copy2(binary_path, STAGED_BINARY_PATH) sys.stderr.write(f" staged binary -> {STAGED_BINARY_PATH}\n") + shutil.copy2(daemon_path, STAGED_DAEMON_PATH) + sys.stderr.write(f" staged daemon -> {STAGED_DAEMON_PATH}\n") # FastLED/fbuild#829: also build + stage the PyO3 extension so # `from fbuild import ...` works after an editable / source install. @@ -544,5 +617,5 @@ def copy_scripts(self): # noqa: D401 — distutils API name "build_scripts": BuildBinaryScripts, }, distclass=BinaryDistribution, - scripts=[str(STAGED_BINARY_PATH)], + scripts=[str(STAGED_BINARY_PATH), str(STAGED_DAEMON_PATH)], ) diff --git a/uv.lock b/uv.lock index 48e04b8c..da7c9500 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "fbuild" -version = "2.4.0" +version = "2.5.1" source = { editable = "." } [package.dev-dependencies]