From 4bda240efb891e50091f799582d611295e5fce3b Mon Sep 17 00:00:00 2001 From: deadprogram Date: Sat, 18 Jul 2026 11:25:01 +0200 Subject: [PATCH] espflasher: add retry logic for flash block writes and fix compressed timeouts Add per-block retry (3 attempts) for both compressed and uncompressed flash writes, matching esptool.py's WRITE_BLOCK_ATTEMPTS behavior. Transient SLIP timeouts and framing errors are common during flashing and typically resolve on retry. The stub handles duplicate sequence numbers gracefully, so resending an already-processed block is safe. Fix the per-block timeout for compressed flash writes: the timeout was computed from the compressed block size, but the stub must decompress and write much more data before ACKing. Scale the timeout by the compression ratio so it reflects the actual flash write time. Fix retry desync: wait before flushing (not after) so a delayed response from the timed-out attempt arrives and gets cleared by the flush, preventing stale SLIP frames from corrupting the retry's response parsing. Signed-off-by: deadprogram --- go.mod | 2 +- pkg/espflasher/flasher.go | 46 ++++++++++++++++++++++++++++++++++---- pkg/espflasher/protocol.go | 21 ++++++++++++++--- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 6bcc2bd..ab0f92e 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,12 @@ go 1.22 require ( github.com/stretchr/testify v1.7.0 go.bug.st/serial v1.6.2 + golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 ) require ( github.com/creack/goselect v0.1.2 // indirect github.com/davecgh/go-spew v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) diff --git a/pkg/espflasher/flasher.go b/pkg/espflasher/flasher.go index fcc3d79..a5beb15 100644 --- a/pkg/espflasher/flasher.go +++ b/pkg/espflasher/flasher.go @@ -737,8 +737,10 @@ func (f *Flasher) flashCompressed(data []byte, offset uint32, progress ProgressF blockLen := min(compSize-sent, writeSize) block := compressed[sent : sent+blockLen] - if err := f.conn.flashDeflData(block, seq); err != nil { - return fmt.Errorf("flash block %d of %d: %w", seq, numBlocks, err) + if err := f.retryFlashBlock(seq, numBlocks, func() error { + return f.conn.flashDeflData(block, seq) + }); err != nil { + return err } sent += blockLen @@ -810,8 +812,10 @@ func (f *Flasher) flashUncompressed(data []byte, offset uint32, progress Progres block = padded } - if err := f.conn.flashData(block, seq); err != nil { - return fmt.Errorf("flash block %d of %d: %w", seq, numBlocks, err) + if err := f.retryFlashBlock(seq, numBlocks, func() error { + return f.conn.flashData(block, seq) + }); err != nil { + return err } sent += blockLen @@ -923,6 +927,13 @@ func (f *Flasher) EraseRegion(offset, size uint32, progress ProgressFunc) error }) } +// flashBlockRetries is the number of attempts for each flash data block write. +// Transient serial errors (SLIP timeouts, framing glitches, USB CDC buffer +// drops) are common during flashing and typically resolve on retry. The stub +// handles duplicate sequence numbers gracefully, so resending an +// already-processed block is safe. +const flashBlockRetries = 3 + // eraseProgressInterval is the tick interval used by tickErase when reporting // synthetic erase progress via EraseFlash and EraseRegion. const eraseProgressInterval = 500 * time.Millisecond @@ -1269,6 +1280,33 @@ func compressData(data []byte) ([]byte, error) { return buf.Bytes(), nil } +// retryFlashBlock attempts a flash block write up to flashBlockRetries times. +// On failure, it flushes stale serial data and waits briefly before retrying. +// This handles transient SLIP timeouts and framing errors that are common +// during ESP32 flash operations, matching esptool.py's WRITE_BLOCK_ATTEMPTS +// retry behavior. +func (f *Flasher) retryFlashBlock(seq, numBlocks uint32, writeFn func() error) error { + var err error + for attempt := range flashBlockRetries { + err = writeFn() + if err == nil { + return nil + } + if attempt < flashBlockRetries-1 { + f.logf("Warning: block %d/%d write failed (attempt %d/%d): %v — retrying", + seq, numBlocks, attempt+1, flashBlockRetries, err) + // Wait before flushing so a delayed response from the timed-out + // attempt has time to arrive and gets cleared by the flush. + // Without this ordering, the stale response arrives after the + // flush and corrupts the retry's response parsing (e.g. SLIP + // END 0xC0 read as a status byte). + time.Sleep(200 * time.Millisecond) + f.conn.flushInput() + } + } + return fmt.Errorf("flash block %d of %d: %w", seq, numBlocks, err) +} + // logf logs a message if a logger is configured. func (f *Flasher) logf(format string, args ...interface{}) { if f.opts.Logger != nil { diff --git a/pkg/espflasher/protocol.go b/pkg/espflasher/protocol.go index dc67350..afcdea8 100644 --- a/pkg/espflasher/protocol.go +++ b/pkg/espflasher/protocol.go @@ -92,6 +92,10 @@ type conn struct { // deflCompSize is the compressed size passed to the most recent // flashDeflBegin call, used to scale the flashDeflEnd ack timeout. deflCompSize uint32 + // deflUncompSize is the uncompressed size passed to the most recent + // flashDeflBegin call, used with deflCompSize to estimate the + // decompression ratio for per-block timeouts in flashDeflData. + deflUncompSize uint32 } // isStub returns whether the stub loader is running. @@ -456,9 +460,11 @@ func (c *conn) flashDeflBegin(uncompSize, compSize, offset uint32, encrypted boo timeout := eraseTimeoutForSize(uncompSize) - // Remember the compressed size for this download so flashDeflEnd can - // scale its own ack timeout the same way. + // Remember sizes for this download so flashDeflData can estimate per-block + // decompressed sizes for timeout scaling, and flashDeflEnd can scale its + // own ack timeout. c.deflCompSize = compSize + c.deflUncompSize = uncompSize // ESP32-S2 and newer ROM bootloaders support a 5th parameter (encrypted // flag). ESP8266 and original ESP32 ROM only accept 4 parameters (16 bytes). @@ -488,7 +494,16 @@ func (c *conn) flashDeflData(block []byte, seq uint32) error { binary.LittleEndian.PutUint32(data[12:16], 0) copy(data[16:], block) - timeout := flashWriteTimeoutForSize(uint32(len(block))) + // The stub must decompress this block and write the result to flash + // before ACKing. The decompressed data can be much larger than the + // compressed block, so scale the timeout by the compression ratio + // to account for the actual flash write time. + estimatedSize := uint32(len(block)) + if c.deflCompSize > 0 && c.deflUncompSize > c.deflCompSize { + ratio := float64(c.deflUncompSize) / float64(c.deflCompSize) + estimatedSize = uint32(float64(len(block)) * ratio) + } + timeout := flashWriteTimeoutForSize(estimatedSize) _, err := c.checkCommand("write compressed flash block", cmdFlashDeflData, data, checksum(block), timeout, 0) return err }