Repository navigation
Description
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)) # freshA 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
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. - 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
- fbuild 2.5.23, Linux, two USB-CDC devices on one host.
- Related: deploy/port: accept a USB serial where --port takes a node path (unattended benches renumber) #1428 (this bench also needs serial-number port selection; node paths renumber).
- FastLED-side issues where this surfaced: follow-up: harden RP2350W peer OTA flow from #3863 FastLED#3956 (peer OTA never reaches an OTA step), test(rp2350): complete RP2350(W) loopback driver and subsystem HIL FastLED#3899.
🤖 Generated with Claude Code
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTriage
Activity
fix(autoresearch): name the process holding a port that will not open
zackees commentedon Sep 10, 2026
Correction and generalisation: the daemon retains whichever port it last used
My original report framed this as the daemon holding the companion's port. That framing was too narrow, and the narrow version would invite too narrow a fix.
A single-device run, where only one port is ever touched:
The daemon retains
ttyACM0— the only port in play, and the run passed. So the retention is not specific to a companion, a second device, or an ESP32-C6.fbuild-daemonkeeps an exclusive fd on the last port it used, always.Why only the second device ever fails
That single rule explains the whole asymmetry, with no device-specific reasoning:
EBUSY→attach failed: open_port(...) exceeded 3s; serial driver may be wedged.Across every occurrence on this bench the failure was the companion and never the primary, and I spent a long time looking for something specific to that board — its USB-JTAG CDC, its post-flash reset, its re-enumeration behaviour. None of it was relevant. The board is simply the one that happens to be second.
Falsifiable prediction: reverse the deploy order and the failure follows the order, not the hardware — the RP2350W would fail to attach and the ESP32-C6 would attach cleanly.
Why the single-device case looks fine
It has the same retention; nothing simply tries to reopen that port afterwards, so the leak is invisible. Any workflow that deploys, finishes, and then re-attaches to the same port hits it — which is exactly what a second device makes routine.
Bearing on the request
Request 1 in the original report should be read in this stronger form: release the fd whenever an operation completes, whichever port it was, not merely "release the companion's port". Retention was observed to expire after roughly 5 s once and to persist indefinitely in other runs, so it is not reliably bounded today either way.
Requests 2 and 3 stand as written. Request 2 in particular — report
EBUSYas contention and name the holder — would have collapsed this entire investigation to one step;serial driver may be wedgedsent it into the device and the USB stack instead.zackees commentedon Sep 10, 2026
Correction: fd retention alone does not block opens — my central claim was wrong
I reported that
fbuild-daemonretaining a handle is what makes the nextopen()fail withEBUSY. That is not correct, and I would rather say so than leave a wrong mechanism in an issue someone may act on.A short single-device run, immediately afterwards:
So the daemon can hold an fd on a port while another process opens it without difficulty. Retention is real, and by itself it is harmless.
What is actually established
open()open()returnsEBUSYafter a failed two-device attach[Errno 16]from plainos.open()EBUSYThe
EBUSYis real and reproducible, but it correlates with the two-device flow, not with mere fd retention. Something in that path makes the port exclusively locked where the single-device path does not — plausibly two monitors open on the same port at once (the deploy's and the freshly-constructed companion interface's), or an exclusive-mode flag set on one of them. I have not isolated which, and I am not going to guess a third time.What still stands
EBUSY, and reporting it asserial driver may be wedgedis misleading — the device is healthy and enumerated throughout. Request 2 of the original report is unaffected and remains the highest-value fix: name the holder and call it contention.What I withdraw
Request 1 as I phrased it — "release the fd when an operation completes" — was premised on retention being the cause. Retention may still be worth tidying, but it is not what breaks the attach, and fixing it alone would likely not fix this. The real question is why the two-device path ends up with an exclusive lock on a port that the single-device path leaves freely openable.
Apologies for the churn; the earlier framing was measured too narrowly and I generalised it before testing the single-device case.
zackees commentedon Sep 10, 2026
Mechanism isolated: the
EBUSYisTIOCEXCL, reproduced from first principlesFollowing the correction above, here is the actual mechanism, derived without guessing.
The tty permits multiple simultaneous openers. Exclusive mode is what produces the failure:
So
attach failed: open_port(...) exceeded 3s; serial driver may be wedgedis a secondopen()against a port on which some component has setTIOCEXCLand not cleared it. That reconciles every observation that previously looked contradictory:open()succeeds in 0.0 sopen()returnsEBUSYafter a failed two-device attachTIOCEXCLis advisory-until-set and process-scoped, which is why it survives as long as the holder lives and vanishes the moment it exits — matching the "sometimes released in ~5 s, sometimes not" behaviour I reported earlier. That was not a timing race; it was whether the holding handle happened to be closed.Who sets it — stated as a hypothesis, not a finding
I have not isolated the component. Ruling out the obvious candidate: pyserial 3.5 implements its
exclusiveparameter withflock(LOCK_EX), notTIOCEXCL, and does not lock by default — so pyserial-based tooling is not the likely source.A plausible candidate is the Rust serial layer:
serialport-rssetsTIOCEXCLon Unix when opening. If a monitor or deploy handle is opened through it and kept alive, every lateropen()on that port getsEBUSY. That is a hypothesis from library behaviour, not something I measured, and you are far better placed to confirm it.Suggested fix, revised
TIOCNXCL) or close the handle when an operation completes. This supersedes my withdrawn Request 1: the problem is not that an fd lingers, it is that a exclusive fd lingers.EBUSYas contention and name the holder (unchanged, and still the highest-value change).serial driver may be wedgedis what sent this investigation through the device, the USB stack, CDC re-enumeration, and two wrong root causes before a five-lineTIOCEXCLtest settled it.Reproducer, if useful — no hardware-specific setup, any tty will do:
fix(autoresearch): report a locked port only when it is actually locked
zackees commentedon Sep 10, 2026
fbuild daemon locksreports the defect directly: a port held for a dead clientI had been reconstructing this from
/proc/*/fdand a hand-rolledTIOCEXCLtest. fbuild already reports it, and its own output is decisive. Immediately after a successful single-device run (rc=0,RPC smoke PASS):The daemon knows the owning client is dead — it prints
(dead)— and the port is still[HELD]withopen=true,readers=0,writer=none. Concurrently, a plainos.open()on that port returnedDevice or resource busy.So the lock outlives the client that took it, and the daemon has the information needed to reclaim it at the moment it renders that line.
It is time-limited, not permanent
Re-checking the same port a couple of minutes later,
os.open()succeeded. That resolves the inconsistency in my earlier reports — I described release as "about 5 s once and not at all in other runs", and treated the difference as significant. It was not: the lock is reclaimed eventually, and I was sampling at different points in that window. Apologies for the noise.The practical consequence stands, because the window overlaps exactly the moment a caller needs the port: a two-device flow deploys the companion, then immediately constructs an interface for it and hits the held lock.
fbuild daemon restartclears it immediatelyUseful as a workaround, and it confirms the lock is daemon-held state rather than anything at the device or driver level.
What this changes about the earlier reports
TIOCEXCL-attribution question. I showedTIOCEXCLreproduces the identicalEBUSYand speculated about which library sets it. That is beside the point: the daemon is holding the port open for a dead client, andlockssays so. Whether it additionally sets exclusive mode is an implementation detail you can see directly.(dead)annotation shows the condition is already detected.attach failed: open_port(...) exceeded 3s; serial driver may be wedgeddescribes a device fault for whatlockscorrectly reports as a stale lock. Pointing that message atfbuild daemon locks— or simply saying "port held by a dead client, runfbuild daemon restart" — would have ended this investigation on day one instead of sending it through the device, the USB stack, and CDC re-enumeration.fix(autoresearch): reclaim a port the daemon holds for an exited client
diag(autoresearch): say when a port is locked, and by whom (#4259)
fix(serial): name the port holder instead of blaming the driver on op…
fix(serial): reopen instead of reusing a session whose reader exited (#…
fix(daemon): always release the ws writer on cleanup so idle close ca…
refactor(serial): make port-holder matching pure so it needs no cfg o…
fix(serial): use NormalizedPath and async canonicalize in port_holder…
14 remaining items
fix(serial): use NormalizedPath and async canonicalize in port_holder…
fix(serial): name the port holder instead of blaming the driver on op…
fix(serial): reopen instead of reusing a session whose reader exited (#…
fix(daemon): always release the ws writer on cleanup so idle close ca…
refactor(serial): make port-holder matching pure so it needs no cfg o…
fix(serial): use NormalizedPath and async canonicalize in port_holder…
fix(serial): address review — keep alias on stale reopen, bound the /…
fix(serial): stop holding ports after deploy; name the holder on EBUSY (
fix(daemon): reclaim serial sessions whose clients have all exited (#…
fix(daemon): reclaim serial sessions whose clients have all exited (#…
zackees commentedon Oct 25, 2026
#1727 (merged) implements request 1. The daemon now closes, every 2 s, any serial session in which every client has a recorded pid and none of those processes is alive. This is the
owner=<id> writer=none readers=0 … (dead)state in thelocksoutput above.Root cause:
open_portpublishes the session before the client attaches a reader. A client that dies in that window, or whose WS attach times out, never triggers the detach-driven idle close.Sessions with a live or unknown-pid client are kept, so the deploy → attach hand-off is unaffected. Request 2 (timeout text naming the dead client) is still open. I could not reproduce the two-device window locally, so the new path is unit-tested only. A rerun of the two-device flow on a release containing #1727 would confirm it.
fix(daemon): name the exited client when an attach times out on a hel…
fix(daemon): name the exited client when an attach times out on a hel…
fix(daemon): name the exited client when an attach times out on a hel…