Summary
fbuild-daemon keeps an exclusive handle on a device's serial port after the operation that opened it. Every subsequent open() on that port fails with EBUSY, and the failure is surfaced as:
attach failed: open_port(/dev/ttyACM2) exceeded 3s; serial driver may be wedged
The driver is not wedged and the device is not wedged. The port is simply held by the daemon.
Proof
Measured on a Linux bench with an RP2350W and an ESP32-C6 attached, during FastLED's two-device --net-peer --ota autoresearch flow.
1. The holder is identified. Scanning /proc/*/fd for the port, while no run is active:
pid=1427887 fbuild-daemon holds: ttyACM2
Note it holds ttyACM2 (the companion) and not ttyACM0 (the primary).
2. The failure is EBUSY, not a driver fault. A plain os.open() bypassing all tooling:
plain open(): OSError: [Errno 16] Device or resource busy: '/dev/ttyACM2'
3. The device is healthy throughout. Still enumerated, same node:
C6 303a:1001 devnum=20 tty=ttyACM2
4. Removing the holder fixes it immediately. Killing the process that holds the fd:
holders: none
plain open(): SUCCESS
That is a complete causal chain: holder present → EBUSY → attach fails; holder gone → open succeeds.
5. A single-device flow on the same port passes. fbuild-backed autoresearch against the C6 alone, which also flashes it and attaches immediately afterwards:
RESULT: RPC smoke PASS (discovery, help, ping, payload, status, drivers, testNoSerial, error handling)
So neither the board, its USB-CDC, nor the port is at fault. The failure needs a second serial interface on the same port.
Why it is always the second device
In the caller, the primary reuses the interface built during its deploy, while the companion constructs a fresh one:
primary = RpcClient(upload_port, serial_interface=serial_iface) # reused
peer = RpcClient(peer_upload_port,
serial_interface=create_serial_interface(peer_upload_port)) # fresh
A fresh interface means a fresh monitor, which contends with the handle the daemon still holds from that device's deploy. The primary never contends because it never opens a second one. Across every captured occurrence on this bench the failure is on the companion, never the primary.
Retrying makes it worse rather than better, because closing the client nulls its interface and the next attempt constructs another one:
ESP32-C6 did not answer on /dev/ttyACM2 (attempt 1/3: No response with ID 1 within 10.0s); reconnecting
ESP32-C6 did not answer on /dev/ttyACM2 (attempt 2/3: No response with ID 2 within 10.0s); reconnecting
attach failed: open_port(/dev/ttyACM2) exceeded 3s; serial driver may be wedged
Attempts 1–2 reach a session that holds the port but answers nothing; by attempt 3 the port cannot be opened at all. Backing off further (2 s → 4 s → 6 s) does not recover it.
Requests
- Release the port when an operation completes. A deploy or monitor that has finished should not retain an exclusive fd. Release was observed within ~5 s in one instance and not at all in others, so it is not reliable today.
- Report
EBUSY as contention, naming the holder. serial driver may be wedged sent this investigation to the device and the USB layer for a long time before /proc/*/fd showed a plain lock. Something like port held by fbuild-daemon (pid N) would have made it immediate.
- Make re-attaching to a port the daemon already owns non-destructive — either hand back the existing session or release and re-acquire cleanly, so a caller opening a second interface does not deadlock against the first.
Context
🤖 Generated with Claude Code
https://claude.ai/code/session_01KkufoNxfnNRU9psT3R9F51
Summary
fbuild-daemonkeeps an exclusive handle on a device's serial port after the operation that opened it. Every subsequentopen()on that port fails withEBUSY, and the failure is surfaced as:The driver is not wedged and the device is not wedged. The port is simply held by the daemon.
Proof
Measured on a Linux bench with an RP2350W and an ESP32-C6 attached, during FastLED's two-device
--net-peer --otaautoresearch flow.1. The holder is identified. Scanning
/proc/*/fdfor the port, while no run is active:Note it holds
ttyACM2(the companion) and notttyACM0(the primary).2. The failure is EBUSY, not a driver fault. A plain
os.open()bypassing all tooling:3. The device is healthy throughout. Still enumerated, same node:
4. Removing the holder fixes it immediately. Killing the process that holds the fd:
That is a complete causal chain: holder present → EBUSY → attach fails; holder gone → open succeeds.
5. A single-device flow on the same port passes.
fbuild-backed autoresearch against the C6 alone, which also flashes it and attaches immediately afterwards:So neither the board, its USB-CDC, nor the port is at fault. The failure needs a second serial interface on the same port.
Why it is always the second device
In the caller, the primary reuses the interface built during its deploy, while the companion constructs a fresh one:
A fresh interface means a fresh monitor, which contends with the handle the daemon still holds from that device's deploy. The primary never contends because it never opens a second one. Across every captured occurrence on this bench the failure is on the companion, never the primary.
Retrying makes it worse rather than better, because closing the client nulls its interface and the next attempt constructs another one:
Attempts 1–2 reach a session that holds the port but answers nothing; by attempt 3 the port cannot be opened at all. Backing off further (2 s → 4 s → 6 s) does not recover it.
Requests
EBUSYas contention, naming the holder.serial driver may be wedgedsent this investigation to the device and the USB layer for a long time before/proc/*/fdshowed a plain lock. Something likeport held by fbuild-daemon (pid N)would have made it immediate.Context
🤖 Generated with Claude Code
https://claude.ai/code/session_01KkufoNxfnNRU9psT3R9F51