From ff6804f61181fdfb34160dfc101df0a0cacb7770 Mon Sep 17 00:00:00 2001 From: Nerixyz Date: Thu, 28 Sep 2023 19:25:35 +0200 Subject: [PATCH] feat: add crash recovery on Windows --- .clang-tidy | 1 + .github/workflows/build.yml | 2 +- .github/workflows/test-windows.yml | 1 + CHANGELOG.md | 1 + CMakeLists.txt | 2 + auxiliary/.clang-format | 55 +++ auxiliary/CMakeLists.txt | 8 + auxiliary/crash-handler/CMakeLists.txt | 95 +++++ auxiliary/crash-handler/commandline.cpp | 44 +++ auxiliary/crash-handler/commandline.hpp | 10 + auxiliary/crash-handler/commandline_test.cpp | 41 +++ auxiliary/crash-handler/main.cpp | 54 +++ auxiliary/crash-handler/recovery.cpp | 174 +++++++++ auxiliary/crash-handler/recovery.hpp | 32 ++ auxiliary/crash-handler/recovery_test.cpp | 347 ++++++++++++++++++ auxiliary/crash-handler/win_support.cpp | 63 ++++ auxiliary/crash-handler/win_support.hpp | 6 + auxiliary/crash-handler/win_support_test.cpp | 48 +++ mocks/include/mocks/EmptyApplication.hpp | 5 + src/Application.cpp | 7 +- src/Application.hpp | 7 + src/CMakeLists.txt | 5 +- src/RunGui.cpp | 54 +-- src/common/Args.cpp | 120 +++++- src/common/Args.hpp | 28 ++ src/common/FlagsEnum.hpp | 5 + src/main.cpp | 2 +- src/providers/Crashpad.cpp | 95 ----- src/providers/Crashpad.hpp | 14 - src/singletons/Crashpad.cpp | 218 +++++++++++ src/singletons/Crashpad.hpp | 37 ++ src/singletons/Settings.hpp | 1 - src/widgets/dialogs/LastRunCrashDialog.cpp | 193 ++++++---- src/widgets/dialogs/LastRunCrashDialog.hpp | 4 +- src/widgets/settingspages/GeneralPage.cpp | 14 +- src/widgets/settingspages/GeneralPageView.cpp | 22 ++ src/widgets/settingspages/GeneralPageView.hpp | 5 + 37 files changed, 1576 insertions(+), 244 deletions(-) create mode 100644 auxiliary/.clang-format create mode 100644 auxiliary/CMakeLists.txt create mode 100644 auxiliary/crash-handler/CMakeLists.txt create mode 100644 auxiliary/crash-handler/commandline.cpp create mode 100644 auxiliary/crash-handler/commandline.hpp create mode 100644 auxiliary/crash-handler/commandline_test.cpp create mode 100644 auxiliary/crash-handler/main.cpp create mode 100644 auxiliary/crash-handler/recovery.cpp create mode 100644 auxiliary/crash-handler/recovery.hpp create mode 100644 auxiliary/crash-handler/recovery_test.cpp create mode 100644 auxiliary/crash-handler/win_support.cpp create mode 100644 auxiliary/crash-handler/win_support.hpp create mode 100644 auxiliary/crash-handler/win_support_test.cpp delete mode 100644 src/providers/Crashpad.cpp delete mode 100644 src/providers/Crashpad.hpp create mode 100644 src/singletons/Crashpad.cpp create mode 100644 src/singletons/Crashpad.hpp diff --git a/.clang-tidy b/.clang-tidy index 658f661392a..32bd6b420bd 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -29,6 +29,7 @@ Checks: "-*, -readability-function-cognitive-complexity, -bugprone-easily-swappable-parameters, -cert-err58-cpp, + -modernize-avoid-c-arrays " CheckOptions: - key: readability-identifier-naming.ClassCase diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70a952134d5..16e39204eb9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -230,7 +230,7 @@ jobs: run: | cd build set cl=/MP - nmake /S /NOLOGO crashpad_handler + nmake /S /NOLOGO chatterino-crash-handler mkdir Chatterino2/crashpad cp bin/crashpad/crashpad_handler.exe Chatterino2/crashpad/crashpad_handler.exe 7z a bin/chatterino-Qt-${{ matrix.qt-version }}.pdb.7z bin/chatterino.pdb diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index bb543ff2371..7aefdf1f940 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -124,6 +124,7 @@ jobs: .. set cl=/MP nmake /S /NOLOGO + nmake /S /NOLOGO chatterino-crash-handler-test working-directory: build-test - name: Download and extract Twitch PubSub Server Test diff --git a/CHANGELOG.md b/CHANGELOG.md index cd00604d3bd..09647fdc587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ - Bugfix: Fixed support for Windows 11 Snap layouts. (#4994) - Bugfix: Fixed some windows appearing between screens. (#4797) - Bugfix: Fixed a bug on Wayland where tooltips would spawn as separate windows instead of behaving like tooltips. (#4998) +- Bugfix: Fixed _Restart on crash_ option not working on Windows. (#5012) - Dev: Run miniaudio in a separate thread, and simplify it to not manage the device ourselves. There's a chance the simplification is a bad idea. (#4978) - Dev: Change clang-format from v14 to v16. (#4929) - Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e74581b692..1ada22e2bd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -244,4 +244,6 @@ if (BUILD_BENCHMARKS) add_subdirectory(benchmarks) endif () +add_subdirectory(auxiliary) + feature_summary(WHAT ALL) diff --git a/auxiliary/.clang-format b/auxiliary/.clang-format new file mode 100644 index 00000000000..7bae09f2ce3 --- /dev/null +++ b/auxiliary/.clang-format @@ -0,0 +1,55 @@ +Language: Cpp + +AccessModifierOffset: -4 +AlignEscapedNewlinesLeft: true +AllowShortFunctionsOnASingleLine: false +AllowShortIfStatementsOnASingleLine: false +AllowShortLambdasOnASingleLine: Empty +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: false +AlwaysBreakBeforeMultilineStrings: false +BasedOnStyle: Google +BraceWrapping: + AfterClass: "true" + AfterControlStatement: "true" + AfterFunction: "true" + AfterNamespace: "false" + BeforeCatch: "true" + BeforeElse: "true" +BreakBeforeBraces: Custom +BreakConstructorInitializersBeforeComma: true +ColumnLimit: 80 +ConstructorInitializerAllOnOneLineOrOnePerLine: false +DerivePointerBinding: false +FixNamespaceComments: true +IndentCaseLabels: true +IndentWidth: 4 +IndentWrappedFunctionNames: true +IndentPPDirectives: AfterHash +SortIncludes: CaseInsensitive +IncludeBlocks: Regroup +IncludeCategories: + # Project includes + - Regex: '^"[a-zA-Z\._-]+(/[a-zA-Z0-9\._-]+)*"$' + Priority: 1 + # Third party library includes + - Regex: '<[[:alnum:].]+/[a-zA-Z0-9\._\/-]+>' + Priority: 3 + # Qt includes + - Regex: '^$' + Priority: 3 + CaseSensitive: true + # LibCommuni includes + - Regex: "^$" + Priority: 3 + # Misc libraries + - Regex: '^<[a-zA-Z_0-9]+\.h(pp)?>$' + Priority: 3 + # Standard library includes + - Regex: "^<[a-zA-Z_]+>$" + Priority: 4 +NamespaceIndentation: Inner +PointerBindsToType: false +SpacesBeforeTrailingComments: 2 +Standard: Auto +ReflowComments: false diff --git a/auxiliary/CMakeLists.txt b/auxiliary/CMakeLists.txt new file mode 100644 index 00000000000..9f6acad24aa --- /dev/null +++ b/auxiliary/CMakeLists.txt @@ -0,0 +1,8 @@ +if(BUILD_WITH_CRASHPAD) + if(WIN32) + # crash-handler is only used on Windows for now. + add_subdirectory(crash-handler) + else() + message(WARNING "Crashpad was enabled but the custom crash handler is Windows only!") + endif() +endif() diff --git a/auxiliary/crash-handler/CMakeLists.txt b/auxiliary/crash-handler/CMakeLists.txt new file mode 100644 index 00000000000..4faab2298a5 --- /dev/null +++ b/auxiliary/crash-handler/CMakeLists.txt @@ -0,0 +1,95 @@ +project(chatterino-crash-handler + VERSION 0.1.0 + LANGUAGES CXX +) +set(PROJECT_LIB "${PROJECT_NAME}-lib") + +set(LIB_SOURCE_FILES + commandline.hpp + commandline.cpp + recovery.hpp + recovery.cpp +) + +set(EXE_SOURCE_FILES + main.cpp +) + +if(WIN32) + list(APPEND LIB_SOURCE_FILES + win_support.hpp + win_support.cpp + ) +endif() + +add_library(${PROJECT_LIB} OBJECT ${LIB_SOURCE_FILES}) +target_link_libraries(${PROJECT_LIB} + PUBLIC + crashpad_handler_lib + crashpad_tools + MagicEnum +) +set_target_properties(${PROJECT_LIB} + PROPERTIES + CXX_STANDARD 23 + CXX_STANDARD_REQUIRED On +) + +add_executable(${PROJECT_NAME} WIN32 ${EXE_SOURCE_FILES}) +target_link_libraries(${PROJECT_NAME} PRIVATE ${PROJECT_LIB}) +set_target_properties(${PROJECT_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/crashpad" + OUTPUT_NAME "crashpad_handler" + CXX_STANDARD 23 + CXX_STANDARD_REQUIRED On +) + +if(WIN32) + target_compile_definitions(${PROJECT_LIB} PUBLIC NOMINMAX WIN32_LEAN_AND_MEAN) +endif() + +# Configure compilers +if(MSVC) + target_compile_options(${PROJECT_LIB} PUBLIC /EHsc /bigobj /utf-8) + target_compile_options(${PROJECT_LIB} PUBLIC + /W4 + # 5038 - warnings about initialization order + /w15038 + ) +else() + message(WARNING "No warnings configured for this compiler!") +endif() + +if(CHATTERINO_ENABLE_LTO) + message(STATUS "Enabling LTO for ${PROJECT_NAME}") + set_property(TARGET ${PROJECT_NAME} + PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE) +endif() + +# TESTS + +if(TARGET gtest) + message(STATUS "Tests enabled for ${PROJECT_NAME}") + + set(PROJECT_TESTS "${PROJECT_NAME}-test") + set(TEST_FILES + commandline_test.cpp + recovery_test.cpp + ) + if(WIN32) + list(APPEND TEST_FILES + win_support_test.cpp + ) + endif() + + add_executable(${PROJECT_TESTS} ${TEST_FILES}) + target_link_libraries(${PROJECT_TESTS} PRIVATE ${PROJECT_LIB} GTest::gtest_main gmock) + set_target_properties(${PROJECT_TESTS} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + CXX_STANDARD 23 + CXX_STANDARD_REQUIRED On + ) + gtest_discover_tests(${PROJECT_TESTS}) +endif() diff --git a/auxiliary/crash-handler/commandline.cpp b/auxiliary/crash-handler/commandline.cpp new file mode 100644 index 00000000000..a4e7c9ec5ba --- /dev/null +++ b/auxiliary/crash-handler/commandline.cpp @@ -0,0 +1,44 @@ +#include "commandline.hpp" + +std::vector splitChatterinoArgs(const std::wstring &args) +{ + std::vector parts; + + std::wstring_view view(args); + std::wstring_view::size_type pos{}; + std::wstring part; + + while ((pos = view.find(L'+')) != std::wstring_view::npos) + { + if (pos + 1 == view.length()) // string ends with + + { + parts.emplace_back(std::move(part)); + return parts; + } + + auto next = view[pos + 1]; + if (next == L'+') // escaped plus (++) + { + part += view.substr(0, pos); + part.push_back(L'+'); + view = view.substr(pos + 2); + continue; + } + + // actual separator + part += view.substr(0, pos); + parts.emplace_back(std::move(part)); + view = view.substr(pos + 1); + } + + if (!view.empty()) + { + part += view; + } + if (!part.empty()) + { + parts.emplace_back(std::move(part)); + } + + return parts; +} diff --git a/auxiliary/crash-handler/commandline.hpp b/auxiliary/crash-handler/commandline.hpp new file mode 100644 index 00000000000..ad1e411e3b1 --- /dev/null +++ b/auxiliary/crash-handler/commandline.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +/// Parse a command line string from chatterino into its arguments. +/// +/// The command line arguments are joined by '+'. A plus is escaped by an +/// additional plus ('++' -> '+'). +std::vector splitChatterinoArgs(const std::wstring &args); diff --git a/auxiliary/crash-handler/commandline_test.cpp b/auxiliary/crash-handler/commandline_test.cpp new file mode 100644 index 00000000000..5e258f408b2 --- /dev/null +++ b/auxiliary/crash-handler/commandline_test.cpp @@ -0,0 +1,41 @@ +#include "commandline.hpp" + +#include + +using namespace std::string_literals; + +TEST(CommandLineTest, splitChatterinoArgs) +{ + struct TestCase { + std::wstring input; + std::vector output; + }; + + std::initializer_list testCases{ + { + L"-c+t:alien+--safe-mode"s, + {L"-c"s, L"t:alien"s, L"--safe-mode"s}, + }, + { + L"-c+t:++++++breaking news++++++!!+-V"s, + {L"-c"s, L"t:+++breaking news+++!!"s, L"-V"s}, + }, + { + L"++"s, + {L"+"s}, + }, + { + L""s, + {}, + }, + { + L"--channels=t:foo;t:bar;t:++++++foo++++++"s, + {L"--channels=t:foo;t:bar;t:+++foo+++"}, + }, + }; + + for (const auto &testCase : testCases) + { + EXPECT_EQ(splitChatterinoArgs(testCase.input), testCase.output); + } +} diff --git a/auxiliary/crash-handler/main.cpp b/auxiliary/crash-handler/main.cpp new file mode 100644 index 00000000000..802d2e58c1c --- /dev/null +++ b/auxiliary/crash-handler/main.cpp @@ -0,0 +1,54 @@ +#include "recovery.hpp" + +#include +#include + +#if BUILDFLAG(IS_WIN) +# include +#endif + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays) +int actualMain(int argc, char *argv[]) +{ + // We're tapping into the crash handling by registering a data source. + // Our source doesn't actually provide any data, but it records the crash. + // Once the crash is recorded, we know that one happened and can attempt to restart + // the host application. + crashpad::UserStreamDataSources sources; + sources.emplace_back(std::make_unique()); + + auto ret = crashpad::HandlerMain(argc, argv, &sources); + + if (ret == 0) + { + auto *recoverer = dynamic_cast(sources.front().get()); + if (recoverer != nullptr) + { + recoverer->attemptRecovery(); + } + } + return ret; +} + +} // namespace + +// The following is adapted from handler/main.cc +#if BUILDFLAG(IS_POSIX) + +int main(int argc, char *argv[]) +{ + return actualMain(argc, argv); +} + +#elif BUILDFLAG(IS_WIN) + +// The default entry point for /subsystem:windows. +int APIENTRY wWinMain(HINSTANCE /*hInstance*/, HINSTANCE /*hPrevInstance*/, + PWSTR /*pCmdLine*/, int /*nCmdShow*/) +{ + return crashpad::ToolSupport::Wmain(__argc, __wargv, actualMain); +} + +#endif // BUILDFLAG(IS_POSIX) diff --git a/auxiliary/crash-handler/recovery.cpp b/auxiliary/crash-handler/recovery.cpp new file mode 100644 index 00000000000..851fc86e383 --- /dev/null +++ b/auxiliary/crash-handler/recovery.cpp @@ -0,0 +1,174 @@ +#include "recovery.hpp" + +#include "commandline.hpp" + +#include + +#if BUILDFLAG(IS_WIN) +# include "win_support.hpp" + +# include +#endif + +#include +#include +#include + +#include +#include +#include + +#include + +using namespace std::literals; +namespace ranges = std::ranges; +namespace views = std::views; +namespace chrono = std::chrono; + +namespace { + +/// Get a value out of a map if it exists. +template +std::optional tryGet(const std::map &map, const K &key) +{ + auto it = map.find(key); + if (it == map.end()) + { + return std::nullopt; + } + return it->second; +} + +/// Try to get a value with the key `name` out of a map (`annotations`). +/// If it doesn't exist, bail out, +/// otherwise, put the value in the variable `name`. +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +#define getOrBail(annotations, name) \ + auto name##Opt = tryGet(annotations, #name ""s); \ + if (!(name##Opt)) \ + { \ + return {}; \ + } \ + auto(name) = *(name##Opt); + +chrono::utc_time parseTime(const std::string &source) +{ + chrono::utc_time parsed; + std::stringstream(source) >> chrono::parse("%FT%TZ", parsed); + return parsed; +} + +} // namespace + +std::unique_ptr + CrashRecoverer::ProduceStreamData(crashpad::ProcessSnapshot *snapshot) +{ + if (snapshot == nullptr) + { + return {}; + } + + const auto &annotations = snapshot->AnnotationsSimpleMap(); + getOrBail(annotations, exePath); + getOrBail(annotations, canRestart); + getOrBail(annotations, startedAt); + auto exeArguments = tryGet(annotations, "exeArguments"s); + + if (canRestart != "true"s) + { + return {}; + } + + auto startTime = parseTime(startedAt); + auto now = std::chrono::time_point_cast( + std::chrono::utc_clock::now()); + if (now - startTime < 1min) + { + return {}; + } + + // we know we can restart now, + // prepare the arguments (gather basic info about the crash) + + std::vector arguments; + if (exeArguments) + { + arguments = splitChatterinoArgs(base::UTF8ToWide(*exeArguments)); + } + + const auto *exception = snapshot->Exception(); + if (exception != nullptr) + { + arguments.emplace_back(L"--cr-exception-code"s); + arguments.emplace_back(std::format(L"{}", exception->Exception())); +#if BUILDFLAG(IS_WIN) + auto message = formatCommonException(exception->Exception()); + if (message) + { + arguments.emplace_back(L"--cr-exception-message"s); + arguments.emplace_back(*message); + } +#endif + + // The amount of extra memory captured. + // This is almost always 0. + auto extraMemory = ranges::fold_left( + exception->ExtraMemory() | views::transform([](const auto *mem) { + return mem->Size(); + }), + 0, std::plus<>{}); + if (extraMemory > 0) + { + arguments.emplace_back(L"--cr-extra-memory"s); + arguments.emplace_back(std::format(L"{}", extraMemory)); + } + } + + this->restartInfo_ = RestartInfo{ + .applicationPath = base::UTF8ToWide(exePath), + .arguments = arguments, + }; + + return {}; +} + +void CrashRecoverer::attemptRecovery() const +{ +#if BUILDFLAG(IS_WIN) + + if (!this->restartInfo_) + { + return; + } + const auto &restartInfo = *this->restartInfo_; + + auto commandline = + std::format(L"\"{}\" --crash-recovery", restartInfo.applicationPath); + for (const auto &arg : restartInfo.arguments) + { + crashpad::AppendCommandLineArgument(arg, &commandline); + } + + STARTUPINFOW si; + PROCESS_INFORMATION pi; + + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + ZeroMemory(&pi, sizeof(pi)); + + auto result = CreateProcessW( + restartInfo.applicationPath.c_str(), + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) + const_cast(commandline.c_str()), nullptr, nullptr, FALSE, + DETACHED_PROCESS, nullptr, nullptr, &si, &pi); + + if (result == TRUE) + { + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + +#else + return; +#endif +} diff --git a/auxiliary/crash-handler/recovery.hpp b/auxiliary/crash-handler/recovery.hpp new file mode 100644 index 00000000000..7f46d882957 --- /dev/null +++ b/auxiliary/crash-handler/recovery.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include +#include +#include + +struct RestartInfo { + std::wstring applicationPath; + std::vector arguments; + + auto operator<=>(const RestartInfo &) const = default; +}; + +class CrashRecoverer : public crashpad::UserStreamDataSource +{ +public: + std::unique_ptr + ProduceStreamData(crashpad::ProcessSnapshot *process_snapshot) override; + + void attemptRecovery() const; + + const std::optional &restartInfo() const + { + return this->restartInfo_; + } + +private: + std::optional restartInfo_; +}; diff --git a/auxiliary/crash-handler/recovery_test.cpp b/auxiliary/crash-handler/recovery_test.cpp new file mode 100644 index 00000000000..088b793874f --- /dev/null +++ b/auxiliary/crash-handler/recovery_test.cpp @@ -0,0 +1,347 @@ +#include "recovery.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace std::string_literals; +using namespace std::chrono_literals; +using namespace testing; + +namespace chrono = std::chrono; +namespace ranges = std::ranges; +namespace views = std::ranges::views; + +namespace { + +class MockSnapshot : public crashpad::ProcessSnapshot +{ +public: + MOCK_METHOD(crashpad::ProcessID, ProcessID, (), (const, override)); + MOCK_METHOD(crashpad::ProcessID, ParentProcessID, (), (const, override)); + MOCK_METHOD(void, SnapshotTime, (timeval *), (const, override)); + MOCK_METHOD(void, ProcessStartTime, (timeval *), (const, override)); + MOCK_METHOD(void, ProcessCPUTimes, (timeval *, timeval *), + (const, override)); + MOCK_METHOD(void, ReportID, (crashpad::UUID *), (const, override)); + MOCK_METHOD(void, ClientID, (crashpad::UUID *), (const, override)); + MOCK_METHOD((const std::map &), + AnnotationsSimpleMap, (), (const, override)); + MOCK_METHOD(const crashpad::SystemSnapshot *, System, (), + (const, override)); + MOCK_METHOD(std::vector, Modules, (), + (const, override)); + MOCK_METHOD(std::vector, UnloadedModules, + (), (const, override)); + MOCK_METHOD(std::vector, Threads, (), + (const, override)); + MOCK_METHOD(const crashpad::ExceptionSnapshot *, Exception, (), + (const, override)); + MOCK_METHOD(std::vector, + MemoryMap, (), (const, override)); + MOCK_METHOD(std::vector, Handles, (), + (const, override)); + MOCK_METHOD(std::vector, ExtraMemory, (), + (const, override)); + MOCK_METHOD(const crashpad::ProcessMemory *, Memory, (), (const, override)); +}; + +class MyMemorySnapshot : public crashpad::MemorySnapshot +{ +public: + MyMemorySnapshot(size_t size) + : size_(size) + { + } + + size_t Size() const override + { + return this->size_; + } + + uint64_t Address() const override + { + return 0; + } + bool Read(Delegate * /*unused*/) const override + { + return false; + } + const MemorySnapshot *MergeWithOtherSnapshot( + const MemorySnapshot * /*unused*/) const override + { + return nullptr; + } + +private: + size_t size_; +}; + +class MyExceptionSnapshot : public crashpad::ExceptionSnapshot +{ +public: + MyExceptionSnapshot(uint32_t exception, + std::vector extraMemory = {}) + : exception_(exception) + , extraMemory_(std::move(extraMemory)) + { + } + + uint32_t Exception() const override + { + return this->exception_; + } + + std::vector ExtraMemory() const override + { + std::vector v; + v.reserve(this->extraMemory_.size()); + ranges::copy(this->extraMemory_ | views::transform([&](const auto &it) { + return ⁢ + }), + std::back_inserter(v)); + return v; + } + + MOCK_METHOD(const crashpad::CPUContext *, Context, (), (const, override)); + MOCK_METHOD(uint64_t, ThreadID, (), (const, override)); + MOCK_METHOD(uint32_t, ExceptionInfo, (), (const, override)); + MOCK_METHOD(uint64_t, ExceptionAddress, (), (const, override)); + MOCK_METHOD(const std::vector &, Codes, (), (const, override)); + +private: + uint32_t exception_; + std::vector extraMemory_; +}; + +std::string formatTs(chrono::utc_time ts) +{ + return std::format("{:%FT%TZ}", ts); +} + +std::string formatFromNow(chrono::seconds diff) +{ + return formatTs( + chrono::time_point_cast(chrono::utc_clock::now()) + + diff); +} + +} // namespace + +TEST(CrashRecoverer, ProduceStreamData) +{ + struct TestCase { + const char *name; + std::map annotations; + std::optional exception; + std::optional output; + }; + + std::initializer_list testCases{ + { + "Restart + No Exception", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "true"s}, + {"startedAt"s, formatFromNow(-2min)}, + }, + std::nullopt, + RestartInfo{ + L"foobar"s, + {}, + }, + }, + { + "Restart + Exception(access violation, 0b)", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "true"s}, + {"startedAt"s, formatFromNow(-2min)}, + }, + {EXCEPTION_ACCESS_VIOLATION}, + RestartInfo{ + L"foobar"s, + { + L"--cr-exception-code"s, + std::format(L"{}", EXCEPTION_ACCESS_VIOLATION), + L"--cr-exception-message"s, + L"ExceptionAccessViolation"s, + }, + }, + }, + { + "Restart + Exception(access violation, 42b)", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "true"s}, + {"startedAt"s, formatFromNow(-2min)}, + }, + std::make_optional( + EXCEPTION_ACCESS_VIOLATION, + std::vector{{42}}), + RestartInfo{ + L"foobar"s, + { + L"--cr-exception-code"s, + std::format(L"{}", EXCEPTION_ACCESS_VIOLATION), + L"--cr-exception-message"s, + L"ExceptionAccessViolation"s, + L"--cr-extra-memory"s, + L"42"s, + }, + }, + }, + { + "Restart + Exception(access violation, 21b + 21b)", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "true"s}, + {"startedAt"s, formatFromNow(-2min)}, + }, + std::make_optional( + EXCEPTION_ACCESS_VIOLATION, + std::vector{{21}, {21}}), + RestartInfo{ + L"foobar"s, + { + L"--cr-exception-code"s, + std::format(L"{}", EXCEPTION_ACCESS_VIOLATION), + L"--cr-exception-message"s, + L"ExceptionAccessViolation"s, + L"--cr-extra-memory"s, + L"42"s, + }, + }, + }, + { + "Restart + Exception(user triggered, 0b) + Args", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "true"s}, + {"startedAt"s, formatFromNow(-2min)}, + {"exeArguments"s, "--foo+--bar=foo++bar"s}, + }, + {static_cast( + crashpad::ExceptionCodes::kTriggeredExceptionCode)}, + RestartInfo{ + L"foobar"s, + { + L"--foo"s, + L"--bar=foo+bar"s, + L"--cr-exception-code"s, + std::format( + L"{}", + static_cast( + crashpad::ExceptionCodes::kTriggeredExceptionCode)), + L"--cr-exception-message"s, + L"TriggeredExceptionCode"s, + }, + }, + }, + { + "No Restart + Exception(access violation, 0b)", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "false"s}, + {"startedAt"s, formatFromNow(-2min)}, + }, + std::make_optional( + EXCEPTION_ACCESS_VIOLATION, + std::vector{{21}, {21}}), + std::nullopt, + }, + { + "No Restart + Args", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "false"s}, + {"startedAt"s, formatFromNow(-2min)}, + {"exeArguments"s, "--foo+--bar"s}, + }, + std::nullopt, + std::nullopt, + }, + { + "No Restart", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "True"s}, + {"startedAt"s, formatFromNow(-2min)}, + }, + std::nullopt, + std::nullopt, + }, + { + "Too early crash", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "true"s}, + {"startedAt"s, formatFromNow(-10s)}, + {"exeArguments"s, "--foo+--bar"s}, + }, + std::nullopt, + std::nullopt, + }, + { + "Missing path", + { + {"canRestart"s, "true"s}, + {"startedAt"s, formatFromNow(-2min)}, + {"exeArguments"s, "--foo+--bar"s}, + }, + std::nullopt, + std::nullopt, + }, + { + "Missing restart", + { + {"exePath"s, "foobar"s}, + {"startedAt"s, formatFromNow(-2min)}, + {"exeArguments"s, "--foo+--bar"s}, + }, + std::nullopt, + std::nullopt, + }, + { + "Missing start", + { + {"exePath"s, "foobar"s}, + {"canRestart"s, "true"s}, + {"exeArguments"s, "--foo+--bar"s}, + }, + std::nullopt, + std::nullopt, + }, + }; + + EXPECT_EQ(CrashRecoverer{}.ProduceStreamData(nullptr), nullptr); + + for (const auto &testCase : testCases) + { + CrashRecoverer recoverer; + MockSnapshot snapshot; + EXPECT_CALL(snapshot, AnnotationsSimpleMap()) + .Times(Exactly(1)) + .WillOnce(ReturnRef(testCase.annotations)); + + EXPECT_CALL(snapshot, Exception()) + .Times(Exactly(testCase.output ? 1 : 0)) + .WillOnce(Invoke([&]() -> const crashpad::ExceptionSnapshot * { + if (testCase.exception) + { + return &*testCase.exception; + } + return nullptr; + })); + EXPECT_EQ(recoverer.ProduceStreamData(&snapshot), nullptr); + EXPECT_EQ(recoverer.restartInfo(), testCase.output) << testCase.name; + } +} diff --git a/auxiliary/crash-handler/win_support.cpp b/auxiliary/crash-handler/win_support.cpp new file mode 100644 index 00000000000..05df9df2228 --- /dev/null +++ b/auxiliary/crash-handler/win_support.cpp @@ -0,0 +1,63 @@ +#include "win_support.hpp" + +#include +#include + +#define MAGIC_ENUM_USING_ALIAS_STRING_VIEW \ + using string_view = std::wstring_view; +#define MAGIC_ENUM_USING_ALIAS_STRING using string = std::wstring; +#include + +using namespace std::string_literals; + +namespace { + +enum class WinException : uint32_t { + ExceptionAccessViolation = EXCEPTION_ACCESS_VIOLATION, + ExceptionDatatypeMisalignment = EXCEPTION_DATATYPE_MISALIGNMENT, + ExceptionBreakpoint = EXCEPTION_BREAKPOINT, + ExceptionSingleStep = EXCEPTION_SINGLE_STEP, + ExceptionArrayBoundsExceeded = EXCEPTION_ARRAY_BOUNDS_EXCEEDED, + ExceptionFltDenormalOperand = EXCEPTION_FLT_DENORMAL_OPERAND, + ExceptionFltDivideByZero = EXCEPTION_FLT_DIVIDE_BY_ZERO, + ExceptionFltInexactResult = EXCEPTION_FLT_INEXACT_RESULT, + ExceptionFltInvalidOperation = EXCEPTION_FLT_INVALID_OPERATION, + ExceptionFltOverflow = EXCEPTION_FLT_OVERFLOW, + ExceptionFltStackCheck = EXCEPTION_FLT_STACK_CHECK, + ExceptionFltUnderflow = EXCEPTION_FLT_UNDERFLOW, + ExceptionIntDivideByZero = EXCEPTION_INT_DIVIDE_BY_ZERO, + ExceptionIntOverflow = EXCEPTION_INT_OVERFLOW, + ExceptionPrivInstruction = EXCEPTION_PRIV_INSTRUCTION, + ExceptionInPageError = EXCEPTION_IN_PAGE_ERROR, + ExceptionIllegalInstruction = EXCEPTION_ILLEGAL_INSTRUCTION, + ExceptionNoncontinuableException = EXCEPTION_NONCONTINUABLE_EXCEPTION, + ExceptionStackOverflow = EXCEPTION_STACK_OVERFLOW, + ExceptionInvalidDisposition = EXCEPTION_INVALID_DISPOSITION, + ExceptionGuardPage = EXCEPTION_GUARD_PAGE, + ExceptionInvalidHandle = EXCEPTION_INVALID_HANDLE, +}; + +} // namespace + +template <> +struct magic_enum::customize::enum_range { + // NOLINTNEXTLINE(readability-identifier-naming) + static constexpr uint32_t min = 0xC0000000L; + // NOLINTNEXTLINE(readability-identifier-naming) + static constexpr uint32_t max = 0xC00000ffL; +}; + +std::optional formatCommonException(uint32_t ex) +{ + if (ex == crashpad::ExceptionCodes::kTriggeredExceptionCode) + { + return L"TriggeredExceptionCode"s; + } + + auto name = magic_enum::enum_name(static_cast(ex)); + if (name.empty()) + { + return std::nullopt; + } + return std::wstring{name}; +} diff --git a/auxiliary/crash-handler/win_support.hpp b/auxiliary/crash-handler/win_support.hpp new file mode 100644 index 00000000000..f0b60b12ae3 --- /dev/null +++ b/auxiliary/crash-handler/win_support.hpp @@ -0,0 +1,6 @@ +#pragma once + +#include +#include + +std::optional formatCommonException(uint32_t ex); diff --git a/auxiliary/crash-handler/win_support_test.cpp b/auxiliary/crash-handler/win_support_test.cpp new file mode 100644 index 00000000000..05b53e95d50 --- /dev/null +++ b/auxiliary/crash-handler/win_support_test.cpp @@ -0,0 +1,48 @@ +#include "win_support.hpp" + +#include +#include +#include + +using namespace std::string_literals; + +TEST(WinSupport, formatCommonException) +{ + struct TestCase { + uint32_t input = 0; + std::optional output; + }; + + std::initializer_list testCases{ + { + EXCEPTION_ACCESS_VIOLATION, + L"ExceptionAccessViolation"s, + }, + { + EXCEPTION_INVALID_HANDLE, + L"ExceptionInvalidHandle"s, + }, + { + 0x5, + std::nullopt, + }, + { + 0xff, + std::nullopt, + }, + { + 0x100, + std::nullopt, + }, + { + crashpad::ExceptionCodes::kTriggeredExceptionCode, + L"TriggeredExceptionCode"s, + }, + }; + + for (const auto &testCase : testCases) + { + EXPECT_EQ(formatCommonException(testCase.input), testCase.output) + << testCase.input; + } +} diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 87deafa8a7f..041a48e22ee 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -44,6 +44,11 @@ class EmptyApplication : public IApplication return nullptr; } + CrashRecovery *getCrashRecovery() override + { + return nullptr; + } + CommandController *getCommands() override { return nullptr; diff --git a/src/Application.cpp b/src/Application.cpp index d8a7a575469..d4c631d2b52 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -38,6 +38,7 @@ #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" +#include "singletons/Crashpad.hpp" #include "singletons/Emotes.hpp" #include "singletons/Fonts.hpp" #include "singletons/helper/LoggingChannel.hpp" @@ -113,6 +114,7 @@ Application::Application(Settings &_settings, Paths &_paths) , toasts(&this->emplace()) , imageUploader(&this->emplace()) , seventvAPI(&this->emplace()) + , crashRecovery(&this->emplace()) , commands(&this->emplace()) , notifications(&this->emplace()) @@ -174,7 +176,9 @@ void Application::initialize(Settings &settings, Paths &paths) singleton->initialize(settings, paths); } - // add crash message + // Show crash message. + // On Windows, the crash message was already shown. +#ifndef Q_OS_WIN if (!getArgs().isFramelessEmbed && getArgs().crashRecovery) { if (auto selected = @@ -195,6 +199,7 @@ void Application::initialize(Settings &settings, Paths &paths) } } } +#endif this->windows->updateWordTypeMask(); diff --git a/src/Application.hpp b/src/Application.hpp index 5dec1e9066a..eb9673bafa4 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -44,6 +44,7 @@ class FfzBadges; class SeventvBadges; class ImageUploader; class SeventvAPI; +class CrashRecovery; class IApplication { @@ -60,6 +61,7 @@ class IApplication virtual HotkeyController *getHotkeys() = 0; virtual WindowManager *getWindows() = 0; virtual Toasts *getToasts() = 0; + virtual CrashRecovery *getCrashRecovery() = 0; virtual CommandController *getCommands() = 0; virtual HighlightController *getHighlights() = 0; virtual NotificationController *getNotifications() = 0; @@ -102,6 +104,7 @@ class Application : public IApplication Toasts *const toasts{}; ImageUploader *const imageUploader{}; SeventvAPI *const seventvAPI{}; + CrashRecovery *const crashRecovery{}; CommandController *const commands{}; NotificationController *const notifications{}; @@ -148,6 +151,10 @@ class Application : public IApplication { return this->toasts; } + CrashRecovery *getCrashRecovery() override + { + return this->crashRecovery; + } CommandController *getCommands() override { return this->commands; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1c6c3c0a0f3..4fec7aafe60 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -289,8 +289,6 @@ set(SOURCE_FILES messages/search/SubtierPredicate.cpp messages/search/SubtierPredicate.hpp - providers/Crashpad.cpp - providers/Crashpad.hpp providers/IvrApi.cpp providers/IvrApi.hpp providers/LinkResolver.cpp @@ -425,6 +423,8 @@ set(SOURCE_FILES singletons/Badges.cpp singletons/Badges.hpp + singletons/Crashpad.cpp + singletons/Crashpad.hpp singletons/Emotes.cpp singletons/Emotes.hpp singletons/Fonts.cpp @@ -1007,7 +1007,6 @@ endif () if (BUILD_WITH_CRASHPAD) target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO_WITH_CRASHPAD) target_link_libraries(${LIBRARY_PROJECT} PUBLIC crashpad::client) - set_target_directory_hierarchy(crashpad_handler crashpad) endif() # Configure compiler warnings diff --git a/src/RunGui.cpp b/src/RunGui.cpp index 62f1d6a43d5..ce084ee6b26 100644 --- a/src/RunGui.cpp +++ b/src/RunGui.cpp @@ -5,6 +5,7 @@ #include "common/Modes.hpp" #include "common/NetworkManager.hpp" #include "common/QLogging.hpp" +#include "singletons/Crashpad.hpp" #include "singletons/Paths.hpp" #include "singletons/Resources.hpp" #include "singletons/Settings.hpp" @@ -99,21 +100,10 @@ namespace { void showLastCrashDialog() { - //#ifndef C_DISABLE_CRASH_DIALOG - // LastRunCrashDialog dialog; - - // switch (dialog.exec()) - // { - // case QDialog::Accepted: - // { - // }; - // break; - // default: - // { - // _exit(0); - // } - // } - //#endif + auto *dialog = new LastRunCrashDialog; + // Use exec() over open() to block the app from being loaded + // and to be able to set the safe mode. + dialog->exec(); } void createRunningFile(const QString &path) @@ -131,14 +121,14 @@ namespace { } std::chrono::steady_clock::time_point signalsInitTime; - bool restartOnSignal = false; [[noreturn]] void handleSignal(int signum) { using namespace std::chrono_literals; - if (restartOnSignal && - std::chrono::steady_clock::now() - signalsInitTime > 30s) + if (std::chrono::steady_clock::now() - signalsInitTime > 30s && + getApp()->crashRecovery->recoveryFlags().has( + CrashRecovery::Flag::DoCrashRecovery)) { QProcess proc; @@ -240,9 +230,12 @@ void runGui(QApplication &a, Paths &paths, Settings &settings) initResources(); initSignalHandler(); - settings.restartOnCrash.connect([](const bool &value) { - restartOnSignal = value; - }); +#ifdef Q_OS_WIN + if (getArgs().crashRecovery) + { + showLastCrashDialog(); + } +#endif auto thread = std::thread([dir = paths.miscDirectory] { { @@ -279,30 +272,11 @@ void runGui(QApplication &a, Paths &paths, Settings &settings) chatterino::NetworkManager::init(); chatterino::Updates::instance().checkForUpdates(); -#ifdef C_USE_BREAKPAD - QBreakpadInstance.setDumpPath(getPaths()->settingsFolderPath + "/Crashes"); -#endif - - // Running file - auto runningPath = - paths.miscDirectory + "/running_" + paths.applicationFilePathHash; - - if (QFile::exists(runningPath)) - { - showLastCrashDialog(); - } - else - { - createRunningFile(runningPath); - } - Application app(settings, paths); app.initialize(settings, paths); app.run(a); app.save(); - removeRunningFile(runningPath); - if (!getArgs().dontSaveSettings) { pajlada::Settings::SettingManager::gSave(); diff --git a/src/common/Args.cpp b/src/common/Args.cpp index 7bc48573c8f..ac82ed470a7 100644 --- a/src/common/Args.cpp +++ b/src/common/Args.cpp @@ -1,6 +1,7 @@ #include "Args.hpp" #include "common/QLogging.hpp" +#include "debug/AssertInGuiThread.hpp" #include "singletons/Paths.hpp" #include "singletons/WindowManager.hpp" #include "util/AttachToConsole.hpp" @@ -14,6 +15,55 @@ #include #include +namespace { + +template +QCommandLineOption hiddenOption(Args... args) +{ + QCommandLineOption opt(args...); + opt.setFlags(QCommandLineOption::HiddenFromHelp); + return opt; +} + +QStringList extractCommandLine( + const QCommandLineParser &parser, + std::initializer_list options) +{ + QStringList args; + for (const auto &option : options) + { + if (parser.isSet(option)) + { + auto optionName = option.names().first(); + if (optionName.length() == 1) + { + optionName.prepend(u'-'); + } + else + { + optionName.prepend("--"); + } + + auto values = parser.values(option); + if (values.empty()) + { + args += optionName; + } + else + { + for (const auto &value : values) + { + args += optionName; + args += value; + } + } + } + } + return args; +} + +} // namespace + namespace chatterino { Args::Args(const QApplication &app) @@ -23,39 +73,46 @@ Args::Args(const QApplication &app) parser.addHelpOption(); // Used internally by app to restart after unexpected crashes - QCommandLineOption crashRecoveryOption("crash-recovery"); - crashRecoveryOption.setFlags(QCommandLineOption::HiddenFromHelp); + auto crashRecoveryOption = hiddenOption("crash-recovery"); + auto exceptionCodeOption = hiddenOption("cr-exception-code", "", "code"); + auto exceptionMessageOption = + hiddenOption("cr-exception-message", "", "message"); + auto extraMemoryOption = hiddenOption("cr-extra-memory", "", "bytes"); // Added to ignore the parent-window option passed during native messaging - QCommandLineOption parentWindowOption("parent-window"); - parentWindowOption.setFlags(QCommandLineOption::HiddenFromHelp); - QCommandLineOption parentWindowIdOption("x-attach-split-to-window", "", - "window-id"); - parentWindowIdOption.setFlags(QCommandLineOption::HiddenFromHelp); + auto parentWindowOption = hiddenOption("parent-window"); + auto parentWindowIdOption = + hiddenOption("x-attach-split-to-window", "", "window-id"); // Verbose - QCommandLineOption verboseOption({{"v", "verbose"}, + auto verboseOption = hiddenOption(QStringList{"v", "verbose"}, "Attaches to the Console on windows, " - "allowing you to see debug output."}); - crashRecoveryOption.setFlags(QCommandLineOption::HiddenFromHelp); + "allowing you to see debug output."); + // Safe mode QCommandLineOption safeModeOption( "safe-mode", "Starts Chatterino without loading Plugins and always " "show the settings button."); + // Channel layout + auto channelLayout = QCommandLineOption( + {"c", "channels"}, + "Joins only supplied channels on startup. Use letters with colons to " + "specify platform. Only Twitch channels are supported at the moment.\n" + "If platform isn't specified, default is Twitch.", + "t:channel1;t:channel2;..."); + parser.addOptions({ {{"V", "version"}, "Displays version information."}, crashRecoveryOption, + exceptionCodeOption, + exceptionMessageOption, + extraMemoryOption, parentWindowOption, parentWindowIdOption, verboseOption, safeModeOption, + channelLayout, }); - parser.addOption(QCommandLineOption( - {"c", "channels"}, - "Joins only supplied channels on startup. Use letters with colons to " - "specify platform. Only Twitch channels are supported at the moment.\n" - "If platform isn't specified, default is Twitch.", - "t:channel1;t:channel2;...")); if (!parser.parse(app.arguments())) { @@ -75,15 +132,29 @@ Args::Args(const QApplication &app) (args.size() > 0 && (args[0].startsWith("chrome-extension://") || args[0].endsWith(".json"))); - if (parser.isSet("c")) + if (parser.isSet(channelLayout)) { - this->applyCustomChannelLayout(parser.value("c")); + this->applyCustomChannelLayout(parser.value(channelLayout)); } this->verbose = parser.isSet(verboseOption); this->printVersion = parser.isSet("V"); - this->crashRecovery = parser.isSet("crash-recovery"); + + this->crashRecovery = parser.isSet(crashRecoveryOption); + if (parser.isSet(exceptionCodeOption)) + { + this->exceptionCode = + static_cast(parser.value(exceptionCodeOption).toULong()); + } + if (parser.isSet(exceptionMessageOption)) + { + this->exceptionMessage = parser.value(exceptionMessageOption); + } + if (parser.isSet(extraMemoryOption)) + { + this->extraMemory = parser.value(extraMemoryOption).toULongLong(); + } if (parser.isSet(parentWindowIdOption)) { @@ -97,6 +168,17 @@ Args::Args(const QApplication &app) { this->safeMode = true; } + + this->currentArguments_ = extractCommandLine(parser, { + verboseOption, + safeModeOption, + channelLayout, + }); +} + +QStringList Args::currentArguments() const +{ + return this->currentArguments_; } void Args::applyCustomChannelLayout(const QString &argValue) diff --git a/src/common/Args.hpp b/src/common/Args.hpp index 73144b7da9c..c49ef03854b 100644 --- a/src/common/Args.hpp +++ b/src/common/Args.hpp @@ -9,13 +9,37 @@ namespace chatterino { /// Command line arguments passed to Chatterino. +/// +/// All accepted arguments: +/// +/// Crash recovery: +/// --crash-recovery +/// --cr-exception-code code +/// --cr-exception-message message +/// --cr-extra-memory=bytes +/// +/// Native messaging: +/// --parent-window +/// --x-attach-split-to-window=window-id +/// +/// -v, --verbose +/// -V, --version +/// -c, --channels=t:channel1;t:channel2;... +/// --safe-mode +/// +/// See documentation on `QGuiApplication` for documentation on Qt arguments like -platform. class Args { public: Args(const QApplication &app); bool printVersion{}; + bool crashRecovery{}; + std::optional exceptionCode{}; + std::optional exceptionMessage{}; + std::optional extraMemory{}; + bool shouldRunBrowserExtensionHost{}; // Shows a single chat. Used on windows to embed in another application. bool isFramelessEmbed{}; @@ -28,8 +52,12 @@ class Args bool verbose{}; bool safeMode{}; + QStringList currentArguments() const; + private: void applyCustomChannelLayout(const QString &argValue); + + QStringList currentArguments_; }; void initArgs(const QApplication &app); diff --git a/src/common/FlagsEnum.hpp b/src/common/FlagsEnum.hpp index 07d67275184..f83b820a1c5 100644 --- a/src/common/FlagsEnum.hpp +++ b/src/common/FlagsEnum.hpp @@ -97,6 +97,11 @@ class FlagsEnum return !this->hasAny(flags); } + T value() const + { + return this->value_; + } + private: T value_{}; }; diff --git a/src/main.cpp b/src/main.cpp index 9dce310a0e3..b3f8c8437e0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,11 +4,11 @@ #include "common/Modes.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" -#include "providers/Crashpad.hpp" #include "providers/IvrApi.hpp" #include "providers/NetworkConfigurationProvider.hpp" #include "providers/twitch/api/Helix.hpp" #include "RunGui.hpp" +#include "singletons/Crashpad.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" #include "util/AttachToConsole.hpp" diff --git a/src/providers/Crashpad.cpp b/src/providers/Crashpad.cpp deleted file mode 100644 index f81cbe071fb..00000000000 --- a/src/providers/Crashpad.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#ifdef CHATTERINO_WITH_CRASHPAD -# include "providers/Crashpad.hpp" - -# include "common/QLogging.hpp" -# include "singletons/Paths.hpp" - -# include -# include -# include - -# include -# include - -namespace { - -/// The name of the crashpad handler executable. -/// This varies across platforms -# if defined(Q_OS_UNIX) -const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad_handler"); -# elif defined(Q_OS_WINDOWS) -const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad_handler.exe"); -# else -# error Unsupported platform -# endif - -/// Converts a QString into the platform string representation. -# if defined(Q_OS_UNIX) -std::string nativeString(const QString &s) -{ - return s.toStdString(); -} -# elif defined(Q_OS_WINDOWS) -std::wstring nativeString(const QString &s) -{ - return s.toStdWString(); -} -# else -# error Unsupported platform -# endif - -} // namespace - -namespace chatterino { - -std::unique_ptr installCrashHandler() -{ - // Currently, the following directory layout is assumed: - // [applicationDirPath] - // │ - // ├─chatterino - // │ - // ╰─[crashpad] - // │ - // ╰─crashpad_handler - // TODO: The location of the binary might vary across platforms - auto crashpadBinDir = QDir(QApplication::applicationDirPath()); - - if (!crashpadBinDir.cd("crashpad")) - { - qCDebug(chatterinoApp) << "Cannot find crashpad directory"; - return nullptr; - } - if (!crashpadBinDir.exists(CRASHPAD_EXECUTABLE_NAME)) - { - qCDebug(chatterinoApp) << "Cannot find crashpad handler executable"; - return nullptr; - } - - const auto handlerPath = base::FilePath(nativeString( - crashpadBinDir.absoluteFilePath(CRASHPAD_EXECUTABLE_NAME))); - - // Argument passed in --database - // > Crash reports are written to this database, and if uploads are enabled, - // uploaded from this database to a crash report collection server. - const auto databaseDir = - base::FilePath(nativeString(getPaths()->crashdumpDirectory)); - - auto client = std::make_unique(); - - // See https://chromium.googlesource.com/crashpad/crashpad/+/HEAD/handler/crashpad_handler.md - // for documentation on available options. - if (!client->StartHandler(handlerPath, databaseDir, {}, {}, {}, {}, {}, - true, false)) - { - qCDebug(chatterinoApp) << "Failed to start crashpad handler"; - return nullptr; - } - - qCDebug(chatterinoApp) << "Started crashpad handler"; - return client; -} - -} // namespace chatterino - -#endif diff --git a/src/providers/Crashpad.hpp b/src/providers/Crashpad.hpp deleted file mode 100644 index d15f3fcb705..00000000000 --- a/src/providers/Crashpad.hpp +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#ifdef CHATTERINO_WITH_CRASHPAD -# include - -# include - -namespace chatterino { - -std::unique_ptr installCrashHandler(); - -} // namespace chatterino - -#endif diff --git a/src/singletons/Crashpad.cpp b/src/singletons/Crashpad.cpp new file mode 100644 index 00000000000..c0a98297b99 --- /dev/null +++ b/src/singletons/Crashpad.cpp @@ -0,0 +1,218 @@ +#include "singletons/Crashpad.hpp" + +#include "common/Args.hpp" +#include "common/Literals.hpp" +#include "common/QLogging.hpp" +#include "singletons/Paths.hpp" + +#include +#include +#include + +#ifdef CHATTERINO_WITH_CRASHPAD +# include + +# include +# include +#endif + +namespace { + +using namespace chatterino; +using namespace literals; + +/// The name of the crashpad handler executable. +/// This varies across platforms +#if defined(Q_OS_UNIX) +const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad_handler"); +#elif defined(Q_OS_WINDOWS) +const QString CRASHPAD_EXECUTABLE_NAME = QStringLiteral("crashpad_handler.exe"); +#else +# error Unsupported platform +#endif + +/// Converts a QString into the platform string representation. +#if defined(Q_OS_UNIX) +std::string nativeString(const QString &s) +{ + return s.toStdString(); +} +#elif defined(Q_OS_WINDOWS) +std::wstring nativeString(const QString &s) +{ + return s.toStdWString(); +} +#else +# error Unsupported platform +#endif + +const QString RECOVERY_FILE = u"chatterino-recovery.bin"_s; + +/// The recovery options are saved outside the settings +/// to be able to read them without loading the settings. +/// +/// The flags are saved in the `RECOVERY_FILE` in their binary representation. +std::optional readFlags(const Paths &paths) +{ + using Flag = CrashRecovery::Flag; + + QFile file(QDir(paths.crashdumpDirectory).filePath(RECOVERY_FILE)); + if (!file.open(QFile::ReadOnly)) + { + return std::nullopt; + } + + auto line = file.readLine(64); + + static_assert(std::is_same_v, uint64_t>); + // Qt doesn't expose methods parsing uint64_t directly + static_assert(sizeof(uint64_t) == sizeof(unsigned long long)); + + bool ok = false; + auto value = static_cast(line.toULongLong(&ok)); + if (!ok) + { + return std::nullopt; + } + + return value; +} + +bool canRestart(const Paths &paths) +{ +#ifdef NDEBUG + const auto &args = chatterino::getArgs(); + bool noBadArgs = + !args.isFramelessEmbed && !args.shouldRunBrowserExtensionHost; + + return noBadArgs && readFlags(paths) + .value_or(CrashRecovery::Flag::None) + .has(CrashRecovery::Flag::DoCrashRecovery); +#else + (void)paths; + return false; +#endif +} + +std::string encodeArguments() +{ + std::string args; + for (auto arg : getArgs().currentArguments()) + { + if (!args.empty()) + { + args.push_back('+'); + } + args += arg.replace(u'+', u"++"_s).toStdString(); + } + return args; +} + +} // namespace + +namespace chatterino { + +using namespace std::string_literals; + +void CrashRecovery::initialize(Settings & /*settings*/, Paths &paths) +{ + auto flags = readFlags(paths); + if (flags) + { + this->currentFlags_ = *flags; + } + else + { + // By default, we don't restart after a crash. + this->updateFlags(Flag::None); + } +} + +CrashRecovery::Flags CrashRecovery::recoveryFlags() const +{ + return this->currentFlags_; +} + +void CrashRecovery::updateFlags(Flags flags) +{ + this->currentFlags_ = flags; + + QFile file(QDir(getPaths()->crashdumpDirectory).filePath(RECOVERY_FILE)); + if (!file.open(QFile::WriteOnly | QFile::Truncate)) + { + qCWarning(chatterinoApp) << "Failed to open" << file.fileName(); + return; + } + + static_assert(std::is_same_v, uint64_t>); + file.write( + QByteArray::number(static_cast(this->currentFlags_.value()))); +} + +#ifdef CHATTERINO_WITH_CRASHPAD +std::unique_ptr installCrashHandler() +{ + // Currently, the following directory layout is assumed: + // [applicationDirPath] + // ├─chatterino(.exe) + // ╰─[crashpad] + // ╰─crashpad_handler(.exe) + // TODO: The location of the binary might vary across platforms + auto crashpadBinDir = QDir(QApplication::applicationDirPath()); + + if (!crashpadBinDir.cd("crashpad")) + { + qCDebug(chatterinoApp) << "Cannot find crashpad directory"; + return nullptr; + } + if (!crashpadBinDir.exists(CRASHPAD_EXECUTABLE_NAME)) + { + qCDebug(chatterinoApp) << "Cannot find crashpad handler executable"; + return nullptr; + } + + auto handlerPath = base::FilePath(nativeString( + crashpadBinDir.absoluteFilePath(CRASHPAD_EXECUTABLE_NAME))); + + // Argument passed in --database + // > Crash reports are written to this database, and if uploads are enabled, + // uploaded from this database to a crash report collection server. + auto databaseDir = + base::FilePath(nativeString(getPaths()->crashdumpDirectory)); + + auto client = std::make_unique(); + + std::map annotations{ + { + "canRestart"s, + canRestart(*getPaths()) ? "true"s : "false"s, + }, + { + "exePath"s, + QApplication::applicationFilePath().toStdString(), + }, + { + "startedAt"s, + QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toStdString(), + }, + { + "exeArguments"s, + encodeArguments(), + }, + }; + + // See https://chromium.googlesource.com/crashpad/crashpad/+/HEAD/handler/crashpad_handler.md + // for documentation on available options. + if (!client->StartHandler(handlerPath, databaseDir, {}, {}, {}, annotations, + {}, true, false)) + { + qCDebug(chatterinoApp) << "Failed to start crashpad handler"; + return nullptr; + } + + qCDebug(chatterinoApp) << "Started crashpad handler"; + return client; +} +#endif + +} // namespace chatterino diff --git a/src/singletons/Crashpad.hpp b/src/singletons/Crashpad.hpp new file mode 100644 index 00000000000..0426d76b4d8 --- /dev/null +++ b/src/singletons/Crashpad.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "common/FlagsEnum.hpp" +#include "common/Singleton.hpp" + +#ifdef CHATTERINO_WITH_CRASHPAD +# include + +# include +#endif + +namespace chatterino { + +class CrashRecovery : public Singleton +{ +public: + enum class Flag : uint64_t { + None, + DoCrashRecovery = 1 << 0, + }; + + using Flags = FlagsEnum; + + Flags recoveryFlags() const; + void updateFlags(Flags flags); + + void initialize(Settings &settings, Paths &paths) override; + +private: + Flags currentFlags_ = Flag::None; +}; + +#ifdef CHATTERINO_WITH_CRASHPAD +std::unique_ptr installCrashHandler(); +#endif + +} // namespace chatterino diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 8e650de873d..612f8ebefde 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -510,7 +510,6 @@ class Settings ThumbnailPreviewMode::AlwaysShow, }; QStringSetting cachePath = {"/cache/path", ""}; - BoolSetting restartOnCrash = {"/misc/restartOnCrash", false}; BoolSetting attachExtensionToAnyProcess = { "/misc/attachExtensionToAnyProcess", false}; BoolSetting askOnImageUpload = {"/misc/askOnImageUpload", true}; diff --git a/src/widgets/dialogs/LastRunCrashDialog.cpp b/src/widgets/dialogs/LastRunCrashDialog.cpp index f3dc7792186..e6136885b2e 100644 --- a/src/widgets/dialogs/LastRunCrashDialog.cpp +++ b/src/widgets/dialogs/LastRunCrashDialog.cpp @@ -1,93 +1,158 @@ -#include "LastRunCrashDialog.hpp" +#include "widgets/dialogs/LastRunCrashDialog.hpp" -#include "singletons/Updates.hpp" +#include "common/Args.hpp" +#include "common/Literals.hpp" +#include "common/Modes.hpp" +#include "singletons/Paths.hpp" #include "util/LayoutCreator.hpp" -#include "util/PostToThread.hpp" +#include #include +#include #include #include +#include +#include #include +namespace { + +using namespace chatterino::literals; +const std::vector MESSAGES = { + u"Oops..."_s, + u"NotLikeThis"_s, + u"NOOOOOO"_s, + u"I'm sorry"_s, + u"We're sorry"_s, + u"My bad"_s, + u"FailFish"_s, + u"O_o"_s, + uR"("%/§*'"$)%=})"_s, + u"Sorry :("_s, + u"I blame cosmic rays"_s, + u"I blame TMI"_s, + u"I blame Helix"_s, + // "Wtf is Utf16?" (but with swapped endian) + u"圀琀昀\u2000椀猀\u2000唀琀昀㄀㘀㼀"_s, + u"Oopsie woopsie"_s, +}; + +QString randomMessage() +{ + return MESSAGES[QRandomGenerator::global()->bounded(MESSAGES.size())]; +} + +} // namespace + namespace chatterino { +using namespace literals; + LastRunCrashDialog::LastRunCrashDialog() { this->setWindowFlag(Qt::WindowContextHelpButtonHint, false); - this->setWindowTitle("Chatterino"); + this->setWindowTitle(u"Chatterino" % randomMessage()); auto layout = LayoutCreator(this).setLayoutType(); - layout.emplace("The application wasn't terminated properly the " - "last time it was executed."); + QString text = + u"Chatterino unexpectedly crashed and restarted. "_s + "You can disable automatic restarts in the settings.

"; - layout->addSpacing(16); +#ifdef CHATTERINO_WITH_CRASHPAD + auto reportsDir = + QDir(getPaths()->crashdumpDirectory).filePath(u"reports"_s); + text += u"A crash report has been saved to " + "" % reportsDir % u".
"; - // auto update = layout.emplace(); - auto buttons = layout.emplace(); + if (getArgs().exceptionCode) + { + text += u"The last run crashed with code 0x" % + QString::number(*getArgs().exceptionCode, 16) % u""; + + if (getArgs().exceptionMessage) + { + text += u" (" % *getArgs().exceptionMessage % u")"; + } + + text += u".
"_s; + } + + if (getArgs().extraMemory && *getArgs().extraMemory > 0) + { + text += QLocale::system().formattedDataSize( + static_cast(*getArgs().extraMemory)) % + " have been included in the report.
"; + } + else + { + text += + u"No extra memory snapshot was saved with the crash report.
"_s; + } - // auto *installUpdateButton = buttons->addButton("Install Update", - // QDialogButtonBox::NoRole); installUpdateButton->setEnabled(false); - // QObject::connect(installUpdateButton, &QPushButton::clicked, [this, - // update]() mutable { - // auto &updateManager = UpdateManager::instance(); + text += + "Crash reports are only stored locally and never uploaded.
" + u"
Please report the crash so it can be prevented in the future."_s; - // updateManager.installUpdates(); - // this->setEnabled(false); - // update->setText("Downloading updates..."); - // }); + if (Modes::instance().isNightly) + { + text += u" Make sure you're using the latest nightly version!"_s; + } - auto *okButton = - buttons->addButton("Ignore", QDialogButtonBox::ButtonRole::NoRole); + text += + u"
For more information, consult the wiki."_s; +#endif + + auto label = layout.emplace(text); + label->setTextInteractionFlags(Qt::TextBrowserInteraction); + label->setOpenExternalLinks(true); + label->setWordWrap(true); + + layout->addSpacing(16); + + auto buttons = layout.emplace(); + + auto *okButton = buttons->addButton(u"Ok"_s, QDialogButtonBox::AcceptRole); QObject::connect(okButton, &QPushButton::clicked, [this] { this->accept(); }); - // Updates - // auto updateUpdateLabel = [update]() mutable { - // auto &updateManager = UpdateManager::instance(); - - // switch (updateManager.getStatus()) { - // case UpdateManager::None: { - // update->setText("Not checking for updates."); - // } break; - // case UpdateManager::Searching: { - // update->setText("Checking for updates..."); - // } break; - // case UpdateManager::UpdateAvailable: { - // update->setText("Update available."); - // } break; - // case UpdateManager::NoUpdateAvailable: { - // update->setText("No update abailable."); - // } break; - // case UpdateManager::SearchFailed: { - // update->setText("Error while searching for update.\nEither - // the update service is " - // "temporarily down or there is an issue - // with your installation."); - // } break; - // case UpdateManager::Downloading: { - // update->setText( - // "Downloading the update. Chatterino will close once - // the download is done."); - // } break; - // case UpdateManager::DownloadFailed: { - // update->setText("Download failed."); - // } break; - // case UpdateManager::WriteFileFailed: { - // update->setText("Writing the update file to the hard drive - // failed."); - // } break; - // } - // }; - - // updateUpdateLabel(); - // this->signalHolder_.managedConnect(updateManager.statusUpdated, - // [updateUpdateLabel](auto) mutable { - // postToThread([updateUpdateLabel]() mutable { updateUpdateLabel(); - // }); - // }); + if (!getArgs().safeMode) + { + auto *safeModeButton = buttons->addButton(u"Restart in safe mode"_s, + QDialogButtonBox::NoRole); + safeModeButton->setToolTipDuration(0); + safeModeButton->setToolTip( + u"In safe mode, the settings button is always shown"_s +#ifdef CHATTERINO_HAVE_PLUGINS + " and plugins are disabled" +#endif + ".\nSame as starting with --safe-mode."); + + QObject::connect(safeModeButton, &QPushButton::clicked, [this] { + auto args = getArgs().currentArguments(); + args += u"--safe-mode"_s; + if (QProcess::startDetached(qApp->applicationFilePath(), args)) + { + // We have to do this as we show this dialog with exec() + _exit(0); + return; + } + + QMessageBox::critical( + this, u"Error"_s, + u"Failed to start the app with --safe-mode. Please restart the app manually."_s, + QMessageBox::Close); + QApplication::exit(0); + }); + } +} + +LastRunCrashDialog::~LastRunCrashDialog() +{ + _CrtDbgBreak(); } } // namespace chatterino diff --git a/src/widgets/dialogs/LastRunCrashDialog.hpp b/src/widgets/dialogs/LastRunCrashDialog.hpp index ebd1e3a4cb7..e96db91193b 100644 --- a/src/widgets/dialogs/LastRunCrashDialog.hpp +++ b/src/widgets/dialogs/LastRunCrashDialog.hpp @@ -9,9 +9,7 @@ class LastRunCrashDialog : public QDialog { public: LastRunCrashDialog(); - -private: - pajlada::Signals::SignalHolder signalHolder_; + ~LastRunCrashDialog(); }; } // namespace chatterino diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 3382199763b..0a4e857864a 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -8,6 +8,7 @@ #include "controllers/sound/ISoundController.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Crashpad.hpp" #include "singletons/Fonts.hpp" #include "singletons/NativeMessaging.hpp" #include "singletons/Paths.hpp" @@ -875,8 +876,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) s.openLinksIncognito); } - layout.addCheckbox( - "Restart on crash", s.restartOnCrash, false, + layout.addCustomCheckbox( + "Restart on crash (requires restart)", + [] { + return getApp()->crashRecovery->recoveryFlags().has( + CrashRecovery::Flag::DoCrashRecovery); + }, + [](bool on) { + return getApp()->crashRecovery->updateFlags( + on ? CrashRecovery::Flag::DoCrashRecovery + : CrashRecovery::Flag::None); + }, "When possible, restart Chatterino if the program crashes"); #if defined(Q_OS_LINUX) && !defined(NO_QTKEYCHAIN) diff --git a/src/widgets/settingspages/GeneralPageView.cpp b/src/widgets/settingspages/GeneralPageView.cpp index b063c617796..95a0d42fc49 100644 --- a/src/widgets/settingspages/GeneralPageView.cpp +++ b/src/widgets/settingspages/GeneralPageView.cpp @@ -125,6 +125,28 @@ QCheckBox *GeneralPageView::addCheckbox(const QString &text, return check; } +QCheckBox *GeneralPageView::addCustomCheckbox(const QString &text, + const std::function &load, + std::function save, + const QString &toolTipText) +{ + auto *check = new QCheckBox(text); + this->addToolTip(*check, toolTipText); + + check->setChecked(load()); + + QObject::connect(check, &QCheckBox::toggled, this, + [save = std::move(save)](bool state) { + save(state); + }); + + this->addWidget(check); + + this->groups_.back().widgets.push_back({check, {text}}); + + return check; +} + ComboBox *GeneralPageView::addDropdown(const QString &text, const QStringList &list, QString toolTipText) diff --git a/src/widgets/settingspages/GeneralPageView.hpp b/src/widgets/settingspages/GeneralPageView.hpp index 33785549521..289d99367ff 100644 --- a/src/widgets/settingspages/GeneralPageView.hpp +++ b/src/widgets/settingspages/GeneralPageView.hpp @@ -103,6 +103,11 @@ class GeneralPageView : public QWidget /// @param inverse Inverses true to false and vice versa QCheckBox *addCheckbox(const QString &text, BoolSetting &setting, bool inverse = false, QString toolTipText = {}); + QCheckBox *addCustomCheckbox(const QString &text, + const std::function &load, + std::function save, + const QString &toolTipText = {}); + ComboBox *addDropdown(const QString &text, const QStringList &items, QString toolTipText = {}); ComboBox *addDropdown(const QString &text, const QStringList &items,