Skip to content

_forward_io dies silently when a multi-byte UTF-8 char is split across writes, truncating DUT output (CII-239) #425

Description

@hacker-cb

Describe the bug

_PopenRedirectProcess._forward_io (pytest-embedded/pytest_embedded/log.py:155-162, identical on v2.8.1 and main) reads the redirect log in text mode:

with open(logfile) as fr:          # text mode, errors='strict'
    while True:
        try:
            msg_queue.put(fr.read())  # msg_queue may be closed
        except Exception:
            break

The file is being appended to concurrently, so the reader keeps hitting a temporary EOF. When that end falls inside a multi-byte sequence, the decoder raises:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe2 in position 43: unexpected end of data

except Exception swallows it: the reader exits with exitcode=0, empty stderr, and DUT output simply stops mid-stream. Downstream this surfaces as pexpect.exceptions.TIMEOUT on whatever expect() was waiting for, with nothing anywhere mentioning decoding.

Not specific to read() without a size — read(n) and readline() fail the same way; only binary mode avoids it. The text-mode open exists in 1.x too, so the decode issue predates 387b877 (#374); that commit only made it silent (1.18.x prints a traceback and exits 1).

Configuration Files

Not relevant — no project config needed, and no ESP-IDF: the defect is in the pure-Python forwarding path.

To Reproduce

The trigger is a multi-byte character split across two writes to the log, which a byte-at-a-time UART/QEMU stream does naturally. Writing whole lines with a single write() does not reproduce.

The decode itself:

p = 'x.log'
open(p, 'wb').write(b'hello \xe2\x80\x94 world\n[SKIP] tail \xe2')
open(p).read()   # UnicodeDecodeError: ... unexpected end of data

Against the real class: start _PopenRedirectProcess(msg_queue, logfile), append an ASCII chunk (arrives), then append a chunk ending mid-em-dash — is_alive=False, exitcode=0, child stderr empty, and the text before the split byte is lost too (read() raises before returning anything). End to end in my run, one em-dash (U+2014) in device output stopped the stream at line 40 and the run failed with TIMEOUT: Not found "Press ENTER to see the list of tests" after 30 s.

Expected behavior

Forwarding should not depend on where chunk boundaries fall.

Suggested fix:

with open(logfile, 'rb') as fr:

The decode looks unnecessary here: MessageQueue.put accepts str | bytes and calls to_bytes() (identity for bytes), PexpectProcess's buffer is bytes, and the serial transport already puts raw bytes on the same queue (pytest-embedded-serial/pytest_embedded_serial/serial.py:244, self._q.put(s) with s = self._s.read_all(), no decode) — so today's path is a bytes → str → bytes round trip, and 'rb' makes the two transports consistent.

One honest caveat: text mode does universal-newline translation, so 'rb' stops swallowing CR (CRLF sample: 45 bytes written → 43 received before, 45 after). That is the same raw \r\n the serial path already delivers, and _listen's \r\n\n plus splitlines() in the Unity parser already handle it.

Optionally, except Exception could be narrowed — MessageQueue.put already swallows a closed queue itself, and the only escape I observed was BrokenPipeError at teardown — but I haven't proven that list exhaustive, so treat it as a suggestion.

Related, not part of this report: pytest-embedded-idf/pytest_embedded_idf/unity_tester.py:181 does a strict .decode('utf8'), and _listen's to_str(msg) uses errors='ignore', so a split character vanishes from the console copy without a trace.

Dev Environment (please complete the following information):

  • OS: Linux (Ubuntu 24.04, Docker) and macOS
  • Python Version: 3.12.3 and 3.14.5
  • Pytest Version: 9.1.1
  • pytest-embedded Version: 2.8.1 and 2.2.1 (also 1.18.1, where it exits 1 with a traceback); git diff v2.8.1..main -- pytest-embedded/pytest_embedded/log.py is empty

I couldn't find an existing issue for this. Happy to send a PR with the 'rb' change and a regression test if that's welcome.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions