diff --git a/.github/workflows/dylint.yml b/.github/workflows/dylint.yml index 7de6f552..ce461762 100644 --- a/.github/workflows/dylint.yml +++ b/.github/workflows/dylint.yml @@ -96,8 +96,13 @@ jobs: *"@"*) continue ;; esac alias_path="$lib_dir/${stem}@${toolchain}.${ext}" - if [ ! -e "$alias_path" ]; then - cp "$lib" "$alias_path" + # Refresh a stale alias too: cargo rebuilds the bare + # library when a lint source/allowlist changes, but a + # cache-restored `@` copy is what cargo-dylint + # actually loads — never overwriting it pins every lint + # to its first-ever compiled allowlist. + if [ ! -e "$alias_path" ] || [ "$lib" -nt "$alias_path" ]; then + cp -f "$lib" "$alias_path" created_any=1 fi done diff --git a/crates/fbuild-config/src/board/loaders.rs b/crates/fbuild-config/src/board/loaders.rs index 416f9a9f..8c288020 100644 --- a/crates/fbuild-config/src/board/loaders.rs +++ b/crates/fbuild-config/src/board/loaders.rs @@ -320,8 +320,7 @@ impl BoardConfig { name: get("name", board_id), mcu: get("mcu", "unknown"), f_cpu: get("f_cpu", "16000000L"), - clock_source: Some(get("clock_source", "")) - .filter(|source| !source.is_empty()), + clock_source: Some(get("clock_source", "")).filter(|source| !source.is_empty()), board: get("board", &board_id_to_board_define(board_id)), core: get("core", "arduino"), variant: get("variant", "standard"), diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index 0e3560bf..d11d3fd4 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -803,21 +803,23 @@ pub async fn deploy( baud_override, no_probe_rs, ), - fbuild_core::Platform::Ch32v => { - match req.protocol.as_deref() { - Some("isp") => { - let mcu = board.as_ref().map(|b| b.mcu.as_str()).unwrap_or_default(); - if !fbuild_deploy::wchisp::supports_mcu(mcu) { - return Err(fbuild_core::FbuildError::DeployFailed(format!( - "no ISP bootloader on {mcu}; use a WCH-LinkE probe" - ))); - } - Box::new(fbuild_deploy::wchisp::WchispDeployer::new()) + fbuild_core::Platform::Ch32v => match req.protocol.as_deref() { + Some("isp") => { + let mcu = board.as_ref().map(|b| b.mcu.as_str()).unwrap_or_default(); + if !fbuild_deploy::wchisp::supports_mcu(mcu) { + return Err(fbuild_core::FbuildError::DeployFailed(format!( + "no ISP bootloader on {mcu}; use a WCH-LinkE probe" + ))); } - Some("wlink") | None => Box::new(fbuild_deploy::wlink::WlinkDeployer::new()), - Some(protocol) => return Err(fbuild_core::FbuildError::DeployFailed(format!("unsupported CH32V deploy protocol: {protocol}"))), + Box::new(fbuild_deploy::wchisp::WchispDeployer::new()) } - } + Some("wlink") | None => Box::new(fbuild_deploy::wlink::WlinkDeployer::new()), + Some(protocol) => { + return Err(fbuild_core::FbuildError::DeployFailed(format!( + "unsupported CH32V deploy protocol: {protocol}" + ))); + } + }, _ => { return Err(fbuild_core::FbuildError::DeployFailed(format!( "deployer for {:?} not yet implemented", @@ -892,12 +894,23 @@ pub async fn deploy( &deploy_result, Ok(r) if r.success && matches!(r.outcome, fbuild_deploy::DeployOutcome::VerifySkip) ); - let recovery_port = deploy_port_str.clone().or_else(|| { + // FastLED/fbuild#1147: when the deployer owns post-flash port discovery + // (RP2040), `DeploymentResult.port` is the only health-gated, + // open-verified endpoint. The historical `requested.or(result.port)` + // order re-blessed a stale pre-flash COM name (e.g. a retained + // CM_PROB_PHANTOM devnode whose serial still matches the board) whenever + // the runtime CDC failed to recover. + let port_discovery_owned = deployer_for_recovery + .as_ref() + .is_some_and(|deployer| deployer.owns_post_flash_port_discovery()); + let recovery_port = resolve_recovery_port( + port_discovery_owned, + deploy_port_str.clone(), deploy_result .as_ref() .ok() - .and_then(|result| result.port.clone()) - }); + .and_then(|result| result.port.clone()), + ); if let Some(ref p) = recovery_port { ctx.serial_manager.clear_preemption(p).await; if !deploy_skipped_bus_work { @@ -933,42 +946,63 @@ pub async fn deploy( } } - let (deploy_success, deploy_stdout, mut deploy_stderr, deploy_outcome, deploy_post_port) = - match deploy_result { - Ok(r) if r.success => (true, Some(r.stdout), Some(r.stderr), r.outcome, r.port), - Ok(r) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(OperationResponse { - success: false, - request_id, - message: r.message, - exit_code: 1, - output_file: Some(reported_output_file.clone()), - output_dir: reported_output_dir.clone(), - launch_url: None, - stdout: Some(r.stdout), - stderr: Some(r.stderr), - }), - ); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(deploy_error_response(request_id, &e)), - ); - } - }; + let ( + deploy_success, + deploy_stdout, + mut deploy_stderr, + deploy_outcome, + deploy_post_port, + deploy_message, + ) = match deploy_result { + Ok(r) if r.success => ( + true, + Some(r.stdout), + Some(r.stderr), + r.outcome, + r.port, + r.message, + ), + Ok(r) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(OperationResponse { + success: false, + request_id, + message: r.message, + exit_code: 1, + output_file: Some(reported_output_file.clone()), + output_dir: reported_output_dir.clone(), + launch_url: None, + stdout: Some(r.stdout), + stderr: Some(r.stderr), + }), + ); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(deploy_error_response(request_id, &e)), + ); + } + }; append_warning_to_stderr(&mut deploy_stderr, deploy_port_warning); // Build the "deploy succeeded (...)" prefix used by every // monitor-attached and non-monitor-attached response below. Stable // wording — see GitHub issue #76 and the DeployOutcome::describe // test in fbuild-deploy. - let deploy_prefix = format!( + let mut deploy_prefix = format!( "deploy succeeded ({}); FBUILD_DEPLOY_PORT={}", deploy_outcome.describe(), deploy_post_port.as_deref().unwrap_or("") ); + // FastLED/fbuild#1147: a port-discovery-owning deployer distinguishes + // "firmware transferred" from "runtime CDC recovered". When the flash + // succeeded but no healthy endpoint returned, its `message` carries the + // structured recovery diagnostic, which the success arms previously + // discarded — surface it instead of a bare empty port. + if port_discovery_owned && deploy_post_port.is_none() { + deploy_prefix = format!("{deploy_prefix}; {deploy_message}"); + } // Post-deploy monitoring: if monitor_after is set, open the serial port // and stream lines checking halt conditions (matching Python behavior). @@ -977,10 +1011,39 @@ pub async fn deploy( // Teensy state machine in #433 returns the freshly re-enumerated CDC // ACM port, which can differ from the pre-flash `--port`). Fall back // to the caller-supplied port, then to a platform default. - let monitor_port = deploy_post_port - .clone() - .or(deploy_port_str.clone()) - .unwrap_or_else(|| "/dev/ttyUSB0".to_string()); + // FastLED/fbuild#1147: when the deployer owns post-flash port + // discovery, an absent recovered port means there is nothing safe to + // monitor — attaching to the stale pre-flash name (or a guessed + // default) would target a known-dead endpoint. + let monitor_port = if port_discovery_owned { + match deploy_post_port.clone() { + Some(port) => port, + None => { + return ( + StatusCode::OK, + Json(OperationResponse { + success: true, + request_id, + message: format!( + "{} but monitor was skipped: no healthy runtime CDC endpoint was recovered after the flash", + deploy_prefix + ), + exit_code: 0, + output_file: Some(reported_output_file.clone()), + output_dir: reported_output_dir.clone(), + launch_url: None, + stdout: deploy_stdout, + stderr: deploy_stderr, + }), + ); + } + } + } else { + deploy_post_port + .clone() + .or(deploy_port_str.clone()) + .unwrap_or_else(|| "/dev/ttyUSB0".to_string()) + }; if deploy_post_port .as_deref() .zip(deploy_port_str.as_deref()) @@ -1168,6 +1231,25 @@ pub async fn deploy( ) } +/// Post-deploy recovery/monitor port choice (FastLED/fbuild#1147). +/// +/// A deployer that owns post-flash port discovery returns the only endpoint +/// that passed a fresh health + openability gate; the requested pre-flash +/// name must never be substituted when that endpoint is absent, because on +/// Windows it can be a retained `CM_PROB_PHANTOM` devnode record. Legacy +/// deployers keep the historical requested-first order. +fn resolve_recovery_port( + port_discovery_owned: bool, + requested: Option, + deployer_port: Option, +) -> Option { + if port_discovery_owned { + deployer_port + } else { + requested.or(deployer_port) + } +} + fn deploy_error_response( request_id: String, error: &fbuild_core::FbuildError, @@ -1205,4 +1287,34 @@ mod tests { ); assert_eq!(response.stderr.as_deref(), Some(response.message.as_str())); } + + #[test] + fn owned_port_discovery_never_substitutes_the_requested_preflash_port() { + // FastLED/fbuild#1147: UF2 success + phantom CDC must not resurrect + // the historical COM name for recovery/monitor propagation. + assert_eq!( + resolve_recovery_port(true, Some("COM12".to_string()), None), + None + ); + assert_eq!( + resolve_recovery_port(true, Some("COM12".to_string()), Some("COM27".to_string())), + Some("COM27".to_string()) + ); + } + + #[test] + fn legacy_deployers_keep_requested_first_recovery_order() { + assert_eq!( + resolve_recovery_port(false, Some("COM3".to_string()), None), + Some("COM3".to_string()) + ); + assert_eq!( + resolve_recovery_port(false, Some("COM3".to_string()), Some("COM4".to_string())), + Some("COM3".to_string()) + ); + assert_eq!( + resolve_recovery_port(false, None, Some("COM4".to_string())), + Some("COM4".to_string()) + ); + } } diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy_port.rs b/crates/fbuild-daemon/src/handlers/operations/deploy_port.rs index 210d6649..b8f5de3d 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy_port.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy_port.rs @@ -39,45 +39,13 @@ pub(super) fn choose_deploy_port( .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 matches = device.vid.zip(device.pid).is_some_and(|(vid, pid)| { - rp_profiles_match_generation( - &fbuild_core::usb::profiles::profiles_for(vid, pid), - expected_generation, - ) - }); - matches.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, - }; + let (matches, unhealthy) = partition_rp_candidates(devices, |vid, pid| { + rp_profiles_match_generation( + &fbuild_core::usb::profiles::profiles_for(vid, pid), + expected_generation, + ) + }); + return rp_deploy_choice(matches, unhealthy); } let mut candidates: Vec<_> = devices @@ -135,6 +103,92 @@ pub(super) fn choose_deploy_port( } } +/// Split connected Raspberry Pi family matches into deploy-eligible +/// candidates and known-unhealthy records (FastLED/fbuild#1147). Phantom and +/// present-problem devnodes stay visible for diagnostics but are never +/// auto-selected: touching a stale COM name is guaranteed to fail, while the +/// BOOTSEL-volume path can still deploy. +fn partition_rp_candidates( + devices: Vec, + mut family_match: impl FnMut(u16, u16) -> bool, +) -> (Vec, Vec) { + let mut matches = Vec::new(); + let mut unhealthy = Vec::new(); + for device in devices.into_iter().filter(|device| device.is_connected) { + let matched = device + .vid + .zip(device.pid) + .is_some_and(|(vid, pid)| family_match(vid, pid)); + if !matched { + continue; + } + if device.port_health.is_known_unhealthy() { + unhealthy.push(describe_unhealthy_device(&device)); + continue; + } + matches.push(PortCandidate { + port: device.port, + vid: device.vid, + pid: device.pid, + description: device.description, + }); + } + matches.sort_by(|a, b| a.port.cmp(&b.port)); + (matches, unhealthy) +} + +fn describe_unhealthy_device(device: &DeviceState) -> String { + let problem = device + .port_health + .problem_code() + .map(|code| format!("; problem code {code}")) + .unwrap_or_default(); + let instance = device + .instance_id + .as_deref() + .map(|value| format!("; instance {value}")) + .unwrap_or_default(); + format!( + "{} (health {}{problem}{instance})", + device.port, + device.port_health.label() + ) +} + +/// Final Raspberry Pi deploy-port decision from the partitioned candidates. +fn rp_deploy_choice(matches: Vec, unhealthy: Vec) -> DeployPortChoice { + let unhealthy_note = (!unhealthy.is_empty()).then(|| { + format!( + "excluded known-unhealthy Raspberry Pi CDC record(s) from deploy selection: {}; they stay visible to `fbuild port scan`, and deploy continues via the BOOTSEL volume path", + unhealthy.join(", ") + ) + }); + if matches.len() == 1 { + log_connect("deploy", &matches[0]); + return DeployPortChoice { + port: Some(matches[0].port.clone()), + warning: unhealthy_note, + }; + } + if matches.len() > 1 { + let ambiguity = 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: Some(match unhealthy_note { + Some(note) => format!("{ambiguity}; {note}"), + None => ambiguity, + }), + }; + } + DeployPortChoice { + port: None, + warning: unhealthy_note, + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RpGeneration { Rp2040, @@ -399,6 +453,95 @@ mod tests { assert!(choice.warning.is_none()); } + fn phantom_device(port: &str, instance_id: &str) -> DeviceState { + let mut state = device(port, Some(0x2E8A), Some(0x000A)); + state.port_health = fbuild_serial::ports::PortHealth::Phantom { + problem_code: Some(45), + status: None, + }; + state.instance_id = Some(instance_id.to_string()); + state + } + + #[test] + fn phantom_rp2040_cdc_is_never_auto_selected() { + let (matches, unhealthy) = partition_rp_candidates( + vec![phantom_device( + "COM12", + "USB\\VID_2E8A&PID_000A\\5303284720C4641C", + )], + |_, _| true, + ); + assert!(matches.is_empty()); + let choice = rp_deploy_choice(matches, unhealthy); + assert!(choice.port.is_none()); + let warning = choice.warning.expect("the exclusion must be diagnosed"); + assert!(warning.contains("COM12"), "missing port: {warning}"); + assert!( + warning.contains("health phantom"), + "missing health: {warning}" + ); + assert!( + warning.contains("problem code 45"), + "missing code: {warning}" + ); + assert!( + warning.contains("USB\\VID_2E8A&PID_000A\\5303284720C4641C"), + "missing instance: {warning}" + ); + assert!( + warning.contains("BOOTSEL volume path"), + "missing path: {warning}" + ); + } + + #[test] + fn healthy_rp2040_cdc_is_selected_while_phantom_history_is_reported() { + let mut healthy = device("COM27", Some(0x2E8A), Some(0x000A)); + healthy.port_health = fbuild_serial::ports::PortHealth::HealthyPresent; + let (matches, unhealthy) = partition_rp_candidates( + vec![ + phantom_device("COM12", "USB\\VID_2E8A&PID_000A\\5303284720C4641C"), + healthy, + ], + |_, _| true, + ); + let choice = rp_deploy_choice(matches, unhealthy); + assert_eq!(choice.port.as_deref(), Some("COM27")); + let warning = choice + .warning + .expect("the exclusion must still be diagnosed"); + assert!(warning.contains("COM12")); + } + + #[test] + fn present_problem_rp2040_cdc_is_excluded_from_selection() { + let mut broken = device("COM12", Some(0x2E8A), Some(0x000A)); + broken.port_health = fbuild_serial::ports::PortHealth::PresentProblem { + problem_code: 31, + status: None, + }; + let (matches, unhealthy) = partition_rp_candidates(vec![broken], |_, _| true); + assert!(matches.is_empty()); + assert_eq!(unhealthy.len(), 1); + assert!(unhealthy[0].contains("health present-problem")); + assert!(unhealthy[0].contains("problem code 31")); + } + + #[test] + fn unknown_health_rp2040_cdc_remains_eligible() { + let (matches, unhealthy) = + partition_rp_candidates(vec![device("COM5", Some(0x2E8A), Some(0x000A))], |_, _| { + true + }); + assert_eq!(matches.len(), 1); + assert!(unhealthy.is_empty()); + assert_eq!( + rp_deploy_choice(matches, unhealthy).port.as_deref(), + Some("COM5") + ); + } + #[test] fn stock_raspberry_pi_deploy_does_not_select_unrelated_serial_port() { let choice = choose_deploy_port( diff --git a/crates/fbuild-deploy/src/lib.rs b/crates/fbuild-deploy/src/lib.rs index 338190a8..7ff00854 100644 --- a/crates/fbuild-deploy/src/lib.rs +++ b/crates/fbuild-deploy/src/lib.rs @@ -121,6 +121,20 @@ pub trait Deployer: Send + Sync { port: Option<&str>, ) -> Result; + /// Whether this deployer's `DeploymentResult::port` is the only valid + /// post-flash endpoint (FastLED/fbuild#1147). + /// + /// When `true`, the returned port came from a fresh post-flash catalogue + /// scan that passed health eligibility and a bounded open probe, and a + /// `None` port after a successful flash means the runtime endpoint was + /// not recovered. Callers must not substitute the requested/pre-flash + /// port name for recovery, monitoring, or environment propagation: on + /// Windows that name can be a retained `CM_PROB_PHANTOM` devnode record + /// whose serial still matches the board. + fn owns_post_flash_port_discovery(&self) -> bool { + false + } + /// Post-deploy serial-port recovery. /// /// Called by the daemon's deploy handler after `clear_preemption()` diff --git a/crates/fbuild-deploy/src/rp2040.rs b/crates/fbuild-deploy/src/rp2040.rs index bd2d5e8d..ff2b7133 100644 --- a/crates/fbuild-deploy/src/rp2040.rs +++ b/crates/fbuild-deploy/src/rp2040.rs @@ -19,7 +19,9 @@ mod target; #[path = "rp2040_topology.rs"] mod topology; use mount::try_mount_linux_rom_device; -use target::{resolve_requested_runtime_target, select_cdc_candidate, serial_selector}; +use target::{ + describe_unhealthy, resolve_requested_runtime_target, select_cdc_candidate, serial_selector, +}; const UF2_MAGIC_START0: u32 = 0x0A32_4655; const UF2_MAGIC_START1: u32 = 0x9E5D_5157; @@ -1029,6 +1031,10 @@ struct CdcWaitTimeout { previous_port: Option, requested_serial: Option, candidates: Vec, + /// The most recent bounded-open failure on an otherwise eligible + /// candidate, as `": "` (FastLED/fbuild#1147: healthy + /// metadata alone never proves a live endpoint). + last_open_error: Option, } impl CdcWaitTimeout { @@ -1065,11 +1071,40 @@ impl CdcWaitTimeout { .collect::>() .join(", ") }; + let open_error = self + .last_open_error + .as_deref() + .map(|value| format!("; last open error {value}")) + .unwrap_or_default(); format!( - "elapsed {}ms; prior port {prior_port}; requested serial {requested_serial}; catalogue candidates: {candidates}", + "elapsed {}ms; prior port {prior_port}; requested serial {requested_serial}; catalogue candidates: {candidates}{open_error}", self.elapsed.as_millis() ) } + + /// Manual recovery guidance for a confirmed flash whose runtime CDC never + /// became a healthy, openable endpoint (FastLED/fbuild#1147 step 5). When + /// Windows retained stale devnode records for this board, name them so the + /// user sees exactly why the historical COM port was not returned. + fn recovery_guidance(&self) -> String { + let stale: Vec = self + .candidates + .iter() + .filter(|candidate| candidate.health.is_known_unhealthy()) + .map(describe_unhealthy) + .collect(); + let stale_note = if stale.is_empty() { + String::new() + } else { + format!( + " Windows retained stale/problem devnode record(s) for this board: {}; a historical COM name with a matching serial is not a live endpoint and was not returned.", + stale.join(", ") + ) + }; + format!( + "{stale_note} To recover manually: use a direct motherboard USB port and a known-good data cable, then 1) hold BOOT/BOOTSEL, 2) press and release RESET while still holding BOOT, 3) keep BOOT held for about two seconds, then release, 4) verify an RPI-RP2 volume appears and rerun the deploy." + ) + } } fn wait_for_cdc_port( @@ -1078,7 +1113,7 @@ fn wait_for_cdc_port( before: &BTreeSet, expected_family: u32, timeout: Duration, -) -> std::result::Result { +) -> std::result::Result { let started = Instant::now(); wait_for_cdc_port_with_clock( previous_port, @@ -1086,29 +1121,54 @@ fn wait_for_cdc_port( before, timeout, || catalogue_pico_cdc_ports(expected_family), + probe_cdc_openable, || started.elapsed(), std::thread::sleep, ) } -fn wait_for_cdc_port_with_clock( +/// Bounded openability probe (FastLED/fbuild#1147): health metadata alone +/// never proves a live endpoint, so the selected candidate must open before +/// it may become `DeploymentResult::port`. +fn probe_cdc_openable(port: &PicoCdcPort) -> std::result::Result<(), String> { + serialport::new(&port.name, 115_200) + .timeout(Duration::from_millis(250)) + .open() + .map(drop) + .map_err(|error| error.to_string()) +} + +#[allow(clippy::too_many_arguments)] +fn wait_for_cdc_port_with_clock( previous_port: Option<&str>, requested_serial: Option<&str>, before: &BTreeSet, timeout: Duration, mut catalogue: F, + mut probe: P, mut elapsed: E, mut sleep: S, -) -> std::result::Result +) -> std::result::Result where F: FnMut() -> Result>, + P: FnMut(&PicoCdcPort) -> std::result::Result<(), String>, E: FnMut() -> Duration, S: FnMut(Duration), { + let mut last_open_error: Option = None; loop { let ports = catalogue().map_err(CdcWaitError::Enumeration)?; match select_cdc_candidate(previous_port, requested_serial, before, &ports) { - Ok(Some(selected)) => return Ok(selected), + // A candidate that fails its bounded open probe is not returned; + // the loop keeps polling so a transient just-enumerated failure + // can clear, and the last failure lands in the timeout + // diagnostics (FastLED/fbuild#1147). + Ok(Some(selected)) => match probe(&selected) { + Ok(()) => return Ok(selected), + Err(error) => { + last_open_error = Some(format!("{}: {error}", selected.name)); + } + }, Ok(None) => {} Err(error) => return Err(CdcWaitError::Enumeration(error)), } @@ -1119,6 +1179,7 @@ where previous_port: previous_port.map(str::to_string), requested_serial: requested_serial.map(str::to_string), candidates: ports, + last_open_error, })); } sleep(CDC_POLL_INTERVAL.min(timeout.saturating_sub(waited))); @@ -1141,17 +1202,19 @@ enum PostFlashCdc { /// re-flash a healthy board. Enumeration errors still fail regardless. fn resolve_post_flash_cdc( flash_confirmed: bool, - wait_result: std::result::Result, + wait_result: std::result::Result, window: Duration, ) -> Result { match wait_result { - Ok(port) => Ok(PostFlashCdc::Confirmed(port)), + Ok(port) => Ok(PostFlashCdc::Confirmed(port.name)), Err(CdcWaitError::Enumeration(error)) => Err(error), Err(CdcWaitError::Timeout(diagnostics)) if flash_confirmed => { tracing::warn!(diagnostics = %diagnostics.diagnostics(), "RP2040 runtime CDC did not return before the confirmed-flash deadline"); Ok(PostFlashCdc::Unconfirmed(format!( - "the firmware was flashed and accepted, but the runtime CDC port did not reappear within {}s; first-plug driver installation can exceed this window — the board is likely healthy (extend the window with {POST_DEPLOY_TIMEOUT_ENV})", - window.as_secs() + "the firmware was flashed and accepted, but no healthy, openable runtime CDC port reappeared within {}s ({}).{} First-plug driver installation can also exceed this window (extend it with {POST_DEPLOY_TIMEOUT_ENV})", + window.as_secs(), + diagnostics.diagnostics(), + diagnostics.recovery_guidance(), ))) } Err(CdcWaitError::Timeout(diagnostics)) => { @@ -1450,6 +1513,14 @@ impl Deployer for Rp2040Deployer { }) } + /// The RP2040 deploy path rediscovers its post-flash endpoint through a + /// fresh, health-gated, open-probed catalogue scan (FastLED/fbuild#1147); + /// a `None` port after a successful flash means the runtime CDC was not + /// recovered, and the pre-flash name must never be substituted. + fn owns_post_flash_port_discovery(&self) -> bool { + true + } + async fn post_deploy_recovery(&self, port: &str) -> Result<()> { let deadline = Instant::now() + self.post_deploy_timeout; while Instant::now() < deadline { @@ -2071,6 +2142,24 @@ mod tests { assert!(message.contains("does not identify a QSPI flash fault")); } + fn accept_probe(_port: &PicoCdcPort) -> std::result::Result<(), String> { + Ok(()) + } + + fn cdc_candidate( + name: &str, + serial: Option<&str>, + health: fbuild_serial::ports::PortHealth, + ) -> PicoCdcPort { + PicoCdcPort { + name: name.to_string(), + serial_number: serial.map(str::to_string), + health, + instance_id: Some(format!("USB\\VID_2E8A&PID_000A\\{name}")), + parent_instance_id: None, + } + } + #[test] fn cdc_timeout_without_flash_confirmation_is_an_actionable_failure() { let wait = wait_for_cdc_port_with_clock( @@ -2079,6 +2168,7 @@ mod tests { &BTreeSet::from(["COM7".to_string()]), Duration::ZERO, || Ok(Vec::new()), + accept_probe, || Duration::ZERO, |_| {}, ); @@ -2099,6 +2189,7 @@ mod tests { previous_port: Some("COM7".to_string()), requested_serial: Some("serial".to_string()), candidates: Vec::new(), + last_open_error: None, })), Duration::from_secs(15), ) @@ -2107,9 +2198,117 @@ mod tests { panic!("expected an unconfirmed-CDC downgrade, got {outcome:?}"); }; assert!(note.contains("flashed and accepted")); - assert!(note.contains("did not reappear within 15s")); - assert!(note.contains("first-plug driver installation")); + assert!(note.contains("reappeared within 15s")); + assert!(note.contains("irst-plug driver installation")); assert!(note.contains(POST_DEPLOY_TIMEOUT_ENV)); + assert!(note.contains("hold BOOT/BOOTSEL")); + } + + #[test] + fn confirmed_flash_with_phantom_history_reports_recovery_guidance() { + let outcome = resolve_post_flash_cdc( + true, + Err(CdcWaitError::Timeout(CdcWaitTimeout { + elapsed: Duration::from_secs(30), + previous_port: Some("COM12".to_string()), + requested_serial: Some("5303284720C4641C".to_string()), + candidates: vec![cdc_candidate( + "COM12", + Some("5303284720C4641C"), + fbuild_serial::ports::PortHealth::Phantom { + problem_code: Some(45), + status: None, + }, + )], + last_open_error: Some("COM12: Access is denied.".to_string()), + })), + Duration::from_secs(30), + ) + .unwrap(); + let PostFlashCdc::Unconfirmed(note) = outcome else { + panic!("expected an unconfirmed-CDC downgrade, got {outcome:?}"); + }; + assert!(note.contains("health phantom"), "missing health: {note}"); + assert!(note.contains("problem code 45"), "missing code: {note}"); + assert!( + note.contains("USB\\VID_2E8A&PID_000A\\COM12"), + "missing instance: {note}" + ); + assert!( + note.contains("last open error COM12: Access is denied."), + "missing open error: {note}" + ); + assert!( + note.contains("not a live endpoint"), + "missing phantom explanation: {note}" + ); + assert!(note.contains("RPI-RP2"), "missing BOOTSEL steps: {note}"); + } + + #[test] + fn open_probe_failure_is_never_returned_and_lands_in_diagnostics() { + let mut probes = 0; + let wait = wait_for_cdc_port_with_clock( + None, + Some("5303284720C4641C"), + &BTreeSet::new(), + Duration::from_secs(1), + || { + Ok(vec![cdc_candidate( + "COM27", + Some("5303284720C4641C"), + fbuild_serial::ports::PortHealth::HealthyPresent, + )]) + }, + |port: &PicoCdcPort| { + probes += 1; + Err(format!("open {} timed out", port.name)) + }, + || Duration::from_secs(2), + |_| {}, + ); + let Err(CdcWaitError::Timeout(timeout)) = wait else { + panic!("an unopenable candidate must never be returned, got {wait:?}"); + }; + assert!(probes >= 1); + assert_eq!( + timeout.last_open_error.as_deref(), + Some("COM27: open COM27 timed out") + ); + assert!(timeout.diagnostics().contains("last open error COM27")); + } + + #[test] + fn candidate_that_turns_phantom_is_never_returned() { + let mut scans = 0; + let wait = wait_for_cdc_port_with_clock( + Some("COM12"), + Some("5303284720C4641C"), + &BTreeSet::from(["COM12".to_string()]), + Duration::from_secs(1), + || { + scans += 1; + Ok(vec![cdc_candidate( + "COM12", + Some("5303284720C4641C"), + if scans == 1 { + fbuild_serial::ports::PortHealth::Phantom { + problem_code: None, + status: None, + } + } else { + fbuild_serial::ports::PortHealth::Phantom { + problem_code: Some(45), + status: None, + } + }, + )]) + }, + |_port: &PicoCdcPort| panic!("a phantom record must never reach the open probe"), + || Duration::from_secs(2), + |_| {}, + ); + assert!(matches!(wait, Err(CdcWaitError::Timeout(_)))); } #[test] @@ -2125,6 +2324,7 @@ mod tests { instance_id: None, parent_instance_id: None, }], + last_open_error: None, } .diagnostics(); @@ -2142,6 +2342,7 @@ mod tests { &BTreeSet::new(), Duration::from_secs(5), || Err(FbuildError::SerialError("enumeration exploded".into())), + accept_probe, || Duration::ZERO, |_| {}, ); @@ -2154,7 +2355,11 @@ mod tests { for flash_confirmed in [true, false] { let outcome = resolve_post_flash_cdc( flash_confirmed, - Ok("COM9".to_string()), + Ok(cdc_candidate( + "COM9", + None, + fbuild_serial::ports::PortHealth::HealthyPresent, + )), Duration::from_secs(15), ) .unwrap(); @@ -2185,12 +2390,13 @@ mod tests { }] }) }, + accept_probe, || elapsed.next().unwrap_or(Duration::from_secs(16)), |_| {}, ) .expect("a matching delayed Pico CDC endpoint must be returned"); - assert_eq!(port, "COM27"); + assert_eq!(port.name, "COM27"); assert_eq!(scans, 2); } @@ -2213,12 +2419,13 @@ mod tests { parent_instance_id: None, }]) }, + accept_probe, || elapsed.next().unwrap_or(Duration::from_secs(30)), |_| {}, ) .expect("the final catalogue scan must accept a boundary arrival"); - assert_eq!(port, "COM27"); + assert_eq!(port.name, "COM27"); assert_eq!(scans, 1); } @@ -2252,6 +2459,7 @@ mod tests { }, ]) }, + accept_probe, || Duration::ZERO, |_| {}, ); diff --git a/crates/fbuild-deploy/src/rp2040_target.rs b/crates/fbuild-deploy/src/rp2040_target.rs index 1ca076e8..1ff55bc7 100644 --- a/crates/fbuild-deploy/src/rp2040_target.rs +++ b/crates/fbuild-deploy/src/rp2040_target.rs @@ -20,6 +20,26 @@ pub(super) fn serial_selector(selector: &str) -> Option<&str> { .filter(|serial| !serial.is_empty()) } +/// Identity/health line for refusing or reporting a known-unhealthy record +/// (FastLED/fbuild#1147: diagnostic visibility is not deploy eligibility). +pub(super) fn describe_unhealthy(port: &PicoCdcPort) -> String { + let problem = port + .health + .problem_code() + .map(|code| format!("; problem code {code}")) + .unwrap_or_default(); + let instance = port + .instance_id + .as_deref() + .map(|value| format!("; instance {value}")) + .unwrap_or_default(); + format!( + "{} (health {}{problem}{instance})", + port.name, + port.health.label() + ) +} + pub(super) fn resolve_requested_runtime_target( selector: &str, candidates: &[PicoCdcPort], @@ -36,6 +56,14 @@ pub(super) fn resolve_requested_runtime_target( .collect() }; match matches.as_slice() { + // An explicit selector never overrides known-unhealthy Windows PnP + // state: a phantom/problem devnode is a historical record, not a live + // endpoint, and opening or 1200-baud-touching it is guaranteed stale + // (FastLED/fbuild#1147). + [only] if only.health.is_known_unhealthy() => Err(FbuildError::DeployFailed(format!( + "RP2040 runtime selector {selector:?} matches {}, which Windows reports as not usable; fbuild will not open or reset a stale devnode record — reconnect the board (or enter BOOTSEL) and retry", + describe_unhealthy(only) + ))), [only] => Ok(RequestedRuntimeTarget { port: only.name.clone(), serial_number: only.serial_number.clone(), @@ -53,20 +81,32 @@ pub(super) fn resolve_requested_runtime_target( } } +/// Select the post-flash runtime CDC endpoint from one fresh catalogue scan. +/// +/// Known-unhealthy (phantom / present-problem) records stay visible in the +/// caller's diagnostics but are never eligible here (FastLED/fbuild#1147): a +/// serial or name match against a stale Windows devnode proves history, not a +/// live endpoint. Returning `Ok(None)` keeps the caller polling until its +/// bounded deadline, when the unhealthy records surface in the timeout +/// diagnostics. pub(super) fn select_cdc_candidate( previous_port: Option<&str>, requested_serial: Option<&str>, before: &BTreeSet, ports: &[PicoCdcPort], -) -> Result> { +) -> Result> { + let eligible: Vec<&PicoCdcPort> = ports + .iter() + .filter(|port| !port.health.is_known_unhealthy()) + .collect(); if let Some(serial) = requested_serial { - let matching: Vec<_> = ports + let matching: Vec<_> = eligible .iter() .filter(|port| port.serial_number.as_deref() == Some(serial)) .collect(); return match matching.as_slice() { [] => Ok(None), - [only] => Ok(Some(only.name.clone())), + [only] => Ok(Some((**only).clone())), many => Err(FbuildError::DeployFailed(format!( "multiple Raspberry Pi CDC interfaces have USB serial {serial}: {}", many.iter() @@ -77,21 +117,23 @@ pub(super) fn select_cdc_candidate( }; } if let Some(old) = previous_port { - if ports.iter().any(|port| port.name == old) { - return Ok(Some(old.to_string())); + if let Some(port) = eligible.iter().find(|port| port.name == old) { + return Ok(Some((*port).clone())); } } - let new_names: Vec<_> = ports + let new_ports: Vec<_> = eligible .iter() .filter(|port| !before.contains(&port.name)) - .map(|port| port.name.clone()) .collect(); - match new_names.as_slice() { + match new_ports.as_slice() { [] => Ok(None), - [only] => Ok(Some(only.clone())), + [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(", ") + many.iter() + .map(|port| port.name.as_str()) + .collect::>() + .join(", ") ))), } } @@ -99,23 +141,32 @@ pub(super) fn select_cdc_candidate( #[cfg(test)] mod tests { use super::*; + use fbuild_serial::ports::PortHealth; fn cdc(name: &str, serial_number: Option<&str>) -> PicoCdcPort { + cdc_with_health(name, serial_number, PortHealth::Unknown) + } + + fn cdc_with_health(name: &str, serial_number: Option<&str>, health: PortHealth) -> PicoCdcPort { PicoCdcPort { name: name.to_string(), serial_number: serial_number.map(str::to_string), - health: fbuild_serial::ports::PortHealth::Unknown, - instance_id: None, + health, + instance_id: Some(format!("USB\\VID_2E8A&PID_000A\\{name}")), parent_instance_id: None, } } + fn selected_name(candidate: Option) -> Option { + candidate.map(|port| port.name) + } + #[test] fn changed_cdc_port_is_returned_instead_of_stale_port() { let before = BTreeSet::from(["COM7".to_string()]); let after = vec![cdc("COM12", Some("PICO-1"))]; assert_eq!( - select_cdc_candidate(Some("COM7"), None, &before, &after).unwrap(), + selected_name(select_cdc_candidate(Some("COM7"), None, &before, &after).unwrap()), Some("COM12".to_string()) ); } @@ -125,7 +176,9 @@ mod tests { let before = BTreeSet::from(["COM7".to_string()]); let after = vec![cdc("COM12", Some("PICO-1")), cdc("COM13", Some("PICO-2"))]; assert_eq!( - select_cdc_candidate(Some("COM7"), Some("PICO-1"), &before, &after).unwrap(), + selected_name( + select_cdc_candidate(Some("COM7"), Some("PICO-1"), &before, &after).unwrap() + ), Some("COM12".to_string()) ); assert_eq!( @@ -152,4 +205,123 @@ mod tests { .contains("multiple new Raspberry Pi CDC ports") ); } + + #[test] + fn phantom_serial_match_is_never_selected() { + let phantom = cdc_with_health( + "COM12", + Some("5303284720C4641C"), + PortHealth::Phantom { + problem_code: None, + status: None, + }, + ); + let result = select_cdc_candidate( + Some("COM12"), + Some("5303284720C4641C"), + &BTreeSet::new(), + &[phantom], + ) + .unwrap(); + assert_eq!(result, None); + } + + #[test] + fn present_problem_previous_port_is_never_reselected() { + let broken = cdc_with_health( + "COM12", + None, + PortHealth::PresentProblem { + problem_code: 31, + status: None, + }, + ); + let before = BTreeSet::from(["COM12".to_string()]); + let result = select_cdc_candidate(Some("COM12"), None, &before, &[broken]).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn healthy_record_wins_over_phantom_history_with_same_serial() { + let ports = vec![ + cdc_with_health( + "COM12", + Some("5303284720C4641C"), + PortHealth::Phantom { + problem_code: None, + status: None, + }, + ), + cdc_with_health( + "COM27", + Some("5303284720C4641C"), + PortHealth::HealthyPresent, + ), + ]; + let selected = select_cdc_candidate( + Some("COM12"), + Some("5303284720C4641C"), + &BTreeSet::from(["COM12".to_string()]), + &ports, + ) + .unwrap() + .expect("the healthy renumbered endpoint must be selected"); + assert_eq!(selected.name, "COM27"); + assert_eq!(selected.health, PortHealth::HealthyPresent); + } + + #[test] + fn unhealthy_new_port_does_not_create_ambiguity() { + let ports = vec![ + cdc_with_health( + "COM12", + None, + PortHealth::Phantom { + problem_code: None, + status: None, + }, + ), + cdc_with_health("COM27", None, PortHealth::HealthyPresent), + ]; + assert_eq!( + selected_name(select_cdc_candidate(None, None, &BTreeSet::new(), &ports).unwrap()), + Some("COM27".to_string()) + ); + } + + #[test] + fn explicit_selector_matching_a_phantom_fails_with_health_details() { + let ports = vec![cdc_with_health( + "COM12", + Some("5303284720C4641C"), + PortHealth::Phantom { + problem_code: Some(45), + status: None, + }, + )]; + for selector in ["COM12", "SER=5303284720C4641C"] { + let message = resolve_requested_runtime_target(selector, &ports) + .unwrap_err() + .to_string(); + assert!( + message.contains("health phantom"), + "missing health: {message}" + ); + assert!( + message.contains("problem code 45"), + "missing code: {message}" + ); + assert!( + message.contains("USB\\VID_2E8A&PID_000A\\COM12"), + "missing instance: {message}" + ); + } + } + + #[test] + fn unknown_health_remains_eligible_for_explicit_selection() { + let ports = vec![cdc("COM5", Some("PICO-9"))]; + assert!(resolve_requested_runtime_target("COM5", &ports).is_ok()); + assert!(resolve_requested_runtime_target("SER=PICO-9", &ports).is_ok()); + } } diff --git a/crates/fbuild-deploy/src/wchisp.rs b/crates/fbuild-deploy/src/wchisp.rs index b235dc44..ef0febb1 100644 --- a/crates/fbuild-deploy/src/wchisp.rs +++ b/crates/fbuild-deploy/src/wchisp.rs @@ -217,7 +217,7 @@ impl crate::Deployer for WchispDeployer { "firmware flashed through wchisp".to_string() } else { format!( - "wchisp failed (exit code {}). If no USB-ISP device was found, hold BOOT0/Download while resetting; on Windows install a WinUSB/WCH USB driver for VID:PID 4348:55e0 or 1a86:55e0.{}", + "wchisp failed (exit code {}). If no USB-ISP device was found, hold BOOT0/Download while resetting; on Windows install a WinUSB/WCH USB driver for the WCH USB-ISP bootloader device (identify it with `fbuild port scan`).{}", result.exit_code, if result.stderr.is_empty() { "" diff --git a/crates/fbuild-library/src/library/ch32v_core.rs b/crates/fbuild-library/src/library/ch32v_core.rs index 3383a56f..3aca544a 100644 --- a/crates/fbuild-library/src/library/ch32v_core.rs +++ b/crates/fbuild-library/src/library/ch32v_core.rs @@ -220,5 +220,4 @@ mod tests { let result = Ch32vCores::validate(tmp.path()); assert!(result.is_err()); } - } diff --git a/crates/fbuild-serial/src/boards.rs b/crates/fbuild-serial/src/boards.rs index 59966294..f052a252 100644 --- a/crates/fbuild-serial/src/boards.rs +++ b/crates/fbuild-serial/src/boards.rs @@ -415,7 +415,10 @@ pub enum ResetMethod { /// /// # Examples /// -/// ``` +/// (`no_run`: resolves through the FastLED/boards registry, which is +/// fetched into the cache at runtime and absent on doctest hosts.) +/// +/// ```no_run /// use fbuild_serial::boards::board_hint; /// /// assert!(board_hint(0x303A, 0x1001) @@ -455,7 +458,10 @@ pub fn board_hint(vid: u16, pid: u16) -> Option { /// /// # Examples /// -/// ``` +/// (`no_run`: resolves through the FastLED/boards registry, which is +/// fetched into the cache at runtime and absent on doctest hosts.) +/// +/// ```no_run /// use fbuild_serial::boards::vcom_for_env; /// /// assert_eq!(vcom_for_env("lpc845brk"), Some((0x16C0, 0x0483))); @@ -521,7 +527,10 @@ fn parse_exact_identity(identity: &str) -> Option<(u16, u16)> { /// /// # Examples /// -/// ``` +/// (`no_run`: resolves through the FastLED/boards registry, which is +/// fetched into the cache at runtime and absent on doctest hosts.) +/// +/// ```no_run /// use fbuild_serial::boards::{family_for_vid_pid, BoardFamily}; /// /// assert_eq!(family_for_vid_pid(0x303A, 0x1001), Some(BoardFamily::Esp32NativeUsbCdc)); diff --git a/crates/fbuild-serial/src/ports.rs b/crates/fbuild-serial/src/ports.rs index 52c63665..11b52e2f 100644 --- a/crates/fbuild-serial/src/ports.rs +++ b/crates/fbuild-serial/src/ports.rs @@ -96,6 +96,11 @@ impl DetectedPort { } } +// The classification pipeline below is fed by the Windows PnP enumeration in +// `imp` and exercised cross-platform by `health_tests`; on non-Windows, +// non-test builds it has no production caller, which `-D warnings` would +// otherwise turn into a hard error. +#[cfg_attr(not(windows), allow(dead_code))] #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PnpObservation { Present { status: u32, problem_code: u32 }, @@ -103,6 +108,7 @@ enum PnpObservation { Unknown, } +#[cfg_attr(not(windows), allow(dead_code))] fn classify_port_health(observation: PnpObservation) -> PortHealth { match observation { PnpObservation::Present { @@ -124,6 +130,7 @@ fn classify_port_health(observation: PnpObservation) -> PortHealth { } } +#[cfg_attr(not(windows), allow(dead_code))] fn health_for_endpoint(observation: PnpObservation, is_usb: bool) -> PortHealth { if is_usb { classify_port_health(observation) diff --git a/crates/fbuild-toolchain/src/toolchain/riscv.rs b/crates/fbuild-toolchain/src/toolchain/riscv.rs index f10be4e8..1755b751 100644 --- a/crates/fbuild-toolchain/src/toolchain/riscv.rs +++ b/crates/fbuild-toolchain/src/toolchain/riscv.rs @@ -139,6 +139,7 @@ impl RiscvToolchain { } fn get_gcc_multilib_dir(&self, march: &str, mabi: &str) -> Option { + // allow-direct-spawn: short synchronous GCC capability probe (-print-multi-directory). let output = Command::new(self.get_gcc_path()) .args([ format!("-march={march}"), diff --git a/dylints/ban_raw_path_prefix_compare/src/allowlist.txt b/dylints/ban_raw_path_prefix_compare/src/allowlist.txt index 03c32184..af72b823 100644 --- a/dylints/ban_raw_path_prefix_compare/src/allowlist.txt +++ b/dylints/ban_raw_path_prefix_compare/src/allowlist.txt @@ -38,3 +38,8 @@ crates/fbuild-library-select/src/lib.rs crates/fbuild-packages-fetch/src/cache.rs crates/fbuild-packages-fetch/src/disk_cache/paths.rs crates/fbuild-paths/src/lib.rs + +# WalkDir-derived file paths are stripped against the exact root spelling +# they were enumerated from (same-normalized by construction); the relative +# result feeds a content hash, not a path-keyed cache lookup. +crates/fbuild-build-esp/src/esp32/orchestrator/framework_library_cache.rs diff --git a/dylints/ban_raw_path_prefix_compare/src/lib.rs b/dylints/ban_raw_path_prefix_compare/src/lib.rs index 94cc9829..e2dcf78e 100644 --- a/dylints/ban_raw_path_prefix_compare/src/lib.rs +++ b/dylints/ban_raw_path_prefix_compare/src/lib.rs @@ -5,9 +5,9 @@ extern crate rustc_hir; extern crate rustc_span; use rustc_errors::DiagDecorator; -use rustc_hir::{def::Res, Expr, ExprKind}; +use rustc_hir::{Expr, ExprKind, def::Res}; use rustc_lint::{LateContext, LateLintPass, LintContext}; -use rustc_span::{symbol::Symbol, FileName, RemapPathScopeComponents}; +use rustc_span::{FileName, RemapPathScopeComponents, symbol::Symbol}; dylint_linting::declare_late_lint! { /// ### What it does @@ -78,6 +78,9 @@ const BANNED_METHOD_PATHS: &[&[&str]] = &[ &["std", "path", "Path", "strip_prefix"], ]; +// allowlist.txt is a compile-time input; CI build caches have been observed +// serving a stale compiled lint after allowlist-only edits, so pair +// allowlist updates with a source touch when the gate must move. const ALLOWLIST: &str = include_str!("allowlist.txt"); /// Production-code scope. Only files whose path contains BOTH diff --git a/dylints/ban_raw_subprocess/src/allowlist.txt b/dylints/ban_raw_subprocess/src/allowlist.txt index 7fd537da..3a52c6d1 100644 --- a/dylints/ban_raw_subprocess/src/allowlist.txt +++ b/dylints/ban_raw_subprocess/src/allowlist.txt @@ -45,3 +45,8 @@ crates/fbuild-build-engine/src/zccache.rs # `containment::tokio_spawn::spawn_contained` would be a no-op anyway; # documented inline at each call site. crates/fbuild-cli/src/cli/clang_tools.rs + +# Short synchronous GCC capability probe (-print-multi-directory) during +# RISC-V toolchain introspection; no long-lived child, nothing to contain. +# Documented inline at the call site. +crates/fbuild-toolchain/src/toolchain/riscv.rs diff --git a/dylints/ban_raw_subprocess/src/lib.rs b/dylints/ban_raw_subprocess/src/lib.rs index b343c62d..487f5a6f 100644 --- a/dylints/ban_raw_subprocess/src/lib.rs +++ b/dylints/ban_raw_subprocess/src/lib.rs @@ -5,9 +5,9 @@ extern crate rustc_hir; extern crate rustc_span; use rustc_errors::DiagDecorator; -use rustc_hir::{def::Res, Expr, ExprKind}; +use rustc_hir::{Expr, ExprKind, def::Res}; use rustc_lint::{LateContext, LateLintPass, LintContext}; -use rustc_span::{symbol::Symbol, FileName, RemapPathScopeComponents}; +use rustc_span::{FileName, RemapPathScopeComponents, symbol::Symbol}; dylint_linting::declare_late_lint! { /// ### What it does