From bda67b82e7d6522ae029851c2f2f0b995bca8ce7 Mon Sep 17 00:00:00 2001 From: Daksh Mamodiya Date: Mon, 15 Jun 2026 18:16:00 +0200 Subject: [PATCH] instrumentation: Write cmakeBuild snippet when build is interrupted The overall `cmakeBuild` snippet is written only after the native build tool returns, so interrupting `cmake --build` with Ctrl+C terminated CMake before it was recorded and lost the build's delineation. When instrumentation is active, install a scoped, async-signal-safe handler (POSIX SIGINT; Windows CTRL_C/CTRL_BREAK) that just flags the interrupt. The existing write then runs during unwind, records the interrupting signal in a new `interruptSignal` field, skips the post-build index hook, and re-raises so the exit status still reflects the signal. The handler lives in its own translation unit, keeping the platform-divergent signal code out of the main instrumentation implementation. Issue: #27859 --- CTestCustom.cmake.in | 1 + Help/manual/cmake-instrumentation.7.rst | 11 ++ .../instrumentation/snippet-v1-schema.json | 8 + .../release/dev/instrumentation-interrupt.rst | 10 ++ Source/CMakeLists.txt | 2 + Source/cmInstrumentation.cxx | 44 ++++- Source/cmInstrumentation.h | 9 +- Source/cmInstrumentationInterrupt.cxx | 155 ++++++++++++++++ Source/cmInstrumentationInterrupt.h | 46 +++++ Source/cmake.cxx | 29 ++- Tests/RunCMake/CMakeLists.txt | 9 + .../RunCMake/Instrumentation/InterruptBuild.c | 169 ++++++++++++++++++ .../Instrumentation/RunCMakeTest.cmake | 75 ++++++++ .../Instrumentation/check-interrupted.cmake | 35 ++++ .../interrupt-build-result.txt | 1 + .../Instrumentation/project/CMakeLists.txt | 9 + .../Instrumentation/query/interrupt.json.in | 5 + .../Instrumentation/verify-snippet.cmake | 2 + 18 files changed, 612 insertions(+), 8 deletions(-) create mode 100644 Help/release/dev/instrumentation-interrupt.rst create mode 100644 Source/cmInstrumentationInterrupt.cxx create mode 100644 Source/cmInstrumentationInterrupt.h create mode 100644 Tests/RunCMake/Instrumentation/InterruptBuild.c create mode 100644 Tests/RunCMake/Instrumentation/check-interrupted.cmake create mode 100644 Tests/RunCMake/Instrumentation/interrupt-build-result.txt create mode 100644 Tests/RunCMake/Instrumentation/query/interrupt.json.in diff --git a/CTestCustom.cmake.in b/CTestCustom.cmake.in index 591da554e3..f0a048e5f1 100644 --- a/CTestCustom.cmake.in +++ b/CTestCustom.cmake.in @@ -134,4 +134,5 @@ list(APPEND CTEST_CUSTOM_COVERAGE_EXCLUDE list(APPEND CTEST_CUSTOM_MEMCHECK_IGNORE kwsys.testProcess-10 # See Source/kwsys/CTestCustom.cmake.in + RunCMake.InstrumentationInterrupt # interrupts cmake with a real signal ) diff --git a/Help/manual/cmake-instrumentation.7.rst b/Help/manual/cmake-instrumentation.7.rst index c1e1d347a7..33f81cb7b8 100644 --- a/Help/manual/cmake-instrumentation.7.rst +++ b/Help/manual/cmake-instrumentation.7.rst @@ -450,6 +450,17 @@ Snippet files have a filename with the syntax The exit code of the command, an integer. This will be ``null`` when ``role`` is ``build``. + ``interruptSignal`` + .. versionadded:: 4.5 + + The integer signal number that interrupted the build before it completed + (for example ``2`` for ``SIGINT`` from Ctrl+C). Only included when ``role`` + is ``cmakeBuild`` and the build was interrupted. Consumers can use the + presence of this field to distinguish an interrupted build from one that + ran to completion. + + Only available as of data version ``1.2``. + ``stdout`` .. versionadded:: 4.4 diff --git a/Help/manual/instrumentation/snippet-v1-schema.json b/Help/manual/instrumentation/snippet-v1-schema.json index f379a89e14..a59de52293 100644 --- a/Help/manual/instrumentation/snippet-v1-schema.json +++ b/Help/manual/instrumentation/snippet-v1-schema.json @@ -235,6 +235,11 @@ } ] }, + "interruptSignal": { + "type": "integer", + "description": "The signal number that interrupted the build before it completed. Only included when role is cmakeBuild and the build was interrupted.", + "minimum": 1 + }, "role": { "type": "string", "description": "The type of command executed.", @@ -617,6 +622,9 @@ }, "traceFile": { "$ref": "#/definitions/fields/traceFile" + }, + "interruptSignal": { + "$ref": "#/definitions/fields/interruptSignal" } }, "additionalProperties": false, diff --git a/Help/release/dev/instrumentation-interrupt.rst b/Help/release/dev/instrumentation-interrupt.rst new file mode 100644 index 0000000000..10383a372f --- /dev/null +++ b/Help/release/dev/instrumentation-interrupt.rst @@ -0,0 +1,10 @@ +instrumentation-interrupt +------------------------- + +* The :manual:`cmake-instrumentation(7)` data version has been updated to 1.2. +* :manual:`cmake-instrumentation(7)` API now records an overall ``cmakeBuild`` + snippet even when a :option:`cmake --build` invocation is interrupted by the + user (for example with Ctrl+C). The snippet includes a new + ``interruptSignal`` field, recording the signal that interrupted the build, + so that consumers can distinguish an interrupted build from one that ran + to completion. diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index b601fe4586..91bc133897 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -385,6 +385,8 @@ add_library( cmInstallScriptHandler.cxx cmInstrumentation.h cmInstrumentation.cxx + cmInstrumentationInterrupt.h + cmInstrumentationInterrupt.cxx cmInstrumentationCommand.h cmInstrumentationCommand.cxx cmInstrumentationQuery.h diff --git a/Source/cmInstrumentation.cxx b/Source/cmInstrumentation.cxx index 5d2755fd5d..ecef7cef05 100644 --- a/Source/cmInstrumentation.cxx +++ b/Source/cmInstrumentation.cxx @@ -29,8 +29,10 @@ #include "cmCryptoHash.h" #include "cmFileLock.h" #include "cmFileLockResult.h" +#include "cmGeneratedFileStream.h" #include "cmGeneratorTarget.h" #include "cmGlobalGenerator.h" +#include "cmInstrumentationInterrupt.h" #include "cmInstrumentationQuery.h" #include "cmJSONState.h" #include "cmList.h" @@ -555,7 +557,7 @@ Json::Value cmInstrumentation::ReadJsonSnippet(std::string const& file_name) void cmInstrumentation::WriteInstrumentationJson( cmInstrumentationQuery::Version version, Json::Value& root, - std::string const& subdir, std::string const& file_name) + std::string const& subdir, std::string const& file_name, Atomic atomic) { root["version"] = Json::objectValue; root["version"]["major"] = version.Major; @@ -567,8 +569,28 @@ void cmInstrumentation::WriteInstrumentationJson( std::unique_ptr(wbuilder.newStreamWriter()); std::string const& directory = cmStrCat(this->timingDirv1, '/', subdir); cmSystemTools::MakeDirectory(directory); + std::string const file_path = cmStrCat(directory, '/', file_name); - cmsys::ofstream ftmp(cmStrCat(directory, '/', file_name).c_str()); + if (atomic == Atomic::Yes) { + // Write to a temporary file and atomically rename it into place, so that + // an interrupt during the write cannot leave a truncated snippet. + cmGeneratedFileStream ftmp(file_path); + if (!ftmp) { + throw std::runtime_error(std::string("Unable to open: ") + file_name); + } + try { + JsonWriter->write(root, &ftmp); + ftmp << "\n"; + // The atomic rename happens when the stream is closed/destroyed. + } catch (std::ios_base::failure& fail) { + cmSystemTools::Error(cmStrCat("Failed to write JSON: ", fail.what())); + } catch (...) { + cmSystemTools::Error("Error writing JSON output for instrumentation."); + } + return; + } + + cmsys::ofstream ftmp(file_path.c_str()); if (!ftmp.good()) { throw std::runtime_error(std::string("Unable to open: ") + file_name); } @@ -747,6 +769,15 @@ int cmInstrumentation::InstrumentCommand( // See SpawnBuildDaemon(); this data is currently meaningless for build. root["result"] = command_type == "build" ? Json::nullValue : ret; + // If the build was interrupted (e.g. by Ctrl+C), record the signal number + // that stopped it, so consumers can distinguish an interrupted build from + // one that ran to completion. Omitted when no interrupt occurred; only a + // command wrapped by HandleInterrupt can observe a pending signal here. + int sig = cmInstrumentationInterrupt::PendingInterruptSignal(); + if (sig != 0) { + root["interruptSignal"] = sig; + } + // Output Sizes if (root.isMember("outputs")) { root["outputSizes"] = Json::arrayValue; @@ -808,7 +839,14 @@ int cmInstrumentation::InstrumentCommand( } this->configureSnippetData.clear(); } - this->WriteInstrumentationJson(latestDataVersion, root, "data", file_name); + // Write the cmakeBuild 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. + this->WriteInstrumentationJson(latestDataVersion, root, "data", file_name, + command_type == "cmakeBuild" ? Atomic::Yes + : Atomic::No); } return ret; } diff --git a/Source/cmInstrumentation.h b/Source/cmInstrumentation.h index a4d9132ac0..6364c497d2 100644 --- a/Source/cmInstrumentation.h +++ b/Source/cmInstrumentation.h @@ -48,6 +48,7 @@ public: cm::optional StdOut; cm::optional StdErr; }; + int InstrumentCommand( std::string command_type, std::vector const& command, std::function const& callback, @@ -100,10 +101,16 @@ private: Json::Value ReadJsonSnippet(std::string const& file_name); bool AcquireLock(std::string const& lock_file, cmFileLock& lock, unsigned long timeout); + enum class Atomic + { + No, + Yes, + }; void WriteInstrumentationJson(cmInstrumentationQuery::Version version, Json::Value& index, std::string const& directory, - std::string const& file_name); + std::string const& file_name, + Atomic atomic = Atomic::No); void InsertStaticSystemInformation(Json::Value& index); void GetDynamicSystemInformation(double& memory, double& load); void InsertDynamicSystemInformation(Json::Value& index, diff --git a/Source/cmInstrumentationInterrupt.cxx b/Source/cmInstrumentationInterrupt.cxx new file mode 100644 index 0000000000..67c2ac945a --- /dev/null +++ b/Source/cmInstrumentationInterrupt.cxx @@ -0,0 +1,155 @@ +/* Distributed under the OSI-approved BSD 3-Clause License. See accompanying + file LICENSE.rst or https://cmake.org/licensing for details. */ + +#if !defined(_POSIX_C_SOURCE) && !defined(_WIN32) && !defined(__sun) && \ + !defined(__OpenBSD__) +// POSIX APIs are needed (sigaction, sigemptyset, SA_RESETHAND). +// NOLINTNEXTLINE(bugprone-reserved-identifier) +# define _POSIX_C_SOURCE 200809L +#endif + +#include "cmInstrumentationInterrupt.h" + +#include +#include +#ifdef _WIN32 +# include + +# include +#else +# include +#endif + +namespace { +// Flag shared between the interrupt handler and the build flow that writes the +// `cmakeBuild` snippet. On Windows the console control handler runs on a +// separate thread, so an atomic is required; on POSIX the handler runs in +// signal context, where only `volatile sig_atomic_t` is guaranteed safe. +#ifdef _WIN32 +std::atomic buildInterruptSignal{ 0 }; + +BOOL WINAPI cmInstrumentationConsoleHandler(DWORD type) +{ + if (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT) { + int expected = 0; + buildInterruptSignal.compare_exchange_strong(expected, SIGINT); + // Return TRUE so the main thread can finish writing the snippet before the + // process exits. The native build tool shares the console and receives + // the event directly, so it still terminates and unblocks our build loop. + return TRUE; + } + return FALSE; +} +#else +sig_atomic_t volatile buildInterruptSignal = 0; +struct sigaction savedSigIntAction; + +extern "C" void cmInstrumentationSignalHandler(int sig) +{ + buildInterruptSignal = sig; +} +#endif + +// Set when the pending interrupt was injected by the test seam below rather +// than delivered by the OS. An injected interrupt must NOT be re-raised (the +// process exits normally after flushing the snippet), so the test stays a +// clean-exit, leak-checkable case on every generator. +bool buildInterruptInjected = false; + +// Test-only seam. An undocumented, unsupported environment variable lets the +// instrumentation test suite inject a "build was interrupted" condition +// deterministically, with no real OS signal -- so the cmakeBuild interrupt +// path can be exercised on every generator and platform. The double- +// underscore name marks it internal; it is never set in normal use. Mirrors +// CTest's internal fake-hook convention. +void InjectTestInterrupt() +{ + char const* value = std::getenv("__CMAKE_INSTRUMENTATION_TEST_INTERRUPT"); + if (!value) { + return; + } + int sig = std::atoi(value); + if (sig <= 0) { + return; + } +#ifdef _WIN32 + buildInterruptSignal.store(sig); +#else + buildInterruptSignal = static_cast(sig); +#endif + buildInterruptInjected = true; +} + +// Install the interrupt handler and clear any previously recorded signal. +void InstallInterruptHandler() +{ +#ifdef _WIN32 + buildInterruptSignal.store(0); + SetConsoleCtrlHandler(cmInstrumentationConsoleHandler, TRUE); +#else + buildInterruptSignal = 0; + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = cmInstrumentationSignalHandler; + sigemptyset(&sa.sa_mask); + // One-shot: after the first interrupt the default disposition is restored, + // so a second Ctrl+C terminates immediately even while we are mid-flush. + sa.sa_flags = SA_RESETHAND; + sigaction(SIGINT, &sa, &savedSigIntAction); +#endif +} + +// Restore the disposition that was in effect before InstallInterruptHandler(). +void RestoreInterruptHandler() +{ +#ifdef _WIN32 + SetConsoleCtrlHandler(cmInstrumentationConsoleHandler, FALSE); +#else + sigaction(SIGINT, &savedSigIntAction, nullptr); +#endif +} +} + +int cmInstrumentationInterrupt::PendingInterruptSignal() +{ +#ifdef _WIN32 + return buildInterruptSignal.load(); +#else + return static_cast(buildInterruptSignal); +#endif +} + +cmInstrumentationInterrupt::InterruptOutcome +cmInstrumentationInterrupt::HandleInterrupt( + bool active, std::function const& callback) +{ + // Only trap interrupts when instrumentation is active, so non-instrumented + // flows keep their default signal behavior. + if (!active) { + return { callback(), false, 0, true }; + } + InstallInterruptHandler(); + buildInterruptInjected = false; + // Test-only: allow the suite to inject an interrupt deterministically. + InjectTestInterrupt(); + int ret = callback(); + int sig = PendingInterruptSignal(); + RestoreInterruptHandler(); + // A real OS interrupt should be re-raised so the exit status reflects it; an + // injected (test) interrupt should not, so the process exits cleanly. + return { ret, sig != 0, sig, !buildInterruptInjected }; +} + +void cmInstrumentationInterrupt::RaiseInterrupt(int sig) +{ +#ifdef _WIN32 + // On Windows the process exits normally after flushing; the caller + // propagates the (failed) build result. + static_cast(sig); +#else + // Restore the default disposition (SA_RESETHAND already did so for the first + // delivery) and re-raise so the exit status reflects the interrupt. + signal(sig, SIG_DFL); + raise(sig); +#endif +} diff --git a/Source/cmInstrumentationInterrupt.h b/Source/cmInstrumentationInterrupt.h new file mode 100644 index 0000000000..42ba28870d --- /dev/null +++ b/Source/cmInstrumentationInterrupt.h @@ -0,0 +1,46 @@ +/* Distributed under the OSI-approved BSD 3-Clause License. See accompanying + file LICENSE.rst or https://cmake.org/licensing for details. */ +#pragma once + +#include "cmConfigure.h" // IWYU pragma: keep + +#include + +// Async-signal-safe handling of a user interrupt (Ctrl+C / SIGINT on POSIX, or +// a console Ctrl event on Windows) around an instrumented command, so that the +// command's snippet can still be written before the process exits. +class cmInstrumentationInterrupt +{ +public: + // Outcome of running a callback under an installed interrupt handler. + struct InterruptOutcome + { + int ExitCode; + bool Interrupted; + int Signal; + // Whether the caught interrupt should be re-raised so the process exit + // status reflects it. True for a real OS signal; false for a test- + // injected interrupt, which exits cleanly after flushing the snippet. + // Always set explicitly at every construction site (no default member + // initializer, so this stays a C++11 aggregate). + bool ShouldRaise; + }; + + // Run `callback` with an interrupt handler installed for its duration, so a + // user interrupt sets a flag instead of terminating the process immediately. + // The handler is deliberately minimal (async-signal-safe): it only records + // the signal. When `active` is false (e.g. instrumentation is not enabled), + // no handler is installed and the callback keeps the default signal + // behavior. Returns the callback's exit code, whether an interrupt was + // caught, and the signal number (0 if none). + static InterruptOutcome HandleInterrupt( + bool active, std::function const& callback); + + // Restore the default disposition and re-raise the given signal so that the + // process exits as if by the interrupt. No-op on platforms where re-raising + // is not the appropriate exit mechanism. + static void RaiseInterrupt(int sig); + + // The pending interrupt signal number, or 0 if no interrupt occurred. + static int PendingInterruptSignal(); +}; diff --git a/Source/cmake.cxx b/Source/cmake.cxx index 362adf1cd3..b9a9aa6515 100644 --- a/Source/cmake.cxx +++ b/Source/cmake.cxx @@ -88,6 +88,7 @@ # include "cmFileAPI.h" # include "cmGraphVizWriter.h" # include "cmInstrumentation.h" +# include "cmInstrumentationInterrupt.h" # include "cmInstrumentationQuery.h" # include "cmMakefileProfilingData.h" # include "cmSarifLog.h" @@ -4197,10 +4198,30 @@ int cmake::Build(cmBuildArgs buildArgs, std::vector targets, // Block the instrumentation build daemon from spawning during this build. // This lock will be released when the process exits at the end of the build. instrumentation.LockBuildDaemon(); - int buildresult = instrumentation.InstrumentCommand( - "cmakeBuild", args, [doBuild]() -> cmInstrumentation::CommandResult { - return { doBuild(), cm::nullopt, cm::nullopt }; - }); + // Run the build under an interrupt handler so that a user interrupt (e.g. + // Ctrl+C) still writes the overall `cmakeBuild` snippet before we exit. + cmInstrumentationInterrupt::InterruptOutcome buildOutcome = + cmInstrumentationInterrupt::HandleInterrupt( + instrumentation.HasQuery(), + [&instrumentation, &args, &doBuild]() -> int { + return instrumentation.InstrumentCommand( + "cmakeBuild", args, + [&doBuild]() -> cmInstrumentation::CommandResult { + return { doBuild(), cm::nullopt, cm::nullopt }; + }); + }); + int buildresult = buildOutcome.ExitCode; + if (buildOutcome.Interrupted) { + // The build was interrupted and its snippet has been written. Skip the + // post-build indexing hook (which would run callbacks and delete data). + // For a real OS interrupt, re-raise so the exit status reflects it; for a + // test-injected interrupt, exit cleanly. The next indexing run will + // reclaim the snippet written above. + if (buildOutcome.ShouldRaise) { + cmInstrumentationInterrupt::RaiseInterrupt(buildOutcome.Signal); + } + return buildresult; + } instrumentation.CollectTimingData( cmInstrumentationQuery::Hook::PostCMakeBuild); #else diff --git a/Tests/RunCMake/CMakeLists.txt b/Tests/RunCMake/CMakeLists.txt index 0662292f76..4a5e45a946 100644 --- a/Tests/RunCMake/CMakeLists.txt +++ b/Tests/RunCMake/CMakeLists.txt @@ -443,7 +443,16 @@ if(CMAKE_GENERATOR MATCHES "Make|Ninja|FASTBuild") -DCMAKE_C_COMPILER_ID=${CMAKE_C_COMPILER_ID} -DCMAKE_C_COMPILER=${CMAKE_C_COMPILER} -DCMAKE_C_COMPILER_VERSION=${CMAKE_C_COMPILER_VERSION} + -DCMAKE_EXECUTABLE_SUFFIX=${CMAKE_EXECUTABLE_SUFFIX} -DCMake_TEST_JSON_SCHEMA=${CMake_TEST_JSON_SCHEMA}) + # The real-signal/console-event interrupt case runs in its own suite so it can + # be excluded from MemCheck (the interrupted cmake is killed by a real signal) + # without dropping leak coverage for the rest of the instrumentation tests. + add_RunCMake_test(InstrumentationInterrupt TEST_DIR Instrumentation + -DINSTRUMENTATION_INTERRUPT_REAL=1 + -DCMAKE_C_COMPILER_ID=${CMAKE_C_COMPILER_ID} + -DCMAKE_C_COMPILER=${CMAKE_C_COMPILER} + -DCMAKE_EXECUTABLE_SUFFIX=${CMAKE_EXECUTABLE_SUFFIX}) endif() add_RunCMake_test(ConfigDir) if(CMake_TEST_FindPython2) diff --git a/Tests/RunCMake/Instrumentation/InterruptBuild.c b/Tests/RunCMake/Instrumentation/InterruptBuild.c new file mode 100644 index 0000000000..b7dfde2445 --- /dev/null +++ b/Tests/RunCMake/Instrumentation/InterruptBuild.c @@ -0,0 +1,169 @@ +/* Distributed under the OSI-approved BSD 3-Clause License. See accompanying + file LICENSE.rst or https://cmake.org/licensing for details. */ + +/* Test helper to exercise instrumentation handling of an interrupted build. + + Usage: InterruptBuild [args...] + + It runs in its own process group, waits , then + delivers a user interrupt to that group only -- mimicking a user pressing + Ctrl+C on the build's foreground process group, while leaving the calling + test driver untouched. It then reports a fixed exit code so the test can + assert deterministically: + 42 - the build was interrupted and then stopped (expected) + 2 - usage error + 3 - failed to start the build + 99 - the build did not stop after the interrupt + 100 + N - the build exited normally with code N (unexpected, POSIX only) + + On POSIX the interrupt is SIGINT sent to the child's process group; the + instrumented `cmake` re-raises it, so the child terminates via a signal. + On Windows the interrupt is a CTRL_BREAK_EVENT sent to the child's process + group (created with CREATE_NEW_PROCESS_GROUP); `cmake`'s console handler + treats CTRL_BREAK identically to CTRL_C. Because Windows `cmake` returns + the build's (failed) exit code rather than a signal status, success is + detected as "the child exited promptly after the interrupt". The real + assertion -- that an interrupted snippet was written -- is done by the + accompanying check script. */ + +#include +#include + +#if defined(_WIN32) + +# include + +# include + +int main(int argc, char** argv) +{ + int delay; + char cmdline[32768]; + size_t len = 0; + int i; + size_t n; + STARTUPINFOA si; + PROCESS_INFORMATION pi; + DWORD waitRc; + + if (argc < 3) { + fprintf(stderr, + "Usage: InterruptBuild [args...]\n"); + return 2; + } + delay = atoi(argv[1]); + if (delay <= 0) { + delay = 1; + } + + /* Rebuild a command line from argv[2..]. The inputs are controlled by the + test (a cmake path plus simple flags) and never contain embedded quotes, + so wrapping each argument in quotes is sufficient and correct. The buffer + is sized to the CreateProcess command-line limit of 32768 characters, + including the terminating null. */ + for (i = 2; i < argc; ++i) { + n = strlen(argv[i]); + if (len + n + 4 >= sizeof(cmdline)) { + return 2; + } + if (i > 2) { + cmdline[len++] = ' '; + } + cmdline[len++] = '"'; + memcpy(cmdline + len, argv[i], n); + len += n; + cmdline[len++] = '"'; + } + cmdline[len] = '\0'; + + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + ZeroMemory(&pi, sizeof(pi)); + + /* New process group so the control event reaches only the build (and its + children), not this helper or the test driver. */ + if (!CreateProcessA(NULL, cmdline, NULL, NULL, FALSE, + CREATE_NEW_PROCESS_GROUP, NULL, NULL, &si, &pi)) { + return 3; + } + + Sleep((DWORD)delay * 1000); + GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pi.dwProcessId); + + waitRc = WaitForSingleObject(pi.hProcess, 30000); + if (waitRc != WAIT_OBJECT_0) { + TerminateProcess(pi.hProcess, 1); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return 99; + } + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return 42; +} + +#else + +# include +# include + +# include +# include + +int main(int argc, char** argv) +{ + int delay; + pid_t build; + pid_t killer; + int status = 0; + + if (argc < 3) { + fprintf(stderr, + "Usage: InterruptBuild [args...]\n"); + return 2; + } + delay = atoi(argv[1]); + if (delay <= 0) { + delay = 1; + } + + build = fork(); + if (build < 0) { + return 3; + } + if (build == 0) { + /* Become the leader of a new process group, then run the build. The + native build tool inherits this group, so a later group signal reaches + it too. */ + setpgid(0, 0); + execvp(argv[2], &argv[2]); + _exit(127); + } + + /* Best-effort from the parent side to avoid a setpgid race; ignore errors. + */ + setpgid(build, build); + + killer = fork(); + if (killer == 0) { + sleep((unsigned int)delay); + killpg(build, SIGINT); + _exit(0); + } + + waitpid(build, &status, 0); + if (killer > 0) { + kill(killer, SIGKILL); + waitpid(killer, NULL, 0); + } + + if (WIFSIGNALED(status)) { + return 42; + } + if (WIFEXITED(status)) { + return 100 + WEXITSTATUS(status); + } + return 99; +} + +#endif diff --git a/Tests/RunCMake/Instrumentation/RunCMakeTest.cmake b/Tests/RunCMake/Instrumentation/RunCMakeTest.cmake index 8c06d7923f..4fdb4f6b44 100644 --- a/Tests/RunCMake/Instrumentation/RunCMakeTest.cmake +++ b/Tests/RunCMake/Instrumentation/RunCMakeTest.cmake @@ -8,6 +8,8 @@ function(instrument test) set(OPTIONS "BUILD" "BUILD_MAKE_PROGRAM" + "INTERRUPT" + "INTERRUPT_SEAM" "INSTALL" "INSTALL_PARALLEL" "TEST" @@ -121,6 +123,10 @@ function(instrument test) if (ARGS_DISABLE_TEST) list(APPEND ARGS_CONFIGURE_ARGS "-DDISABLE_TEST=ON") endif() + if (ARGS_INTERRUPT) + list(APPEND ARGS_CONFIGURE_ARGS + "-DINTERRUPT_BUILD_SRC=${RunCMake_SOURCE_DIR}/InterruptBuild.c") + 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) @@ -176,6 +182,51 @@ function(instrument test) unset(RunCMake_TEST_OUTPUT_MERGE) endif() endif() + if (ARGS_INTERRUPT) + # Build just the interrupt helper so it exists for the interrupted build. + # This uninterrupted build runs the postCMakeBuild hook, so remove the + # postCMakeBuild.hook file it produces; its absence after the interrupted + # build below then proves that build's hook was skipped. + run_cmake_command(${test}-helper + ${CMAKE_COMMAND} --build . --config Debug --target InterruptBuild) + file(REMOVE ${v1}/postCMakeBuild.hook) + + # Run an instrumented build and interrupt it after a few seconds, while the + # slow target is still running. Multi-config generators place the helper + # under a per-config subdirectory; the build below uses --config Debug. + 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}) + set(RunCMake_QUIET_ERROR 1) + run_cmake_command(${test}-build + ${helper} 3 + ${CMAKE_COMMAND} --build . --config Debug) + unset(RunCMake_QUIET_ERROR) + endif() + if (ARGS_INTERRUPT_SEAM) + # Drive the cmakeBuild interrupt path deterministically via the test-only + # injection seam, with no OS signal, so it runs on every generator. First + # build normally so the postCMakeBuild hook runs and creates its marker + # file; remove it so its absence after the injected build proves that + # build's hook was skipped. + set(RunCMake_QUIET_ERROR 1) + run_cmake_command(${test}-warmup + ${CMAKE_COMMAND} --build . --config Debug) + file(REMOVE ${v1}/postCMakeBuild.hook) + + # Inject an interrupt (SIGINT == 2) via the undocumented test seam and build + # again; cmake exits cleanly but writes the interrupted cmakeBuild snippet + # and skips the hook. + set(ENV{__CMAKE_INSTRUMENTATION_TEST_INTERRUPT} 2) + set(RunCMake_TEST_EXPECT_RESULT 0) + run_cmake_command(${test}-build + ${CMAKE_COMMAND} --build . --config Debug) + unset(RunCMake_TEST_EXPECT_RESULT) + unset(ENV{__CMAKE_INSTRUMENTATION_TEST_INTERRUPT}) + unset(RunCMake_QUIET_ERROR) + endif() if (ARGS_BUILD_MAKE_PROGRAM) set(RunCMake_TEST_OUTPUT_MERGE 1) set(RunCMake_QUIET_ERROR 1) @@ -206,6 +257,22 @@ function(instrument test) endif() endfunction() +if (INSTRUMENTATION_INTERRUPT_REAL) + # RunCMake.InstrumentationInterrupt runs ONLY the real-signal/ + # console-event interrupt case, as it must be excluded from MemCheck. + # + # POSIX delivers a real SIGINT to a contained process group. On Windows, only + # the Ninja generator is exercised: its native tool reliably stops on the + # console event and does not re-broadcast it to the runner; the other Windows + # make-family generators are covered by the injection seam instead. + if (NOT WIN32 OR RunCMake_GENERATOR MATCHES "Ninja") + instrument(interrupt INTERRUPT + CHECK_SCRIPT check-interrupted.cmake + ) + endif() + return() +endif() + # Bad Queries instrument(bad-option BAD_QUERY CHECK_SCRIPT check-query-dir.cmake @@ -403,6 +470,14 @@ if (NOT Skip_COMPILE_TRACE_QUERY_Case) endif() endif() +# Test that interrupting `cmake --build` still writes the cmakeBuild snippet, +# recording the interrupting signal. This case uses the deterministic test +# seam (no OS event). The real OS-event counterpart runs in the separate +# RunCMake.InstrumentationInterrupt suite. +instrument(interrupt INTERRUPT_SEAM + CHECK_SCRIPT check-interrupted.cmake +) + # Test make/ninja hooks if(RunCMake_GENERATOR STREQUAL "FASTBuild") # FIXME(#27184): This does not work for FASTBuild. diff --git a/Tests/RunCMake/Instrumentation/check-interrupted.cmake b/Tests/RunCMake/Instrumentation/check-interrupted.cmake new file mode 100644 index 0000000000..00dceed0e9 --- /dev/null +++ b/Tests/RunCMake/Instrumentation/check-interrupted.cmake @@ -0,0 +1,35 @@ +include(${CMAKE_CURRENT_LIST_DIR}/json.cmake) + +# After an interrupted `cmake --build`, exactly one cmakeBuild snippet should be +# present, recording the interrupting signal. Any cmakeBuild snippet from the +# earlier (uninterrupted) helper build was collated and removed by its +# postCMakeBuild hook. +file(GLOB cmakeBuildSnippets LIST_DIRECTORIES false ${v1}/data/cmakeBuild-*.json) +list(LENGTH cmakeBuildSnippets numCmakeBuild) +if (NOT numCmakeBuild EQUAL 1) + add_error("Expected exactly one cmakeBuild snippet, found ${numCmakeBuild}: ${cmakeBuildSnippets}") +else() + read_json("${cmakeBuildSnippets}" contents) + + string(JSON interruptSignal ERROR_VARIABLE noSignal GET "${contents}" interruptSignal) + if (noSignal OR NOT interruptSignal MATCHES "^[1-9][0-9]*$") + add_error("cmakeBuild snippet is not marked interrupted:\n${contents}") + endif() + + string(JSON version_minor GET "${contents}" version minor) + if (NOT version_minor EQUAL 2) + add_error("cmakeBuild snippet version minor expected 2, got: ${version_minor}") + endif() +endif() + +# The postCMakeBuild hook must be skipped entirely on interrupt, so its callback +# must not run. The callback (hook.cmake) writes a postCMakeBuild.hook file +# whenever it runs; the helper build's copy was removed before the interrupted +# build, so its presence here would mean the hook wrongly ran on interrupt. +if (EXISTS ${v1}/postCMakeBuild.hook) + add_error("postCMakeBuild hook should be skipped on interrupt, but it ran") +endif() + +if (DEFINED RunCMake_TEST_FAILED) + set(RunCMake_TEST_FAILED "${RunCMake_TEST_FAILED}" PARENT_SCOPE) +endif() diff --git a/Tests/RunCMake/Instrumentation/interrupt-build-result.txt b/Tests/RunCMake/Instrumentation/interrupt-build-result.txt new file mode 100644 index 0000000000..d81cc0710e --- /dev/null +++ b/Tests/RunCMake/Instrumentation/interrupt-build-result.txt @@ -0,0 +1 @@ +42 diff --git a/Tests/RunCMake/Instrumentation/project/CMakeLists.txt b/Tests/RunCMake/Instrumentation/project/CMakeLists.txt index a932dcaf89..3fde043daf 100644 --- a/Tests/RunCMake/Instrumentation/project/CMakeLists.txt +++ b/Tests/RunCMake/Instrumentation/project/CMakeLists.txt @@ -70,3 +70,12 @@ if (FAIL) add_test(NAME dummy COMMAND ${CMAKE_COMMAND} -E false) install(CODE "message(FATAL_ERROR \"Failed install.\")") endif() + +if (INTERRUPT_BUILD_SRC) + add_executable(InterruptBuild "${INTERRUPT_BUILD_SRC}") + add_custom_target(interruptSlow ALL + COMMAND ${CMAKE_COMMAND} -E echo "interruptSlow: begin" + COMMAND ${CMAKE_COMMAND} -E sleep 30 + COMMAND ${CMAKE_COMMAND} -E echo "interruptSlow: end" + ) +endif() diff --git a/Tests/RunCMake/Instrumentation/query/interrupt.json.in b/Tests/RunCMake/Instrumentation/query/interrupt.json.in new file mode 100644 index 0000000000..db31f2bcd7 --- /dev/null +++ b/Tests/RunCMake/Instrumentation/query/interrupt.json.in @@ -0,0 +1,5 @@ +{ + "version": 1, + "hooks": ["postCMakeBuild"], + "callbacks": ["@GET_HOOK@"] +} diff --git a/Tests/RunCMake/Instrumentation/verify-snippet.cmake b/Tests/RunCMake/Instrumentation/verify-snippet.cmake index 74a21df2f8..839fda1588 100644 --- a/Tests/RunCMake/Instrumentation/verify-snippet.cmake +++ b/Tests/RunCMake/Instrumentation/verify-snippet.cmake @@ -9,6 +9,8 @@ function(snippet_has_fields snippet contents) json_has_key("${snippet}" "${contents}" role) json_has_key("${snippet}" "${contents}" workingDir) json_has_key("${snippet}" "${contents}" result) + # Only an interrupted build records this; completed commands must omit it. + json_missing_key("${snippet}" "${contents}" interruptSignal) if (NOT filename MATCHES "^build-*") json_has_key("${snippet}" "${contents}" command) else()