Repository navigation
Python runtime: stop a backend's log from deadlocking two threads - #24393
shoumikhin wants to merge 2 commits into
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24393
Note: Links to docs will display an error until the docs builds have been completed. ⏳ No Failures, 102 PendingAs of commit 5e58f45 with merge base 822cb60 ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 20m 51s —— View job PR Review: #24393Needs Discussion: this removes the two-thread deadlock, but the new fd-2 sink can still hang a Jupyter kernel on Linux or macOS when one call logs more than the pipe holds, and it loses Python-side log capture in more places than the README says; writing the calling thread's lines out after the call returns would fix both. Detailed reviewSummaryThe PR writes runtime logs to the process's stderr with C stdio instead of
Thread Safety
Backward Compatibility
API Design
Testing
RecommendationNeeds Discussion The fd-2 sink fixes the two-thread deadlock, but it leaves a hang in a single call in Linux and macOS notebooks. It also loses Python-side capture in more places than documented. Writing the calling thread's lines from the outermost
Not done: I did not build or run anything. The ipykernel fd-capture behavior comes from its implementation and was not reproduced here. |
|
Thanks, the latest push does this. Fatal lines and lines logged outside a call still go straight to the process's stderr. The stderr redirect tests are back to their original form, and the docs page and the deadlock test are updated as suggested. |
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 31m 19s —— View job PR Review: #24393Request Changes: buffering a call's log lines fixes the deadlock, but a call that crashes or hangs now loses its logs even in a plain terminal, where nothing needs the buffering; buffering only when Detailed reviewSummaryThe PR keeps the runtime log lines of each binding call and writes them to One major problem: a call that crashes or hangs loses its lines, even when nothing captures
Code Quality
Testing
API Design
Thread Safety
Backward Compatibility
RecommendationRequest Changes Losing the logs of a call that crashes or hangs in a plain terminal is avoidable: buffer only when
Not done: I did not build or run anything. pybind11 is not checked out here, so the pybind11, CPython and glibc behavior cited above comes from their sources, not from a run. |
4ca018c to
7ecff8d
Compare
|
Thanks, reworked along those lines. While A write that fails or is cut short now always falls back to the process's stderr, even if that repeats some lines, and a Ctrl-C raised in it is raised again once the call returns. The saved exception and its allocation are gone. A fatal line is now just that line, written at once while stderr has room. The README states what a long call keeps. Tests cover the direct writes, a full pipe, the cap with its note and escaping, a failed or interrupted write, and the fatal line, which a too-small ETDump debug buffer triggers. The daemon thread crash at exit is real. On Linux, Python 3.13 and earlier end a daemon thread at exit with |
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 46m 51s —— View job Request Changes: the daemon-thread rule applies on platforms and Python versions where the crash it guards against cannot happen, a fatal line leaves behind the kept lines that explain it, and the new UTF-8 escaping leaves the main error message raw; each needs a small change. Detailed reviewPR Review: #24393SummaryThe PR stops the pybindings log callback from calling Python. A backend that logs while holding its own lock can no longer deadlock with a thread that waits for that lock. Lines now go to fd 2 through C stdio, or wait until the outermost binding call returns. Three problems below need small changes before merge; five more are minor. Code Quality
API Design
Backward Compatibility
RecommendationRequest Changes. Three fixes are needed before merge, and each is a few lines: limit the daemon-thread rule to Linux with Python < 3.14, write the kept lines before a fatal line, and escape the whole exception message. The other five items can land in a follow-up.
Not done: I did not build or run anything. pybind11 is not checked out here, so the pybind11, CPython and ipykernel behavior cited above comes from their sources and docs rather than this checkout. |
During a binding call, the Python bindings wrote each runtime log line through sys.stderr.write as it was logged. A backend can log while it holds its own lock, as XNNPACK does when a load fails, and a Python write can let another thread take the GIL (Python's global interpreter lock). If that thread then waits for the backend's lock, for example to execute or to destroy a method, the first thread never gets the GIL back and both hang. The log function no longer calls Python. While sys.stderr is still the stream Python opened on the process's stderr, each line goes straight there through C stdio, so a terminal or a log file sees it at once, even if the process later crashes. When Python has replaced sys.stderr, as pytest's capsys, contextlib.redirect_stderr and Jupyter do, a binding call keeps its lines and writes them to sys.stderr as it returns, when no backend lock is held. On Linux and macOS, it does the same once the process's stderr is a pipe that stays full for a tenth of a second, so a call cannot hang on a pipe that a Python thread in the same process drains. macOS reports room in a small pipe that it may not have, so there a pipe that has not grown past 16 KiB counts as full once it holds 512 bytes. A call keeps at most its newest 64 KiB and notes how much it left out. A fatal line and the lines waiting before it go out at once while there is room, since an abort follows. If writing to sys.stderr fails, the lines go to the process's stderr instead, and a Ctrl-C raised in that write is raised again once the call returns. On Linux with Python before 3.13.8, a daemon thread never writes its lines through Python. Those versions can end such a thread at exit in the middle of that write, and the unwinding then releases Python objects without the GIL and crashes the process. The old log function crashed this way too. New tests cover the hang, daemon threads that log while Python exits, a failing write, a fatal line and the line before it, a call that dies in a busy terminal, a full pipe drained by a thread, and a long call's newest lines. The docs example for registering a PAL (platform abstraction layer) now uses the right namespace.
…rupt program The bindings add the runtime's error lines to the exceptions they raise. Some of those lines carry raw bytes from the input, such as the first bytes of a file that is not a program, which the runtime prints as its identifier. The message itself is cut at 128 bytes, which can split a character of a path. Python rejects an exception message that is not valid UTF-8, so such a load raised UnicodeDecodeError instead of RuntimeError. The whole message is now decoded with backslashreplace, so those bytes show as escapes. The long-call test, which loads such a file, now expects RuntimeError, and a new test loads paths whose message is cut inside a character.
7ecff8d to
5e58f45
Compare
|
Thanks. Changed in this push:
Not changed:
|
|
@claude /pr-review |
PR Review: #24393
|

What is wrong today
Two threads can hang forever when a backend logs while holding a lock. XNNPACK does this when a load fails.
During a binding call, each runtime log line goes to
sys.stderrat once, and a Python write can let another thread take the GIL (Python's global interpreter lock). If that thread then waits for that lock, the first thread never gets the GIL back:What this change does
The log function no longer calls Python. While
sys.stderris the stream Python opened on fd 2, each line goes straight there through C stdio, so it shows at once, even before a crash.When Python has replaced
sys.stderr(capsys,redirect_stderr, Jupyter), a call keeps its lines and writes them there as it returns, with no backend lock held. On Linux and macOS, it does the same once fd 2 is a pipe that stays full for 100 ms, so a pipe that a Python thread drains (wurlitzer.pipes()) cannot hang the call. On macOS, pipes up to 16 KiB count as full at 512 bytes, because macOS can report room they do not have. A call keeps at most its newest 64 KiB. If writing tosys.stderrfails, the lines go to fd 2 instead. A fatal line and the lines waiting before it go out while there is room, since an abort follows.On Linux with Python before 3.13.8, a daemon thread never writes through Python, since Python can end it at exit mid-write and crash the process, as it does on main.
Tests
New tests cover the hang, daemon threads at exit, failed and interrupted writes, fatal lines, a busy terminal, a full pipe drained by a thread, the 64 KiB cap, and cut error messages. Ran the pybindings tests on Linux x86_64 (the quantized-ops tests cannot run in this build) and the XNNPACK case from two threads.
Also fixed: an error message with invalid UTF-8, such as a corrupt program's identifier or a path cut inside a character, raised
UnicodeDecodeErrorinstead ofRuntimeError; such bytes are now escaped. The docs now name the right namespace for registering a PAL (platform abstraction layer).