Skip to content

protocol/ymodem: keep the TX drain inside the retry, not around it - #138

Merged
openipc-ai merged 3 commits into
masterfrom
fix/flush-output-outside-retry-try
Sep 16, 2026
Merged

openipc-ai merged 3 commits into
masterfrom
fix/flush-output-outside-retry-try

Conversation

@openipc-ai

@openipc-ai openipc-ai commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

52582f0 (#137) changed Transport.flush_output() from "discard queued TX" to
"wait for queued TX to drain". That is the right semantics — it matches the
documented contract, and reset_output_buffer() did the opposite of what the
docstring promised. But it also turned a call that effectively never raised
into one that raises TransportTimeout after 5 s, and two retry loops call it
from outside their own try block.

The regression

HiSiliconStandard._send_frame_with_retry:

for attempt in range(retries):
    await transport.flush_input()
    await transport.flush_output()    # <- outside the try
    try:
        await transport.write(frame_data)
        ...
    except Exception:
        continue                      # never reached

RecoverySession.run() calls the protocol inside try/finally with no
handler, and so does burn, so a raise here propagates out of the command.
Measured against a pyserial-shaped port whose out_waiting never reaches
zero:

    master (before #137, purge):  returned False   frames_written=4
    52582f0        (drain):       TransportTimeout frames_written=0

On a stalled PL2303 or FT232R, or with hardware flow control asserted, a burn
that previously purged the queue and retried now aborts before writing a
single frame. This is the primary recovery path for all 112 UART chips.

YModemSender._send_packet has the same shape, with a retry loop that only
catches YModemError, so a stalled queue aborts a stock-U-Boot chainload
mid-transfer instead of costing one retry.

This is a re-introduction, not a new class of bug

tests/test_protocol_standard.py::TestWriteTimeoutRetry already exists
because transport.write() used to sit outside that same try. Its docstring
names the same trigger:

Previously, transport.write() blocked outside the try/except, so a hung
write (e.g. PL2303 TX buffer not draining) would propagate a raw
TransportTimeout up to the caller — bypassing the retry loop entirely.

That test pins the invariant for write() only, which is why moving the
flushes out of the try went unnoticed.

The fix

Move flush_input(), flush_output() and write() inside the existing try
in both loops. A flush that waits on hardware is an I/O operation, not
bookkeeping, so it belongs where transient failures are already absorbed. The
bounded drain from #137 is kept exactly as merged.

Two regression tests, each in the style of the existing one it sits beside.
Both fail against 52582f0 with a raw TransportTimeout and pass with the
flushes inside the try.

Follow-up from review (c943b37)

Review caught a real bug in the second commit, and it is worth spelling out
because the fix creates the window itself. Retrying a stalled drain
retransmits a packet whose first copy is still queued, so when the link
recovers the receiver sees it twice and answers twice. _send_packet consumed
one response and left the spare behind, where the next packet's read window
picked it up: a NAK the receiver really sent was read as an ACK, and the data
it asked to have resent was never retransmitted. That is silent truncation of
the chainloaded image, not a failed transfer.

Reproduced first, then fixed. _send_packet now flushes the receive buffer
before every attempt — which is exactly what _send_frame_with_retry already
does, and why the same duplicate-transmission window never bites the HiSilicon
path. _finish() gets the same flush, because the last data packet can also
leave a spare response that would be read as the EOT answer.

The third test NAKs packet 2 after packet 1 stalls and duplicates, then asserts
packet 2 really is retransmitted. It fails against 9d90a3c and passes on
c943b37.

Testing

uv run pytest tests/ -q --ignore=tests/fuzz     # 871 passed, 3 skipped
uv run pytest tests/fuzz/ -q --hypothesis-seed=0 # 16 passed
uv run ruff check src/ tests/                    # clean
uv run mypy src/defib/ --ignore-missing-imports  # clean, 77 files

Not hardware verified. Reproduced against a port whose out_waiting never
reaches zero. On real hardware the divergence needs a link where the frame is
still draining when the per-attempt ACK timeout fires, so the natural check is
a defib burn on any HiSilicon board — which should behave exactly as it does
today, since the fix only restores the previous behaviour on the stalled path.

Not addressed here

YModemSender._finish() still lets a transport error escape. It has no retry
semantics to restore, and the installer reports it cleanly through the
orchestrator's TransportError handler, so it is left alone.

The remaining flush_output() call sites in vendors/hikvision.py are all
inside deadline-driven loops whose failures reach install's
except (TimeoutError, TransportError) and surface as a normal CLI error.

`_send_frame_with_retry` called `flush_input()` and `flush_output()` outside
its own try block, so anything they raised escaped the retry loop and
propagated out of `RecoverySession.run()`, which wraps the protocol call in
`try/finally` with no handler.

That was harmless while `flush_output()` was `reset_output_buffer()`, which
discards queued bytes and effectively never raises. 52582f0 changed it to
wait for the TX queue to drain against a 5 s deadline and raise
`TransportTimeout` when it does not — exactly the hung-adapter case the
retry loop exists to absorb. On a stalled PL2303/FT232R or with flow control
asserted, a burn that previously purged and retried now aborts before
writing a single frame.

This is the same defect `TestWriteTimeoutRetry` already documents for
`transport.write()`, which was moved inside the try for the identical
reason. Move both flushes in with it.

Verification:
uv run pytest tests/test_protocol_standard.py -k "drain or write_timeout" -q

The added test fails against 52582f0 (raw TransportTimeout escapes
`_send_head`) and passes with the flushes inside the try. Not hardware
verified: reproduced against a pyserial-shaped port whose `out_waiting`
never reaches zero. On real hardware the divergence needs a link where the
frame is still draining when the per-attempt ACK timeout fires.
`_send_packet` called `write()` and `flush_output()` outside its own try
block, and the retry loop only caught `YModemError`. A `TransportTimeout`
from either therefore escaped the sender, past `_chainload`'s `except
YModemError`, and aborted the stock-U-Boot chainload mid-transfer.

This is the same defect as the preceding commit: `flush_output()` waits for
the TX queue to drain and raises when it does not, so it is an I/O call that
belongs inside the retry, not setup around it. `write()` has the same
property through its 5 s write_timeout.

Move both inside the try and treat a transport stall like an unacknowledged
packet, which is what it is — a queue that never drained means the receiver
never saw the whole frame.

Verification:
uv run pytest tests/test_ymodem.py -q

The added test drives the real sender against a receiver that leaves stalled
packets unacknowledged. It fails on 52582f0 with a raw TransportTimeout and
passes with the flush inside the try.

`_finish()` still lets a transport error escape, but it has no retry
semantics to restore and the installer reports it cleanly through the
orchestrator's TransportError handler.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Retry stalled TX drains inside protocol retry loops

🐞 Bug fix 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Retry HiSilicon frame sends when queue flushing encounters transient transport failures.
• Treat YMODEM TX drain timeouts as packet retries instead of transfer-aborting errors.
• Add regressions simulating stalled output queues across both recovery paths.
Diagram

graph TD
  A["Frame attempt"] --> B["Flush queues"] --> C["Write payload"] --> D["Await ACK"] --> E{"Attempt succeeded?"}
  E -- "Yes" --> F["Continue transfer"]
  E -- "No" --> G{"Retries remain?"} -- "Yes" --> A
  G -- "No" --> H["Report failure"]
Loading
High-Level Assessment

The current approach is appropriate because TX draining and writing are both fallible transport I/O operations and should share the protocols’ existing per-attempt recovery boundary. Centralizing retries in the transport layer was considered but would obscure protocol-specific retry limits and acknowledgment handling.

Files changed (4) +88 / -5

Bug fix (2) +14 / -5
hisilicon_standard.pyMove queue flushes into HiSilicon frame retries +7/-2

Move queue flushes into HiSilicon frame retries

• Moves input and output flushing inside '_send_frame_with_retry'’s exception boundary. A stalled TX drain now consumes a frame attempt instead of aborting the burn before any frame is written.

src/defib/protocol/hisilicon_standard.py

ymodem.pyRetry YMODEM packets after TX drain timeouts +7/-3

Retry YMODEM packets after TX drain timeouts

• Moves packet writes and output draining inside the packet retry block and handles 'TransportTimeout' alongside 'YModemError'. Stalled output queues now trigger normal packet retry accounting rather than escaping the chainload.

src/defib/recovery/ymodem.py

Tests (2) +74 / -0
test_protocol_standard.pyCover transient HiSilicon output-drain stalls +27/-0

Cover transient HiSilicon output-drain stalls

• Adds a mock transport whose output drain times out before recovering. The regression verifies HiSilicon frame transmission retries the stalled drains and eventually succeeds.

tests/test_protocol_standard.py

test_ymodem.pyCover YMODEM recovery from stalled TX queues +47/-0

Cover YMODEM recovery from stalled TX queues

• Adds a scripted receiver that raises output-drain timeouts for initial data packets. The test verifies each stall costs a retry while the transfer still completes successfully.

tests/test_ymodem.py

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 16, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Failed packets can appear successful ✓ Resolved 🐞 Bug ≡ Correctness
Description
_send_packet retries a drain timeout by calling write(packet) again even though flush_output()
leaves the original bytes queued. When the link recovers and delivers both copies, the receiver
produces two responses but the sender consumes only one, allowing the leftover response to be
attributed to a later packet that the receiver may have rejected.
Code

src/defib/recovery/ymodem.py[R134-135]

+                await self._transport.write(packet)
+                await self._transport.flush_output()
Evidence
Serial output draining raises while bytes remain queued and never purges them, while the changed
loop immediately writes the packet again on its next attempt. The repository's receiver model
acknowledges every duplicate data packet, and _read_control accepts the next unsequenced control
byte, proving that two delivered copies create an extra response that can flow into the next packet
exchange; the new stalled-transport test avoids this by never retaining or later delivering its
timed-out write.

src/defib/transport/serial.py[111-131]
src/defib/recovery/ymodem.py[127-159]
tests/test_ymodem.py[80-85]
tests/test_ymodem.py[155-167]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A timed-out output drain does not discard the packet already queued, but the next YMODEM retry writes another copy. Once transmission resumes, duplicate responses can become associated with subsequent packets.
## Fix Focus Areas
- src/defib/recovery/ymodem.py[127-159]
- tests/test_ymodem.py[142-186]
## Recommended Fix
Track whether the current packet has already been written. After a drain timeout, retry draining the existing queued transmission and reading its response without writing another copy; only retransmit after the previous output has drained and its response has timed out or explicitly requested a retry. Add a transport test double that retains queued bytes across a drain timeout, releases them on recovery, and verifies duplicate responses cannot acknowledge a later packet.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can group findings by type and pick your Finding display, from Minimal to Full

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/defib/recovery/ymodem.py
Retrying a stalled drain retransmits a packet whose first copy is still
queued, so when the link recovers the receiver sees it twice and answers
twice. `_send_packet` consumed one response and left the spare behind, where
the next packet's read window picked it up — a NAK the receiver really sent
was read as an ACK, and the data it asked to have resent was never
retransmitted. Silent truncation of the chainloaded image, not a failed
transfer.

Flush the receive buffer before every attempt, the way
`HiSiliconStandard._send_frame_with_retry` already does, so a response can
only ever belong to the attempt that just wrote. `_finish()` gets the same
treatment because the last data packet can leave a spare response that would
otherwise be read as the EOT answer.

Found by review of the previous commit, which introduced the retry that makes
duplicate transmission possible.

Verification:
uv run pytest tests/test_ymodem.py -q

The added test NAKs packet 2 after packet 1 stalls and duplicates, then
asserts packet 2 is actually retransmitted. It fails against the previous
commit and passes here.
@openipc-ai
openipc-ai merged commit be1da26 into master Sep 16, 2026
13 checks passed
@ArthurKoba

Copy link
Copy Markdown
Contributor

@openipc-ai , Hardware-tested this on a physical HiSilicon board.

Hardware

  • GARUS GSL-5030X-30AP-NM
  • Hi3516EV200
  • 64 MiB RAM
  • 8 MiB SPI NOR
  • FT232R UART on COM15

Tested against current master containing #138:

uv run python -m defib burn `
  -c hi3516ev200 `
  -p COM15 `
  -b

Result: PASS.

Relevant output:

Firmware: u-boot-hi3516ev200-universal.bin (cached)
Protocol: HiSilicon Standard
Port: COM15
Loaded profile: hi3516ev200
Using sendFrameForStart handshake for hi3516ev200
Sending 234599 bytes of firmware...
SPL boundary detected (gzip) at 0x4400 (17408 bytes); profile default was 0x6000 (24576 bytes)
Waiting for U-Boot to start (up to 15s)...
Autoboot detected, sending Ctrl-C...
U-Boot console ready

Sending DDR step  100%
Sending SPL       100%
Sending U-Boot    100%

Done! (29977ms)

There was one initial attempt that failed at:

DDR init failed: PRESTEP0 HEAD frame not ACKed

An immediate retry completed the full DDR -> SPL -> U-Boot upload and reached the U-Boot console normally.

So the normal HiSilicon burn path works on real hardware with #138 applied.

This does not specifically reproduce the stalled-out_waiting condition described in the PR, but it provides hardware confirmation that the retry/flush changes do not regress the normal HiSilicon Standard recovery path.

@openipc-ai

Copy link
Copy Markdown
Contributor Author

Thank you — that is exactly the check that was missing, and on the right kind of link.

The FT232R is a nice accident: that adapter is already on record here for dropping off USB when TX is driven across a PoE energize, which is one of the real routes into the stalled-queue state this PR is about. So the run exercised the normal path on hardware that can actually produce the abnormal one.

You are right that it does not reproduce the stalled out_waiting condition, and I would not want it read as though it does. What it does establish is the thing I could not: that moving the flushes inside the try does not regress the normal HiSiliconStandard recovery path on real silicon. The change only alters behaviour when flush_output() raises, which needs a TX queue that will not drain, so "no change on the healthy path" was the specific risk worth retiring. It is retired.

On the first attempt failing with DDR init failed: PRESTEP0 HEAD frame not ACKed — that one is not yours and not #138. It is the documented deferred frame-blast fast-fail. hi3516ev200 is a frame-blast chip, and with manual power cycling there is no drain point before flooding starts, so defib opens the port to a chip in an unknown state and fails loudly in about six seconds rather than hanging. The same signature was recorded in May on a hi3518ev200 over a PL2303, so your run widens the evidence to a second chip and a second adapter family rather than showing anything new. It is also structurally impossible for #138 to have caused it: the change can only bite once a TX queue has stalled, and this failed on the very first HEAD frame.

--power-cycle avoids it, because the explicit cycle drains the line before flooding begins. The real fix is to teach frame-blast chips the running-data to silence to power-on wait that the other chips already do before enabling the flood. That is still open work, unrelated to this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants