Skip to content

Commit 78381e5

Browse files
authored
Improve C++ test infrastructure: progress reporter, timeouts, and skip hanging Move Subinterpreter test (#5942)
* Improve C++ test infrastructure and disable hanging test This commit improves the C++ test infrastructure to ensure test output is visible in CI logs, and disables a test that hangs on free-threaded Python 3.14+. Changes: ## CI/test infrastructure improvements - .github/workflows: Added `timeout-minutes: 3` to all C++ test steps to prevent indefinite hangs. - tests/**/CMakeLists.txt: Added `USES_TERMINAL` to C++ test targets (cpptest, test_cross_module_rtti, test_pure_cpp) to ensure output is shown immediately rather than buffered and possibly lost on crash/timeout. - tests/test_with_catch/catch.cpp: Added a custom Catch2 progress reporter with timestamps, Python version info, and a SIGTERM handler to make test execution and failures clearly visible in CI logs. ## Disabled hanging test - The "Move Subinterpreter" test is disabled on free-threaded Python 3.14+ due to a hang in Py_EndInterpreter() when the subinterpreter is destroyed from a different thread than it was created on. Work on fixing the underlying issue will continue under PR #5940. Context: We were in the dark for months (since we started testing with Python 3.14t) because CI logs gave no clue about the root cause of hangs. This led to ignoring intermittent hangs (mostly on macOS). Our hand was forced only with the Python 3.14.1 release, when hangs became predictable on all platforms. For the full development history of these changes, see PR #5933. * Add test summary to progress reporter Print the total number of test cases and assertions at the end of the test run, making it easy to spot if tests are disabled or added. Example output: [ PASSED ] 20 test cases, 1589 assertions. * Add PYBIND11_CATCH2_SKIP_IF macro to skip tests at runtime Catch2 v2 doesn't have native skip support (v3 does with SKIP()). This macro allows tests to be skipped with a visible message while still appearing in the test list. Use this for the Move Subinterpreter test on free-threaded Python 3.14+ so it shows as skipped rather than being conditionally compiled out. Example output: [ RUN ] Move Subinterpreter [ SKIPPED ] Skipped on free-threaded Python 3.14+ (see PR #5940) [ OK ] Move Subinterpreter * Fix clang-tidy bugprone-macro-parentheses warning in PYBIND11_CATCH2_SKIP_IF
1 parent 3aeb113 commit 78381e5

File tree

9 files changed

+184
-3
lines changed

9 files changed

+184
-3
lines changed

.github/workflows/ci.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ jobs:
229229
run: cmake --build . --target pytest
230230

231231
- name: Compiled tests
232+
timeout-minutes: 3
232233
run: cmake --build . --target cpptest
233234

234235
- name: Interface test
@@ -334,6 +335,7 @@ jobs:
334335
run: cmake --build --preset default --target pytest
335336

336337
- name: C++ tests
338+
timeout-minutes: 3
337339
run: cmake --build --preset default --target cpptest
338340

339341
- name: Visibility test
@@ -393,6 +395,7 @@ jobs:
393395
run: cmake --build build --target pytest
394396

395397
- name: C++ tests
398+
timeout-minutes: 3
396399
run: cmake --build build --target cpptest
397400

398401
- name: Interface test
@@ -516,6 +519,7 @@ jobs:
516519
run: cmake --build build --target pytest
517520

518521
- name: C++ tests
522+
timeout-minutes: 3
519523
run: cmake --build build --target cpptest
520524

521525
- name: Interface test
@@ -570,6 +574,7 @@ jobs:
570574
run: cmake --build build --target pytest
571575

572576
- name: C++ tests
577+
timeout-minutes: 3
573578
run: cmake --build build --target cpptest
574579

575580
- name: Interface test
@@ -652,6 +657,7 @@ jobs:
652657
cmake --build build-11 --target check
653658
654659
- name: C++ tests C++11
660+
timeout-minutes: 3
655661
run: |
656662
set +e; source /opt/intel/oneapi/setvars.sh; set -e
657663
cmake --build build-11 --target cpptest
@@ -689,6 +695,7 @@ jobs:
689695
cmake --build build-17 --target check
690696
691697
- name: C++ tests C++17
698+
timeout-minutes: 3
692699
run: |
693700
set +e; source /opt/intel/oneapi/setvars.sh; set -e
694701
cmake --build build-17 --target cpptest
@@ -760,6 +767,7 @@ jobs:
760767
run: cmake --build build --target pytest
761768

762769
- name: C++ tests
770+
timeout-minutes: 3
763771
run: cmake --build build --target cpptest
764772

765773
- name: Interface test
@@ -1001,6 +1009,7 @@ jobs:
10011009
run: cmake --build build --target pytest
10021010

10031011
- name: C++20 tests
1012+
timeout-minutes: 3
10041013
run: cmake --build build --target cpptest -j 2
10051014

10061015
- name: Interface test C++20
@@ -1077,6 +1086,7 @@ jobs:
10771086
run: cmake --build build --target pytest -j 2
10781087

10791088
- name: C++11 tests
1089+
timeout-minutes: 3
10801090
run: PYTHONHOME=/${{matrix.sys}} PYTHONPATH=/${{matrix.sys}} cmake --build build --target cpptest -j 2
10811091

10821092
- name: Interface test C++11
@@ -1101,6 +1111,7 @@ jobs:
11011111
run: cmake --build build2 --target pytest -j 2
11021112

11031113
- name: C++14 tests
1114+
timeout-minutes: 3
11041115
run: PYTHONHOME=/${{matrix.sys}} PYTHONPATH=/${{matrix.sys}} cmake --build build2 --target cpptest -j 2
11051116

11061117
- name: Interface test C++14
@@ -1125,6 +1136,7 @@ jobs:
11251136
run: cmake --build build3 --target pytest -j 2
11261137

11271138
- name: C++17 tests
1139+
timeout-minutes: 3
11281140
run: PYTHONHOME=/${{matrix.sys}} PYTHONPATH=/${{matrix.sys}} cmake --build build3 --target cpptest -j 2
11291141

11301142
- name: Interface test C++17
@@ -1196,6 +1208,7 @@ jobs:
11961208
run: cmake --build . --target pytest -j 2
11971209

11981210
- name: C++ tests
1211+
timeout-minutes: 3
11991212
run: cmake --build . --target cpptest -j 2
12001213

12011214
- name: Interface test
@@ -1258,6 +1271,7 @@ jobs:
12581271
run: cmake --build . --target pytest -j 2
12591272

12601273
- name: C++ tests
1274+
timeout-minutes: 3
12611275
run: cmake --build . --target cpptest -j 2
12621276

12631277
- name: Interface test
@@ -1330,6 +1344,7 @@ jobs:
13301344
run: cmake --build build --target pytest -j 2
13311345

13321346
- name: C++ tests
1347+
timeout-minutes: 3
13331348
run: PYTHONHOME=/clangarm64 PYTHONPATH=/clangarm64 cmake --build build --target cpptest -j 2
13341349

13351350
- name: Interface test

.github/workflows/reusable-standard.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ jobs:
8383
run: cmake --build build --target pytest
8484

8585
- name: C++ tests
86+
timeout-minutes: 3
8687
run: cmake --build build --target cpptest
8788

8889
- name: Interface test

.github/workflows/upstream.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ jobs:
6666
run: cmake --build build11 --target pytest -j 2
6767

6868
- name: C++11 tests
69+
timeout-minutes: 3
6970
run: cmake --build build11 --target cpptest -j 2
7071

7172
- name: Interface test C++11
@@ -87,6 +88,7 @@ jobs:
8788
run: cmake --build build17 --target pytest
8889

8990
- name: C++17 tests
91+
timeout-minutes: 3
9092
run: cmake --build build17 --target cpptest
9193

9294
# Third build - C++17 mode with unstable ABI

tests/pure_cpp/CMakeLists.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ target_link_libraries(smart_holder_poc_test PRIVATE pybind11::headers Catch2::Ca
1515
add_custom_target(
1616
test_pure_cpp
1717
COMMAND "$<TARGET_FILE:smart_holder_poc_test>"
18-
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
18+
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
19+
USES_TERMINAL # Ensures output is shown immediately (not buffered and possibly lost on crash)
20+
)
1921

2022
add_dependencies(check test_pure_cpp)

tests/test_cross_module_rtti/CMakeLists.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ add_custom_target(
6060
test_cross_module_rtti
6161
COMMAND "$<TARGET_FILE:test_cross_module_rtti_main>"
6262
DEPENDS test_cross_module_rtti_main
63-
WORKING_DIRECTORY "$<TARGET_FILE_DIR:test_cross_module_rtti_main>")
63+
WORKING_DIRECTORY "$<TARGET_FILE_DIR:test_cross_module_rtti_main>"
64+
USES_TERMINAL # Ensures output is shown immediately (not buffered and possibly lost on crash)
65+
)
6466

6567
set_target_properties(test_cross_module_rtti_bindings PROPERTIES LIBRARY_OUTPUT_DIRECTORY
6668
"${CMAKE_CURRENT_BINARY_DIR}")

tests/test_with_catch/CMakeLists.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ add_custom_target(
4747
cpptest
4848
COMMAND "$<TARGET_FILE:test_with_catch>"
4949
DEPENDS test_with_catch
50-
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
50+
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
51+
USES_TERMINAL # Ensures output is shown immediately (not buffered and possibly lost on crash)
52+
)
5153

5254
pybind11_add_module(external_module THIN_LTO external_module.cpp)
5355
set_target_properties(external_module PROPERTIES LIBRARY_OUTPUT_DIRECTORY

tests/test_with_catch/catch.cpp

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33

44
#include <pybind11/embed.h>
55

6+
#include <chrono>
7+
#include <csignal>
8+
#include <cstring>
9+
#include <ctime>
10+
#include <iomanip>
11+
#include <sstream>
12+
13+
#ifndef _WIN32
14+
# include <unistd.h>
15+
#endif
16+
617
// Silence MSVC C++17 deprecation warning from Catch regarding std::uncaught_exceptions (up to
718
// catch 2.0.1; this should be fixed in the next catch release after 2.0.1).
819
PYBIND11_WARNING_DISABLE_MSVC(4996)
@@ -13,11 +24,126 @@ PYBIND11_WARNING_DISABLE_MSVC(4996)
1324
#endif
1425

1526
#define CATCH_CONFIG_RUNNER
27+
#define CATCH_CONFIG_DEFAULT_REPORTER "progress"
28+
#include "catch_skip.h"
29+
1630
#include <catch.hpp>
1731

1832
namespace py = pybind11;
1933

34+
// Simple progress reporter that prints a line per test case.
35+
namespace {
36+
37+
class ProgressReporter : public Catch::StreamingReporterBase<ProgressReporter> {
38+
public:
39+
using StreamingReporterBase<ProgressReporter>::StreamingReporterBase;
40+
41+
static std::string getDescription() { return "Simple progress reporter (one line per test)"; }
42+
43+
void testCaseStarting(Catch::TestCaseInfo const &testInfo) override {
44+
print_python_version_once();
45+
auto &os = Catch::cout();
46+
os << "[ RUN ] " << testInfo.name << '\n';
47+
os.flush();
48+
}
49+
50+
void testCaseEnded(Catch::TestCaseStats const &stats) override {
51+
bool failed = stats.totals.assertions.failed > 0;
52+
auto &os = Catch::cout();
53+
os << (failed ? "[ FAILED ] " : "[ OK ] ") << stats.testInfo.name << '\n';
54+
os.flush();
55+
}
56+
57+
void noMatchingTestCases(std::string const &spec) override {
58+
auto &os = Catch::cout();
59+
os << "[ NO TEST ] no matching test cases for spec: " << spec << '\n';
60+
os.flush();
61+
}
62+
63+
void reportInvalidArguments(std::string const &arg) override {
64+
auto &os = Catch::cout();
65+
os << "[ ERROR ] invalid Catch2 arguments: " << arg << '\n';
66+
os.flush();
67+
}
68+
69+
void assertionStarting(Catch::AssertionInfo const &) override {}
70+
71+
bool assertionEnded(Catch::AssertionStats const &) override { return false; }
72+
73+
void testRunEnded(Catch::TestRunStats const &stats) override {
74+
auto &os = Catch::cout();
75+
auto passed = stats.totals.testCases.passed;
76+
auto failed = stats.totals.testCases.failed;
77+
auto total = passed + failed;
78+
auto assertions = stats.totals.assertions.passed + stats.totals.assertions.failed;
79+
if (failed == 0) {
80+
os << "[ PASSED ] " << total << " test cases, " << assertions << " assertions.\n";
81+
} else {
82+
os << "[ FAILED ] " << failed << " of " << total << " test cases, " << assertions
83+
<< " assertions.\n";
84+
}
85+
os.flush();
86+
}
87+
88+
private:
89+
void print_python_version_once() {
90+
if (printed_) {
91+
return;
92+
}
93+
printed_ = true;
94+
auto &os = Catch::cout();
95+
os << "[ PYTHON ] " << Py_GetVersion() << '\n';
96+
os.flush();
97+
}
98+
99+
bool printed_ = false;
100+
};
101+
102+
} // namespace
103+
104+
CATCH_REGISTER_REPORTER("progress", ProgressReporter)
105+
106+
namespace {
107+
108+
std::string get_utc_timestamp() {
109+
auto now = std::chrono::system_clock::now();
110+
auto time_t_now = std::chrono::system_clock::to_time_t(now);
111+
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
112+
113+
std::tm utc_tm{};
114+
#if defined(_WIN32)
115+
gmtime_s(&utc_tm, &time_t_now);
116+
#else
117+
gmtime_r(&time_t_now, &utc_tm);
118+
#endif
119+
120+
std::ostringstream oss;
121+
oss << std::put_time(&utc_tm, "%Y-%m-%d %H:%M:%S") << '.' << std::setfill('0') << std::setw(3)
122+
<< ms.count() << 'Z';
123+
return oss.str();
124+
}
125+
126+
#ifndef _WIN32
127+
// Signal handler to print a message when the process is terminated.
128+
// Uses only async-signal-safe functions.
129+
void termination_signal_handler(int sig) {
130+
const char *msg = "[ SIGNAL ] Process received SIGTERM\n";
131+
// write() is async-signal-safe, unlike std::cout
132+
ssize_t written = write(STDOUT_FILENO, msg, strlen(msg));
133+
(void) written; // suppress "unused variable" warnings
134+
// Re-raise with default handler to get proper exit status
135+
std::signal(sig, SIG_DFL);
136+
std::raise(sig);
137+
}
138+
#endif
139+
140+
} // namespace
141+
20142
int main(int argc, char *argv[]) {
143+
#ifndef _WIN32
144+
std::signal(SIGTERM, termination_signal_handler);
145+
#endif
146+
21147
// Setup for TEST_CASE in test_interpreter.cpp, tagging on a large random number:
22148
std::string updated_pythonpath("pybind11_test_with_catch_PYTHONPATH_2099743835476552");
23149
const char *preexisting_pythonpath = getenv("PYTHONPATH");
@@ -35,9 +161,15 @@ int main(int argc, char *argv[]) {
35161
setenv("PYTHONPATH", updated_pythonpath.c_str(), /*replace=*/1);
36162
#endif
37163

164+
std::cout << "[ STARTING ] " << get_utc_timestamp() << '\n';
165+
std::cout.flush();
166+
38167
py::scoped_interpreter guard{};
39168

40169
auto result = Catch::Session().run(argc, argv);
41170

171+
std::cout << "[ DONE ] " << get_utc_timestamp() << " (result " << result << ")\n";
172+
std::cout.flush();
173+
42174
return result < 0xff ? result : 0xff;
43175
}

tests/test_with_catch/catch_skip.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Macro to skip a test at runtime with a visible message.
2+
// Catch2 v2 doesn't have native skip support (v3 does with SKIP()).
3+
// The test will count as "passed" in totals, but the output clearly shows it was skipped.
4+
5+
#pragma once
6+
7+
#include <catch.hpp>
8+
9+
#define PYBIND11_CATCH2_SKIP_IF(condition, reason) \
10+
do { \
11+
if (condition) { \
12+
Catch::cout() << "[ SKIPPED ] " << (reason) << '\n'; \
13+
Catch::cout().flush(); \
14+
return; \
15+
} \
16+
} while (0)

tests/test_with_catch/test_subinterpreter.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
// catch 2.0.1; this should be fixed in the next catch release after 2.0.1).
77
PYBIND11_WARNING_DISABLE_MSVC(4996)
88

9+
# include "catch_skip.h"
10+
911
# include <catch.hpp>
1012
# include <cstdlib>
1113
# include <fstream>
@@ -92,6 +94,13 @@ TEST_CASE("Single Subinterpreter") {
9294

9395
# if PY_VERSION_HEX >= 0x030D0000
9496
TEST_CASE("Move Subinterpreter") {
97+
// Test is skipped on free-threaded Python 3.14+ due to a hang in Py_EndInterpreter()
98+
// when the subinterpreter is destroyed from a different thread than it was created on.
99+
// See: https://github.com/pybind/pybind11/pull/5940
100+
# if PY_VERSION_HEX >= 0x030E0000 && defined(Py_GIL_DISABLED)
101+
PYBIND11_CATCH2_SKIP_IF(true, "Skipped on free-threaded Python 3.14+ (see PR #5940)");
102+
# endif
103+
95104
std::unique_ptr<py::subinterpreter> sub(new py::subinterpreter(py::subinterpreter::create()));
96105

97106
// on this thread, use the subinterpreter and import some non-trivial junk

0 commit comments

Comments
 (0)