Merge topic 'instrumentation-interrupt-ctest'

d5d2741414 Instrumentation: Write ctest snippet on interrupted test run

Acked-by: Kitware Robot <kwrobot@kitware.com>
Tested-by: buildbot <buildbot@kitware.com>
Merge-request: !12272
This commit is contained in:
Brad King
2026-07-17 14:21:32 +00:00
committed by Kitware Robot
15 changed files with 313 additions and 17 deletions

View File

@@ -456,9 +456,9 @@ Snippet files have a filename with the syntax
The integer signal number that interrupted the command before it completed
(for example ``2`` for ``SIGINT`` from Ctrl+C). Only included when ``role``
is ``cmakeBuild`` or ``cmakeInstall`` and the command was interrupted.
Consumers can use the presence of this field to distinguish an interrupted
command from one that ran to completion.
is ``cmakeBuild``, ``cmakeInstall``, or ``ctest`` and the command was
interrupted. Consumers can use the presence of this field to distinguish an
interrupted command from one that ran to completion.
Only available as of data version ``1.2``.

View File

@@ -237,7 +237,7 @@
},
"interruptSignal": {
"type": "integer",
"description": "The signal number that interrupted the command before it completed. Only included when role is cmakeBuild or cmakeInstall and the command was interrupted.",
"description": "The signal number that interrupted the command before it completed. Only included when role is cmakeBuild, cmakeInstall, or ctest and the command was interrupted.",
"minimum": 1
},
"role": {

View File

@@ -11,3 +11,8 @@ instrumentation-interrupt
* Similarly, :manual:`cmake-instrumentation(7)` API now records an overall
``cmakeInstall`` snippet, marked with the same ``interruptSignal`` field,
when a :option:`cmake --install` invocation is interrupted by the user.
* Similarly, :manual:`cmake-instrumentation(7)` API now records an overall
``ctest`` snippet, marked with the same ``interruptSignal`` field, when a
:manual:`ctest(1)` invocation is interrupted by the user. On interrupt CTest
stops scheduling further tests and preserves its checkpoint file so that a
later :option:`ctest -F` can resume the interrupted test set.

View File

@@ -35,6 +35,7 @@
#include "cmCTestBinPacker.h"
#include "cmCTestRunTest.h"
#include "cmCTestTestHandler.h"
#include "cmInstrumentationInterrupt.h"
#include "cmJSONState.h"
#include "cmListFileCache.h"
#include "cmRange.h"
@@ -246,13 +247,21 @@ void cmCTestMultiProcessHandler::RunTests()
uv_run(this->Loop, UV_RUN_DEFAULT);
this->FinalizeLoop();
if (!this->StopTimePassed && !this->CheckStopOnFailure()) {
// A user interrupt (e.g. Ctrl+C) deliberately stops scheduling with tests
// still pending, so skip the completion asserts in that case. Canceled
// launches release their resources, so AllResourcesAvailable() still holds.
if (!this->StopTimePassed && !this->CheckStopOnFailure() &&
cmInstrumentationInterrupt::PendingInterruptSignal() == 0) {
assert(this->Complete());
assert(this->PendingTests.empty());
}
assert(this->AllResourcesAvailable());
this->MarkFinished();
// On interrupt, leave the checkpoint file intact so a later `ctest -F` can
// resume from where this run left off; MarkFinished() would delete it.
if (cmInstrumentationInterrupt::PendingInterruptSignal() == 0) {
this->MarkFinished();
}
this->UpdateCostData();
}
@@ -583,6 +592,17 @@ void cmCTestMultiProcessHandler::JobServerReceivedToken()
assert(!this->JobServerQueuedTests.empty());
int test = this->JobServerQueuedTests.front();
this->JobServerQueuedTests.pop_front();
if (cmInstrumentationInterrupt::PendingInterruptSignal() != 0) {
// Interrupted (e.g. Ctrl+C): do not launch this queued test. Its
// resources were locked when it was queued in StartNextTests, and this
// callback runs holding a job server token, so release both to keep the
// scheduler's bookkeeping balanced (see the AllResourcesAvailable() and
// token invariants checked after the loop).
this->DeallocateResources(test);
this->UnlockResources(test);
this->JobServerClient->ReleaseToken();
return;
}
this->StartTestProcess(test);
}
@@ -594,7 +614,11 @@ void cmCTestMultiProcessHandler::StartNextTests()
this->StartNextTestsOnTimer_.stop();
if (this->PendingTests.empty() || this->CheckStopTimePassed() ||
(this->CheckStopOnFailure() && !this->Failed->empty())) {
(this->CheckStopOnFailure() && !this->Failed->empty()) ||
cmInstrumentationInterrupt::PendingInterruptSignal() != 0) {
// A user interrupt (e.g. Ctrl+C) stops scheduling: launch no further
// tests. Tests already running receive the interrupt too and unwind on
// their own.
return;
}
@@ -650,6 +674,7 @@ void cmCTestMultiProcessHandler::StartNextTests()
// Start tests in the preferred order, each subject to readiness checks.
auto ti = this->OrderedTests.begin();
while (numToStart > 0 && !this->SerialTestRunning &&
cmInstrumentationInterrupt::PendingInterruptSignal() == 0 &&
ti != this->OrderedTests.end()) {
// Increment the test iterator now because the current list
// entry may be deleted below.
@@ -791,6 +816,7 @@ void cmCTestMultiProcessHandler::FinishTestProcess(
}
if (started) {
if (!this->StopTimePassed &&
cmInstrumentationInterrupt::PendingInterruptSignal() == 0 &&
cmCTestRunTest::StartAgain(std::move(runner), this->Completed)) {
this->Completed--; // remove the completed test because run again
return;
@@ -807,7 +833,12 @@ void cmCTestMultiProcessHandler::FinishTestProcess(
t.second.Depends.erase(test);
}
this->WriteCheckpoint(test);
// A test killed by the interrupt (e.g. Ctrl+C) never truly finished, so do
// not record it in the checkpoint; otherwise `ctest -F` would skip it when
// resuming this interrupted run.
if (cmInstrumentationInterrupt::PendingInterruptSignal() == 0) {
this->WriteCheckpoint(test);
}
this->DeallocateResources(test);
this->UnlockResources(test);

View File

@@ -52,6 +52,7 @@
#include "cmGeneratedFileStream.h"
#include "cmGlobalGenerator.h"
#include "cmInstrumentation.h"
#include "cmInstrumentationInterrupt.h"
#include "cmInstrumentationQuery.h"
#include "cmJSONState.h"
#include "cmList.h"
@@ -2758,12 +2759,40 @@ int cmCTest::ExecuteTests(std::vector<std::string> const& args)
};
std::map<std::string, std::string> data;
data["showOnly"] = this->GetShowOnly() ? "1" : "0";
int ret = instrumentation.InstrumentCommand(
"ctest", args,
[processHandler]() -> cmInstrumentation::CommandResult {
return { processHandler(), cm::nullopt, cm::nullopt };
},
data);
// Run the tests under an interrupt handler so that a user interrupt (e.g.
// Ctrl+C) still writes the overall `ctest` snippet before we exit.
cmInstrumentationInterrupt::InterruptOutcome testsOutcome =
cmInstrumentationInterrupt::HandleInterrupt(
instrumentation.HasQuery(),
[&instrumentation, &args, &processHandler, &data]() -> int {
return instrumentation.InstrumentCommand(
"ctest", args,
[&processHandler]() -> cmInstrumentation::CommandResult {
return { processHandler(), cm::nullopt, cm::nullopt };
},
data);
});
int ret = testsOutcome.ExitCode;
if (testsOutcome.Interrupted) {
// The tests were interrupted and the `ctest` snippet has been written.
// Skip the post-ctest indexing hook and make the exit status reflect the
// interrupt the same way it does without instrumentation, so enabling
// instrumentation does not change the exit code of an interrupted run.
// For a real signal, re-raise it on POSIX (the shell then reports
// 128+signo); on Windows our console handler suppressed the OS default, so
// report the status Windows itself uses for a Ctrl+C-terminated process.
// A test-injected interrupt has no real signal, so it instead returns a
// normal error status to keep the seam case deterministic.
if (testsOutcome.ShouldRaise) {
cmInstrumentationInterrupt::RaiseInterrupt(testsOutcome.Signal);
#ifdef _WIN32
// STATUS_CONTROL_C_EXIT: the exit status Windows reports for a process
// terminated by Ctrl+C, which our console handler otherwise suppressed.
return static_cast<int>(0xC000013A);
#endif
}
return cmCTest::TEST_ERRORS;
}
instrumentation.CollectTimingData(cmInstrumentationQuery::Hook::PostCTest);
if (ret == cmCTest::TEST_ERRORS) {
cmCTestLog(this, ERROR_MESSAGE, "Errors while running CTest\n");

View File

@@ -840,13 +840,13 @@ int cmInstrumentation::InstrumentCommand(
}
this->configureSnippetData.clear();
}
// Write the cmakeBuild/cmakeInstall envelope atomically (temp file +
// Write the cmakeBuild/cmakeInstall/ctest envelope atomically (temp file +
// rename). This is the snippet flushed while unwinding from a user
// interrupt, where a second Ctrl+C could otherwise truncate it mid-write;
// the atomic write guarantees it is either absent or complete. Per-step
// snippets are never flushed under interrupt and are left non-atomic.
bool const atomicEnvelope =
command_type == "cmakeBuild" || command_type == "cmakeInstall";
bool const atomicEnvelope = command_type == "cmakeBuild" ||
command_type == "cmakeInstall" || command_type == "ctest";
this->WriteInstrumentationJson(latestDataVersion, root, "data", file_name,
atomicEnvelope ? Atomic::Yes : Atomic::No);
}

View File

@@ -16,6 +16,9 @@ function(instrument test)
"INSTALL_SEAM"
"INSTALL_INTERRUPT"
"TEST"
"CTEST_SEAM"
"CTEST_INTERRUPT"
"CTEST_FAILOVER"
"WORKFLOW"
"COPY_QUERIES"
"COPY_QUERIES_GENERATED"
@@ -135,6 +138,11 @@ function(instrument test)
"-DINTERRUPT_BUILD_SRC=${RunCMake_SOURCE_DIR}/InterruptBuild.c"
"-DINSTALL_INTERRUPT=ON")
endif()
if (ARGS_CTEST_INTERRUPT OR ARGS_CTEST_FAILOVER)
list(APPEND ARGS_CONFIGURE_ARGS
"-DINTERRUPT_BUILD_SRC=${RunCMake_SOURCE_DIR}/InterruptBuild.c"
"-DCTEST_INTERRUPT=ON")
endif()
set(RunCMake_TEST_SOURCE_DIR ${RunCMake_SOURCE_DIR}/project)
if(NOT RunCMake_GENERATOR_IS_MULTI_CONFIG)
set(maybe_CMAKE_BUILD_TYPE -DCMAKE_BUILD_TYPE=Debug)
@@ -313,6 +321,106 @@ function(instrument test)
if (ARGS_TEST)
run_cmake_command(${test}-test ${CMAKE_CTEST_COMMAND} . -C Debug)
endif()
if (ARGS_CTEST_SEAM)
# Drive the ctest interrupt path deterministically via the test-only
# injection seam, with no OS signal, so it runs on every generator. First
# run ctest normally so the postCTest hook runs and creates its marker file;
# remove it so its absence after the injected run proves that run's hook was
# skipped.
set(RunCMake_QUIET_ERROR 1)
run_cmake_command(${test}-warmup
${CMAKE_CTEST_COMMAND} . -C Debug)
file(REMOVE ${v1}/postCTest.hook)
# Inject an interrupt (SIGINT == 2) via the undocumented test seam and run
# ctest again; ctest exits with an error status (cmCTest::TEST_ERRORS == 8)
# but writes the interrupted ctest snippet and skips the hook.
set(ENV{__CMAKE_INSTRUMENTATION_TEST_INTERRUPT} 2)
set(RunCMake_TEST_EXPECT_RESULT 8)
run_cmake_command(${test}-seam
${CMAKE_CTEST_COMMAND} . -C Debug)
unset(RunCMake_TEST_EXPECT_RESULT)
unset(ENV{__CMAKE_INSTRUMENTATION_TEST_INTERRUPT})
unset(RunCMake_QUIET_ERROR)
endif()
if (ARGS_CTEST_INTERRUPT)
# Build just the interrupt helper so it exists for the interrupted run.
run_cmake_command(${test}-helper
${CMAKE_COMMAND} --build . --config Debug --target InterruptBuild)
file(REMOVE ${v1}/postCTest.hook)
file(REMOVE_RECURSE ${RunCMake_TEST_BINARY_DIR}/ran)
set(helper_dir ${RunCMake_TEST_BINARY_DIR})
if (RunCMake_GENERATOR_IS_MULTI_CONFIG)
set(helper_dir ${helper_dir}/Debug)
endif()
set(helper ${helper_dir}/InterruptBuild${CMAKE_EXECUTABLE_SUFFIX})
# Interrupt a serial `ctest -j 1` a few seconds in, while the fast test has
# finished and a slow test is in-flight with others pending. The
# instrumented ctest re-raises the signal, so the helper reports exit 42.
# Restrict to the ctest* fixture tests (the project's `test` needs `main`).
set(RunCMake_TEST_OUTPUT_MERGE 1)
set(RunCMake_QUIET_ERROR 1)
run_cmake_command(${test}-signal
${helper} 4
${CMAKE_CTEST_COMMAND} . -C Debug -j 1 -R ctest)
unset(RunCMake_QUIET_ERROR)
unset(RunCMake_TEST_OUTPUT_MERGE)
# Record which tests ran during the interrupted run so the check script can
# assert the pending tests were canceled.
if (EXISTS ${RunCMake_TEST_BINARY_DIR}/ran)
file(RENAME ${RunCMake_TEST_BINARY_DIR}/ran
${RunCMake_TEST_BINARY_DIR}/ran-interrupt)
endif()
endif()
if (ARGS_CTEST_FAILOVER)
# Build just the interrupt helper so it exists for the interrupted run.
run_cmake_command(${test}-helper
${CMAKE_COMMAND} --build . --config Debug --target InterruptBuild)
file(REMOVE ${v1}/postCTest.hook)
file(REMOVE_RECURSE ${RunCMake_TEST_BINARY_DIR}/ran)
set(helper_dir ${RunCMake_TEST_BINARY_DIR})
if (RunCMake_GENERATOR_IS_MULTI_CONFIG)
set(helper_dir ${helper_dir}/Debug)
endif()
set(helper ${helper_dir}/InterruptBuild${CMAKE_EXECUTABLE_SUFFIX})
# Phase 1: interrupt a serial `ctest -j 1` while a slow test is in-flight.
# The fast test finishes (checkpointed); the in-flight slow test is killed
# (deliberately not checkpointed) and the rest stay pending. Restrict to
# the ctest* fixture tests (the project's `test` needs `main`).
set(RunCMake_TEST_OUTPUT_MERGE 1)
set(RunCMake_QUIET_ERROR 1)
run_cmake_command(${test}-signal
${helper} 4
${CMAKE_CTEST_COMMAND} . -C Debug -j 1 -R ctest)
if (EXISTS ${RunCMake_TEST_BINARY_DIR}/ran)
file(RENAME ${RunCMake_TEST_BINARY_DIR}/ran
${RunCMake_TEST_BINARY_DIR}/ran-interrupt)
endif()
# The interrupted run left an un-indexed ctest snippet marked with
# interruptSignal. The resuming run below re-indexes instrumentation, and
# the generic snippet validator rejects that field, so clear the snippet
# data first. The `ctest -F` checkpoint lives under Testing/Temporary and
# is untouched.
file(REMOVE_RECURSE ${v1}/data)
# Phase 2: resume the interrupted test set with `ctest -F`. It must skip
# the already-finished fast test and run the interrupted and pending slow
# tests (run in parallel so resume finishes promptly).
run_cmake_command(${test}-resume
${CMAKE_CTEST_COMMAND} . -C Debug -F -j 4 -R ctest)
unset(RunCMake_QUIET_ERROR)
unset(RunCMake_TEST_OUTPUT_MERGE)
if (EXISTS ${RunCMake_TEST_BINARY_DIR}/ran)
file(RENAME ${RunCMake_TEST_BINARY_DIR}/ran
${RunCMake_TEST_BINARY_DIR}/ran-resume)
endif()
endif()
if (ARGS_MANUAL_HOOK)
run_cmake_command(${test}-index ${CMAKE_CTEST_COMMAND} --collect-instrumentation .)
endif()
@@ -345,6 +453,18 @@ if (INSTRUMENTATION_INTERRUPT_REAL)
instrument(interrupt-install INSTALL_INTERRUPT
CHECK_SCRIPT check-installation-interrupted.cmake
)
# Interrupt a `ctest` run with a real OS signal, proving the scheduler stops
# launching pending tests, skips the hook, and preserves the `ctest -F`
# checkpoint so the interrupted test set can be resumed.
instrument(interrupt-test CTEST_INTERRUPT
CHECK_SCRIPT check-test-interrupted.cmake
)
# Interrupt a `ctest` run and then resume it with `ctest -F`, proving the
# checkpoint keeps the finished test (skipped on resume) but not the
# in-flight test killed by the interrupt (re-run on resume).
instrument(interrupt-test-failover CTEST_FAILOVER
CHECK_SCRIPT check-test-failover.cmake
)
endif()
return()
endif()
@@ -567,6 +687,9 @@ instrument(interrupt-build INTERRUPT_SEAM
instrument(interrupt-install BUILD INSTALL_SEAM
CHECK_SCRIPT check-installation-interrupted.cmake
)
instrument(interrupt-test BUILD CTEST_SEAM
CHECK_SCRIPT check-test-interrupted.cmake
)
# Test make/ninja hooks
if(RunCMake_GENERATOR STREQUAL "FASTBuild")

View File

@@ -0,0 +1,33 @@
include(${CMAKE_CURRENT_LIST_DIR}/json.cmake)
# This checks a `ctest -F` resume of an interrupted test set. The overall
# ctest snippet and the postCTest hook are exercised by the seam and
# interrupt-only cases; here the resuming run re-runs (and re-indexes) the
# instrumentation, so only the resume behavior is asserted.
set(ran_interrupt ${RunCMake_TEST_BINARY_DIR}/ran-interrupt)
set(ran_resume ${RunCMake_TEST_BINARY_DIR}/ran-resume)
if (NOT EXISTS ${ran_interrupt})
add_error("Expected the interrupted run to record which tests ran")
elseif (NOT EXISTS ${ran_interrupt}/ctestFast)
add_error("ctestFast should have finished before the interrupt, but did not")
endif()
if (NOT EXISTS ${ran_resume})
add_error("Expected the `ctest -F` resume to record which tests ran")
else ()
# Failover: `ctest -F` must skip the already-finished fast test (it was
# checkpointed) and re-run the interrupted and pending slow tests (the
# in-flight test killed by the interrupt was deliberately not checkpointed).
if (EXISTS ${ran_resume}/ctestFast)
add_error("ctestFast finished before the interrupt and should be skipped "
"by `ctest -F`, but it ran again")
endif()
foreach (n 1 2 3)
if (NOT EXISTS ${ran_resume}/ctestSlow${n})
add_error("ctestSlow${n} was not finished before the interrupt and must "
"run on `ctest -F` resume, but it did not")
endif()
endforeach()
endif()

View File

@@ -0,0 +1,25 @@
include(${CMAKE_CURRENT_LIST_DIR}/json.cmake)
# After an interrupted `ctest`, exactly one ctest snippet should be present,
# marked with the interrupting signal, and the postCTest hook must have been
# skipped.
check_interrupted_snippet(ctest postCTest)
# The real-signal case (CTEST_INTERRUPT) records which tests ran in a marker
# directory. The deterministic seam case runs no tests, so guard on the
# directory's existence.
set(ran_interrupt ${RunCMake_TEST_BINARY_DIR}/ran-interrupt)
if (EXISTS ${ran_interrupt})
# Cancellation: the fast test finished but the pending slow tests must not all
# have started, proving the scheduler stopped launching tests on interrupt.
if (NOT EXISTS ${ran_interrupt}/ctestFast)
add_error("ctestFast should have run before the interrupt, but did not")
endif()
file(GLOB slow_ran LIST_DIRECTORIES false ${ran_interrupt}/ctestSlow*)
list(LENGTH slow_ran num_slow_ran)
if (num_slow_ran GREATER_EQUAL 3)
add_error("Expected some slow tests to remain pending on interrupt, "
"but all ${num_slow_ran} ran: ${slow_ran}")
endif()
endif()

View File

@@ -0,0 +1 @@
42

View File

@@ -89,3 +89,25 @@ if (INSTALL_INTERRUPT)
add_subdirectory(installSlow2)
add_subdirectory(installSlow3)
endif()
if (CTEST_INTERRUPT)
# A fast test plus several slow tests. A high COST on the fast test makes it
# sort first (CTest orders by descending cost, and on a first run with no
# historical cost data the COST property is used), so with `ctest -j 1` the
# fast test finishes first -- recording a completed test in the checkpoint --
# and then one slow test is in-flight, with the remaining slow tests pending,
# when the interrupt arrives. Each test touches a marker so the check script
# can observe which tests ran. Dependencies are deliberately avoided: on a
# `ctest -F` resume the finished fast test is removed, which would otherwise
# leave its dependents blocked. (InterruptBuild is provided by the
# INTERRUPT_BUILD_SRC block above, which is always enabled together with this.)
set(runtest "${CMAKE_CURRENT_SOURCE_DIR}/runtest.cmake")
add_test(NAME ctestFast COMMAND ${CMAKE_COMMAND}
-DMARKER=${CMAKE_BINARY_DIR}/ran/ctestFast -P ${runtest})
set_tests_properties(ctestFast PROPERTIES COST 100)
foreach (n 1 2 3)
add_test(NAME ctestSlow${n} COMMAND ${CMAKE_COMMAND}
-DMARKER=${CMAKE_BINARY_DIR}/ran/ctestSlow${n} -DSECONDS=10 -P ${runtest})
set_tests_properties(ctestSlow${n} PROPERTIES COST 1)
endforeach()
endif()

View File

@@ -0,0 +1,16 @@
# Test helper run as a CTest test command:
# cmake -DMARKER=<path> [-DSECONDS=<n>] -P runtest.cmake
#
# It records that the test started by touching MARKER, then optionally sleeps
# for SECONDS. The marker lets the interrupt tests observe exactly which tests
# ran (and thus which pending tests were canceled, or which finished tests a
# `ctest -F` resume skipped).
cmake_minimum_required(VERSION 3.30)
cmake_path(GET MARKER PARENT_PATH marker_dir)
file(MAKE_DIRECTORY "${marker_dir}")
file(TOUCH "${MARKER}")
if (SECONDS)
execute_process(COMMAND "${CMAKE_COMMAND}" -E sleep "${SECONDS}")
endif()

View File

@@ -0,0 +1,5 @@
{
"version": 1,
"hooks": ["postCTest"],
"callbacks": ["@GET_HOOK@"]
}

View File

@@ -0,0 +1,5 @@
{
"version": 1,
"hooks": ["postCTest"],
"callbacks": ["@GET_HOOK@"]
}