Skip to content

feat(serial): ReaderControl channel to restore ClearBuffer + GetInWaiting #756

Description

@zackees

Sub-issue of #755 (post-#750 follow-up meta).

Problem

SerialClientMessage::ClearBuffer and SerialClientMessage::GetInWaiting used to reach into the broadcast receiver rx from the same task that handled inbound WebSocket messages (pre-#750, single tokio::select! loop). PR #750 split that handler into three concurrent tasks (reader / writer / inbound) for throughput reasons — and as a side effect, the inbound task no longer has a handle to rx.

The post-#750 code makes both methods explicit logged no-ops:

Ok(SerialClientMessage::ClearBuffer) => {
    tracing::debug!(... "clear_buffer requested (no-op post-#749 split; see comment)");
}
Ok(SerialClientMessage::GetInWaiting) => {
    let _ = out_tx_inbound.send(SerialServerMessage::InWaiting { count: 0 });
}

Honest, but a regression for the FastLED #605 use case (pyserial-equivalent semantics over the WS bridge).

Fix design — ReaderControl channel

Add an internal mpsc from the inbound task to the reader task. Reader's tokio::select! learns one new branch.

// In handle_serial_ws():
let (control_tx, mut control_rx) = mpsc::unbounded_channel::<ReaderControl>();

enum ReaderControl {
    Drain { reply: oneshot::Sender<usize> },     // for ClearBuffer
    GetDepth { reply: oneshot::Sender<usize> },  // for GetInWaiting
}

// Reader task body:
loop {
    tokio::select! {
        result = rx.recv() => { /* existing Data/event forwarding */ }
        Some(cmd) = control_rx.recv() => {
            match cmd {
                ReaderControl::Drain { reply } => {
                    let mut drained = 0usize;
                    while rx.try_recv().is_ok() { drained += 1; }
                    let _ = reply.send(drained);
                }
                ReaderControl::GetDepth { reply } => {
                    let _ = reply.send(rx.len());
                }
            }
        }
    }
}

// Inbound task — replace the current no-ops:
Ok(SerialClientMessage::ClearBuffer) => {
    let (tx, rx_reply) = oneshot::channel();
    let _ = control_tx.send(ReaderControl::Drain { reply: tx });
    let drained = rx_reply.await.unwrap_or(0);
    tracing::debug!(client_id = %client_id_owned, port = %port_owned, drained, "clear_buffer drained N lines via ReaderControl");
}
Ok(SerialClientMessage::GetInWaiting) => {
    let (tx, rx_reply) = oneshot::channel();
    let _ = control_tx.send(ReaderControl::GetDepth { reply: tx });
    let count = rx_reply.await.unwrap_or(0);
    let _ = out_tx_inbound.send(SerialServerMessage::InWaiting { count });
}

Roughly 40 LOC including the enum, the new select arm in reader, and the two inbound handlers. No public API change.

Test plan

  • Unit: spawn the three-task harness with mock channels, exercise Drain and GetDepth, assert reply values + that reader's main loop stays responsive (no head-of-line blocking on control).
  • Integration: a python client that opens the serial-WS, pumps 200 lines from a fake serial source, then issues ClearBuffer and asserts subsequent GetInWaiting returns 0.

Local editable install workflow (validation against FastLED)

fbuild lives at ~/dev/fbuild. FastLED consumes it via pyproject.toml and can be redirected to the local checkout for end-to-end validation BEFORE the upstream release ships.

Step 1 — make the change in fbuild

cd ~/dev/fbuild
git checkout -b fix/<n>-readercontrol main
# edit crates/fbuild-daemon/src/handlers/websockets.rs + add tests
FL_AGENT_ALLOW_BARE_CARGO=1 soldr cargo build -p fbuild-daemon
FL_AGENT_ALLOW_BARE_CARGO=1 soldr cargo test -p fbuild-daemon

Step 2 — point FastLED at the local checkout

In ~/dev/fastled/pyproject.toml, replace the fbuild==X.Y.Z pin with:

dependencies = [
    "fbuild",   # editable; resolved by [tool.uv.sources]
    ...
]

[tool.uv.sources]
fbuild = { path = "../fbuild", editable = true }

Then:

cd ~/dev/fastled
uv sync --reinstall-package fbuild
uv run fbuild --version   # confirm it resolves to the local checkout

Step 3 — bench-validate on real hardware

# Teensy 4.0 on COM20, exercises serial-WS bridge under load
bash autoresearch teensy40 --object-fled --strip-sizes 5 --timeout 60
# Then exercise the new ClearBuffer/GetInWaiting paths directly:
uv run python ci/autoresearch/debug_find_pins.py COM20   # confirms findConnectedPins still round-trips
# (add a ClearBuffer/GetInWaiting smoke-test alongside debug_object_fled_capture.py)

Acceptance: 4/4 patterns flow through wrapper in seconds, and the new RPCs return non-trivial drain counts when fired mid-burst.

Step 4 — push the fbuild PR and admin-merge immediately

Once local validation is green:

cd ~/dev/fbuild
git push -u origin fix/<n>-readercontrol
gh pr create --title "feat(serial): ReaderControl restores ClearBuffer/GetInWaiting (closes #<n>)" --body-file <path>
gh pr merge <pr#> --squash --admin --delete-branch

(Same merge protocol used for #750 — admin merge bypasses the pre-existing master-level CI failures that aren't in our diff. Verify those failures still cite files outside crates/fbuild-daemon/src/handlers/websockets.rs before bypassing.)

Step 5 — bump FastLED to the new fbuild version

cd ~/dev/fastled
# Wait for release-auto.yml to publish fbuild vX.Y.Z+1
# (~5-10 min after merge; or check `gh release list` from ~/dev/fbuild)
# In pyproject.toml: revert [tool.uv.sources] and bump pin:
"fbuild==X.Y.Z+1",
uv sync --reinstall-package fbuild

Open a small follow-up PR on FastLED with just the pyproject.toml bump and reference this fbuild PR in the body.

Anti-goals

  • Do not widen ReaderControl to cover features that don't exist in SerialClientMessage today. The enum is exactly two variants because that's exactly what the protocol exposes. Future protocol additions get their own variants in their own PRs.
  • Do not make the control mpsc bounded with a non-trivial capacity — the inbound task only sends one control message per client-side ClearBuffer/GetInWaiting, so even capacity=4 is overkill. Unbounded is fine and avoids deadlock corner cases.

Cross-links

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions