From 9b9f9e2e22e6d802d36f05a2ae66827df7c064cc Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 01:08:32 +0800 Subject: [PATCH 01/15] feat(zest): support parallel test execution via --parallel flag Add `bool parallel` to RunnerOptions and `--parallel` CLI flag. When enabled, all test cases run concurrently via std::jthread and results are printed after completion. Also make deferred_cancel_await in when_tests thread-safe via thread_local. Co-Authored-By: Claude Opus 4.6 (1M context) --- include/eventide/zest/run.h | 2 + src/zest/runner.cpp | 95 ++++++++++++++++++++++++++++----- tests/unit/async/when_tests.cpp | 17 +++--- 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/include/eventide/zest/run.h b/include/eventide/zest/run.h index fa67ccca..2a105142 100644 --- a/include/eventide/zest/run.h +++ b/include/eventide/zest/run.h @@ -11,6 +11,8 @@ struct RunnerOptions { std::string filter; /// When true, per-test output is limited to failing cases; the final summary is still printed. bool only_failed_output = false; + /// When true, test cases are executed in parallel across multiple threads. + bool parallel = false; }; /// Parse CLI arguments into RunnerOptions and execute registered tests. diff --git a/src/zest/runner.cpp b/src/zest/runner.cpp index f9f0cea9..3a2285d6 100644 --- a/src/zest/runner.cpp +++ b/src/zest/runner.cpp @@ -1,10 +1,13 @@ #include #include #include +#include #include #include #include +#include #include +#include #include #include "eventide/deco/deco.h" @@ -34,6 +37,9 @@ struct ZestCliOptions { DecoFlag(names = {"--only-failed"}; help = "Only print failed test cases"; required = false) only_failed = false; + + DecoFlag(names = {"--parallel"}; help = "Run test cases in parallel"; required = false) + parallel = false; }; auto to_runner_options(ZestCliOptions options) @@ -44,6 +50,7 @@ auto to_runner_options(ZestCliOptions options) eventide::zest::RunnerOptions runner_options; runner_options.only_failed_output = *options.only_failed; + runner_options.parallel = *options.parallel; if(options.test_filter_input.has_value()) { runner_options.filter = std::move(*options.test_filter_input); } else { @@ -72,6 +79,14 @@ struct RunSummary { std::vector failed_tests; }; +struct TestResult { + std::string display_name; + std::string path; + std::size_t line; + eventide::zest::TestState state; + std::chrono::milliseconds duration; +}; + using SuiteMap = std::unordered_map>; bool matches_pattern(std::string_view text, std::string_view pattern) { @@ -274,20 +289,28 @@ int Runner::run_tests(RunnerOptions options) { std::println("{}[ FOCUS ] Running in focus-only mode.{}", yellow, clear); } + // Collect all runnable test cases. + struct RunnableTest { + std::string display_name; + std::string path; + std::size_t line; + std::function test; + }; + + std::vector runnable; + std::unordered_set active_suites; + for(auto& [suite_name, test_cases]: grouped_suites) { if(!matches_suite_filter(suite_name, patterns)) { continue; } - bool suite_has_tests = false; - for(auto& test_case: test_cases) { if(!matches_test_filter(suite_name, test_case.name, patterns)) { continue; } const auto display_name = make_display_name(suite_name, test_case.name); - suite_has_tests = true; if(focus_mode && !test_case.attrs.focus) { summary.skipped += 1; @@ -302,31 +325,79 @@ int Runner::run_tests(RunnerOptions options) { continue; } + active_suites.insert(std::string(suite_name)); + runnable.push_back(RunnableTest{ + .display_name = display_name, + .path = test_case.path, + .line = test_case.line, + .test = std::move(test_case.test), + }); + } + } + + summary.suites = static_cast(active_suites.size()); + summary.tests = static_cast(runnable.size()); + + // Execute tests. + std::vector results(runnable.size()); + + if(options.parallel) { + std::vector threads; + threads.reserve(runnable.size()); + + for(std::size_t i = 0; i < runnable.size(); ++i) { + threads.emplace_back([&, i]() { + using namespace std::chrono; + auto begin = system_clock::now(); + auto state = runnable[i].test(); + auto end = system_clock::now(); + + results[i] = TestResult{ + .display_name = runnable[i].display_name, + .path = runnable[i].path, + .line = runnable[i].line, + .state = state, + .duration = duration_cast(end - begin), + }; + }); + } + + // jthread destructor joins automatically. + threads.clear(); + + // Print results after all tests complete. + for(const auto& result: results) { + const bool failed = is_failure(result.state); + print_run_result(result.display_name, failed, result.duration, options.only_failed_output); + summary.duration += result.duration; + if(failed) { + summary.failed += 1; + summary.failed_tests.push_back( + FailedTest{result.display_name, result.path, result.line}); + } + } + } else { + for(std::size_t i = 0; i < runnable.size(); ++i) { if(!options.only_failed_output) { - std::println("{}[ RUN ] {}{}", green, display_name, clear); + std::println("{}[ RUN ] {}{}", green, runnable[i].display_name, clear); } - summary.tests += 1; using namespace std::chrono; auto begin = system_clock::now(); - auto state = test_case.test(); + auto state = runnable[i].test(); auto end = system_clock::now(); auto duration = duration_cast(end - begin); const bool failed = is_failure(state); - print_run_result(display_name, failed, duration, options.only_failed_output); + print_run_result(runnable[i].display_name, failed, duration, options.only_failed_output); summary.duration += duration; if(failed) { summary.failed += 1; summary.failed_tests.push_back( - FailedTest{display_name, test_case.path, test_case.line}); + FailedTest{runnable[i].display_name, runnable[i].path, runnable[i].line}); } } - - if(suite_has_tests) { - summary.suites += 1; - } } print_summary(summary); diff --git a/tests/unit/async/when_tests.cpp b/tests/unit/async/when_tests.cpp index 3278091d..e29ddd2f 100644 --- a/tests/unit/async/when_tests.cpp +++ b/tests/unit/async/when_tests.cpp @@ -9,14 +9,17 @@ namespace eventide { namespace { struct deferred_cancel_await : system_op { - inline static deferred_cancel_await* pending = nullptr; + static deferred_cancel_await*& pending() { + thread_local deferred_cancel_await* p = nullptr; + return p; + } int* destroyed = nullptr; explicit deferred_cancel_await(int& destroyed_count) : destroyed(&destroyed_count) { - assert(pending == nullptr && "only one deferred_cancel_await may be pending at a time"); + assert(pending() == nullptr && "only one deferred_cancel_await may be pending at a time"); action = &on_cancel; - pending = this; + pending() = this; } deferred_cancel_await(const deferred_cancel_await&) = delete; @@ -28,8 +31,8 @@ struct deferred_cancel_await : system_op { if(destroyed) { *destroyed += 1; } - if(pending == this) { - pending = nullptr; + if(pending() == this) { + pending() = nullptr; } } @@ -49,9 +52,9 @@ struct deferred_cancel_await : system_op { void await_resume() const noexcept {} static void finish_pending_cancel() { - auto* op = pending; + auto* op = pending(); assert(op != nullptr && "finish_pending_cancel requires a pending awaiter"); - pending = nullptr; + pending() = nullptr; op->complete(); } }; From 39b0eb722782629f59757d2b631ff7306294da2f Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 01:37:22 +0800 Subject: [PATCH 02/15] fix: make tests parallel-safe - Add tcp::local_port() to query bound port from acceptor, eliminating pick_free_port() TOCTOU race by binding to port 0 directly - Convert global static state to thread_local in when_tests and deco runtime tests Co-Authored-By: Claude Opus 4.6 (1M context) --- include/eventide/async/io/stream.h | 3 ++ src/async/io/acceptor.cpp | 21 ++++++++++ tests/unit/async/stream_tests.cpp | 62 ++++++++++-------------------- tests/unit/deco/runtime.cc | 32 +++++++-------- 4 files changed, 60 insertions(+), 58 deletions(-) diff --git a/include/eventide/async/io/stream.h b/include/eventide/async/io/stream.h index 2e0ea33d..b47052e8 100644 --- a/include/eventide/async/io/stream.h +++ b/include/eventide/async/io/stream.h @@ -197,6 +197,9 @@ class tcp : public stream { int port, options opts = options(), event_loop& loop = event_loop::current()); + + /// Query the local address/port of a listening acceptor. + static result local_port(acceptor& acc); }; /// TTY/console wrapper. diff --git a/src/async/io/acceptor.cpp b/src/async/io/acceptor.cpp index 46a9cca5..72b5b38e 100644 --- a/src/async/io/acceptor.cpp +++ b/src/async/io/acceptor.cpp @@ -430,4 +430,25 @@ result return tcp::acceptor(std::move(self)); } +result tcp::local_port(tcp::acceptor& acc) { + if(!acc.self) { + return outcome_error(error::invalid_argument); + } + + sockaddr_storage storage{}; + int namelen = sizeof(storage); + int err = uv_tcp_getsockname(&acc->tcp, reinterpret_cast(&storage), &namelen); + if(err != 0) { + return outcome_error(uv::status_to_error(err)); + } + + if(storage.ss_family == AF_INET) { + return ntohs(reinterpret_cast(&storage)->sin_port); + } else if(storage.ss_family == AF_INET6) { + return ntohs(reinterpret_cast(&storage)->sin6_port); + } + + return outcome_error(error::invalid_argument); +} + } // namespace eventide diff --git a/tests/unit/async/stream_tests.cpp b/tests/unit/async/stream_tests.cpp index e90b6f0a..e99e3427 100644 --- a/tests/unit/async/stream_tests.cpp +++ b/tests/unit/async/stream_tests.cpp @@ -60,32 +60,6 @@ inline int close_socket(socket_t sock) { } #endif -int pick_free_port() { - socket_t fd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); - if(fd == invalid_socket) { - return -1; - } - - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = 0; - - if(::bind(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { - close_socket(fd); - return -1; - } - - socklen_t len = sizeof(addr); - if(::getsockname(fd, reinterpret_cast(&addr), &len) != 0) { - close_socket(fd); - return -1; - } - - int port = ntohs(addr.sin_port); - close_socket(fd); - return port; -} bool bump_and_stop(int& done, int target) { done += 1; @@ -505,13 +479,14 @@ TEST_CASE(stop) { TEST_SUITE(tcp) { TEST_CASE(accept_and_read) { - int port = pick_free_port(); - ASSERT_TRUE(port > 0); - event_loop loop; - auto acc_res = tcp::listen("127.0.0.1", port, {}, loop); + auto acc_res = tcp::listen("127.0.0.1", 0, {}, loop); ASSERT_TRUE(acc_res.has_value()); + auto port_res = tcp::local_port(*acc_res); + ASSERT_TRUE(port_res.has_value()); + int port = *port_res; + auto server = accept_and_read(std::move(*acc_res)); socket_t client_fd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); @@ -538,13 +513,14 @@ TEST_CASE(accept_and_read) { } TEST_CASE(accept_already_waiting) { - int port = pick_free_port(); - ASSERT_TRUE(port > 0); - event_loop loop; - auto acc_res = tcp::listen("127.0.0.1", port, {}, loop); + auto acc_res = tcp::listen("127.0.0.1", 0, {}, loop); ASSERT_TRUE(acc_res.has_value()); + auto port_res = tcp::local_port(*acc_res); + ASSERT_TRUE(port_res.has_value()); + int port = *port_res; + auto acc = std::move(*acc_res); int done = 0; @@ -576,13 +552,14 @@ TEST_CASE(accept_already_waiting) { } TEST_CASE(connect_and_write) { - int port = pick_free_port(); - ASSERT_TRUE(port > 0); - event_loop loop; - auto acc_res = tcp::listen("127.0.0.1", port, {}, loop); + auto acc_res = tcp::listen("127.0.0.1", 0, {}, loop); ASSERT_TRUE(acc_res.has_value()); + auto port_res = tcp::local_port(*acc_res); + ASSERT_TRUE(port_res.has_value()); + int port = *port_res; + int done = 0; auto server = accept_and_read_once(std::move(*acc_res), done); auto client = connect_and_send("127.0.0.1", port, "eventide-tcp-connect", done); @@ -599,13 +576,14 @@ TEST_CASE(connect_and_write) { } TEST_CASE(read_some_error) { - int port = pick_free_port(); - ASSERT_TRUE(port > 0); - event_loop loop; - auto acc_res = tcp::listen("127.0.0.1", port, {}, loop); + auto acc_res = tcp::listen("127.0.0.1", 0, {}, loop); ASSERT_TRUE(acc_res.has_value()); + auto port_res = tcp::local_port(*acc_res); + ASSERT_TRUE(port_res.has_value()); + int port = *port_res; + auto server = accept_and_read_some(std::move(*acc_res)); socket_t client_fd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); diff --git a/tests/unit/deco/runtime.cc b/tests/unit/deco/runtime.cc index 93eabe5a..be035f88 100644 --- a/tests/unit/deco/runtime.cc +++ b/tests/unit/deco/runtime.cc @@ -134,10 +134,10 @@ struct TrailingOnlyOpt { }; struct CallbackStopState { - inline static unsigned arg_index = 0; - inline static unsigned next_cursor = 0; - inline static std::size_t argv_size = 0; - inline static std::string value; + inline thread_local static unsigned arg_index = 0; + inline thread_local static unsigned next_cursor = 0; + inline thread_local static std::size_t argv_size = 0; + inline thread_local static std::string value; static void reset() { arg_index = 0; @@ -162,9 +162,9 @@ struct CallbackStopOpt { }; struct CallbackRestartState { - inline static unsigned arg_index = 0; - inline static unsigned next_cursor = 0; - inline static std::string value; + inline thread_local static unsigned arg_index = 0; + inline thread_local static unsigned next_cursor = 0; + inline thread_local static std::string value; static void reset() { arg_index = 0; @@ -190,9 +190,9 @@ struct CallbackRestartOpt { }; struct CallbackRestartOwnedState { - inline static unsigned arg_index = 0; - inline static unsigned next_cursor = 0; - inline static std::string value; + inline thread_local static unsigned arg_index = 0; + inline thread_local static unsigned next_cursor = 0; + inline thread_local static std::string value; static void reset() { arg_index = 0; @@ -217,7 +217,7 @@ struct CallbackRestartOwnedOpt { }; struct CallbackRestartTwiceState { - inline static unsigned restart_count = 0; + inline thread_local static unsigned restart_count = 0; static void reset() { restart_count = 0; @@ -251,11 +251,11 @@ struct CallbackShortcutOpt { }; struct CallbackComposeState { - inline static unsigned arg_index = 0; - inline static unsigned next_cursor = 0; - inline static std::size_t argv_size = 0; - inline static bool value = false; - inline static unsigned count = 0; + inline thread_local static unsigned arg_index = 0; + inline thread_local static unsigned next_cursor = 0; + inline thread_local static std::size_t argv_size = 0; + inline thread_local static bool value = false; + inline thread_local static unsigned count = 0; static void reset() { arg_index = 0; From a6c0345bb27b3ff3d58d1c3c01fea0306cc81735 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 08:49:10 +0800 Subject: [PATCH 03/15] refactor(zest): use thread pool instead of per-test threads Replace N-thread-per-test with fixed-size thread pool (default: hardware_concurrency) using atomic task counter. Add --parallel-workers flag. Also fix small_vector test global state to thread_local. Co-Authored-By: Claude Opus 4.6 (1M context) --- include/eventide/zest/run.h | 4 ++- src/zest/runner.cpp | 41 ++++++++++++++++++------ tests/unit/common/small_vector_tests.cpp | 4 +-- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/include/eventide/zest/run.h b/include/eventide/zest/run.h index 2a105142..635c8e04 100644 --- a/include/eventide/zest/run.h +++ b/include/eventide/zest/run.h @@ -11,8 +11,10 @@ struct RunnerOptions { std::string filter; /// When true, per-test output is limited to failing cases; the final summary is still printed. bool only_failed_output = false; - /// When true, test cases are executed in parallel across multiple threads. + /// When true, test cases are executed in parallel across a thread pool. bool parallel = false; + /// Number of worker threads for parallel mode (0 = hardware_concurrency). + unsigned parallel_workers = 0; }; /// Parse CLI arguments into RunnerOptions and execute registered tests. diff --git a/src/zest/runner.cpp b/src/zest/runner.cpp index 3a2285d6..579d0ec5 100644 --- a/src/zest/runner.cpp +++ b/src/zest/runner.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -40,6 +41,13 @@ struct ZestCliOptions { DecoFlag(names = {"--parallel"}; help = "Run test cases in parallel"; required = false) parallel = false; + + DecoKVStyled(style = deco::decl::KVStyle::Joined | deco::decl::KVStyle::Separate, + names = {"--parallel-workers"}; + meta_var = ""; + help = "Number of worker threads for parallel mode (default: hardware_concurrency)"; + required = false) + parallel_workers = 0; }; auto to_runner_options(ZestCliOptions options) @@ -51,6 +59,7 @@ auto to_runner_options(ZestCliOptions options) eventide::zest::RunnerOptions runner_options; runner_options.only_failed_output = *options.only_failed; runner_options.parallel = *options.parallel; + runner_options.parallel_workers = *options.parallel_workers; if(options.test_filter_input.has_value()) { runner_options.filter = std::move(*options.test_filter_input); } else { @@ -342,11 +351,20 @@ int Runner::run_tests(RunnerOptions options) { std::vector results(runnable.size()); if(options.parallel) { - std::vector threads; - threads.reserve(runnable.size()); + const auto num_workers = + std::max(1u, options.parallel_workers + ? options.parallel_workers + : std::thread::hardware_concurrency()); + + std::atomic next_task{0}; + + auto worker = [&]() { + while(true) { + auto i = next_task.fetch_add(1, std::memory_order_relaxed); + if(i >= runnable.size()) { + break; + } - for(std::size_t i = 0; i < runnable.size(); ++i) { - threads.emplace_back([&, i]() { using namespace std::chrono; auto begin = system_clock::now(); auto state = runnable[i].test(); @@ -359,13 +377,18 @@ int Runner::run_tests(RunnerOptions options) { .state = state, .duration = duration_cast(end - begin), }; - }); - } + } + }; - // jthread destructor joins automatically. - threads.clear(); + { + std::vector pool; + pool.reserve(num_workers); + for(unsigned w = 0; w < num_workers; ++w) { + pool.emplace_back(worker); + } + } - // Print results after all tests complete. + // Print results after all workers finish. for(const auto& result: results) { const bool failed = is_failure(result.state); print_run_result(result.display_name, failed, result.duration, options.only_failed_output); diff --git a/tests/unit/common/small_vector_tests.cpp b/tests/unit/common/small_vector_tests.cpp index 44953164..448ece1b 100644 --- a/tests/unit/common/small_vector_tests.cpp +++ b/tests/unit/common/small_vector_tests.cpp @@ -44,7 +44,7 @@ struct move_only { } }; -static int nontrivial_alive = 0; +static thread_local int nontrivial_alive = 0; struct nontrivial { int value; @@ -1389,7 +1389,7 @@ TEST_CASE(assign_from_empty_hybrid_vector) { #ifdef __cpp_exceptions struct throwing_copy { - inline static int throw_after = -1; + inline thread_local static int throw_after = -1; int value = 0; throwing_copy() = default; From 7d11140d590b9cd1f3495dd454b86679e1db9216 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 12:00:15 +0800 Subject: [PATCH 04/15] fix: parallel test safety and add serial test attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `serial` attribute to zest test framework (suite-level and case-level) - Fix thread-safety issues: static → thread_local for deco args helper, thread-local libuv sync loop, mutex-guarded uv_spawn - Mark process_io suite and cancellation pool-exhaustion tests as serial - Rewrite query_info_child test with pipe synchronization (no sleep) - Add parallel test run to CI alongside serial run Co-Authored-By: Claude Opus 4.6 --- .github/workflows/cmake.yml | 4 + include/eventide/zest/detail/registry.h | 1 + include/eventide/zest/detail/suite.h | 18 +++- include/eventide/zest/macro.h | 3 +- src/async/io/fs.cpp | 22 ++++- src/async/libuv.h | 12 ++- src/zest/runner.cpp | 118 +++++++++++++----------- tests/unit/async/cancellation_tests.cpp | 4 +- tests/unit/async/process_tests.cpp | 47 ++++++---- tests/unit/deco/runtime.cc | 2 +- 10 files changed, 149 insertions(+), 82 deletions(-) diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 11a7e1b6..585699d7 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -165,6 +165,10 @@ jobs: cmake --build "${build_dir}" --parallel ctest --test-dir "${build_dir}" --verbose + echo "::group::Parallel test run" + "${build_dir}/unit_tests" --parallel + echo "::endgroup::" + - name: Show compiler cache stats shell: bash run: | diff --git a/include/eventide/zest/detail/registry.h b/include/eventide/zest/detail/registry.h index 3b6ee5a6..57133090 100644 --- a/include/eventide/zest/detail/registry.h +++ b/include/eventide/zest/detail/registry.h @@ -19,6 +19,7 @@ enum class TestState { struct TestAttrs { bool skip = false; bool focus = false; + bool serial = false; }; struct TestCase { diff --git a/include/eventide/zest/detail/suite.h b/include/eventide/zest/detail/suite.h index b0048783..f290410a 100644 --- a/include/eventide/zest/detail/suite.h +++ b/include/eventide/zest/detail/suite.h @@ -5,7 +5,17 @@ namespace eventide::zest { -template +/// Merge suite-level and case-level test attributes. +/// Case-level flags override suite defaults when explicitly set to true. +constexpr TestAttrs merge_attrs(TestAttrs suite, TestAttrs test_case) { + return { + .skip = suite.skip || test_case.skip, + .focus = suite.focus || test_case.focus, + .serial = suite.serial || test_case.serial, + }; +} + +template struct TestSuiteDef { private: TestState state = TestState::Passed; @@ -61,7 +71,11 @@ struct TestSuiteDef { return test.state; }; - test_cases().emplace_back(case_name.data(), path.data(), line, attrs, run_test); + test_cases().emplace_back(case_name.data(), + path.data(), + line, + merge_attrs(SuiteAttrs, attrs), + run_test); return true; }(); }; diff --git a/include/eventide/zest/macro.h b/include/eventide/zest/macro.h index 1de43248..a0829d95 100644 --- a/include/eventide/zest/macro.h +++ b/include/eventide/zest/macro.h @@ -4,7 +4,8 @@ #include "eventide/zest/detail/suite.h" #include "eventide/zest/detail/trace.h" -#define TEST_SUITE(name) struct name##TEST : ::eventide::zest::TestSuiteDef<#name, name##TEST> +#define TEST_SUITE(name, ...) \ + struct name##TEST : ::eventide::zest::TestSuiteDef<#name, name##TEST __VA_OPT__(, ) __VA_ARGS__> #define TEST_CASE(name, ...) \ void _register_##name() { \ diff --git a/src/async/io/fs.cpp b/src/async/io/fs.cpp index aafcd8e9..0a48a85a 100644 --- a/src/async/io/fs.cpp +++ b/src/async/io/fs.cpp @@ -617,11 +617,25 @@ task, error> fs::readdir(fs::dir_handle& dir, event_loop namespace { -/// Returns the default loop handle for synchronous libuv fs calls. -/// libuv sync operations (cb = nullptr) don't actually interact with the -/// event loop, so uv_default_loop() is safe to use from any thread. +/// Returns a thread-local loop handle for synchronous libuv fs calls. +/// libuv sync operations (cb = nullptr) don't actually run the event loop, +/// but uv_default_loop() is a process-global singleton and is NOT thread-safe. +/// Using a thread-local loop avoids data races when sync fs calls are made +/// from multiple threads concurrently. uv_loop_t* sync_loop() noexcept { - return uv_default_loop(); + static thread_local struct sync_loop_holder { + uv_loop_t loop{}; + + sync_loop_holder() { + uv_loop_init(&loop); + } + + ~sync_loop_holder() { + uv_loop_close(&loop); + } + } holder; + + return &holder.loop; } } // namespace diff --git a/src/async/libuv.h b/src/async/libuv.h index 43f36bab..17c5e98b 100644 --- a/src/async/libuv.h +++ b/src/async/libuv.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -546,10 +547,15 @@ ALWAYS_INLINE std::size_t udp_get_send_queue_count(const uv_udp_t& handle) noexc return ::uv_udp_get_send_queue_count(&handle); } -ALWAYS_INLINE error spawn(uv_loop_t& loop, - uv_process_t& process, - const uv_process_options_t& options) noexcept { +/// uv_spawn internally registers a SIGCHLD handler in a process-global +/// red-black tree that is NOT thread-safe. Serialise all spawn calls +/// so that concurrent event-loops on different threads do not race. +inline error spawn(uv_loop_t& loop, + uv_process_t& process, + const uv_process_options_t& options) noexcept { assert(options.file != nullptr && "uv::spawn requires options.file"); + static std::mutex spawn_mutex; + std::lock_guard lock(spawn_mutex); return status_to_error(::uv_spawn(&loop, &process, &options)); } diff --git a/src/zest/runner.cpp b/src/zest/runner.cpp index 579d0ec5..b6675681 100644 --- a/src/zest/runner.cpp +++ b/src/zest/runner.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include #include @@ -42,11 +41,12 @@ struct ZestCliOptions { DecoFlag(names = {"--parallel"}; help = "Run test cases in parallel"; required = false) parallel = false; - DecoKVStyled(style = deco::decl::KVStyle::Joined | deco::decl::KVStyle::Separate, - names = {"--parallel-workers"}; - meta_var = ""; - help = "Number of worker threads for parallel mode (default: hardware_concurrency)"; - required = false) + DecoKVStyled( + style = deco::decl::KVStyle::Joined | deco::decl::KVStyle::Separate, + names = {"--parallel-workers"}; + meta_var = ""; + help = "Number of worker threads for parallel mode (default: hardware_concurrency)"; + required = false) parallel_workers = 0; }; @@ -303,6 +303,7 @@ int Runner::run_tests(RunnerOptions options) { std::string display_name; std::string path; std::size_t line; + bool serial; std::function test; }; @@ -339,6 +340,7 @@ int Runner::run_tests(RunnerOptions options) { .display_name = display_name, .path = test_case.path, .line = test_case.line, + .serial = test_case.attrs.serial, .test = std::move(test_case.test), }); } @@ -347,36 +349,67 @@ int Runner::run_tests(RunnerOptions options) { summary.suites = static_cast(active_suites.size()); summary.tests = static_cast(runnable.size()); + auto run_single = [&](const RunnableTest& test, bool show_run_line) -> TestResult { + if(show_run_line && !options.only_failed_output) { + std::println("{}[ RUN ] {}{}", green, test.display_name, clear); + } + + using namespace std::chrono; + auto begin = system_clock::now(); + auto state = test.test(); + auto end = system_clock::now(); + + return TestResult{ + .display_name = test.display_name, + .path = test.path, + .line = test.line, + .state = state, + .duration = duration_cast(end - begin), + }; + }; + + auto record_result = [&](const TestResult& result) { + const bool failed = is_failure(result.state); + print_run_result(result.display_name, failed, result.duration, options.only_failed_output); + summary.duration += result.duration; + if(failed) { + summary.failed += 1; + summary.failed_tests.push_back( + FailedTest{result.display_name, result.path, result.line}); + } + }; + // Execute tests. std::vector results(runnable.size()); if(options.parallel) { + // Partition: parallel-safe tests first, serial tests after. + std::vector parallel_indices; + std::vector serial_indices; + for(std::size_t i = 0; i < runnable.size(); ++i) { + if(runnable[i].serial) { + serial_indices.push_back(i); + } else { + parallel_indices.push_back(i); + } + } + + // Run parallel-safe tests across the thread pool. const auto num_workers = - std::max(1u, options.parallel_workers - ? options.parallel_workers - : std::thread::hardware_concurrency()); + std::max(1u, + options.parallel_workers ? options.parallel_workers + : std::thread::hardware_concurrency()); std::atomic next_task{0}; auto worker = [&]() { while(true) { - auto i = next_task.fetch_add(1, std::memory_order_relaxed); - if(i >= runnable.size()) { + auto idx = next_task.fetch_add(1, std::memory_order_relaxed); + if(idx >= parallel_indices.size()) { break; } - - using namespace std::chrono; - auto begin = system_clock::now(); - auto state = runnable[i].test(); - auto end = system_clock::now(); - - results[i] = TestResult{ - .display_name = runnable[i].display_name, - .path = runnable[i].path, - .line = runnable[i].line, - .state = state, - .duration = duration_cast(end - begin), - }; + auto i = parallel_indices[idx]; + results[i] = run_single(runnable[i], false); } }; @@ -388,38 +421,19 @@ int Runner::run_tests(RunnerOptions options) { } } - // Print results after all workers finish. + // Run serial tests sequentially after the parallel batch. + for(auto i: serial_indices) { + results[i] = run_single(runnable[i], false); + } + + // Print all results in original order. for(const auto& result: results) { - const bool failed = is_failure(result.state); - print_run_result(result.display_name, failed, result.duration, options.only_failed_output); - summary.duration += result.duration; - if(failed) { - summary.failed += 1; - summary.failed_tests.push_back( - FailedTest{result.display_name, result.path, result.line}); - } + record_result(result); } } else { for(std::size_t i = 0; i < runnable.size(); ++i) { - if(!options.only_failed_output) { - std::println("{}[ RUN ] {}{}", green, runnable[i].display_name, clear); - } - - using namespace std::chrono; - auto begin = system_clock::now(); - auto state = runnable[i].test(); - auto end = system_clock::now(); - - auto duration = duration_cast(end - begin); - const bool failed = is_failure(state); - print_run_result(runnable[i].display_name, failed, duration, options.only_failed_output); - - summary.duration += duration; - if(failed) { - summary.failed += 1; - summary.failed_tests.push_back( - FailedTest{runnable[i].display_name, runnable[i].path, runnable[i].line}); - } + results[i] = run_single(runnable[i], true); + record_result(results[i]); } } diff --git a/tests/unit/async/cancellation_tests.cpp b/tests/unit/async/cancellation_tests.cpp index 88087f75..7f75463a 100644 --- a/tests/unit/async/cancellation_tests.cpp +++ b/tests/unit/async/cancellation_tests.cpp @@ -126,7 +126,7 @@ TEST_CASE(token_share_state) { EXPECT_TRUE(token_b.cancelled()); } -TEST_CASE(queue_cancel_resume) { +TEST_CASE(queue_cancel_resume, zest::TestAttrs{.serial = true}) { event_loop loop; cancellation_source source; event start_target; @@ -213,7 +213,7 @@ TEST_CASE(queue_cancel_resume) { EXPECT_FALSE(target_started.load(std::memory_order_acquire)); } -TEST_CASE(fs_cancel_resume) { +TEST_CASE(fs_cancel_resume, zest::TestAttrs{.serial = true}) { event_loop loop; cancellation_source source; event start_target; diff --git a/tests/unit/async/process_tests.cpp b/tests/unit/async/process_tests.cpp index 3be18589..890401c0 100644 --- a/tests/unit/async/process_tests.cpp +++ b/tests/unit/async/process_tests.cpp @@ -50,7 +50,7 @@ task, result>> read_two_chunks(pipe p } // namespace -TEST_SUITE(process_io) { +TEST_SUITE(process_io, zest::TestAttrs{.serial = true}) { TEST_CASE(spawn_wait_simple) { event_loop loop; @@ -312,13 +312,16 @@ TEST_CASE(query_info_child) { process::options opts; #ifdef _WIN32 opts.file = "cmd.exe"; - // Run a command that takes a moment so we can query it while alive. - opts.args = {opts.file, "/c", "ping -n 2 127.0.0.1 >nul"}; + // Echo a ready marker then block on stdin until the parent closes it. + opts.args = {opts.file, "/c", "echo x & set /p dummy="}; #else opts.file = "/bin/sh"; - opts.args = {opts.file, "-c", "sleep 0.2"}; + // Print a ready marker then block on stdin until the parent closes the pipe. + opts.args = {opts.file, "-c", "printf x; read _"}; #endif - opts.streams = {process::stdio::ignore(), process::stdio::ignore(), process::stdio::ignore()}; + opts.streams = {process::stdio::pipe(true, false), + process::stdio::pipe(false, true), + process::stdio::ignore()}; auto spawn_res = process::spawn(opts, loop); ASSERT_TRUE(spawn_res.has_value()); @@ -326,19 +329,29 @@ TEST_CASE(query_info_child) { auto pid = spawn_res->proc.pid(); EXPECT_GT(pid, 0); - // Query via the instance method. - auto info = spawn_res->proc.query_info(); - ASSERT_TRUE(info.has_value()); - EXPECT_EQ(info->pid, pid); - EXPECT_GT(info->rss, std::size_t{0}); - - // Query via the static overload. - auto info2 = process::query_info(pid); - ASSERT_TRUE(info2.has_value()); - EXPECT_EQ(info2->pid, pid); + auto verify = [&]() -> task { + // Wait until the child has written to stdout — it is definitely running. + auto data = co_await spawn_res->stdout_pipe.read(); + EXPECT_TRUE(data.has_value()); + + // Query via the instance method. + auto info = spawn_res->proc.query_info(); + CO_ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->pid, pid); + EXPECT_GT(info->rss, std::size_t{0}); + + // Query via the static overload. + auto info2 = process::query_info(pid); + CO_ASSERT_TRUE(info2.has_value()); + EXPECT_EQ(info2->pid, pid); + + // Destroy stdin pipe so the child gets EOF and exits. + { auto drop = std::move(spawn_res->stdin_pipe); } + co_await spawn_res->proc.wait(); + event_loop::current().stop(); + }; - auto worker = wait_for_exit(spawn_res->proc); - loop.schedule(worker); + loop.schedule(verify()); loop.run(); } diff --git a/tests/unit/deco/runtime.cc b/tests/unit/deco/runtime.cc index be035f88..471b6ac2 100644 --- a/tests/unit/deco/runtime.cc +++ b/tests/unit/deco/runtime.cc @@ -318,7 +318,7 @@ namespace { template std::span into_deco_args(Args&&... args) { - static std::vector res; + static thread_local std::vector res; res.clear(); res.reserve(sizeof...(args)); (res.emplace_back(std::forward(args)), ...); From af8e6f3243748964d9e711c6c3e7dda4c734fec4 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 12:17:47 +0800 Subject: [PATCH 05/15] ci: simplify parallel test run output Co-Authored-By: Claude Opus 4.6 --- .github/workflows/cmake.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 585699d7..6231f3bf 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -165,9 +165,8 @@ jobs: cmake --build "${build_dir}" --parallel ctest --test-dir "${build_dir}" --verbose - echo "::group::Parallel test run" + echo "--- Running tests in parallel mode ---" "${build_dir}/unit_tests" --parallel - echo "::endgroup::" - name: Show compiler cache stats shell: bash From cc20ea1c1f7f0c6c2f26cb92595a31d661c19c07 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 13:10:24 +0800 Subject: [PATCH 06/15] fix: use std::thread instead of std::jthread for Apple Clang compat Co-Authored-By: Claude Opus 4.6 --- src/zest/runner.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/zest/runner.cpp b/src/zest/runner.cpp index b6675681..ba0179bb 100644 --- a/src/zest/runner.cpp +++ b/src/zest/runner.cpp @@ -414,11 +414,14 @@ int Runner::run_tests(RunnerOptions options) { }; { - std::vector pool; + std::vector pool; pool.reserve(num_workers); for(unsigned w = 0; w < num_workers; ++w) { pool.emplace_back(worker); } + for(auto& t: pool) { + t.join(); + } } // Run serial tests sequentially after the parallel batch. From da49a51c7dd5c978f2212a178ab1a538a0f44d76 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 13:34:30 +0800 Subject: [PATCH 07/15] fix: mark pipe tests with hardcoded names as serial pipe.connect_failure and pipe.stop use a hardcoded pipe name on Windows, causing conflicts when run in parallel. Co-Authored-By: Claude Opus 4.6 --- tests/unit/async/stream_tests.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/async/stream_tests.cpp b/tests/unit/async/stream_tests.cpp index 56938817..62d1c121 100644 --- a/tests/unit/async/stream_tests.cpp +++ b/tests/unit/async/stream_tests.cpp @@ -412,7 +412,7 @@ TEST_CASE(connect_and_accept) { } } -TEST_CASE(connect_failure) { +TEST_CASE(connect_failure, zest::TestAttrs{.serial = true}) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else @@ -431,7 +431,7 @@ TEST_CASE(connect_failure) { EXPECT_FALSE(client_res.has_value()); } -TEST_CASE(stop) { +TEST_CASE(stop, zest::TestAttrs{.serial = true}) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else From babb344ea2258fc0322afa40cbada2777b2516b0 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 14:21:22 +0800 Subject: [PATCH 08/15] refactor: expose TestAttrs alias in TestSuiteDef for shorter TEST_CASE usage TEST_CASE(name, TestAttrs{.serial = true}) instead of TEST_CASE(name, zest::TestAttrs{.serial = true}) Co-Authored-By: Claude Opus 4.6 --- include/eventide/zest/detail/suite.h | 1 + tests/unit/async/cancellation_tests.cpp | 4 ++-- tests/unit/async/stream_tests.cpp | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/include/eventide/zest/detail/suite.h b/include/eventide/zest/detail/suite.h index 897879e4..716a2753 100644 --- a/include/eventide/zest/detail/suite.h +++ b/include/eventide/zest/detail/suite.h @@ -18,6 +18,7 @@ constexpr TestAttrs merge_attrs(TestAttrs suite, TestAttrs test_case) { template struct TestSuiteDef { using Self = Derived; + using TestAttrs = ::eventide::zest::TestAttrs; constexpr inline static auto& test_cases() { static std::vector instance; diff --git a/tests/unit/async/cancellation_tests.cpp b/tests/unit/async/cancellation_tests.cpp index addaa96c..dfdc1c14 100644 --- a/tests/unit/async/cancellation_tests.cpp +++ b/tests/unit/async/cancellation_tests.cpp @@ -121,7 +121,7 @@ TEST_CASE(token_share_state) { EXPECT_TRUE(token_b.cancelled()); } -TEST_CASE(queue_cancel_resume, zest::TestAttrs{.serial = true}) { +TEST_CASE(queue_cancel_resume, TestAttrs{.serial = true}) { cancellation_source source; event start_target; event target_submitted; @@ -207,7 +207,7 @@ TEST_CASE(queue_cancel_resume, zest::TestAttrs{.serial = true}) { EXPECT_FALSE(target_started.load(std::memory_order_acquire)); } -TEST_CASE(fs_cancel_resume, zest::TestAttrs{.serial = true}) { +TEST_CASE(fs_cancel_resume, TestAttrs{.serial = true}) { cancellation_source source; event start_target; event target_submitted; diff --git a/tests/unit/async/stream_tests.cpp b/tests/unit/async/stream_tests.cpp index 62d1c121..3dc60df9 100644 --- a/tests/unit/async/stream_tests.cpp +++ b/tests/unit/async/stream_tests.cpp @@ -412,7 +412,7 @@ TEST_CASE(connect_and_accept) { } } -TEST_CASE(connect_failure, zest::TestAttrs{.serial = true}) { +TEST_CASE(connect_failure, TestAttrs{.serial = true}) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else @@ -431,7 +431,7 @@ TEST_CASE(connect_failure, zest::TestAttrs{.serial = true}) { EXPECT_FALSE(client_res.has_value()); } -TEST_CASE(stop, zest::TestAttrs{.serial = true}) { +TEST_CASE(stop, TestAttrs{.serial = true}) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else From 5758b0435f9275ce1d5cc0f4a8251576520203e5 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 14:26:20 +0800 Subject: [PATCH 09/15] refactor(zest): use assignment syntax for TEST_CASE attrs TEST_CASE(name, serial = true) instead of TEST_CASE(name, TestAttrs{.serial = true}) Uses the same DECO pattern: __VA_ARGS__ becomes statements inside a constructor of a struct inheriting from TestAttrs. Co-Authored-By: Claude Opus 4.6 --- include/eventide/zest/macro.h | 10 ++++++++-- tests/unit/async/cancellation_tests.cpp | 4 ++-- tests/unit/async/stream_tests.cpp | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/include/eventide/zest/macro.h b/include/eventide/zest/macro.h index 2c4e9284..f34b791e 100644 --- a/include/eventide/zest/macro.h +++ b/include/eventide/zest/macro.h @@ -12,11 +12,17 @@ constexpr auto file_name = std::source_location::current().file_name(); \ constexpr auto file_len = std::string_view(file_name).size(); \ (void)_register_suites<>; \ + constexpr auto _zest_attrs_ = [] constexpr { \ + struct _B : ::eventide::zest::TestAttrs { \ + constexpr _B() { __VA_OPT__(__VA_ARGS__;) } \ + }; \ + return ::eventide::zest::TestAttrs{_B{}}; \ + }(); \ (void)_register_test_case<#name, \ &Self::test_##name, \ ::eventide::fixed_string(file_name), \ - std::source_location::current().line() __VA_OPT__(, ) \ - __VA_ARGS__>; \ + std::source_location::current().line(), \ + _zest_attrs_>; \ } \ void test_##name() diff --git a/tests/unit/async/cancellation_tests.cpp b/tests/unit/async/cancellation_tests.cpp index dfdc1c14..e9b0a391 100644 --- a/tests/unit/async/cancellation_tests.cpp +++ b/tests/unit/async/cancellation_tests.cpp @@ -121,7 +121,7 @@ TEST_CASE(token_share_state) { EXPECT_TRUE(token_b.cancelled()); } -TEST_CASE(queue_cancel_resume, TestAttrs{.serial = true}) { +TEST_CASE(queue_cancel_resume, serial = true) { cancellation_source source; event start_target; event target_submitted; @@ -207,7 +207,7 @@ TEST_CASE(queue_cancel_resume, TestAttrs{.serial = true}) { EXPECT_FALSE(target_started.load(std::memory_order_acquire)); } -TEST_CASE(fs_cancel_resume, TestAttrs{.serial = true}) { +TEST_CASE(fs_cancel_resume, serial = true) { cancellation_source source; event start_target; event target_submitted; diff --git a/tests/unit/async/stream_tests.cpp b/tests/unit/async/stream_tests.cpp index 3dc60df9..8409a37e 100644 --- a/tests/unit/async/stream_tests.cpp +++ b/tests/unit/async/stream_tests.cpp @@ -412,7 +412,7 @@ TEST_CASE(connect_and_accept) { } } -TEST_CASE(connect_failure, TestAttrs{.serial = true}) { +TEST_CASE(connect_failure, serial = true) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else @@ -431,7 +431,7 @@ TEST_CASE(connect_failure, TestAttrs{.serial = true}) { EXPECT_FALSE(client_res.has_value()); } -TEST_CASE(stop, TestAttrs{.serial = true}) { +TEST_CASE(stop, serial = true) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else From 8ad3d1270b3be26b2db7ab7c748a442e3d07a4dc Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 14:35:40 +0800 Subject: [PATCH 10/15] feat(zest): add TEST_SUITE_ATTRS macro for suite-level attributes TEST_SUITE(process_io, loop_fixture) { TEST_SUITE_ATTRS(serial = true); ... }; Eliminates the need for a separate fixture struct just to carry attrs. Co-Authored-By: Claude Opus 4.6 --- include/eventide/zest/macro.h | 8 ++++++++ tests/unit/async/process_tests.cpp | 7 ++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/include/eventide/zest/macro.h b/include/eventide/zest/macro.h index f34b791e..77497cf4 100644 --- a/include/eventide/zest/macro.h +++ b/include/eventide/zest/macro.h @@ -7,6 +7,14 @@ #define TEST_SUITE(name, ...) \ struct name##TEST : __VA_OPT__(__VA_ARGS__, )::eventide::zest::TestSuiteDef<#name, name##TEST> +#define TEST_SUITE_ATTRS(...) \ + constexpr static ::eventide::zest::TestAttrs suite_attrs = [] constexpr { \ + struct _B : ::eventide::zest::TestAttrs { \ + constexpr _B() { __VA_ARGS__; } \ + }; \ + return ::eventide::zest::TestAttrs{_B{}}; \ + }() + #define TEST_CASE(name, ...) \ void _register_##name() { \ constexpr auto file_name = std::source_location::current().file_name(); \ diff --git a/tests/unit/async/process_tests.cpp b/tests/unit/async/process_tests.cpp index c2a14a40..a9229e88 100644 --- a/tests/unit/async/process_tests.cpp +++ b/tests/unit/async/process_tests.cpp @@ -50,11 +50,8 @@ task, result>> read_two_chunks(pipe p } // namespace -struct serial_loop_fixture : loop_fixture { - constexpr static zest::TestAttrs suite_attrs{.serial = true}; -}; - -TEST_SUITE(process_io, serial_loop_fixture) { +TEST_SUITE(process_io, loop_fixture) { + TEST_SUITE_ATTRS(serial = true); TEST_CASE(spawn_wait_simple) { process::options opts; From a2f70646bad250c40e93d43dfc0dada5ca517e7c Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 14:41:37 +0800 Subject: [PATCH 11/15] fix: address CodeRabbit review feedback - Add missing and includes - Use wall-clock time for parallel mode summary duration - Clamp thread pool size to number of parallel tests - Add error handling for uv_loop_init/uv_loop_close Co-Authored-By: Claude Opus 4.6 --- src/async/io/fs.cpp | 8 ++++++-- src/zest/runner.cpp | 25 ++++++++++++++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/async/io/fs.cpp b/src/async/io/fs.cpp index 0a48a85a..603f284b 100644 --- a/src/async/io/fs.cpp +++ b/src/async/io/fs.cpp @@ -627,11 +627,15 @@ uv_loop_t* sync_loop() noexcept { uv_loop_t loop{}; sync_loop_holder() { - uv_loop_init(&loop); + if(uv_loop_init(&loop) != 0) { + std::terminate(); + } } ~sync_loop_holder() { - uv_loop_close(&loop); + if(uv_loop_close(&loop) != 0) { + std::terminate(); + } } } holder; diff --git a/src/zest/runner.cpp b/src/zest/runner.cpp index ba0179bb..5f0f18b8 100644 --- a/src/zest/runner.cpp +++ b/src/zest/runner.cpp @@ -1,6 +1,8 @@ +#include #include #include #include +#include #include #include #include @@ -383,6 +385,9 @@ int Runner::run_tests(RunnerOptions options) { std::vector results(runnable.size()); if(options.parallel) { + using namespace std::chrono; + auto wall_begin = system_clock::now(); + // Partition: parallel-safe tests first, serial tests after. std::vector parallel_indices; std::vector serial_indices; @@ -395,10 +400,12 @@ int Runner::run_tests(RunnerOptions options) { } // Run parallel-safe tests across the thread pool. - const auto num_workers = - std::max(1u, - options.parallel_workers ? options.parallel_workers - : std::thread::hardware_concurrency()); + const auto num_workers = std::min( + static_cast( + std::max(1u, + options.parallel_workers ? options.parallel_workers + : std::thread::hardware_concurrency())), + parallel_indices.size()); std::atomic next_task{0}; @@ -429,9 +436,17 @@ int Runner::run_tests(RunnerOptions options) { results[i] = run_single(runnable[i], false); } + summary.duration = duration_cast(system_clock::now() - wall_begin); + // Print all results in original order. for(const auto& result: results) { - record_result(result); + const bool failed = is_failure(result.state); + print_run_result(result.display_name, failed, result.duration, options.only_failed_output); + if(failed) { + summary.failed += 1; + summary.failed_tests.push_back( + FailedTest{result.display_name, result.path, result.line}); + } } } else { for(std::size_t i = 0; i < runnable.size(); ++i) { From be0f922182388bea423ca9253a133b912d4fcac8 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 14:55:24 +0800 Subject: [PATCH 12/15] fix: GCC constexpr compat and format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use designated initializers instead of local struct in constexpr lambda for TEST_CASE/TEST_SUITE_ATTRS — GCC rejects constructing a local struct within the same constexpr lambda where it's defined. Syntax is now: TEST_CASE(name, .serial = true) Co-Authored-By: Claude Opus 4.6 --- include/eventide/zest/detail/suite.h | 1 - include/eventide/zest/macro.h | 19 +++++-------------- src/zest/runner.cpp | 17 ++++++++++------- tests/unit/async/cancellation_tests.cpp | 4 ++-- tests/unit/async/process_tests.cpp | 3 ++- tests/unit/async/stream_tests.cpp | 4 ++-- 6 files changed, 21 insertions(+), 27 deletions(-) diff --git a/include/eventide/zest/detail/suite.h b/include/eventide/zest/detail/suite.h index 716a2753..897879e4 100644 --- a/include/eventide/zest/detail/suite.h +++ b/include/eventide/zest/detail/suite.h @@ -18,7 +18,6 @@ constexpr TestAttrs merge_attrs(TestAttrs suite, TestAttrs test_case) { template struct TestSuiteDef { using Self = Derived; - using TestAttrs = ::eventide::zest::TestAttrs; constexpr inline static auto& test_cases() { static std::vector instance; diff --git a/include/eventide/zest/macro.h b/include/eventide/zest/macro.h index 77497cf4..d485afab 100644 --- a/include/eventide/zest/macro.h +++ b/include/eventide/zest/macro.h @@ -8,29 +8,20 @@ struct name##TEST : __VA_OPT__(__VA_ARGS__, )::eventide::zest::TestSuiteDef<#name, name##TEST> #define TEST_SUITE_ATTRS(...) \ - constexpr static ::eventide::zest::TestAttrs suite_attrs = [] constexpr { \ - struct _B : ::eventide::zest::TestAttrs { \ - constexpr _B() { __VA_ARGS__; } \ - }; \ - return ::eventide::zest::TestAttrs{_B{}}; \ - }() + constexpr static ::eventide::zest::TestAttrs suite_attrs { \ + __VA_ARGS__ \ + } #define TEST_CASE(name, ...) \ void _register_##name() { \ constexpr auto file_name = std::source_location::current().file_name(); \ constexpr auto file_len = std::string_view(file_name).size(); \ (void)_register_suites<>; \ - constexpr auto _zest_attrs_ = [] constexpr { \ - struct _B : ::eventide::zest::TestAttrs { \ - constexpr _B() { __VA_OPT__(__VA_ARGS__;) } \ - }; \ - return ::eventide::zest::TestAttrs{_B{}}; \ - }(); \ (void)_register_test_case<#name, \ &Self::test_##name, \ ::eventide::fixed_string(file_name), \ - std::source_location::current().line(), \ - _zest_attrs_>; \ + std::source_location::current().line() \ + __VA_OPT__(, ::eventide::zest::TestAttrs{__VA_ARGS__})>; \ } \ void test_##name() diff --git a/src/zest/runner.cpp b/src/zest/runner.cpp index 5f0f18b8..f7c4bdc4 100644 --- a/src/zest/runner.cpp +++ b/src/zest/runner.cpp @@ -400,12 +400,12 @@ int Runner::run_tests(RunnerOptions options) { } // Run parallel-safe tests across the thread pool. - const auto num_workers = std::min( - static_cast( - std::max(1u, - options.parallel_workers ? options.parallel_workers - : std::thread::hardware_concurrency())), - parallel_indices.size()); + const auto num_workers = + std::min(static_cast(std::max(1u, + options.parallel_workers + ? options.parallel_workers + : std::thread::hardware_concurrency())), + parallel_indices.size()); std::atomic next_task{0}; @@ -441,7 +441,10 @@ int Runner::run_tests(RunnerOptions options) { // Print all results in original order. for(const auto& result: results) { const bool failed = is_failure(result.state); - print_run_result(result.display_name, failed, result.duration, options.only_failed_output); + print_run_result(result.display_name, + failed, + result.duration, + options.only_failed_output); if(failed) { summary.failed += 1; summary.failed_tests.push_back( diff --git a/tests/unit/async/cancellation_tests.cpp b/tests/unit/async/cancellation_tests.cpp index e9b0a391..8ef22aba 100644 --- a/tests/unit/async/cancellation_tests.cpp +++ b/tests/unit/async/cancellation_tests.cpp @@ -121,7 +121,7 @@ TEST_CASE(token_share_state) { EXPECT_TRUE(token_b.cancelled()); } -TEST_CASE(queue_cancel_resume, serial = true) { +TEST_CASE(queue_cancel_resume, .serial = true) { cancellation_source source; event start_target; event target_submitted; @@ -207,7 +207,7 @@ TEST_CASE(queue_cancel_resume, serial = true) { EXPECT_FALSE(target_started.load(std::memory_order_acquire)); } -TEST_CASE(fs_cancel_resume, serial = true) { +TEST_CASE(fs_cancel_resume, .serial = true) { cancellation_source source; event start_target; event target_submitted; diff --git a/tests/unit/async/process_tests.cpp b/tests/unit/async/process_tests.cpp index a9229e88..097e11a5 100644 --- a/tests/unit/async/process_tests.cpp +++ b/tests/unit/async/process_tests.cpp @@ -51,7 +51,8 @@ task, result>> read_two_chunks(pipe p } // namespace TEST_SUITE(process_io, loop_fixture) { - TEST_SUITE_ATTRS(serial = true); + +TEST_SUITE_ATTRS(.serial = true); TEST_CASE(spawn_wait_simple) { process::options opts; diff --git a/tests/unit/async/stream_tests.cpp b/tests/unit/async/stream_tests.cpp index 8409a37e..01a0bd5a 100644 --- a/tests/unit/async/stream_tests.cpp +++ b/tests/unit/async/stream_tests.cpp @@ -412,7 +412,7 @@ TEST_CASE(connect_and_accept) { } } -TEST_CASE(connect_failure, serial = true) { +TEST_CASE(connect_failure, .serial = true) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else @@ -431,7 +431,7 @@ TEST_CASE(connect_failure, serial = true) { EXPECT_FALSE(client_res.has_value()); } -TEST_CASE(stop, serial = true) { +TEST_CASE(stop, .serial = true) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else From 673a45d3fcfa26cecfbfcba10712dad91e86ba94 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 15:00:17 +0800 Subject: [PATCH 13/15] fix: use local reference variables for TEST_CASE/TEST_SUITE_ATTRS Replace constexpr local struct (GCC-incompatible) and designated initializers with a ZEST_MAKE_ATTRS helper that declares local references to TestAttrs fields, enabling plain assignment syntax: TEST_CASE(name, serial = true) TEST_SUITE_ATTRS(serial = true) Co-Authored-By: Claude Opus 4.6 --- include/eventide/zest/macro.h | 21 ++++++++++++++++----- tests/unit/async/cancellation_tests.cpp | 4 ++-- tests/unit/async/process_tests.cpp | 2 +- tests/unit/async/stream_tests.cpp | 4 ++-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/include/eventide/zest/macro.h b/include/eventide/zest/macro.h index d485afab..55bdfa2c 100644 --- a/include/eventide/zest/macro.h +++ b/include/eventide/zest/macro.h @@ -7,21 +7,32 @@ #define TEST_SUITE(name, ...) \ struct name##TEST : __VA_OPT__(__VA_ARGS__, )::eventide::zest::TestSuiteDef<#name, name##TEST> +// clang-format off +#define ZEST_MAKE_ATTRS(...) \ + [] constexpr { \ + ::eventide::zest::TestAttrs _a{}; \ + [[maybe_unused]] auto& skip = _a.skip; \ + [[maybe_unused]] auto& focus = _a.focus; \ + [[maybe_unused]] auto& serial = _a.serial; \ + __VA_ARGS__; \ + return _a; \ + }() +// clang-format on + #define TEST_SUITE_ATTRS(...) \ - constexpr static ::eventide::zest::TestAttrs suite_attrs { \ - __VA_ARGS__ \ - } + constexpr static ::eventide::zest::TestAttrs suite_attrs = ZEST_MAKE_ATTRS(__VA_ARGS__) #define TEST_CASE(name, ...) \ void _register_##name() { \ constexpr auto file_name = std::source_location::current().file_name(); \ constexpr auto file_len = std::string_view(file_name).size(); \ (void)_register_suites<>; \ + constexpr auto _zest_attrs_ = ZEST_MAKE_ATTRS(__VA_OPT__(__VA_ARGS__)); \ (void)_register_test_case<#name, \ &Self::test_##name, \ ::eventide::fixed_string(file_name), \ - std::source_location::current().line() \ - __VA_OPT__(, ::eventide::zest::TestAttrs{__VA_ARGS__})>; \ + std::source_location::current().line(), \ + _zest_attrs_>; \ } \ void test_##name() diff --git a/tests/unit/async/cancellation_tests.cpp b/tests/unit/async/cancellation_tests.cpp index 8ef22aba..e9b0a391 100644 --- a/tests/unit/async/cancellation_tests.cpp +++ b/tests/unit/async/cancellation_tests.cpp @@ -121,7 +121,7 @@ TEST_CASE(token_share_state) { EXPECT_TRUE(token_b.cancelled()); } -TEST_CASE(queue_cancel_resume, .serial = true) { +TEST_CASE(queue_cancel_resume, serial = true) { cancellation_source source; event start_target; event target_submitted; @@ -207,7 +207,7 @@ TEST_CASE(queue_cancel_resume, .serial = true) { EXPECT_FALSE(target_started.load(std::memory_order_acquire)); } -TEST_CASE(fs_cancel_resume, .serial = true) { +TEST_CASE(fs_cancel_resume, serial = true) { cancellation_source source; event start_target; event target_submitted; diff --git a/tests/unit/async/process_tests.cpp b/tests/unit/async/process_tests.cpp index 097e11a5..79ab320d 100644 --- a/tests/unit/async/process_tests.cpp +++ b/tests/unit/async/process_tests.cpp @@ -52,7 +52,7 @@ task, result>> read_two_chunks(pipe p TEST_SUITE(process_io, loop_fixture) { -TEST_SUITE_ATTRS(.serial = true); +TEST_SUITE_ATTRS(serial = true); TEST_CASE(spawn_wait_simple) { process::options opts; diff --git a/tests/unit/async/stream_tests.cpp b/tests/unit/async/stream_tests.cpp index 01a0bd5a..8409a37e 100644 --- a/tests/unit/async/stream_tests.cpp +++ b/tests/unit/async/stream_tests.cpp @@ -412,7 +412,7 @@ TEST_CASE(connect_and_accept) { } } -TEST_CASE(connect_failure, .serial = true) { +TEST_CASE(connect_failure, serial = true) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else @@ -431,7 +431,7 @@ TEST_CASE(connect_failure, .serial = true) { EXPECT_FALSE(client_res.has_value()); } -TEST_CASE(stop, .serial = true) { +TEST_CASE(stop, serial = true) { #ifdef _WIN32 const std::string name = "\\\\.\\pipe\\eventide-test-pipe-missing"; #else From e88ec43442191644f2396d8c3fd9fb4ff07e2642 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 15:07:00 +0800 Subject: [PATCH 14/15] refactor(zest): use structured bindings in ZEST_MAKE_ATTRS Replace individual field references with `auto& [skip, focus, serial] = _a;` to eliminate duplication between TestAttrs fields and macro variables. Co-Authored-By: Claude Opus 4.6 --- include/eventide/zest/macro.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/include/eventide/zest/macro.h b/include/eventide/zest/macro.h index 55bdfa2c..b83e866d 100644 --- a/include/eventide/zest/macro.h +++ b/include/eventide/zest/macro.h @@ -11,9 +11,7 @@ #define ZEST_MAKE_ATTRS(...) \ [] constexpr { \ ::eventide::zest::TestAttrs _a{}; \ - [[maybe_unused]] auto& skip = _a.skip; \ - [[maybe_unused]] auto& focus = _a.focus; \ - [[maybe_unused]] auto& serial = _a.serial; \ + auto& [skip, focus, serial] = _a; \ __VA_ARGS__; \ return _a; \ }() From 1a1d21e3ac4a91bf9367830d436a48b38f8e95d4 Mon Sep 17 00:00:00 2001 From: ykiko Date: Mon, 30 Mar 2026 15:24:45 +0800 Subject: [PATCH 15/15] style: format macro.h Co-Authored-By: Claude Opus 4.6 --- include/eventide/zest/macro.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/eventide/zest/macro.h b/include/eventide/zest/macro.h index b83e866d..f0421e71 100644 --- a/include/eventide/zest/macro.h +++ b/include/eventide/zest/macro.h @@ -29,7 +29,7 @@ (void)_register_test_case<#name, \ &Self::test_##name, \ ::eventide::fixed_string(file_name), \ - std::source_location::current().line(), \ + std::source_location::current().line(), \ _zest_attrs_>; \ } \ void test_##name()