Skip to content

fix: escape undecodable subprocess output bytes and deflake graceful shutdown test#277

Merged
epmog merged 1 commit into
OpenJobDescription:mainlinefrom
leongdl:fix/py312-flake-and-backslashreplace
Jul 21, 2026
Merged

fix: escape undecodable subprocess output bytes and deflake graceful shutdown test#277
epmog merged 1 commit into
OpenJobDescription:mainlinefrom
leongdl:fix/py312-flake-and-backslashreplace

Conversation

@leongdl

@leongdl leongdl commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #275.

What was the problem/requirement? (What/Why)

Two things left over from #275:

  1. errors="replace" discards the original byte value. The review on fix: decode subprocess output with errors="replace" to keep the reader thread alive #275 recommended backslashreplace instead: it keeps the stream-reader thread alive exactly the same way, but renders an undecodable byte as \xc7 instead of , preserving the byte value in the logs. That is genuinely useful when debugging which codepage a DCC batch renderer is emitting — the very scenario that motivated the original fix — and it is what CPython itself uses for its own stderr.

  2. test_graceful_shutdown is flaky. The Python 3.12 macOS CI job on fix: decode subprocess output with errors="replace" to keep the reader thread alive #275 failed (attempt 2) with AssertionError: assert 'Received SIGTERM signal.' in '' and only passed on re-run. The test spawned fake_client.py, slept a fixed 0.5 s, then sent SIGTERM. On a slow CI host the signal can arrive before the client registers its signal handler in HTTPClientInterface.__init__, so the default handler kills the process and stdout is empty. The failure is timing-dependent, not 3.12-specific.

What was the solution? (How)

  1. Switched the Popen decode error handler in _logging_subprocess.py from replace to backslashreplace, expanded the call-site comment (including a note that encoding/errors also apply to stdin), and updated the three Popen argument assertions in the unit tests.
  2. Replaced the fixed sleep with a handshake: fake_client.py now prints a flushed client ready line after its constructor (which registers the signal handler) returns, and the test blocks on stdout.readline() for that line before sending the signal.

Also removed the redundant p._cleanup_io_threads() call in the regression test from #275p.wait() already performs the cleanup on every path.

What is the impact of this change?

Undecodable bytes in subprocess output now appear in logs as their escaped byte value (e.g. \xc7) instead of the replacement character, so no forensic information is lost. The graceful-shutdown integration test no longer depends on a fixed startup sleep.

How was this change tested?

  • New parametrized integration test test_non_utf8_output_is_escaped covering: a single cp1252 byte (0xc7, observed from 3dsmaxbatch.exe), 0xff (never valid in UTF-8), consecutive invalid bytes, a cp1252 two-byte run, and a truncated UTF-8 multi-byte sequence. Each case asserts the exact escaped form is logged, the full line is preserved, and no U+FFFD appears.

  • New integration test test_valid_utf8_is_not_escaped asserting valid multi-byte UTF-8 (héllo wörld Ç 星期五) passes through unmodified.

  • Updated the fix: decode subprocess output with errors="replace" to keep the reader thread alive #275 regression test to assert the escaped form.

  • Ran the previously flaky test_graceful_shutdown 10× consecutively on Python 3.12 — stable.

  • Full suite on Python 3.12: 482 passed, 26 skipped. ruff, black --check, and mypy all clean.

  • Have you run the unit tests? Yes.

Was this change documented?

The comment at the Popen call site was updated to explain the escaping behavior and the stdin caveat. No public docstrings needed changes.

Is this a breaking change?

No. The only behavioral change is that previously-replaced undecodable bytes are now rendered as escaped byte values in logs.

Does this change impact security?

No. backslashreplace only affects invalid byte sequences; it cannot synthesize newlines or control characters, and it does not change any trust boundary.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

…shutdown test

Follow-up to OpenJobDescription#275, which decoded subprocess stdout/stderr with
errors="replace" to keep the stream-reader thread alive when a child
process emits non-UTF-8 (locale-encoded) bytes.

Switch the decode error handler from "replace" to "backslashreplace"
so undecodable bytes are escaped (e.g. b"\xc7" -> "\xc7") instead of
collapsed to U+FFFD. This keeps the reader alive exactly the same way
while preserving the original byte values in the logs, which helps
identify the codepage a DCC batch renderer is emitting. Add integration
tests covering single invalid bytes, consecutive invalid bytes, cp1252
text runs, truncated UTF-8 sequences, and that valid multi-byte UTF-8
passes through unescaped.

Also fix the flaky test_graceful_shutdown integration test that failed
on the Python 3.12 macOS CI job for OpenJobDescription#275. The test slept a fixed 0.5s
after spawning fake_client.py before sending SIGTERM; on a slow CI host
the signal could arrive before the client registered its handler,
killing the process with empty output. The client now prints a ready
line after handler registration and the test blocks on it before
signaling.

Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
@leongdl
leongdl requested a review from a team as a code owner July 20, 2026 23:25
test_client = FakeClient("1234")
# Signal handler registration (in the main thread) happens in the constructor above.
# Tests wait for this line before sending signals to avoid a startup race.
print("client ready", flush=True)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100 out of 100 runs passed, zero failures.

Each iteration was a fresh pytest invocation of test_graceful_shutdown on Python 3.12, so every run spawned a new fake_client.py subprocess and exercised the full spawn → handshake → SIGTERM → assert cycle independently. Under the old fixed-sleep approach this is exactly the loop that would eventually hit the race; with the client ready handshake the signal can never arrive before the handler is registered, so the fix is deterministic rather than just statistically better.

@epmog
epmog enabled auto-merge (squash) July 21, 2026 17:10
@epmog
epmog merged commit 67efbac into OpenJobDescription:mainline Jul 21, 2026
25 checks passed
@leongdl
leongdl deleted the fix/py312-flake-and-backslashreplace branch July 21, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants