fix(avr/serial): drain RX queue every frame and clear it on stop

User report: Arduino Nano connected to an Uno-TX wire received bytes
but displayed them poorly, and pressing Stop then Run "killed" the
serial link until the page reloaded.

Two paired bugs in the cross-board serial path:

(1) drainSerialRxQueue was only ever re-fired from usart.onRxComplete,
    which itself only fires AFTER a successful delivery. If the very
    first delivery attempt fails (rxEnable=false because the sketch
    hasn't reached Serial.begin yet — extremely common when one board
    starts emitting bytes before the receiving board's setup() runs)
    nothing re-kicks the queue and every subsequent byte from the
    sibling board sits in serialRxQueue indefinitely. Adding a
    per-frame drain attempt (no-op when queue is empty or rxBusyValue
    is set, so cost is negligible) makes the link self-heal across
    cold-start races and Serial.end()/begin() toggles.

(2) stop() never cleared serialRxQueue. On Run after Stop the new
    USART would re-drain the previous run's leftovers into the fresh
    sketch before its setup() ran, corrupting the first bytes the
    user saw on the receiving side. Clearing the queue in stop() —
    same place we already clear scheduledPinChanges — keeps each Run
    a clean slate.

Verified 52 existing tests still pass (dual-pico-serial-passthrough,
dual-arduino-software-serial, interconnect-routing, avr-uart-tx
-waveform, serial-batching, AVRSimulator, pin-position-rotation).
This commit is contained in:
David Montero 2026-05-26 15:47:55 +02:00
parent 467ca4455f
commit 56d3bc4f12
1 changed files with 19 additions and 0 deletions

View File

@ -791,6 +791,18 @@ export class AVRSimulator {
// Poll PWM registers every frame // Poll PWM registers every frame
this.pollPwmRegisters(); this.pollPwmRegisters();
// Try to drain any pending RX byte every frame. The primary
// drain path is onRxComplete (re-fires after each successful
// delivery), but that callback only ever fires AFTER a byte was
// accepted — if the very first delivery attempt fails (sketch
// hasn't called Serial.begin yet, so rxEnable is false) nothing
// would ever re-kick the queue and bytes from a sibling board
// sit there forever. A per-frame retry is cheap (no-op when the
// queue is empty or rxBusyValue is set) and makes the link
// self-heal across both startup races and Serial.end()/begin()
// toggles in the sketch.
if (this.serialRxQueue.length > 0) this.drainSerialRxQueue();
frameCount++; frameCount++;
if (frameCount % 60 === 0) { if (frameCount % 60 === 0) {
console.log(`[CPU] Frame ${frameCount}, PC: ${this.cpu.pc}, Cycles: ${this.cpu.cycles}`); console.log(`[CPU] Frame ${frameCount}, PC: ${this.cpu.pc}, Cycles: ${this.cpu.cycles}`);
@ -820,6 +832,13 @@ export class AVRSimulator {
} }
this.scheduledPinChanges = []; this.scheduledPinChanges = [];
// Drop any bytes the previous run had queued for the sketch's RX
// but never delivered (RX disabled, busy, or the sketch hadn't
// reached Serial.begin yet). Without this the next run starts with
// a stale tail that drains into the fresh USART before the sketch
// is ready, and from the user's point of view the link is "dead".
this.serialRxQueue = [];
console.log('AVR simulation stopped'); console.log('AVR simulation stopped');
} }