diff --git a/code/framework/src/external/sentry/wrapper.cpp b/code/framework/src/external/sentry/wrapper.cpp index 742ee27b4..e8e32b967 100644 --- a/code/framework/src/external/sentry/wrapper.cpp +++ b/code/framework/src/external/sentry/wrapper.cpp @@ -19,6 +19,7 @@ #include #include +#include namespace Framework::External::Sentry { namespace { @@ -51,9 +52,17 @@ namespace Framework::External::Sentry { default: return "info"; } } + + void AddOptionsAttachment(sentry_options_t *options, const std::filesystem::path &path) { +#ifdef _WIN32 + sentry_options_add_attachmentw(options, path.c_str()); +#else + sentry_options_add_attachment(options, path.c_str()); +#endif + } } // namespace - Utils::Result Wrapper::Init(const InitOptions &options) { + Utils::Result Wrapper::Init(const InitOptions &options) try { // sentry_init takes ownership below; every path that bails out before that // has to free the payload itself, so let a guard own it until then. std::unique_ptr opts(sentry_options_new(), sentry_options_free); @@ -89,17 +98,25 @@ namespace Framework::External::Sentry { } } - sentry_options_set_handler_path(opts.get(), breakpadFile.path().c_str()); - sentry_options_set_database_path(opts.get(), cacheDirectory.path().c_str()); + // The framework accepts native filesystem strings; Sentry's narrow Windows + // API requires UTF-8. Preserve the native path through its wide API instead. + const std::filesystem::path handlerPath(breakpadFile.path()); + const std::filesystem::path databasePath(cacheDirectory.path()); +#ifdef _WIN32 + sentry_options_set_handler_pathw(opts.get(), handlerPath.c_str()); + sentry_options_set_database_pathw(opts.get(), databasePath.c_str()); +#else + sentry_options_set_handler_path(opts.get(), handlerPath.c_str()); + sentry_options_set_database_path(opts.get(), databasePath.c_str()); +#endif // Crashpad reads attachments lazily at crash time, so cef.log carries the CHECK/FATAL // line CEF writes before it fast-fails. Registered before init so the handler knows it. - const std::string cefLog = std::filesystem::absolute(std::filesystem::path(options.handlerPath) / "logs" / "cef.log").string(); - sentry_options_add_attachment(opts.get(), cefLog.c_str()); + const auto cefLog = std::filesystem::absolute(std::filesystem::path(options.handlerPath) / "logs" / "cef.log"); + AddOptionsAttachment(opts.get(), cefLog); for (const auto &attachment : options.attachments) { - const std::string absolute = std::filesystem::absolute(std::filesystem::path(attachment)).string(); - sentry_options_add_attachment(opts.get(), absolute.c_str()); + AddOptionsAttachment(opts.get(), std::filesystem::absolute(std::filesystem::path(attachment))); } if (sentry_init(opts.release()) != 0) { @@ -112,6 +129,9 @@ namespace Framework::External::Sentry { CrashTrail::Install(); return {}; } + catch (const std::system_error &error) { + return Framework::Error("Failed to configure Sentry paths: " + std::string(error.what()), error.code().value()); + } bool Wrapper::Flush(uint32_t timeoutMs) const { if (!_initialized) { @@ -129,14 +149,21 @@ namespace Framework::External::Sentry { Lifecycle::Shutdown(); } - Utils::Result Wrapper::AddAttachment(const std::string &path) const { + Utils::Result Wrapper::AddAttachment(const std::string &path) const try { if (!_initialized) { return Framework::Error {"Sentry is not initialized"}; } - const std::string absolute = std::filesystem::absolute(std::filesystem::path(path)).string(); + const auto absolute = std::filesystem::absolute(std::filesystem::path(path)); +#ifdef _WIN32 + sentry_attach_filew(absolute.c_str()); +#else sentry_attach_file(absolute.c_str()); +#endif return {}; } + catch (const std::system_error &error) { + return Framework::Error("Failed to configure Sentry attachment path: " + std::string(error.what()), error.code().value()); + } void Wrapper::SetTag(const std::string &key, const std::string &value) const { if (!_initialized) { diff --git a/code/framework/src/external/sentry/wrapper.h b/code/framework/src/external/sentry/wrapper.h index e87b10d1c..b7871f139 100644 --- a/code/framework/src/external/sentry/wrapper.h +++ b/code/framework/src/external/sentry/wrapper.h @@ -49,6 +49,7 @@ namespace Framework::External::Sentry { struct InitOptions { std::string dsn; + // Filesystem paths use the native narrow encoding, including the Windows code page. std::string handlerPath; std::string release; std::string environment; diff --git a/code/tests/CMakeLists.txt b/code/tests/CMakeLists.txt index 7efd0ee7e..adf3e50f7 100644 --- a/code/tests/CMakeLists.txt +++ b/code/tests/CMakeLists.txt @@ -26,6 +26,14 @@ if(WIN32) ../framework/src/utils/hooking/hooking.cpp ../framework/src/graphics/backend/d3d12_ui_compositor.cpp) target_link_libraries(FrameworkTests d3d12 dxgi d3dcompiler) + + add_executable(FrameworkSentryTests sentry_ut.cpp) + target_include_directories(FrameworkSentryTests PRIVATE ../framework/src) + target_link_libraries(FrameworkSentryTests PRIVATE Framework) + add_custom_target(RunFrameworkSentryTests + COMMAND $ + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + DEPENDS FrameworkSentryTests) endif() target_include_directories(FrameworkTests PRIVATE . ../framework/src) @@ -39,6 +47,9 @@ add_custom_target(RunFrameworkTests COMMAND $ COMMAND $ DEPENDS FrameworkTests FrameworkClientScriptingTests) +if(WIN32) + add_dependencies(RunFrameworkTests RunFrameworkSentryTests) +endif() # NodeEngine and V8Engine each initialize V8's process-wide platform, so exercise # the client runtime in its own process. diff --git a/code/tests/sentry_ut.cpp b/code/tests/sentry_ut.cpp new file mode 100644 index 000000000..d90def616 --- /dev/null +++ b/code/tests/sentry_ut.cpp @@ -0,0 +1,148 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + namespace fs = std::filesystem; + + fs::path NativeUnicodeDirectory() { + // Choose a lossless fixture for the active Windows code page; East Asian + // code pages cannot necessarily represent the Turkish desktop name. + for (const auto name : {L"Masa\u00FCst\u00FC", L"\u041F\u0440\u0438\u0432\u0435\u0442", L"\u65E5\u672C", L"\u4E2D\u6587", L"\uD55C\uAE00", L"\u03A9", L"\u05D0", L"\u0627", L"\u0E01"}) { + try { + const fs::path candidate(name); + if (fs::path(candidate.string()) == candidate) { + return candidate; + } + } + catch (const std::system_error &) { + } + } + throw std::runtime_error("No non-ASCII fixture is representable in the native code page."); + } + + int RunCase(std::wstring_view scenario, const fs::path &sandbox, const fs::path &handler) { + Framework::Logging::GetInstance()->PauseLogging(true); + const fs::path root = sandbox / (scenario == L"native-unicode" ? NativeUnicodeDirectory() : fs::path(scenario)); + fs::create_directories(root); + if (scenario != L"missing-handler") { + fs::copy_file(handler, root / "crashpad_handler.exe"); + } + if (scenario == L"blocked-cache") { + std::ofstream(root / "cache") << "A file blocks the cache directory."; + } + fs::create_directories(root / "logs"); + const fs::path attachment = root / "logs" / NativeUnicodeDirectory(); + std::ofstream(attachment) << "attachment regression"; + + Framework::External::Sentry::InitOptions options; + // An empty DSN disables uploads while exercising the real Crashpad backend. + options.dsn = ""; + options.handlerPath = root.string(); + options.attachments.push_back(attachment.string()); + if (scenario == L"relative") { + fs::current_path(root); + options.handlerPath = "."; + options.attachments = {"logs/log.txt"}; + } + + Framework::External::Sentry::Wrapper reporter; + const auto result = reporter.Init(options); + const bool expectFailure = scenario == L"missing-handler" || scenario == L"blocked-cache"; + if (expectFailure) { + if (result || reporter.IsInitialized()) { + std::fprintf(stderr, "Expected initialization to return an error.\n"); + return 1; + } + std::printf("Expected error: %s\n", result.GetError().message.c_str()); + return 0; + } + if (!result) { + std::fprintf(stderr, "Initialization failed: %s\n", result.GetError().message.c_str()); + return 1; + } + const bool initialized = reporter.IsInitialized() && fs::is_directory(root / "cache" / "sentry"); + const auto attached = reporter.AddAttachment(attachment.string()); + reporter.Shutdown(); + return initialized && attached && !reporter.IsInitialized() ? 0 : 1; + } + + bool RunChild(const fs::path &executable, std::wstring_view scenario, const fs::path &sandbox) { + std::wstring command = L"\"" + executable.wstring() + L"\" --case " + std::wstring(scenario) + L" \"" + sandbox.wstring() + L"\""; + STARTUPINFOW startup {sizeof(startup)}; + PROCESS_INFORMATION process {}; + if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &startup, &process)) { + std::fprintf(stderr, "CreateProcessW failed: %lu\n", GetLastError()); + return false; + } + const DWORD wait = WaitForSingleObject(process.hProcess, 30000); + DWORD exitCode = 1; + if (wait == WAIT_OBJECT_0) { + GetExitCodeProcess(process.hProcess, &exitCode); + } + else { + TerminateProcess(process.hProcess, 1); + WaitForSingleObject(process.hProcess, 5000); + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + std::printf("%ls: %s (exit 0x%08lX)\n", std::wstring(scenario).c_str(), exitCode == 0 ? "PASS" : "FAIL", exitCode); + return exitCode == 0; + } +} // namespace + +int wmain(int argc, wchar_t **argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + wchar_t executableName[32768] {}; + if (!GetModuleFileNameW(nullptr, executableName, 32768)) { + return 1; + } + const fs::path executable(executableName); + const fs::path handler = executable.parent_path() / "crashpad_handler.exe"; + try { + if (argc == 4 && std::wstring_view(argv[1]) == L"--case") { + return RunCase(argv[2], fs::path(argv[3]), handler); + } + + // Each SDK instance gets its own process, so an access violation fails a case + // without preventing the remaining cases from running. + const fs::path sandbox = fs::current_path() / ("framework_ut_sentry_" + std::to_string(GetCurrentProcessId())); + if (!fs::create_directory(sandbox)) { + std::fprintf(stderr, "Test sandbox already exists.\n"); + return 1; + } + std::printf("Windows native code page: %u\n", GetACP()); + bool passed = true; + for (const auto scenario : {L"ascii", L"native-unicode", L"relative", L"missing-handler", L"blocked-cache"}) { + passed = RunChild(executable, scenario, sandbox) && passed; + } + std::error_code cleanupError; + fs::remove_all(sandbox, cleanupError); + if (cleanupError) { + std::fprintf(stderr, "Could not remove test sandbox: %s\n", cleanupError.message().c_str()); + passed = false; + } + return passed ? 0 : 1; + } + catch (const std::exception &error) { + std::fprintf(stderr, "Sentry test failed: %s\n", error.what()); + return 1; + } +}