Skip to content

Commit 6455716

Browse files
committed
feat(test): --timeout <secs> per-test run deadline (POSIX; Windows best-effort untimed)
1 parent 631b400 commit 6455716

5 files changed

Lines changed: 204 additions & 4 deletions

File tree

‎src/build/execute.cppm‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,7 @@ export struct TestOptions {
678678
std::string filter; // substring match on the path-based test name; empty = all
679679
TestMessageFormat format = TestMessageFormat::Human;
680680
bool list = false; // enumerate only, no build/run
681+
int timeoutSecs = 0; // per-test run deadline; 0 = unlimited
681682
};
682683

683684
// Minimal JSON string escaping for the --message-format json records. Same
@@ -881,6 +882,7 @@ export int run_tests(std::span<const std::string> passthrough,
881882
std::string compileOutput;
882883
std::string runOutput;
883884
long long durationMs = 0; // build+run wall time for THIS test
885+
bool timedOut = false; // killed by --timeout
884886
};
885887
std::vector<TestResult> results;
886888

@@ -895,9 +897,10 @@ export int run_tests(std::span<const std::string> passthrough,
895897
std::string signal = (r.exitCode > 128 && r.exitCode < 128 + 65)
896898
? std::to_string(r.exitCode - 128) : "null";
897899
std::println("{{\"test\":\"{}\",\"status\":\"{}\",\"exit_code\":{},\"signal\":{},"
898-
"\"duration_ms\":{},"
900+
"\"duration_ms\":{},\"timed_out\":{},"
899901
"\"compile_output\":\"{}\",\"run_output\":\"{}\"}}",
900902
test_json_escape(r.name), st, r.exitCode, signal, r.durationMs,
903+
r.timedOut ? "true" : "false",
901904
test_json_escape(r.compileOutput), test_json_escape(r.runOutput));
902905
std::fflush(stdout);
903906
};
@@ -1043,17 +1046,27 @@ export int run_tests(std::span<const std::string> passthrough,
10431046

10441047
// JSON mode captures the test's combined stdout+stderr into the
10451048
// record; human mode streams it to the terminal as before.
1049+
auto deadline = std::chrono::milliseconds(
1050+
static_cast<long long>(testOpts.timeoutSecs) * 1000);
1051+
bool timedOut = false;
10461052
int exitCode;
10471053
std::string runOutput;
10481054
if (json) {
1049-
auto rr = mcpp::platform::process::capture_exec(argv, childEnv);
1055+
auto rr = mcpp::platform::process::capture_exec_deadline(
1056+
argv, childEnv, deadline, &timedOut);
10501057
exitCode = rr.exit_code;
10511058
runOutput = std::move(rr.output);
10521059
} else {
1053-
exitCode = mcpp::platform::process::run_exec(argv, childEnv);
1060+
exitCode = mcpp::platform::process::run_exec_deadline(
1061+
argv, childEnv, deadline, &timedOut);
10541062
}
10551063

1056-
if (exitCode == 0) {
1064+
if (timedOut) {
1065+
if (!json) std::println("{} ... FAIL (timeout after {}s)",
1066+
lu.targetName, testOpts.timeoutSecs);
1067+
results.push_back({lu.targetName, TestResult::St::RunFail, exitCode, {},
1068+
std::move(runOutput), test_ms(), true});
1069+
} else if (exitCode == 0) {
10571070
if (!json) std::println("{} ... ok", lu.targetName);
10581071
results.push_back({lu.targetName, TestResult::St::Pass, 0, {},
10591072
std::move(runOutput), test_ms()});

‎src/cli.cppm‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ int run(int argc, char** argv) {
257257
.help("Output format: human (default) | json (NDJSON, one record per test)"))
258258
.option(cl::Option("list")
259259
.help("List (filtered) tests without building or running them"))
260+
.option(cl::Option("timeout").takes_value().value_name("SECS")
261+
.help("Kill a test still running after SECS seconds (reported as a timeout failure)"))
260262
.option(cl::Option("profile").takes_value().value_name("NAME")
261263
.help("Build profile for the test build: release (default) | dev | dist | <[profile.*] name>"))
262264
.option(cl::Option("features").takes_value().value_name("LIST")

‎src/cli/cmd_build.cppm‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,15 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
128128
mcpp::build::TestOptions to;
129129
if (parsed.positional_count() > 0) to.filter = parsed.positional(0);
130130
to.list = parsed.is_flag_set("list");
131+
if (auto ts = parsed.value("timeout")) {
132+
int secs = 0;
133+
auto [p, ec] = std::from_chars(ts->data(), ts->data() + ts->size(), secs);
134+
if (ec != std::errc{} || p != ts->data() + ts->size() || secs < 0) {
135+
mcpp::ui::error(std::format("invalid --timeout '{}' (whole seconds >= 0)", *ts));
136+
return 2;
137+
}
138+
to.timeoutSecs = secs;
139+
}
131140
if (auto mf = parsed.value("message-format")) {
132141
if (*mf == "json") to.format = mcpp::build::TestMessageFormat::Json;
133142
else if (*mf != "human") {

‎src/platform/process.cppm‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ module;
3434
#include <unistd.h> // pipe, dup2, close, read
3535
#include <sys/wait.h> // waitpid
3636
#include <spawn.h> // posix_spawnp, posix_spawn_file_actions_* (incl. addchdir_np)
37+
#include <signal.h> // kill, SIGKILL (deadline runners)
38+
#include <cerrno> // errno, EINTR (deadline wait loop)
39+
#include <poll.h> // poll (deadline capture)
40+
#include <fcntl.h> // fcntl O_NONBLOCK (deadline capture)
41+
#include <time.h> // nanosleep (deadline wait loop)
3742
#if defined(__APPLE__)
3843
#include <crt_externs.h> // _NSGetEnviron — direct `environ` is only linkable
3944
// from executables on Apple, not from dylibs
@@ -88,6 +93,21 @@ RunResult capture_exec(
8893
const std::vector<std::pair<std::string, std::string>>& extraEnv = {},
8994
std::string_view cwd = {});
9095

96+
// Deadline variants (POSIX): kill the child with SIGKILL once `deadline`
97+
// elapses and set *timed_out. A zero deadline means no limit. On Windows the
98+
// deadline is currently ignored (no supported kill-by-handle path in the
99+
// residual shell launcher) — callers must treat the timeout as best-effort.
100+
int run_exec_deadline(const std::vector<std::string>& argv,
101+
const std::vector<std::pair<std::string, std::string>>& extraEnv,
102+
std::chrono::milliseconds deadline,
103+
bool* timed_out);
104+
105+
RunResult capture_exec_deadline(
106+
const std::vector<std::string>& argv,
107+
const std::vector<std::pair<std::string, std::string>>& extraEnv,
108+
std::chrono::milliseconds deadline,
109+
bool* timed_out);
110+
91111
// Run `command` silently (discard stdout/stderr).
92112
// On POSIX, stdin is automatically redirected from /dev/null.
93113
int run_silent(std::string_view command);
@@ -451,4 +471,117 @@ RunResult capture_exec(
451471
#endif
452472
}
453473

474+
int run_exec_deadline(const std::vector<std::string>& argv,
475+
const std::vector<std::pair<std::string, std::string>>& extraEnv,
476+
std::chrono::milliseconds deadline,
477+
bool* timed_out)
478+
{
479+
if (timed_out) *timed_out = false;
480+
if (deadline.count() <= 0) return run_exec(argv, extraEnv);
481+
if (argv.empty()) return 127;
482+
#if defined(__linux__) || defined(__APPLE__)
483+
auto envStore = merged_environ(extraEnv);
484+
std::vector<char*> envp;
485+
for (auto& s : envStore) envp.push_back(s.data());
486+
envp.push_back(nullptr);
487+
std::vector<char*> cargv;
488+
for (auto& a : argv) cargv.push_back(const_cast<char*>(a.c_str()));
489+
cargv.push_back(nullptr);
490+
491+
pid_t pid = 0;
492+
if (::posix_spawnp(&pid, cargv[0], nullptr, nullptr, cargv.data(), envp.data()) != 0)
493+
return 127;
494+
495+
auto until = std::chrono::steady_clock::now() + deadline;
496+
int status = 0;
497+
for (;;) {
498+
pid_t r = ::waitpid(pid, &status, WNOHANG);
499+
if (r == pid) return normalize_exit_code(status);
500+
if (r < 0 && errno != EINTR) return 127;
501+
if (std::chrono::steady_clock::now() >= until) {
502+
::kill(pid, SIGKILL);
503+
while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ }
504+
if (timed_out) *timed_out = true;
505+
return normalize_exit_code(status);
506+
}
507+
struct timespec ts{0, 20'000'000}; // 20ms
508+
::nanosleep(&ts, nullptr);
509+
}
510+
#else
511+
// Windows: the residual shell launcher has no kill-by-handle path yet —
512+
// run untimed (documented best-effort semantics).
513+
return run_exec(argv, extraEnv);
514+
#endif
515+
}
516+
517+
RunResult capture_exec_deadline(
518+
const std::vector<std::string>& argv,
519+
const std::vector<std::pair<std::string, std::string>>& extraEnv,
520+
std::chrono::milliseconds deadline,
521+
bool* timed_out)
522+
{
523+
if (timed_out) *timed_out = false;
524+
if (deadline.count() <= 0) return capture_exec(argv, extraEnv);
525+
RunResult result;
526+
if (argv.empty()) { result.exit_code = 127; return result; }
527+
#if defined(__linux__) || defined(__APPLE__)
528+
int fds[2];
529+
if (::pipe(fds) != 0) { result.exit_code = 127; return result; }
530+
531+
auto envStore = merged_environ(extraEnv);
532+
std::vector<char*> envp;
533+
for (auto& s : envStore) envp.push_back(s.data());
534+
envp.push_back(nullptr);
535+
std::vector<char*> cargv;
536+
for (auto& a : argv) cargv.push_back(const_cast<char*>(a.c_str()));
537+
cargv.push_back(nullptr);
538+
539+
posix_spawn_file_actions_t fa;
540+
::posix_spawn_file_actions_init(&fa);
541+
::posix_spawn_file_actions_adddup2(&fa, fds[1], 1);
542+
::posix_spawn_file_actions_adddup2(&fa, fds[1], 2);
543+
::posix_spawn_file_actions_addclose(&fa, fds[0]);
544+
::posix_spawn_file_actions_addclose(&fa, fds[1]);
545+
546+
pid_t pid = 0;
547+
int sp = ::posix_spawnp(&pid, cargv[0], &fa, nullptr, cargv.data(), envp.data());
548+
::posix_spawn_file_actions_destroy(&fa);
549+
::close(fds[1]);
550+
if (sp != 0) { ::close(fds[0]); result.exit_code = 127; return result; }
551+
552+
::fcntl(fds[0], F_SETFL, ::fcntl(fds[0], F_GETFL) | O_NONBLOCK);
553+
554+
auto until = std::chrono::steady_clock::now() + deadline;
555+
bool killed = false;
556+
std::array<char, 4096> buf{};
557+
for (;;) {
558+
struct pollfd pfd{fds[0], POLLIN, 0};
559+
::poll(&pfd, 1, 50);
560+
for (;;) {
561+
ssize_t n = ::read(fds[0], buf.data(), buf.size());
562+
if (n > 0) { result.output.append(buf.data(), static_cast<size_t>(n)); continue; }
563+
break;
564+
}
565+
int status = 0;
566+
pid_t r = ::waitpid(pid, &status, WNOHANG);
567+
if (r == pid) {
568+
// Drain whatever is left in the pipe after exit.
569+
ssize_t n;
570+
while ((n = ::read(fds[0], buf.data(), buf.size())) > 0)
571+
result.output.append(buf.data(), static_cast<size_t>(n));
572+
::close(fds[0]);
573+
result.exit_code = normalize_exit_code(status);
574+
if (timed_out) *timed_out = killed;
575+
return result;
576+
}
577+
if (!killed && std::chrono::steady_clock::now() >= until) {
578+
::kill(pid, SIGKILL);
579+
killed = true;
580+
}
581+
}
582+
#else
583+
return capture_exec(argv, extraEnv);
584+
#endif
585+
}
586+
454587
} // namespace mcpp::platform::process

‎tests/e2e/126_test_timeout.sh‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env bash
2+
# requires:
3+
# `mcpp test --timeout <secs>`: a hung test is killed and reported as a
4+
# timeout failure; the rest of the suite still runs.
5+
set -e
6+
7+
TMP=$(mktemp -d)
8+
trap "rm -rf $TMP" EXIT
9+
10+
cd "$TMP"
11+
mkdir -p pkg/tests
12+
cd pkg
13+
cat > mcpp.toml <<'EOF'
14+
[package]
15+
name = "hang"
16+
version = "0.1.0"
17+
standard = "c++23"
18+
EOF
19+
cat > tests/sleepy.cpp <<'EOF'
20+
import std;
21+
int main() { std::this_thread::sleep_for(std::chrono::seconds(30)); return 0; }
22+
EOF
23+
cat > tests/quick.cpp <<'EOF'
24+
int main() { return 0; }
25+
EOF
26+
27+
start=$(date +%s)
28+
set +e
29+
out=$("$MCPP" test --timeout 2 2>&1)
30+
code=$?
31+
set -e
32+
elapsed=$(( $(date +%s) - start ))
33+
[[ $code -eq 1 ]] || { echo "expected exit 1, got $code: $out"; exit 1; }
34+
[[ $elapsed -lt 25 ]] || { echo "timeout did not take effect (took ${elapsed}s)"; exit 1; }
35+
[[ "$out" == *"sleepy ... FAIL (timeout"* ]] || { echo "missing timeout marker: $out"; exit 1; }
36+
[[ "$out" == *"quick ... ok"* ]] || { echo "quick test did not run: $out"; exit 1; }
37+
38+
set +e
39+
outj=$("$MCPP" test --timeout 2 --message-format json 2>/dev/null)
40+
set -e
41+
echo "$outj" | grep -q '"test":"sleepy","status":"run_fail"' || { echo "json: no run_fail"; exit 1; }
42+
echo "$outj" | grep -q '"timed_out":true' || { echo "json: timed_out missing: $outj"; exit 1; }
43+
echo OK

0 commit comments

Comments
 (0)