Skip to content
Draft
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
161 changes: 157 additions & 4 deletions crates/mb-device/src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,18 @@ impl ProbeResult {
pub fn probe_usb_micro() -> ProbeResult {
#[cfg(target_os = "macos")]
{
match system_profiler_usb_text(std::time::Duration::from_secs(3)) {
Some(text) => match_usb_text(&text),
None => ProbeResult::absent(),
if let Some(text) = system_profiler_usb_text(std::time::Duration::from_secs(3)) {
let result = match_usb_text(&text);
if result.present {
return result;
}
}
// `system_profiler SPUSBDataType` returns empty on some macOS builds;
// `ioreg` still lists attached USB devices.
if let Some(text) = ioreg_usb_text() {
return match_ioreg_text(&text);
}
ProbeResult::absent()
}
#[cfg(not(target_os = "macos"))]
{
Expand Down Expand Up @@ -63,6 +71,69 @@ pub fn match_usb_text(raw: &str) -> ProbeResult {
ProbeResult::absent()
}

/// Match `ioreg -p IOUSB -l` text (decimal idVendor/idProduct or product name).
pub fn match_ioreg_text(raw: &str) -> ProbeResult {
let lower = raw.to_ascii_lowercase();

if let Some(pid) = ioreg_matching_pid(&lower) {
return ProbeResult {
present: true,
product_id: Some(pid),
};
}

if lower.contains("codex micro") || (lower.contains("work louder") && lower.contains("codex")) {
return ProbeResult {
present: true,
product_id: Some(CODEX_MICRO_PID),
};
}

ProbeResult::absent()
}

/// Find a supported VID/PID pair in `ioreg -p IOUSB -l` output.
///
/// Scoped per device node (`ioreg` starts each with `+-o <name>`) for the same
/// reason [`match_usb_text`] scopes to a record block: the ids only mean
/// anything together. A flat line scan would pair one device's vendor with the
/// next device's product — and would depend on `ioreg` emitting `idVendor`
/// before `idProduct`, which is a property-dictionary order it does not promise.
fn ioreg_matching_pid(lower: &str) -> Option<u16> {
// `split` on the node marker rather than `lines`, so the first fragment is
// the (device-less) header and every other fragment is exactly one node.
lower.split("+-o ").find_map(ioreg_node_matching_pid)
}

fn ioreg_node_matching_pid(node: &str) -> Option<u16> {
let mut vid = None;
let mut pid = None;
for line in node.lines() {
let line = line.trim();
// A malformed value must skip that property, never abort the scan: the
// Codex Micro may be listed after whatever failed to parse.
if let Some(rest) = line.strip_prefix("\"idvendor\" =") {
vid = vid.or_else(|| parse_ioreg_id(rest));
Comment on lines +112 to +116
} else if let Some(rest) = line.strip_prefix("\"idproduct\" =") {
pid = pid.or_else(|| parse_ioreg_id(rest));
}
}
if vid != Some(WL_VID) {
return None;
}
pid.filter(|pid| is_supported_pid(*pid))
}

/// `ioreg -l` prints these as decimal, but tolerate a `0x` form too rather than
/// silently treating a hex listing as "no device attached".
fn parse_ioreg_id(raw: &str) -> Option<u16> {
let value = raw.trim();
if let Some(hex) = value.strip_prefix("0x") {
return u16::from_str_radix(hex, 16).ok();
}
value.parse::<u16>().ok()
}

fn block_matching_pid(block: &str) -> Option<u16> {
let vid = parse_id_field(block, "vendor id")?;
if vid != WL_VID {
Expand Down Expand Up @@ -90,14 +161,30 @@ fn parse_hex_id(raw: &str) -> Option<u16> {
u16::from_str_radix(hex, 16).ok()
}

#[cfg(target_os = "macos")]
fn ioreg_usb_text() -> Option<String> {
use std::process::{Command, Stdio};

let output = Command::new("/usr/sbin/ioreg")
.args(["-p", "IOUSB", "-l"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
Comment on lines +164 to +178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound the ioreg invocation.

Command::output() waits indefinitely. When system_profiler fails/returns empty, a stalled ioreg now blocks the USB presence probe forever. Apply the same bounded-process behavior used for system_profiler_usb_text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/mb-device/src/probe.rs` around lines 164 - 178, Update ioreg_usb_text
to invoke ioreg with the same bounded-process mechanism used by
system_profiler_usb_text, preserving its existing arguments and success/output
handling while ensuring stalled processes terminate instead of blocking the USB
probe indefinitely.


#[cfg(target_os = "macos")]
fn system_profiler_usb_text(timeout: std::time::Duration) -> Option<String> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Instant;

let mut child = Command::new("system_profiler")
let mut child = Command::new("/usr/sbin/system_profiler")
.args(["SPUSBDataType", "-detailLevel", "mini"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
Expand Down Expand Up @@ -161,4 +248,70 @@ ESP Serial:
let r = match_usb_text("Something Codex Micro attached");
assert!(r.present);
}

#[test]
fn matches_ioreg_decimal_ids() {
let sample = r#"
| +-o Codex Micro@02100000
| "USB Product Name" = "Codex Micro"
| "idVendor" = 12346
| "idProduct" = 33632
"#;
let r = match_ioreg_text(sample);
assert!(r.present);
assert_eq!(r.product_id, Some(CODEX_MICRO_PID));
}

#[test]
fn matches_ioreg_name_fallback() {
let sample = r#""kUSBProductString" = "Codex Micro""#;
assert!(match_ioreg_text(sample).present);
}

/// A device whose ids do not parse must not hide devices listed after it.
#[test]
fn ioreg_unparseable_id_does_not_abort_the_scan() {
let sample = r#"
| +-o SomeHub@01000000
| "idVendor" = <unparseable>
| "idProduct" = not-a-number
| +-o Codex Micro@02100000
| "idVendor" = 12346
| "idProduct" = 33632
"#;
let r = match_ioreg_text(sample);
assert!(r.present, "a malformed earlier device hid the Micro");
assert_eq!(r.product_id, Some(CODEX_MICRO_PID));
}

/// Ids are only meaningful together, so they must come from one node.
#[test]
fn ioreg_does_not_pair_ids_across_devices() {
let sample = r#"
| +-o WorkLouderOther@01000000
| "idVendor" = 12346
| "idProduct" = 4097
| +-o UnrelatedVendor@02100000
| "idProduct" = 33632
"#;
// The supported PID belongs to a node with no Work Louder vendor id.
assert!(!match_ioreg_text(sample).present);
}

#[test]
fn ioreg_tolerates_property_order_and_hex() {
let sample = r#"
| +-o Codex Micro@02100000
| "idProduct" = 33632
| "idVendor" = 12346
"#;
assert!(match_ioreg_text(sample).present, "idProduct listed first");

let hex = r#"
| +-o Codex Micro@02100000
| "idVendor" = 0x303a
| "idProduct" = 0x8360
"#;
assert!(match_ioreg_text(hex).present, "hex-formatted ids");
}
}