Skip to content

fix(daemon): enforce exclusive device leases across deploy and RPC workflows; add bounded sharing queue #1864

Open

Description

@zackees

Context

The daemon prevents two simultaneous flash operations, but an explicit fbuild device lease ... --track-serial does not reserve the board against another project's deploy. This breaks sharing one board between independent build/flash/RPC test workflows: a second caller can replace the firmware between the first caller's deploy and its validation.

Observed on Linux during FastLED/FastLED#5001 hardware validation, with two independent checkouts (fastled and fastled3) targeting the same ESP32-S3 at /dev/ttyACM1 through the production daemon.

Version: fbuild 2.5.49+git.98affea702ff (98affea702ff58e551124cda68f863659acebb7c). Source was also checked at current local main bebd679cb2decd3ac6cd860f413d1b9bbca5b904; the relevant device-manager/deploy/preemption files are unchanged between those revisions.

Two distinct observations:

  1. Concurrent deploy protection works, but does not queue. A deploy from fastled ended after 311 seconds of build work with:

    serial error: port /dev/ttyACM1 is in use: deploy (project /home/niteris/dev/fastled3/.build/fbuild/esp32s3) by request 521b9eed-ff9c-4695-b428-91c8bb288829 for 20s
    Deploy failed (fbuild) [311.0s]
    

    This is the intended fail-fast operation guard from Daemon lets a second deploy preempt an in-flight deploy on the same port (no per-port lease); contention misreported as 'serial driver may be wedged' #1461 / fix(serial): reject concurrent device operations #1502, not proof that two flashers overlapped.

  2. The explicit device lease is bypassed. The daemon log shows:

    2026-11-02T23:09:14.480681Z exclusive lease acquired on '/dev/ttyACM1' by client '7a3bf83c-4e00-4d81-b95e-62f02b8840de'
    2026-11-02T23:14:32.662980Z port closed port="/dev/ttyACM1" client_id="deploy_preemption"
    2026-11-02T23:14:36.650408Z flashing /home/niteris/dev/fastled3/.build/fbuild/esp32s3/.fbuild/build/release/firmware.bin to /dev/ttyACM1
    

    No intervening lease-release or daemon-restart event appears in that interval. The next daemon restart is at 23:15:41. This distinguishes the bypass from a lease lost on restart. The lease was acquired for the other checkout's ongoing validation workflow.

Investigation was read-only: no new deploy/reset/serial attach was attempted to reproduce contention on the board currently reserved for validation.

Confirmed source path

  • handlers/devices.rs:107 delegates the public lease endpoint to DeviceManager::acquire_exclusive.
  • device_manager.rs:473 checks/stores DeviceState.exclusive_lease.
  • handlers/operations/deploy.rs:490 admits deploy through serial_manager.preempt_for_deploy, without consulting that lease.
  • fbuild-serial/src/manager.rs:961 uses a separate PreemptionTracker. Its per-operation guard is released after deploy/recovery.
  • DeployRequest has request attribution, but no workflow lease ID/token to carry into deploy and later serial/RPC attachment.
  • Current tests cover exclusive-lease-vs-lease conflicts and deploy-vs-deploy conflicts separately; they do not establish that an explicit exclusive device lease blocks a competing deploy.

Proposal

Unify admission around an identity-bound workflow lease, while retaining the existing per-operation protection:

  1. Require every mutating device path (deploy, reset, serial write/RPC writer) to honor a foreign exclusive device lease before touching the board. Read-only monitor subscribers may continue to share the daemon's stream where supported.
  2. Let the lease holder pass its lease identity through deploy, re-enumeration/recovery, and subsequent serial/RPC calls. Holding a lease must not prevent its owner from using the device, and releasing an individual operation must not release the enclosing workflow lease.
  3. Add an opt-in bounded wait/queue for competing workflows, with holder/progress diagnostics, cancellation, and deterministic ordering. Retain fail-fast behavior for callers that do not opt in. Waiting must occur before preemption/reset/open, not after affecting the current holder.
  4. Keep the lease keyed to physical device identity when track_serial is requested, and make disconnect/restart/lost-lease events observable so callers cannot unknowingly validate replacement firmware.

Acceptance criteria

  • RED -> GREEN: first add a hardware-free regression using the production admission path: acquire an exclusive device lease for client A, attempt client B's deploy, and assert B cannot enter preemption/flashing. The current code should fail that assertion; the implementation must make it pass.
  • Add the equivalent reset/write admission coverage; demonstrate that A can deploy and then perform RPC under the same lease without a release/reacquire gap.
  • A bounded queued B waits while A validates, then starts only after A releases. Timeout/cancellation removes B without resetting/closing/flashing A's board; competing devices remain independent.
  • Existing fix(serial): reject concurrent device operations #1502 concurrent-deploy protection stays green. A workflow token must not permit two overlapping operations within the same workflow.
  • Cover tracked USB port renumbering and clear lost-lease reporting on daemon restart/disconnect.
  • After unit coverage passes, validate two real client processes/checkouts taking turns on one board: A builds its firmware, acquires the workflow lease, deploys and completes RPC validation; B queues and runs only after release. Record firmware identity and holder attribution so sequential sharing is proven.

Decisions

Related issues

Activity

  1. zackees commented on Nov 3, 2026

    @zackees
    MemberAuthor

    Steward note: the admission half of this issue is now in PR #1869.

    • DeviceManager::admit_operation(port, lease_id) refuses a mutating
      operation on a port leased by another client with a new
      DeviceLeaseError::ForeignLeaseHeld (names the lease id, the holder and both
      escape hatches).
    • /api/deploy consults it on the resolved port before
      serial_manager.preempt_for_deploy, so a competing checkout can no longer
      close the holder's session or flash over its firmware — closing observation
      2 from the report. DeployRequest/fbuild deploy gain an optional
      --lease-id.
    • /api/reset gets the same gate (a reset would drop the holder
      mid-validation).
    • fbuild device take --reason remains the explicit human override.

    Still open here (not in #1869): bounded wait/queue for competing workflows
    with ordering + cancellation, lease identity on the serial-write/RPC writer
    path, tracked-renumbering coverage beyond the existing move test, and
    lost-lease reporting on daemon restart/disconnect. The two-process
    on-hardware validation still needs a board slot that is not in use for
    FastLED/FastLED#5001; it was not attempted this hour.

  2. zackees commented on Nov 3, 2026

    @zackees
    MemberAuthor

    Admission slice of this issue is merged: #1869 (e55b63e3, squash-merged as 13e48205).

    What landed:

    • DeviceManager::admit_operation(port, lease_id) — an exclusive lease admits only the lease id it was acquired under; anything else returns DeviceLeaseError::ForeignLeaseHeld naming the lease, holder, --lease-id <id> and fbuild device release <port> --lease-id <id>.
    • /api/deploy checks admission on the resolved port before preempt_for_deploy, so a competing client can no longer close the holder's session or flash over its image. /api/reset is gated the same way.
    • DeployRequest/ResetRequest carry an optional lease_id; CLI gains fbuild deploy --lease-id <id> (also threaded through debug, mcp tools and the daemon client types).
    • Monitor leases never block; unknown ports admit (emulator/probe routes stay outside the lease path); fbuild device take --reason is still the explicit human override.
    • Tests: 68 lines of device-manager unit tests (unleased admits, foreign-no-lease refused with holder+id, wrong id refused, holder admits, monitor doesn't block, unknown port admits) plus 47 lines of handler tests that drive the real DaemonContext; verified RED first by stubbing the gate.

    Still open on this issue (not in #1869):

    1. Bounded queue / deterministic ordering for competing workflows (sequestration instead of refusal).
    2. Lease identity on the serial-write / RPC writer path (a second writer can still take the port for raw serial writes).
    3. Obtaining a lease by workflow rather than --track-serial heuristics (renumbering test beyond the existing move test).
    4. Lost-lease reporting on daemon restart.
  3. zackees commented on Nov 3, 2026

    @zackees
    MemberAuthor

    Second slice of #1864 landed as PR #1871 (unmerged, awaiting review/CI): the
    serial write path now honors a foreign exclusive lease.

    • SerialClientMessage::Attach carries an optional lease_id (omitted by
      older clients → "no lease", the safe reading).
    • handle_serial_ws admits through DeviceManager::admit_operation before
      open_port(), so a refused client never opens/resets/reads the board, and
      re-admits on every write frame so a lease acquired mid-session stops the
      next byte with write_ack{success:false}.
    • fbuild monitor --lease-id <id> lets the holder's own session through.

    Coverage: ws_serial_attach_honors_foreign_exclusive_lease drives the real
    handler over a real WebSocket + pty and the real DeviceManager (refused
    attach with no port opened; holder attaches and writes; mid-session lease
    steal refuses the next write). RED first with the gate stubbed, then green;
    daemon 307p/0f, serial 203p/0f, cli bin 472p/0f, clippy -D warnings clean.

    Remaining acceptance criteria on this issue:

    1. opt-in bounded wait/queue with deterministic ordering, cancellation and
      holder/progress diagnostics (criterion 3);
    2. lease identity across re-enumeration/recovery reattach;
    3. python facade lease_id plumbing (currently refused-by-default, safe);
    4. two real checkouts taking turns on one board (criterion 6, needs an idle
      board).
  4. zackees commented on Nov 3, 2026

    @zackees
    MemberAuthor

    Second slice is now merged: PR #1871 — the serial write path honors an
    exclusive workflow lease (merge commit 9f182e4f).

    Shipped:

    • SerialClientMessage::Attach carries an optional lease_id; omitted means
      "present no lease" (older clients), which is refused on a reserved board.
    • handle_serial_ws admits through DeviceManager::admit_operation before
      open_port(), so a refused client never opens/resets/reads the board, and
      re-admits on every write frame — a lease taken by another workflow
      mid-session stops the next byte with write_ack{success:false}.
    • fbuild monitor --lease-id <id> for the holder; docs/reference/cli.md
      workflow-lease section updated.

    Coverage on main: ws_serial_attach_is_refused_under_a_foreign_exclusive_lease
    (portable: refuses no-lease attach without opening the port; holder clears
    admission) and the mid-session steal assertion in
    serial_ws_replies_follow_request_order (real pty). Zero new host-platform
    ledger rows.

    Still open on this issue:

    1. opt-in bounded wait/queue: deterministic ordering, cancellation, holder and
      progress diagnostics, waiting before preemption/reset/open;
    2. lease identity across re-enumeration/recovery reattach (the holder's own
      reattach after PortRenumbered is not yet re-admitted on the new port);
    3. python facade lease_id plumbing (fbuild-python has its own message type;
      today such sessions are correctly refused, which is the safe default);
    4. explicit lost-lease reporting on daemon restart/disconnect;
    5. two real checkouts taking turns on one board (criterion 6) — needs an idle
      board.
  5. zackees commented on Nov 3, 2026

    @zackees
    MemberAuthor

    Third slice of #1864 is merged: PR #1873 — the opt-in bounded wait/queue
    (acceptance criterion 3). Merge commit acc3ac55.

    Shipped:

    • DeviceManager admission queue (device_manager/wait_queue.rs):
      enqueue_waiter / leave_queue / queued_client_ids / poll_admission and
      the bounded await_admission.
    • Deterministic ordering: a waiter is admitted only at the head of its
      port's FIFO with no foreign lease, the lease holder is never queued, and a
      fail-fast caller (no ticket) is refused while anyone is queued — so two
      clients that observe a release in the same instant cannot reorder. Queues are
      per device, so competing boards stay independent.
    • Cancellation: QueueGuard owns the ticket, so a dropped/cancelled request
      leaves the queue and promotes the next workflow (no phantom head-of-line
      blocker). A timeout also leaves the queue.
    • fbuild deploy --lease-wait <seconds> / DeployRequest.lease_wait_ms
      (serde default 0 = today's fail-fast behaviour). The wait runs at the same
      admission site as fix(daemon): enforce explicit device leases on deploy and reset admission #1869, on the resolved port, before
      preempt_for_deploy — so a queued deploy never resets, closes or flashes the
      holder's board, and never opens the port.
    • Diagnostics: DeviceLeaseError::LeaseWaitTimedOut names the queue position,
      the holder (lease id, client, description) and the elapsed wait, and states
      the board was not reset/closed/flashed; the refusal without --lease-wait
      now recommends the flag. queued_client_ids/queued_workflows expose queue
      depth for status output.

    Coverage (RED first, behaviourally: stubbing the admission rule gave
    2 passed; 6 failed; restored → green): FIFO head-only ordering, foreign-lease
    blocking + holder naming, unqueued caller cannot jump the queue, unknown ports
    admitted, bounded wait admits after a real release (≥100 ms), timeout leaves the
    queue empty, wait_ms == 0 stays fail-fast, holder never queued, plus
    lease_wait_ms serde defaults. fbuild-daemon --lib 317 passed / 0 failed,
    fbuild-cli --bin fbuild 472 passed / 0 failed, clippy -D warnings rc 0,
    fmt rc 0, boundary rows=44; dylint_rows=43 (ledger untouched).

    Remaining on this issue:

    1. lease identity across re-enumeration/recovery reattach (criterion 4's
      renumbering half);
    2. Python facade lease_id (and now lease_wait_ms) plumbing;
    3. two real checkouts taking turns on one board (criterion 6) — needs a board
      slot not in use for ESP32-S3 LCD_CLOCKLESS: let the application choose the worker task's core (currently created unpinned at priority 23) FastLED#5001.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions