diff --git a/docs/ci-and-misc.md b/docs/ci-and-misc.md index 49bbd989..d2ef3fd7 100644 --- a/docs/ci-and-misc.md +++ b/docs/ci-and-misc.md @@ -66,11 +66,14 @@ test execution. Specifically it understands * JUnit output path via `XML_OUTPUT_FILE` * Test filtering via `TESTBRIDGE_TEST_ONLY` * Test sharding via `TEST_SHARD_INDEX`, `TEST_TOTAL_SHARDS`, and `TEST_SHARD_STATUS_FILE` + * Creating a file to signal premature test exit via `TEST_PREMATURE_EXIT_FILE` > Support for `XML_OUTPUT_FILE` was [introduced](https://github.com/catchorg/Catch2/pull/2399) in Catch2 3.0.1 > Support for `TESTBRIDGE_TEST_ONLY` and sharding was introduced in Catch2 3.2.0 +> Support for `TEST_PREMATURE_EXIT_FILE` was introduced in Catch2 X.Y.Z + This integration is enabled via either a [compile time configuration option](configuration.md#bazel-support), or via `BAZEL_TEST` environment variable set to "1". diff --git a/docs/command-line.md b/docs/command-line.md index 73b94f6c..651efd01 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -32,6 +32,7 @@ [Test Sharding](#test-sharding)
[Allow running the binary without tests](#allow-running-the-binary-without-tests)
[Output verbosity](#output-verbosity)
+[Create file to guard against silent early termination](#create-file-to-guard-against-silent-early-termination)
Catch works quite nicely without any command line options at all - but for those times when you want greater control the following options are available. Click one of the following links to take you straight to that option - or scroll on to browse the available options. @@ -649,6 +650,21 @@ ignored. Verbosity defaults to _normal_. +## Create file to guard against silent early termination +
--premature-exit-guard-file <path>
+ +> Introduced in Catch2 X.Y.Z + +Tells Catch2 to create an empty file at specified path before the tests +start, and delete it after the tests finish. If the file is present after +the process stops, it can be assumed that the testing binary exited +prematurely, e.g. due to the OOM killer. + +All directories in the path must already exist. If this option is used +and Catch2 cannot create the file (e.g. the location is not writable), +the test run will fail. + + --- [Home](Readme.md#top) diff --git a/src/catch2/catch_config.cpp b/src/catch2/catch_config.cpp index 352c1f42..978bcfd7 100644 --- a/src/catch2/catch_config.cpp +++ b/src/catch2/catch_config.cpp @@ -119,6 +119,8 @@ namespace Catch { m_data.reporterSpecifications.push_back( std::move( *parsed ) ); } + // Reading bazel env vars can change some parts of the config data, + // so we have to process the bazel env before acting on the config. if ( enableBazelEnvSupport() ) { readBazelEnvVars(); } @@ -183,6 +185,8 @@ namespace Catch { bool Config::showHelp() const { return m_data.showHelp; } + std::string const& Config::getExitGuardFilePath() const { return m_data.prematureExitGuardFilePath; } + // IConfig interface bool Config::allowThrows() const { return !m_data.noThrow; } StringRef Config::name() const { return m_data.name.empty() ? m_data.processName : m_data.name; } @@ -244,6 +248,11 @@ namespace Catch { m_data.shardCount = bazelShardOptions->shardCount; } } + + const auto bazelExitGuardFile = Detail::getEnv( "TEST_PREMATURE_EXIT_FILE" ); + if (bazelExitGuardFile) { + m_data.prematureExitGuardFilePath = bazelExitGuardFile; + } } } // end namespace Catch diff --git a/src/catch2/catch_config.hpp b/src/catch2/catch_config.hpp index b7b1315e..cdf286ad 100644 --- a/src/catch2/catch_config.hpp +++ b/src/catch2/catch_config.hpp @@ -87,6 +87,8 @@ namespace Catch { std::vector testsOrTags; std::vector sectionsToRun; + + std::string prematureExitGuardFilePath; }; @@ -114,6 +116,8 @@ namespace Catch { bool showHelp() const; + std::string const& getExitGuardFilePath() const; + // IConfig interface bool allowThrows() const override; StringRef name() const override; diff --git a/src/catch2/catch_session.cpp b/src/catch2/catch_session.cpp index 7542447b..042b7f4d 100644 --- a/src/catch2/catch_session.cpp +++ b/src/catch2/catch_session.cpp @@ -26,6 +26,8 @@ #include #include +#include +#include #include #include #include @@ -140,6 +142,50 @@ namespace Catch { } } + // Creates empty file at path. The path must be writable, we do not + // try to create directories in path because that's hard in C++14. + void setUpGuardFile( std::string const& guardFilePath ) { + if ( !guardFilePath.empty() ) { +#if defined( _MSC_VER ) + std::FILE* file = nullptr; + if ( fopen_s( &file, guardFilePath.c_str(), "w" ) ) { + char msgBuffer[100]; + const auto err = errno; + std::string errMsg; + if ( !strerror_s( msgBuffer, err ) ) { + errMsg = msgBuffer; + } else { + errMsg = "Could not translate errno to a string"; + } + +#else + std::FILE* file = std::fopen( guardFilePath.c_str(), "w" ); + if ( !file ) { + const auto err = errno; + const char* errMsg = std::strerror( err ); +#endif + + CATCH_RUNTIME_ERROR( "Could not open the exit guard file '" + << guardFilePath << "' because '" + << errMsg << "' (" << err << ')' ); + } + const int ret = std::fclose( file ); + CATCH_ENFORCE( + ret == 0, + "Error when closing the exit guard file: " << ret ); + } + } + + // Removes file at path. Assuming we created it in setUpGuardFile. + void tearDownGuardFile( std::string const& guardFilePath ) { + if ( !guardFilePath.empty() ) { + const int ret = std::remove( guardFilePath.c_str() ); + CATCH_ENFORCE( + ret == 0, + "Error when removing the exit guard file: " << ret ); + } + } + } // anon namespace Session::Session() { @@ -258,6 +304,7 @@ namespace Catch { static_cast(std::getchar()); } int exitCode = runInternal(); + if( ( m_configData.waitForKeypress & WaitForKeypress::BeforeExit ) != 0 ) { Catch::cout() << "...waiting for enter/ return before exiting, with code: " << exitCode << '\n' << std::flush; static_cast(std::getchar()); @@ -298,6 +345,10 @@ namespace Catch { CATCH_TRY { config(); // Force config to be constructed + // We need to retrieve potential Bazel config with the full Config + // constructor, so we have to create the guard file after it is created. + setUpGuardFile( m_config->getExitGuardFilePath() ); + seedRng( *m_config ); if (m_configData.filenamesAsTags) { @@ -327,9 +378,12 @@ namespace Catch { TestGroup tests { CATCH_MOVE(reporter), m_config.get() }; auto const totals = tests.execute(); + // If we got here, running the tests finished normally-enough. + // They might've failed, but that would've been reported elsewhere. + tearDownGuardFile( m_config->getExitGuardFilePath() ); + if ( tests.hadUnmatchedTestSpecs() && m_config->warnAboutUnmatchedTestSpecs() ) { - // UnmatchedTestSpecExitCode return UnmatchedTestSpecExitCode; } diff --git a/src/catch2/internal/catch_commandline.cpp b/src/catch2/internal/catch_commandline.cpp index 212f1774..db903507 100644 --- a/src/catch2/internal/catch_commandline.cpp +++ b/src/catch2/internal/catch_commandline.cpp @@ -305,6 +305,9 @@ namespace Catch { | Opt( config.allowZeroTests ) ["--allow-running-no-tests"] ( "Treat 'No tests run' as a success" ) + | Opt( config.prematureExitGuardFilePath, "path" ) + ["--premature-exit-guard-file"] + ( "create a file before running tests and delete it during clean exit" ) | Arg( config.testsOrTags, "test name|pattern|tags" ) ( "which test or tests to use" ); diff --git a/tests/ExtraTests/CMakeLists.txt b/tests/ExtraTests/CMakeLists.txt index bb7292d6..28f545c5 100644 --- a/tests/ExtraTests/CMakeLists.txt +++ b/tests/ExtraTests/CMakeLists.txt @@ -429,6 +429,50 @@ set_tests_properties(Reporters::CrashInJunitReporter LABELS "uses-signals" ) + +add_executable(QuickExitInTest ${TESTS_DIR}/X40-QuickExit.cpp) +target_link_libraries(QuickExitInTest PRIVATE Catch2::Catch2WithMain) +add_test( + NAME BazelEnv::TEST_PREMATURE_EXIT_FILE + COMMAND + Python3::Interpreter + "${CATCH_DIR}/tests/TestScripts/testBazelExitGuardFile.py" + $ + "${CMAKE_CURRENT_BINARY_DIR}" + "bazel" +) +set_tests_properties(BazelEnv::TEST_PREMATURE_EXIT_FILE + PROPERTIES + LABELS "uses-python" +) +add_test( + NAME PrematureExitGuardFileCanBeUsedFromCLI::CheckAfterCrash + COMMAND + Python3::Interpreter + "${CATCH_DIR}/tests/TestScripts/testBazelExitGuardFile.py" + $ + "${CMAKE_CURRENT_BINARY_DIR}" + "cli" +) +set_tests_properties(PrematureExitGuardFileCanBeUsedFromCLI::CheckAfterCrash + PROPERTIES + LABELS "uses-python" +) +add_test( + NAME PrematureExitGuardFileCanBeUsedFromCLI::CheckWithoutCrash + COMMAND + Python3::Interpreter + "${CATCH_DIR}/tests/TestScripts/testBazelExitGuardFile.py" + $ + "${CMAKE_CURRENT_BINARY_DIR}" + "no-crash" +) +set_tests_properties(PrematureExitGuardFileCanBeUsedFromCLI::CheckWithoutCrash + PROPERTIES + LABELS "uses-python" +) + + add_executable(AssertionStartingEventGoesBeforeAssertionIsEvaluated X20-AssertionStartingEventGoesBeforeAssertionIsEvaluated.cpp ) diff --git a/tests/ExtraTests/X40-QuickExit.cpp b/tests/ExtraTests/X40-QuickExit.cpp new file mode 100644 index 00000000..3afb7b58 --- /dev/null +++ b/tests/ExtraTests/X40-QuickExit.cpp @@ -0,0 +1,28 @@ + +// Copyright Catch2 Authors +// Distributed under the Boost Software License, Version 1.0. +// (See accompanying file LICENSE.txt or copy at +// https://www.boost.org/LICENSE_1_0.txt) + +// SPDX-License-Identifier: BSL-1.0 + +/**\file + * Call ~~quick_exit~~ inside a test. + * + * This is used to test whether Catch2 properly creates the crash guard + * file based on provided arguments. + */ + +#include + +#include + +TEST_CASE("quick_exit", "[quick_exit]") { + // Return 0 as fake "successful" exit, while there should be a guard + // file created and kept. + std::exit(0); + // We cannot use quick_exit because libstdc++ on older MacOS versions didn't support it yet. + // std::quick_exit(0); +} + +TEST_CASE("pass") {} diff --git a/tests/TestScripts/testBazelExitGuardFile.py b/tests/TestScripts/testBazelExitGuardFile.py new file mode 100755 index 00000000..b4d31b52 --- /dev/null +++ b/tests/TestScripts/testBazelExitGuardFile.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +# Copyright Catch2 Authors +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE.txt or copy at +# https://www.boost.org/LICENSE_1_0.txt) + +# SPDX-License-Identifier: BSL-1.0 + +import os +import sys +import subprocess + +def generate_path_suffix() -> str: + return os.urandom(16).hex()[:16] + + +def run_common(cmd, env = None): + cmd_env = env if env is not None else os.environ.copy() + print('Running:', cmd) + + try: + ret = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + universal_newlines=True, + env=cmd_env + ) + except subprocess.SubprocessError as ex: + print('Could not run "{}"'.format(cmd)) + print("Return code: {}".format(ex.returncode)) + print("stdout: {}".format(ex.stdout)) + print("stderr: {}".format(ex.stderr)) + raise + + +def test_bazel_env_vars(bin_path, guard_path): + env = os.environ.copy() + env["TEST_PREMATURE_EXIT_FILE"] = guard_path + env["BAZEL_TEST"] = '1' + run_common([bin_path], env) + +def test_cli_parameter(bin_path, guard_path): + cmd = [ + bin_path, + '--premature-exit-guard-file', + guard_path + ] + run_common(cmd) + +def test_no_crash(bin_path, guard_path): + cmd = [ + bin_path, + '--premature-exit-guard-file', + guard_path, + # Disable the quick-exit test + '~[quick_exit]' + ] + run_common(cmd) + +checks = { + 'bazel': (test_bazel_env_vars, True), + 'cli': (test_cli_parameter, True), + 'no-crash': (test_no_crash, False), +} + + +bin_path = os.path.abspath(sys.argv[1]) +output_dir = os.path.abspath(sys.argv[2]) +test_kind = sys.argv[3] +guard_file_path = os.path.join(output_dir, f"guard_file.{generate_path_suffix()}") +print(f'Guard file path: "{guard_file_path}"') + +check_func, file_should_exist = checks[test_kind] +check_func(bin_path, guard_file_path) + +assert os.path.exists(guard_file_path) == file_should_exist +# With randomly generated file suffix, we should not run into name conflicts. +# However, we try to cleanup anyway, to avoid having infinity files in +# long living build directories. +if file_should_exist: + try: + os.remove(guard_file_path) + except Exception as ex: + print(f'Could not remove file {guard_file_path} because: {ex}') +