-
Notifications
You must be signed in to change notification settings - Fork 2.7k
fix(engine): keep a Python thread's own name in the engine and in Python #2369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,243 @@ | ||
| // ============================================================================= | ||
| // MIT License | ||
| // Copyright (c) 2026 Aparavi Software AG | ||
| // | ||
| // Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| // of this software and associated documentation files (the "Software"), to deal | ||
| // in the Software without restriction, including without limitation the rights | ||
| // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| // copies of the Software, and to permit persons to whom the Software is | ||
| // furnished to do so, subject to the following conditions: | ||
| // | ||
| // The above copyright notice and this permission notice shall be included in | ||
| // all copies or substantial portions of the Software. | ||
| // | ||
| // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| // SOFTWARE. | ||
| // ============================================================================= | ||
| // | ||
| // One name per thread on both sides of the language boundary. | ||
| // | ||
| // The engine keeps a thread's name in its thread context, Python in its | ||
| // threading.Thread. Whoever started the thread named it: the engine its own | ||
| // threads, Python its own (asyncio_0, ThreadPoolExecutor-0_1). Each side | ||
| // knows a thread it did not start only by a placeholder, "External" in the | ||
| // engine and "Dummy-N" in Python, and has to take the other side's name. The | ||
| // profiler and the logs then agree on which thread did what. | ||
| // | ||
| // NOTE: as in profiler.cpp, assertions never run on a worker thread: Catch2's | ||
| // macros are not thread safe. Workers record what they saw, the Catch2 | ||
| // thread asserts. | ||
| // ============================================================================= | ||
|
|
||
| #include <pybind11/embed.h> | ||
|
|
||
| #include <chrono> | ||
| #include <condition_variable> | ||
| #include <memory> | ||
| #include <mutex> | ||
| #include <string> | ||
| #include <thread> | ||
|
|
||
| #include "test.h" | ||
|
|
||
| namespace py = pybind11; | ||
|
|
||
| // Named rather than anonymous: a unity build puts this file in one unit with | ||
| // profiler.cpp, whose anonymous namespace would then be this one too | ||
| namespace thread_names { | ||
|
|
||
| // Every wait is bounded: a deadlock in a GIL path must fail the run, not hang | ||
| constexpr auto kTimeout = std::chrono::seconds(60); | ||
|
|
||
| /// A thread's name as each side sees it | ||
| struct Names { | ||
| std::string engine; | ||
| std::string python; | ||
| }; | ||
|
|
||
| /// The calling thread's name in the engine | ||
| std::string engineName() { | ||
| return std::string{ap::async::getCurrentThreadName()}; | ||
| } | ||
|
|
||
| /// The calling thread's name in Python; GIL must be held | ||
| std::string pythonName() { | ||
| return py::cast<std::string>( | ||
| py::module_::import("threading").attr("current_thread")().attr("name")); | ||
| } | ||
|
|
||
| //--------------------------------------------------------------------- | ||
| /// @details | ||
| /// Both names on a thread outside Python, reading Python's through | ||
| /// the engine's real seam into Python, as a pipeline filter does | ||
| //--------------------------------------------------------------------- | ||
| Names bothNames() { | ||
| Names names; | ||
| if (callPython(localfcn()->Error { | ||
| names.python = pythonName(); | ||
| return {}; | ||
| })) | ||
| names.python = "<callPython failed>"; | ||
| names.engine = engineName(); | ||
| return names; | ||
| } | ||
|
|
||
| //--------------------------------------------------------------------- | ||
| /// @details | ||
| /// Signals the Catch2 thread that a worker is done, so it can wait | ||
| /// with a timeout. Not std::latch, which has no timed wait | ||
| //--------------------------------------------------------------------- | ||
| class Done { | ||
| public: | ||
| void signal() noexcept { | ||
| { | ||
| std::lock_guard<std::mutex> guard(m_mutex); | ||
| m_done = true; | ||
| } | ||
| m_cv.notify_all(); | ||
| } | ||
|
|
||
| bool wait() noexcept { | ||
| std::unique_lock<std::mutex> guard(m_mutex); | ||
| return m_cv.wait_for(guard, kTimeout, [&] { return m_done; }); | ||
| } | ||
|
|
||
| private: | ||
| std::mutex m_mutex; | ||
| std::condition_variable m_cv; | ||
| bool m_done = false; | ||
| }; | ||
|
|
||
| //--------------------------------------------------------------------- | ||
| /// @details | ||
| /// Starts a Python thread named @p name that calls into the engine, | ||
| /// the way the data path hands a document to the engine from an | ||
| /// asyncio worker, and returns what that thread saw. With | ||
| /// @p engineFirst the engine creates its thread context before the | ||
| /// call reaches UnlockPython, as a cancellation check or a log line | ||
| /// would. Runs on the Catch2 thread. | ||
| //--------------------------------------------------------------------- | ||
| Names fromPythonThread(const char *name, bool engineFirst) { | ||
| Names names; | ||
| if (callPython(localfcn()->Error { | ||
| py::exec(R"PY( | ||
| import threading as _rr_threading | ||
| import engtest_thread_names as _rr_thread_names | ||
|
|
||
|
|
||
| def rr_thread_names_run(name, engine_first): | ||
| seen = [] | ||
| thread = _rr_threading.Thread( | ||
| name=name, | ||
| target=lambda: seen.append(_rr_thread_names.enter_engine(engine_first))) | ||
| thread.start() | ||
| thread.join(60) | ||
| return seen[0] if seen else ('<no result>', '<no result>') | ||
| )PY"); | ||
| auto seen = py::module_::import("__main__") | ||
| .attr("rr_thread_names_run")(name, engineFirst) | ||
| .cast<py::tuple>(); | ||
| names.engine = seen[0].cast<std::string>(); | ||
| names.python = seen[1].cast<std::string>(); | ||
| return {}; | ||
| })) | ||
| names.engine = names.python = "<callPython failed>"; | ||
| return names; | ||
| } | ||
|
|
||
| } // namespace thread_names | ||
|
|
||
| // Called from a thread Python started: the engine side of that thread | ||
| PYBIND11_EMBEDDED_MODULE(engtest_thread_names, m) { | ||
| m.def("enter_engine", [](bool engineFirst) { | ||
| if (engineFirst) | ||
| (void)thread_names::engineName(); | ||
|
|
||
| thread_names::Names names; | ||
| { | ||
| // The engine's entry from Python, as a pipe write takes it | ||
| engine::python::UnlockPython unlock; | ||
| names.engine = thread_names::engineName(); | ||
|
|
||
| // On to a Python node, as the engine does, through the real seam | ||
| if (callPython(localfcn()->Error { | ||
| names.python = thread_names::pythonName(); | ||
| return {}; | ||
| })) | ||
| names.python = "<callPython failed>"; | ||
| } | ||
| return py::make_tuple(names.engine, names.python); | ||
| }); | ||
| } | ||
|
|
||
| //----------------------------------------------------------------------------- | ||
| // A thread Python started keeps Python's name, and the engine takes it too. | ||
| //----------------------------------------------------------------------------- | ||
| TEST_CASE("python::thread_names::python_thread") { | ||
| auto names = thread_names::fromPythonThread("rr-py-thread", false); | ||
|
|
||
| CHECK(names.engine == "rr-py-thread"); | ||
| CHECK(names.python == "rr-py-thread"); | ||
| } | ||
|
|
||
| //----------------------------------------------------------------------------- | ||
| // Same, when the engine made its thread context before the thread reached | ||
| // UnlockPython: the placeholder it started with gives way to Python's name. | ||
| //----------------------------------------------------------------------------- | ||
| TEST_CASE("python::thread_names::python_thread_engine_first") { | ||
| auto names = thread_names::fromPythonThread("rr-py-engine-first", true); | ||
|
|
||
| CHECK(names.engine == "rr-py-engine-first"); | ||
| CHECK(names.python == "rr-py-engine-first"); | ||
| } | ||
|
|
||
| //----------------------------------------------------------------------------- | ||
| // A thread the engine started gives its name to Python. | ||
| //----------------------------------------------------------------------------- | ||
| TEST_CASE("python::thread_names::engine_thread") { | ||
| thread_names::Names names; | ||
| thread_names::Done done; | ||
| auto thread = | ||
| std::make_unique<ap::async::Thread>(_location, "rr-engine", [&] { | ||
| names = thread_names::bothNames(); | ||
| done.signal(); | ||
| }); | ||
| REQUIRE_NO_ERROR(thread->start()); | ||
|
|
||
| if (!done.wait()) { | ||
| // Joining a hung thread would hang the run; leak it instead | ||
| (void)thread.release(); | ||
| FAIL("engine thread did not finish within the timeout"); | ||
| } | ||
| thread.reset(); | ||
|
|
||
| CHECK(names.engine == "rr-engine"); | ||
| CHECK(names.python == "rr-engine"); | ||
| } | ||
|
|
||
| //----------------------------------------------------------------------------- | ||
| // A thread neither side started keeps the engine's placeholder on both sides. | ||
| //----------------------------------------------------------------------------- | ||
| TEST_CASE("python::thread_names::foreign_thread") { | ||
| thread_names::Names names; | ||
| thread_names::Done done; | ||
| std::thread thread([&] { | ||
| names = thread_names::bothNames(); | ||
| done.signal(); | ||
| }); | ||
|
|
||
| if (!done.wait()) { | ||
| thread.detach(); | ||
|
Comment on lines
+215
to
+236
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '70,250p' packages/server/engine-lib/test/python/thread_names.cpp
rg -n 'class Done|struct Done|Done done|thread.release|thread.detach' packages/server/engine-lib/test/python/thread_names.cpp packages/server/engine-lib/testRepository: rocketride-org/rocketride-server Length of output: 7562 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- thread_names.cpp includes and test region ---'
sed -n '1,35p' packages/server/engine-lib/test/python/thread_names.cpp
sed -n '190,245p' packages/server/engine-lib/test/python/thread_names.cpp
printf '%s\n' '--- Thread declarations and relevant ownership APIs ---'
rg -n -g '*.cpp' -g '*.h' -g '*.hpp' 'class Thread|struct Thread|Thread::~Thread|Thread::start|namespace async' packages/server/engine-lib packages/server | head -120
printf '%s\n' '--- FAIL definitions/usages ---'
rg -n -g '*.h' -g '*.hpp' -g '*.cpp' '`#define` FAIL|FAIL\(' packages/server/engine-lib/test packages/server/engine-lib | head -100Repository: rocketride-org/rocketride-server Length of output: 6817 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- thread_names.cpp includes ---'
sed -n '30,75p' packages/server/engine-lib/test/python/thread_names.cpp
printf '%s\n' '--- async Thread contract ---'
cat -n packages/server/engine-core/apLib/async/Thread.hpp
printf '%s\n' '--- local Catch2 headers, if present ---'
rg -n -g '*.h' -g '*.hpp' -g '*.cpp' 'define[[:space:]]+FAIL|void[[:space:]]+fail|class TestFailureException|TEST_CASE' packages/server | head -120Repository: rocketride-org/rocketride-server Length of output: 21090 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- test headers ---'
fd -t f 'test\.h$|test\.hpp$' packages/server
printf '%s\n' '--- FAIL binding in located headers ---'
for f in $(fd -t f 'test\.h$|test\.hpp$' packages/server); do
rg -n -C 3 'FAIL|catch2|Catch' "$f" || true
doneRepository: rocketride-org/rocketride-server Length of output: 422 🏁 Script executed: #!/bin/bash
set -e
for f in packages/server/engine-lib/test/test.h packages/server/engine-core/test/test.h; do
if [ -f "$f" ]; then
printf '%s\n' "--- $f ---"
cat -n "$f" | head -120
fi
doneRepository: rocketride-org/rocketride-server Length of output: 4833 Keep timeout state alive while abandoned workers can still run. Both lambdas capture stack-owned Store 🤖 Prompt for AI Agents |
||
| FAIL("foreign thread did not finish within the timeout"); | ||
| } | ||
| thread.join(); | ||
|
|
||
| CHECK(names.engine == "External"); | ||
| CHECK(names.python == "External"); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: rocketride-org/rocketride-server
Length of output: 12818
🏁 Script executed:
Repository: rocketride-org/rocketride-server
Length of output: 9819
🤖 get_repo_knowledge executed:
get_repo_knowledge rocketride-org/rocketride-server /tmp/coderabbit-repo-knowledge/rocketride-org-rocketride-server-e15c1928/learnings /tmp/coderabbit-repo-knowledge/rocketride-org-rocketride-server-e15c1928/conventionsLength of output: 36993
🏁 Script executed:
Repository: rocketride-org/rocketride-server
Length of output: 9050
🏁 Script executed:
Repository: rocketride-org/rocketride-server
Length of output: 18714
Do not leave the timed-out Python thread running.
thread.join(60)returns while the standardthreading.Threadremains alive. The test executable later callsengine::deinit(), which callsPy_FinalizeEx(). Python shutdown waits for this non-daemon thread. Ifenter_engineblocks, the test process can remain stuck indefinitely after reporting the failure.Run this deadlock probe in a child process. Apply the timeout and termination from the parent process.
🤖 Prompt for AI Agents