Skip to content

fix(rp2040): reject phantom CDC ports and give recoverable BOOTSEL guidance #1147

Description

@zackees

Outcome

RP2040 deployment must never treat a stale Windows CDC history record as a usable runtime port. After a UF2 transfer, fbuild may report a port only when the current scan says it is not known-unhealthy and a bounded openability probe succeeds.

This issue consumes the health-aware records from #1146. Manual recovery must work without #1148; the optional elevated path can be integrated afterward.

Reproduction already observed

On the attached RP2040:

  • runtime identity: VID:PID 2E8A:000A;
  • serial: 5303284720C4641C;
  • historical runtime port: COM12;
  • manual BOOTSEL volume: RPI-RP2 on G:;
  • UF2 copy succeeded and the ROM volume ejected;
  • Windows then exposed USB\VID_2E8A&PID_000A\5303284720C4641C as CM_PROB_PHANTOM;
  • opening COM12 exceeded the probe timeout;
  • fbuild nevertheless returned COM12 as the deploy port.

A remembered serial-number match is not proof that the COM endpoint is live.

Dependency

Required RP2040 state machine

Implement the following ordered behavior in crates/fbuild-deploy/src/rp2040.rs and keep pure selection logic in crates/fbuild-deploy/src/rp2040_target.rs.

1. Preflight snapshot

  • Catalogue RP2040 runtime CDC candidates by expected board identity/VID:PID, serial, port name, instance ID, and health.
  • Keep known-unhealthy candidates for diagnostics, but exclude them from automatic selection and from the 1200-baud touch path.
  • An explicit --port COMx does not override known Phantom or PresentProblem health. Fail with the exact health/problem information instead of trying a guaranteed-stale handle.
  • Unknown health on hosts without Windows metadata remains eligible only through the existing openability behavior.

2. Runtime-to-BOOTSEL transition

  • If exactly one matching healthy runtime CDC endpoint is selected, perform the existing 1200-baud touch.
  • Preserve its serial/instance identity as a matching hint, not as a future declaration that its old COM name is usable.
  • If no healthy runtime endpoint exists, continue through the existing manual BOOTSEL-volume workflow.
  • Never touch an unrelated or ambiguous CDC endpoint.

3. UF2 transfer

  • Keep the current BOOTSEL volume identity/ambiguity checks and existing copy/eject diagnostics.
  • Multiple matching BOOTSEL volumes remain an error unless the user supplied a selector that makes the target unique.
  • A successful UF2 copy proves flash transfer only; it does not prove CDC recovery.

4. Post-flash reacquisition

Use the delayed handoff already added by #1095, but each scan must use #1146 health:

  1. rescan until the existing bounded deadline;
  2. select by stable serial/instance identity when available;
  3. accept a renumbered COM port when the same live device returns;
  4. require the candidate to be not known-unhealthy;
  5. require a bounded serial open to succeed;
  6. only then return it as DeploymentResult.port.

For a board entered into BOOTSEL manually, a unique pre-flash runtime candidate may be retained only if it was healthy and openable in that same preflight scan. It is still only a hint: after flashing, fbuild must rediscover a healthy/openable endpoint. Do not return the old COM name merely because its serial matches.

5. Recovery result

If UF2 transfer succeeded but CDC did not recover:

  • return/record full-flash success separately from port recovery;
  • set DeploymentResult.port = None;
  • do not emit a stale FBUILD_DEPLOY_PORT or otherwise advertise the phantom COM name as usable;
  • show the matching instance ID, health state, problem code, and last open error;
  • explain that Windows retained a phantom/bad devnode;
  • recommend a direct motherboard USB port and a known data cable;
  • give exact manual recovery:
    1. hold BOOT/BOOTSEL;
    2. press and release RESET while still holding BOOT;
    3. keep BOOT held for about two seconds, then release;
    4. verify an RPI-RP2 volume appears and rerun deploy;
  • if feat(windows): add scoped elevated USB PnP recovery helper #1148 is available and the user explicitly selected --admin, request that one-shot recovery, rescan once through a bounded deadline, and then apply the same health/openability gate.

The deploy message must distinguish ?firmware copied successfully; runtime CDC unavailable? from ?firmware transfer failed.?

Implementation map

  • crates/fbuild-deploy/src/rp2040.rs
    • enrich PicoCdcPort;
    • update catalogue_pico_cdc_ports;
    • gate touch_1200bps;
    • make post-flash polling health-aware;
    • ensure post_deploy_recovery does not bless the requested/old name after an open failure.
  • crates/fbuild-deploy/src/rp2040_target.rs
    • keep deterministic candidate matching and ambiguity errors pure/testable;
    • never choose a known-unhealthy candidate.
  • crates/fbuild-daemon/src/handlers/operations/deploy.rs
    • preserve the distinction between successful flash and absent recovered port;
    • do not substitute the pre-deploy/requested port when the deployer returns no healthy port.
  • CLI/response rendering
    • print the structured recovery diagnostic without converting it back into a generic success port.

Important rejected shortcut

A patch that only remembers ?the unique pre-flash COM port? during manual BOOTSEL is incomplete. It recreates this bug if that record has become CM_PROB_PHANTOM. Any retained target must carry its live health, and the returned post-flash endpoint must be rediscovered and opened.

Deterministic tests

Add injected scan/open fixtures; do not require real USB for unit tests.

Required cases:

  • healthy COM12 -> BOOTSEL -> healthy/openable COM12: accepted;
  • healthy COM12 -> BOOTSEL -> healthy/openable COM27 with same serial: COM27 accepted;
  • healthy COM12 -> BOOTSEL -> phantom COM12: flash success, port absent, recovery diagnostic;
  • present/problem COM12: excluded before 1200-baud touch;
  • explicit --port COM12 where COM12 is phantom: rejected with health details;
  • manual BOOTSEL plus one healthy preflight runtime identity: used only as a hint and revalidated after flash;
  • manual BOOTSEL plus only a phantom historical candidate: not used;
  • multiple healthy candidates without selector: no guessing;
  • delayed healthy CDC appearance within fix(rp2040): wait for delayed Windows CDC handoff #1095 deadline: accepted;
  • candidate becomes phantom between scans: never returned;
  • open probe fails despite healthy metadata: never returned;
  • non-Windows Unknown health plus successful open: preserves existing behavior;
  • daemon does not fall back to the requested/pre-flash port when result port is None.

Run:

soldr cargo test -p fbuild-deploy rp2040
soldr cargo test -p fbuild-daemon deploy
soldr cargo clippy -p fbuild-deploy -p fbuild-daemon --all-targets -- -D warnings

Windows hardware validation

Hardware validation is required for this child, but a factory-blank board is not required. Use an existing RP2040 and fbuild only.

To avoid colliding with another checkout's daemon, use a unique five-digit port and dev mode. From the FastLED checkout, with the candidate fbuild worktree installed editable into FastLED's venv:

uv pip install --python .venv\Scripts\python.exe --editable C:\path\to\fbuild-worktree
$env:FBUILD_DEV_MODE = '1'
$env:FBUILD_DAEMON_PORT = '55175'
bash autoresearch rp2040 --rpc-smoke --timeout 120s --skip-lint

Record:

  • preflight fbuild port scan health;
  • selected runtime identity;
  • BOOTSEL volume observed;
  • UF2 transfer result;
  • post-flash port identity and health;
  • successful RPC smoke if CDC recovers;
  • exact structured diagnostic and absence of a returned port if Windows reproduces the phantom state.

Do not use raw pyserial, picotool, or manual file-copy commands as acceptance evidence.

Acceptance criteria

  • Known phantom/problem RP2040 CDC records remain visible diagnostically but are never auto-selected or touched.
  • Explicit stale --port fails with actionable health details.
  • A healthy CDC endpoint can still be touched into BOOTSEL.
  • Renumbered healthy CDC is reacquired by stable identity.
  • UF2 success plus CDC failure returns no usable deploy port and does not emit stale FBUILD_DEPLOY_PORT.
  • Manual BOOTSEL works without guessing among candidates.
  • Recovery instructions identify BOOT as BOOTSEL and give the BOOT+RESET sequence.
  • Unit tests cover the transition matrix above.
  • A real fbuild-only AutoResearch RPC smoke passes on a healthy attached RP2040, or a reproduced phantom run proves the fail-safe diagnostic path. Full closeout should include the healthy RPC path.

Non-goals

Related issues

Implementation handoff: state-machine ownership and current bug anchor

Begin this child only after #1146 and #1148 are merged to main. Rebase from that main; do not copy a local experimental patch that remembers a pre-flash COM name.

The current dangerous anchor is in crates/fbuild-daemon/src/handlers/operations/deploy.rs: recovery input is chosen as deploy_port_str.or_else(|| deploy_result.port). That prioritizes the requested/pre-flash port even when the deployer has no freshly recovered runtime port. Replace this behavior so post-flash recovery and all response/environment propagation use only a freshly catalogued, health-eligible, bounded-openable endpoint. If CDC recovery fails after a confirmed UF2 transfer, preserve success: true for the transfer but return port: None; never call recovery/open/monitor using the old requested COM name.

Required state-machine seams

Keep candidate selection pure and inject scan/open/clock behavior for tests. Extend the existing RP2040 catalogue and wait_for_cdc_port_with_clock seam rather than adding a second polling path:

  1. Catalogue PicoCdcPort with PortHealth, canonical instance ID, parent instance ID, serial, and name.
  2. Preflight: known-unhealthy candidates are visible in diagnostics but cannot be selected, explicitly touched at 1200 baud, or used as the manual-BOOTSEL hint.
  3. Transfer: preserve existing BOOTSEL volume ambiguity/identity protections; a successful copy is transfer-only evidence.
  4. Reacquisition: on every scan, match serial/instance as hints, accept COM renumbering, require health not known-unhealthy, then require bounded open. Only this candidate may become DeploymentResult.port.
  5. Failure: distinguish transfer success/CDC unavailable from transfer failure, include health/problem/instance and BOOTSEL recovery instructions, emit no stale port variable.
  6. Optional recovery: consume only feat(windows): add scoped elevated USB PnP recovery helper #1148's typed request under explicit --admin; after helper exit, repeat step 4 once without rebuilding or reflashing. Default/no-admin ends with manual guidance.

Completion evidence checklist

Before live flashing, install the candidate fbuild worktree editable into FastLED's venv and select an unused five-digit FBUILD_DAEMON_PORT; record that port so another checkout's daemon is untouched. Use only fbuild port scan and bash autoresearch rp2040 --rpc-smoke --timeout 120s --skip-lint for device evidence.

The final issue comment must include one healthy board run with its actual serial, pre/post scan health, transfer, returned port, and RPC smoke result. If a phantom state is reproduced, include the fail-closed run separately: it must show port=None/no stale environment port plus the exact recovery message. A historical phantom observation is context, not evidence that this PR works.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions