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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/fbuild-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,9 @@ sha2 = { workspace = true }
tempfile = { workspace = true }
walkdir = { workspace = true }
owo-colors = { workspace = true }

# O_NONBLOCK / O_NOCTTY for the read-only serial probe in `port doctor`
# (FastLED/fbuild#1424). Unix-only: the probe itself is cfg'd to Linux.
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
semver = { workspace = true }
1 change: 1 addition & 0 deletions crates/fbuild-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub mod serial_probe;
pub mod show;
pub mod symbols_cmd;
pub mod sync_cmd;
pub mod udev;
// #1148 supplies the helper foundation; #1147 is deliberately its first
// production consumer once RP2040 deployment can emit a typed request.
#[allow(
Expand Down
147 changes: 147 additions & 0 deletions crates/fbuild-cli/src/cli/port_doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ pub struct PortDiagnosis {
/// a single-port query — see `query_last_seen_secs` for why.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_seen_secs_ago: Option<i64>,
/// Whether this process can actually open the port.
///
/// `None` when not probed. Never silently `true`: a port the host lists
/// as healthy can still be unopenable, and reporting that as fine is the
/// failure this field exists to stop (FastLED/fbuild#1424).
#[serde(skip_serializing_if = "Option::is_none")]
pub openable: Option<bool>,
}

/// What the diagnosis means and what to do about it.
Expand Down Expand Up @@ -68,10 +75,70 @@ pub fn diagnose(port: &DetectedPort, power_rows: &[(String, bool)]) -> PortDiagn
parent_instance_id: port.parent_instance_id.clone(),
suspend_allowed: suspend_for_ancestors(power_rows, &port.ancestor_instance_ids),
last_seen_secs_ago: None,
openable: probe_openable(&port.info.port_name),
}
}

/// Whether the current process can open `port`, or `None` where the question
/// is not meaningful.
///
/// Linux only. Elsewhere serial access is not group-gated the same way and a
/// speculative open would be a side effect in a command documented as
/// strictly read-only. Opening for read is enough to surface `EACCES` and
/// does not disturb a device: no DTR/RTS assertion, no write.
#[cfg(target_os = "linux")]
pub fn probe_openable(port: &str) -> Option<bool> {
use std::io::ErrorKind;
use std::os::unix::fs::OpenOptionsExt;
// O_NONBLOCK: without CLOCAL set, a terminal open blocks until carrier
// detect is asserted, which would hang a command documented as a quick
// read-only diagnostic. O_NOCTTY: never let the probe acquire a
// controlling terminal -- signals delivered to that terminal would then
// reach fbuild. Both matter here: an open on a contended port was
// measured at 13.3 s on the bench that motivated FastLED/fbuild#1424.
match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY)
.open(port)
{
Ok(_) => Some(true),
Err(e) if e.kind() == ErrorKind::PermissionDenied => Some(false),
// Busy, absent, or anything else is a different question that the
// presence/problem-code verdict already covers. Claiming "not
// openable" here would blame permissions for an unrelated fault.
Err(_) => None,
}
}

#[cfg(not(target_os = "linux"))]
pub fn probe_openable(_port: &str) -> Option<bool> {
None
}

pub fn verdict(diagnosis: &PortDiagnosis) -> Verdict {
// Checked before presence/problem-code: a port can be attached, healthy,
// and still impossible to open. That combination previously rendered as
// "attached and healthy" with an empty remedy, so `fbuild deploy` would
// flash successfully and then time out reopening the port it had just
// reported healthy — with recovery advice about cables and BOOTSEL that
// had nothing to do with the cause (FastLED/fbuild#1424).
if diagnosis.openable == Some(false) {
return Verdict {
summary: "attached, but this process cannot open the port (permission denied)"
.to_string(),
remedy: concat!(
"serial nodes are typically root:dialout 0660 and your user ",
"is not in that group. Generate rules with `fbuild port udev`, ",
"install them as /etc/udev/rules.d/99-fbuild.rules, then ",
"`sudo udevadm control --reload-rules && sudo udevadm trigger`. ",
"Adding your user to the group works too, but needs a fresh ",
"login. A one-shot chmod does not hold: deploy re-enumerates ",
"the board and udev recreates the node",
)
.to_string(),
needs_hands: true,
};
}
match (diagnosis.presence, diagnosis.problem_code) {
// The case this command exists for. A phantom record is not a fault.
(Some(false), _) => Verdict {
Expand Down Expand Up @@ -593,9 +660,89 @@ mod tests {
parent_instance_id: None,
suspend_allowed: None,
last_seen_secs_ago: None,
openable: None,
}
}

fn diag_openable(presence: Option<bool>, openable: Option<bool>) -> PortDiagnosis {
PortDiagnosis {
openable,
..diag(presence, None)
}
}

/// The bug this branch exists for: a port the host lists as healthy but
/// that cannot be opened used to render as "attached and healthy" with an
/// empty remedy, so deploy flashed fine and then timed out reopening it.
#[test]
fn unopenable_port_is_not_reported_as_healthy() {
let v = verdict(&diag_openable(Some(true), Some(false)));
assert!(v.summary.contains("cannot open"), "got: {}", v.summary);
assert!(
v.summary.contains("permission denied"),
"got: {}",
v.summary
);
assert!(
!v.summary.contains("attached and healthy"),
"got: {}",
v.summary
);
assert!(
!v.remedy.is_empty(),
"an unopenable port must carry a remedy"
);
assert!(v.needs_hands);
}

/// The remedy has to name a fix that survives re-enumeration. A one-shot
/// chmod is wiped when deploy cycles the board through BOOTSEL.
#[test]
fn permission_remedy_points_at_udev_not_a_chmod() {
let v = verdict(&diag_openable(Some(true), Some(false)));
assert!(v.remedy.contains("udev"), "got: {}", v.remedy);
assert!(v.remedy.contains("fbuild port udev"), "got: {}", v.remedy);
assert!(
!v.remedy.contains("cable"),
"cables are unrelated: {}",
v.remedy
);
assert!(
!v.remedy.contains("BOOTSEL"),
"BOOTSEL is unrelated: {}",
v.remedy
);
}

/// Permission state must not mask the absent-board verdict, which is the
/// case this whole command was written for.
#[test]
fn openable_port_still_reports_the_presence_verdict() {
let v = verdict(&diag_openable(Some(true), Some(true)));
assert!(
v.summary.contains("attached and healthy"),
"got: {}",
v.summary
);

let absent = verdict(&diag_openable(Some(false), None));
assert!(
absent.summary.contains("not attached"),
"got: {}",
absent.summary
);
}

/// An unprobed port must fall through untouched — `None` is "unknown",
/// never "fine".
#[test]
fn unprobed_openability_changes_nothing() {
assert_eq!(
verdict(&diag_openable(Some(true), None)),
verdict(&diag(Some(true), None))
);
}

/// The headline: an absent board must be called out as absent, and the
/// remedy must be "plug it in" — never a recovery procedure.
#[test]
Expand Down
46 changes: 46 additions & 0 deletions crates/fbuild-cli/src/cli/port_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ pub enum PortAction {
#[arg(long, conflicts_with_all = ["port", "fix", "dry_run"])]
hub: Option<String>,
},
/// Print udev rules granting serial access for every vendor in the
/// FastLED/boards registry (FastLED/fbuild#1424).
///
/// Prints rather than installs. On NixOS `/etc` is generated from
/// declarative config, so a written file there is out-of-band and liable
/// to be clobbered -- those users need the content for
/// `services.udev.extraRules`, not a mutation. Everywhere else, redirect
/// it yourself so the privileged write stays explicit.
Udev {
/// Group to grant access to. Defaults to `plugdev`, which unlike
/// `dialout` does not also confer modem/PPP access.
#[arg(long)]
group: Option<String>,
},
}

/// Top-level entry — dispatcher calls this.
Expand All @@ -94,6 +108,38 @@ pub fn run_port(action: PortAction) -> Result<()> {
super::port_doctor::run(port.as_deref(), hub.as_deref(), json)
}
}
PortAction::Udev { group } => run_udev(group.as_deref()),
}
}

/// Emit udev rules derived from the ingested registry.
fn run_udev(group: Option<&str>) -> Result<()> {
use super::udev::{DEFAULT_UDEV_GROUP, UDEV_RULES_FILENAME, render_udev_rules};

// Refresh first, exactly as `scan` does: rules generated from a stale or
// absent overlay would silently omit vendors the user has plugged in.
populate_online_overlay();

let vids = fbuild_core::usb::online_vendor_vids();
let group = group.unwrap_or(DEFAULT_UDEV_GROUP);
match render_udev_rules(&vids, group) {
Some(rules) => {
print!("{rules}");
eprintln!(
"# {} vendor rule(s) from the FastLED/boards registry. \
Install as /etc/udev/rules.d/{UDEV_RULES_FILENAME}.",
vids.len()
);
Ok(())
}
// Refuse rather than emit an empty file: one that looks configured
// and grants nothing is worse than none at all.
None => Err(FbuildError::Other(
"USB vendor registry is empty — cannot generate udev rules. Run \
`fbuild port scan` once with network access to populate the \
FastLED/boards cache, then retry."
.to_string(),
)),
}
}

Expand Down
Loading
Loading