diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index 34298416..b19638ff 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -1182,6 +1182,18 @@ pub async fn deploy( { Some(rx) => rx, None => { + // open_port created the session above; a close/open race can + // drop its broadcaster before attach_reader runs. Release the + // handle the same way the normal path below does, or it stays + // open and the next client gets EBUSY (FastLED/fbuild#1426). + ctx.serial_manager.detach_reader(&monitor_port, &request_id); + if !ctx.serial_manager.has_clients(&monitor_port) { + ctx.serial_manager.close_port_after_grace_if_idle( + &monitor_port, + &request_id, + std::time::Duration::from_secs(2), + ); + } return ( StatusCode::OK, Json(OperationResponse { @@ -1228,6 +1240,19 @@ pub async fn deploy( }; ctx.serial_manager.detach_reader(&monitor_port, &request_id); + // Detaching the reader alone leaves the session -- and its OS serial + // handle -- open forever, so the next client gets EBUSY on a board + // that is enumerated and perfectly healthy. The monitor and WebSocket + // cleanup paths both schedule the physical close here; this one did + // not, which is why the leak only showed up after a deploy. + // See FastLED/fbuild#1426. + if !ctx.serial_manager.has_clients(&monitor_port) { + ctx.serial_manager.close_port_after_grace_if_idle( + &monitor_port, + &request_id, + std::time::Duration::from_secs(2), + ); + } return match monitor_result { MonitorOutcome::Success(msg) => (