Skip to content

Python runtime: stop a backend's log from deadlocking 2 threads - #24393

Merged
shoumikhin merged 2 commits into
pytorch:mainfrom
shoumikhin:pybindings-release-gil
Nov 3, 2026
Merged

shoumikhin merged 2 commits into
pytorch:mainfrom
shoumikhin:pybindings-release-gil

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

2 threads can hang forever when a backend logs while holding a lock, as XNNPACK does when a load fails. A log line from a thread that holds the GIL (Python's global interpreter lock) goes to sys.stderr at once, and that Python write can let another thread take the GIL. If that thread then waits for the lock, neither thread moves again:

thread A: load_method -> takes lock L -> logs -> sys.stderr.write releases the GIL
thread B: takes the GIL -> execute -> waits for lock L, holding the GIL
thread A: waits for the GIL forever

What this change does

The log function never runs Python code. While sys.stderr is the process's stderr (fd 2), each line goes straight to fd 2.

When Python has replaced sys.stderr (capsys, redirect_stderr, a Jupyter notebook), the lines wait:

  • A binding call's lines are written as it returns, with no backend lock held.
  • A line that Python's main thread logs outside a call while holding the GIL (for example while the LLM runner loads) is written before the next Python code runs, so it still lands in a short redirect_stderr block.
  • Other threads' lines go to a thread that the bindings start. It never writes from inside another thread's sys.stderr.write, so a stream whose write takes a lock cannot hang.

Outside a binding call, threads that do not hold the GIL write to fd 2, as today. Up to 1 MiB waits, keeping the newest.

On Linux and macOS, a line also waits while fd 2 is a pipe or socket that stays full, so a GIL holder does not block while a Python thread drains it (wurlitzer.pipes()). A terminal is written at once, since its reader is normally another process. A fatal line goes to fd 2 at once while there is room.

On Linux with Python before 3.13.8, a daemon thread never writes through Python. Today it does, and Python can end it mid-write at exit, which crashes the process.

Tests

New tests cover the hang (also from the LLM runner), a stream whose write takes a lock, a full pipe or socket drained by a thread, lines inside a short redirect, daemon threads at exit, and cut error messages. Ran them on Linux x86_64 and macOS, and the logging cases in Jupyter with fd capture on and off.

Also fixed: an error message with invalid UTF-8, such as a corrupt program's identifier, raised UnicodeDecodeError, not RuntimeError. The docs on registering a PAL (platform abstraction layer) now use the right namespace and a complete example.

@shoumikhin shoumikhin added the release notes: api Changes to public facing apis (any interfaces, pybinded runtime methods, etc.) label Oct 31, 2026
Copilot AI balanced review requested due to automatic review settings October 31, 2026 17:23
@pytorch-bot

pytorch-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

🔗 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.

❌ 1 New Failure, 2 Cancelled Jobs, 1 Unrelated Failure

As of commit 7a5fb48 with merge base 0f8e096 (image):

NEW FAILURE - The following job has failed:

CANCELLED JOBS - The following jobs were cancelled. Please retry:

FLAKY - The following job failed but was likely due to flakiness present on trunk:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 20m 51s —— View job


PR Review: #24393

Needs 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 review

Summary

The PR writes runtime logs to the process's stderr with C stdio instead of sys.stderr, so a backend that logs while holding its own lock can no longer hand the GIL to a thread that waits for that lock. Four problems remain:

  • another way to hang, in Jupyter on Linux and macOS
  • Python-side log capture is lost in more places than documented
  • a docs page is now out of date
  • the new test can miss failures on its worker thread

Thread Safety

  • extension/pybindings/pybindings.cpp:5346: in Jupyter on Linux and macOS, this write can still block while the GIL is held.
    • ipykernel 6+ points fd 2 at a pipe. A Python thread (OutStream._watch_pipe_fd) empties that pipe, and it needs the GIL after every read.
    • The bindings hold the GIL for the whole load_method/execute. Two things follow:
      • Runtime lines reach the notebook only after the call returns, and may show up after output the cell prints later. They do not show "right away" as the description says.
      • If one call logs more than the pipe holds (64 KiB by default on Linux), fwrite blocks in write(2) forever. While blocked it holds the GIL, the stdio lock on stderr, and in the motivating case the backend lock. Holding the stdio lock means every other native thread that logs blocks too. This hangs even a single-threaded notebook.
    • What triggers it: a Debug-log build, or a delegate that forwards verbose library logs to ET_LOG.
    • Other tools that hang the same way: wurlitzer, or any capture that pairs a pipe with a Python reader thread. The README's advice to "capture file descriptor 2" points users toward these. capfd is safe, because it writes to a temp file.
    • The old path went through OutStream.write, which only buffers in memory.
    • Suggested direction:
      • While runtime_error_scopes != 0, have emit_log_message append the formatted lines to a plain thread_local buffer, the way it already fills runtime_errors.
      • Write those lines to sys.stderr from the outermost ~RuntimeErrorScope. The call guard is destroyed after the bound function returns, so at that point the GIL is held and no backend lock or ObjectLock is.
      • This avoids both the original deadlock and this one, and keeps Python-side capture working (see Backward Compatibility).
      • Costs:
        • Lines appear when the call returns, not while it runs.
        • kFatal lines should still go straight to fd 2, because an abort follows.
        • The destructor can run during stack unwinding, so it must swallow Python errors.
    • I did not reproduce this. It is based on how ipykernel's OutStream captures fds. Fix this →

Backward Compatibility

  • extension/pybindings/pybindings.cpp:5346, extension/pybindings/README.md:238-242: Python-side capture is lost in more places than the description and README say.
    • The description names only contextlib.redirect_stderr. These break the same way:
      • pytest capsys
      • unittest --buffer: logs are no longer buffered, and they are not attached to failure reports
      • IPython %%capture: in a Linux or macOS notebook, the fd watcher forwards the lines, so they bypass %%capture and still print
      • anything else that installs its own sys.stderr
    • Jupyter on Windows is affected too. ipykernel does not capture fds there, so runtime logs go to the server's console instead of the notebook.
    • In all of these cases, Info and Warning lines are gone. The exception keeps only the text of Error-level messages, cut off at kRuntimeErrorsCapacity (1024 bytes; pybindings.cpp:138, :5333-5345). The README's "includes the error lines" does not mention this limit.
    • If the fd-2 design stays, the README note should list these cases and the 1 KiB limit. Fix this →

API Design

  • docs/source/using-executorch-runtime-integration.md:42-64 is now out of date. It still presents the pybindings sink as the example "where logs are redirected to show up properly in a Python notebook environment". Its sample writes to std::cerr, which the new comment at pybindings.cpp:5319-5322 rules out in a Python process. The PR updates the README but not this page. Fix this →

Testing

  • extension/pybindings/test/test_pybindings.py:431-468: failures on the worker thread are not reported.
    • run() runs on a bare threading.Thread, and nothing collects its exception.
    • Suppose method(inputs) raises after the load has returned, for example because the failed init left the shared workspace unusable. The script still prints "no deadlock" and the test passes.
    • Suppose instead it raises while SlowStderr is installed. The traceback goes into SlowStderr, which throws the text away, so the test fails showing only a writes count.
    • test_torch_inputs_are_safe_across_threads in the same file reports worker errors through future.result(). Do the same here, and have SlowStderr keep its text so the assertion message can show it. Fix this →

Recommendation

Needs 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 ~RuntimeErrorScope would fix both without releasing the GIL. If the fd-2 design stays, the README and the docs page need the corrections above.


  • Gather context (diff, commits, CLAUDE.md, review checklist)
  • Run three independent reviewers over the diff
  • Consolidate findings
  • Fact-check findings
  • Post review

Not done: I did not build or run anything. The ipykernel fd-capture behavior comes from its implementation and was not reproduced here.

Copilot AI balanced review requested due to automatic review settings October 31, 2026 23:16
@shoumikhin shoumikhin changed the title Python runtime: write runtime logs straight to stderr so two threads cannot deadlock Python runtime: write a call's logs when it returns, so a backend's log cannot deadlock two threads Oct 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

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.

Copilot AI balanced review requested due to automatic review settings November 1, 2026 06:35
@shoumikhin shoumikhin changed the title Python runtime: write a call's logs when it returns, so a backend's log cannot deadlock two threads Python runtime: write a call's logs when it returns, so a backend's log no longer deadlocks two threads Nov 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 31m 19s —— View job


PR Review: #24393

Request 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 sys.stderr has been replaced would keep both.

Detailed review

Summary

The PR keeps the runtime log lines of each binding call and writes them to sys.stderr when the outermost call returns. A backend that logs while holding its own lock therefore can no longer hand the GIL to a thread that waits for that lock.

One major problem: a call that crashes or hangs loses its lines, even when nothing captures sys.stderr. The rest are minor:

  • The lines of an interrupted write are either lost or written twice, depending on a condition unrelated to the write.
  • An out-of-memory guard does not cover the allocation most likely to fail.
  • Several new paths are untested.
  • The README's 64 KiB figure is misleading.
  • A daemon thread may abort the process at exit (low confidence).

Code Quality

  • extension/pybindings/pybindings.cpp:251-254: the lines of an interrupted write are either lost or written twice.
    • Lost: with no exception saved earlier, a KeyboardInterrupt from sys.stderr.write sets written = true. If the writer raised before writing anything, the lines reach no stream at all. test_pybindings.py:555-558 asserts exactly this.
    • Written twice: if PyErr_CheckSignals() already saved an exception (one Ctrl-C during the call, a second during the write), the condition is false and written stays false. Line 261 then writes every line to fd 2, even if the interrupted write already printed some. The comment at 248-250 says those lines are not written again.
    • Fix: decide written separately from saving the exception, and use one rule for both cases. For failure logs, a possible duplicate costs less than a loss. Fix this →
  • extension/pybindings/pybindings.cpp:228-231: new (std::nothrow) does not make this construction out-of-memory safe.
    • It covers only the outer allocation. pybind11's error_already_set() (2.10 and later) also allocates its fetched-error state with a plain new inside the constructor.
    • Line 228 is outside the try, in a noexcept function, so a bad_alloc from that inner allocation still ends in std::terminate.
    • Fix: drop std::nothrow and the nullptr branch. That is simpler, which CLAUDE.md prefers, and no worse.
    • This is based on pybind11's source; the submodule is not checked out here. Fix this →

Testing

  • extension/pybindings/test/test_pybindings.py:380-558: most of the new paths have no test.
    • Ctrl-C during a call (PyErr_CheckSignals() at pybindings.cpp:227-232). The description promises "A Ctrl-C during a call reaches the caller after the call's lines", but the new test only covers a write that raises.
    • The 64 KiB cut and its left-out note (:165-175, :235-237).
    • write_fatal_context (:183-195, :5499-5504), which is now the only way a call's lines survive an abort.
    • The backslashreplace decoding (:240-241). This one is cheap to cover:
      • _load_program_from_buffer(b"\xff" * 128) logs the raw bytes through Program identifier '%.4s' (runtime/executor/program.cpp:170).
      • With a StringIO installed as sys.stderr, the test should get those bytes back escaped. Fix this →

API Design

  • extension/pybindings/README.md:240-241: "keeps its last 64 KiB" overstates what is kept.
    • Whenever the lines pass 64 KiB, keep_line (pybindings.cpp:165-175) cuts them to at most 32 KiB.
    • So a call that logs more than 64 KiB keeps between about 32 and 64 KiB. A call that logs 66 KiB shows only about its last 33 KiB. Fix this →

Thread Safety

  • extension/pybindings/pybindings.cpp:245, :256, :260: a daemon thread can abort the process at exit (low confidence).
    • What changed: ~RuntimeErrorScope now runs code that releases the GIL:

      • the sys.stderr.write at 245, since a file write drops the GIL around the system call;
      • the py::gil_scoped_release at 260.

      Before this PR, the destructor only copied runtime_errors back.

    • Why it aborts: on CPython 3.13 and earlier, a thread that retakes the GIL after another thread has started Py_Finalize is ended with pthread_exit, which glibc runs as a forced unwind.

      • At 245, the catch (...) at 256 swallows that unwind, and glibc then aborts with FATAL: exception not rethrown.
      • At 260, the unwind starts inside ~gil_scoped_release, which is noexcept, so it calls std::terminate.
    • Trigger: a daemon thread whose binding call logged something is inside that write when the main thread exits. CPython 3.14 hangs such threads instead of ending them.

    • Confidence: not reproduced; this comes from CPython's take_gil and glibc's pthread_exit. I don't see a simple fix inside a noexcept destructor, so this may only need a note.

Backward Compatibility

  • extension/pybindings/pybindings.cpp:5495-5505, README.md:244: a call that crashes or hangs now loses its log lines, even when nothing captures sys.stderr.
    • What changed: during a binding call, every non-fatal line goes only into PendingLogs and leaves at line 293, when the call returns. Before this PR it went to sys.stderr at once, so a plain python script.py, pytest -s or CI log showed it right away.

    • Triggers:

      • a delegate logs an error, then segfaults or aborts without ET_LOG(Fatal) (for example a vendor SDK abort(), or std::terminate);
      • the process is killed;
      • the call hangs.

      These are the usual situations in which someone reads a delegate's log.

    • Why it is avoidable: the buffering only matters when a line has to go through Python. While sys.stderr is still the original stream, lines can go to fd 2 directly. That is the C stdio write the PR already uses for lines logged outside a call, and it cannot hand off the GIL.

    • Suggested fix:

      • When the outermost RuntimeErrorScope starts (GIL held), compare PySys_GetObject("stderr") with PySys_GetObject("__stderr__"). Both are borrowed lookups that run no Python code.
      • If they match, write each line to fd 2 as it is logged.
      • Otherwise sys.stderr has been replaced, and only then should lines be buffered. capsys, redirect_stderr, unittest --buffer, %%capture and ipykernel's OutStream all replace it, so Python-side capture and the Jupyter pipe case keep the buffered path.
      • This still leaves one exposure: fd-level capture that leaves sys.stderr alone, such as wurlitzer.pipes(), would take the direct path. Lines logged outside a call already do.
    • The earlier review suggested buffering every line and missed this cost. Fix this →

Recommendation

Request Changes

Losing the logs of a call that crashes or hangs in a plain terminal is avoidable: buffer only when sys.stderr has been replaced. The other items are minor.


  • Gather context (diff, commits, CLAUDE.md, review checklist)
  • Run three independent reviewers over the diff
  • Consolidate findings
  • Fact-check findings
  • Post review

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.

@shoumikhin shoumikhin changed the title Python runtime: write a call's logs when it returns, so a backend's log no longer deadlocks two threads Python runtime: write runtime logs without calling Python, so a backend's log no longer deadlocks two threads Nov 1, 2026
Copilot AI balanced review requested due to automatic review settings November 1, 2026 17:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin shoumikhin changed the title Python runtime: write runtime logs without calling Python, so a backend's log no longer deadlocks two threads Python runtime: stop a backend's log from deadlocking two threads Nov 1, 2026
@shoumikhin
shoumikhin force-pushed the pybindings-release-gil branch from 4ca018c to 7ecff8d Compare November 2, 2026 15:00
Copilot AI balanced review requested due to automatic review settings November 2, 2026 15:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks, reworked along those lines. While sys.stderr is the process's own stderr, each runtime log line now goes straight to it as it is logged, so a crash or hang no longer loses it. Lines wait for the call to return only when Python has replaced sys.stderr, or, on Linux and macOS, when the process's stderr is a pipe that stays full for a tenth of a second, as when a Python thread in the same process drains it under wurlitzer.pipes().

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 pthread_exit when it waits for the GIL. If that happens inside a write through Python, the unwinding releases Python objects without the GIL. The main branch crashes this way too, with a segfault in its log function. So a daemon thread now never writes its lines through Python; they go straight to the process's stderr. In a script whose daemon threads keep logging while Python exits, the main branch crashed in all 30 runs with sys.stderr left alone and in 10 of 30 with it replaced. With this change, none of the 60 runs crashed, and a test covers it.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

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 review

PR Review: #24393

Summary

The 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

  • A fatal line goes out alone, and the kept lines that explain it die with the abort (extension/pybindings/pybindings.cpp:5522-5526). The call's lines start to wait once sys.stderr is replaced or a pipe fills. A later fatal line then goes to fd 2 at once while there is room. The earlier lines stay in pending_logs, and the abort() that usually follows loses them, although they carry the context for the abort. The README accepts that a crash loses waiting lines, but here the code knows the abort is coming. Fix: before the fatal line, write the kept lines to fd 2 while there is room, as the daemon branch does (252-261). Fix this →

  • The UTF-8 escaping covers only the appended runtime errors (pybindings.cpp:287-301). result(message) stays raw. THROW_IF_ERROR and THROW_INDEX_IF_ERROR (77-95) cut the message at 128 bytes with snprintf. pybind11 sets the exception message with PyErr_SetString, which decodes strictly. So a non-ASCII path cut mid-character still raises UnicodeDecodeError instead of RuntimeError or IndexError. Example: loader_from_file (2241-2245) with a long CJK path that does not exist. The last commit fixes this same error, but only for the appended part. Fix: decode the whole message once with backslashreplace. Fix this →

  • One 100 ms wait on a full pipe holds every later line of the call (pybindings.cpp:5522). direct = false stays set for the rest of the call. Under 2>&1 | tee or a CI log pipe, a single stall by the reader is enough. The later lines then wait in memory: past 64 KiB the oldest are dropped, and a crash or hang in the call loses all of them. On main they were written. Fix: when a full pipe caused the wait, rather than a replaced sys.stderr, check again with a zero timeout on later lines. Once there is room, write the kept lines and go back to direct writes. Fix this →

  • A KeyboardInterrupt from the deferred write is replayed as a new SIGINT, not re-raised (pybindings.cpp:272-277).

    • PyErr_SetInterrupt runs the Python SIGINT handler again, so a custom handler that raises runs twice for one Ctrl-C.
    • If SIGINT was ignored when the process started, for example in a background job (cmd &) of a non-interactive shell, Python installs no handler and the call does nothing. In such an environment the test's assert interrupted (test_pybindings.py:607) fails.
    • Other exceptions a signal handler raises in that write are still swallowed, as on main. One example is SystemExit from a SIGTERM handler that calls sys.exit().

    Fix (low confidence, since pybind11 is not checked out here): keep the error_already_set and rethrow it from ~RuntimeErrorScope when no exception is unwinding. Fix this →

API Design

  • The runtime-integration guide repeats the bindings' routing rules, and already gets daemon threads wrong (docs/source/using-executorch-runtime-integration.md:74-80). It says a waiting line "goes to sys.stderr". On a daemon thread it goes to fd 2 instead. A second copy of these rules will drift from the README Note and from emit_log_message. Fix: keep the deadlock rationale and leave the routing details to the existing README link. Fix this →

Backward Compatibility

  • The daemon-thread rule applies on every OS and Python version, but the crash it avoids needs Linux and Python 3.13 or earlier (pybindings.cpp:187-198, used by RuntimeErrorScope).

    • The code comment and the README limit the crash to Linux.
    • Python 3.14 hangs such a thread instead of calling pthread_exit (pthread_exit & PyThread_exit_thread from PyEval_RestoreThread etc. are harmful python/cpython#87135), and ExecuTorch supports 3.11 to 3.15 (pyproject.toml:53-60).
    • Everywhere else, a daemon thread's lines skip a replaced sys.stderr. They escape capsys and redirect_stderr. On Windows, where ipykernel does not capture fd 2, they never reach the notebook.
    • Daemon threads are common: multiprocessing.pool.ThreadPool workers, ThreadingHTTPServer handlers, and Thread(daemon=True) in notebooks.
    • Their kept lines reach fd 2 only while it has room (252-261). The rest are dropped without the note that keep_line adds.
    • test_pybindings.py:553 asserts writes through Python: 0 on every platform, which locks this in.

    Fix: return false except on Linux with Python < 3.14, and scope the README and the test to match. If the rule is meant to be broader, the comment should give a reason that holds on the other platforms. Fix this →

  • Lines logged outside a binding call now skip sys.stderr, even while the thread holds the GIL (pybindings.cpp:5515).

    • On main they went through sys.stderr.write. Now they escape capsys, never reach a notebook on Windows, and are written with no room check.
    • On Linux and macOS, that write can block on a full kernel pipe while it holds the GIL. The README documents this as a hang.
    • The README lists destructors and other modules as "outside a call". The ExecuTorchResult and CUDA stream bindings (5063-5085, 5097-5181) also omit call_guard, and the README does not mention them.
    • to_host and from_host reach CUDAGuard::create, which can log (extension/cuda/device_guard.cpp:25-34).

    Fix: guard those bindings. For a line logged outside a call on a thread that holds the GIL, drop the line rather than block when the pipe has no room. Fix this →

  • Non-ASCII runtime lines on a Windows console now show in the console's code page (pybindings.cpp:160-163). write_to_stderr hands UTF-8 bytes to fwrite. On main, a line logged with the GIL held went through sys.stderr, whose console writer handles Unicode. Non-ASCII text, such as a path in a loader error, is now garbled. The README documents this, but it is still a regression. Fix: when fd 2 is a console, convert to UTF-16 and write with WriteConsoleW. Fix this →

Recommendation

Request 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.

  • Gather context
  • Run three independent reviewers
  • Consolidate findings
  • Fact-check findings
  • Post review

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.

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 58m 35s —— View job


Approve. Nothing here blocks the merge; all the remaining issues are minor:

  • a fatal line skips the lines already waiting for Python's main thread;
  • outside a call, every line logged into a full pipe waits 100 ms, and lines can come out of order;
  • a socket on fd 2 gets no room check;
  • a few comments and one README sentence promise more than the code does.
Detailed review

PR Review: #24393

Summary

The PR writes runtime logs to fd 2 through C stdio, so a backend that logs while holding its own lock can no longer deadlock with a thread waiting for the GIL. When sys.stderr is replaced or the pipe stays full, the lines wait until the binding call returns or until Python's main thread writes them. A corrupt program now raises RuntimeError. Seven minor findings remain, and none of them blocks merging.

Code Quality

  • A fatal line leaves the lines waiting for Python's main thread unwritten (extension/pybindings/pybindings.cpp:5654-5678).

    • On a kFatal line inside a call, only the lines that call kept are written (5674-5678). Outside a call, only the fatal line goes out (5656-5660). Anything in deferred_logs() is lost when the process aborts.
    • Trigger: in a notebook, the LLM runner's constructor logs on the main thread with the GIL held, and an ET_CHECK then fails in the same call.
    • The README sentence "Any other crash or hang of the process loses the lines still waiting" (README.md:266-267) reads as if a fatal line writes these lines.
    • main also lost these lines in that case, because they sat in ipykernel's buffer, so this is not a regression.
    • Fix, either way: take the queue with try_lock and write it first. Use try_lock because defer_lines holds the mutex during a blocking write once exiting is set. Or say in the README that these lines are lost.

    Fix this →

  • Importing under a redirect leaves the cached sys.stderr check empty (pybindings.cpp:195-209, 5182).

    • stderr_is_process_stderr() caches nothing while sys.stderr is replaced.
    • Trigger: import the module inside with contextlib.redirect_stderr(buf):, then leave the block. line_outside_a_call_waits (5615-5619) then compares the restored sys.stderr with nullptr. Until the first binding call, it defers every line that a thread holding the GIL logs outside a call.
    • This contradicts the comment at 5180-5181 and the README's "as they are logged". An abort in that window loses those lines.
    • Fix: cache sys.__stderr__ and return whether sys.stderr is that object. The check at import then fills the cache even under a redirect.

    Fix this →

  • THROW_IF_ERROR and THROW_INDEX_IF_ERROR now need the GIL (pybindings.cpp:76-96, 405-420).

    • with_runtime_errors now calls PyUnicode_DecodeUTF8; on main it was plain C++. The macro comment doesn't mention the GIL.
    • No code in this file throws without the GIL today. But extension/llm/runner/pybindings.cpp:99-102 throws inside py::gil_scoped_release with its own macro, and the same pattern would crash here.
    • The call_guard comment at 5177-5178 says only the destructor needs the GIL. The constructor also calls on_daemon_thread() and stderr_is_process_stderr().

    Fix this →

Testing

  • The exit path is not tested (pybindings.cpp:373-403).

    • No test checks that write_deferred_logs_at_exit writes the waiting lines. No test checks that a line logged after it, through the exiting branch of defer_lines, reaches fd 2.
    • test_daemon_threads_that_log_do_not_crash_at_exit (test_pybindings.py:509) checks only the exit code and the counts printed before exit.
    • Suggested test: register an atexit handler before importing portable_lib, so it runs after the module's own handler. In it, replace sys.stderr and log outside a call. That covers the exiting branch.

    Fix this →

API Design

  • The README's exit sentence covers only the main thread's queue (extension/pybindings/README.md:267-268).

    • "Once Python starts to exit, the lines still waiting are written, and later lines go to the process's stderr" holds only for lines that go through defer_lines.
    • A binding call still running on another thread writes the lines it kept only when it returns.
    • A call that returns during exit still writes through sys.stderr. The only exception is a daemon thread on Linux with Python before 3.13.8.

    Fix this →

Performance

  • Outside a call, every line logged into a full pipe waits its own 100 ms, and lines can come out of order (pybindings.cpp:5615-5619, 5654-5662).

    • A call stops polling after its first miss (direct = false, 5672). line_outside_a_call_waits polls on every line.
    • Under wurlitzer.pipes(), a thread holding the GIL, such as the LLM runner loading a model, stalls every Python thread for 100 ms per line once the pipe is full.
    • With a reader in another process, a later line that finds room is written at 5660, ahead of the lines already queued.
    • Fix: while lines are queued, defer the next line without polling. For example, use an atomic flag that defer_lines sets and the code that takes the queue clears.

    Fix this →

  • A socket on fd 2 gets no room check (pybindings.cpp:254).

    • stderr_has_room returns true for anything that is not S_ISFIFO.
    • Under systemd with StandardError=journal, fd 2 is a Unix socket. write_to_stderr then blocks in fwrite with the GIL held, and every Python thread stalls while journald lags. On main, sys.stderr.write released the GIL during the write.
    • Fix: poll S_ISSOCK too, apply the macOS st_blksize rule to pipes only, and mention sockets in README.md:253-256.

    Fix this →

Recommendation

Approve

All seven findings are minor. The fixes worth making first are the fatal path and the per-line polling; the rest are small edits.

  • Gather context (diff, commits, CLAUDE.md, review checklist)
  • Run three independent reviewers over the diff
  • Consolidate findings (about 15 candidates after removing duplicates)
  • Fact-check findings
  • Post review

Not done: I built and ran nothing. third-party/pybind11 is not checked out here, so I didn't read pybind11's internals. What this review says about CPython, ipykernel, systemd/journald and wurlitzer comes from reading their behavior, not from running them.

@shoumikhin
shoumikhin force-pushed the pybindings-release-gil branch from bd9384d to b1424ae Compare November 3, 2026 04:43
Copilot AI balanced review requested due to automatic review settings November 3, 2026 04:43
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Changed in this push: a fix for a hang at exit, a test fix for Windows, and the README and comment fixes from the review.

With sys.stderr replaced and fd 2 a full pipe that nobody reads, the process could hang while Python exits. Once exit begins, the lines that would wait for Python's main thread go to fd 2 instead, and that write blocked while its thread held the GIL. The main thread then waited for the GIL in an atexit callback forever. This happened, for example, when a daemon thread ended a binding call during exit, on Linux with Python before 3.13.8.

That write now goes out only while fd 2 has room and drops the rest, as a daemon thread already does when sys.stderr is fd 2. The daemon-thread test gains this case, and it times out on the previous head.

On Windows the LLM runner is not built, and its package then raises RuntimeError rather than ImportError. The test for lines logged outside a call caught only ImportError, so it failed there instead of skipping. The pybindings tests that import the LLM runner now catch both.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks.

Fatal line and the lines waiting for the main thread. Right, a fatal line writes only the lines its own call kept. As you note, main loses those lines too, so this is not a regression. I kept the fatal path minimal, and the README now says that this abort loses the lines still waiting.

Import under a redirect. Right. Until the first binding call, such a line then waits for the main thread instead of going out at once. It still reaches stderr unless the process aborts first. Caching sys.__stderr__ is a good change for a follow-up.

THROW_IF_ERROR and the GIL. Right, the macros now need the GIL. Every caller in this file holds it, and the LLM runner's bindings use their own macro, which never calls Python. Both comments are fixed in this push.

Exit path tests. Added in this push: the daemon-thread test registers an exit hook that runs after the bindings' own, checks that lines logged then reach fd 2, and that a full pipe no longer hangs the exit. Only the hook's flush of lines already waiting stays untested: Python still wrote them without the hook, so no test fails without it.

README exit sentence. Fixed in this push: it now covers only the lines waiting for the main thread.

Polling outside a call, and order. Right on both. It needs a pipe that stays full and a thread that logs outside a call with the GIL held, so I left it for the follow-up that writes waiting lines from their own thread.

Socket on fd 2. Right. A socket gets no room check, and unlike main, a write to a full one blocks with the GIL held. Treating a socket like a pipe fits the same follow-up.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026

Copy link
Copy Markdown

Claude Code is working…

I'll analyze this and get back to you.

View job run

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 54m 52s —— View job


Approve: nothing blocks merging. The main issue is that lines waiting for Python's main thread are written by a pending call, which can re-enter a replaced sys.stderr and puts a thread's lines out of order.

Detailed review

PR Review: #24393

Summary

The PR stops the PAL log hook from calling Python, so a backend that logs while it holds its own lock no longer deadlocks another thread. Each line now goes to fd 2 through C stdio, waits for its binding call to return, or waits for Python's main thread through Py_AddPendingCall. The PR also decodes exception messages with backslashreplace. Nothing blocks merging. The problems found:

  • Lines that wait can re-enter a replaced sys.stderr and come out of order.
  • The README misses one hang that remains.
  • The Buck target skips the LLM runner tests.
  • The routing comments repeat each other.

Thread Safety

  • Lines that wait can re-enter sys.stderr and come out of order (pybindings.cpp:392-396, 367-375).

    • Re-entry. Python's main thread runs write_deferred_logs at any bytecode boundary, like a signal handler. Its sys.stderr.write (line 350) therefore runs inside whatever Python code the main thread is in, and CPython's docs call Py_AddPendingCall "generally not suitable for calling Python code". One such place is the main thread's own write of a call's lines at line 350. If sys.stderr is a Python object whose write takes a threading.Lock, the inner write waits for a lock its own thread holds, and the main thread hangs.
    • Order. This happens when sys.stderr is replaced. A worker thread that holds the GIL and logs outside a call, as TextLLMRunner's constructor does (test_pybindings.py:913), queues the line for the main thread. The worker's next binding call writes its own lines at once from ~RuntimeErrorScope, so the later lines come out first. If the main thread is in join(), the queued line also waits until the join returns.
    • Comment. The comment at 327-328 ("Runs as the outermost binding call returns") is wrong for two of the function's three callers (374, 410).
    • Fix:
      • In ~RuntimeErrorScope on a thread that is not a daemon, take the queue under its mutex and write it before the call's own lines. The pending call then writes only when no call returned first.
      • Say in the README that a replacement sys.stderr.write can be re-entered from the main thread.
      • Reword the comment.

    Fix this →

  • The README's list of hangs that remain leaves out threads that do not hold the GIL (README.md:257-259).

    • What happens. Outside a call, such a thread writes to fd 2 with a blocking fwrite and no room check (pybindings.cpp:5662-5668). Since pending_logs is per thread, this includes a thread that a backend starts during a binding call.
    • When it hangs. Jupyter drains fd 2 through a Python thread, as the comment at 361-362 notes. There, once the pipe is full, the worker waits for the drain thread. The drain thread waits for the GIL, and the binding call holds the GIL while it waits for the worker.
    • Why it matters. main makes the same blocking write through std::cerr, so this is not a regression, but the README names only two ways the process can still hang.
    • Fix: add this case to the list. Alternatively, check stderr_has_room on such a thread and pass the line to defer_lines, which does not need the GIL.

    Fix this →

Infrastructure

  • The new LLM runner tests always skip under Buck (test/BUCK:41-47).

    • Cause. test_pybindings_portable_lib gained xnnpack_partitioner for the subprocess scripts, but not //executorch/extension/llm/runner:runner, so the scripts cannot import TextLLMRunner.
    • Effect. Two tests skip:
      • test_runtime_logs_outside_a_call_reach_sys_stderr (test_pybindings.py:648-649).
      • The LLM runner mode of test_full_stderr_pipe_drained_by_a_thread_does_not_block_a_call (950-951).
    • Scope. The CMake pybind preset builds the runner on Linux and macOS (tools/cmake/preset/pybind.cmake:60, 90), so only the Buck run loses this coverage.
    • Fix: add the runner dep to the target.

    Fix this →

Code Quality

  • Repeated comments and a single-use helper (pybindings.cpp:5617-5638, 5676-5683).

    • Problem. Three comments describe the same routing, and the README describes it again. line_outside_a_call_waits is a single-use helper. CLAUDE.md asks for minimal comments and no trivial single-use helpers.
    • Fix: the helper and the ternary at 5664-5665 reduce to one condition, which needs a single comment on emit_log_message:
      holds_gil() && ((level != kFatal && (PySys_GetObject("stderr") != checked_stderr || !checked_stderr_is_fd_2)) || !stderr_has_room(size))

    Fix this →

Recommendation

Approve. Nothing here blocks merging. The re-entry and ordering of lines that wait is the finding most worth fixing. The others are one README sentence, one Buck dep and a cleanup.

Not done

  • Nothing was built or run. python3 and git fetch were blocked in this run, and third-party/pybind11 is not checked out.
  • The re-entry hang and the reordering were not reproduced. They come from reading the code and CPython's Py_AddPendingCall docs.
  • The Buck dep was not tried in a Buck build.
  • Gather context
  • Run three reviewers
  • Consolidate
  • Fact-check
  • Post review

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks. Answers, without code changes:

Re-entry and order. Right, the main thread's pending call runs between bytecodes, so it can run inside a sys.stderr.write written in Python, though not inside a real file or StringIO, whose writes run no bytecode. ipykernel's stream handles it: 7.4 takes a re-entrant lock, and 6.5 hands the text to its I/O thread. A replacement writer that takes a plain lock could still deadlock. That, and the order of a worker's queued line against its next call's lines, belong with the follow-up that writes waiting lines from their own thread, so the README does not list them as limits. The comment on write_logs_to_python gets fixed there too.

Threads that do not hold the GIL. Right, and as you note, the main branch blocks the same way. The follow-up will also hand such a line to its writer thread when fd 2 has no room, so I did not add it to the README as a new limit.

Buck. Right, these tests skip under Buck. The CMake jobs run them on Linux and macOS. No Buck job in this repo runs these tests, so I could not try the runner dependency and left it out.

Comments and helper. The helper names the rule for lines logged outside a call, which keeps the log function short, so I kept it. The follow-up rewrites this routing, and I will merge the comments there.

The Python bindings wrote each runtime log line through sys.stderr.write
as it was logged, whenever the logging thread held the GIL (Python's
global interpreter lock). 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. 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, most lines
still reach it. A binding call keeps its lines and writes them to
sys.stderr as it returns, when no backend lock is held. A line that
Python's main thread logs outside any binding call, for example while
the LLM runner loads, is written by that thread before it runs more
Python code, so it still lands in a short redirect. It logged the line
from native code, so it is not inside its own sys.stderr.write. A line
from any other thread goes to a thread that the bindings start for it. A
binding call's lines then follow the lines its thread handed to that
thread, so one thread's lines keep their order. Python's main thread may
be in the middle of a sys.stderr.write whose stream holds a lock, and a
write nested there would wait for that lock forever. Outside a call,
only a thread that holds the GIL can tell where sys.stderr goes, by
comparing it with the stream checked last, a check that runs no Python
code. A thread that does not hold the GIL writes to the process's
stderr, as before, so a notebook with ipykernel's fd capture off does
not show its lines. Lines from a thread that holds the GIL do reach such
a notebook.

On Linux and macOS, a line also waits once the process's stderr is a
pipe or socket that stays full for 100 ms, so a thread that holds the
GIL does not hang on it while a Python thread in the same process drains
it. The rest of a binding call's lines then wait for the call to return.
A line logged outside a call does not wait: the bindings' thread writes
it without the GIL or any lock that Python's exit needs. While that
thread writes, a binding call's lines wait for the call to return, since
it can take the room a check just saw. A terminal is written at once,
since its reader is another process. The process can still hang if
another writer fills the pipe right after the check for room, or if a
Python thread in the same process reads a terminal that is its stderr.
macOS reports room in a small pipe that it may not have, so there a pipe
that has not grown past 16 KiB takes a line only once it fits in 512
bytes beside what the pipe holds. Up to 1 MiB of lines wait at a time,
keeping the newest and noting how much was left out. A fatal line goes
out at once while there is room, with the lines its call kept, since an
abort usually 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. Once Python starts to exit, the
lines still waiting are written, and later lines logged outside a call
go to the process's stderr while it has room. Lines for the process's
stderr are left out instead while the bindings' thread is still writing
to it.

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. The bindings' thread writes the daemon thread's lines
instead.

New tests cover the hang, also when the LLM runner logs under XNNPACK's
lock outside any call, a sys.stderr whose write runs Python code while
it holds a lock, daemon threads that log while Python exits, lines
logged outside a call with sys.stderr replaced before or after import or
left alone, a daemon thread's lines with a replaced sys.stderr, a
failing write, a fatal line and the line before it, a call that dies in
a busy terminal, a full pipe or socket drained by a thread, for a call,
for the LLM runner and for both at once, and a long call's newest lines.
The docs example for registering a PAL (platform abstraction layer) now
uses the right namespace and compiles on its own, and the docs say why
the bindings' log function never calls Python.
…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 4 bytes near
the start of a file that is not a program, which the runtime prints as
its identifier. The message itself is cut to fit 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.
@shoumikhin
shoumikhin force-pushed the pybindings-release-gil branch from b1424ae to 7a5fb48 Compare November 3, 2026 18:19
Copilot AI balanced review requested due to automatic review settings November 3, 2026 18:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

What changed since the last push:

  • Lines that other threads log outside a binding call no longer reach sys.stderr through Py_AddPendingCall. Before, the main thread could write them from inside its own sys.stderr.write, and a stream whose write takes a lock (for example one built on queue.Queue) hung. Now only the main thread's own lines use the pending call. Lines from other threads go to a short-lived thread of the bindings, which never writes inside another thread's write.
  • The room check now covers a socket on fd 2 as well as a pipe. Before, a full socket drained by a Python thread could hang the thread that held the GIL.
  • While the bindings' thread writes to fd 2, a binding call keeps its lines until it returns, so the two never race for the same room.
  • A thread's binding-call lines now come after its earlier lines that are still waiting, so one thread's lines stay in order.
  • Outside a call, a line that is not fatal no longer waits 100 ms for room. On macOS the wait for a small pipe now counts real time.

New tests cover a stream whose write takes a lock, a socket on fd 2, the bindings' thread and binding calls writing to a full fd 2 at once, and the order of a worker thread's lines.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

PR Review: #24393

  • Gather context (diff, commits, CLAUDE.md, review checklist)
  • Run three independent reviewers over the diff
  • Consolidate findings
  • Fact-check findings
  • Post review

The checkout is shallow and origin/main has no merge base with this branch, so the review diffs against the PR's parent commit 0f8e096 (5 files, +1425/−43, same as the PR).

View job run

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@JacobSzwejbka, on the notebook question: I reran the logging cases on this head in a real Jupyter kernel (ipykernel 7.4, Python 3.12) with fd capture off, next to main.

  • A failed load that logs inside a call: 1 of 1 line shown in the cell, same as main.
  • Lines the LLM runner logs while it is built, outside any call: 5 of 5, same as main.
  • A daemon thread's lines: 3 of 3, same as main.
  • One call that logs 2000 lines: all 2000, same as main.
  • A fatal line, then an abort: the kernel dies with both builds. On main the lines are lost. With this change they reach the process's stderr (the kernel's own log), but not the cell.

With fd capture on, every case matches main, including the fatal case, where the lines are lost with both builds.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

PR review in progress

  • Gather context
  • Run three independent reviewers
  • Consolidate findings
  • Fact-check against the code
  • Post review

I merged the duplicates and re-read the code each finding cites. About ten findings hold up. The main one is still the main thread's pending call, which keeps re-queuing itself while the writer thread holds one of its batches. I also confirmed that the writer thread holds back every binding call's lines for its whole lifetime, not only while it writes. Now writing the review.

View job run

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude review this PR

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 8m 33s —— View job


Approve: nothing blocks merging. The main problems are in how Python's main thread re-queues and orders the lines it hands off, plus a possible use-after-free at exit (low confidence); the rest are a README contradiction and one minor write-path issue.

Detailed review

PR Review: #24393

Summary

The PR keeps the PAL log hook from calling Python. Each line goes to fd 2, waits for its binding call to return, or waits for Python's main thread (Py_AddPendingCall) or the bindings' writer thread. I found no blocker. Five problems remain: two in the main thread's handoff, one possible crash at exit, one README sentence that contradicts the code, and one way the writer can split lines.

Thread Safety

  • The main thread's pending call can keep re-queuing itself while the writer holds one of its batches (extension/pybindings/pybindings.cpp:423-431, 393, 512-519).

    • How it happens: write_main_thread_logs clears main_thread_write_scheduled, swaps the lines out, and calls write_logs_to_python. That function sees own_deferred_lines_wait() and calls defer_lines. With sys.stderr replaced, defer_lines puts the lines back in main_thread_logs and calls Py_AddPendingCall again. This repeats at every eval-breaker check until the writer marks its batch written. Each pass copies up to 1 MiB of lines.
    • Side effect: while the loop runs, deferred_lines_wait() stays true, so every thread's in-call lines are held back instead of going straight to fd 2 (5859).
    • Trigger:
      1. With sys.stderr still on fd 2, the main thread logs outside a call while fd 2 is full. The line goes to the writer, which then blocks in write_to_fd_2 (for example, a paused | less), or writes slowly through a replaced sys.stderr.
      2. The main thread then makes a logging binding call under redirect_stderr or capsys.
    • Fix: when the main thread's own lines are waiting for the writer, append its new lines to deferred.logs, behind them, instead of scheduling the pending call again.
    • Two of the three reviewers found this, and I traced it in the code. Not reproduced.

    Fix this →

  • On the main thread, a call's lines can come out before that thread's own lines waiting for the pending call (pybindings.cpp:392-396, 542-546).

    • Cause: own_deferred_lines_wait() checks only the writer's batches (last_deferred_batch). It ignores main_thread_logs.
    • Trigger: sys.stderr is replaced. In an except RuntimeError: block after TextLLMRunner(missing_pte), the code calls _load_program_from_buffer(bad_bytes). A CALL that raises skips the eval-breaker check that follows a normal return. So the second call's ~RuntimeErrorScope can write its lines before the pending call writes the runner's earlier lines.
    • Why it matters: the latest push says a thread's binding-call lines come after its earlier lines that are still waiting. The tests check that only for a worker thread.
    • Fix: on the main thread, defer the call's lines into main_thread_logs while main_thread_write_scheduled is set.
    • Confidence: found by one reviewer. The missing check is confirmed in the code. When CPython runs the pending call on the exception path is not verified.

    Fix this →

  • Low confidence: the writer can touch a freed thread state after exit (pybindings.cpp:482-488, 563-569).

    • Problem: write_deferred_logs_at_exit waits only 100 ms for a writer that is still writing. That writer is blocked in write_to_fd_2 with its PyGILState_Ensure thread state saved by PyEval_SaveThread. Python then finalizes, and finalizing frees every other thread's state.
    • Trigger: fd 2 is a pipe read by another process that drains it after the 100 ms but before the process exits. The writer's write returns, and PyEval_RestoreThread(state) and PyGILState_Release then use the freed state.
    • Fix: after the write, check deferred.exiting under the mutex and, if it is set, never return to Python (leak the thread state).
    • Not reproduced. This comes from reading the code and from how CPython frees thread states at finalization.

    Fix this →

API Design

  • The README contradicts itself about threads that log outside a call (extension/pybindings/README.md, second paragraph of the Note).

    • The contradiction: the second paragraph says "A thread that logs outside a call does not wait for room itself. It hands the line to the bindings' thread." The first paragraph, and line_outside_a_call_waits (pybindings.cpp:5806-5810), hand a line over only on a thread that holds the GIL. Any other thread writes with a blocking fwrite (5852).
    • Why it matters: under wurlitzer.pipes() or Jupyter fd capture, such a thread can block on a full pipe while a binding call holds the GIL and waits for it. The author already plans that fix as a follow-up, and main blocks the same way. Until then, the sentence promises more than the code does, and the list of hangs that remain leaves this one out.

    Fix this →

Code Quality

  • The writer can split other threads' lines (pybindings.cpp:483, 216-231).

    • Problem: the writer sends a whole batch, up to 1 MiB, in one write_to_fd_2 loop, while other threads write single lines through C stdio. On a pipe, a write larger than PIPE_BUF is not atomic, so another thread's line can land in the middle of a line.
    • Second problem: if fd 2 is non-blocking, write_to_fd_2 returns on EAGAIN and drops the rest of the batch silently.
    • Fix: write the batch one line at a time, as write_lines_while_room already does.

    Fix this →

Recommendation

Approve. Nothing here blocks merging. The re-queue loop and the main-thread ordering are the fixes most worth making, and each is a few lines in write_logs_to_python/defer_lines. The exit issue needs confirmation before anyone spends effort on it.

  • Gather context (diff against the PR's merge base 0f8e096, since origin/main has no merge base in this shallow checkout)
  • Run three independent reviewers
  • Consolidate findings
  • Fact-check findings
  • Post review

Not done: nothing was built or run, and Python was not available to this job. The exit-time crash and the CPython eval-breaker behavior come from reading code.

@shoumikhin
shoumikhin merged commit f1240a2 into pytorch:main Nov 3, 2026
251 of 256 checks passed
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@pytorchbot cherry-pick --onto release/1.6 -c critical

@pytorchbot

Copy link
Copy Markdown
Collaborator

Cherry picking #24393

The cherry pick PR is at #24529 and it is recommended to link a critical cherry pick PR with an issue.

Details for Dev Infra team Raised by workflow job

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. release notes: api Changes to public facing apis (any interfaces, pybinded runtime methods, etc.)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants